diff --git a/.gitattributes b/.gitattributes index 2bce16d..1743d8c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Mark pdfjs as vendored code -frontend/static/pdfjs/** linguist-vendored \ No newline at end of file +frontend/static/pdfjs/** linguist-vendored + +# Mark foliate-js as vendored code +frontend/src/lib/vendor/** linguist-vendored \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore index be19ecc..27b587b 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -11,3 +11,6 @@ coverage # Miscellaneous /static/ + +# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh +/src/lib/vendor/ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 2c49fa6..da76ca7 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -12,6 +12,8 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), + // Vendored third-party source. Tracked, so .gitignore does not cover it. + { ignores: ['src/lib/vendor/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/package.json b/frontend/package.json index d39dad1..1e89371 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "vite": "^7.3.1" }, "dependencies": { + "construct-style-sheets-polyfill": "^3.1.0", "epubjs": "^0.3.93", "mode-watcher": "^1.1.0", "svelte-sonner": "^1.0.8", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 7281fd9..a941bf8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + construct-style-sheets-polyfill: + specifier: ^3.1.0 + version: 3.1.0 epubjs: specifier: ^0.3.93 version: 0.3.93 @@ -1166,6 +1169,9 @@ packages: resolution: {integrity: sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==} engines: {node: '>=20'} + construct-style-sheets-polyfill@3.1.0: + resolution: {integrity: sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -3346,6 +3352,8 @@ snapshots: semver: 7.7.4 uint8array-extras: 1.5.0 + construct-style-sheets-polyfill@3.1.0: {} + cookie@0.6.0: {} core-js@3.48.0: {} diff --git a/frontend/scripts/vendor-foliate.sh b/frontend/scripts/vendor-foliate.sh new file mode 100755 index 0000000..9ca3281 --- /dev/null +++ b/frontend/scripts/vendor-foliate.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# Vendors foliate-js into src/lib/vendor/foliate-js/. +# +# foliate-js has no npm release and no build step; upstream recommends a git +# submodule. We copy instead, because this repo has no submodules (pdf.js is +# vendored the same way under static/pdfjs) and because a submodule would drag +# in 231 files / 13 MB, of which 191 files / 12 MB is a bundled pdf.js build we +# deliberately do not use — Chitai serves PDFs through static/pdfjs/web/viewer.html. +# +# Only the files reachable from view.js are copied: 15 upstream files, ~656 KB. +# pdf.js is NOT copied; a stub is written in its place (see below). +# +# To update: bump FOLIATE_SHA, re-run, review the diff, then smoke-test the +# reader — paginator.js is ~3800 lines of gesture and animation code and this +# fork is pushed to frequently. +# +# Usage: ./scripts/vendor-foliate.sh +set -euo pipefail + +FOLIATE_REPO="https://github.com/readest/foliate-js.git" +FOLIATE_SHA="63a2eb1fc1e4813c4e849ccdb3d4be2c54a35869" + +DEST="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/src/lib/vendor/foliate-js" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# The reachable closure from view.js. Everything else upstream ships is either +# unreachable (dict.js, opds.js, footnotes.js, quote-image.js, uri-template.js), +# a demo (reader.js), or build tooling (rollup.config.js, eslint.config.js). +FILES=( + view.js + epub.js + epubcfi.js + paginator.js + fixed-layout.js + overlayer.js + progress.js + search.js + text-walker.js + tts.js + mobi.js + comic-book.js + fb2.js + vendor/zip.js + vendor/fflate.js +) + +echo "Cloning $FOLIATE_REPO @ ${FOLIATE_SHA:0:7} ..." +git clone --quiet --filter=blob:none --no-checkout "$FOLIATE_REPO" "$TMP/foliate" +git -C "$TMP/foliate" checkout --quiet "$FOLIATE_SHA" + +rm -rf "$DEST" +mkdir -p "$DEST/vendor" + +for f in "${FILES[@]}"; do + if [ ! -f "$TMP/foliate/$f" ]; then + echo "ERROR: $f is missing upstream at ${FOLIATE_SHA:0:7}." >&2 + echo "The file list in this script is stale; re-check the import graph." >&2 + exit 1 + fi + cp "$TMP/foliate/$f" "$DEST/$f" +done + +cp "$TMP/foliate/LICENSE" "$DEST/LICENSE" + +# view.js does `await import('./pdf.js')` inside makeBook. That is a static-string +# dynamic import, so Rollup resolves it at build time whether or not the code path +# ever runs — and upstream's pdf.js opens with `import '@pdfjs/pdf.min.mjs'`, a bare +# specifier that does not resolve here. Shipping this stub at that path keeps the +# build working without a Vite alias, and without vendoring 12 MB of pdf.js. +cat > "$DEST/pdf.js" <<'STUB' +// NOT upstream foliate-js. See README.chitai.md. +// +// Chitai renders PDFs with the pdf.js viewer vendored at static/pdfjs/, so +// foliate's PDF backend is not vendored. view.js still references this module +// from makeBook via a static-string dynamic import, which Rollup resolves at +// build time regardless of whether it executes — so the file has to exist. +// +// Throwing at module scope surfaces a legible message in the reader's error +// card if a PDF is ever routed to the EPUB reader by mistake, rather than a +// TypeError from `globalThis.pdfjsLib` being undefined. +throw new Error('foliate-js PDF rendering is not enabled in Chitai'); +STUB + +cat > "$DEST/README.chitai.md" < (Readest's fork of johnfactotum/foliate-js) | +| Pinned commit | \`$FOLIATE_SHA\` | +| Licence | MIT — see \`LICENSE\` | + +Readest's fork is used rather than upstream for its paginator work: touch/swipe +turn handling, fixed-layout spread centring, and a malformed-XHTML fallback in +\`loadDocument\`. + +## What is here + +Only the import closure reachable from \`view.js\`. Not vendored, because nothing +reaches them: \`dict.js\`, \`opds.js\`, \`footnotes.js\`, \`quote-image.js\`, +\`uri-template.js\`, \`reader.js\` (upstream's demo), and the build configs. + +## pdf.js is ours, not upstream's + +\`pdf.js\` in this directory is a **stub that throws**. Upstream's version imports +\`@pdfjs/pdf.min.mjs\` — a bare specifier backed by a 12 MB vendored pdf.js build — +and \`view.js\` reaches it through \`await import('./pdf.js')\`, which Rollup resolves +at build time even though Chitai never takes that path. Chitai serves PDFs from +\`static/pdfjs/web/viewer.html\` instead. + +To enable foliate's PDF backend, add \`pdf.js\` and \`vendor/pdfjs/\` to the file list +in the vendor script and drop the stub. + +## Updating + +Bump \`FOLIATE_SHA\` in \`frontend/scripts/vendor-foliate.sh\`, re-run it, review the +diff, and smoke-test the reader — \`paginator.js\` is ~3800 lines of gesture and +animation code and this fork is pushed to frequently. +EOF + +echo +echo "Vendored ${#FILES[@]} files + LICENSE + pdf.js stub + README.chitai.md to:" +echo " $DEST" +du -sh "$DEST" | sed 's/^/ /' diff --git a/frontend/src/lib/vendor/foliate-js/LICENSE b/frontend/src/lib/vendor/foliate-js/LICENSE new file mode 100644 index 0000000..5930571 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 John Factotum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/src/lib/vendor/foliate-js/README.chitai.md b/frontend/src/lib/vendor/foliate-js/README.chitai.md new file mode 100644 index 0000000..6fdf4ca --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/README.chitai.md @@ -0,0 +1,37 @@ +# Vendored foliate-js + +Do not edit these files. They are copied verbatim from upstream by +`frontend/scripts/vendor-foliate.sh`; local changes are lost on the next run. + +| | | +| --- | --- | +| Upstream | (Readest's fork of johnfactotum/foliate-js) | +| Pinned commit | `63a2eb1fc1e4813c4e849ccdb3d4be2c54a35869` | +| Licence | MIT — see `LICENSE` | + +Readest's fork is used rather than upstream for its paginator work: touch/swipe +turn handling, fixed-layout spread centring, and a malformed-XHTML fallback in +`loadDocument`. + +## What is here + +Only the import closure reachable from `view.js`. Not vendored, because nothing +reaches them: `dict.js`, `opds.js`, `footnotes.js`, `quote-image.js`, +`uri-template.js`, `reader.js` (upstream's demo), and the build configs. + +## pdf.js is ours, not upstream's + +`pdf.js` in this directory is a **stub that throws**. Upstream's version imports +`@pdfjs/pdf.min.mjs` — a bare specifier backed by a 12 MB vendored pdf.js build — +and `view.js` reaches it through `await import('./pdf.js')`, which Rollup resolves +at build time even though Chitai never takes that path. Chitai serves PDFs from +`static/pdfjs/web/viewer.html` instead. + +To enable foliate's PDF backend, add `pdf.js` and `vendor/pdfjs/` to the file list +in the vendor script and drop the stub. + +## Updating + +Bump `FOLIATE_SHA` in `frontend/scripts/vendor-foliate.sh`, re-run it, review the +diff, and smoke-test the reader — `paginator.js` is ~3800 lines of gesture and +animation code and this fork is pushed to frequently. diff --git a/frontend/src/lib/vendor/foliate-js/comic-book.js b/frontend/src/lib/vendor/foliate-js/comic-book.js new file mode 100644 index 0000000..30850eb --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/comic-book.js @@ -0,0 +1,140 @@ +// Read series metadata from a ComicInfo.xml entry, if present. +// Spec: https://anansi-project.github.io/docs/comicinfo/intro +const readComicInfoXML = async ({ entries, loadBlob }) => { + const entry = entries.find(e => e.filename.toLowerCase() === 'comicinfo.xml') + ?? entries.find(e => e.filename.split('/').pop()?.toLowerCase() === 'comicinfo.xml') + if (!entry) return null + let text + try { + text = await (await loadBlob(entry.filename)).text() + } catch { + return null + } + let doc + try { + doc = new DOMParser().parseFromString(text, 'application/xml') + } catch { + return null + } + if (!doc || doc.getElementsByTagName('parsererror').length) return null + const get = name => doc.getElementsByTagName(name).item(0)?.textContent?.trim() || undefined + const getPositiveInteger = name => { + const value = Number.parseInt(get(name), 10) + return Number.isFinite(value) && value > 0 ? value : undefined + } + const getSubjects = () => [...new Set([get('Genre'), get('Tags')] + .flatMap(value => value?.split(/[,;|]/).map(x => x.trim()).filter(Boolean) ?? []))] + const getPublished = () => { + const year = getPositiveInteger('Year') + if (!year) return undefined + const month = getPositiveInteger('Month') + const day = getPositiveInteger('Day') + const yyyy = String(year).padStart(4, '0') + if (!month || month > 12) return yyyy + const yyyyMm = `${yyyy}-${String(month).padStart(2, '0')}` + if (!day || day > 31) return yyyyMm + return `${yyyyMm}-${String(day).padStart(2, '0')}` + } + const subjects = getSubjects() + return { + title: get('Title'), + publisher: get('Publisher'), + language: get('LanguageISO'), + author: get('Writer'), + published: getPublished(), + description: get('Summary'), + subject: subjects.length ? subjects : undefined, + identifier: get('Web'), + series: get('Series'), + seriesPosition: get('Number'), + seriesTotal: get('Count'), + } +} + +const readComicBookInfo = async ({ getComment }) => { + let info + try { + info = JSON.parse(await getComment() || '')['ComicBookInfo/1.0'] + } catch { + return null + } + if (!info) return null + const year = info.publicationYear + const month = info.publicationMonth + const mm = month && month >= 1 && month <= 12 ? String(month).padStart(2, '0') : null + return { + title: info.title, + publisher: info.publisher, + language: info.language || info.lang, + author: info.credits ? info.credits.map(c => `${c.person} (${c.role})`).join(', ') : '', + published: year && month ? `${year}-${mm}` : undefined, + series: info.series, + seriesPosition: info.issue == null ? undefined : String(info.issue), + } +} + +export const makeComicBook = async ({ entries, loadBlob, getSize, getComment }, file) => { + const cache = new Map() + const urls = new Map() + const load = async name => { + if (cache.has(name)) return cache.get(name) + const src = URL.createObjectURL(await loadBlob(name)) + const page = URL.createObjectURL( + new Blob([``], { type: 'text/html' })) + urls.set(name, [src, page]) + cache.set(name, page) + return page + } + const unload = name => { + urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url)) + urls.delete(name) + cache.delete(name) + } + + const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif'] + const files = entries + .map(entry => entry.filename) + .filter(name => exts.some(ext => name.endsWith(ext))) + .sort() + if (!files.length) throw new Error('No supported image files in archive') + + const book = {} + // Prefer ComicInfo.xml (Anansi standard) over ComicBookInfo (JSON in zip comment). + // Fields missing from the preferred source fall through to the secondary one. + const xml = await readComicInfoXML({ entries, loadBlob }) + const cbi = await readComicBookInfo({ getComment }) + const merged = { ...(cbi || {}), ...(xml || {}) } + book.metadata = { + title: merged.title || file.name, + publisher: merged.publisher, + language: merged.language, + author: merged.author, + published: merged.published, + description: merged.description, + subject: merged.subject, + identifier: merged.identifier, + } + if (merged.series) { + const series = { name: merged.series } + if (merged.seriesPosition) series.position = merged.seriesPosition + if (merged.seriesTotal) series.total = merged.seriesTotal + book.metadata.belongsTo = { series } + } + book.getCover = () => loadBlob(files[0]) + book.sections = files.map(name => ({ + id: name, + load: () => load(name), + unload: () => unload(name), + size: getSize(name), + })) + book.toc = files.map(name => ({ label: name, href: name })) + book.rendition = { layout: 'pre-paginated' } + book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) }) + book.splitTOCHref = href => [href, null] + book.getTOCFragment = doc => doc.documentElement + book.destroy = () => { + for (const arr of urls.values()) + for (const url of arr) URL.revokeObjectURL(url) + } + return book +} diff --git a/frontend/src/lib/vendor/foliate-js/epub.js b/frontend/src/lib/vendor/foliate-js/epub.js new file mode 100644 index 0000000..89d7039 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/epub.js @@ -0,0 +1,1345 @@ +import * as CFI from './epubcfi.js' + +const NS = { + CONTAINER: 'urn:oasis:names:tc:opendocument:xmlns:container', + XHTML: 'http://www.w3.org/1999/xhtml', + OPF: 'http://www.idpf.org/2007/opf', + EPUB: 'http://www.idpf.org/2007/ops', + DC: 'http://purl.org/dc/elements/1.1/', + DCTERMS: 'http://purl.org/dc/terms/', + ENC: 'http://www.w3.org/2001/04/xmlenc#', + NCX: 'http://www.daisy.org/z3986/2005/ncx/', + XLINK: 'http://www.w3.org/1999/xlink', + SMIL: 'http://www.w3.org/ns/SMIL', +} + +const MIME = { + XML: 'application/xml', + NCX: 'application/x-dtbncx+xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', + JS: /\/(x-)?(javascript|ecmascript)/, +} + +// https://www.w3.org/TR/epub-33/#sec-reserved-prefixes +const PREFIX = { + a11y: 'http://www.idpf.org/epub/vocab/package/a11y/#', + dcterms: 'http://purl.org/dc/terms/', + marc: 'http://id.loc.gov/vocabulary/', + media: 'http://www.idpf.org/epub/vocab/overlays/#', + onix: 'http://www.editeur.org/ONIX/book/codelists/current.html#', + rendition: 'http://www.idpf.org/vocab/rendition/#', + schema: 'http://schema.org/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + msv: 'http://www.idpf.org/epub/vocab/structure/magazine/#', + prism: 'http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#', +} + +const RELATORS = { + art: 'artist', + aut: 'author', + clr: 'colorist', + edt: 'editor', + ill: 'illustrator', + nrt: 'narrator', + trl: 'translator', + pbl: 'publisher', +} + +const ONIX5 = { + '02': 'isbn', + '06': 'doi', + '15': 'isbn', + '26': 'doi', + '34': 'issn', +} + +// convert to camel case +const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase()) + +// strip and collapse ASCII whitespace +// https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace +const normalizeWhitespace = str => str ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') : '' + +const filterAttribute = (attr, value, isList) => isList + ? el => el.getAttribute(attr)?.split(/\s/)?.includes(value) + : typeof value === 'function' + ? el => value(el.getAttribute(attr)) + : el => el.getAttribute(attr) === value + +const getAttributes = (...xs) => el => + el ? Object.fromEntries(xs.map(x => [camel(x), el.getAttribute(x)])) : null + +const getElementText = el => normalizeWhitespace(el?.textContent) + +const childGetter = (doc, ns) => { + // ignore the namespace if it doesn't appear in document at all + const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) + const f = useNS + ? (el, name) => el => el.namespaceURI === ns && el.localName === name + : (el, name) => el => el.localName === name + return { + $: (el, name) => [...el.children].find(f(el, name)), + $$: (el, name) => [...el.children].filter(f(el, name)), + $$$: useNS + ? (el, name) => [...el.getElementsByTagNameNS(ns, name)] + : (el, name) => [...el.getElementsByTagName(name)], + } +} + +// Zip entry names are raw, so a resolved href has to be fully decoded to match +// one. `decodeURI()` can't do it: by spec it preserves the reserved set +// (`; / ? : @ & = + $ , #`), leaving an entry named `a&b.html` unreachable +// behind its manifest href `a%26b.html`. Decode as a component instead, keeping +// only `/` and `#` encoded, which would otherwise turn into a path or fragment +// separator. Malformed escapes (a bare `%` in a name) decode to themselves. +const decodeURIPath = path => { + try { + return decodeURIComponent(path.replace(/%(2f|23)/gi, '%25$1')) + } catch { + return path + } +} + +const resolveURL = (url, relativeTo) => { + try { + if (isExternal(relativeTo)) return new URL(url, relativeTo) + // the base needs to be a valid URL, so set a base URL and then remove it + const root = 'https://invalid.invalid/' + const obj = new URL(url, root + relativeTo) + obj.search = '' + return decodeURIPath(obj.href.replace(root, '')) + } catch(e) { + console.warn(e) + return url + } +} + +const isExternal = uri => /^(?!blob)\w+:/i.test(uri) + +// like `path.relative()` in Node.js +const pathRelative = (from, to) => { + if (!from) return to + const as = from.replace(/\/$/, '').split('/') + const bs = to.replace(/\/$/, '').split('/') + const i = (as.length > bs.length ? as : bs).findIndex((_, i) => as[i] !== bs[i]) + return i < 0 ? '' : Array(as.length - i).fill('..').concat(bs.slice(i)).join('/') +} + +const pathDirname = str => str.slice(0, str.lastIndexOf('/') + 1) + +// replace asynchronously and sequentially +// same technique as https://stackoverflow.com/a/48032528 +const replaceSeries = async (str, regex, f) => { + const matches = [] + str.replace(regex, (...args) => (matches.push(args), null)) + const results = [] + for (const args of matches) results.push(await f(...args)) + return str.replace(regex, () => results.shift()) +} + +const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + +const tidy = obj => { + for (const [key, val] of Object.entries(obj)) + if (val == null) delete obj[key] + else if (Array.isArray(val)) { + obj[key] = val.filter(x => x).map(x => + typeof x === 'object' && !Array.isArray(x) ? tidy(x) : x) + if (!obj[key].length) delete obj[key] + else if (obj[key].length === 1) obj[key] = obj[key][0] + } + else if (typeof val === 'object') { + obj[key] = tidy(val) + if (!Object.keys(val).length) delete obj[key] + } + const keys = Object.keys(obj) + if (keys.length === 1 && keys[0] === 'name') return obj[keys[0]] + return obj +} + +// https://www.w3.org/TR/epub/#sec-prefix-attr +const getPrefixes = doc => { + const map = new Map(Object.entries(PREFIX)) + const value = doc.documentElement.getAttributeNS(NS.EPUB, 'prefix') + || doc.documentElement.getAttribute('prefix') + if (value) for (const [, prefix, url] of value + .matchAll(/(.+): +(.+)[ \t\r\n]*/g)) map.set(prefix, url) + return map +} + +// https://www.w3.org/TR/epub-rs/#sec-property-values +// but ignoring the case where the prefix is omitted +const getPropertyURL = (value, prefixes) => { + if (!value) return null + const [a, b] = value.split(':') + const prefix = b ? a : null + const reference = b ? b : a + const baseURL = prefixes.get(prefix) + return baseURL ? baseURL + reference : null +} + +// See the call site in getMetadata() for the two calibre encodings this reads. +const getCalibreUserMetadata = (metaEls, legacyMeta) => { + // calibre's to_json wraps non-JSON types; only datetime appears in columns + const fromJSON = x => x?.__class__ === 'datetime.datetime' ? x.__value__ : x + const isEmpty = (value, datatype) => value == null || value === '' + || Array.isArray(value) && !value.length + // calibre can't distinguish these from unset, and neither can we + || datatype === 'datetime' && String(value).startsWith('0101-01-01') + || datatype === 'rating' && !value + const columns = [] + const add = (key, fm) => { + if (!key?.startsWith('#') || typeof fm !== 'object' || !fm) return + const datatype = fm.datatype ?? 'text' + const value = fromJSON(fm['#value#']) + if (isEmpty(value, datatype)) return + const extra = fromJSON(fm['#extra#']) + const label = key.slice(1) + columns.push({ + label, + name: typeof fm.name === 'string' && fm.name ? fm.name : label, + datatype, value, + ...extra != null ? { extra } : {}, + }) + } + for (const el of metaEls ?? []) { + if (el.getAttribute('property')?.toLowerCase() !== 'calibre:user_metadata') continue + try { + for (const [key, fm] of Object.entries(JSON.parse(getElementText(el)))) + add(key, fm) + } catch {} + } + if (!columns.length) + for (const [name, content] of Object.entries(legacyMeta ?? {})) { + if (!name.startsWith('calibre:user_metadata:')) continue + try { + add(name.slice('calibre:user_metadata:'.length), JSON.parse(content)) + } catch {} + } + return columns.length ? columns : null +} + +const getMetadata = opf => { + const { $ } = childGetter(opf, NS.OPF) + const $metadata = $(opf.documentElement, 'metadata') + + // first pass: convert to JS objects + const els = Object.groupBy($metadata.children, el => + el.namespaceURI === NS.DC ? 'dc' + : el.namespaceURI === NS.OPF && el.localName === 'meta' ? + (el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '') + const baseLang = $metadata.getAttribute('xml:lang') + ?? opf.documentElement.getAttribute('xml:lang') ?? 'und' + const prefixes = getPrefixes(opf) + const parse = el => { + const property = el.getAttribute('property') + const scheme = el.getAttribute('scheme') + return { + property: getPropertyURL(property, prefixes) ?? property, + scheme: getPropertyURL(scheme, prefixes) ?? scheme, + lang: el.getAttribute('xml:lang'), + value: getElementText(el), + props: getProperties(el), + // `opf:` attributes from EPUB 2 & EPUB 3.1 (removed in EPUB 3.2) + attrs: Object.fromEntries(Array.from(el.attributes) + .filter(attr => attr.namespaceURI === NS.OPF) + .map(attr => [attr.localName, attr.value])), + } + } + const refines = Map.groupBy(els.meta ?? [], el => el.getAttribute('refines')) + const getProperties = el => { + const els = refines.get(el ? '#' + el.getAttribute('id') : null) + if (!els) return null + return Object.groupBy(els.map(parse), x => x.property) + } + const dc = Object.fromEntries(Object.entries(Object.groupBy(els.dc || [], el => el.localName)) + .map(([name, els]) => [name, els.map(parse)])) + const properties = getProperties() ?? {} + const legacyMeta = Object.fromEntries(els.legacyMeta?.map(el => + [el.getAttribute('name'), el.getAttribute('content')]) ?? []) + + // second pass: map to webpub + const one = x => x?.[0]?.value + const prop = (x, p) => one(x?.props?.[p]) + const makeLanguageMap = x => { + if (!x) return null + const alts = x.props?.['alternate-script'] ?? [] + const altRep = x.attrs['alt-rep'] + if (!alts.length && (!x.lang || x.lang === baseLang) && !altRep) return x.value + const map = { [x.lang ?? baseLang]: x.value } + if (altRep) map[x.attrs['alt-rep-lang']] = altRep + for (const y of alts) map[y.lang] ??= y.value + return map + } + const makeContributor = x => x ? ({ + name: makeLanguageMap(x), + sortAs: makeLanguageMap(x.props?.['file-as']?.[0]) ?? x.attrs['file-as'], + role: x.props?.role?.filter(x => x.scheme === PREFIX.marc + 'relators') + ?.map(x => x.value) ?? [x.attrs.role], + code: prop(x, 'term') ?? x.attrs.term, + scheme: prop(x, 'authority') ?? x.attrs.authority, + }) : null + const makeCollection = x => ({ + name: makeLanguageMap(x), + // NOTE: webpub requires number but EPUB allows values like "2.2.1" + position: one(x.props?.['group-position']), + }) + const makeSeries = x => ({ + name: x.value, + position: one(x.props?.['group-position']), + }) + const makeAltIdentifier = x => { + const { value } = x + if (/^urn:/i.test(value)) return value + if (/^doi:/i.test(value)) return `urn:${value}` + const type = x.props?.['identifier-type'] + if (!type) { + const scheme = x.attrs.scheme + if (!scheme) return value + // https://idpf.github.io/epub-registries/identifiers/ + // but no "jdcn", which isn't a registered URN namespace + if (/^(doi|isbn|uuid)$/i.test(scheme)) return `urn:${scheme}:${value}` + // NOTE: webpub requires scheme to be a URI; EPUB allows anything + return { scheme, value } + } + if (type.scheme === PREFIX.onix + 'codelist5') { + const nid = ONIX5[type.value] + if (nid) return `urn:${nid}:${value}` + } + return value + } + const belongsTo = Object.groupBy(properties['belongs-to-collection'] ?? [], + x => prop(x, 'collection-type') === 'series' ? 'series' : 'collection') + const mainTitle = dc.title?.find(x => prop(x, 'title-type') === 'main') ?? dc.title?.[0] + const metadata = { + identifier: getIdentifier(opf), + title: makeLanguageMap(mainTitle), + sortAs: makeLanguageMap(mainTitle?.props?.['file-as']?.[0]) + ?? mainTitle?.attrs?.['file-as'] + ?? legacyMeta?.['calibre:title_sort'], + subtitle: dc.title?.find(x => prop(x, 'title-type') === 'subtitle')?.value, + language: dc.language?.map(x => x.value), + description: one(dc.description), + publisher: makeContributor(dc.publisher?.[0]), + published: dc.date?.find(x => x.attrs.event === 'publication')?.value + ?? one(dc.date), + modified: one(properties[PREFIX.dcterms + 'modified']) + ?? dc.date?.find(x => x.attrs.event === 'modification')?.value, + subject: dc.subject?.map(makeContributor), + belongsTo: { + collection: belongsTo.collection?.map(makeCollection), + series: belongsTo.series?.map(makeSeries) + ?? (legacyMeta?.['calibre:series'] ? { + name: legacyMeta?.['calibre:series'], + position: parseFloat(legacyMeta?.['calibre:series_index']), + } : null), + }, + altIdentifier: dc.identifier?.map(makeAltIdentifier), + source: dc.source?.map(makeAltIdentifier), // NOTE: not in webpub schema + rights: one(dc.rights), // NOTE: not in webpub schema + } + const remapContributor = defaultKey => x => { + const keys = new Set(x.role?.map(role => RELATORS[role] ?? defaultKey)) + return [keys.size ? keys : [defaultKey], x] + } + for (const [keys, val] of [].concat( + dc.creator?.map(makeContributor)?.map(remapContributor('author')) ?? [], + dc.contributor?.map(makeContributor)?.map(remapContributor('contributor')) ?? [])) + for (const key of keys) { + // if already parsed publisher don't remap it from author/contributor again + if (key === 'publisher' && metadata.publisher) continue + if (metadata[key]) metadata[key].push(val) + else metadata[key] = [val] + } + tidy(metadata) + if (metadata.altIdentifier === metadata.identifier) + delete metadata.altIdentifier + // Calibre embeds its custom columns ("user metadata") when polishing or + // sending books. Two encodings (see calibre's opf2.py/opf3.py): + // OPF 2: per column + // OPF 3: a single whose text is + // a JSON dict of all columns keyed by "#label"; calibre prefers + // this form over the legacy metas when both are present + // The column value lives in `#value#` (series index in `#extra#`); + // datetimes are wrapped as {"__class__": "datetime.datetime", + // "__value__": } with 0101-01-01 meaning unset. Embedded files carry + // every column of the library, so empty values are dropped here. Must run + // after tidy(), which would otherwise collapse single-element value arrays. + const calibreColumns = getCalibreUserMetadata(els.meta, legacyMeta) + if (calibreColumns) metadata.calibreColumns = calibreColumns + + const rendition = {} + const media = {} + for (const [key, val] of Object.entries(properties)) { + if (key.startsWith(PREFIX.rendition)) + rendition[camel(key.replace(PREFIX.rendition, ''))] = one(val) + else if (key.startsWith(PREFIX.media)) + media[camel(key.replace(PREFIX.media, ''))] = one(val) + } + if (media.duration) media.duration = parseClock(media.duration) + return { metadata, rendition, media } +} + +const parseNav = (doc, resolve = f => f) => { + const { $, $$, $$$ } = childGetter(doc, NS.XHTML) + const resolveHref = href => href ? decodeURI(resolve(href)) : null + const parseLI = getType => $li => { + const $a = $($li, 'a') ?? $($li, 'span') + const $ol = $($li, 'ol') + const href = resolveHref($a?.getAttribute('href')) + const label = getElementText($a) || $a?.getAttribute('title') + // TODO: get and concat alt/title texts in content + const result = { label, href, subitems: parseOL($ol) } + if (getType) result.type = $a?.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) + return result + } + const parseOL = ($ol, getType) => $ol ? $$($ol, 'li').map(parseLI(getType)) : null + const parseNav = ($nav, getType) => parseOL($($nav, 'ol'), getType) + + const $$nav = $$$(doc, 'nav') + let toc = null, pageList = null, landmarks = null, others = [] + for (const $nav of $$nav) { + const type = $nav.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) ?? [] + if (type.includes('toc')) toc ??= parseNav($nav) + else if (type.includes('page-list')) pageList ??= parseNav($nav) + else if (type.includes('landmarks')) landmarks ??= parseNav($nav, true) + else others.push({ + label: getElementText($nav.firstElementChild), type, + list: parseNav($nav), + }) + } + return { toc, pageList, landmarks, others } +} + +const parseNCX = (doc, resolve = f => f) => { + const { $, $$ } = childGetter(doc, NS.NCX) + const resolveHref = href => href ? decodeURI(resolve(href)) : null + const parseItem = el => { + const $label = $(el, 'navLabel') + const $content = $(el, 'content') + const label = getElementText($label) + const href = resolveHref($content.getAttribute('src')) + if (el.localName === 'navPoint') { + const els = $$(el, 'navPoint') + return { label, href, subitems: els.length ? els.map(parseItem) : null } + } + return { label, href } + } + const parseList = (el, itemName) => $$(el, itemName).map(parseItem) + const getSingle = (container, itemName) => { + const $container = $(doc.documentElement, container) + return $container ? parseList($container, itemName) : null + } + return { + toc: getSingle('navMap', 'navPoint'), + pageList: getSingle('pageList', 'pageTarget'), + others: $$(doc.documentElement, 'navList').map(el => ({ + label: getElementText($(el, 'navLabel')), + list: parseList(el, 'navTarget'), + })), + } +} + +const parseClock = str => { + if (!str) return + const parts = str.split(':').map(x => parseFloat(x)) + if (parts.length === 3) { + const [h, m, s] = parts + return h * 60 * 60 + m * 60 + s + } + if (parts.length === 2) { + const [m, s] = parts + return m * 60 + s + } + const [x, unit] = str.split(/(?=[^\d.])/) + const n = parseFloat(x) + const f = unit === 'h' ? 60 * 60 + : unit === 'min' ? 60 + : unit === 'ms' ? .001 + : 1 + return n * f +} + +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] +const FONT_EXTENSIONS = ['woff', 'woff2', 'ttf', 'otf'] + +const getImageMediaType = (path) => { + const extension = path.toLowerCase().split('.').pop() + const mediaTypeMap = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + } + return mediaTypeMap[extension] || 'image/jpeg' +} + +const getFontMediaType = (path) => { + const extension = path.toLowerCase().split('.').pop() + const mediaTypeMap = { + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'ttf': 'font/ttf', + 'otf': 'font/otf', + } + return mediaTypeMap[extension] || 'font/ttf' +} + +// Container entry whose file name ends in `cover`/`couv` (the French +// spelling) plus an image extension, e.g. `cover.jpg`, `Images/Cover.PNG`, +// `couv.jpeg`. Same shape `gnome-epub-thumbnailer` falls back to. +const UNDECLARED_COVER_RE = /(?:cover|couv)\.(?:jpe?g|png|gif|webp|svg)$/i + +// Last-ditch cover lookup for EPUBs where the manifest resolves to nothing: +// scan the container's own file names. `names` is iterated in central +// directory order, so the first match wins. +const findUndeclaredCover = names => { + for (const name of names) if (UNDECLARED_COVER_RE.test(name)) return name + return null +} + +class MediaOverlay extends EventTarget { + #entries + #lastMediaOverlayItem + #sectionIndex + #audioIndex + #itemIndex + #audio + #volume = 1 + #rate = 1 + #state + constructor(book, loadXML) { + super() + this.book = book + this.loadXML = loadXML + } + async #loadSMIL(item) { + if (this.#lastMediaOverlayItem === item) return + const doc = await this.loadXML(item.href) + const resolve = href => href ? resolveURL(href, item.href) : null + const { $, $$$ } = childGetter(doc, NS.SMIL) + this.#audioIndex = -1 + this.#itemIndex = -1 + this.#entries = $$$(doc, 'par').reduce((arr, $par) => { + const text = resolve($($par, 'text')?.getAttribute('src')) + const $audio = $($par, 'audio') + if (!text || !$audio) return arr + const src = resolve($audio.getAttribute('src')) + const begin = parseClock($audio.getAttribute('clipBegin')) + const end = parseClock($audio.getAttribute('clipEnd')) + const last = arr.at(-1) + if (last?.src === src) last.items.push({ text, begin, end }) + else arr.push({ src, items: [{ text, begin, end }] }) + return arr + }, []) + this.#lastMediaOverlayItem = item + } + get #activeAudio() { + return this.#entries[this.#audioIndex] + } + get #activeItem() { + return this.#activeAudio?.items?.[this.#itemIndex] + } + #error(e) { + console.error(e) + this.dispatchEvent(new CustomEvent('error', { detail: e })) + } + #highlight() { + this.dispatchEvent(new CustomEvent('highlight', { detail: this.#activeItem })) + } + #unhighlight() { + this.dispatchEvent(new CustomEvent('unhighlight', { detail: this.#activeItem })) + } + async #play(audioIndex, itemIndex) { + this.#stop() + this.#audioIndex = audioIndex + this.#itemIndex = itemIndex + const src = this.#activeAudio?.src + if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1) + + const url = URL.createObjectURL(await this.book.loadBlob(src)) + const audio = new Audio(url) + this.#audio = audio + audio.volume = this.#volume + audio.playbackRate = this.#rate + audio.addEventListener('timeupdate', () => { + if (audio.paused) return + const t = audio.currentTime + const { items } = this.#activeAudio + if (t > this.#activeItem?.end) { + this.#unhighlight() + if (this.#itemIndex === items.length - 1) { + this.#play(this.#audioIndex + 1, 0).catch(e => this.#error(e)) + return + } + } + const oldIndex = this.#itemIndex + while (items[this.#itemIndex + 1]?.begin <= t) this.#itemIndex++ + if (this.#itemIndex !== oldIndex) this.#highlight() + }) + audio.addEventListener('error', () => + this.#error(new Error(`Failed to load ${src}`))) + audio.addEventListener('playing', () => this.#highlight()) + audio.addEventListener('ended', () => { + this.#unhighlight() + URL.revokeObjectURL(url) + this.#audio = null + this.#play(audioIndex + 1, 0).catch(e => this.#error(e)) + }) + if (this.#state === 'paused') { + this.#highlight() + audio.currentTime = this.#activeItem.begin ?? 0 + } + else audio.addEventListener('canplaythrough', () => { + // for some reason need to seek in `canplaythrough` + // or it won't play when skipping in WebKit + audio.currentTime = this.#activeItem.begin ?? 0 + this.#state = 'playing' + audio.play().catch(e => this.#error(e)) + }, { once: true }) + } + async start(sectionIndex, filter = () => true) { + this.#audio?.pause() + const section = this.book.sections[sectionIndex] + const href = section?.id + if (!href) return + + const { mediaOverlay } = section + if (!mediaOverlay) return this.start(sectionIndex + 1) + this.#sectionIndex = sectionIndex + await this.#loadSMIL(mediaOverlay) + + for (let i = 0; i < this.#entries.length; i++) { + const { items } = this.#entries[i] + for (let j = 0; j < items.length; j++) { + if (items[j].text.split('#')[0] === href && filter(items[j], j, items)) + return this.#play(i, j).catch(e => this.#error(e)) + } + } + } + pause() { + this.#state = 'paused' + this.#audio?.pause() + } + resume() { + this.#state = 'playing' + this.#audio?.play().catch(e => this.#error(e)) + } + #stop() { + if (this.#audio) { + this.#audio.pause() + URL.revokeObjectURL(this.#audio.src) + this.#audio = null + this.#unhighlight() + } + } + stop() { + this.#state = 'stopped' + this.#stop() + } + prev() { + if (this.#itemIndex > 0) this.#play(this.#audioIndex, this.#itemIndex - 1) + else if (this.#audioIndex > 0) this.#play(this.#audioIndex - 1, + this.#entries[this.#audioIndex - 1].items.length - 1) + else if (this.#sectionIndex > 0) + this.start(this.#sectionIndex - 1, (_, i, items) => i === items.length - 1) + } + next() { + this.#play(this.#audioIndex, this.#itemIndex + 1) + } + setVolume(volume) { + this.#volume = volume + if (this.#audio) this.#audio.volume = volume + } + setRate(rate) { + this.#rate = rate + if (this.#audio) this.#audio.playbackRate = rate + } +} + +const isUUID = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/i + +const getUUID = opf => { + const extractUUID = el => { + const text = getElementText(el) + const id = text.split(':').slice(-1)[0] + const match = isUUID.exec(id) + return match ? match[0] : null + } + const identifiers = Array.from(opf.getElementsByTagNameNS(NS.DC, 'identifier')) + // 1. Prefer the unique-identifier (used by Adobe font obfuscation) + const uniqueIdAttr = opf.documentElement.getAttribute('unique-identifier') + if (uniqueIdAttr) { + const el = identifiers.find(el => el.getAttribute('id') === uniqueIdAttr) + if (el) { + const uuid = extractUUID(el) + if (uuid) return uuid + } + } + // 2. Prefer urn:uuid: identifiers (standard UUID URN per RFC 4122) + for (const el of identifiers) { + const text = getElementText(el) + if (/^urn:uuid:/i.test(text)) { + const uuid = extractUUID(el) + if (uuid) return uuid + } + } + // 3. Fall back to any identifier containing a UUID + for (const el of identifiers) { + const uuid = extractUUID(el) + if (uuid) return uuid + } + return '' +} + +const getIdentifier = opf => getElementText( + opf.getElementById(opf.documentElement.getAttribute('unique-identifier')) + ?? opf.getElementsByTagNameNS(NS.DC, 'identifier')[0]) + +// https://www.w3.org/publishing/epub32/epub-ocf.html#sec-resource-obfuscation +const deobfuscate = async (key, length, blob) => { + const array = new Uint8Array(await blob.slice(0, length).arrayBuffer()) + length = Math.min(length, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + return new Blob([array, blob.slice(length)], { type: blob.type }) +} + +const WebCryptoSHA1 = async str => { + const data = new TextEncoder().encode(str) + const buffer = await globalThis.crypto.subtle.digest('SHA-1', data) + return new Uint8Array(buffer) +} + +const deobfuscators = (sha1 = WebCryptoSHA1) => ({ + 'http://www.idpf.org/2008/embedding': { + key: opf => sha1(getIdentifier(opf) + // eslint-disable-next-line no-control-regex + .replaceAll(/[\u0020\u0009\u000d\u000a]/g, '')), + decode: (key, blob) => deobfuscate(key, 1040, blob), + }, + 'http://ns.adobe.com/pdf/enc#RC': { + key: opf => { + const uuid = getUUID(opf).replaceAll('-', '') + return Uint8Array.from({ length: 16 }, (_, i) => + parseInt(uuid.slice(i * 2, i * 2 + 2), 16)) + }, + decode: (key, blob) => deobfuscate(key, 1024, blob), + }, +}) + +class Encryption { + #uris = new Map() + #decoders = new Map() + #algorithms + constructor(algorithms) { + this.#algorithms = algorithms + } + async init(encryption, opf) { + if (!encryption) return + const data = Array.from( + encryption.getElementsByTagNameNS(NS.ENC, 'EncryptedData'), el => ({ + algorithm: el.getElementsByTagNameNS(NS.ENC, 'EncryptionMethod')[0] + ?.getAttribute('Algorithm'), + uri: el.getElementsByTagNameNS(NS.ENC, 'CipherReference')[0] + ?.getAttribute('URI'), + })) + for (const { algorithm, uri } of data) { + if (!this.#decoders.has(algorithm)) { + const algo = this.#algorithms[algorithm] + if (!algo) { + console.warn('Unknown encryption algorithm') + continue + } + const key = await algo.key(opf) + this.#decoders.set(algorithm, blob => algo.decode(key, blob)) + } + this.#uris.set(uri, algorithm) + } + } + getDecoder(uri) { + return this.#decoders.get(this.#uris.get(uri)) ?? (x => x) + } +} + +class Resources { + constructor({ opf, resolveHref }) { + this.opf = opf + const { $, $$, $$$ } = childGetter(opf, NS.OPF) + + const $manifest = $(opf.documentElement, 'manifest') + const $spine = $(opf.documentElement, 'spine') + const $$itemref = $$($spine, 'itemref') + + this.manifest = $$($manifest, 'item') + .map(getAttributes('href', 'id', 'media-type', 'properties', 'media-overlay')) + .map(item => { + item.href = resolveHref(item.href) + item.properties = item.properties?.split(/\s/) + return item + }) + this.manifestById = new Map(this.manifest.map(item => [item.id, item])) + this.spine = $$itemref + .map(getAttributes('idref', 'id', 'linear', 'properties')) + .map(item => (item.properties = item.properties?.split(/\s/), item)) + this.pageProgressionDirection = $spine + .getAttribute('page-progression-direction') + + this.navPath = this.getItemByProperty('nav')?.href + this.ncxPath = (this.getItemByID($spine.getAttribute('toc')) + ?? this.manifest.find(item => item.mediaType === MIME.NCX))?.href + + const $guide = $(opf.documentElement, 'guide') + if ($guide) this.guide = $$($guide, 'reference') + .map(getAttributes('type', 'title', 'href')) + .map(({ type, title, href }) => ({ + label: title, + type: type.split(/\s/), + href: resolveHref(href), + })) + + this.cover = this.getItemByProperty('cover-image') + // EPUB 2 compat + ?? this.getItemByID($$$(opf, 'meta') + .find(filterAttribute('name', 'cover')) + ?.getAttribute('content')) + ?? this.manifest.find(item => item.id === 'cover' + && item.mediaType.startsWith('image')) + ?? this.manifest.find(item => item.href.includes('cover') + && item.mediaType.startsWith('image')) + ?? this.getItemByHref(this.guide + ?.find(ref => ref.type.includes('cover'))?.href) + // last resort: first image in manifest + ?? this.manifest.find(item => item.mediaType.startsWith('image')) + + this.cfis = CFI.fromElements($$itemref) + } + getItemByID(id) { + return this.manifestById.get(id) + } + getItemByHref(href) { + return this.manifest.find(item => item.href === href) + } + getItemByProperty(prop) { + return this.manifest.find(item => item.properties?.includes(prop)) + } + resolveCFI(cfi) { + const parts = CFI.parse(cfi) + const top = (parts.parent ?? parts).shift() + let $itemref = CFI.toElement(this.opf, top) + // make sure it's an idref; if not, try again without the ID assertion + // mainly because Epub.js used to generate wrong ID assertions + // https://github.com/futurepress/epub.js/issues/1236 + if ($itemref && $itemref.nodeName !== 'idref') { + top.at(-1).id = null + $itemref = CFI.toElement(this.opf, top) + } + const idref = $itemref?.getAttribute('idref') + const index = this.spine.findIndex(item => item.idref === idref) + const anchor = doc => CFI.toRange(doc, parts) + return { index, anchor } + } +} + +class Loader { + #cache = new Map() + #cacheXHTMLContent = new Map() + #children = new Map() + #refCount = new Map() + eventTarget = new EventTarget() + constructor({ loadText, loadBlob, resources, entries }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.manifest = resources.manifest + this.assets = resources.manifest + this.entries = entries + // needed only when replacing in (X)HTML w/o parsing (see below) + //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType)) + } + async createURL(href, data, type, parent) { + if (!data) return '' + const detail = { data, type } + Object.defineProperty(detail, 'name', { value: href }) // readonly + const event = new CustomEvent('data', { detail }) + this.eventTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const url = URL.createObjectURL(new Blob([newData], { type: newType })) + this.#cache.set(href, url) + this.#refCount.set(href, 1) + if (newType === MIME.XHTML || newType === MIME.HTML) { + this.#cacheXHTMLContent.set(url, {href, type: newType, data: newData}) + } + if (parent) { + const childList = this.#children.get(parent) + if (childList) childList.push(href) + else this.#children.set(parent, [href]) + } + return url + } + ref(href, parent) { + const childList = this.#children.get(parent) + if (!childList?.includes(href)) { + this.#refCount.set(href, this.#refCount.get(href) + 1) + //console.log(`referencing ${href}, now ${this.#refCount.get(href)}`) + if (childList) childList.push(href) + else this.#children.set(parent, [href]) + } + return this.#cache.get(href) + } + unref(href) { + if (!this.#refCount.has(href)) return + const count = this.#refCount.get(href) - 1 + //console.log(`unreferencing ${href}, now ${count}`) + if (count < 1) { + //console.log(`unloading ${href}`) + const url = this.#cache.get(href) + URL.revokeObjectURL(url) + this.#cache.delete(href) + this.#cacheXHTMLContent.delete(url) + this.#refCount.delete(href) + // unref children + const childList = this.#children.get(href) + if (childList) while (childList.length) this.unref(childList.pop()) + this.#children.delete(href) + } else this.#refCount.set(href, count) + } + // load manifest item, recursively loading all resources as needed + async loadItem(item, parents = []) { + if (!item) return null + const { href, mediaType } = item + + const isScript = MIME.JS.test(item.mediaType) + const detail = { type: mediaType, href, isScript, allow: true} + const event = new CustomEvent('load', { detail }) + this.eventTarget.dispatchEvent(event) + const { allow, url } = await event.detail + if (!allow) return null + if (url !== undefined) return url + + const parent = parents.at(-1) + if (this.#cache.has(href)) return this.ref(href, parent) + + const shouldReplace = + (isScript || [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(mediaType)) + // prevent circular references + && parents.every(p => p !== href) + if (shouldReplace) return this.loadReplaced(item, parents) + // NOTE: this can be replaced with `Promise.try()` + const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)) + return this.createURL(href, tryLoadBlob, mediaType, parent) + } + async loadItemXHTMLContent(item, parents = []) { + const url = await this.loadItem(item, parents) + if (url) return this.#cacheXHTMLContent.get(url)?.data + } + tryImageEntryItem(path) { + if (!IMAGE_EXTENSIONS.some(ext => path.toLowerCase().endsWith(`.${ext}`))) { + return null + } + if (!this.entries.get(path)) { + return null + } + return { + href: path, + mediaType: getImageMediaType(path), + } + } + tryFontEntryItem(path) { + if (!FONT_EXTENSIONS.some(ext => path.toLowerCase().endsWith(`.${ext}`))) { + return null + } + if (this.entries.get(path)) { + return { + href: path, + mediaType: getFontMediaType(path), + } + } + return { + href: `fonts/${path.split('/').pop()}`, + mediaType: getFontMediaType(path), + } + } + async loadHref(href, base, parents = []) { + if (isExternal(href)) return href + const path = resolveURL(href, base) + let item = this.manifest.find(item => item.href === path) + if (!item) { + item = this.tryImageEntryItem(path) ?? this.tryFontEntryItem(path) + if (!item) { + return href + } + } + return this.loadItem(item, parents.concat(base)) + } + async loadReplaced(item, parents = []) { + const { href, mediaType } = item + const parent = parents.at(-1) + let str = '' + try { + str = await this.loadText(href) + } catch (e) { + return this.createURL(href, Promise.reject(e), mediaType, parent) + } + if (!str) return null + + // note that one can also just use `replaceString` for everything: + // ``` + // const replaced = await this.replaceString(str, href, parents) + // return this.createURL(href, replaced, mediaType, parent) + // ``` + // which is basically what Epub.js does, which is simpler, but will + // break things like iframes (because you don't want to replace links) + // or text that just happen to be paths + + // parse and replace in HTML + if ([MIME.XHTML, MIME.HTML, MIME.SVG].includes(mediaType)) { + let doc = new DOMParser().parseFromString(str, mediaType) + // change to HTML if it's not valid XHTML + if (mediaType === MIME.XHTML && (doc.querySelector('parsererror') + || !doc.documentElement?.namespaceURI)) { + console.warn(doc.querySelector('parsererror')?.innerText ?? 'Invalid XHTML') + item.mediaType = MIME.HTML + doc = new DOMParser().parseFromString(str, item.mediaType) + } + // replace hrefs in XML processing instructions + // this is mainly for SVGs that use xml-stylesheet + if ([MIME.XHTML, MIME.SVG].includes(item.mediaType)) { + let child = doc.firstChild + while (child instanceof ProcessingInstruction) { + if (child.data) { + const replacedData = await replaceSeries(child.data, + /(?:^|\s*)(href\s*=\s*['"])([^'"]*)(['"])/i, + (_, p1, p2, p3) => this.loadHref(p2, href, parents) + .then(p2 => `${p1}${p2}${p3}`)) + child.replaceWith(doc.createProcessingInstruction( + child.target, replacedData)) + } + child = child.nextSibling + } + } + // replace hrefs (excluding anchors) + const replace = async (el, attr) => el.setAttribute(attr, + await this.loadHref(el.getAttribute(attr), href, parents)) + for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href') + for (const el of doc.querySelectorAll('[src]')) await replace(el, 'src') + for (const el of doc.querySelectorAll('[poster]')) await replace(el, 'poster') + for (const el of doc.querySelectorAll('object[data]')) await replace(el, 'data') + for (const el of doc.querySelectorAll('[*|href]:not([href])')) + el.setAttributeNS(NS.XLINK, 'href', await this.loadHref( + el.getAttributeNS(NS.XLINK, 'href'), href, parents)) + for (const el of doc.querySelectorAll('[srcset]')) + el.setAttribute('srcset', await replaceSeries(el.getAttribute('srcset'), + /(\s*)(.+?)\s*((?:\s[\d.]+[wx])+\s*(?:,|$)|,\s+|$)/g, + (_, p1, p2, p3) => this.loadHref(p2, href, parents) + .then(p2 => `${p1}${p2}${p3}`))) + // replace inline styles + for (const el of doc.querySelectorAll('style')) + if (el.textContent) el.textContent = + await this.replaceCSS(el.textContent, href, parents) + for (const el of doc.querySelectorAll('[style]')) + el.setAttribute('style', + await this.replaceCSS(el.getAttribute('style'), href, parents)) + // TODO: replace inline scripts? probably not worth the trouble + const result = new XMLSerializer().serializeToString(doc) + return this.createURL(href, result, item.mediaType, parent) + } + + const result = mediaType === MIME.CSS + ? await this.replaceCSS(str, href, parents) + : await this.replaceString(str, href, parents) + return this.createURL(href, result, mediaType, parent) + } + async replaceCSS(str, href, parents = []) { + const replacedUrls = await replaceSeries(str, + /url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi, + (_, url) => this.loadHref(url, href, parents) + .then(url => `url("${url}")`)) + // apart from `url()`, strings can be used for `@import` (but why?!) + return replaceSeries(replacedUrls, + /@import\s*["']([^"'\n]*?)["']/gi, + (_, url) => this.loadHref(url, href, parents) + .then(url => `@import "${url}"`)) + } + // find & replace all possible relative paths for all assets without parsing + replaceString(str, href, parents = []) { + const assetMap = new Map() + const urls = this.assets.map(asset => { + // do not replace references to the file itself + if (asset.href === href) return + // href was decoded and resolved when parsing the manifest + const relative = pathRelative(pathDirname(href), asset.href) + const relativeEnc = encodeURI(relative) + const rootRelative = '/' + asset.href + const rootRelativeEnc = encodeURI(rootRelative) + const set = new Set([relative, relativeEnc, rootRelative, rootRelativeEnc]) + for (const url of set) assetMap.set(url, asset) + return Array.from(set) + }).flat().filter(x => x) + if (!urls.length) return str + const regex = new RegExp(urls.map(regexEscape).join('|'), 'g') + return replaceSeries(str, regex, async match => + this.loadItem(assetMap.get(match.replace(/^\//, '')), + parents.concat(href))) + } + unloadItem(item) { + this.unref(item?.href) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +const getHTMLFragment = (doc, id) => doc.getElementById(id) + ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) + +const getPageSpread = properties => { + for (const p of properties) { + if (p === 'page-spread-left' || p === 'rendition:page-spread-left') + return 'left' + if (p === 'page-spread-right' || p === 'rendition:page-spread-right') + return 'right' + if (p === 'rendition:page-spread-center') return 'center' + } +} + +const getDisplayOptions = doc => { + if (!doc) return null + return { + fixedLayout: getElementText(doc.querySelector('option[name="fixed-layout"]')), + openToSpread: getElementText(doc.querySelector('option[name="open-to-spread"]')), + } +} + +// Some EPUBs ship an OPF/NCX/nav doc that isn't well-formed XML: either named +// HTML entities that XML doesn't predefine (` ` …), or — worse — a bare +// `&` that was never escaped (e.g. a hand-built manifest id like +// `id="Search_&_Rescue"`). A strict XML parser rejects both, failing the whole +// import. Map the known named entities to numeric refs, then escape any +// remaining `&` that doesn't begin a valid character/entity reference so the +// document parses instead. +const xmlNamedEntities = { + nbsp: ' ', mdash: '—', ndash: '–', + ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', + hellip: '…', copy: '©', reg: '®', trade: '™', + bull: '•', middot: '·', +} +const sanitizeXMLEntities = str => str + .replace(/&([a-z]+);/gi, (match, entity) => + xmlNamedEntities[entity.toLowerCase()] ?? match) + .replace(/&(?!#\d+;|#x[0-9a-f]+;|[a-z][a-z0-9]*;)/gi, '&') + +export class EPUB { + parser = new DOMParser() + #loader + #encryption + constructor({ entries, loadText, loadBlob, getSize, sha1 }) { + this.entries = entries.reduce((map, entry) => { + map.set(entry.filename, entry) + return map + }, new Map()) + this.loadText = loadText + this.loadBlob = loadBlob + this.getSize = getSize + this.#encryption = new Encryption(deobfuscators(sha1)) + } + async #loadXML(uri) { + const str = await this.loadText(uri) + if (!str) return null + const sanitized = sanitizeXMLEntities(str) + const doc = this.parser.parseFromString(sanitized, MIME.XML) + if (doc.querySelector('parsererror')) + throw new Error(`XML parsing error: ${uri} +${doc.querySelector('parsererror').innerText}`) + return doc + } + async init() { + const $container = await this.#loadXML('META-INF/container.xml') + if (!$container) throw new Error('Failed to load container file') + + const opfs = Array.from( + $container.getElementsByTagNameNS(NS.CONTAINER, 'rootfile'), + getAttributes('full-path', 'media-type')) + .filter(file => file.mediaType === 'application/oebps-package+xml') + + if (!opfs.length) throw new Error('No package document defined in container') + const opfPath = opfs[0].fullPath + const opf = await this.#loadXML(opfPath) + if (!opf) throw new Error('Failed to load package document') + + const $encryption = await this.#loadXML('META-INF/encryption.xml') + await this.#encryption.init($encryption, opf) + + this.resources = new Resources({ + opf, + resolveHref: url => resolveURL(url, opfPath), + }) + this.#loader = new Loader({ + loadText: this.loadText, + loadBlob: uri => Promise.resolve(this.loadBlob(uri)) + .then(this.#encryption.getDecoder(uri)), + resources: this.resources, + entries: this.entries, + }) + this.transformTarget = this.#loader.eventTarget + this.sections = this.resources.spine.map((spineItem, index) => { + const { idref, linear, properties = [] } = spineItem + const item = this.resources.getItemByID(idref) + if (!item) { + console.warn(`Could not find item with ID "${idref}" in manifest`) + return null + } + return { + id: item.href, + load: () => this.#loader.loadItem(item), + unload: () => this.#loader.unloadItem(item), + loadText: () => this.#loader.loadText(item.href), + loadContent: () => this.#loader.loadItemXHTMLContent(item), + createDocument: () => this.loadDocument(item), + size: this.getSize(item.href), + cfi: this.resources.cfis[index], + linear, + spineProperties: properties, + pageSpread: getPageSpread(properties), + resolveHref: href => resolveURL(href, item.href), + mediaOverlay: item.mediaOverlay + ? this.resources.getItemByID(item.mediaOverlay) : null, + } + }).filter(s => s) + + const { navPath, ncxPath } = this.resources + if (navPath) try { + const resolve = url => resolveURL(url, navPath) + const nav = parseNav(await this.#loadXML(navPath), resolve) + this.toc = nav.toc + this.pageList = nav.pageList + this.landmarks = nav.landmarks + } catch(e) { + console.warn(e) + } + // Some publishers ship an EPUB3 nav doc whose
  • s contain only + // plain text (no ). parseNav returns a non-empty array, so + // the original check `if (!this.toc)` would skip the NCX fallback + // and the reader ends up with an unusable empty TOC. Detect this + // case by recursively checking whether any item has a real href. + const hasNavigableHref = items => Array.isArray(items) && items.some( + it => (it && (it.href || hasNavigableHref(it.subitems)))) + if (!hasNavigableHref(this.toc) && ncxPath) try { + const resolve = url => resolveURL(url, ncxPath) + const ncx = parseNCX(await this.#loadXML(ncxPath), resolve) + this.toc = ncx.toc + this.pageList = ncx.pageList + } catch(e) { + console.warn(e) + } + + this.landmarks ??= this.resources.guide + + const { metadata, rendition, media } = getMetadata(opf) + this.metadata = metadata + this.rendition = rendition + this.media = media + this.dir = this.resources.pageProgressionDirection + const displayOptions = getDisplayOptions( + await this.#loadXML('META-INF/com.apple.ibooks.display-options.xml') + ?? await this.#loadXML('META-INF/com.kobobooks.display-options.xml')) + if (displayOptions) { + if (displayOptions.fixedLayout === 'true') + this.rendition.layout ??= 'pre-paginated' + if (displayOptions.openToSpread === 'false') this.sections + .find(section => section.linear !== 'no').pageSpread ??= + this.dir === 'rtl' ? 'left' : 'right' + } + return this + } + async loadDocument(item) { + const str = await this.loadText(item.href) + const doc = this.parser.parseFromString(str, item.mediaType) + // Same fallback as the render path in `loadReplaced`: a file the + // manifest declares as XHTML but which isn't well-formed XML (an + // unclosed `` is the usual culprit) parses into a + // `parsererror` document whose `body` is null. Callers of + // `createDocument` walk that body, so retry as HTML instead. + if (item.mediaType === MIME.XHTML + && (doc.querySelector('parsererror') || !doc.documentElement?.namespaceURI)) + return this.parser.parseFromString(str, MIME.HTML) + return doc + } + getMediaOverlay() { + return new MediaOverlay(this, this.#loadXML.bind(this)) + } + resolveCFI(cfi) { + return this.resources.resolveCFI(cfi) + } + resolveHref(href) { + const [path, hash] = href.split('#') + const item = this.resources.getItemByHref(decodeURI(path)) + if (!item) return null + const index = this.resources.spine.findIndex(({ idref }) => idref === item.id) + const anchor = hash ? doc => getHTMLFragment(doc, hash) : () => 0 + return { index, anchor } + } + splitTOCHref(href) { + return href?.split('#') ?? [] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) + } + isExternal(uri) { + return isExternal(uri) + } + async getCover() { + const cover = this.resources?.cover + if (cover?.href) return new Blob([await this.loadBlob(cover.href)], + { type: cover.mediaType }) + // Fall back to a cover-named container entry. Some EPUBs ship the + // cover image without ever declaring it (no `cover-image` property, + // no `` target, no manifest item), which leaves + // every manifest-driven lookup above empty even though the image is + // sitting right there in the zip. + const href = findUndeclaredCover(this.entries.keys()) + if (!href) return null + const blob = await this.loadBlob(href) + return blob ? new Blob([blob], { type: getImageMediaType(href) }) : null + } + async getCalibreBookmarks() { + const txt = await this.loadText('META-INF/calibre_bookmarks.txt') + const magic = 'encoding=json+base64:' + if (txt?.startsWith(magic)) { + const json = atob(txt.slice(magic.length)) + return JSON.parse(json) + } + } + destroy() { + this.#loader?.destroy() + } +} + +// Standalone OPF metadata extractor. +// +// Exposed so callers that already have the OPF bytes in hand (e.g. a +// platform-native pre-parser that read the zip on a faster runtime) +// can derive `Book.metadata` without driving the full `EPUB.init()` — +// which would force `@zip.js/zip.js` to scan the central directory +// and inflate nav.xhtml/ncx the importer never reads. The output +// shape is identical to what `EPUB.init()` would produce, so the +// import-path BookDoc and the reader-path BookDoc remain byte-stable +// across `Book.metadata.identifier`, title, contributors, refines +// chains, ONIX5, and `belongs-to-collection`. +// +// Two entry points to fit different callers: +// - `getEpubMetadata(opfDoc)` — already-parsed OPF Document +// - `parseEpubMetadataFromXML(xml)` — raw OPF XML string +export const getEpubMetadata = opf => getMetadata(opf) +export const parseEpubMetadataFromXML = xml => { + const opf = new DOMParser().parseFromString(sanitizeXMLEntities(xml), 'application/xml') + return getMetadata(opf) +} diff --git a/frontend/src/lib/vendor/foliate-js/epubcfi.js b/frontend/src/lib/vendor/foliate-js/epubcfi.js new file mode 100644 index 0000000..e4c6bf3 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/epubcfi.js @@ -0,0 +1,369 @@ +const findIndices = (arr, f) => arr + .map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null) +const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) => + ({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs +const concatArrays = (a, b) => + a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1)) + +const isNumber = /\d/ +export const isCFI = /^epubcfi\((.*)\)$/ +const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&') + +const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})` +const unwrap = x => x.match(isCFI)?.[1] ?? x +const lift = f => (...xs) => + `epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})` +export const joinIndir = lift((...xs) => xs.join('!')) + +const tokenizer = str => { + const tokens = [] + let state, escape, value = '' + const push = x => (tokens.push(x), state = null, value = '') + const cat = x => (value += x, escape = false) + for (const char of Array.from(str.trim()).concat('')) { + if (char === '^' && !escape) { + escape = true + continue + } + if (state === '!') push(['!']) + else if (state === ',') push([',']) + else if (state === '/' || state === ':') { + if (isNumber.test(char)) { + cat(char) + continue + } else push([state, parseInt(value)]) + } else if (state === '~') { + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['~', parseFloat(value)]) + } else if (state === '@') { + if (char === ':') { + push(['@', parseFloat(value)]) + state = '@' + continue + } + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['@', parseFloat(value)]) + } else if (state === '[') { + if (char === ';' && !escape) { + push(['[', value]) + state = ';' + } else if (char === ',' && !escape) { + push(['[', value]) + state = '[' + } else if (char === ']' && !escape) push(['[', value]) + else cat(char) + continue + } else if (state?.startsWith(';')) { + if (char === '=' && !escape) { + state = `;${value}` + value = '' + } else if (char === ';' && !escape) { + push([state, value]) + state = ';' + } else if (char === ']' && !escape) push([state, value]) + else cat(char) + continue + } + if (char === '/' || char === ':' || char === '~' || char === '@' + || char === '[' || char === '!' || char === ',') state = char + } + return tokens +} + +const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x) + +const parser = tokens => { + const parts = [] + let state + for (const [type, val] of tokens) { + if (type === '/') parts.push({ index: val }) + else { + const last = parts[parts.length - 1] + if (type === ':') last.offset = val + else if (type === '~') last.temporal = val + else if (type === '@') last.spatial = (last.spatial ?? []).concat(val) + else if (type === ';s') last.side = val + else if (type === '[') { + if (state === '/' && val) last.id = val + else { + last.text = (last.text ?? []).concat(val) + continue + } + } + } + state = type + } + return parts +} + +// split at step indirections, then parse each part +const parserIndir = tokens => + splitAt(tokens, findTokens(tokens, '!')).map(parser) + +export const parse = cfi => { + const tokens = tokenizer(unwrap(cfi)) + const commas = findTokens(tokens, ',') + if (!commas.length) return parserIndir(tokens) + const [parent, start, end] = splitAt(tokens, commas).map(parserIndir) + return { parent, start, end } +} + +const partToString = ({ index, id, offset, temporal, spatial, text, side }) => { + const param = side ? `;s=${side}` : '' + return `/${index}` + + (id ? `[${escapeCFI(id)}${param}]` : '') + // "CFI expressions [..] SHOULD include an explicit character offset" + + (offset != null && index % 2 ? `:${offset}` : '') + + (temporal ? `~${temporal}` : '') + + (spatial ? `@${spatial.join(':')}` : '') + + (text || (!id && side) ? '[' + + (text?.map(escapeCFI)?.join(',') ?? '') + + param + ']' : '') +} + +const toInnerString = parsed => parsed.parent + ? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',') + : parsed.map(parts => parts.map(partToString).join('')).join('!') + +const toString = parsed => wrap(toInnerString(parsed)) + +export const collapse = (x, toEnd) => typeof x === 'string' + ? toString(collapse(parse(x), toEnd)) + : x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x + +// create range CFI from two CFIs +const buildRange = (from, to) => { + if (typeof from === 'string') from = parse(from) + if (typeof to === 'string') to = parse(to) + from = collapse(from) + to = collapse(to, true) + // ranges across multiple documents are not allowed; handle local paths only + const localFrom = from[from.length - 1], localTo = to[to.length - 1] + const localParent = [], localStart = [], localEnd = [] + let pushToParent = true + const len = Math.max(localFrom.length, localTo.length) + for (let i = 0; i < len; i++) { + const a = localFrom[i], b = localTo[i] + pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset + if (pushToParent) localParent.push(a) + else { + if (a) localStart.push(a) + if (b) localEnd.push(b) + } + } + // copy non-local paths from `from` + const parent = from.slice(0, -1).concat([localParent]) + return toString({ parent, start: [localStart], end: [localEnd] }) +} + +export const compare = (a, b) => { + if (typeof a === 'string') a = parse(a) + if (typeof b === 'string') b = parse(b) + if (a.start || b.start) return compare(collapse(a), collapse(b)) + || compare(collapse(a, true), collapse(b, true)) + + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const p = a[i] ?? [], q = b[i] ?? [] + const maxIndex = Math.max(p.length, q.length) - 1 + for (let i = 0; i <= maxIndex; i++) { + const x = p[i], y = q[i] + if (!x) return -1 + if (!y) return 1 + if (x.index > y.index) return 1 + if (x.index < y.index) return -1 + if (i === maxIndex) { + // TODO: compare temporal & spatial offsets + if (x.offset > y.offset) return 1 + if (x.offset < y.offset) return -1 + } + } + } + return 0 +} + +const isTextNode = node => node?.nodeType === 3 || node?.nodeType === 4 +const isElementNode = node => node?.nodeType === 1 +// cfi-inert: the node AND its subtree are invisible to CFI (e.g. injected a11y +// skip-links). cfi-skip: only the node itself is invisible — its children are +// hoisted into its parent, so they keep the indices they'd have without the +// wrapper (e.g. a layout-only
    that wraps a table/equation for scrolling). +const isInertNode = (node) => node.hasAttribute?.('cfi-inert') +const isSkipNode = (node) => node.hasAttribute?.('cfi-skip') + +// CFI-relevant children: text + elements, with cfi-inert nodes removed and +// cfi-skip wrappers spliced out (their own children hoisted in place, recursively). +const rawChildNodes = (node) => Array.from(node.childNodes) + // "content other than element and character data is ignored" + .filter(node => isTextNode(node) || isElementNode(node)) + .filter(node => !isInertNode(node)) + .flatMap(node => isSkipNode(node) ? rawChildNodes(node) : [node]) + +const getChildNodes = (node, filter) => { + const nodes = rawChildNodes(node) + return filter ? nodes.map(node => { + const accept = filter(node) + if (accept === NodeFilter.FILTER_REJECT) return null + else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter) + else return node + }).flat().filter(x => x) : nodes +} + +// child nodes are organized such that the result is always +// [element, text, element, text, ..., element], +// regardless of the actual structure in the document; +// so multiple text nodes need to be combined, and nonexistent ones counted; +// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec +const indexChildNodes = (node, filter) => { + const nodes = getChildNodes(node, filter) + .reduce((arr, node) => { + let last = arr[arr.length - 1] + if (!last) arr.push(node) + // "there is one chunk between each pair of child elements" + else if (isTextNode(node)) { + if (Array.isArray(last)) last.push(node) + else if (isTextNode(last)) arr[arr.length - 1] = [last, node] + else arr.push(node) + } else { + if (isElementNode(last)) arr.push(null, node) + else arr.push(node) + } + return arr + }, []) + // "the first chunk is located before the first child element" + if (isElementNode(nodes[0])) nodes.unshift('first') + // "the last chunk is located after the last child element" + if (isElementNode(nodes[nodes.length - 1])) nodes.push('last') + // "'virtual' elements" + nodes.unshift('before') // "0 is a valid index" + nodes.push('after') // "n+2 is a valid index" + return nodes +} + +const partsToNode = (node, parts, filter) => { + const { id } = parts[parts.length - 1] + if (id) { + const el = node.ownerDocument.getElementById(id) + if (el) return { node: el, offset: 0 } + } + for (const { index } of parts) { + const newNode = node ? indexChildNodes(node, filter)[index] : null + // handle non-existent nodes + if (newNode === 'first') return { node: node.firstChild ?? node } + if (newNode === 'last') return { node: node.lastChild ?? node } + if (newNode === 'before') return { node, before: true } + if (newNode === 'after') return { node, after: true } + node = newNode + } + const { offset } = parts[parts.length - 1] + if (!Array.isArray(node)) return { node, offset } + // get underlying text node and offset from the chunk + let sum = 0 + for (const n of node) { + const { length } = n.nodeValue + if (sum + length >= offset) return { node: n, offset: offset - sum } + sum += length + } +} + +const nodeToParts = (node, offset, filter) => { + const { id } = node + // A cfi-skip wrapper is invisible to CFI, so index this node within the + // wrapper's nearest non-skip ancestor — where rawChildNodes has hoisted it — + // rather than within the wrapper. Otherwise its index would be computed + // relative to the wrapper and not match the same node without the wrapper. + let parentNode = node.parentNode + while (parentNode && isSkipNode(parentNode)) parentNode = parentNode.parentNode + const indexed = indexChildNodes(parentNode, filter) + const index = indexed.findIndex(x => + Array.isArray(x) ? x.some(x => x === node) : x === node) + // adjust offset as if merging the text nodes in the chunk + const chunk = indexed[index] + if (Array.isArray(chunk)) { + let sum = 0 + for (const x of chunk) { + if (x === node) { + sum += offset + break + } else sum += x.nodeValue.length + } + offset = sum + } + const part = { id, index, offset } + return (parentNode !== node.ownerDocument.documentElement + ? nodeToParts(parentNode, null, filter).concat(part) : [part]) + // remove ignored nodes + .filter(x => x.index !== -1) +} + +export const fromRange = (range, filter) => { + const { startContainer, startOffset, endContainer, endOffset } = range + const start = nodeToParts(startContainer, startOffset, filter) + if (range.collapsed) return toString([start]) + const end = nodeToParts(endContainer, endOffset, filter) + return buildRange([start], [end]) +} + +export const toRange = (doc, parts, filter) => { + try { + const startParts = collapse(parts) + const endParts = collapse(parts, true) + + const root = doc.documentElement + const start = partsToNode(root, startParts[0], filter) + const end = partsToNode(root, endParts[0], filter) + + if (!start?.node || !end?.node) return null + + const range = doc.createRange() + + if (start.before) range.setStartBefore(start.node) + else if (start.after) range.setStartAfter(start.node) + else range.setStart(start.node, start.offset) + + if (end.before) range.setEndBefore(end.node) + else if (end.after) range.setEndAfter(end.node) + else range.setEnd(end.node, end.offset) + return range + } catch { + return null + } +} + +// faster way of getting CFIs for sorted elements in a single parent +export const fromElements = elements => { + const results = [] + const { parentNode } = elements[0] + const parts = nodeToParts(parentNode) + for (const [index, node] of indexChildNodes(parentNode).entries()) { + const el = elements[results.length] + if (node === el) + results.push(toString([parts.concat({ id: el.id, index })])) + } + return results +} + +export const toElement = (doc, parts) => + partsToNode(doc.documentElement, collapse(parts)).node + +// turn indices into standard CFIs when you don't have an actual package document +export const fake = { + fromIndex: index => wrap(`/6/${(index + 1) * 2}`), + toIndex: parts => parts?.at(-1).index / 2 - 1, +} + +// get CFI from Calibre bookmarks +// see https://github.com/johnfactotum/foliate/issues/849 +export const fromCalibrePos = pos => { + const [parts] = parse(pos) + const item = parts.shift() + parts.shift() + return toString([[{ index: 6 }, item], parts]) +} +export const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => { + const pre = fake.fromIndex(spine_index) + '!' + return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2)) +} diff --git a/frontend/src/lib/vendor/foliate-js/fb2.js b/frontend/src/lib/vendor/foliate-js/fb2.js new file mode 100644 index 0000000..31ec453 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/fb2.js @@ -0,0 +1,356 @@ +const normalizeWhitespace = str => str ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') : '' +const getElementText = el => normalizeWhitespace(el?.textContent) + +const NS = { + XLINK: 'http://www.w3.org/1999/xlink', + EPUB: 'http://www.idpf.org/2007/ops', +} + +const MIME = { + XML: 'application/xml', + XHTML: 'application/xhtml+xml', +} + +const STYLE = { + 'strong': ['strong', 'self'], + 'emphasis': ['em', 'self'], + 'style': ['span', 'self'], + 'a': 'anchor', + 'strikethrough': ['s', 'self'], + 'sub': ['sub', 'self'], + 'sup': ['sup', 'self'], + 'code': ['code', 'self'], + 'image': 'image', +} + +const TABLE = { + 'tr': ['tr', { + 'th': ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + 'td': ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + }, ['align']], +} + +const POEM = { + 'epigraph': ['blockquote'], + 'subtitle': ['h2', STYLE], + 'text-author': ['p', STYLE], + 'date': ['p', STYLE], + 'stanza': ['div', 'self'], + 'v': ['div', STYLE], +} + +const SECTION = { + 'title': ['header', { + 'p': ['h1', STYLE], + 'empty-line': ['br'], + }], + 'epigraph': ['blockquote', 'self'], + 'image': 'image', + 'annotation': ['aside'], + 'section': ['section', 'self'], + 'p': ['p', STYLE], + 'poem': ['blockquote', POEM], + 'subtitle': ['h2', STYLE], + 'cite': ['blockquote', 'self'], + 'empty-line': ['br'], + 'table': ['table', TABLE], + 'text-author': ['p', STYLE], +} +POEM['epigraph'].push(SECTION) + +const BODY = { + 'image': 'image', + 'title': ['section', { + 'p': ['h1', STYLE], + 'empty-line': ['br'], + }], + 'epigraph': ['section', SECTION], + 'section': ['section', SECTION], +} + +class FB2Converter { + constructor(fb2) { + this.fb2 = fb2 + this.doc = document.implementation.createDocument(NS.XHTML, 'html') + // use this instead of `getElementById` to allow images like + // `` + this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'), + el => [el.id, el])) + } + getImageSrc(el) { + const href = el.getAttributeNS(NS.XLINK, 'href') + if (!href) return 'data:,' + const [, id] = href.split('#') + if (!id) return href + const bin = this.bins.get(id) + return bin + ? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}` + : href + } + image(node) { + const el = this.doc.createElement('img') + el.alt = node.getAttribute('alt') + el.title = node.getAttribute('title') + el.setAttribute('src', this.getImageSrc(node)) + return el + } + anchor(node) { + const el = this.convert(node, { 'a': ['a', STYLE] }) + el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href')) + if (node.getAttribute('type') === 'note') + el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref') + return el + } + convert(node, def) { + // not an element; return text content + if (node.nodeType === 3) return this.doc.createTextNode(node.textContent) + if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent) + if (node.nodeType === 8) return this.doc.createComment(node.textContent) + + const d = def?.[node.nodeName] + if (!d) return null + if (typeof d === 'string') return this[d](node) + + const [name, opts, attrs] = d + const el = this.doc.createElement(name) + + // copy the ID, and set class name from original element name + if (node.id) el.id = node.id + el.classList.add(node.nodeName) + + // copy attributes + if (Array.isArray(attrs)) for (const attr of attrs) { + const value = node.getAttribute(attr) + if (value) el.setAttribute(attr, value) + } + + // process child elements recursively + const childDef = opts === 'self' ? def : opts + let child = node.firstChild + while (child) { + const childEl = this.convert(child, childDef) + if (childEl) el.append(childEl) + child = child.nextSibling + } + return el + } +} + +const parseXML = async blob => { + const buffer = await blob.arrayBuffer() + const str = new TextDecoder('utf-8').decode(buffer) + const parser = new DOMParser() + const doc = parser.parseFromString(str, MIME.XML) + const encoding = doc.xmlEncoding + // `Document.xmlEncoding` is deprecated, and already removed in Firefox + // so parse the XML declaration manually + || str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1] + if (encoding && encoding.toLowerCase() !== 'utf-8') { + const str = new TextDecoder(encoding).decode(buffer) + return parser.parseFromString(str, MIME.XML) + } + return doc +} + +const style = URL.createObjectURL(new Blob([` +@namespace epub "http://www.idpf.org/2007/ops"; +body > img, section > img { + display: block; + margin: auto; +} +.title h1 { + text-align: center; +} +body > section > .title, body.notesBodyType > .title { + margin: 3em 0; +} +body.notesBodyType > section .title h1 { + text-align: start; +} +body.notesBodyType > section .title { + margin: 1em 0; +} +p { + text-indent: 1em; + margin: 0; +} +:not(p) + p, p:first-child { + text-indent: 0; +} +.stanza { + text-indent: 0; + margin: 1em 0; +} +.text-author, .date { + text-align: end; +} +.text-author:before { + content: "—"; +} +table { + border-collapse: collapse; +} +td, th { + padding: .25em; +} +a[epub|type~="noteref"] { + font-size: .75em; + vertical-align: super; +} +body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph { + margin: 3em 0; +} +`], { type: 'text/css' })) + +const template = html => ` + + + ${html} +` + +// name of custom ID attribute for TOC items +const dataID = 'data-foliate-id' + +export const makeFB2 = async blob => { + const book = {} + const doc = await parseXML(blob) + const converter = new FB2Converter(doc) + + const $ = x => doc.querySelector(x) + const $$ = x => [...doc.querySelectorAll(x)] + const getPerson = el => { + const nick = getElementText(el.querySelector('nickname')) + if (nick) return nick + const first = getElementText(el.querySelector('first-name')) + const middle = getElementText(el.querySelector('middle-name')) + const last = getElementText(el.querySelector('last-name')) + const name = [first, middle, last].filter(x => x).join(' ') + const sortAs = last + ? [last, [first, middle].filter(x => x).join(' ')].join(', ') + : null + return { name, sortAs } + } + const getDate = el => el?.getAttribute('value') ?? getElementText(el) + const annotation = $('title-info annotation') + // FB2 stores series info as `` in title-info + const series = $$('title-info > sequence') + .map(el => ({ + name: normalizeWhitespace(el.getAttribute('name')), + position: el.getAttribute('number') || undefined, + })) + .filter(x => x.name) + book.metadata = { + title: getElementText($('title-info book-title')), + identifier: getElementText($('document-info id')), + language: getElementText($('title-info lang')), + author: $$('title-info author').map(getPerson), + translator: $$('title-info translator').map(getPerson), + contributor: $$('document-info author').map(getPerson) + // techincially the program probably shouldn't get the `bkp` role + // but it has been so used by calibre, so ¯\_(ツ)_/¯ + .concat($$('document-info program-used').map(getElementText)) + .map(x => Object.assign(typeof x === 'string' ? { name: x } : x, + { role: 'bkp' })), + belongsTo: series.length ? { series } : undefined, + publisher: getElementText($('publish-info publisher')), + published: getDate($('title-info date')), + modified: getDate($('document-info date')), + description: annotation ? converter.convert(annotation, + { annotation: ['div', SECTION] }).innerHTML : null, + subject: $$('title-info genre').map(getElementText), + } + if ($('coverpage image')) { + const src = converter.getImageSrc($('coverpage image')) + book.getCover = () => fetch(src).then(res => res.blob()) + } else book.getCover = () => null + + // get convert each body + const bodyData = Array.from(doc.querySelectorAll('body'), body => { + const converted = converter.convert(body, { body: ['body', BODY] }) + return [Array.from(converted.children, el => { + // get list of IDs in the section + const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id) + return { el, ids } + }), converted] + }) + + const urls = [] + const sectionData = bodyData[0][0] + // make a separate section for each section in the first body + .map(({ el, ids }, id) => { + // set up titles for TOC + const titles = Array.from( + el.querySelectorAll(':scope > section > .title'), + (el, index) => { + el.setAttribute(dataID, index) + const section = el.closest('section') + const size = new TextEncoder().encode(section.innerHTML).length + - Array.from(section.querySelectorAll('[src]')) + .reduce((sum, el) => sum + (el.getAttribute('src')?.length ?? 0), 0) + return { title: getElementText(el), index, size, href: `${id}#${index}` } + }) + return { ids, titles, el } + }) + // for additional bodies, only make one section for each body + .concat(bodyData.slice(1).map(([sections, body]) => { + const ids = sections.map(s => s.ids).flat() + body.classList.add('notesBodyType') + return { ids, el: body, linear: 'no' } + })) + .map(({ ids, titles, el, linear }) => { + const str = template(el.outerHTML) + const blob = new Blob([str], { type: MIME.XHTML }) + const url = URL.createObjectURL(blob) + urls.push(url) + const title = normalizeWhitespace( + el.querySelector('.title, .subtitle, p')?.textContent + ?? (el.classList.contains('title') ? el.textContent : '')) + return { + ids, title, titles, load: () => url, + createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML), + // doo't count image data as it'd skew the size too much + size: blob.size - Array.from(el.querySelectorAll('[src]'), + el => el.getAttribute('src')?.length ?? 0) + .reduce((a, b) => a + b, 0), + linear, + } + }) + + const idMap = new Map() + book.sections = sectionData.map((section, index) => { + const { ids, load, createDocument, size, linear, titles } = section + for (const id of ids) if (id) idMap.set(id, index) + return { id: index, load, createDocument, size, linear, subitems: titles } + }) + + book.toc = sectionData.map(({ title, titles }, index) => { + const id = index.toString() + return { + label: title, + href: id, + subitems: titles?.length ? titles.map(({ title, index }) => ({ + label: title, + href: `${id}#${index}`, + })) : null, + } + }).filter(item => item) + + book.resolveHref = href => { + const [a, b] = href.split('#') + return a + // the link is from the TOC + ? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) } + // link from within the page + : { index: idMap.get(b), anchor: doc => doc.getElementById(b) } + } + book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? [] + book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`) + + book.destroy = () => { + for (const url of urls) URL.revokeObjectURL(url) + } + return book +} diff --git a/frontend/src/lib/vendor/foliate-js/fixed-layout.js b/frontend/src/lib/vendor/foliate-js/fixed-layout.js new file mode 100644 index 0000000..b76a7bc --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/fixed-layout.js @@ -0,0 +1,1815 @@ +import 'construct-style-sheets-polyfill' + +const parseViewport = str => str + ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid + ?.filter(x => x) + ?.map(x => x.split('=').map(x => x.trim())) + +const getViewport = (doc, viewport) => { + // use `viewBox` for SVG + if (doc.documentElement.localName === 'svg') { + const [, , width, height] = doc.documentElement + .getAttribute('viewBox')?.split(/\s/) ?? [] + return { width, height } + } + + // get `viewport` `meta` element + const meta = parseViewport(doc.querySelector('meta[name="viewport"]') + ?.getAttribute('content')) + if (meta) return Object.fromEntries(meta) + + // fallback to book's viewport + if (typeof viewport === 'string') return parseViewport(viewport) + if (viewport?.width && viewport.height) return viewport + + // if no viewport (possibly with image directly in spine), get image size + const img = doc.querySelector('img') + if (img) return { width: img.naturalWidth, height: img.naturalHeight } + + // just show *something*, i guess... + console.warn(new Error('Missing viewport properties')) + return { width: 1000, height: 2000 } +} + +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) + +export const captureScrollModeAnchor = (pages, scrollPos, fallbackIndex = -1) => { + const fallbackPage = pages.find(page => page.index === fallbackIndex) + const currentPage = pages.find(page => + page.size > 0 + && scrollPos >= page.start + && scrollPos < page.start + page.size) + ?? fallbackPage + ?? pages.find(page => page.size > 0) + + if (!currentPage) return null + return { + index: currentPage.index, + fraction: currentPage.size > 0 + ? clamp((scrollPos - currentPage.start) / currentPage.size, 0, 1) + : 0, + scrollPos, + } +} + +export const restoreScrollModeAnchor = (pages, anchor, maxScrollPos) => { + if (!anchor) return 0 + const page = pages.find(candidate => candidate.index === anchor.index) + if (!page || page.size <= 0) return clamp(anchor.scrollPos, 0, maxScrollPos) + return clamp(page.start + page.size * anchor.fraction, 0, maxScrollPos) +} + +export const scrollGapToCss = (value) => { + const n = parseFloat(value) + return Number.isFinite(n) && n >= 0 ? `${n}px` : null +} + +// Decide which scroll-mode pages to begin loading and which to evict, given the +// reader's current page and each page's load state. `visible` is set by the +// IntersectionObserver (true while the page sits within the widened preload +// margin). Visible idle pages closest to the reader load first, bounded by how +// many loads may run at once; loaded pages farthest from the reader are evicted +// once over the in-memory cap, but a visible page is never torn out from under +// the reader. Prioritising the nearest page and bounding concurrency keeps a +// fast fling from kicking off a full-resolution canvas render for every page it +// flies past — that thrashes the main thread and spikes WebView memory +// (readest#4795), the same pressure the PDF range-read throttle guards against +// (readest#3470). +export const planScrollModePages = ({ + pages, currentIndex, maxLoaded, maxConcurrent, loadingCount, +}) => { + const dist = page => Math.abs(page.index - currentIndex) + + const budget = Math.max(0, maxConcurrent - loadingCount) + const load = budget === 0 ? [] : pages + .filter(page => page.visible && page.state === 'idle') + .sort((a, b) => dist(a) - dist(b)) + .slice(0, budget) + .map(page => page.index) + + const loaded = pages.filter(page => page.state === 'loaded') + const evict = loaded.length <= maxLoaded ? [] : loaded + .filter(page => !page.visible) + .sort((a, b) => dist(b) - dist(a)) + .slice(0, loaded.length - maxLoaded) + .map(page => page.index) + + return { load, evict } +} + +// Live CSS transform for a scroll-mode pinch gesture. Scroll mode has no single +// spread frame to scale, so the whole scroll container is scaled for immediate +// visual feedback while the fingers move (instead of only re-rendering on +// release). The scale is anchored at the centre of the viewport (in the +// container's coordinate space); the post-pinch re-render then scrolls the +// centre page back to the rect it occupied in this preview (see +// #restorePinchAnchor), so the committed zoom lands without a jump. +export const computeScrollPinchTransform = ({ + ratio, scrollLeft, scrollTop, viewportWidth, viewportHeight, +}) => ({ + transform: `scale(${ratio})`, + transformOrigin: `${scrollLeft + viewportWidth / 2}px ${scrollTop + viewportHeight / 2}px`, +}) + +// Scroll offsets to apply to the host (`overflow:auto`) after rendering a +// paginated page. Horizontal is always re-centered so the page sits in the +// middle of the viewport. Vertical is reset to the top only on a page turn: +// a tall fit-width page overflows the host vertically, and without the reset the +// freshly-shown page inherits the previous page's offset and opens scrolled to +// the bottom (#4683). Plain re-renders (resize, zoom, theme) keep the reader's +// current vertical position within the page. +export const computePaginatedScroll = ({ elementWidth, containerWidth, scrollTop, pageTurn }) => ({ + scrollLeft: (elementWidth - containerWidth) / 2, + scrollTop: pageTurn ? 0 : scrollTop, +}) + +// Translate a vertical wheel tick into a horizontal scroll delta for +// horizontal scroll mode (pdf.js behavior, readest#4995). Returns null when +// the tick belongs to native scrolling instead: vertical mode, pinch zoom +// (ctrl+wheel), horizontal-dominant trackpad pans, or a strip with vertical +// overflow to consume (a zoomed page pans vertically first). Translating is +// safe with respect to the readest#4727 double-scroll: with no vertical +// overflow the browser cannot natively consume a vertical delta, so the +// translated scroll cannot stack on a native one. +export const computeScrollWheelDelta = ({ + deltaX, deltaY, ctrlKey, horizontal, rtl, verticalOverflow, +}) => { + if (!horizontal || ctrlKey || verticalOverflow) return null + if (Math.abs(deltaY) <= Math.abs(deltaX)) return null + return { left: rtl ? -deltaY : deltaY } +} + +// Visual shift (CSS px) to apply to the right page of a two-page spread to hide +// the one-pixel white spine seam (#4857). The two page iframes are independent +// compositor layers, each scaled by a (usually non-integer) factor. At a +// fractional devicePixelRatio the spine between them lands on a fractional +// device pixel, so each layer's edge there is anti-aliased against transparency +// and the reader background bleeds through as a thin white seam. Pulling the +// top-most (right) page onto the left by exactly one device pixel makes each +// soft edge sit over the neighbour's opaque content instead of the background. +// Returns 0 for layouts with no touching spine (single/centred/portrait page or +// a blank-padded slot). The pages stay adjacent at every zoom, so the overlap +// applies at sub-100% zoom too. +export const computeSpreadSpineOverlap = ({ + center = false, portrait = false, leftBlank = false, rightBlank = false, + devicePixelRatio = 1, +} = {}) => { + if (center || portrait || leftBlank || rightBlank) return 0 + return -1 / (devicePixelRatio || 1) +} + +// Inline margins for the two pages of a spread. In landscape both pages are +// shown and pushed together at the spine: the left page hugs the right edge +// (`margin-inline-start: auto`) and the right page hugs the left edge +// (`margin-inline-end: auto`), so the pair sits centred. In portrait only one +// page of the spread is shown; a one-sided auto margin would strand that lone +// page in one half of the viewport whenever it is narrower than the viewport +// (readest#4984), so both margins are auto to centre it. Both inline margins are +// always set explicitly (the opposite side cleared to '') so a re-render after +// an orientation change fully overwrites the previous layout's margins — frames +// are re-styled in place, not recreated, on rotation. +export const computeSpreadInlineMargins = (portrait) => portrait + ? { + left: { marginInlineStart: 'auto', marginInlineEnd: 'auto' }, + right: { marginInlineStart: 'auto', marginInlineEnd: 'auto' }, + } + : { + left: { marginInlineStart: 'auto', marginInlineEnd: '' }, + right: { marginInlineStart: '', marginInlineEnd: 'auto' }, + } + +// Align the SVG overlayer's coord system with the iframe's unscaled content. +// When the iframe is visually scaled via CSS transform (non-PDF path), +// getClientRects() inside the iframe returns positions in the iframe's native +// coord system, so the SVG must use a matching viewBox to scale rects to the +// on-screen size. PDFs re-render their text layer at scale via onZoom, so +// rects are already in scaled coords and no viewBox is needed. +export const applyOverlayerViewBox = (frame, overlayer) => { + if (!overlayer?.element) return + const el = overlayer.element + if (frame?.onZoom) { + el.removeAttribute('viewBox') + el.removeAttribute('preserveAspectRatio') + } else { + const w = frame?.width ?? frame?.vpWidth + const h = frame?.height ?? frame?.vpHeight + if (w && h) { + el.setAttribute('viewBox', `0 0 ${w} ${h}`) + el.setAttribute('preserveAspectRatio', 'none') + } + } +} + +export class FixedLayout extends HTMLElement { + static observedAttributes = ['zoom', 'scale-factor', 'spread', 'flow', 'scroll-gap', 'scroll-direction'] + #root = this.attachShadow({ mode: 'open' }) + #observer = new ResizeObserver(() => this.#render()) + #spreads + #index = -1 + defaultViewport + spread + #portrait = false + #left + #right + #center + #side + #zoom + #scaleFactor = 1.0 + #totalScaleFactor = 1.0 + #scrollLocked = false + #isOverflowX = false + #isOverflowY = false + #preloadCache = new Map() + #prerenderedSpreads = new Map() + #spreadAccessTime = new Map() + #maxConcurrentPreloads = 1 + #numPrerenderedSpreads = 1 + #maxCachedSpreads = 2 + #overlayers = new Map() + #pageColors = {} + #preloadQueue = [] + #activePreloads = 0 + // Scroll mode fields + #scrollMode = false + #scrollHorizontal = false + #scrollPages = [] + #scrollObserver = null + #scrollContainer = null + #scrollLoadGen = new Map() + // Live rendered-canvas cap. Each PDF page canvas is sized to the on-screen + // page box × devicePixelRatio (~7 MB at dpr 3), so this is the dominant + // memory ceiling — keep it just above the visible window plus preload lead. + #scrollMaxLoaded = 12 + // Cap on concurrent page loads. A fast fling crosses many pages; without a + // bound it would start a full-resolution render for every one, thrashing the + // main thread and spiking memory. Nearest-to-viewport pages load first. + #scrollMaxConcurrent = 3 + #scrollLoadingCount = 0 + #scrollIdleTimer = null + #scrollCurrentIndex = -1 + // True while the host is actively scrolling. Pages load interactive only + // when idle so a page that finishes loading mid-scroll can't flip its iframe + // interactive and let its own pointer handlers hijack the native scroll. + #scrolling = false + // True while a pinch gesture is live. Suppresses page load/eviction so the + // placeholder layout (and thus scrollTop) can't drift mid-pinch, which would + // make the live preview and the committed zoom land in different places. + #pinching = false + // On-screen rect of the page under the viewport centre, captured from the + // live (still-transformed) preview at pinch end ({ index, top, left }). The + // commit re-render scrolls that page back to this exact rect, so the zoom + // lands where the preview showed it. Using the real getBoundingClientRect + // (not fraction maths) sidesteps gap/page-boundary and header-offset errors. + #pinchAnchor = null + #captureCenterPageRect() { + const hostRect = this.getBoundingClientRect() + const c = this.#scrollHorizontal + ? hostRect.left + this.clientWidth / 2 + : hostRect.top + this.clientHeight / 2 + for (const page of this.#scrollPages) { + const rect = page.el.getBoundingClientRect() + const lo = this.#scrollHorizontal ? rect.left : rect.top + const hi = this.#scrollHorizontal ? rect.right : rect.bottom + if (lo <= c && hi > c) { + return { index: page.index, top: rect.top, left: rect.left } + } + } + return null + } + // Scroll so the captured page sits back at its pre-commit on-screen rect. + #restorePinchAnchor(anchor) { + const page = this.#scrollPages.find(p => p.index === anchor.index) + if (!page) return + const rect = page.el.getBoundingClientRect() + const maxTop = Math.max(0, this.scrollHeight - this.clientHeight) + const maxLeft = Math.max(0, this.scrollWidth - this.clientWidth) + this.scrollTop = clamp(this.scrollTop + (rect.top - anchor.top), 0, maxTop) + this.scrollLeft = clamp(this.scrollLeft + (rect.left - anchor.left), 0, maxLeft) + } + #getScrollModePageMetrics() { + return this.#scrollPages.map(page => ({ + index: page.index, + start: this.#scrollHorizontal ? page.el.offsetLeft : page.el.offsetTop, + size: this.#scrollHorizontal ? page.el.offsetWidth : page.el.offsetHeight, + })) + } + #captureScrollModeAnchor() { + if (!this.#scrollPages.length) return null + const fallbackIndex = this.#scrollCurrentIndex >= 0 + ? this.#scrollCurrentIndex : this.#getScrollIndex() + return captureScrollModeAnchor( + this.#getScrollModePageMetrics(), + this.#scrollContentPos(), + fallbackIndex, + ) + } + #restoreScrollModeAnchor(anchor) { + if (!anchor || !this.#scrollPages.length) return + const maxScrollPos = Math.max(0, this.#scrollTotalLength() - this.#scrollViewLength()) + const restoredPos = restoreScrollModeAnchor( + this.#getScrollModePageMetrics(), + anchor, + maxScrollPos, + ) + // Only write when the position actually moves. Assigning scrollLeft/Top + // unconditionally aborts any in-progress `behavior: 'smooth'` scroll + // (e.g. a next()/prev() page turn) even when the value lands on the + // exact spot the animation is already at — and #render()'s mandatory + // initial ResizeObserver callback can land in the same tick as a page + // turn requested right after open(), silently freezing it. + if (Math.abs(restoredPos - this.#scrollContentPos()) > 0.5) { + this.#setScrollContentPos(restoredPos) + } + this.#scrollCurrentIndex = anchor.index + } + // Length of the viewport along the scroll axis. + #scrollViewLength() { + return this.#scrollHorizontal ? this.clientWidth : this.clientHeight + } + // Total scrollable length along the scroll axis. + #scrollTotalLength() { + return this.#scrollHorizontal ? this.scrollWidth : this.scrollHeight + } + // Position of the viewport's leading edge in content coordinates (0 = the + // content's top/left edge). RTL horizontal scrolls into negative + // scrollLeft (direction: rtl container), so shift by the max offset to + // stay in the same coordinate space as offsetLeft page metrics. + #scrollContentPos() { + if (!this.#scrollHorizontal) return this.scrollTop + return this.rtl + ? this.scrollWidth - this.clientWidth + this.scrollLeft + : this.scrollLeft + } + #setScrollContentPos(pos) { + if (!this.#scrollHorizontal) { + this.scrollTop = pos + return + } + this.scrollLeft = this.rtl ? pos - (this.scrollWidth - this.clientWidth) : pos + } + // Distance read from the book start along the reading direction. Equals + // content position except for RTL horizontal, where reading starts at the + // right edge and progresses into negative scrollLeft. + #scrollProgression() { + if (!this.#scrollHorizontal) return this.scrollTop + return this.rtl ? -this.scrollLeft : this.scrollLeft + } + constructor() { + super() + + const sheet = new CSSStyleSheet() + this.#root.adoptedStyleSheets = [sheet] + sheet.replaceSync(`:host { + width: 100%; + height: 100%; + display: flex; + justify-content: flex-start; + align-items: center; + overflow: auto; + } + @supports (justify-content: safe center) { + :host { + justify-content: safe center; + } + } + :host([flow="scrolled"]) { + display: block; + overflow-y: auto; + /* auto (not hidden) so a zoomed page wider than the viewport can be + panned horizontally; collapses to no scrollbar when pages fit. */ + overflow-x: auto; + /* Keep one-finger pan (native scroll) but reserve two-finger + gestures for JS so a pinch is delivered instead of triggering the + browser's own pinch-zoom or being swallowed by the scroller. */ + touch-action: pan-x pan-y; + } + :host([flow="scrolled"]) .scroll-page { + touch-action: pan-x pan-y; + } + :host([flow="scrolled"]) .scroll-container { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100%; + /* Grow to the widest (zoomed) page so the host can scroll across its + full width, but stay at least viewport-wide so unzoomed pages stay + centered. Without max-content the centered overflow is unreachable + (the flexbox centered-overflow scroll trap). */ + width: max-content; + min-width: 100%; + background-color: var(--scroll-bg-color); + background-opacity: var(--scroll-bg-opacity); + } + :host([flow="scrolled"]) .scroll-page { + position: relative; + flex-shrink: 0; + overflow: hidden; + /* Scale the gap with the zoom so the committed layout matches the + pinch preview, whose transform scales the whole container (gaps + included). Without this the gap snaps back to a fixed px on + release and the pages shift. */ + margin: calc(var(--scroll-page-gap, 4px) * var(--scroll-zoom, 1)) 0; + } + :host([flow="scrolled"]) .scroll-page iframe { + pointer-events: none; + } + :host([flow="scrolled"][scroll-direction="horizontal"]) .scroll-container { + flex-direction: row; + height: max-content; + min-height: 100%; + } + :host([flow="scrolled"][scroll-direction="horizontal"]) .scroll-page { + margin: 0 calc(var(--scroll-page-gap, 4px) * var(--scroll-zoom, 1)); + }`) + + this.#observer.observe(this) + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'zoom': + this.#zoom = value !== 'fit-width' && value !== 'fit-page' + ? parseFloat(value) : value + this.#render() + break + case 'scale-factor': + this.#scaleFactor = parseFloat(value) / 100 + this.#render() + break + case 'spread': + this.#respread(value) + break + case 'flow': + if (value === 'scrolled' && !this.#scrollMode) { + // Capture index from paginated mode BEFORE setting scroll flag + const savedIndex = this.index + this.#scrollMode = true + if (this.book) this.#initScrollMode(savedIndex) + } else if (value !== 'scrolled' && this.#scrollMode) { + this.#destroyScrollMode() + this.#scrollMode = false + this.#render() + } + break + case 'scroll-gap': { + const css = scrollGapToCss(value) + const anchor = this.#scrollMode ? this.#captureScrollModeAnchor() : null + if (css === null) this.style.removeProperty('--scroll-page-gap') + else this.style.setProperty('--scroll-page-gap', css) + if (anchor) this.#restoreScrollModeAnchor(anchor) + break + } + case 'scroll-direction': { + const horizontal = value === 'horizontal' + if (horizontal === this.#scrollHorizontal) break + this.#scrollHorizontal = horizontal + if (this.#scrollMode && this.book) { + // Rebuild the strip on the new axis, preserving the page. + const savedIndex = this.#scrollCurrentIndex >= 0 ? this.#scrollCurrentIndex : 0 + this.#destroyScrollMode(false) + this.#initScrollMode(savedIndex) + } + break + } + } + } + async #createFrame({ index, src: srcOption, detached = false }) { + const srcOptionIsString = typeof srcOption === 'string' + const src = srcOptionIsString ? srcOption : srcOption?.src + const data = srcOptionIsString ? null : srcOption?.data + const onZoom = srcOptionIsString ? null : srcOption?.onZoom + const element = document.createElement('div') + element.setAttribute('dir', 'ltr') + element.style.position = 'relative' + const iframe = document.createElement('iframe') + element.append(iframe) + Object.assign(iframe.style, { + border: '0', + display: 'none', + overflow: 'hidden', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + iframe.setAttribute('scrolling', 'no') + iframe.setAttribute('part', 'filter') + this.#root.append(element) + + if (detached) { + Object.assign(element.style, { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + }) + } + + if (!src) return { blank: true, element, iframe } + return new Promise(resolve => { + iframe.addEventListener('load', () => { + const doc = iframe.contentDocument + iframe.dataset.sectionIndex = index + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) + const { width, height } = getViewport(doc, this.defaultViewport) + resolve({ + element, iframe, + width: parseFloat(width), + height: parseFloat(height), + onZoom, + detached, + }) + }, { once: true }) + if (data) { + iframe.srcdoc = data + } else { + iframe.src = src + } + }) + } + #render(side = this.#side, pageTurn = false) { + if (this.#scrollMode) { + this.#renderScrollMode() + return [] + } + if (!side) return [] + const left = this.#left ?? {} + const right = this.#center ?? this.#right ?? {} + const target = side === 'left' ? left : right + const { width, height } = this.getBoundingClientRect() + // for unfolded devices with slightly taller height than width also use landscape layout + const portrait = this.spread !== 'both' && this.spread !== 'portrait' + && height > width * 1.2 + this.#portrait = portrait + const blankWidth = left.width ?? right.width ?? 0 + const blankHeight = left.height ?? right.height ?? 0 + + let scale = typeof this.#zoom === 'number' && !isNaN(this.#zoom) + ? this.#zoom + : (this.#zoom === 'fit-width' + ? (portrait || this.#center + ? width / (target.width ?? blankWidth) + : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth))) + : (portrait || this.#center + ? Math.min( + width / (target.width ?? blankWidth), + height / (target.height ?? blankHeight)) + : Math.min( + width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)), + height / Math.max( + left.height ?? blankHeight, + right.height ?? blankHeight))) + ) || 1 + + scale *= this.#scaleFactor + this.#totalScaleFactor = scale + + const renderPromises = [] + const transform = ({frame, styles}) => { + let { element, iframe, width, height, blank, onZoom } = frame + if (!iframe) return + if (onZoom) { + const p = onZoom({ doc: frame.iframe.contentDocument, scale, pageColors: this.#pageColors }) + if (p?.then) { + // onZoom (e.g. pdf.js) may rebuild the text layer DOM, + // invalidating Range objects stored in the overlayer. After + // the rebuild, re-emit create-overlayer so listeners can + // re-anchor annotations against the fresh DOM. + const refreshed = p.then(() => this.#refreshOverlayerForFrame(frame)) + renderPromises.push(refreshed) + } + } + const iframeScale = onZoom ? scale : 1 + const zoomedOut = this.#scaleFactor < 1.0 + // Centering a zoomed-out page inside its box only works for the PDF + // path, whose iframe is natively sized to the (scaled) box. Non-PDF + // fixed layout keeps the iframe at its native size and shrinks it + // with `transform: scale`, so flex-centering the un-scaled iframe + // pushes it out of view and blanks the page (#4857). Keep those in + // normal block flow at every zoom. + const centerInBox = zoomedOut && onZoom + Object.assign(iframe.style, { + width: `${width * iframeScale}px`, + height: `${height * iframeScale}px`, + transform: onZoom ? 'none' : `scale(${scale})`, + transformOrigin: 'top left', + display: blank ? 'none' : 'block', + }) + Object.assign(element.style, { + width: `${(width ?? blankWidth) * scale}px`, + height: `${(height ?? blankHeight) * scale}px`, + flexShrink: '0', + display: centerInBox ? 'flex' : 'block', + marginBlock: centerInBox ? undefined : 'auto', + alignItems: centerInBox ? 'center' : undefined, + justifyContent: centerInBox ? 'center' : undefined, + ...styles, + }) + if (portrait && frame !== target) { + element.style.display = 'none' + } + + // position and redraw overlayer to match the scaled iframe + const sectionIndex = iframe.dataset.sectionIndex != null + ? parseInt(iframe.dataset.sectionIndex) : undefined + if (sectionIndex != null) { + const overlayer = this.#overlayers.get(sectionIndex) + if (overlayer) { + Object.assign(overlayer.element.style, { + position: 'absolute', + top: '0', + left: '0', + width: `${(width ?? blankWidth) * scale}px`, + height: `${(height ?? blankHeight) * scale}px`, + }) + applyOverlayerViewBox({ + onZoom, + width: width ?? blankWidth, + height: height ?? blankHeight, + }, overlayer) + overlayer.redraw() + } + } + + const container= element.parentNode?.host + if (!container) return + const containerWidth = container.clientWidth + const containerHeight = container.clientHeight + const { scrollLeft, scrollTop } = computePaginatedScroll({ + elementWidth: element.clientWidth, + containerWidth, + scrollTop: container.scrollTop, + pageTurn, + }) + container.scrollLeft = scrollLeft + container.scrollTop = scrollTop + + return { + width: element.clientWidth, + height: element.clientHeight, + containerWidth, + containerHeight, + } + } + if (this.#center) { + const dimensions = transform({frame: this.#center, styles: { marginInline: 'auto' }}) + if (!dimensions) return renderPromises + const {width, height, containerWidth, containerHeight} = dimensions + this.#isOverflowX = width > containerWidth + this.#isOverflowY = height > containerHeight + } else { + // Hide the 1px white spine seam on a two-page spread by overlapping + // the right page onto the left by one device pixel (#4857). Always + // set `transform` (to 'none' when not overlapping) so a stale shift + // from a previous render is cleared when the layout changes. + const overlapX = computeSpreadSpineOverlap({ + portrait, + leftBlank: Boolean(left.blank), + rightBlank: Boolean(right.blank), + devicePixelRatio: window.devicePixelRatio || 1, + }) + // In portrait only the target page is shown; centre it instead of + // hugging the spine, which would strand it in one half of the + // viewport (#4984). + const margins = computeSpreadInlineMargins(portrait) + const leftDimensions = transform({frame: left, styles: margins.left}) + const rightDimensions = transform({frame: right, styles: { + ...margins.right, + transform: overlapX ? `translateX(${overlapX}px)` : 'none', + }}) + if (!leftDimensions || !rightDimensions) return renderPromises + const {width: leftWidth, height: leftHeight, containerWidth, containerHeight} = leftDimensions + const {width: rightWidth, height: rightHeight} = rightDimensions + this.#isOverflowX = leftWidth + rightWidth > containerWidth + this.#isOverflowY = Math.max(leftHeight, rightHeight) > containerHeight + } + // A pinch commit overrides the default re-centring above: scroll the + // spread back to the on-screen rect it occupied in the live preview so + // the zoom doesn't jump (matters most when the page was scrolled within + // an overflowing zoom). See pinchEnd. + if (this.#pinchAnchor) { + const frame = this.#center ?? this.#left ?? this.#right + if (frame?.element) { + const b = frame.element.getBoundingClientRect() + const maxTop = Math.max(0, this.scrollHeight - this.clientHeight) + const maxLeft = Math.max(0, this.scrollWidth - this.clientWidth) + this.scrollTop = clamp(this.scrollTop + (b.top - this.#pinchAnchor.top), 0, maxTop) + this.scrollLeft = clamp(this.scrollLeft + (b.left - this.#pinchAnchor.left), 0, maxLeft) + } + this.#pinchAnchor = null + } + return renderPromises + } + async #showSpread({ left, right, center, side, spreadIndex }) { + this.#left = null + this.#right = null + this.#center = null + + const cacheKey = spreadIndex !== undefined ? `spread-${spreadIndex}` : null + const prerendered = cacheKey ? this.#prerenderedSpreads.get(cacheKey) : null + + if (prerendered) { + this.#spreadAccessTime.set(cacheKey, Date.now()) + if (prerendered.center) { + this.#center = prerendered.center + } else { + this.#left = prerendered.left + this.#right = prerendered.right + } + } else { + if (center) { + this.#center = await this.#createFrame(center) + if (cacheKey) { + this.#prerenderedSpreads.set(cacheKey, { center: this.#center }) + this.#spreadAccessTime.set(cacheKey, Date.now()) + } + } else { + this.#left = await this.#createFrame(left) + this.#right = await this.#createFrame(right) + if (cacheKey) { + this.#prerenderedSpreads.set(cacheKey, { left: this.#left, right: this.#right }) + this.#spreadAccessTime.set(cacheKey, Date.now()) + } + } + } + + this.#side = center ? 'center' : this.#left?.blank ? 'right' + : this.#right?.blank ? 'left' : side + const visibleFrames = center + ? [this.#center?.element] + : [this.#left?.element, this.#right?.element] + + Array.from(this.#root.children).forEach(child => { + const isVisible = visibleFrames.includes(child) + Object.assign(child.style, { + position: isVisible ? 'relative' : 'absolute', + visibility: isVisible ? 'visible' : 'hidden', + pointerEvents: isVisible ? 'auto' : 'none', + }) + }) + + // Render layout and await any async onZoom callbacks (e.g. PDF text + // layer rendering) so the document is fully populated before overlayers + // try to resolve CFIs against it. Pass pageTurn so a tall fit-width page + // starts at the top instead of inheriting the previous page's scroll. + const renderPromises = this.#render(this.#side, true) + if (renderPromises.length) await Promise.all(renderPromises) + + const showingFrames = center + ? [this.#center] + : [this.#left, this.#right] + for (const frame of showingFrames) { + if (!frame?.iframe) continue + const index = frame.iframe.dataset.sectionIndex != null + ? parseInt(frame.iframe.dataset.sectionIndex) : undefined + if (index != null && !this.#overlayers.has(index)) { + const doc = frame.iframe.contentDocument + if (doc) { + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc, index, + attach: overlayer => { + this.#overlayers.set(index, overlayer) + frame.element.append(overlayer.element) + applyOverlayerViewBox(frame, overlayer) + }, + }, + })) + } + } + } + } + #initScrollMode(targetIndex = 0) { + const currentIndex = targetIndex + + // Hide all paginated content + for (const child of Array.from(this.#root.children)) { + child.style.display = 'none' + } + + this.#scrollContainer = document.createElement('div') + this.#scrollContainer.className = 'scroll-container' + this.#root.append(this.#scrollContainer) + + // RTL books read right to left: direction rtl on the host (the + // scrolling element itself) is what puts scrollLeft into the browser's + // negative-scrollLeft RTL convention — that convention is keyed off the + // scrolling box's own computed direction, not a descendant's. It also + // lays the flex row from the right edge and makes the leftward overflow + // reachable (overflow only grows toward the inline-end side). The + // container inherits this. Page content stays LTR via the per-frame + // dir attribute. + this.style.direction = this.#scrollHorizontal && this.rtl ? 'rtl' : '' + + const sections = this.book.sections + const viewport = this.defaultViewport + const vw = viewport?.width ?? 1000 + const vh = viewport?.height ?? 1400 + this.#scrollPages = sections.map((section, i) => { + const el = document.createElement('div') + el.className = 'scroll-page' + el.dataset.index = i + this.#scrollContainer.append(el) + return { el, index: i, section, state: 'idle', visible: false, frame: null, vpWidth: vw, vpHeight: vh } + }) + + this.#renderScrollMode() + + // Scroll to target position BEFORE setting up the observer + // so only pages near the target are observed as intersecting + if (currentIndex >= 0 && currentIndex < this.#scrollPages.length) { + this.#scrollPages[currentIndex].el.scrollIntoView( + this.#scrollHorizontal ? { inline: 'start', block: 'nearest' } : undefined) + this.#scrollCurrentIndex = currentIndex + } + + this.addEventListener('scroll', this.#handleScrollEvent) + if (this.#scrollHorizontal) { + // passive: false because a translated tick must preventDefault so the + // (no-op) native vertical scroll cannot also fire elastic overscroll. + this.addEventListener('wheel', this.#handleScrollWheel, { passive: false }) + } + + // Set up IntersectionObserver after scroll position is established. + // rootMargin '200%' marks pages within ~2 viewport heights above/below as + // visible, giving the ~400 ms-per-page render enough lead time to finish + // before the page scrolls into view. The observer only flags visibility; + // #scheduleScrollPages decides what to actually load (nearest first, + // bounded concurrency) and evict. + this.#scrollObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + const index = parseInt(entry.target.dataset.index) + const pageData = this.#scrollPages[index] + if (pageData) pageData.visible = entry.isIntersecting + } + this.#scheduleScrollPages() + }, { root: this, rootMargin: this.#scrollHorizontal ? '0px 200%' : '200% 0px' }) + + for (const page of this.#scrollPages) { + this.#scrollObserver.observe(page.el) + } + } + // Load the nearest visible idle pages and evict the farthest off-screen ones, + // honouring the concurrency and in-memory caps. Re-run whenever visibility or + // load state changes so a finished load immediately pulls in the next page. + #scheduleScrollPages() { + // While pinching, loading/evicting pages would resize placeholders and + // drift the scroll position, breaking the preview-to-commit alignment. + if (this.#pinching) return + const currentIndex = this.#getScrollIndex() + const { load, evict } = planScrollModePages({ + pages: this.#scrollPages, + currentIndex, + maxLoaded: this.#scrollMaxLoaded, + maxConcurrent: this.#scrollMaxConcurrent, + loadingCount: this.#scrollLoadingCount, + }) + for (const index of evict) this.#teardownScrollPage(this.#scrollPages[index]) + for (const index of load) this.#loadScrollPage(this.#scrollPages[index]) + } + #handleScrollEvent = () => { + // Drop iframe interaction while the host is actively scrolling so the + // scroll stays native-smooth (the iframe's own pointer handlers can't + // hijack it), then restore it on settle so text selection, taps, and + // same-page pinch work again. (Cross-page pinch is intentionally not + // supported in this mode: a gesture spanning two page iframes can't be + // owned by one document — keeping the iframes interactive is the + // trade-off for native selection.) + this.#scrolling = true + this.#setScrollIframeInteraction(false) + if (this.#scrollIdleTimer) clearTimeout(this.#scrollIdleTimer) + this.#scrollIdleTimer = setTimeout(() => { + this.#scrolling = false + this.#setScrollIframeInteraction(true) + // Report location only after scroll settles to avoid + // expensive React re-renders on every frame + this.#reportScrollLocation() + }, 150) + } + #handleScrollWheel = e => { + const delta = computeScrollWheelDelta({ + deltaX: e.deltaX, deltaY: e.deltaY, ctrlKey: e.ctrlKey, + horizontal: this.#scrollHorizontal, rtl: this.rtl, + verticalOverflow: this.scrollHeight > this.clientHeight + 1, + }) + if (!delta) return + e.preventDefault() + this.scrollBy({ left: delta.left, behavior: 'auto' }) + } + #setScrollIframeInteraction(enabled) { + const value = enabled ? 'auto' : '' + for (const page of this.#scrollPages) { + if (page.frame?.iframe) { + page.frame.iframe.style.pointerEvents = value + } + } + } + #destroyScrollMode(navigate = true) { + // Use the cached scroll index because by the time attributeChangedCallback + // fires, the CSS has already switched from block/scroll to flex layout, + // making #getScrollIndex() return incorrect positions + const currentIndex = this.#scrollCurrentIndex >= 0 + ? this.#scrollCurrentIndex : this.#getScrollIndex() + this.removeEventListener('scroll', this.#handleScrollEvent) + this.removeEventListener('wheel', this.#handleScrollWheel) + if (this.#scrollObserver) { + this.#scrollObserver.disconnect() + this.#scrollObserver = null + } + if (this.#scrollIdleTimer) { + clearTimeout(this.#scrollIdleTimer) + this.#scrollIdleTimer = null + } + // Clean up all scroll page frames and overlayers + for (const page of this.#scrollPages) { + this.#teardownScrollPage(page) + } + this.#scrollPages = [] + this.#scrollLoadGen.clear() + this.#scrollLoadingCount = 0 + this.#scrollCurrentIndex = -1 + if (this.#scrollContainer) { + this.#scrollContainer.remove() + this.#scrollContainer = null + } + + // Reset scroll position left over from scroll mode + this.scrollTop = 0 + this.scrollLeft = 0 + // Must run even when navigate is false (axis rebuild): otherwise a + // horizontal-RTL -> vertical switch would leave the host direction + // rtl and the vertical re-init would inherit it. + this.style.removeProperty('direction') + + if (navigate) { + // Restore paginated content + for (const child of Array.from(this.#root.children)) { + child.style.display = '' + } + + // Navigate to the page we were on + if (currentIndex >= 0) { + const section = this.book.sections[currentIndex] + if (section) { + const spread = this.getSpreadOf(section) + if (spread) { + this.#index = -1 + this.goToSpread(spread.index, spread.side, 'page') + } + } + } + } + } + // Create an iframe directly inside the page placeholder (no reparenting) + async #createScrollFrame(pageData, srcOption) { + const srcOptionIsString = typeof srcOption === 'string' + const src = srcOptionIsString ? srcOption : srcOption?.src + const data = srcOptionIsString ? null : srcOption?.data + const onZoom = srcOptionIsString ? null : srcOption?.onZoom + + const element = document.createElement('div') + element.setAttribute('dir', 'ltr') + element.style.position = 'relative' + const iframe = document.createElement('iframe') + element.append(iframe) + Object.assign(iframe.style, { + border: '0', + display: 'none', + overflow: 'hidden', + }) + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + iframe.setAttribute('scrolling', 'no') + iframe.setAttribute('part', 'filter') + // Place directly in the placeholder — no root append + reparent + pageData.el.append(element) + + if (!src) return { blank: true, element, iframe } + return new Promise(resolve => { + iframe.addEventListener('load', () => { + const doc = iframe.contentDocument + iframe.dataset.sectionIndex = pageData.index + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index: pageData.index } })) + const { width, height } = getViewport(doc, this.defaultViewport) + resolve({ + element, iframe, + width: parseFloat(width), + height: parseFloat(height), + onZoom, + }) + }, { once: true }) + if (data) { + iframe.srcdoc = data + } else { + iframe.src = src + } + }) + } + async #loadScrollPage(pageData) { + if (pageData.state !== 'idle') return + pageData.state = 'loading' + this.#scrollLoadingCount++ + + // Generation counter to detect stale loads + const gen = (this.#scrollLoadGen.get(pageData.index) || 0) + 1 + this.#scrollLoadGen.set(pageData.index, gen) + + try { + const src = await pageData.section.load?.() + // Bail if cancelled or mode changed + if (this.#scrollLoadGen.get(pageData.index) !== gen || !this.#scrollMode) { + pageData.state = 'idle' + return + } + // No content for this page: mark terminal so the post-completion + // reschedule does not re-pick it forever (a visible idle page is + // always a load candidate). + if (!src) { pageData.state = 'error'; return } + + const frame = await this.#createScrollFrame(pageData, src) + // Bail if cancelled during frame creation + if (this.#scrollLoadGen.get(pageData.index) !== gen || !this.#scrollMode) { + frame.element?.remove() + pageData.state = 'idle' + return + } + + pageData.frame = frame + pageData.state = 'loaded' + const scrollAnchor = this.#captureScrollModeAnchor() + // Update dimensions from actual page viewport + if (frame.width && frame.height) { + pageData.vpWidth = frame.width + pageData.vpHeight = frame.height + } + this.#renderScrollPage(pageData) + this.#restoreScrollModeAnchor(scrollAnchor) + + // Make the page interactive right away when idle so text selection + // and taps work without first scrolling. While scrolling, leave it + // inert (the scroll-settle handler turns it back on) so its pointer + // handlers can't hijack the native scroll. + if (!this.#scrolling && !this.#pinching && frame.iframe) { + frame.iframe.style.pointerEvents = 'auto' + } + + // Create overlayer + const doc = frame.iframe.contentDocument + if (doc) { + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc, index: pageData.index, + attach: overlayer => { + this.#overlayers.set(pageData.index, overlayer) + frame.element.append(overlayer.element) + applyOverlayerViewBox(frame, overlayer) + }, + }, + })) + // During the brief idle window after scrolling settles the + // iframe is interactive (pointer-events: auto), so the first + // wheel tick of a new gesture lands on it (readest#4727). + // Vertical mode: the browser already chains that tick to the + // host scroller natively (a single smooth scroll, matching the + // page margins) — so JS must NOT scroll the host itself, or the + // manual scroll stacks on top of the native one and the page + // jumps twice as far in an instant lurch. Horizontal mode: the + // host only overflows horizontally, so native chaining cannot + // consume a vertical tick at all — without translating it here, + // the first tick of every gesture over a page is simply lost. + // computeScrollWheelDelta returns null whenever `horizontal` is + // false, so this never scrolls in the vertical case, and its + // other guards (pinch, vertical overflow, horizontal-dominant + // pans) keep this a no-op wherever native handling still + // applies — so the translated scroll can never stack on a + // native one either. + doc.addEventListener('wheel', e => { + this.#setScrollIframeInteraction(false) + const delta = computeScrollWheelDelta({ + deltaX: e.deltaX, deltaY: e.deltaY, ctrlKey: e.ctrlKey, + horizontal: this.#scrollHorizontal, rtl: this.rtl, + verticalOverflow: this.scrollHeight > this.clientHeight + 1, + }) + if (delta) this.scrollBy({ left: delta.left, behavior: 'auto' }) + }, { passive: true }) + } + } catch (e) { + console.warn('Failed to load scroll page', pageData.index, e) + // Terminal state: leaving it 'idle' would let the post-completion + // reschedule retry a persistently failing page in a tight async loop. + pageData.state = 'error' + } finally { + this.#scrollLoadingCount = Math.max(0, this.#scrollLoadingCount - 1) + // A concurrency slot freed up: pull in the next nearest page (and + // apply any pending eviction now that this page's state has settled). + if (this.#scrollMode) this.#scheduleScrollPages() + } + } + // Remove a loaded scroll page's frame and overlayer + #teardownScrollPage(pageData) { + // Bump generation to cancel any in-progress load + const gen = (this.#scrollLoadGen.get(pageData.index) || 0) + 1 + this.#scrollLoadGen.set(pageData.index, gen) + + if (pageData.frame) { + const idx = pageData.index + this.#overlayers.delete(idx) + pageData.frame.element?.remove() + } + pageData.frame = null + pageData.state = 'idle' + } + #renderScrollMode() { + const { width: hostWidth, height: hostHeight } = this.getBoundingClientRect() + if (!(this.#scrollHorizontal ? hostHeight : hostWidth)) return + // Scale the inter-page gap with the zoom so the committed layout matches + // the pinch preview (which scales the whole container, gaps included). + this.style.setProperty('--scroll-zoom', String(this.#scaleFactor)) + // A pinch commit restores the viewport-centre anchor (both axes) so the + // zoom lands exactly where the live preview showed it; every other + // re-render keeps the reader's vertical position via the top anchor. + const pinchAnchor = this.#pinchAnchor + const scrollAnchor = pinchAnchor ? null : this.#captureScrollModeAnchor() + for (const page of this.#scrollPages) { + const scale = this.#scrollHorizontal + ? (hostHeight / page.vpHeight) * this.#scaleFactor + : (hostWidth / page.vpWidth) * this.#scaleFactor + page.el.style.width = `${page.vpWidth * scale}px` + page.el.style.height = `${page.vpHeight * scale}px` + if (page.state === 'loaded' && page.frame) { + this.#renderScrollPage(page) + } + } + if (pinchAnchor) { + this.#restorePinchAnchor(pinchAnchor) + this.#pinchAnchor = null + } else { + this.#restoreScrollModeAnchor(scrollAnchor) + } + } + #renderScrollPage(pageData) { + const { width: hostWidth, height: hostHeight } = this.getBoundingClientRect() + if (!(this.#scrollHorizontal ? hostHeight : hostWidth) || !pageData.frame) return + const { vpWidth: vw, vpHeight: vh, frame } = pageData + const scale = this.#scrollHorizontal + ? (hostHeight / vh) * this.#scaleFactor + : (hostWidth / vw) * this.#scaleFactor + + if (frame.onZoom) { + frame.onZoom({ doc: frame.iframe.contentDocument, scale, pageColors: this.#pageColors }) + Object.assign(frame.iframe.style, { + width: `${vw * scale}px`, + height: `${vh * scale}px`, + transform: 'none', + display: 'block', + }) + } else { + Object.assign(frame.iframe.style, { + width: `${vw}px`, + height: `${vh}px`, + transform: `scale(${scale})`, + transformOrigin: 'top left', + display: 'block', + }) + } + Object.assign(frame.element.style, { + width: `${vw * scale}px`, + height: `${vh * scale}px`, + }) + // Update placeholder to match actual page dimensions + pageData.el.style.width = `${vw * scale}px` + pageData.el.style.height = `${vh * scale}px` + + const overlayer = this.#overlayers.get(pageData.index) + if (overlayer) { + Object.assign(overlayer.element.style, { + position: 'absolute', + top: '0', + left: '0', + width: `${vw * scale}px`, + height: `${vh * scale}px`, + }) + applyOverlayerViewBox(frame, overlayer) + overlayer.redraw() + } + } + #getScrollIndex() { + if (!this.#scrollPages.length) return -1 + const hostRect = this.getBoundingClientRect() + const mid = this.#scrollHorizontal + ? hostRect.left + hostRect.width / 2 + : hostRect.top + hostRect.height / 2 + for (const page of this.#scrollPages) { + const rect = page.el.getBoundingClientRect() + const lo = this.#scrollHorizontal ? rect.left : rect.top + const hi = this.#scrollHorizontal ? rect.right : rect.bottom + if (lo <= mid && hi >= mid) return page.index + } + let closest = 0, minDist = Infinity + for (const page of this.#scrollPages) { + const rect = page.el.getBoundingClientRect() + const center = this.#scrollHorizontal + ? rect.left + rect.width / 2 + : rect.top + rect.height / 2 + const dist = Math.abs(center - mid) + if (dist < minDist) { minDist = dist; closest = page.index } + } + return closest + } + #reportScrollLocation() { + const index = this.#getScrollIndex() + if (index < 0) return + this.#scrollCurrentIndex = index + this.dispatchEvent(new CustomEvent('relocate', { detail: + { reason: 'scroll', range: null, index, fraction: 0, size: 1 } })) + } + #goLeft() { + if (this.#center || this.#left?.blank) return + if (this.#portrait && this.#left?.element?.style?.display === 'none') { + this.#side = 'left' + this.#render(this.#side, true) + this.#reportLocation('page') + return true + } + } + #goRight() { + if (this.#center || this.#right?.blank) return + if (this.#portrait && this.#right?.element?.style?.display === 'none') { + this.#side = 'right' + this.#render(this.#side, true) + this.#reportLocation('page') + return true + } + } + open(book) { + this.book = book + this.defaultViewport = book.rendition?.viewport + this.rtl = book.dir === 'rtl' + + this.#spread() + if (this.#scrollMode) this.#initScrollMode() + } + #spread(mode) { + const book = this.book + const { rendition } = book + const rtl = this.rtl + const ltr = !rtl + this.spread = mode || rendition?.spread + + if (this.spread === 'none') + this.#spreads = book.sections.map(section => ({ center: section })) + else this.#spreads = book.sections.reduce((arr, section, i) => { + const last = arr[arr.length - 1] + const { pageSpread } = section + const newSpread = () => { + const spread = {} + arr.push(spread) + return spread + } + if (pageSpread === 'center') { + const spread = last.left || last.right ? newSpread() : last + spread.center = section + } + else if (pageSpread === 'left') { + const spread = last.center || last.left || ltr && i ? newSpread() : last + spread.left = section + } + else if (pageSpread === 'right') { + const spread = last.center || last.right || rtl && i ? newSpread() : last + spread.right = section + } + else if (ltr) { + if (last.center || last.right) newSpread().left = section + else if (last.left || !i) last.right = section + else last.left = section + } + else { + if (last.center || last.left) newSpread().right = section + else if (last.right || !i) last.left = section + else last.right = section + } + return arr + }, [{}]) + } + #respread(spreadMode) { + if (this.#index === -1) return + const section = this.book.sections[this.index] + this.#spread(spreadMode) + const { index } = this.getSpreadOf(section) + this.#index = -1 + this.#preloadCache.clear() + for (const frames of this.#prerenderedSpreads.values()) { + if (frames.center) { + frames.center.element?.remove() + } else { + frames.left?.element?.remove() + frames.right?.element?.remove() + } + } + this.#prerenderedSpreads.clear() + this.#spreadAccessTime.clear() + this.#overlayers.clear() + this.goToSpread(index, this.rtl ? 'right' : 'left', 'page') + } + get index() { + if (this.#scrollMode) return this.#scrollCurrentIndex >= 0 + ? this.#scrollCurrentIndex : this.#getScrollIndex() + if (this.#index < 0 || !this.#spreads) return -1 + const spread = this.#spreads[this.#index] + if (!spread) return -1 + const section = spread.center ?? (this.#side === 'left' + ? spread.left ?? spread.right : spread.right ?? spread.left) + return this.book.sections.indexOf(section) + } + get pageColors() { + return this.#pageColors + } + set pageColors(value) { + this.#pageColors = value + this.#render() + } + get scrolled() { + return this.#scrollMode + } + get scrollLocked() { + return this.#scrollLocked + } + set scrollLocked(value) { + this.#scrollLocked = value + } + get isOverflowX() { + return this.#isOverflowX + } + get isOverflowY() { + return this.#isOverflowY + } + get atStart() { + if (this.#scrollMode) return this.#scrollProgression() <= 0 + return this.#index <= 0 + } + get atEnd() { + if (this.#scrollMode) + return this.#scrollProgression() + this.#scrollViewLength() + >= this.#scrollTotalLength() - 2 + return this.#index >= this.#spreads.length - 1 + } + #reportLocation(reason) { + this.dispatchEvent(new CustomEvent('relocate', { detail: + { reason, range: null, index: this.index, fraction: 0, size: 1 } })) + } + getSpreadOf(section) { + const spreads = this.#spreads + for (let index = 0; index < spreads.length; index++) { + const { left, right, center } = spreads[index] + if (left === section) return { index, side: 'left' } + if (right === section) return { index, side: 'right' } + if (center === section) return { index, side: 'center' } + } + } + async goToSpread(index, side, reason) { + if (index < 0 || index > this.#spreads.length - 1) return + if (index === this.#index) { + this.#render(side) + return + } + this.#index = index + const spread = this.#spreads[index] + const cacheKey = `spread-${index}` + const cached = this.#preloadCache.get(cacheKey) + if (cached && cached !== 'loading') { + if (cached.center) { + const sectionIndex = this.book.sections.indexOf(spread.center) + await this.#showSpread({ center: { index: sectionIndex, src: cached.center }, spreadIndex: index, side }) + } else { + const indexL = this.book.sections.indexOf(spread.left) + const indexR = this.book.sections.indexOf(spread.right) + const left = { index: indexL, src: cached.left } + const right = { index: indexR, src: cached.right } + await this.#showSpread({ left, right, side, spreadIndex: index }) + } + } else { + if (spread.center) { + const sectionIndex = this.book.sections.indexOf(spread.center) + const src = await spread.center?.load?.() + await this.#showSpread({ center: { index: sectionIndex, src }, spreadIndex: index, side }) + } else { + const indexL = this.book.sections.indexOf(spread.left) + const indexR = this.book.sections.indexOf(spread.right) + const srcL = await spread.left?.load?.() + const srcR = await spread.right?.load?.() + const left = { index: indexL, src: srcL } + const right = { index: indexR, src: srcR } + await this.#showSpread({ left, right, side, spreadIndex: index }) + } + } + + this.#reportLocation(reason) + this.#preloadNextSpreads() + } + #preloadNextSpreads() { + this.#cleanupPreloadCache() + + if (this.#numPrerenderedSpreads <= 0) return + + const toPreload = [] + const forwardPreloadCount = Math.max(1, this.#numPrerenderedSpreads - 1) + const backwardPreloadCount = Math.max(0, this.#numPrerenderedSpreads - forwardPreloadCount) + for (let distance = 1; distance <= forwardPreloadCount; distance++) { + const forwardIndex = this.#index + distance + if (forwardIndex >= 0 && forwardIndex < this.#spreads.length) { + toPreload.push({ index: forwardIndex, direction: 'forward', distance }) + } + } + for (let distance = 1; distance <= backwardPreloadCount; distance++) { + const backwardIndex = this.#index - distance + if (backwardIndex >= 0 && backwardIndex < this.#spreads.length) { + toPreload.push({ index: backwardIndex, direction: 'backward', distance }) + } + } + for (const { index: targetIndex, direction } of toPreload) { + const cacheKey = `spread-${targetIndex}` + if (this.#prerenderedSpreads.has(cacheKey)) continue + const spread = this.#spreads[targetIndex] + if (!spread) continue + this.#preloadQueue.push({ targetIndex, direction, spread, cacheKey }) + } + + this.#processPreloadQueue() + } + + async #processPreloadQueue() { + while (this.#preloadQueue.length > 0 && this.#activePreloads < this.#maxConcurrentPreloads) { + const task = this.#preloadQueue.shift() + if (!task) break + + const { spread, cacheKey } = task + this.#preloadCache.set(cacheKey, 'loading') + this.#activePreloads++ + Promise.resolve().then(async () => { + try { + if (spread.center) { + const src = await spread.center?.load?.() + this.#preloadCache.set(cacheKey, { center: src }) + + const sectionIndex = this.book.sections.indexOf(spread.center) + const frame = await this.#createFrame({ index: sectionIndex, src, detached: true }) + + this.#prerenderedSpreads.set(cacheKey, { center: frame }) + this.#spreadAccessTime.set(cacheKey, Date.now()) + if (frame.onZoom) { + const doc = frame.iframe.contentDocument + frame.onZoom({ doc, scale: this.#totalScaleFactor, pageColors: this.#pageColors }) + } + } else { + const srcL = await spread.left?.load?.() + const srcR = await spread.right?.load?.() + this.#preloadCache.set(cacheKey, { left: srcL, right: srcR }) + + const indexL = this.book.sections.indexOf(spread.left) + const indexR = this.book.sections.indexOf(spread.right) + const leftFrame = await this.#createFrame({ index: indexL, src: srcL, detached: true }) + const rightFrame = await this.#createFrame({ index: indexR, src: srcR, detached: true }) + + this.#prerenderedSpreads.set(cacheKey, { left: leftFrame, right: rightFrame }) + this.#spreadAccessTime.set(cacheKey, Date.now()) + + if (leftFrame.onZoom) { + const docL = leftFrame.iframe.contentDocument + leftFrame.onZoom({ doc: docL, scale: this.#totalScaleFactor, pageColors: this.#pageColors }) + } + if (rightFrame.onZoom) { + const docR = rightFrame.iframe.contentDocument + rightFrame.onZoom({ doc: docR, scale: this.#totalScaleFactor, pageColors: this.#pageColors }) + } + } + } catch { + this.#preloadCache.delete(cacheKey) + this.#prerenderedSpreads.delete(cacheKey) + } finally { + this.#activePreloads-- + this.#processPreloadQueue() + } + }) + } + } + #cleanupPreloadCache() { + const maxSpreads = this.#maxCachedSpreads + if (this.#prerenderedSpreads.size <= maxSpreads) { + return + } + + const framesByAge = Array.from(this.#prerenderedSpreads.keys()) + .map(key => ({ + key, + accessTime: this.#spreadAccessTime.get(key) || 0, + })) + .sort((a, b) => a.accessTime - b.accessTime) + + const numToRemove = this.#prerenderedSpreads.size - maxSpreads + const framesToDelete = framesByAge.slice(0, numToRemove).map(item => item.key) + + if (framesToDelete.length > 0) { + framesToDelete.forEach(key => { + const frames = this.#prerenderedSpreads.get(key) + if (frames) { + if (frames.center) { + this.#removeOverlayerForFrame(frames.center) + frames.center.element?.remove() + } else { + this.#removeOverlayerForFrame(frames.left) + this.#removeOverlayerForFrame(frames.right) + frames.left?.element?.remove() + frames.right?.element?.remove() + } + } + + this.#prerenderedSpreads.delete(key) + this.#spreadAccessTime.delete(key) + this.#preloadCache.delete(key) + }) + } + } + #removeOverlayerForFrame(frame) { + if (!frame?.iframe) return + const idx = frame.iframe.dataset.sectionIndex != null + ? parseInt(frame.iframe.dataset.sectionIndex) : undefined + if (idx != null) this.#overlayers.delete(idx) + } + // Drop a frame's overlayer and re-emit create-overlayer so listeners can + // re-add annotations. Called after a text layer rebuild (e.g. pdf.js + // onZoom) which invalidates Range objects stored in the overlayer. + #refreshOverlayerForFrame(frame) { + if (!frame?.iframe) return + const index = frame.iframe.dataset.sectionIndex != null + ? parseInt(frame.iframe.dataset.sectionIndex) : undefined + if (index == null) return + const stale = this.#overlayers.get(index) + if (!stale) return + // Only refresh for frames currently visible; hidden frames keep their + // overlayer untouched until they are shown again. + const isVisible = frame.element?.parentNode + && frame.element.style.visibility !== 'hidden' + if (!isVisible) return + stale.element?.remove() + this.#overlayers.delete(index) + const doc = frame.iframe.contentDocument + if (!doc) return + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc, index, + attach: overlayer => { + this.#overlayers.set(index, overlayer) + frame.element.append(overlayer.element) + }, + }, + })) + } + async select(target) { + await this.goTo(target) + // TODO + } + async goTo(target) { + const resolved = await target + if (this.#scrollMode) { + const page = this.#scrollPages[resolved.index] + if (page) { + page.el.scrollIntoView( + this.#scrollHorizontal ? { inline: 'start', block: 'nearest' } : undefined) + this.#scrollCurrentIndex = resolved.index + } + return + } + const { book } = this + const section = book.sections[resolved.index] + if (!section) return + const { index, side } = this.getSpreadOf(section) + await this.goToSpread(index, side) + } + async next(distance) { + if (this.#scrollMode) { + if (this.#scrollHorizontal) { + const d = distance || this.clientWidth + this.scrollBy({ left: this.rtl ? -d : d, behavior: 'smooth' }) + } else { + this.scrollBy({ top: distance || this.clientHeight, behavior: 'smooth' }) + } + return + } + const s = this.rtl ? this.#goLeft() : this.#goRight() + if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page') + } + async prev(distance) { + if (this.#scrollMode) { + if (this.#scrollHorizontal) { + const d = distance || this.clientWidth + this.scrollBy({ left: this.rtl ? d : -d, behavior: 'smooth' }) + } else { + this.scrollBy({ top: -(distance || this.clientHeight), behavior: 'smooth' }) + } + return + } + const s = this.rtl ? this.#goRight() : this.#goLeft() + if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page') + } + nextSection() { + if (!this.#scrollMode) return + const currentIndex = this.#getScrollIndex() + const nextIndex = Math.min(currentIndex + 1, this.#scrollPages.length - 1) + this.#scrollPages[nextIndex]?.el.scrollIntoView(this.#scrollHorizontal + ? { behavior: 'smooth', inline: 'start', block: 'nearest' } + : { behavior: 'smooth' }) + this.#scrollCurrentIndex = nextIndex + } + prevSection() { + if (!this.#scrollMode) return + const currentIndex = this.#getScrollIndex() + const prevIndex = Math.max(currentIndex - 1, 0) + this.#scrollPages[prevIndex]?.el.scrollIntoView(this.#scrollHorizontal + ? { behavior: 'smooth', inline: 'start', block: 'nearest' } + : { behavior: 'smooth' }) + this.#scrollCurrentIndex = prevIndex + } + async pan(dx, dy) { + if (this.#scrollMode) { + this.scrollBy({ top: dy, left: dx, behavior: 'auto' }) + return + } + if (this.#scrollLocked) return + this.#scrollLocked = true + + const transform = frame => { + let { element, iframe } = frame + if (!iframe || !element) return + + const scrollableContainer = element.parentNode.host + scrollableContainer.scrollLeft += dx + scrollableContainer.scrollTop += dy + } + + transform(this.#center ?? this.#right ?? {}) + this.#scrollLocked = false + } + getContents() { + if (this.#scrollMode) { + return this.#scrollPages + .filter(p => p.state === 'loaded' && p.frame?.iframe) + .map(p => ({ + doc: p.frame.iframe.contentDocument, + index: p.index, + overlayer: this.#overlayers.get(p.index), + })) + } + return Array.from(this.#root.querySelectorAll('iframe')) + .filter(frame => { + const parent = frame.parentElement + return parent && parent.style.visibility !== 'hidden' + }) + .map(frame => { + const index = frame.dataset.sectionIndex != null + ? parseInt(frame.dataset.sectionIndex) : undefined + return { + doc: frame.contentDocument, + index, + overlayer: index != null ? this.#overlayers.get(index) : undefined, + } + }) + } + pinchZoom(ratio) { + // Scroll mode: scale the whole scroll container so the zoom tracks the + // fingers live, anchored at the viewport centre. Suppress paging and + // snapshot the centre anchor on the first move so the layout stays still + // and the commit lands exactly where the preview shows. + if (this.#scrollMode) { + if (this.#scrollContainer) { + // Suppress paging so the layout can't drift mid-pinch. + this.#pinching = true + const { transform, transformOrigin } = computeScrollPinchTransform({ + ratio, + scrollLeft: this.#scrollHorizontal && this.rtl + ? this.scrollWidth - this.clientWidth + this.scrollLeft + : this.scrollLeft, + scrollTop: this.scrollTop, + viewportWidth: this.clientWidth, + viewportHeight: this.clientHeight, + }) + this.#scrollContainer.style.transformOrigin = transformOrigin + this.#scrollContainer.style.transform = transform + } + return + } + const frames = this.#center + ? [this.#center] + : [this.#left, this.#right] + for (const frame of frames) { + if (!frame?.element || frame.element.style.visibility === 'hidden') continue + frame.element.style.transform = `scale(${ratio})` + frame.element.style.transformOrigin = 'center' + } + } + pinchEnd() { + if (this.#scrollMode) { + // Snapshot the centre page's on-screen rect from the still-scaled + // preview, then drop the transform and resume paging. The committed + // zoom (scale-factor) re-renders the pages and #renderScrollMode + // scrolls that page back to this rect, so the zoom doesn't jump. + this.#pinching = false + if (this.#scrollContainer) { + this.#pinchAnchor = this.#captureCenterPageRect() + this.#scrollContainer.style.removeProperty('transform') + this.#scrollContainer.style.removeProperty('transform-origin') + } + return + } + // Paginated: snapshot the spread's on-screen rect from the still-scaled + // preview so the committed zoom (#render) can scroll it back to the same + // spot instead of re-centring and jumping. + const shown = this.#center ?? this.#left ?? this.#right + if (shown?.element) { + const b = shown.element.getBoundingClientRect() + this.#pinchAnchor = { top: b.top, left: b.left } + } + for (const frame of [this.#center, this.#left, this.#right]) { + if (!frame?.element) continue + frame.element.style.removeProperty('transform') + frame.element.style.removeProperty('transform-origin') + } + } + get size() { + // The app turns pages by `size - scrollingOverlap`; in horizontal + // scroll mode a page step is one viewport width. + return this.#scrollMode && this.#scrollHorizontal ? this.clientWidth : this.clientHeight + } + get viewSize() { + return this.#scrollMode ? this.#scrollTotalLength() : this.clientHeight + } + get start() { + return this.#scrollMode ? this.#scrollProgression() : 0 + } + get end() { + return this.#scrollMode + ? this.#scrollProgression() + this.#scrollViewLength() + : this.clientHeight + } + get page() { + if (this.#scrollMode) return this.#scrollCurrentIndex >= 0 + ? this.#scrollCurrentIndex : this.#getScrollIndex() + return this.#index + } + get pages() { + if (this.#scrollMode) return this.#scrollPages.length + return this.#spreads?.length ?? 0 + } + get containerPosition() { + // Reading progression from the book start (see #scrollProgression), so + // relative scroll consumers (auto scroll, middle-click autoscroll) can + // `+= delta` to advance in reading order on every axis and direction. + // Paginated fixed layout pages via spreads; keep returning 0 there. + return this.#scrollMode ? this.#scrollProgression() : 0 + } + set containerPosition(newVal) { + // Mirror the paginator's read/write containerPosition contract (see + // READEST-11). No-op when paginated. + if (!this.#scrollMode) return + if (this.#scrollHorizontal) this.scrollLeft = this.rtl ? -newVal : newVal + else this.scrollTop = newVal + } + get sideProp() { + if (this.#scrollMode) return this.#scrollHorizontal ? 'width' : 'height' + return 'width' + } + destroy() { + this.#observer.unobserve(this) + if (this.#scrollMode) { + this.removeEventListener('scroll', this.#handleScrollEvent) + this.removeEventListener('wheel', this.#handleScrollWheel) + if (this.#scrollObserver) { + this.#scrollObserver.disconnect() + this.#scrollObserver = null + } + if (this.#scrollIdleTimer) { + clearTimeout(this.#scrollIdleTimer) + this.#scrollIdleTimer = null + } + for (const page of this.#scrollPages) { + this.#teardownScrollPage(page) + } + this.#scrollPages = [] + this.#scrollLoadGen.clear() + this.#scrollLoadingCount = 0 + if (this.#scrollContainer) { + this.#scrollContainer.remove() + this.#scrollContainer = null + } + } + for (const frames of this.#prerenderedSpreads.values()) { + if (frames.center) { + frames.center.element?.remove() + } else { + frames.left?.element?.remove() + frames.right?.element?.remove() + } + } + this.#prerenderedSpreads.clear() + this.#preloadCache.clear() + this.#spreadAccessTime.clear() + this.#overlayers.clear() + } +} + +customElements.define('foliate-fxl', FixedLayout) diff --git a/frontend/src/lib/vendor/foliate-js/mobi.js b/frontend/src/lib/vendor/foliate-js/mobi.js new file mode 100644 index 0000000..e517dbd --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/mobi.js @@ -0,0 +1,1279 @@ +const unescapeHTML = str => { + if (!str) return '' + const textarea = document.createElement('textarea') + textarea.innerHTML = str + return textarea.value +} + +const MIME = { + XML: 'application/xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', +} + +const PDB_HEADER = { + name: [0, 32, 'string'], + type: [60, 4, 'string'], + creator: [64, 4, 'string'], + numRecords: [76, 2, 'uint'], +} + +const PALMDOC_HEADER = { + compression: [0, 2, 'uint'], + numTextRecords: [8, 2, 'uint'], + recordSize: [10, 2, 'uint'], + encryption: [12, 2, 'uint'], +} + +const MOBI_HEADER = { + magic: [16, 4, 'string'], + length: [20, 4, 'uint'], + type: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + uid: [32, 4, 'uint'], + version: [36, 4, 'uint'], + titleOffset: [84, 4, 'uint'], + titleLength: [88, 4, 'uint'], + localeRegion: [94, 1, 'uint'], + localeLanguage: [95, 1, 'uint'], + resourceStart: [108, 4, 'uint'], + huffcdic: [112, 4, 'uint'], + numHuffcdic: [116, 4, 'uint'], + exthFlag: [128, 4, 'uint'], + trailingFlags: [240, 4, 'uint'], + indx: [244, 4, 'uint'], +} + +const KF8_HEADER = { + resourceStart: [108, 4, 'uint'], + fdst: [192, 4, 'uint'], + numFdst: [196, 4, 'uint'], + frag: [248, 4, 'uint'], + skel: [252, 4, 'uint'], + guide: [260, 4, 'uint'], +} + +const EXTH_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + count: [8, 4, 'uint'], +} + +const INDX_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + type: [8, 4, 'uint'], + idxt: [20, 4, 'uint'], + numRecords: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + language: [32, 4, 'uint'], + total: [36, 4, 'uint'], + ordt: [40, 4, 'uint'], + ligt: [44, 4, 'uint'], + numLigt: [48, 4, 'uint'], + numCncx: [52, 4, 'uint'], +} + +const TAGX_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numControlBytes: [8, 4, 'uint'], +} + +const HUFF_HEADER = { + magic: [0, 4, 'string'], + offset1: [8, 4, 'uint'], + offset2: [12, 4, 'uint'], +} + +const CDIC_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numEntries: [8, 4, 'uint'], + codeLength: [12, 4, 'uint'], +} + +const FDST_HEADER = { + magic: [0, 4, 'string'], + numEntries: [8, 4, 'uint'], +} + +const FONT_HEADER = { + flags: [8, 4, 'uint'], + dataStart: [12, 4, 'uint'], + keyLength: [16, 4, 'uint'], + keyStart: [20, 4, 'uint'], +} + +const MOBI_ENCODING = { + 1252: 'windows-1252', + 65001: 'utf-8', +} + +const EXTH_RECORD_TYPE = { + 100: ['creator', 'string', true], + 101: ['publisher'], + 103: ['description'], + 104: ['isbn'], + 105: ['subject', 'string', true], + 106: ['date'], + 108: ['contributor', 'string', true], + 109: ['rights'], + 110: ['subjectCode', 'string', true], + 112: ['source', 'string', true], + 113: ['asin'], + 121: ['boundary', 'uint'], + 122: ['fixedLayout'], + 125: ['numResources', 'uint'], + 126: ['originalResolution'], + 127: ['zeroGutter'], + 128: ['zeroMargin'], + 129: ['coverURI'], + 132: ['regionMagnification'], + 201: ['coverOffset', 'uint'], + 202: ['thumbnailOffset', 'uint'], + 503: ['title'], + 524: ['language', 'string', true], + 527: ['pageProgressionDirection'], +} + +const MOBI_LANG = { + 1: ['ar', 'ar-SA', 'ar-IQ', 'ar-EG', 'ar-LY', 'ar-DZ', 'ar-MA', 'ar-TN', 'ar-OM', + 'ar-YE', 'ar-SY', 'ar-JO', 'ar-LB', 'ar-KW', 'ar-AE', 'ar-BH', 'ar-QA'], + 2: ['bg'], 3: ['ca'], 4: ['zh', 'zh-TW', 'zh-CN', 'zh-HK', 'zh-SG'], 5: ['cs'], + 6: ['da'], 7: ['de', 'de-DE', 'de-CH', 'de-AT', 'de-LU', 'de-LI'], 8: ['el'], + 9: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-NZ', 'en-IE', 'en-ZA', + 'en-JM', null, 'en-BZ', 'en-TT', 'en-ZW', 'en-PH'], + 10: ['es', 'es-ES', 'es-MX', null, 'es-GT', 'es-CR', 'es-PA', 'es-DO', + 'es-VE', 'es-CO', 'es-PE', 'es-AR', 'es-EC', 'es-CL', 'es-UY', 'es-PY', + 'es-BO', 'es-SV', 'es-HN', 'es-NI', 'es-PR'], + 11: ['fi'], 12: ['fr', 'fr-FR', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-LU', 'fr-MC'], + 13: ['he'], 14: ['hu'], 15: ['is'], 16: ['it', 'it-IT', 'it-CH'], + 17: ['ja'], 18: ['ko'], 19: ['nl', 'nl-NL', 'nl-BE'], 20: ['no', 'nb', 'nn'], + 21: ['pl'], 22: ['pt', 'pt-BR', 'pt-PT'], 23: ['rm'], 24: ['ro'], 25: ['ru'], + 26: ['hr', null, 'sr'], 27: ['sk'], 28: ['sq'], 29: ['sv', 'sv-SE', 'sv-FI'], + 30: ['th'], 31: ['tr'], 32: ['ur'], 33: ['id'], 34: ['uk'], 35: ['be'], + 36: ['sl'], 37: ['et'], 38: ['lv'], 39: ['lt'], 41: ['fa'], 42: ['vi'], + 43: ['hy'], 44: ['az'], 45: ['eu'], 46: ['hsb'], 47: ['mk'], 48: ['st'], + 49: ['ts'], 50: ['tn'], 52: ['xh'], 53: ['zu'], 54: ['af'], 55: ['ka'], + 56: ['fo'], 57: ['hi'], 58: ['mt'], 59: ['se'], 62: ['ms'], 63: ['kk'], + 65: ['sw'], 67: ['uz', null, 'uz-UZ'], 68: ['tt'], 69: ['bn'], 70: ['pa'], + 71: ['gu'], 72: ['or'], 73: ['ta'], 74: ['te'], 75: ['kn'], 76: ['ml'], + 77: ['as'], 78: ['mr'], 79: ['sa'], 82: ['cy', 'cy-GB'], 83: ['gl', 'gl-ES'], + 87: ['kok'], 97: ['ne'], 98: ['fy'], +} + +const concatTypedArray = (a, b) => { + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result +} +const concatTypedArray3 = (a, b, c) => { + const result = new a.constructor(a.length + b.length + c.length) + result.set(a) + result.set(b, a.length) + result.set(c, a.length + b.length) + return result +} + +const decoder = new TextDecoder() +const getString = buffer => decoder.decode(buffer) +const getUint = buffer => { + if (!buffer) return + const l = buffer.byteLength + const func = l === 4 ? 'getUint32' : l === 2 ? 'getUint16' : 'getUint8' + return new DataView(buffer)[func](0) +} +const getStruct = (def, buffer) => Object.fromEntries(Array.from(Object.entries(def)) + .map(([key, [start, len, type]]) => [key, + (type === 'string' ? getString : getUint)(buffer.slice(start, start + len))])) + +const getDecoder = x => new TextDecoder(MOBI_ENCODING[x]) + +const getVarLen = (byteArray, i = 0) => { + let value = 0, length = 0 + for (const byte of byteArray.subarray(i, i + 4)) { + value = (value << 7) | (byte & 0b111_1111) >>> 0 + length++ + if (byte & 0b1000_0000) break + } + return { value, length } +} + +// variable-length quantity, but read from the end of data +const getVarLenFromEnd = byteArray => { + let value = 0 + for (const byte of byteArray.subarray(-4)) { + // `byte & 0b1000_0000` indicates the start of value + if (byte & 0b1000_0000) value = 0 + value = (value << 7) | (byte & 0b111_1111) + } + return value +} + +const countBitsSet = x => { + let count = 0 + for (; x > 0; x = x >> 1) if ((x & 1) === 1) count++ + return count +} + +const countUnsetEnd = x => { + let count = 0 + while ((x & 1) === 0) x = x >> 1, count++ + return count +} + +const decompressPalmDOC = array => { + let output = [] + for (let i = 0; i < array.length; i++) { + const byte = array[i] + if (byte === 0) output.push(0) // uncompressed literal, just copy it + else if (byte <= 8) // copy next 1-8 bytes + for (const x of array.subarray(i + 1, (i += byte) + 1)) + output.push(x) + else if (byte <= 0b0111_1111) output.push(byte) // uncompressed literal + else if (byte <= 0b1011_1111) { + // 1st and 2nd bits are 10, meaning this is a length-distance pair + // read next byte and combine it with current byte + const bytes = (byte << 8) | array[i++ + 1] + // the 3rd to 13th bits encode distance + const distance = (bytes & 0b0011_1111_1111_1111) >>> 3 + // the last 3 bits, plus 3, is the length to copy + const length = (bytes & 0b111) + 3 + for (let j = 0; j < length; j++) + output.push(output[output.length - distance]) + } + // compressed from space plus char + else output.push(32, byte ^ 0b1000_0000) + } + return Uint8Array.from(output) +} + +const read32Bits = (byteArray, from) => { + const startByte = from >> 3 + const end = from + 32 + const endByte = end >> 3 + let bits = 0n + for (let i = startByte; i <= endByte; i++) + bits = bits << 8n | BigInt(byteArray[i] ?? 0) + return (bits >> (8n - BigInt(end & 7))) & 0xffffffffn +} + +const huffcdic = async (mobi, loadRecord) => { + const huffRecord = await loadRecord(mobi.huffcdic) + const { magic, offset1, offset2 } = getStruct(HUFF_HEADER, huffRecord) + if (magic !== 'HUFF') throw new Error('Invalid HUFF record') + + // table1 is indexed by byte value + const table1 = Array.from({ length: 256 }, (_, i) => offset1 + i * 4) + .map(offset => getUint(huffRecord.slice(offset, offset + 4))) + .map(x => [x & 0b1000_0000, x & 0b1_1111, x >>> 8]) + + // table2 is indexed by code length + const table2 = [null].concat(Array.from({ length: 32 }, (_, i) => offset2 + i * 8) + .map(offset => [ + getUint(huffRecord.slice(offset, offset + 4)), + getUint(huffRecord.slice(offset + 4, offset + 8))])) + + const dictionary = [] + for (let i = 1; i < mobi.numHuffcdic; i++) { + const record = await loadRecord(mobi.huffcdic + i) + const cdic = getStruct(CDIC_HEADER, record) + if (cdic.magic !== 'CDIC') throw new Error('Invalid CDIC record') + // `numEntries` is the total number of dictionary data across CDIC records + // so `n` here is the number of entries in *this* record + const n = Math.min(1 << cdic.codeLength, cdic.numEntries - dictionary.length) + const buffer = record.slice(cdic.length) + for (let i = 0; i < n; i++) { + const offset = getUint(buffer.slice(i * 2, i * 2 + 2)) + const x = getUint(buffer.slice(offset, offset + 2)) + const length = x & 0x7fff + const decompressed = x & 0x8000 + const value = new Uint8Array( + buffer.slice(offset + 2, offset + 2 + length)) + dictionary.push([value, decompressed]) + } + } + + const decompress = byteArray => { + let output = new Uint8Array() + const bitLength = byteArray.byteLength * 8 + for (let i = 0; i < bitLength;) { + const bits = Number(read32Bits(byteArray, i)) + let [found, codeLength, value] = table1[bits >>> 24] + if (!found) { + while (bits >>> (32 - codeLength) < table2[codeLength][0]) + codeLength += 1 + value = table2[codeLength][1] + } + if ((i += codeLength) > bitLength) break + + const code = value - (bits >>> (32 - codeLength)) + let [result, decompressed] = dictionary[code] + if (!decompressed) { + // the result is itself compressed + result = decompress(result) + // cache the result for next time + dictionary[code] = [result, true] + } + output = concatTypedArray(output, result) + } + return output + } + return decompress +} + +const getIndexData = async (indxIndex, loadRecord) => { + const indxRecord = await loadRecord(indxIndex) + const indx = getStruct(INDX_HEADER, indxRecord) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + const decoder = getDecoder(indx.encoding) + + const tagxBuffer = indxRecord.slice(indx.length) + const tagx = getStruct(TAGX_HEADER, tagxBuffer) + if (tagx.magic !== 'TAGX') throw new Error('Invalid TAGX section') + const numTags = (tagx.length - 12) / 4 + const tagTable = Array.from({ length: numTags }, (_, i) => + new Uint8Array(tagxBuffer.slice(12 + i * 4, 12 + i * 4 + 4))) + + const cncx = {} + let cncxRecordOffset = 0 + for (let i = 0; i < indx.numCncx; i++) { + const record = await loadRecord(indxIndex + indx.numRecords + i + 1) + const array = new Uint8Array(record) + for (let pos = 0; pos < array.byteLength;) { + const index = pos + const { value, length } = getVarLen(array, pos) + pos += length + const result = record.slice(pos, pos + value) + pos += value + cncx[cncxRecordOffset + index] = decoder.decode(result) + } + cncxRecordOffset += 0x10000 + } + + const table = [] + for (let i = 0; i < indx.numRecords; i++) { + const record = await loadRecord(indxIndex + 1 + i) + const array = new Uint8Array(record) + const indx = getStruct(INDX_HEADER, record) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + for (let j = 0; j < indx.numRecords; j++) { + const offsetOffset = indx.idxt + 4 + 2 * j + const offset = getUint(record.slice(offsetOffset, offsetOffset + 2)) + + const length = getUint(record.slice(offset, offset + 1)) + const name = getString(record.slice(offset + 1, offset + 1 + length)) + + const tags = [] + const startPos = offset + 1 + length + let controlByteIndex = 0 + let pos = startPos + tagx.numControlBytes + for (const [tag, numValues, mask, end] of tagTable) { + if (end & 1) { + controlByteIndex++ + continue + } + const offset = startPos + controlByteIndex + const value = getUint(record.slice(offset, offset + 1)) & mask + if (value === mask) { + if (countBitsSet(mask) > 1) { + const { value, length } = getVarLen(array, pos) + tags.push([tag, null, value, numValues]) + pos += length + } else tags.push([tag, 1, null, numValues]) + } else tags.push([tag, value >> countUnsetEnd(mask), null, numValues]) + } + + const tagMap = {} + for (const [tag, valueCount, valueBytes, numValues] of tags) { + const values = [] + if (valueCount != null) { + for (let i = 0; i < valueCount * numValues; i++) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + } + } else { + let count = 0 + while (count < valueBytes) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + count += length + } + } + tagMap[tag] = values + } + table.push({ name, tagMap }) + } + } + return { table, cncx } +} + +const getNCX = async (indxIndex, loadRecord) => { + const { table, cncx } = await getIndexData(indxIndex, loadRecord) + const items = table.map(({ tagMap }, index) => ({ + index, + offset: tagMap[1]?.[0], + size: tagMap[2]?.[0], + label: cncx[tagMap[3]] ?? '', + headingLevel: tagMap[4]?.[0], + pos: tagMap[6], + parent: tagMap[21]?.[0], + firstChild: tagMap[22]?.[0], + lastChild: tagMap[23]?.[0], + })) + const getChildren = item => { + if (item.firstChild == null) return item + item.children = items.filter(x => x.parent === item.index).map(getChildren) + return item + } + return items.filter(item => item.headingLevel === 0).map(getChildren) +} + +const getEXTH = (buf, encoding) => { + const { magic, count } = getStruct(EXTH_HEADER, buf) + if (magic !== 'EXTH') throw new Error('Invalid EXTH header') + const decoder = getDecoder(encoding) + const results = {} + let offset = 12 + for (let i = 0; i < count; i++) { + const type = getUint(buf.slice(offset, offset + 4)) + const length = getUint(buf.slice(offset + 4, offset + 8)) + if (type in EXTH_RECORD_TYPE) { + const [name, typ, many] = EXTH_RECORD_TYPE[type] + const data = buf.slice(offset + 8, offset + length) + const value = typ === 'uint' ? getUint(data) : decoder.decode(data) + if (many) { + results[name] ??= [] + results[name].push(value) + } else results[name] = value + } + offset += length + } + return results +} + +const getFont = async (buf, unzlib) => { + const { flags, dataStart, keyLength, keyStart } = getStruct(FONT_HEADER, buf) + const array = new Uint8Array(buf.slice(dataStart)) + // deobfuscate font + if (flags & 0b10) { + const bytes = keyLength === 16 ? 1024 : 1040 + const key = new Uint8Array(buf.slice(keyStart, keyStart + keyLength)) + const length = Math.min(bytes, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + } + // decompress font + if (flags & 1) try { + return await unzlib(array) + } catch (e) { + console.warn(e) + console.warn('Failed to decompress font') + } + return array +} + +export const isMOBI = async file => { + const magic = getString(await file.slice(60, 68).arrayBuffer()) + return magic === 'BOOKMOBI'// || magic === 'TEXtREAd' +} + +class PDB { + #file + #offsets + pdb + async open(file) { + this.#file = file + const pdb = getStruct(PDB_HEADER, await file.slice(0, 78).arrayBuffer()) + this.pdb = pdb + const buffer = await file.slice(78, 78 + pdb.numRecords * 8).arrayBuffer() + // get start and end offsets for each record + this.#offsets = Array.from({ length: pdb.numRecords }, + (_, i) => getUint(buffer.slice(i * 8, i * 8 + 4))) + .map((x, i, a) => [x, a[i + 1]]) + } + loadRecord(index) { + const offsets = this.#offsets[index] + if (!offsets) throw new RangeError('Record index out of bounds') + return this.#file.slice(...offsets).arrayBuffer() + } + async loadMagic(index) { + const start = this.#offsets[index][0] + return getString(await this.#file.slice(start, start + 4).arrayBuffer()) + } +} + +export class MOBI extends PDB { + #start = 0 + #resourceStart + #decoder + #encoder + #decompress + #removeTrailingEntries + constructor({ unzlib }) { + super() + this.unzlib = unzlib + } + async open(file, { metadataOnly = false } = {}) { + await super.open(file) + // TODO: if (this.pdb.type === 'TEXt') + this.headers = this.#getHeaders(await super.loadRecord(0)) + this.#resourceStart = this.headers.mobi.resourceStart + let isKF8 = this.headers.mobi.version >= 8 + if (!isKF8) { + const boundary = this.headers.exth?.boundary + if (boundary < 0xffffffff) try { + // it's a "combo" MOBI/KF8 file; try to open the KF8 part + this.headers = this.#getHeaders(await super.loadRecord(boundary)) + this.#start = boundary + isKF8 = true + } catch (e) { + console.warn(e) + console.warn('Failed to open KF8; falling back to MOBI') + } + } + await this.#setup() + // Metadata-only short-circuit. Skips the (expensive) MOBI6 / + // KF8 init(), which walks every text record and parses + // fdst/skel/frag indices the importer never reads. + // `this.headers` and `this.decode()` are already populated by + // #setup, so `this.getMetadata()` and `this.getCover()` are + // the only surfaces the caller is allowed to use here. + if (metadataOnly) return this + return isKF8 ? new KF8(this).init() : new MOBI6(this).init() + } + #getHeaders(buf) { + const palmdoc = getStruct(PALMDOC_HEADER, buf) + const mobi = getStruct(MOBI_HEADER, buf) + if (mobi.magic !== 'MOBI') throw new Error('Missing MOBI header') + + const { titleOffset, titleLength, localeLanguage, localeRegion } = mobi + mobi.title = buf.slice(titleOffset, titleOffset + titleLength) + const lang = MOBI_LANG[localeLanguage] + mobi.language = lang?.[localeRegion >> 2] ?? lang?.[0] + + const exth = mobi.exthFlag & 0b100_0000 + ? getEXTH(buf.slice(mobi.length + 16), mobi.encoding) : null + const kf8 = mobi.version >= 8 ? getStruct(KF8_HEADER, buf) : null + return { palmdoc, mobi, exth, kf8 } + } + async #setup() { + const { palmdoc, mobi } = this.headers + this.#decoder = getDecoder(mobi.encoding) + // `TextEncoder` only supports UTF-8 + // we are only encoding ASCII anyway, so I think it's fine + this.#encoder = new TextEncoder() + + // set up decompressor + const { compression } = palmdoc + this.#decompress = compression === 1 ? f => f + : compression === 2 ? decompressPalmDOC + : compression === 17480 ? await huffcdic(mobi, this.loadRecord.bind(this)) + : null + if (!this.#decompress) throw new Error('Unknown compression type') + + // set up function for removing trailing bytes + const { trailingFlags } = mobi + const multibyte = trailingFlags & 1 + const numTrailingEntries = countBitsSet(trailingFlags >>> 1) + this.#removeTrailingEntries = array => { + for (let i = 0; i < numTrailingEntries; i++) { + const length = getVarLenFromEnd(array) + array = array.subarray(0, -length) + } + if (multibyte) { + const length = (array[array.length - 1] & 0b11) + 1 + array = array.subarray(0, -length) + } + return array + } + } + decode(...args) { + return this.#decoder.decode(...args) + } + encode(...args) { + return this.#encoder.encode(...args) + } + loadRecord(index) { + return super.loadRecord(this.#start + index) + } + loadMagic(index) { + return super.loadMagic(this.#start + index) + } + loadText(index) { + return this.loadRecord(index + 1) + .then(buf => new Uint8Array(buf)) + .then(this.#removeTrailingEntries) + .then(this.#decompress) + } + async loadResource(index) { + const buf = await super.loadRecord(this.#resourceStart + index) + const magic = getString(buf.slice(0, 4)) + if (magic === 'FONT') return getFont(buf, this.unzlib) + if (magic === 'VIDE' || magic === 'AUDI') return buf.slice(12) + return buf + } + getNCX() { + const index = this.headers.mobi.indx + if (index < 0xffffffff) return getNCX(index, this.loadRecord.bind(this)) + } + getMetadata() { + const { mobi, exth } = this.headers + return { + identifier: mobi.uid.toString(), + title: unescapeHTML(exth?.title || this.decode(mobi.title)), + author: exth?.creator?.map(unescapeHTML), + publisher: unescapeHTML(exth?.publisher), + language: exth?.language ?? mobi.language, + published: exth?.date, + description: unescapeHTML(exth?.description), + subject: exth?.subject?.map(unescapeHTML), + rights: unescapeHTML(exth?.rights), + contributor: exth?.contributor, + } + } + async getCover() { + const { exth } = this.headers + const offset = exth?.coverOffset < 0xffffffff ? exth?.coverOffset + : exth?.thumbnailOffset < 0xffffffff ? exth?.thumbnailOffset : null + if (offset != null) { + const buf = await this.loadResource(offset) + return new Blob([buf]) + } + } +} + +const mbpPagebreakRegex = /<\s*(?:mbp:)?pagebreak[^>]*>/gi +const fileposRegex = /<[^<>]+filepos=['"]{0,1}(\d+)[^<>]*>/gi +const selfClosingRegex = /<(a|div|span|p)\s*\/>/gi + +const getIndent = el => { + let x = 0 + while (el) { + const parent = el.parentElement + if (parent) { + const tag = parent.tagName.toLowerCase() + if (tag === 'p') x += 1.5 + else if (tag === 'blockquote') x += 2 + } + el = parent + } + return x +} + +function rawBytesToString(uint8Array) { + const chunkSize = 0x8000 + let result = '' + for (let i = 0; i < uint8Array.length; i += chunkSize) { + result += String.fromCharCode.apply(null, uint8Array.subarray(i, i + chunkSize)) + } + return result +} + +class MOBI6 { + parser = new DOMParser() + serializer = new XMLSerializer() + #resourceCache = new Map() + #textCache = new Map() + #cache = new Map() + #sections + #fileposList = [] + #type = MIME.HTML + constructor(mobi) { + this.mobi = mobi + } + async init() { + const recordBuffers = [] + for (let i = 0; i < this.mobi.headers.palmdoc.numTextRecords; i++) { + const buf = await this.mobi.loadText(i) + recordBuffers.push(buf) + } + const totalLength = recordBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) + // load all text records in an array + const array = new Uint8Array(totalLength) + recordBuffers.reduce((offset, buf) => { + array.set(new Uint8Array(buf), offset) + return offset + buf.byteLength + }, 0) + // convert to string so we can use regex + // note that `filepos` are byte offsets + // so it needs to preserve each byte as a separate character + // (see https://stackoverflow.com/q/50198017) + const str = rawBytesToString(array) + + // split content into sections at each `` + this.#sections = [0] + .concat(Array.from(str.matchAll(mbpPagebreakRegex), m => m.index)) + .map((start, i, a) => { + const end = a[i + 1] ?? array.length + return { book: this, raw: array.subarray(start, end) } + }) + // get start and end filepos for each section + .map((section, i, arr) => { + section.start = arr[i - 1]?.end ?? 0 + section.end = section.start + section.raw.byteLength + return section + }) + + this.sections = this.#sections.map((section, index) => ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.end - section.start, + })) + + try { + this.landmarks = await this.getGuide() + const tocHref = this.landmarks + .find(({ type }) => type?.includes('toc'))?.href + if (tocHref) { + const { index } = this.resolveHref(tocHref) + const doc = await this.sections[index].createDocument() + let lastItem + let lastLevel = 0 + let lastIndent = 0 + const lastLevelOfIndent = new Map() + const lastParentOfLevel = new Map() + this.toc = Array.from(doc.querySelectorAll('a[filepos]')) + .reduce((arr, a) => { + const indent = getIndent(a) + const item = { + label: a.innerText?.trim() ?? '', + href: `filepos:${a.getAttribute('filepos')}`, + } + const level = indent > lastIndent ? lastLevel + 1 + : indent === lastIndent ? lastLevel + : lastLevelOfIndent.get(indent) ?? Math.max(0, lastLevel - 1) + if (level > lastLevel) { + if (lastItem) { + lastItem.subitems ??= [] + lastItem.subitems.push(item) + lastParentOfLevel.set(level, lastItem) + } + else arr.push(item) + } + else { + const parent = lastParentOfLevel.get(level) + if (parent) parent.subitems.push(item) + else arr.push(item) + } + lastItem = item + lastLevel = level + lastIndent = indent + lastLevelOfIndent.set(indent, level) + return arr + }, []) + } + } catch(e) { + console.warn(e) + } + + // get list of all `filepos` references in the book, + // which will be used to insert anchor elements + // because only then can they be referenced in the DOM + this.#fileposList = [...new Set( + Array.from(str.matchAll(fileposRegex), m => m[1]))] + .map(filepos => ({ filepos, number: Number(filepos) })) + .sort((a, b) => a.number - b.number) + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + async getGuide() { + const doc = await this.createDocument(this.#sections[0]) + return Array.from(doc.getElementsByTagName('reference'), ref => ({ + label: ref.getAttribute('title'), + type: ref.getAttribute('type')?.split(/\s/), + href: `filepos:${ref.getAttribute('filepos')}`, + })) + } + async loadResource(index) { + if (this.#resourceCache.has(index)) return this.#resourceCache.get(index) + const raw = await this.mobi.loadResource(index) + const url = URL.createObjectURL(new Blob([raw])) + this.#resourceCache.set(index, url) + return url + } + async loadRecindex(recindex) { + return this.loadResource(Number(recindex) - 1) + } + async replaceResources(doc) { + for (const img of doc.querySelectorAll('img[recindex]')) { + const recindex = img.getAttribute('recindex') + try { + img.src = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load image ${recindex}`) + } + } + for (const media of doc.querySelectorAll('[mediarecindex]')) { + const mediarecindex = media.getAttribute('mediarecindex') + const recindex = media.getAttribute('recindex') + try { + media.src = await this.loadRecindex(mediarecindex) + if (recindex) media.poster = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load media ${mediarecindex}`) + } + } + for (const a of doc.querySelectorAll('[filepos]')) { + const filepos = a.getAttribute('filepos') + a.href = `filepos:${filepos}` + } + } + async loadText(section) { + if (this.#textCache.has(section)) return this.#textCache.get(section) + const { raw } = section + + // insert anchor elements for each `filepos` + const fileposList = this.#fileposList + .filter(({ number }) => number >= section.start && number < section.end) + .map(obj => ({ ...obj, offset: obj.number - section.start })) + let arr = raw + if (fileposList.length) { + arr = raw.subarray(0, fileposList[0].offset) + fileposList.forEach(({ filepos, offset }, i) => { + const next = fileposList[i + 1] + const a = this.mobi.encode(``) + arr = concatTypedArray3(arr, a, raw.subarray(offset, next?.offset)) + }) + } + const str = this.mobi.decode(arr).replaceAll(mbpPagebreakRegex, '') + this.#textCache.set(section, str) + return str + } + #sanitize(str) { + // HTML5 ignores the `/` in `` for non-void elements, + // leaving the tag unclosed. Rewrite the ones we've seen in MOBI. + return str.replace(selfClosingRegex, '<$1>') + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(this.#sanitize(str), this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const doc = await this.createDocument(section) + + // inject default stylesheet + const style = doc.createElement('style') + doc.head.append(style) + // blockquotes in MOBI seem to have only a small left margin by default + // many books seem to rely on this, as it's the only way to set margin + // (since there's no CSS) + style.append(doc.createTextNode(`blockquote { + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 1em; + margin-inline-end: 0; + }`)) + + await this.replaceResources(doc) + const result = this.serializer.serializeToString(doc) + const url = URL.createObjectURL(new Blob([result], { type: this.#type })) + this.#cache.set(section, url) + return url + } + resolveHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex(section => section.end > number) + const anchor = doc => doc.getElementById(`filepos${filepos}`) + return { index, anchor } + } + splitTOCHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex(section => section.end > number) + return [index, `filepos${filepos}`] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + } + isExternal(uri) { + return /^(?!blob|filepos)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#resourceCache.values()) URL.revokeObjectURL(url) + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +// handlers for `kindle:` uris +const kindleResourceRegex = /kindle:(flow|embed):(\w+)(?:\?mime=(\w+\/[-+.\w]+))?/ +const kindlePosRegex = /kindle:pos:fid:(\w+):off:(\w+)/ +const parseResourceURI = str => { + const [resourceType, id, type] = str.match(kindleResourceRegex).slice(1) + return { resourceType, id: parseInt(id, 32), type } +} +const parsePosURI = str => { + const [fid, off] = str.match(kindlePosRegex).slice(1) + return { fid: parseInt(fid, 32), off: parseInt(off, 32) } +} +const makePosURI = (fid = 0, off = 0) => + `kindle:pos:fid:${fid.toString(32).toUpperCase().padStart(4, '0') + }:off:${off.toString(32).toUpperCase().padStart(10, '0')}` + +// `kindle:pos:` links are originally links that contain fragments identifiers +// so there should exist an element with `id` or `name` +// otherwise try to find one with an `aid` attribute +const getFragmentSelector = str => { + const match = str.match(/\s(id|name|aid)\s*=\s*['"]([^'"]*)['"]/i) + if (!match) return + const [, attr, value] = match + return `[${attr}="${CSS.escape(value)}"]` +} + +// replace asynchronously and sequentially +const replaceSeries = async (str, regex, f) => { + const matches = [] + str.replace(regex, (...args) => (matches.push(args), null)) + const results = [] + for (const args of matches) results.push(await f(...args)) + return str.replace(regex, () => results.shift()) +} + +const getPageSpread = properties => { + for (const p of properties) { + if (p === 'page-spread-left' || p === 'rendition:page-spread-left') + return 'left' + if (p === 'page-spread-right' || p === 'rendition:page-spread-right') + return 'right' + if (p === 'rendition:page-spread-center') return 'center' + } +} + +class KF8 { + parser = new DOMParser() + serializer = new XMLSerializer() + transformTarget = new EventTarget() + #cache = new Map() + #fragmentOffsets = new Map() + #fragmentSelectors = new Map() + #tables = {} + #sections + #fullRawLength + #rawHead = new Uint8Array() + #rawTail = new Uint8Array() + #lastLoadedHead = -1 + #lastLoadedTail = -1 + #type = MIME.XHTML + #inlineMap = new Map() + constructor(mobi) { + this.mobi = mobi + } + async init() { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { kf8 } = this.mobi.headers + + try { + const fdstBuffer = await loadRecord(kf8.fdst) + const fdst = getStruct(FDST_HEADER, fdstBuffer) + if (fdst.magic !== 'FDST') throw new Error('Missing FDST record') + const fdstTable = Array.from({ length: fdst.numEntries }, + (_, i) => 12 + i * 8) + .map(offset => [ + getUint(fdstBuffer.slice(offset, offset + 4)), + getUint(fdstBuffer.slice(offset + 4, offset + 8))]) + this.#tables.fdstTable = fdstTable + this.#fullRawLength = fdstTable[fdstTable.length - 1][1] + } catch {} + + const skelTable = (await getIndexData(kf8.skel, loadRecord)).table + .map(({ name, tagMap }, index) => ({ + index, name, + numFrag: tagMap[1][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + const fragData = await getIndexData(kf8.frag, loadRecord) + const fragTable = fragData.table.map(({ name, tagMap }) => ({ + insertOffset: parseInt(name), + selector: fragData.cncx[tagMap[2][0]], + index: tagMap[4][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + this.#tables.skelTable = skelTable + this.#tables.fragTable = fragTable + + this.#sections = skelTable.reduce((arr, skel) => { + const last = arr[arr.length - 1] + const fragStart = last?.fragEnd ?? 0, fragEnd = fragStart + skel.numFrag + const frags = fragTable.slice(fragStart, fragEnd) + const length = skel.length + frags.map(f => f.length).reduce((a, b) => a + b, 0) + const totalLength = (last?.totalLength ?? 0) + length + return arr.concat({ skel, frags, fragEnd, length, totalLength }) + }, []) + + const resources = await this.getResourcesByMagic(['RESC', 'PAGE']) + const pageSpreads = new Map() + if (resources.RESC) { + const buf = await this.mobi.loadRecord(resources.RESC) + const str = this.mobi.decode(buf.slice(16)).replace(/\0/g, '') + // the RESC record lacks the root `` element + // but seem to be otherwise valid XML + const index = str.search(/\?>/) + const xmlStr = `${str.slice(index)}` + const opf = this.parser.parseFromString(xmlStr, MIME.XML) + for (const $itemref of opf.querySelectorAll('spine > itemref')) { + const i = parseInt($itemref.getAttribute('skelid')) + pageSpreads.set(i, getPageSpread( + $itemref.getAttribute('properties')?.split(' ') ?? [])) + } + } + + this.sections = this.#sections.map((section, index) => + section.frags.length ? ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.length, + pageSpread: pageSpreads.get(index), + }) : ({ linear: 'no' })) + + try { + const ncx = await this.mobi.getNCX() + const map = ({ label, pos, children }) => { + const [fid, off] = pos + const href = makePosURI(fid, off) + const arr = this.#fragmentOffsets.get(fid) + if (arr) arr.push(off) + else this.#fragmentOffsets.set(fid, [off]) + return { label: unescapeHTML(label), href, subitems: children?.map(map) } + } + this.toc = ncx?.map(map) + this.landmarks = await this.getGuide() + } catch(e) { + console.warn(e) + } + + const { exth } = this.mobi.headers + this.dir = exth.pageProgressionDirection + this.rendition = { + layout: exth.fixedLayout === 'true' ? 'pre-paginated' : 'reflowable', + viewport: Object.fromEntries(exth.originalResolution + ?.split('x')?.slice(0, 2) + ?.map((x, i) => [i ? 'height' : 'width', x]) ?? []), + } + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + // is this really the only way of getting to RESC, PAGE, etc.? + async getResourcesByMagic(keys) { + const results = {} + const start = this.mobi.headers.kf8.resourceStart + const end = this.mobi.pdb.numRecords + for (let i = start; i < end; i++) { + try { + const magic = await this.mobi.loadMagic(i) + const match = keys.find(key => key === magic) + if (match) results[match] = i + } catch {} + } + return results + } + async getGuide() { + const index = this.mobi.headers.kf8.guide + if (index < 0xffffffff) { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { table, cncx } = await getIndexData(index, loadRecord) + return table.map(({ name, tagMap }) => ({ + label: cncx[tagMap[1][0]] ?? '', + type: name?.split(/\s/), + href: makePosURI(tagMap[6]?.[0] ?? tagMap[3]?.[0]), + })) + } + } + async loadResourceBlob(str) { + const { resourceType, id, type } = parseResourceURI(str) + const raw = resourceType === 'flow' ? await this.loadFlow(id) + : await this.mobi.loadResource(id - 1) + const result = [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(type) + ? await this.replaceResources(this.mobi.decode(raw)) : raw + const detail = { data: result, type } + const event = new CustomEvent('data', { detail }) + this.transformTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const doc = newType === MIME.SVG ? this.parser.parseFromString(newData, newType) : null + return [new Blob([newData], { newType }), + // SVG wrappers need to be inlined + // as browsers don't allow external resources when loading SVG as an image + doc?.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'image')?.length + ? doc.documentElement : null] + } + async loadResource(str) { + if (this.#cache.has(str)) return this.#cache.get(str) + const [blob, inline] = await this.loadResourceBlob(str) + const url = inline ? str : URL.createObjectURL(blob) + if (inline) this.#inlineMap.set(url, inline) + this.#cache.set(str, url) + return url + } + replaceResources(str) { + const regex = new RegExp(kindleResourceRegex, 'g') + return replaceSeries(str, regex, this.loadResource.bind(this)) + } + // NOTE: there doesn't seem to be a way to access text randomly? + // how to know the decompressed size of the records without decompressing? + // 4096 is just the maximum size + async loadRaw(start, end) { + // here we load either from the front or back until we have reached the + // required offsets; at worst you'd have to load half the book at once + const distanceHead = end - this.#rawHead.length + const distanceEnd = this.#fullRawLength == null ? Infinity + : (this.#fullRawLength - this.#rawTail.length) - start + // load from the start + if (distanceHead < 0 || distanceHead < distanceEnd) { + while (this.#rawHead.length < end) { + const index = ++this.#lastLoadedHead + const data = await this.mobi.loadText(index) + this.#rawHead = concatTypedArray(this.#rawHead, data) + } + return this.#rawHead.slice(start, end) + } + // load from the end + while (this.#fullRawLength - this.#rawTail.length > start) { + const index = this.mobi.headers.palmdoc.numTextRecords - 1 + - (++this.#lastLoadedTail) + const data = await this.mobi.loadText(index) + this.#rawTail = concatTypedArray(data, this.#rawTail) + } + const rawTailStart = this.#fullRawLength - this.#rawTail.length + return this.#rawTail.slice(start - rawTailStart, end - rawTailStart) + } + loadFlow(index) { + if (index < 0xffffffff) + return this.loadRaw(...this.#tables.fdstTable[index]) + } + async loadText(section) { + const { skel, frags, length } = section + const raw = await this.loadRaw(skel.offset, skel.offset + length) + let skeleton = raw.slice(0, skel.length) + for (const frag of frags) { + const insertOffset = frag.insertOffset - skel.offset + const offset = skel.length + frag.offset + const fragRaw = raw.slice(offset, offset + frag.length) + skeleton = concatTypedArray3( + skeleton.slice(0, insertOffset), fragRaw, + skeleton.slice(insertOffset)) + + const offsets = this.#fragmentOffsets.get(frag.index) + if (offsets) for (const offset of offsets) { + const str = this.mobi.decode(fragRaw.slice(offset)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(frag.index, offset, selector) + } + } + return this.mobi.decode(skeleton) + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const str = await this.loadText(section) + const replaced = await this.replaceResources(str) + + // by default, type is XHTML; change to HTML if it's not valid XHTML + let doc = this.parser.parseFromString(replaced, this.#type) + if (doc.querySelector('parsererror') || !doc.documentElement?.namespaceURI) { + this.#type = MIME.HTML + doc = this.parser.parseFromString(replaced, this.#type) + } + for (const [url, node] of this.#inlineMap) { + for (const el of doc.querySelectorAll(`img[src="${url}"]`)) + el.replaceWith(node) + } + const url = URL.createObjectURL( + new Blob([this.serializer.serializeToString(doc)], { type: this.#type })) + this.#cache.set(section, url) + return url + } + getIndexByFID(fid) { + return this.#sections.findIndex(section => + section.frags.some(frag => frag.index === fid)) + } + #setFragmentSelector(id, offset, selector) { + const map = this.#fragmentSelectors.get(id) + if (map) map.set(offset, selector) + else { + const map = new Map() + this.#fragmentSelectors.set(id, map) + map.set(offset, selector) + } + } + async resolveHref(href) { + const { fid, off } = parsePosURI(href) + const index = this.getIndexByFID(fid) + if (index < 0) return + + const saved = this.#fragmentSelectors.get(fid)?.get(off) + if (saved) return { index, anchor: doc => doc.querySelector(saved) } + + const { skel, frags } = this.#sections[index] + const frag = frags.find(frag => frag.index === fid) + const offset = skel.offset + skel.length + frag.offset + const fragRaw = await this.loadRaw(offset, offset + frag.length) + const str = this.mobi.decode(fragRaw.slice(off)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(fid, off, selector) + const anchor = doc => doc.querySelector(selector) + return { index, anchor } + } + splitTOCHref(href) { + const pos = parsePosURI(href) + const index = this.getIndexByFID(pos.fid) + return [index, pos] + } + getTOCFragment(doc, { fid, off }) { + const selector = this.#fragmentSelectors.get(fid)?.get(off) + return doc.querySelector(selector) + } + isExternal(uri) { + return /^(?!blob|kindle)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +// Standalone MOBI metadata + cover extractor. +// +// Drives just enough of `MOBI.open()` to populate `this.headers` and +// `this.decode()` — the PalmDB header, the record offsets table, +// record 0 (PalmDoc header + MobiHeader + EXTH), and the decoder / +// decompressor hookup. Crucially it does **not** drive +// `new MOBI6(this).init()` / `new KF8(this).init()`: those walk every +// text record in the book (decompressing and parsing fdst/skel/frag +// indices), which is the expensive part of opening a MOBI and the +// importer never reads any of their outputs. +// +// The result is byte-stable against `MOBI.open()`'s metadata path — +// `identifier` is `mobi.uid.toString()` (PalmDB UID, foliate's +// canonical MOBI identifier), title/author/etc. go through the same +// `unescapeHTML` paths in `getMetadata()` — so callers (e.g. a +// platform-native pre-parser) can build a `Book.metadata` that +// matches what the reader path produces, without paying for the +// `init()` work the importer doesn't consume. +// +// Returns `{ metadata, getCover }` where `getCover` is a thunk that +// loads the cover record on demand (the importer typically prefers +// a pre-resized cover from the native side and ignores this). +export const readMobiMetadata = async (file, { unzlib } = {}) => { + const m = await new MOBI({ unzlib }).open(file, { metadataOnly: true }) + return { + metadata: m.getMetadata(), + getCover: m.getCover.bind(m), + } +} diff --git a/frontend/src/lib/vendor/foliate-js/overlayer.js b/frontend/src/lib/vendor/foliate-js/overlayer.js new file mode 100644 index 0000000..7060cb5 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/overlayer.js @@ -0,0 +1,437 @@ +const createSVGElement = tag => + document.createElementNS('http://www.w3.org/2000/svg', tag) + +let overlayerCounter = 0 + +export class Overlayer { + #svg = createSVGElement('svg') + #map = new Map() + #doc = null + #clipPath = null + #clipPathPath = null + #clipPathId + + constructor(doc) { + this.#doc = doc + this.#clipPathId = `foliate-loupe-clip-${overlayerCounter++}` + Object.assign(this.#svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + pointerEvents: 'none', + }) + + // Create a clipPath to cut a hole for the loupe. + // We use clip-rule="evenodd" with a large outer rect and inner circle + // to create the hole effect efficiently without mask compositing. + const defs = createSVGElement('defs') + this.#clipPath = createSVGElement('clipPath') + this.#clipPath.setAttribute('id', this.#clipPathId) + this.#clipPath.setAttribute('clipPathUnits', 'userSpaceOnUse') + + this.#clipPathPath = createSVGElement('path') + this.#clipPathPath.setAttribute('clip-rule', 'evenodd') + this.#clipPathPath.setAttribute('fill-rule', 'evenodd') // for older renderers + + this.#clipPath.append(this.#clipPathPath) + defs.append(this.#clipPath) + this.#svg.append(defs) + } + + setHole(cx, cy, w, h, r) { + // Define a path with a large outer rect and a capsule-shaped hole. + // The capsule is a rounded rectangle (stadium shape) centred at (cx, cy). + const outer = 'M -2000000 -2000000 H 4000000 V 4000000 H -2000000 Z' + const hw = w / 2, hh = h / 2 + const cr = Math.min(r, hw, hh) // clamp corner radius + const inner = `M ${cx - hw + cr} ${cy - hh}` + + ` H ${cx + hw - cr}` + + ` A ${cr} ${cr} 0 0 1 ${cx + hw} ${cy - hh + cr}` + + ` V ${cy + hh - cr}` + + ` A ${cr} ${cr} 0 0 1 ${cx + hw - cr} ${cy + hh}` + + ` H ${cx - hw + cr}` + + ` A ${cr} ${cr} 0 0 1 ${cx - hw} ${cy + hh - cr}` + + ` V ${cy - hh + cr}` + + ` A ${cr} ${cr} 0 0 1 ${cx - hw + cr} ${cy - hh} Z` + this.#clipPathPath.setAttribute('d', `${outer} ${inner}`) + + this.#svg.setAttribute('clip-path', `url(#${this.#clipPathId})`) + this.#svg.style.webkitClipPath = `url(#${this.#clipPathId})` + } + + clearHole() { + this.#svg.removeAttribute('clip-path') + this.#svg.style.webkitClipPath = '' + this.#clipPathPath.removeAttribute('d') + } + + get element() { + return this.#svg + } + get #zoom() { + // Safari does not zoom the client rects, while Chrome, Edge and Firefox does + if (/^((?!chrome|android).)*AppleWebKit/i.test(navigator.userAgent) && !window.chrome) { + return window.getComputedStyle(this.#doc.body).zoom || 1.0 + } + return 1.0 + } + // Split a range into per-text-node sub-ranges (plus replaced elements + // like images), so `getClientRects()` only ever returns line-level boxes. + // Collecting rects on the whole range would also include the border boxes + // of fully contained block elements, over-highlighting blank space. + #splitRange(range) { + const ancestor = range.commonAncestorContainer + if (ancestor.nodeType !== Node.ELEMENT_NODE + && ancestor.nodeType !== Node.DOCUMENT_NODE) return [range] + const doc = ancestor.ownerDocument ?? ancestor + const walker = doc.createTreeWalker(ancestor, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { + acceptNode: node => { + if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT + // Ruby annotations sit on their own line above (or beside) + // the base, so their rects would draw a second detached box + // over the furigana. Never paint them — not the book's own + // ruby, not injected glosses. + const el = node.nodeType === Node.TEXT_NODE + ? node.parentElement : node + if (el?.closest?.('rt, rp, rtc, [cfi-inert]')) + return NodeFilter.FILTER_REJECT + if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT + return node.matches?.('img, svg') + ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP + }, + }) + const splitRanges = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const subRange = doc.createRange() + if (node.nodeType === Node.TEXT_NODE) { + subRange.selectNodeContents(node) + if (subRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) { + subRange.setStart(range.startContainer, range.startOffset) + } + if (subRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) { + subRange.setEnd(range.endContainer, range.endOffset) + } + } else subRange.selectNode(node) + splitRanges.push(subRange) + } + return splitRanges.length === 0 ? [range] : splitRanges + } + #getRects(range) { + const zoom = this.#zoom + const rects = [] + for (const subRange of this.#splitRange(range)) { + for (const rect of subRange.getClientRects()) { + rects.push({ + left: rect.left * zoom, + top: rect.top * zoom, + right: rect.right * zoom, + bottom: rect.bottom * zoom, + width: rect.width * zoom, + height: rect.height * zoom, + }) + } + } + return rects + } + add(key, range, draw, options) { + if (this.#map.has(key)) this.remove(key) + if (typeof range === 'function') range = range(this.#svg.getRootNode()) + const rects = this.#getRects(range) + const element = draw(rects, options) + this.#svg.append(element) + this.#map.set(key, { range, draw, options, element, rects }) + } + remove(key) { + if (!this.#map.has(key)) return + this.#svg.removeChild(this.#map.get(key).element) + this.#map.delete(key) + } + redraw() { + for (const obj of this.#map.values()) { + const { range, draw, options, element } = obj + this.#svg.removeChild(element) + const rects = this.#getRects(range) + const el = draw(rects, options) + this.#svg.append(el) + obj.element = el + obj.rects = rects + } + } + hitTest({ x, y }) { + const arr = Array.from(this.#map.entries()) + // loop in reverse to hit more recently added items first + for (let i = arr.length - 1; i >= 0; i--) { + const tolerance = 5 + const [key, obj] = arr[i] + for (const { left, top, right, bottom } of obj.rects) { + if ( + top <= y + tolerance && + left <= x + tolerance && + bottom > y - tolerance && + right > x - tolerance + ) { + return [key, obj.range, { left, top, right, bottom }] + } + } + } + return [] + } + static underline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, padding = 0, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', right - strokeWidth / 2 + padding) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else for (const { left, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', bottom - strokeWidth / 2 + padding) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static strikethrough(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, left, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', (right + left) / 2) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else for (const { left, top, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', (top + bottom) / 2) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static squiggly(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, padding = 0, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + const block = strokeWidth * 1.5 + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('path') + const n = Math.round(height / block / 1.5) + const inline = height / n + const ls = Array.from({ length: n }, + (_, i) => `l${i % 2 ? -block : block} ${inline}`).join('') + el.setAttribute('d', `M${right - strokeWidth / 2 + padding} ${top}${ls}`) + g.append(el) + } + else for (const { left, bottom, width } of rects) { + const el = createSVGElement('path') + const n = Math.round(width / block / 1.5) + const inline = width / n + const ls = Array.from({ length: n }, + (_, i) => `l${inline} ${i % 2 ? block : -block}`).join('') + el.setAttribute('d', `M${left} ${bottom + strokeWidth / 2 + padding}${ls}`) + g.append(el) + } + return g + } + static highlight(rects, options = {}) { + const { + color = 'red', + padding = 0, + radius = 4, + radiusPadding = 2, + vertical = false, + } = options + + const g = createSVGElement('g') + g.setAttribute('fill', color) + g.style.opacity = 'var(--overlayer-highlight-opacity, .3)' + g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)' + + for (const [index, { left, top, height, width }] of rects.entries()) { + const isFirst = index === 0 + const isLast = index === rects.length - 1 + + let x, y, w, h + + let radiusTopLeft, radiusTopRight, radiusBottomRight, radiusBottomLeft + + if (vertical) { + x = left - padding + y = top - padding - (isFirst ? radiusPadding : 0) + w = width + padding * 2 + h = height + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0) + radiusTopLeft = isFirst ? radius : 0 + radiusTopRight = isFirst ? radius : 0 + radiusBottomRight = isLast ? radius : 0 + radiusBottomLeft = isLast ? radius : 0 + } else { + x = left - padding - (isFirst ? radiusPadding : 0) + y = top - padding + w = width + padding * 2 + (isFirst ? radiusPadding : 0) + (isLast ? radiusPadding : 0) + h = height + padding * 2 + radiusTopLeft = isFirst ? radius : 0 + radiusTopRight = isLast ? radius : 0 + radiusBottomRight = isLast ? radius : 0 + radiusBottomLeft = isFirst ? radius : 0 + } + + const rtl = Math.min(radiusTopLeft, w / 2, h / 2) + const rtr = Math.min(radiusTopRight, w / 2, h / 2) + const rbr = Math.min(radiusBottomRight, w / 2, h / 2) + const rbl = Math.min(radiusBottomLeft, w / 2, h / 2) + + if (rtl === 0 && rtr === 0 && rbr === 0 && rbl === 0) { + const el = createSVGElement('rect') + el.setAttribute('x', x) + el.setAttribute('y', y) + el.setAttribute('height', h) + el.setAttribute('width', w) + g.append(el) + } else { + const el = createSVGElement('path') + const d = ` + M ${x + rtl} ${y} + L ${x + w - rtr} ${y} + ${rtr > 0 ? `Q ${x + w} ${y} ${x + w} ${y + rtr}` : `L ${x + w} ${y}`} + L ${x + w} ${y + h - rbr} + ${rbr > 0 ? `Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` : `L ${x + w} ${y + h}`} + L ${x + rbl} ${y + h} + ${rbl > 0 ? `Q ${x} ${y + h} ${x} ${y + h - rbl}` : `L ${x} ${y + h}`} + L ${x} ${y + rtl} + ${rtl > 0 ? `Q ${x} ${y} ${x + rtl} ${y}` : `L ${x} ${y}`} + Z + `.trim().replace(/\s+/g, ' ') + el.setAttribute('d', d) + g.append(el) + } + } + return g + } + static outline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 3, padding = 0, radius = 3 } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left - padding) + el.setAttribute('y', top - padding) + el.setAttribute('height', height + padding * 2) + el.setAttribute('width', width + padding * 2) + el.setAttribute('rx', radius) + g.append(el) + } + return g + } + static bubble(rects, options = {}) { + const { color = '#fbbf24', writingMode, opacity = 0.85, size = 20, padding = 10 } = options + const isVertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr' + const g = createSVGElement('g') + g.style.opacity = opacity + if (rects.length === 0) return g + rects.splice(1) + const firstRect = rects[0] + const x = isVertical ? firstRect.right - size + padding : firstRect.right - size + padding + const y = isVertical ? firstRect.bottom - size + padding : firstRect.top - size + padding + firstRect.top = y - padding + firstRect.right = x + size + padding + firstRect.bottom = y + size + padding + firstRect.left = x - padding + const bubble = createSVGElement('path') + const s = size + const r = s * 0.15 + // Speech bubble shape with a small tail + // Main rounded rectangle body + const d = ` + M ${x + r} ${y} + h ${s - 2 * r} + a ${r} ${r} 0 0 1 ${r} ${r} + v ${s * 0.65 - 2 * r} + a ${r} ${r} 0 0 1 ${-r} ${r} + h ${-s * 0.3} + l ${-s * 0.15} ${s * 0.2} + l ${s * 0.05} ${-s * 0.2} + h ${-s * 0.6 + 2 * r} + a ${r} ${r} 0 0 1 ${-r} ${-r} + v ${-s * 0.65 + 2 * r} + a ${r} ${r} 0 0 1 ${r} ${-r} + z + `.replace(/\s+/g, ' ').trim() + + bubble.setAttribute('d', d) + bubble.setAttribute('fill', color) + bubble.setAttribute('stroke', 'rgba(0, 0, 0, 0.2)') + bubble.setAttribute('stroke-width', '1') + // Add horizontal lines inside to represent text + const lineGroup = createSVGElement('g') + lineGroup.setAttribute('stroke', 'rgba(0, 0, 0, 0.3)') + lineGroup.setAttribute('stroke-width', '1.5') + lineGroup.setAttribute('stroke-linecap', 'round') + const lineY1 = y + s * 0.18 + const lineY2 = y + s * 0.33 + const lineY3 = y + s * 0.48 + const lineX1 = x + s * 0.2 + const lineX2 = x + s * 0.8 + const line1 = createSVGElement('line') + line1.setAttribute('x1', lineX1) + line1.setAttribute('y1', lineY1) + line1.setAttribute('x2', lineX2) + line1.setAttribute('y2', lineY1) + const line2 = createSVGElement('line') + line2.setAttribute('x1', lineX1) + line2.setAttribute('y1', lineY2) + line2.setAttribute('x2', lineX2) + line2.setAttribute('y2', lineY2) + const line3 = createSVGElement('line') + line3.setAttribute('x1', lineX1) + line3.setAttribute('y1', lineY3) + line3.setAttribute('x2', x + s * 0.6) + line3.setAttribute('y2', lineY3) + lineGroup.append(line1, line2, line3) + + if (isVertical) { + const centerX = x + s / 2 + const centerY = y + s / 2 + bubble.setAttribute('transform', `rotate(90 ${centerX} ${centerY})`) + lineGroup.setAttribute('transform', `rotate(90 ${centerX} ${centerY})`) + } + + g.append(bubble) + g.append(lineGroup) + return g + } + // make an exact copy of an image in the overlay + // one can then apply filters to the entire element, without affecting them; + // it's a bit silly and probably better to just invert images twice + // (though the color will be off in that case if you do heu-rotate) + static copyImage([rect], options = {}) { + const { src } = options + const image = createSVGElement('image') + const { left, top, height, width } = rect + image.setAttribute('href', src) + image.setAttribute('x', left) + image.setAttribute('y', top) + image.setAttribute('height', height) + image.setAttribute('width', width) + return image + } +} + diff --git a/frontend/src/lib/vendor/foliate-js/paginator.js b/frontend/src/lib/vendor/foliate-js/paginator.js new file mode 100644 index 0000000..8aa2174 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/paginator.js @@ -0,0 +1,3794 @@ +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +const debounce = (f, wait, immediate) => { + let timeout + return (...args) => { + const later = () => { + timeout = null + if (!immediate) f(...args) + } + const callNow = immediate && !timeout + if (timeout) clearTimeout(timeout) + timeout = setTimeout(later, wait) + if (callNow) f(...args) + } +} + +// Transforms ALL children of the container so multi-view layouts +// animate as a unified whole. Extra elements (e.g. background) are +// also transformed so they slide in sync with the content. +const cssAnimateScroll = (element, scrollProp, startValue, endValue, duration, extraElements = []) => new Promise(resolve => { + if (document.hidden) { + element[scrollProp] = endValue + return resolve() + } + + const children = [...element.children] + if (!children.length) { + element[scrollProp] = endValue + return resolve() + } + + const allElements = [...children, ...extraElements] + const isHorizontal = scrollProp === 'scrollLeft' + const delta = endValue - startValue + const transformProp = isHorizontal ? 'translateX' : 'translateY' + + // Prepare all elements for animation + for (const el of allElements) { + el.style.willChange = 'transform' + el.style.transform = `${transformProp}(0px)` + el.style.transition = 'none' + } + + // Force reflow to apply initial state + element.getBoundingClientRect() + + // Start animation on all elements + for (const el of allElements) { + el.style.transition = `transform ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` + el.style.transform = `${transformProp}(${-delta}px)` + } + + let resolved = false + const cleanup = () => { + if (resolved) return + resolved = true + + for (const el of allElements) { + el.style.willChange = '' + el.style.transform = '' + el.style.transition = '' + } + + // Apply final scroll position + element[scrollProp] = endValue + resolve() + } + + // Listen for transition end on the first child + const first = children[0] + const onTransitionEnd = (e) => { + if (e.target === first && e.propertyName === 'transform') { + first.removeEventListener('transitionend', onTransitionEnd) + cleanup() + } + } + first.addEventListener('transitionend', onTransitionEnd) + + // Fallback timeout in case transitionend doesn't fire + setTimeout(cleanup, duration + 50) +}) + +// Two-phase page-turn slide for vertical (vertical-rl/lr) paginated books. +// Their pages read horizontally but CSS fragmentation stacks them along the +// vertical scroll axis, so the outgoing and incoming page can never be on +// screen side by side (readest#624). Instead the outgoing page exits +// horizontally along the page progression, the scroll offset jumps while the +// viewport shows only the page background, and the incoming page follows in +// from the opposite edge. `startX` continues from a finger drag already in +// progress; `isStale` lets a newer turn supersede this one: when it reports +// true, this animation stops touching the DOM. +const slideTurnAnimation = (element, scrollProp, endValue, exitSign, width, duration, isStale, onSwap, startX = 0) => new Promise(resolve => { + const children = [...element.children] + if (document.hidden || !children.length) { + element[scrollProp] = endValue + return resolve() + } + const half = duration / 2 + const exitTarget = exitSign * width + // Scale the exit by the distance the drag already covered so a released + // drag continues at the same pace instead of restarting from rest. + const exitDuration = Math.max(16, half * Math.min(1, Math.abs(exitTarget - startX) / width)) + const setAll = (transition, transform) => { + for (const el of children) { + el.style.transition = transition + el.style.transform = transform + } + } + for (const el of children) el.style.willChange = 'transform' + // Phase 1: the outgoing page accelerates off-screen. + setAll('none', `translateX(${startX}px)`) + element.getBoundingClientRect() + setAll(`transform ${exitDuration}ms cubic-bezier(0.55, 0, 1, 0.45)`, `translateX(${exitTarget}px)`) + setTimeout(() => { + if (isStale()) return resolve() + // Midpoint: swap pages while everything is off-screen. + element[scrollProp] = endValue + onSwap?.() + setAll('none', `translateX(${-exitSign * width}px)`) + element.getBoundingClientRect() + // Phase 2: the incoming page decelerates into place. + setAll(`transform ${half}ms cubic-bezier(0, 0.55, 0.45, 1)`, 'translateX(0px)') + setTimeout(() => { + if (isStale()) return resolve() + for (const el of children) { + el.style.willChange = '' + el.style.transition = '' + el.style.transform = '' + } + resolve() + }, half + 20) + }, exitDuration + 10) +}) + +// Layered page-turn styles (readest#555). The `slide` and `curl` turn styles +// need the outgoing and incoming page on screen at once as separate layers, +// which the rigid column strip inside one iframe cannot provide. The View +// Transitions API can: the browser rasterizes the outgoing page (overlays and +// annotations included) as a snapshot that animates over the live, stationary +// incoming page — an Apple Books style slide or curl. The choreography lives +// in a document-level stylesheet because the ::view-transition pseudo tree +// attaches to the document root, not to the paginator's shadow root. +const VIEW_TRANSITION_CLASSES = [ + 'foliate-vt', 'foliate-vt-slide', 'foliate-vt-curl', + 'foliate-vt-scrub', + 'foliate-vt-forward', 'foliate-vt-backward', + 'foliate-vt-left', 'foliate-vt-right', + 'foliate-vt-eat-left', 'foliate-vt-eat-right', +] + +const RELEASE_VELOCITY_WINDOW_MS = 90 +const RELEASE_PAUSE_THRESHOLD_MS = 80 +const SLIDE_RELEASE_PROJECTION_MS = 240 +const LAYERED_EDGE_REGION = 0.18 +const LAYERED_EARLY_CLAIM_PX = 6 +const LAYERED_EARLY_SAMPLE_INTERVAL_MS = 80 +const LAYERED_VERTICAL_REJECT_PX = 8 +const LAYERED_FALLBACK_CLAIM_PX = 24 +const LAYERED_FALLBACK_DOMINANCE = 1.5 + +const updateReleaseSample = (state, distance, time) => { + const previous = state.releaseSamples.at(-1) + if (!previous || distance !== previous.distance) state.lastMovementTime = time + if (previous?.time === time) previous.distance = distance + else state.releaseSamples.push({ distance, time }) + + const cutoff = time - RELEASE_VELOCITY_WINDOW_MS + while (state.releaseSamples.length > 2 + && state.releaseSamples[1].time < cutoff) state.releaseSamples.shift() +} + +const getReleaseVelocity = state => { + const latest = state.releaseSamples.at(-1) + if (!latest || latest.time - state.lastMovementTime > RELEASE_PAUSE_THRESHOLD_MS) return 0 + + const cutoff = latest.time - RELEASE_VELOCITY_WINDOW_MS + const before = state.releaseSamples[0] + const after = state.releaseSamples.find(sample => sample.time >= cutoff) + if (!before || !after) return 0 + + const startTime = Math.max(cutoff, before.time) + if (latest.time <= startTime) return 0 + const interval = after.time - before.time + const startDistance = interval > 0 && startTime > before.time + ? before.distance + + (after.distance - before.distance) * (startTime - before.time) / interval + : after.distance + return (latest.distance - startDistance) / (latest.time - startTime) +} + +// Release speed controls the remaining settle rate for both layered styles. +// Slide can carry more momentum than the heavier curl. +const LAYERED_SETTLE_CONFIG = { + slide: { minSpeed: 0.2, maxSpeed: 1, maxRate: 2 }, + curl: { minSpeed: 0.3, maxSpeed: 1.5, maxRate: 1.5 }, +} + +const layeredSettlePlaybackRate = (style, speed) => { + const config = LAYERED_SETTLE_CONFIG[style] + if (!config || !(speed > config.minSpeed)) return 1 + const { minSpeed, maxSpeed, maxRate } = config + const amount = Math.min(1, (speed - minSpeed) / (maxSpeed - minSpeed)) + return 1 + amount * (maxRate - 1) +} + +const updatePlaybackRate = (animation, rate) => { + if (rate === 1) return + try { + animation.updatePlaybackRate(rate) + return + } catch { /* unsupported for this UA animation */ } + try { animation.playbackRate = rate } catch { /* unsupported */ } +} + +const injectViewTransitionStyles = () => { + const id = 'foliate-view-transition-styles' + if (document.getElementById(id)) return + const style = document.createElement('style') + style.id = id + style.textContent = ` + .foliate-vt::view-transition { + pointer-events: none; + } + /* Only the page turn animates; keep the root snapshot inert. */ + .foliate-vt::view-transition-old(root), + .foliate-vt::view-transition-new(root) { + animation: none; + } + /* The turn layers must OCCLUDE, not blend: the UA pairs old/new with + mix-blend-mode: plus-lighter for its default cross-fade, which turns + the still page ghostly under a moving page. Also back both layers + with the page colour, since snapshots of textured themes or books + without a background are transparent. */ + .foliate-vt::view-transition-old(foliate-turn), + .foliate-vt::view-transition-new(foliate-turn) { + animation: none; + background: var(--foliate-vt-bg, Canvas); + mix-blend-mode: normal; + } + /* Slide: the moving page travels over the still page with a soft edge + shadow, like the Apple Books slide. Forward moves the outgoing + snapshot out on top; backward brings the incoming snapshot in on + top of the still outgoing page. */ + .foliate-vt-slide.foliate-vt-forward::view-transition-old(foliate-turn) { + z-index: 1; + animation: foliate-turn-slide-out-left 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + box-shadow: 0 0 24px rgba(0, 0, 0, 0.35); + } + .foliate-vt-slide.foliate-vt-forward.foliate-vt-right::view-transition-old(foliate-turn) { + animation-name: foliate-turn-slide-out-right; + } + .foliate-vt-slide.foliate-vt-backward::view-transition-new(foliate-turn) { + animation: foliate-turn-slide-in-left 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + box-shadow: 0 0 24px rgba(0, 0, 0, 0.35); + } + .foliate-vt-slide.foliate-vt-backward.foliate-vt-right::view-transition-new(foliate-turn) { + animation-name: foliate-turn-slide-in-right; + } + /* Finger-tracked turns map distance directly to animation time. Declare + linear timing at the CSS source because some Android WebViews expose + UA pseudo animations but reject KeyframeEffect.updateTiming(). */ + .foliate-vt-scrub::view-transition-old(foliate-turn), + .foliate-vt-scrub::view-transition-new(foliate-turn) { + animation-timing-function: linear !important; + } + @keyframes foliate-turn-slide-out-left { to { transform: translateX(-100%); } } + @keyframes foliate-turn-slide-out-right { to { transform: translateX(100%); } } + @keyframes foliate-turn-slide-in-left { from { transform: translateX(-100%); } } + @keyframes foliate-turn-slide-in-right { from { transform: translateX(100%); } } + /* Curl: a fold line travels across the page, peeling it off the still + page underneath like the Apple Books / Kindle curl. The fold is an + oversized gradient mask slid across the OLD snapshot (mask-position is + animatable; gradients themselves are not), so the page dissolves over + a soft band at the traveling edge — the lifted-page falloff. Chrome + paints masks on the static old snapshot but not on the live new layer, + so backward turns also choreograph the old page: it recedes from the + spine side, which reads the same as the incoming page unfolding. + Filters and shadows are applied before masking and would be cut off + with the page, hence the soft edge. A flat snapshot cannot mesh-bend + like a native curl; this is the closest two-layer approximation. */ + /* The curl consumes the old page along a CURVED fold: a transparent + disc grows out of the page's outer-bottom corner (the corner a reader + lifts), so the fold edge is an arc sweeping across the page toward + the spine — the bent-page line of a corner curl. Backward turns grow + the arc from the spine-side corner instead, receding the old page so + the previous page appears to unfold (eat side precomputed on the + root). The fold edge is an animated gradient STOP (registered custom + property) re-rasterized each frame against the element box: + mask-position/mask-size animations paint unreliably on + view-transition pseudos. The 6% band is the lifted-page falloff. */ + @property --foliate-fold { + syntax: ''; + inherits: false; + initial-value: 0%; + } + .foliate-vt-curl.foliate-vt-eat-right::view-transition-old(foliate-turn) { + z-index: 1; + -webkit-mask-image: radial-gradient(circle at 108% 108%, transparent calc(var(--foliate-fold) - 6%), black var(--foliate-fold)); + mask-image: radial-gradient(circle at 108% 108%, transparent calc(var(--foliate-fold) - 6%), black var(--foliate-fold)); + animation: foliate-turn-curl-fold 450ms cubic-bezier(0.3, 0.1, 0.4, 1) both; + } + .foliate-vt-curl.foliate-vt-eat-left::view-transition-old(foliate-turn) { + z-index: 1; + -webkit-mask-image: radial-gradient(circle at -8% 108%, transparent calc(var(--foliate-fold) - 6%), black var(--foliate-fold)); + mask-image: radial-gradient(circle at -8% 108%, transparent calc(var(--foliate-fold) - 6%), black var(--foliate-fold)); + animation: foliate-turn-curl-fold 450ms cubic-bezier(0.3, 0.1, 0.4, 1) both; + } + .foliate-vt-curl::view-transition-new(foliate-turn) { + animation: none; + } + @keyframes foliate-turn-curl-fold { + from { --foliate-fold: 0%; } + to { --foliate-fold: 118%; } + } + ` + document.head.append(style) +} + +const lerp = (min, max, x) => x * (max - min) + min +const easeOutQuad = x => 1 - (1 - x) * (1 - x) +// rAF animation of a scalar (used for the native scroll offset). Unlike the +// CSS-transform animate, this never composites the whole section as a +// single layer, so it doesn't block when the section exceeds the GPU texture +// limit — it just changes scroll offset each frame (incremental/tiled). +const rafAnimateScroll = (a, b, duration, ease, render) => new Promise(resolve => { + let start + const step = now => { + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() + } + start ??= now + const fraction = Math.min(1, (now - start) / duration) + render(lerp(a, b, ease(fraction))) + if (fraction < 1) requestAnimationFrame(step) + else resolve() + } + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() + } + requestAnimationFrame(step) +}) + +// A CSS-transform page-turn must composite the whole section as one layer. Once +// that layer is past the GPU texture limit (large sections; worse at high DPR on +// Android) Blink blocks the UI for ~1s preparing it before the turn snaps. Above +// this accumulated rendered-view size, animate the native scroll offset instead. +const RAF_ANIMATE_SCROLL_THRESHOLD = 20000 + +// collapsed range doesn't return client rects sometimes (or always?) +// try make get a non-collapsed range or element +const uncollapse = range => { + if (!range?.collapsed) return range + const { endOffset, endContainer } = range + if (endContainer.nodeType === 1) { + const node = endContainer.childNodes[endOffset] + if (node?.nodeType === 1) return node + return endContainer + } + if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1) + else if (endOffset > 1) range.setStart(endContainer, endOffset - 1) + else return endContainer.parentNode + return range +} + +const makeRange = (doc, node, start, end = start) => { + const range = doc.createRange() + range.setStart(node, start) + range.setEnd(node, end) + return range +} + +// use binary search to find an offset value in a text node +const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => { + if (end - start === 1) { + const result = cb(makeRange(doc, node, start), makeRange(doc, node, end)) + return result < 0 ? start : end + } + const mid = Math.floor(start + (end - start) / 2) + const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end)) + return result < 0 ? bisectNode(doc, node, cb, start, mid) + : result > 0 ? bisectNode(doc, node, cb, mid, end) : mid +} + +const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION, + FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter + +const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION + +// needed cause there seems to be a bug in `getBoundingClientRect()` in Firefox +// where it fails to include rects that have zero width and non-zero height +// (CSSOM spec says "rectangles [...] of which the height or width is not zero") +// which makes the visible range include an extra space at column boundaries +const getBoundingClientRect = target => { + let top = Infinity, right = -Infinity, left = Infinity, bottom = -Infinity + for (const rect of target.getClientRects()) { + left = Math.min(left, rect.left) + top = Math.min(top, rect.top) + right = Math.max(right, rect.right) + bottom = Math.max(bottom, rect.bottom) + } + return new DOMRect(left, top, right - left, bottom - top) +} + +const getVisibleRange = (doc, start, end, mapRect) => { + // A resize/scroll callback can fire after the view's document has been + // torn down (e.g. during teardown, or while an async section load is still + // settling); there is nothing to measure without a body. + if (!doc?.body) return + // first get all visible nodes + const acceptNode = node => { + const name = node.localName?.toLowerCase() + // ignore all scripts, styles, and their children + if (name === 'script' || name === 'style') return FILTER_REJECT + // ignore cfi-inert nodes (e.g. injected a11y skip-links) and their + // subtree: they are invisible to CFI, so anchoring the visible range on + // one yields a degenerate CFI and can crash `fromRange` when such a node + // is the only child of its parent (content-less background sections). + if (node.nodeType === 1 && node.hasAttribute?.('cfi-inert')) return FILTER_REJECT + if (node.nodeType === 1) { + const { left, right } = mapRect(node.getBoundingClientRect()) + if (left === 0 && right === 0) return FILTER_REJECT + // no need to check child nodes if it's completely out of view + if (right < start || left > end) return FILTER_REJECT + // elements must be completely in view to be considered visible + // because you can't specify offsets for elements + if (left >= start && right <= end) return FILTER_ACCEPT + // TODO: it should probably allow elements that do not contain text + // because they can exceed the whole viewport in both directions + // especially in scrolled mode + } else { + // ignore empty text nodes + if (!node.nodeValue?.trim()) return FILTER_SKIP + // create range to get rect + const range = doc.createRange() + range.selectNodeContents(node) + const { left, right } = mapRect(range.getBoundingClientRect()) + // it's visible if any part of it is in view + if (left === 0 && right === 0) return FILTER_REJECT + if (right >= start && left <= end) return FILTER_ACCEPT + } + return FILTER_SKIP + } + const walker = doc.createTreeWalker(doc.body, filter, { acceptNode }) + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + + // we're only interested in the first and last visible nodes + const from = nodes[0] ?? doc.body + const to = nodes[nodes.length - 1] ?? from + + // find the offset at which visibility changes + const startOffset = from.nodeType === 1 ? 0 + : bisectNode(doc, from, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < start && q.left > start) return 0 + return q.left > start ? -1 : 1 + }) + const endOffset = to.nodeType === 1 ? 0 + : bisectNode(doc, to, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < end && q.left > end) return 0 + return q.left > end ? -1 : 1 + }) + + const range = doc.createRange() + range.setStart(from, startOffset) + range.setEnd(to, endOffset) + return range +} + +const selectionIsBackward = sel => { + const range = document.createRange() + range.setStart(sel.anchorNode, sel.anchorOffset) + range.setEnd(sel.focusNode, sel.focusOffset) + return range.collapsed +} + +const setSelectionTo = (target, collapse) => { + let range + if (target.startContainer) range = target.cloneRange() + else if (target.nodeType) { + range = document.createRange() + range.selectNode(target) + } + if (range) { + const sel = range.startContainer.ownerDocument?.defaultView.getSelection() + if (sel) { + sel.removeAllRanges() + if (collapse === -1) range.collapse(true) + else if (collapse === 1) range.collapse() + sel.addRange(range) + } + } +} + +// Whether a view's bounding rect overlaps the visible region of its container. +// Used by #syncA11y to mark only the pre-loaded views that lie outside the +// viewport as `aria-hidden`. Views still visible to sighted users (e.g. the +// right column in a dual-page spread that belongs to a different section +// than the left column) stay exposed to assistive tech. +// See readest/readest#4243 and readest/readest#4259. +export const isViewVisibleInContainer = (viewRect, containerRect) => + viewRect.right > containerRect.left + && viewRect.left < containerRect.right + && viewRect.bottom > containerRect.top + && viewRect.top < containerRect.bottom + +export const getDirection = doc => { + const { defaultView } = doc + // A view's iframe document can be blank/detached while a section loads or + // the view is torn down, leaving body null; getComputedStyle(null) then + // throws "parameter 1 is not of type 'Element'" (READEST-2X). Fall back to + // horizontal-ltr until real content is present. + if (!defaultView || !doc.body) return { vertical: false, rtl: false } + let { writingMode, direction } = defaultView.getComputedStyle(doc.body) + // Some EPUBs set writing-mode on the first child of body instead of body itself + if (!writingMode || writingMode === 'horizontal-tb') { + const firstChild = doc.body.querySelector(':scope > :not([cfi-inert])') + if (firstChild) { + const childStyle = defaultView.getComputedStyle(firstChild) + if (childStyle.writingMode === 'vertical-rl' + || childStyle.writingMode === 'vertical-lr') { + writingMode = childStyle.writingMode + } + } + } + const vertical = writingMode === 'vertical-rl' + || writingMode === 'vertical-lr' + // `vertical-rl` (Japanese/Chinese vertical) advances columns right-to-left + // even though its computed `direction` stays `ltr`, so the writing mode + // itself marks it RTL and page turns follow the horizontal-rtl convention + // (readest#624). Mirrors getDirection in the app's libs/document.ts. + const rtl = writingMode === 'vertical-rl' + || doc.body.dir === 'rtl' + || direction === 'rtl' + || doc.documentElement.dir === 'rtl' + return { vertical, rtl } +} + +const getBackground = doc => { + // Same blank/detached-document guard as getDirection (READEST-2X). + if (!doc.defaultView || !doc.body) return '' + const bodyStyle = doc.defaultView.getComputedStyle(doc.body) + return bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)' + && bodyStyle.backgroundImage === 'none' + ? doc.defaultView.getComputedStyle(doc.documentElement).background + : bodyStyle.background +} + +// Compute the background segments for paginated mode. Each rendered view yields +// one segment positioned so it tracks its content on screen +// (segStart = inset + viewOffset - scrollPos). Because the paginator rebuilds +// these on every scroll, the backgrounds stay glued to the content while the +// user drags a swipe; when two sections with different backgrounds are both on +// screen the seam falls on the real content boundary instead of one flat colour +// spanning the viewport. +// +// Each segment is clamped to the content area [containerStart, containerEnd] so +// a coloured page stays inside its own column and never bleeds into the outer +// margin gutters (the --_outer-min tracks that keep the left/right margins in +// step with the centre gap). Otherwise a body-coloured page would spill its +// colour into the outer gutter while an adjacent transparent/image page did not, +// shifting the spread off-centre (~250px wide on a desktop, readest#4394). In +// single-column mode the gutters are zero, so the clamp still fills the viewport +// edge to edge. `views` is the sorted list of { size, bg } with bg already +// resolved ('' = transparent → no segment). +export const computeBackgroundSegments = (views, scrollPos, bgSize, inset, containerSize) => { + const containerStart = inset + const containerEnd = inset + containerSize + const segments = [] + let offset = 0 + for (const view of views) { + const segStart = inset + offset - scrollPos + const segEnd = segStart + view.size + offset += view.size + if (segEnd <= 0 || segStart >= bgSize) continue // off screen + if (!view.bg) continue // transparent → let the host/theme show through + const start = Math.max(segStart, containerStart) + const end = Math.min(segEnd, containerEnd) + if (end <= start) continue // entirely in an outer gutter + segments.push({ start, size: end - start, bg: view.bg }) + } + return segments +} + +// When a host background texture is active (mounted on the reader container as +// `.foliate-viewer::before`), a page whose own background is transparent must +// NOT paint a fill — an opaque fill would occlude the texture. Returns '' (no +// fill, so the texture shows through) for a transparent page under a texture, +// and the resolved colour otherwise. Shared by scrolled-mode view elements and +// paginated-mode segments so both modes treat textures identically (readest#4399). +export const textureAwareBackground = (resolved, hasTexture) => { + // A page that paints an image (e.g. a cover set via body `background-image`) + // is NOT transparent — it should occlude the texture, not be dropped. The + // computed `background` shorthand always serializes the transparent + // background-*color* first (`rgba(0, 0, 0, 0) url(...) ...`), so the + // colour-prefix check below would otherwise misclassify a cover as + // transparent and hide it behind the texture (verified on Android WebView). + const hasImage = /\burl\(/i.test(resolved ?? '') + const isTransparent = !hasImage && (!resolved + || /^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/.test(resolved)) + return hasTexture && isTransparent ? '' : resolved +} + +const makeMarginals = (length, part) => Array.from({ length }, () => { + const div = document.createElement('div') + const child = document.createElement('div') + div.append(child) + child.setAttribute('part', part) + return div +}) + +const setStyles = (el, styles) => { + // el is doc.documentElement, which is null while a view's document is blank + // or detached mid-render/teardown (READEST-1H). Nothing to style then. + if (!el) return + const { style } = el + for (const [k, v] of Object.entries(styles)) style.setProperty(k, v) +} + +const setStylesImportant = (el, styles) => { + if (!el) return + const { style } = el + for (const [k, v] of Object.entries(styles)) style.setProperty(k, v, 'important') +} + +class View { + #observer = new ResizeObserver(() => this.expand()) + #element = document.createElement('div') + #iframe = document.createElement('iframe') + #contentRange = document.createRange() + #overlayer + #vertical = false + #rtl = false + #column = true + #size + #columnCount = 1 + #layout = {} + #contentPages = 0 + #bgImageSize = null + fontReady = Promise.resolve() + constructor({ container, onExpand }) { + this.container = container + this.onExpand = onExpand + this.#iframe.setAttribute('part', 'filter') + this.#element.append(this.#iframe) + Object.assign(this.#element.style, { + boxSizing: 'content-box', + position: 'relative', + overflow: 'hidden', + flex: '0 0 auto', + width: '100%', height: '100%', + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + }) + Object.assign(this.#iframe.style, { + overflow: 'hidden', + border: '0', + display: 'none', + width: '100%', height: '100%', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + this.#iframe.setAttribute('scrolling', 'no') + } + get element() { + return this.#element + } + get document() { + return this.#iframe.contentDocument + } + get contentPages() { + return this.#contentPages + } + async load(src, data, afterLoad, beforeRender) { + if (typeof src !== 'string') throw new Error(`${src} is not string`) + return new Promise(resolve => { + this.#iframe.addEventListener('load', async () => { + const doc = this.document + if (!doc?.documentElement || !doc.body) return resolve() + afterLoad?.(doc) + + this.#iframe.setAttribute('aria-label', doc.title) + // it needs to be visible for Firefox to get computed style + this.#iframe.style.display = 'block' + const { vertical, rtl } = getDirection(doc) + this.docBackground = getBackground(doc) + doc.body.style.background = 'none' + // Resolve the body background image's natural size BEFORE the + // first render so the scrolled-mode view is sized to fit it + // from the start. Sizing it lazily — expanding only once the + // image loads — grows the view *after* navigation has already + // scrolled to it. On reopen that growth lands above the saved + // position (e.g. a preloaded previous section's full-page + // illustration) and, with no reliable cross-iframe scroll + // anchoring on WebKit, drifts the viewport to the chapter + // start. Awaiting a local EPUB resource here is near-instant. + let bgRendered = false + const bgUrl = this.docBackground + ?.match(/url\(["']?([^"')]+)["']?\)/)?.[1] + if (bgUrl && !this.container.noBackground) { + const img = new Image() + let resolveWait + const waited = new Promise(res => { resolveWait = res }) + img.onload = () => { + this.#bgImageSize = { + width: img.naturalWidth, + height: img.naturalHeight, + } + // If the image only resolves after this view has + // already rendered (slower than the bounded wait + // below), grow to fit it now — the original lazy path, + // kept as a fallback rather than the norm. + if (bgRendered && !this.#column) this.expand() + resolveWait() + } + // A missing or broken image just renders without the + // background, exactly as before. + img.onerror = () => resolveWait() + img.src = bgUrl + // Bound the wait so a missing, broken, or hung image (one + // that fires neither load nor error) can never block the + // section from rendering. + let timer + await Promise.race([ + waited, + new Promise(res => { timer = setTimeout(res, 3000) }), + ]) + clearTimeout(timer) + } + // Awaiting the background image yields control, so the view may + // have been torn down or reloaded meanwhile — don't render into + // a stale document. + if (this.document !== doc) return resolve() + this.#iframe.style.display = 'none' + + this.#vertical = vertical + this.#rtl = rtl + + this.#contentRange.selectNodeContents(doc.body) + const layout = beforeRender?.({ vertical, rtl }) + this.#iframe.style.display = 'block' + this.render(layout) + bgRendered = true + this.#observer.observe(doc.body) + + // the resize observer above doesn't work in Firefox + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939) + // until the bug is fixed we can at least account for font load + this.fontReady = doc.fonts.ready.then(() => this.expand()) + + resolve() + }, { once: true }) + if (data) { + this.#iframe.srcdoc = data + } else { + this.#iframe.src = src + } + }) + } + render(layout) { + if (!layout || !this.document?.documentElement) return + this.#column = layout.flow !== 'scrolled' + this.#layout = layout + if (this.#column) this.columnize(layout) + else this.scrolled(layout) + } + scrolled({ width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth }) { + const vertical = this.#vertical + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'column-width': 'auto', + 'height': 'auto', + 'width': 'auto', + }) + const availableWidth = Math.trunc(width - marginLeft - marginRight) + const availableHeight = Math.trunc(height - marginTop - marginBottom) + const sidePaddingLeft = marginLeft / 2 + gap / 2 + const sidePaddingRight = marginRight / 2 + gap / 2 + setStyles(doc.documentElement, { + 'padding': vertical + ? `${marginTop * 1.5}px 0px ${marginBottom * 1.5}px 0px` + : `0px ${sidePaddingRight}px 0px ${sidePaddingLeft}px`, + '--page-margin-top': `${vertical ? marginTop * 1.5 : marginTop}px`, + '--page-margin-right': `${vertical ? marginRight : sidePaddingRight}px`, + '--page-margin-bottom': `${vertical ? marginBottom * 1.5 : marginBottom}px`, + '--page-margin-left': `${vertical ? marginLeft : sidePaddingLeft}px`, + '--full-width': `${Math.trunc(width)}`, + '--full-height': `${Math.trunc(height)}`, + '--available-width': `${availableWidth}`, + '--available-height': `${availableHeight}`, + }) + setStylesImportant(doc.body, { + [vertical ? 'max-height' : 'max-width']: `${columnWidth}px`, + 'margin': 'auto', + // Prevent position:absolute/fixed on body from coupling its + // size to the iframe, which causes diverging expand() loops + 'position': 'static', + }) + this.setImageSize(availableWidth, availableHeight) + this.expand() + } + columnize({ width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount }) { + const vertical = this.#vertical + this.#size = vertical ? height : width + this.#columnCount = columnCount || 1 + + const doc = this.document + const horizontalColumnGap = columnCount > 1 ? (marginLeft + marginRight) / 4 + gap / 2 : (marginLeft + marginRight) / 2 + gap + const sidePaddingLeft = columnCount > 1 ? marginLeft / 4 + gap / 4 : marginLeft / 2 + gap / 2 + const sidePaddingRight = columnCount > 1 ? marginRight / 4 + gap / 4 : marginRight / 2 + gap / 2 + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'column-width': `${Math.trunc(columnWidth)}px`, + 'column-gap': vertical ? `${(marginTop + marginBottom) * 1.5}px` : `${horizontalColumnGap}px`, + 'column-fill': 'auto', + ...(vertical + ? { 'width': `${width}px` } + : { 'height': `${height}px` }), + 'overflow': 'hidden', + // force wrap long words + 'overflow-wrap': 'break-word', + // reset some potentially problematic props + 'position': 'static', 'border': '0', 'margin': '0', + 'max-height': 'none', 'max-width': 'none', + 'min-height': 'none', 'min-width': 'none', + // fix glyph clipping in WebKit + '-webkit-line-box-contain': 'block glyphs replaced', + }) + const availableWidth = vertical + ? Math.trunc(width - marginLeft / 2 - marginRight / 2 - gap) + : Math.trunc(width / this.#columnCount) + const availableHeight = vertical + ? Math.trunc(height / this.#columnCount) + : Math.trunc(height - marginTop - marginBottom) + setStyles(doc.documentElement, { + 'padding': vertical + ? `${marginTop * 1.5}px ${marginRight}px ${marginBottom * 1.5}px ${marginLeft}px` + : `${marginTop}px ${sidePaddingRight}px ${marginBottom}px ${sidePaddingLeft}px`, + '--page-margin-top': `${vertical ? marginTop * 1.5 : marginTop}px`, + '--page-margin-right': `${vertical ? marginRight : sidePaddingRight}px`, + '--page-margin-bottom': `${vertical ? marginBottom * 1.5 : marginBottom}px`, + '--page-margin-left': `${vertical ? marginLeft : sidePaddingLeft}px`, + '--full-width': `${Math.trunc(availableWidth)}`, + '--full-height': `${Math.trunc(availableHeight)}`, + '--available-width': `${availableWidth}`, + '--available-height': `${availableHeight}`, + }) + setStylesImportant(doc.body, { + 'max-height': 'none', + 'max-width': 'none', + 'margin': '0', + // Prevent position:absolute/fixed on body from coupling its + // size to the iframe, which causes diverging expand() loops + 'position': 'static', + }) + this.setImageSize(availableWidth, availableHeight) + this.#demoteUnfragmentableBoxes(availableHeight) + this.expand() + } + // Atomic inline-level boxes (inline-block / inline-flex / inline-grid / + // inline-table) cannot be fragmented across columns. When an EPUB declares + // such a display on a tall block container, the box overflows the page and + // every column past the first is clipped, so whole sections silently vanish + // (e.g. a chapter that jumps straight to its references). Detect the + // vertical overflow this causes in paginated mode and demote the offending + // boxes to their fragmentable block-level equivalents so the content + // paginates normally. The querySelectorAll scan only runs when the document + // actually overflows its column, which is the (rare) bug case. + #demoteUnfragmentableBoxes(availableHeight) { + const doc = this.document + const root = doc?.documentElement + if (!root || root.scrollHeight <= root.clientHeight + 1) return + const view = doc.defaultView + const fragmentable = { + 'inline-block': 'block', + 'inline-flex': 'flex', + 'inline-grid': 'grid', + 'inline-table': 'table', + } + for (const el of doc.body.querySelectorAll('*')) { + const replacement = fragmentable[view.getComputedStyle(el).display] + if (replacement && el.getBoundingClientRect().height > availableHeight) + setStylesImportant(el, { display: replacement }) + } + } + setImageSize(availableWidth, availableHeight) { + const { width, height, marginTop, marginRight, marginBottom, marginLeft } = this.#layout + const vertical = this.#vertical + const doc = this.document + const pageFullscreen = doc.documentElement.hasAttribute('data-duokan-page-fullscreen') + // The fullscreen treatment pins the image with position:absolute and + // height:100% so it fills the fixed-height page. That only works in + // paginated (columnized) mode; in scrolled mode the container height is + // `auto`, so height:100% resolves to 0 and the cover collapses out of + // sight (#4379). Apply it only when columnized. + const applyFullscreen = pageFullscreen && this.#column + for (const el of doc.body.querySelectorAll('img, svg, video')) { + // clear previous inline constraints so we read CSS-authored values, + // not stale pixel values from a previous resize (#3634) + el.style.removeProperty('max-width') + el.style.removeProperty('max-height') + // preserve max size if they are already set in CSS + let { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el) + if (parseInt(maxWidth) > availableWidth) { + maxWidth = `${availableWidth}px` + } + if (parseInt(maxHeight) > availableHeight) { + maxHeight = `${availableHeight}px` + } + setStylesImportant(el, { + 'max-height': vertical + ? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%') + : `${height - (applyFullscreen ? 0 : (marginTop + marginBottom))}px`, + 'max-width': vertical + ? `${width - (applyFullscreen ? 0 : (marginLeft + marginRight))}px` + : (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'), + 'object-fit': 'contain', + 'page-break-inside': 'avoid', + 'break-inside': 'avoid', + 'box-sizing': 'border-box', + }) + if (applyFullscreen) { + setStylesImportant(doc.documentElement, { + position: 'relative', + }) + setStylesImportant(el, { + position: 'absolute', + inset: '0', + width: '100%', + height: '100%', + margin: '0', + // fit the whole page while keeping the aspect ratio + // ('contain' set for all images above); black bars fill + // the leftover space like Duokan's native full-page render + 'background-color': '#000', + }) + let ancestor = el.parentElement + while (ancestor && ancestor !== doc.body) { + setStylesImportant(ancestor, { + width: '100%', + height: '100%', + margin: '0', + padding: '0', + // a positioned wrapper (e.g. from duokan-bleed handling) + // would become the containing block for the pinned image, + // whose height:100% then resolves against the wrapper's + // zero height and the cover vanishes (#5263) + position: 'static', + }) + ancestor = ancestor.parentElement + } + } else if (pageFullscreen) { + // Scrolled mode for a fullscreen-cover doc: undo any absolute + // pinning left over from a previous paginated render so the + // image flows normally, bounded by the max-height set above + // (#4379). Without this, toggling paginated -> scrolled keeps + // the stale position:absolute/height:100% and the cover stays + // collapsed. + doc.documentElement.style.removeProperty('position') + for (const prop of ['position', 'inset', 'width', 'height', 'margin', 'background-color']) { + el.style.removeProperty(prop) + } + let ancestor = el.parentElement + while (ancestor && ancestor !== doc.body) { + for (const prop of ['width', 'height', 'margin', 'padding', 'position']) { + ancestor.style.removeProperty(prop) + } + ancestor = ancestor.parentElement + } + } + } + } + get #zoom() { + // Safari does not zoom the client rects, while Chrome, Edge and Firefox does + if (/^((?!chrome|android).)*AppleWebKit/i.test(navigator.userAgent) && !window.chrome) { + return window.getComputedStyle(this.document.body).zoom || 1.0 + } + return 1.0 + } + expand() { + if (!this.document?.documentElement) return + const { documentElement } = this.document + if (this.#column) { + const side = this.#vertical ? 'height' : 'width' + const otherSide = this.#vertical ? 'width' : 'height' + const contentRect = this.#contentRange.getBoundingClientRect() + const rootRect = documentElement.getBoundingClientRect() + // offset caused by column break at the start of the page + // which seem to be supported only by WebKit and only for horizontal writing + const contentStart = this.#vertical ? 0 + : this.#rtl ? rootRect.right - contentRect.right : contentRect.left - rootRect.left + const contentSize = (contentStart + contentRect[side]) * this.#zoom + // Size content by individual columns, not full spreads. + // This allows adjacent sections to share a spread when a + // section doesn't fill all available columns. + const columnSize = this.#size / this.#columnCount + const pageCount = Math.ceil(contentSize / columnSize) + this.#contentPages = pageCount + const expandedSize = pageCount * columnSize + this.#element.style.padding = '0' + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + // One column per "page" — overflow columns extend into adjacent pages + documentElement.style[side] = `${columnSize}px` + if (this.#overlayer) { + this.#overlayer.element.style.margin = '0' + this.#overlayer.element.style.left = '0' + this.#overlayer.element.style.top = '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } else { + const side = this.#vertical ? 'width' : 'height' + const otherSide = this.#vertical ? 'height' : 'width' + const contentSize = documentElement.getBoundingClientRect()[side] + let expandedSize = contentSize + // If the section has a background image, ensure the view is + // at least as large as the image scaled to fit the cross axis + if (this.#bgImageSize) { + const crossSize = this.#element.getBoundingClientRect()[otherSide] + if (crossSize > 0) { + const { width: imgW, height: imgH } = this.#bgImageSize + const scaledSize = this.#vertical + ? imgW * crossSize / imgH + : imgH * crossSize / imgW + expandedSize = Math.max(expandedSize, scaledSize) + } + } + this.#element.style.padding = '0' + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + if (this.#overlayer) { + this.#overlayer.element.style.margin = '0' + this.#overlayer.element.style.left = '0' + this.#overlayer.element.style.top = '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } + this.onExpand() + } + set overlayer(overlayer) { + this.#overlayer = overlayer + this.#element.append(overlayer.element) + } + get overlayer() { + return this.#overlayer + } + #loupeEl = null + #loupeScaler = null + #loupeCursor = null + // Show a magnifier loupe inside the iframe document. + // winX/winY are in main-window (screen) coordinates. + showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) { + const doc = this.document + if (!doc) return + + const frameRect = this.#iframe.getBoundingClientRect() + // Cursor in iframe-viewport coordinates. + const vpX = winX - frameRect.left + const vpY = winY - frameRect.top + + // Cursor in document coordinates (accounts for scroll). + const scrollX = doc.scrollingElement?.scrollLeft ?? 0 + const scrollY = doc.scrollingElement?.scrollTop ?? 0 + const docX = vpX + scrollX + const docY = vpY + scrollY + + const MAGNIFICATION = magnification + const MARGIN = margin + + // Capsule dimensions: elongated along the reading direction. + // For horizontal text the capsule is wider; for vertical it is taller. + const shortSide = radius * 2 + const longSide = Math.round(radius * 3.6) + const loupeW = isVertical ? shortSide : longSide + const loupeH = isVertical ? longSide : shortSide + const halfW = loupeW / 2 + const halfH = loupeH / 2 + const borderRadius = shortSide / 2 // fully rounded ends + + // Position loupe above the cursor (or to the left for vertical text). + const GAP = gap + let loupeLeft = isVertical ? vpX - loupeW - GAP : vpX - halfW + let loupeTop = isVertical ? vpY - halfH : vpY - loupeH - GAP + loupeLeft = Math.max(MARGIN, Math.min(loupeLeft, frameRect.width - loupeW - MARGIN)) + loupeTop = Math.max(MARGIN, Math.min(loupeTop, frameRect.height - loupeH - MARGIN)) + + // CSS-transform math: map document point (docX, docY) to loupe centre. + // visual_pos = offset + coord × MAGNIFICATION = halfW (or halfH) + // ⟹ offset = half − coord × MAGNIFICATION + const offsetX = halfW - docX * MAGNIFICATION + const offsetY = halfH - docY * MAGNIFICATION + + // Build loupe DOM structure once; cache it across hide/show cycles so + // the expensive body clone is not repeated on every drag start. + if (!this.#loupeEl || !this.#loupeEl.isConnected) { + this.#loupeEl = doc.createElement('div') + + // Clone the live body once — inside the iframe the epub's CSS + // variables, @font-face fonts, and styles apply automatically. + const bodyClone = doc.body.cloneNode(true) + + // Wrap the clone in a div that replicates documentElement's inline + // styles (column-width, column-gap, padding, height, etc.) so text + // flows with the same column layout as the original document. + const htmlWrapper = doc.createElement('div') + htmlWrapper.style.cssText = doc.documentElement.style.cssText + // expand() constrains documentElement's page-axis dimension to one + // page size (width for horizontal, height for vertical). Override + // with the full scroll dimension so all columns are rendered. + if (this.#vertical) + htmlWrapper.style.height = `${doc.documentElement.scrollHeight}px` + else + htmlWrapper.style.width = `${doc.documentElement.scrollWidth}px` + htmlWrapper.appendChild(bodyClone) + + this.#loupeScaler = doc.createElement('div') + this.#loupeScaler.appendChild(htmlWrapper) + + const cursorLen = Math.round(shortSide * 0.44) + this.#loupeCursor = doc.createElement('div') + this.#loupeCursor.style.cssText = isVertical + ? `position:absolute;left:calc(50% - ${cursorLen / 2}px);top:50%;` + + `margin-top:-1px;width:${cursorLen}px;height:2px;background:${color};pointer-events:none;z-index:1;box-sizing:border-box;` + : `position:absolute;left:50%;top:calc(50% - ${cursorLen / 2}px);` + + `margin-left:-1px;width:2px;height:${cursorLen}px;background:${color};pointer-events:none;z-index:1;box-sizing:border-box;` + + this.#loupeEl.appendChild(this.#loupeScaler) + this.#loupeEl.appendChild(this.#loupeCursor) + doc.documentElement.appendChild(this.#loupeEl) + + // Static loupe shell styles (set once). + this.#loupeEl.style.cssText = ` + position: absolute; + width: ${loupeW}px; + height: ${loupeH}px; + border-radius: ${borderRadius}px; + overflow: hidden; + border: 2.5px solid ${color}; + box-shadow: 0 6px 24px rgba(0,0,0,0.28); + background-color: var(--theme-bg-color); + z-index: 9999; + pointer-events: none; + user-select: none; + box-sizing: border-box; + contain: strict; + ` + + // Static scaler styles (set once; only left/top change per move). + this.#loupeScaler.style.cssText = ` + position: absolute; + transform: scale(${MAGNIFICATION}); + transform-origin: 0 0; + pointer-events: none; + ` + } + + // Ensure visible (hideLoupe hides via CSS instead of removing). + this.#loupeEl.style.display = '' + + // Update only the dynamic position values (fast path on every move). + this.#loupeScaler.style.left = `${offsetX}px` + this.#loupeScaler.style.top = `${offsetY}px` + this.#loupeScaler.style.width = `${doc.documentElement.scrollWidth}px` + this.#loupeScaler.style.height = `${doc.documentElement.scrollHeight}px` + this.#loupeEl.style.left = `${loupeLeft + scrollX}px` + this.#loupeEl.style.top = `${loupeTop + scrollY}px` + + // Cut a capsule-shaped hole in the overlayer so highlights don't paint + // over the loupe. + if (this.#overlayer) { + const overlayerRect = this.#overlayer.element.getBoundingClientRect() + const dx = frameRect.left - overlayerRect.left + const dy = frameRect.top - overlayerRect.top + + const pad = 3 + const cx = loupeLeft + halfW + dx + const cy = loupeTop + halfH + dy + + this.#overlayer.setHole(cx, cy, loupeW + pad * 2, loupeH + pad * 2, borderRadius + pad) + } + } + hideLoupe() { + // Hide via CSS instead of removing — keeps the cached body clone so + // the next showLoupe call skips the expensive cloneNode(true). + if (this.#loupeEl) { + this.#loupeEl.style.display = 'none' + } + if (this.#overlayer) + this.#overlayer.clearHole() + } + destroyLoupe() { + if (this.#loupeEl) { + this.#loupeEl.remove() + this.#loupeEl = null + this.#loupeScaler = null + this.#loupeCursor = null + } + if (this.#overlayer) + this.#overlayer.clearHole() + } + destroy() { + if (this.document?.body) this.#observer.unobserve(this.document.body) + this.destroyLoupe() + } +} + +// NOTE: everything here assumes the so-called "negative scroll type" for RTL +export class Paginator extends HTMLElement { + static observedAttributes = [ + 'flow', 'gap', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', + 'max-inline-size', 'max-block-size', 'max-column-count', + 'no-preload', 'no-background', 'no-continuous-scroll', + ] + #root = this.attachShadow({ mode: 'open' }) + #observer = new ResizeObserver(() => this.render()) + #top + #background + #container + #header + #footer + #views = new Map() // Map + #primaryIndex = -1 + #vertical = false + #rtl = false + #marginTop = 0 + #marginBottom = 0 + #anchor = 0 // anchor view to a fraction (0-1), Range, or Element + #justAnchored = false + #locked = false // while true, prevent any further navigation + #styles + #styleMap = new WeakMap() + #mediaQuery = matchMedia('(prefers-color-scheme: dark)') + #mediaQueryListener + #scrollBounds + #touchState + #touchScrolled + #lastVisibleRange + #scrollLocked = false + #isAnimating = false + // Generation counter for slideTurnAnimation: a newer vertical page turn + // bumps it so an in-flight two-phase slide stops touching the DOM. + #slideTurnId = 0 + // Horizontal drag offset (px) applied to the views while a finger tracks + // a page turn on a vertical book; consumed as the slide's start position + // when the turn commits, or settled back to 0 when it doesn't. + #dragTranslateX = 0 + // Active finger-tracked layered turn (readest#555): a paused view + // transition whose animations are scrubbed by the drag. + #vtDrag = null + // A released layered turn still owns the global View Transition until its + // commit/cancel cleanup and terminal lifecycle event complete. + #vtFinishing = null + #vtProgrammatic = null + #vtNamedHost = null + // Snapshot of the invariant inputs #replaceBackground needs (theme/texture + // style, background+container geometry, per-view size+colour). Set once when + // a scroll animation starts so the per-frame repaint reuses it instead of + // forcing a fresh style+layout read every frame; null when not animating. + #bgAnimContext = null + #filling = false // true while #fillVisibleArea is running + #fillPromise = null // tracks in-progress #fillVisibleArea for awaiting + #stabilizing = false // true while #display is stabilizing layout + #rendered = false // true after first #display completes + #lastLayout = null // cached layout from the last #beforeRender call + // Cache of section index → vertical (boolean). Populated as views + // are loaded so we can check direction *before* loading a section. + #directionCache = new Map() + constructor() { + super() + this.#root.innerHTML = ` +
    +
    + +
    + +
    + ` + + this.#top = this.#root.getElementById('top') + this.#background = this.#root.getElementById('background') + this.#container = this.#root.getElementById('container') + this.#header = this.#root.getElementById('header') + this.#footer = this.#root.getElementById('footer') + + this.#observer.observe(this.#container) + const debouncedScroll = debounce(() => { + if (this.scrolled && !this.#isAnimating) { + // Skip entirely while stabilizing — preserve #justAnchored + // so the first post-stabilization fire still sees it. + if (this.#stabilizing) return + if (this.#justAnchored) this.#justAnchored = false + else this.#afterScroll('scroll') + // Backward preloading is handled eagerly in the (non-debounced) + // scroll listener below, mirroring the forward buffer. + } else if (!this.scrolled) { + this.#afterScroll('container-scroll') + } + }, 250) + this.#container.addEventListener('scroll', () => { + if (!this.#isAnimating) this.dispatchEvent(new Event('scroll')) + // Keep the per-view backgrounds glued to the content while a swipe + // drag scrolls the container (no animation runs then). During the + // snap animation #isAnimating is set and the destination background + // is already in place, so the rebuild is skipped. + if (!this.scrolled && !this.#isAnimating) this.#replaceBackground() + // Preload forward when fewer than minPages ahead. Skip while a finger + // drag is in progress: loading a section runs columnize/expand on the + // main thread, which drops frames mid-swipe (readest#4785). The buffer + // is still 4+ pages deep during a one-page drag, and the scroll that + // settles the gesture re-fires this with the finger already up, so the + // top-up just moves off the active drag instead of being skipped. + if (!this.noPreload && !this.noContinuousScroll && !this.#filling + && !this.#stabilizing && !this.#touchScrolled) { + const minPages = 5 + const pagesAhead = this.size > 0 + ? Math.floor((this.#renderedViewSize - this.#renderedEnd) / this.size) + : 0 + if (pagesAhead < minPages) { + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] + if (lastIndex != null) { + const nextIdx = this.#adjacentIndex(1, lastIndex) + if (nextIdx != null && !this.#views.has(nextIdx) && this.#isSameDirection(nextIdx)) { + this.#filling = true + this.#loadAdjacentSection(nextIdx) + .finally(() => { + this.#filling = false + this.dispatchEvent(new Event('stabilized')) + }) + } + } + } + } + // Preload backward when fewer than minPages behind, mirroring the + // forward buffer so scrolling up never dead-ends at the top with the + // previous section unloaded (readest/readest#4112). The + // #loadAdjacentSection scroll compensation keeps the viewport + // anchored as the section is inserted above. + if (this.scrolled && !this.noPreload && !this.noContinuousScroll + && !this.#filling && !this.#stabilizing) { + const minPages = 5 + const pagesBehind = this.size > 0 + ? Math.floor(this.#renderedStart / this.size) + : 0 + if (pagesBehind < minPages) { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] + if (firstIndex != null) { + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && !this.#views.has(prevIdx) && this.#isSameDirection(prevIdx)) { + this.#filling = true + this.#loadAdjacentSection(prevIdx) + .finally(() => { + this.#filling = false + this.dispatchEvent(new Event('stabilized')) + }) + } + } + } + } + debouncedScroll() + }) + + const opts = { passive: false } + this.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + this.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + this.addEventListener('touchend', this.#onTouchEnd.bind(this)) + this.addEventListener('touchcancel', this.#onTouchCancel.bind(this)) + this.addEventListener('load', ({ detail: { doc } }) => { + doc.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + doc.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) + doc.addEventListener('touchcancel', this.#onTouchCancel.bind(this)) + }) + + this.addEventListener('relocate', ({ detail }) => { + if (detail.reason === 'selection') setSelectionTo(this.#anchor, 0) + else if (detail.reason === 'navigation') { + if (this.#anchor === 1) setSelectionTo(detail.range, 1) + else if (typeof this.#anchor === 'number') + setSelectionTo(detail.range, -1) + else setSelectionTo(this.#anchor, -1) + } + }) + const checkPointerSelection = debounce((range, sel) => { + if (!sel.rangeCount) return + const selRange = sel.getRangeAt(0) + const backward = selectionIsBackward(sel) + if (backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) + this.prev() + else if (!backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) + this.next() + }, 700) + this.addEventListener('load', ({ detail: { doc } }) => { + let isPointerSelecting = false + doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerup', () => isPointerSelecting = false) + let isKeyboardSelecting = false + doc.addEventListener('keydown', () => isKeyboardSelecting = true) + doc.addEventListener('keyup', () => isKeyboardSelecting = false) + doc.addEventListener('selectionchange', () => { + if (this.scrolled) return + const range = this.#lastVisibleRange + if (!range) return + const sel = doc.getSelection() + if (!sel.rangeCount) return + // FIXME: this won't work on Android WebView, disable for now + if (!isPointerSelecting && isPointerSelecting && sel.type === 'Range') + checkPointerSelection(range, sel) + else if (isKeyboardSelecting) { + const selRange = sel.getRangeAt(0).cloneRange() + const backward = selectionIsBackward(sel) + if (!backward) selRange.collapse() + this.#scrollToAnchor(selRange) + } + }) + doc.addEventListener('focusin', e => { + if (this.scrolled) return null + if (this.#container && this.#container.contains(e.target)) { + // NOTE: `requestAnimationFrame` is needed in WebKit + requestAnimationFrame(() => this.#scrollToAnchor(e.target)) + } + }) + }) + + this.#mediaQueryListener = () => { + const view = this.#primaryView + if (!view) return + this.#replaceBackground() + } + this.#mediaQuery.addEventListener('change', this.#mediaQueryListener) + } + get #primaryView() { + return this.#views.get(this.#primaryIndex) + } + get #sortedViews() { + return [...this.#views.entries()].sort(([a], [b]) => a - b) + } + get primaryIndex() { + return this.#primaryIndex + } + setAttribute(name, value) { + // The scrolled-mode scroll handler is debounced, so #anchor and + // #primaryIndex can lag behind the user's actual viewport by up to + // ~250ms. Toggling out of scrolled mode within that window made + // render() restore the stale anchor — reverting the position to a + // previously visible section. Flush the pending scroll state here, + // before the attribute change so the layout is still in scrolled + // mode and `this.scrolled` (which reads the attribute) is still true. + if (name === 'flow' + && this.scrolled + && String(value) !== 'scrolled' + && this.#views.size > 0) { + this.#flushScrolledState() + } + super.setAttribute(name, value) + } + #flushScrolledState() { + if (this.#views.size > 1) this.#detectPrimaryView() + const result = this.#getVisibleRange() + if (result?.range && !result.range.collapsed) this.#anchor = result.range + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'flow': + this.render() + break + case 'gap': + case 'margin-top': + case 'margin-bottom': + case 'margin-left': + case 'margin-right': + case 'max-block-size': + case 'max-column-count': + this.#top.style.setProperty('--_' + name, value) + this.render() + break + case 'max-inline-size': + // needs explicit `render()` as it doesn't necessarily resize + this.#top.style.setProperty('--_' + name, value) + this.render() + break + case 'no-continuous-scroll': + if (this.noContinuousScroll) { + for (const [i] of this.#views) { + if (i !== this.#primaryIndex) this.#destroyView(i) + } + } + break + } + } + open(book) { + this.bookDir = book.dir + this.sections = book.sections + book.transformTarget?.addEventListener('data', ({ detail }) => { + if (detail.type !== 'text/css') return + detail.data = Promise.resolve(detail.data).then(data => data + // unprefix as most of the props are (only) supported unprefixed + .replace(/([{\s;])-epub-/gi, '$1') + // `page-break-*` unsupported in columns; replace with `column-break-*` + .replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => + `-webkit-column-break-${x}:`) + .replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => + `break-${x}: ${y ?? ''}column`)) + }) + } + #createView(index) { + // Destroy existing view for this index if any + const existing = this.#views.get(index) + if (existing) { + existing.destroy() + this.#container.removeChild(existing.element) + this.#views.delete(index) + } + const view = new View({ + container: this, + onExpand: () => { + // Only the primary view's resize should adjust scroll; + // non-primary views (preloaded/adjacent) must not scroll + if (this.#filling || this.#stabilizing || this.scrolled) return + if (this.#primaryIndex === index) + this.#scrollToAnchor(this.#anchor) + }, + }) + this.#views.set(index, view) + const sorted = this.#sortedViews + const myPos = sorted.findIndex(([i]) => i === index) + const nextEntry = sorted[myPos + 1] + if (nextEntry) this.#container.insertBefore(view.element, nextEntry[1].element) + else this.#container.append(view.element) + this.#syncA11y() + return view + } + // Hide off-screen pre-loaded views from the accessibility tree so + // screen-reader swipe-next does not wander into them (which would land + // several pages into the next section instead of its first paragraph). + // + // Only `aria-hidden` is used — `inert` would also block pointer events + // and text selection, which breaks visible non-primary views such as + // the right column of a dual-page spread when each column belongs to + // a different section (readest/readest#4243, readest/readest#4259). + // + // Visible non-primary views stay exposed to assistive tech because a + // sighted user can read them on the same spread. + #syncA11y() { + const containerRect = this.#container.getBoundingClientRect() + for (const [index, view] of this.#views) { + const isPrimary = index === this.#primaryIndex + const isVisible = isPrimary + || isViewVisibleInContainer( + view.element.getBoundingClientRect(), containerRect) + if (isVisible) view.element.removeAttribute('aria-hidden') + else view.element.setAttribute('aria-hidden', 'true') + } + } + #destroyView(index) { + const view = this.#views.get(index) + if (!view) return + view.destroy() + this.#container.removeChild(view.element) + this.#views.delete(index) + this.sections[index]?.unload?.() + } + #destroyAllViews() { + for (const [index] of this.#views) this.#destroyView(index) + } + #clearViewsExcept(keepIndices) { + for (const [index] of this.#views) { + if (!keepIndices.has(index)) this.#destroyView(index) + } + } + // Check if a section has the same writing direction as current primary. + // Returns true if same or unknown (not yet cached). + #isSameDirection(index) { + if (!this.#directionCache.has(index)) return true + return this.#directionCache.get(index) === this.#vertical + } + // Read the theme/texture style off the primary section's and return a + // resolver that maps a view's raw background onto the active theme. This is a + // forced style read (getComputedStyle), so callers in the animation hot path + // do it once via #computePaginatedBgContext rather than every frame. + #readBackgroundStyle(doc) { + const htmlStyle = doc.defaultView.getComputedStyle(doc.documentElement) + const themeBgColor = htmlStyle.getPropertyValue('--theme-bg-color') + const overrideColor = htmlStyle.getPropertyValue('--override-color') === 'true' + const bgTextureId = htmlStyle.getPropertyValue('--bg-texture-id') + const isDarkMode = htmlStyle.getPropertyValue('color-scheme') === 'dark' + const fallbackBg = themeBgColor || '' + const hasTexture = !!bgTextureId && bgTextureId !== 'none' + + const resolveBackground = (background) => { + if (!background) return fallbackBg + if (themeBgColor) { + const parsed = background.split(/\s(?=(?:url|rgb|hsl|#[0-9a-fA-F]{3,6}))/) + if ((isDarkMode || overrideColor) && (bgTextureId === 'none' || !bgTextureId)) { + parsed[0] = themeBgColor + } + return parsed.join(' ') + } + return background + } + return { fallbackBg, hasTexture, resolveBackground } + } + // Snapshot every input #paintPaginatedBackground needs that stays constant for + // the duration of a scroll animation: the theme/texture style, the + // background+container geometry, and each rendered view's size and resolved + // background. Returns null when there is nothing to paint (no primary doc, + // backgrounds disabled, or scrolled mode). Built once per animation so the + // per-frame repaint never re-runs getComputedStyle or one + // getBoundingClientRect per view — those forced reads, multiplied by the views + // preloaded at a chapter boundary, are what dropped frames mid-swipe + // (readest#4785). + #computePaginatedBgContext() { + const doc = this.#primaryView?.document + if (!doc?.documentElement) return null + if (this.noBackground) return null + if (this.scrolled) return null + const { fallbackBg, hasTexture, resolveBackground } = this.#readBackgroundStyle(doc) + const bgRect = this.#background.getBoundingClientRect() + const containerRect = this.#container.getBoundingClientRect() + const startEdge = this.#vertical ? 'top' : 'left' + const bgSize = bgRect[this.sideProp] + const inset = containerRect[startEdge] - bgRect[startEdge] + const containerSize = containerRect[this.sideProp] + const views = this.#sortedViews.map(([, view]) => ({ + size: view.element.getBoundingClientRect()[this.sideProp], + bg: textureAwareBackground(resolveBackground(view.docBackground), hasTexture), + })) + return { fallbackBg, hasTexture, bgSize, inset, containerSize, views } + } + // Paint one full-bleed background segment per rendered view from a previously + // computed context, positioned so each tracks its content on screen at the + // given scroll position. Rebuilding on every scroll keeps the backgrounds + // glued to the content during a swipe drag — so when two sections with + // different backgrounds are both visible, each half shows its own colour + // instead of one flat colour flashing across the viewport. Only writes layout + // (no reads), so it is safe to run every animation frame. + #paintPaginatedBackground(ctx, atPosition) { + // Reset any inline backgrounds left over from a previous mode so the + // host's texture isn't occluded after toggling. + this.#background.style.background = '' + for (const [, view] of this.#sortedViews) { + view.element.style.background = '' + } + const scrollPos = Math.abs(atPosition ?? this.#renderedStart) + const segments = computeBackgroundSegments( + ctx.views, scrollPos, ctx.bgSize, ctx.inset, ctx.containerSize) + + this.#background.innerHTML = '' + this.#background.style.display = '' + // Under a texture, leave the container transparent so the host texture + // shows through the gaps a transparent page no longer fills (readest#4399). + this.#background.style.background = ctx.hasTexture ? '' : ctx.fallbackBg + + const posProp = this.#vertical ? 'top' : 'left' + const sizeProp = this.#vertical ? 'height' : 'width' + const crossPosProp = this.#vertical ? 'left' : 'top' + const crossSizeProp = this.#vertical ? 'width' : 'height' + for (const { start, size, bg } of segments) { + const seg = document.createElement('div') + seg.style.position = 'absolute' + seg.style[posProp] = `${start}px` + seg.style[sizeProp] = `${size}px` + seg.style[crossPosProp] = '0' + seg.style[crossSizeProp] = '100%' + seg.style.background = bg + seg.style.backgroundAttachment = 'initial' + this.#background.appendChild(seg) + } + } + // Update the #background grid so each column shows the correct section's + // background. Pass atPosition to pre-compute for a destination scroll + // position (e.g. before an animation starts). During a scroll animation the + // invariant context is snapshotted in #bgAnimContext and reused here so the + // per-frame repaint stays read-free. + #replaceBackground(atPosition) { + const doc = this.#primaryView?.document + if (!doc?.documentElement) return + if (this.noBackground) return + + if (this.scrolled) { + // In scrolled mode, set background directly on each view element + // so it scrolls with the content. The static #background provides + // the fallback color for margins and gaps between views. + const { fallbackBg, hasTexture, resolveBackground } = this.#readBackgroundStyle(doc) + this.#background.style.background = '' + this.#background.innerHTML = '' + this.#background.style.display = '' + this.#background.style.background = hasTexture ? '' : fallbackBg + for (const [, view] of this.#sortedViews) { + const resolved = resolveBackground(view.docBackground) + view.element.style.background = textureAwareBackground(resolved, hasTexture) + } + return + } + + const ctx = this.#bgAnimContext ?? this.#computePaginatedBgContext() + if (!ctx) return + this.#paintPaginatedBackground(ctx, atPosition) + } + #beforeRender({ vertical, rtl }) { + // If writing-mode is about to change, destroy all non-primary + // views BEFORE updating global state. This prevents stale views + // with the wrong direction from remaining in the container while + // flex-direction / scrollProp / sideProp flip. + if (this.#rendered && vertical !== this.#vertical) { + for (const [i] of this.#views) { + if (i !== this.#primaryIndex) this.#destroyView(i) + } + } + this.#vertical = vertical + this.#rtl = rtl + this.#top.classList.toggle('vertical', vertical) + this.#container.classList.toggle('vertical', vertical) + + const style = getComputedStyle(this.#top) + const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size')) + const maxColumnCount = parseInt(style.getPropertyValue('--_max-column-count-spread')) + const marginTop = parseFloat(style.getPropertyValue('--_margin-top')) + const marginRight = parseFloat(style.getPropertyValue('--_margin-right')) + const marginBottom = parseFloat(style.getPropertyValue('--_margin-bottom')) + const marginLeft = parseFloat(style.getPropertyValue('--_margin-left')) + this.#marginTop = marginTop + this.#marginBottom = marginBottom + + // Compute the column count from the host (Paginator) size rather than + // the #container size. The container width depends on --_column-count + // via the grid template (the outer 1fr tracks have a non-zero min for + // multi-column spreads), so deriving the column count from container + // size at threshold widths creates a feedback loop where the layout + // oscillates between 1 and 2 columns on resize. + const flow = this.getAttribute('flow') + const hostRect = this.getBoundingClientRect() + const hostSize = vertical ? hostRect.height : hostRect.width + const divisor = flow === 'scrolled' + ? 1 + : Math.min( + maxColumnCount + (vertical ? 1 : 0), + Math.ceil(Math.floor(hostSize) / Math.floor(maxInlineSize)), + ) + // Set --_column-count BEFORE measuring the container so the read + // below reflects the grid template that will actually be used. + this.#top.style.setProperty('--_column-count', divisor) + + const { width, height } = this.#container.getBoundingClientRect() + const size = vertical ? height : width + + const g = parseFloat(style.getPropertyValue('--_gap')) / 100 + // The gap will be a percentage of the #container, not the whole view. + // This means the outer padding will be bigger than the column gap. Let + // `a` be the gap percentage. The actual percentage for the column gap + // will be (1 - a) * a. Let us call this `b`. + // + // To make them the same, we start by shrinking the outer padding + // setting to `b`, but keep the column gap setting the same at `a`. Then + // the actual size for the column gap will be (1 - b) * a. Repeating the + // process again and again, we get the sequence + // x₁ = (1 - b) * a + // x₂ = (1 - x₁) * a + // ... + // which converges to x = (1 - x) * a. Solving for x, x = a / (1 + a). + // So to make the spacing even, we must shrink the outer padding with + // f(x) = x / (1 + x). + // But we want to keep the outer padding, and make the inner gap bigger. + // So we apply the inverse, f⁻¹ = -x / (x - 1) to the column gap. + const gap = -g / (g - 1) * size + + if (flow === 'scrolled') { + // FIXME: vertical-rl only, not -lr + this.setAttribute('dir', vertical ? 'rtl' : 'ltr') + this.#top.style.padding = '0' + const columnWidth = maxInlineSize + + this.heads = null + this.feet = null + this.#header.replaceChildren() + this.#footer.replaceChildren() + + this.columnCount = 1 + this.#replaceBackground() + + const layout = { width, height, flow, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount: 1 } + this.#lastLayout = layout + return layout + } + + const columnWidth = vertical + ? (size / divisor - marginTop * 1.5 - marginBottom * 1.5) + : (size / divisor - gap - marginRight / 2 - marginLeft / 2) + // `dir` mirrors the horizontal scroll coordinates (negative scrollLeft + // for RTL). Vertical books page along scrollTop, which never flips, so + // an RTL writing mode must not reverse the host grid there. + this.setAttribute('dir', rtl && !vertical ? 'rtl' : 'ltr') + + // set background to `doc` background + // this is needed because the iframe does not fill the whole element + this.columnCount = divisor + this.#replaceBackground() + + const marginalDivisor = vertical + ? Math.min(2, Math.ceil(Math.floor(width) / Math.floor(maxInlineSize))) + : divisor + const marginalStyle = { + gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`, + gap: `${gap}px`, + direction: this.bookDir === 'rtl' ? 'rtl' : 'ltr', + } + Object.assign(this.#header.style, marginalStyle) + Object.assign(this.#footer.style, marginalStyle) + const heads = makeMarginals(marginalDivisor, 'head') + const feet = makeMarginals(marginalDivisor, 'foot') + this.heads = heads.map(el => el.children[0]) + this.feet = feet.map(el => el.children[0]) + this.#header.replaceChildren(...heads) + this.#footer.replaceChildren(...feet) + + const layout = { width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount: divisor } + this.#lastLayout = layout + return layout + } + render() { + if (this.#views.size === 0) return + const primaryView = this.#primaryView + if (!primaryView) return + this.#stabilizing = true + const layout = this.#beforeRender({ + vertical: this.#vertical, + rtl: this.#rtl, + }) + for (const [, view] of this.#views) { + if (view.document) view.render(layout) + } + // Scroll synchronously to prevent visible layout shift during resize. + // RAF deferral is only needed for initial display and mode switches + // (handled by #display), not for resize re-renders. + this.#scrollToAnchor(this.#anchor) + this.#stabilizing = false + this.dispatchEvent(new Event('stabilized')) + } + get scrolled() { + return this.getAttribute('flow') === 'scrolled' + } + get noPreload() { + return this.hasAttribute('no-preload') + } + get noBackground() { + return this.hasAttribute('no-background') + } + get noContinuousScroll() { + return this.scrolled && this.hasAttribute('no-continuous-scroll') + } + // The layered turn styles (slide/curl, readest#555) rasterize the outgoing + // page with the View Transitions API; when the engine lacks it the caller + // falls through to the push/two-phase animations, so old WebViews keep + // working page turns. + get #layeredTurn() { + const style = this.getAttribute('turn-style') + return (style === 'slide' || style === 'curl') + && typeof document.startViewTransition === 'function' + ? style : null + } + get scrollProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop') + : scrolled ? 'scrollTop' : 'scrollLeft' + } + get sideProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'width' : 'height') + : scrolled ? 'height' : 'width' + } + get size() { + return this.#container.getBoundingClientRect()[this.sideProp] + } + get viewSize() { + const primaryView = this.#primaryView + if (!primaryView) return 0 + return primaryView.element.getBoundingClientRect()[this.sideProp] + } + get start() { + return this.#renderedStart - this.#getViewOffset(this.#primaryIndex) + } + get end() { + return this.#renderedEnd - this.#getViewOffset(this.#primaryIndex) + } + get page() { + return Math.floor(((this.start + this.end) / 2) / this.size) + } + get pages() { + const primaryView = this.#primaryView + if (!primaryView) return 0 + const viewSize = primaryView.element.getBoundingClientRect()[this.sideProp] + return Math.round(viewSize / this.size) + } + get containerPosition() { + return this.#container[this.scrollProp] + } + get isOverflowX() { + return false + } + get isOverflowY() { + return false + } + get #renderedViewSize() { + if (this.#views.size === 0) return 0 + let total = 0 + for (const [, view] of this.#views) + total += view.element.getBoundingClientRect()[this.sideProp] + return total + } + get #renderedStart() { + return Math.abs(this.#container[this.scrollProp]) + } + get #renderedEnd() { + return this.#renderedStart + this.size + } + get #renderedPage() { + return Math.floor(((this.#renderedStart + this.#renderedEnd) / 2) / this.size) + } + get #renderedPages() { + return Math.round(this.#renderedViewSize / this.size) + } + set containerPosition(newVal) { + this.#container[this.scrollProp] = newVal + } + get scrollLocked() { + return this.#scrollLocked + } + set scrollLocked(value) { + this.#scrollLocked = value + } + + scrollBy(dx, dy) { + // #scrollBounds is populated by #scrollToPage and stays unset until + // the first page settles. A swipe that lands before that happens + // (for example a fast swipe right after the reader mounts, or + // before a section has finished loading) would otherwise blow up + // on the destructuring below — bail out and let the next settled + // scroll re-enable swipe-driven motion. + if (!this.#scrollBounds) return + const delta = this.#vertical ? dy : dx + const [offset, a, b] = this.#scrollBounds + // RTL flips the forward/backward allowances only on the horizontal + // scroll axis (negative scrollLeft); vertical books page along + // scrollTop where forward is always positive. + const rtl = this.#rtl && !this.#vertical + const min = rtl ? offset - b : offset - a + const max = rtl ? offset + a : offset + b + this.containerPosition = Math.max(min, Math.min(max, + this.containerPosition + delta)) + } + + // vx, vy: velocity at the end of the swipe (pixels per ms) + // dx, dy: total distance swiped + // dt: total time of the swipe (ms) + snap(vx, vy, dx, dy, dt) { + // Same guard as scrollBy: an early swipe whose touchend fires + // before the first #scrollToPage seeds #scrollBounds would crash + // on the destructuring. Skip the snap; the next settled scroll + // populates the bounds and subsequent swipes work normally. + if (!this.#scrollBounds) return + // Page-turn swipes are horizontal in every writing mode: vertical-rl + // books turn pages right-to-left like printed Japanese books + // (readest#624), vertical-lr left-to-right. A predominantly vertical + // swipe on a vertical book still pages along the block axis so the + // legacy gesture keeps working. + const horizontal = Math.abs(vx) * 2 > Math.abs(vy) + const useHorizontal = horizontal || !this.#vertical + const pages = this.#renderedPages + let page + if (this.#vertical && useHorizontal && !this.#layeredTurn + && this.hasAttribute('animated') && !this.hasAttribute('eink')) { + // Drag-follow gestures on vertical books (readest#624): the views + // tracked the finger, so judge the turn like a paged carousel by + // where the drag ended plus the release flick, instead of the + // displacement heuristic below (which over-commits once content + // visibly follows the finger). + const width = this.#container.getBoundingClientRect().width + const forwardSign = this.#rtl ? 1 : -1 + const dragged = this.#dragTranslateX + // Flick direction in translate space: a rightward finger (vx < 0) + // drags the views right. + const flick = Math.abs(vx) > 0.3 ? -Math.sign(vx) : 0 + let turn + if (Math.abs(dragged) > width / 2) { + // Past halfway: commit unless flicked back the other way. + turn = flick === -Math.sign(dragged) ? 0 : Math.sign(dragged) + } else if (flick && (!dragged || flick === Math.sign(dragged))) { + turn = flick + } else { + turn = 0 + } + page = this.#renderedPage + turn * forwardSign + } else { + const velocity = useHorizontal ? vx : vy + const avgVelocity = useHorizontal ? dx / dt : dy / dt + // Without drag-follow (eink, animation off, block-axis swipes, + // layered turn styles) the scroll position never moves with the + // finger; judge the whole gesture by displacement (avgVelocity) + // like the eink path. + const snapping = this.hasAttribute('animated') && !this.hasAttribute('eink') + && !this.#vertical && !this.#layeredTurn + // Drag-follow releases are judged by the release flick, so their + // alignment uses the flick (last-sample) velocities. Displacement- + // judged releases weigh the WHOLE gesture and their alignment must + // too: the last-sample ratio is lift-off jitter — a vertical swipe + // whose finger hooks sideways in its final milliseconds read as + // horizontal, and the displacement heuristic amplified the tiny + // net x-drift into a random page turn (layered slide on Android). + const aligned = useHorizontal + ? (snapping ? horizontal : Math.abs(dx) > Math.abs(dy)) + : true + // Horizontal swipes advance against the page progression (RTL: + // next page is to the left); block-axis swipes always advance + // with the scroll axis. + const sign = useHorizontal && this.#rtl ? -1 : 1 + const [offset, a, b] = this.#scrollBounds + const size = this.size + const start = this.#renderedStart + const end = this.#renderedEnd + const min = Math.abs(offset) - a + const max = Math.abs(offset) + b + const v = snapping ? velocity : avgVelocity + const d = v * sign * size * (aligned ? 1 : 0) + const snapOffset = (isNaN(d) ? 0 : snapping ? d * 2 : d * 10) + page = Math.floor(Math.max(min, Math.min(max, (start + end) / 2 + snapOffset)) / size) + } + const dir = page < 0 ? -1 : page >= pages ? 1 : null + const doGoTo = () => { + if (!dir) return + const sorted = this.#sortedViews + const edgeIndex = dir < 0 + ? sorted[0]?.[0] ?? this.#primaryIndex + : sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + return this.#goTo({ + index: this.#adjacentIndex(dir, edgeIndex), + anchor: dir < 0 ? () => 1 : () => 0, + }) + } + // Out of range — skip animation, go straight to adjacent section + if (dir) { + if (this.#vertical) { + const sorted = this.#sortedViews + const edgeIndex = dir < 0 + ? sorted[0]?.[0] ?? this.#primaryIndex + : sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + // Book boundary: nowhere to go, settle the drag back. + if (this.#adjacentIndex(dir, edgeIndex) == null) return this.#settleDrag() + this.#settleDrag(true) + } + return doGoTo() + } + this.#scrollToPage(page, 'snap') + } + #onTouchStart(e) { + const previousState = this.#touchState + const replacementTouch = Boolean(previousState?.active) + if (replacementTouch) this.#rejectLayeredGesture(previousState) + const multiTouch = e.touches.length > 1 + const touch = e.changedTouches[0] + const currentTarget = e.currentTarget + const isInnerDocument = currentTarget?.nodeType === 9 + const bounds = isInnerDocument + ? { left: 0, width: currentTarget.documentElement?.clientWidth ?? 0 } + : this.getBoundingClientRect() + const localX = (touch?.clientX ?? 0) - bounds.left + // Hosts can reserve a left-side control strip (for example, a vertical + // brightness gesture) without disabling horizontal pagination there. + // Only the low-slop fast paths are withheld; the normal fallback stays. + const reservedLeftRatio = Math.max(0, Math.min(0.5, + Number(this.getAttribute('turn-gesture-left-inset')) || 0)) + const earlyClaimBlocked = bounds.width > 0 + && localX <= bounds.width * reservedLeftRatio + const edgeDirection = bounds.width > 0 + ? !earlyClaimBlocked && localX <= bounds.width * LAYERED_EDGE_REGION ? -1 + : localX >= bounds.width * (1 - LAYERED_EDGE_REGION) ? 1 : 0 + : 0 + const blocked = Boolean(multiTouch || this.#vtFinishing || this.#vtProgrammatic) + this.#touchState = { + x: touch?.screenX, y: touch?.screenY, + t: e.timeStamp, + vx: 0, xy: 0, + dx: 0, dy: 0, + dt: 0, + releaseSamples: [{ distance: 0, time: e.timeStamp }], + lastMovementTime: e.timeStamp, + active: true, + blocked, + layeredGesture: blocked ? 'rejected' : 'pending', + layeredEarlyClaimBlocked: earlyClaimBlocked, + layeredEdgeDirection: edgeDirection, + layeredHorizontalDirection: 0, + layeredHorizontalSamples: 0, + layeredHorizontalSampleTime: null, + } + if (replacementTouch || multiTouch) this.#touchScrolled = false + // Hint to browser that scrolling will occur for better GPU layer management + const pv = this.#primaryView + if (pv?.element) { + pv.element.style.willChange = 'transform' + } + // A touch on a vertical book takes over any in-flight page-turn slide + // or settle: freeze the views where they are and let the drag continue + // from that offset. Also re-syncs the drag offset to the rendered + // transform so a stale value can never leak into the next gesture. + if (this.#vertical && !this.scrolled && !this.#layeredTurn) { + this.#slideTurnId++ + this.#isAnimating = false + const children = [...this.#container.children] + const transform = children[0] && getComputedStyle(children[0]).transform + const m41 = transform && transform !== 'none' ? new DOMMatrix(transform).m41 : 0 + this.#dragTranslateX = m41 + for (const el of children) { + if (!m41 && !el.style.transform && !el.style.transition) continue + el.style.transition = 'none' + el.style.transform = m41 ? `translateX(${m41}px)` : '' + } + } + // Snapshot the invariant background paint inputs for the whole drag. The + // layout is settled at the start of a gesture and only the scroll offset + // changes while the finger moves, so every per-move #replaceBackground() + // reuses this instead of forcing a fresh style+layout read each frame + // (readest#4785). Cleared in #onTouchEnd; the snap that follows rebuilds + // its own. + this.#bgAnimContext = this.scrolled ? null : this.#computePaginatedBgContext() + } + #onTouchMove(e) { + const state = this.#touchState + if (!state?.active || state.blocked) return + if (e.touches.length > 1) { + if (this.#touchScrolled) e.preventDefault() + this.#rejectLayeredGesture(state) + return + } + if (state.pinched) { + this.#rejectLayeredGesture(state) + return + } + state.pinched = globalThis.visualViewport.scale > 1 + if (state.pinched) { + this.#rejectLayeredGesture(state) + return + } + if (this.scrolled) return + // When the host opts out of swipe-to-paginate, let touch events reach + // native behavior (text selection, etc.) without us tracking or + // pre-empting them. + if (this.hasAttribute('no-swipe')) { + this.#rejectLayeredGesture(state) + return + } + const doc = this.#primaryView?.document + const selection = doc?.getSelection() + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + this.#rejectLayeredGesture(state) + return + } + const touch = e.changedTouches[0] + const isStylus = touch.touchType === 'stylus' + if (!isStylus) e.preventDefault() + if (this.#scrollLocked) { + this.#rejectLayeredGesture(state) + return + } + const x = touch.screenX, y = touch.screenY + const dx = state.x - x, dy = state.y - y + const dt = e.timeStamp - state.t + state.x = x + state.y = y + state.t = e.timeStamp + state.vx = dx / dt + state.vy = dy / dt + state.dx += dx + state.dy += dy + state.dt += dt + updateReleaseSample(state, state.dx, e.timeStamp) + this.#touchScrolled = true + if (!this.hasAttribute('animated') || this.hasAttribute('eink')) return + // Layered turn styles track the finger by scrubbing a paused view + // transition: the outgoing snapshot follows the drag over the still + // incoming page, then commits or reverses on release. + if (this.#layeredTurn) { + if (!this.#vtDrag) this.#layeredDragStart(state, dx, dy) + const drag = this.#vtDrag + if (drag) { + // Net finger travel along the forward direction (rtl books + // advance with a rightward finger). + const along = this.#rtl ? -state.dx : state.dx + const totalDistance = drag.forward ? along : -along + // Slide begins visually flat at the point where the gesture + // arena awards ownership. The consumed recognition distance + // still contributes to release intent below, but no longer + // appears as a first-frame jump. Curl preserves its existing + // touchstart-relative fold. + const visualDistance = drag.style === 'slide' + ? totalDistance - drag.visualOriginDistance + : totalDistance + drag.progress = Math.max(0, Math.min(1, + visualDistance / drag.width)) + this.#vtDragScrub() + } + return + } + if (!this.#vertical && Math.abs(state.dx) >= Math.abs(state.dy) && !this.hasAttribute('eink') && (!isStylus || Math.abs(dx) > 1)) { + this.scrollBy(dx, 0) + } else if (this.#vertical && Math.abs(state.dx) >= Math.abs(state.dy) && (!isStylus || Math.abs(dx) > 1)) { + // Vertical books track a horizontal finger by translating the + // views sideways: their pages stack along the vertical scroll + // axis, so the scroll offset itself cannot follow the finger + // (readest#624). The turn commits or settles on release in snap(). + this.#dragBy(dx) + } + } + // Prepare the document for a layered page turn: choreography classes on + // the root and the view-transition-name on the turn boundary. The host + // app can mark a wider boundary with [data-view-transition-root] (e.g. + // a wrapper that also contains its page header and footer, so the + // furniture turns with the page in both layers); otherwise the outermost + // shadow host in the document tree is named, since shadow-internal names + // are tree-scoped away from the document-level pseudo selectors. + #vtSetup(style, forward, scrubbing = false) { + injectViewTransitionStyles() + const html = document.documentElement + const side = this.#rtl ? 'right' : 'left' + // Which side the curl fold consumes the old page from: the outer + // edge going forward, the spine going backward. + const eatSide = (forward !== this.#rtl) ? 'right' : 'left' + const classes = ['foliate-vt', `foliate-vt-${style}`, + forward ? 'foliate-vt-forward' : 'foliate-vt-backward', + `foliate-vt-${side}`, `foliate-vt-eat-${eatSide}`] + if (scrubbing) classes.push('foliate-vt-scrub') + let namedHost = this + while (namedHost.getRootNode() instanceof ShadowRoot) { + namedHost = namedHost.getRootNode().host + } + namedHost = namedHost.closest('[data-view-transition-root]') ?? namedHost + namedHost.style.viewTransitionName = 'foliate-turn' + this.#vtNamedHost = namedHost + // Back the turn layers with the page colour: snapshots of books or + // themes without an opaque background (e.g. textured themes) would + // otherwise blend the two pages instead of occluding. + const doc = this.#primaryView?.document + const themeBg = doc?.documentElement + ? doc.defaultView.getComputedStyle(doc.documentElement) + .getPropertyValue('--theme-bg-color').trim() + : '' + html.style.setProperty('--foliate-vt-bg', themeBg || 'Canvas') + html.classList.remove(...VIEW_TRANSITION_CLASSES) + html.classList.add(...classes) + return namedHost + } + #vtCleanup() { + const html = document.documentElement + html.classList.remove(...VIEW_TRANSITION_CLASSES) + html.style.removeProperty('--foliate-vt-bg') + this.#vtNamedHost?.style.removeProperty('view-transition-name') + this.#vtNamedHost = null + } + // Permanently yield this touch sequence to another gesture owner. If the + // layered snapshot already exists, cancel it through the normal lifecycle + // instead of letting later samples (or a replacement finger) scrub it. + #rejectLayeredGesture(state = this.#touchState) { + if (state) { + state.layeredGesture = 'rejected' + state.layeredHorizontalDirection = 0 + state.layeredHorizontalSamples = 0 + state.layeredHorizontalSampleTime = null + } + const drag = this.#vtDrag + if (!drag) return + this.#vtDrag = null + this.#finishLayeredDrag(drag, false) + } + // Begin a finger-tracked layered turn (readest#555). Edge-originated + // gestures can claim on their first clear inward move. In the middle, two + // consecutive horizontal samples can claim after 6px; a vertical gesture + // locks out the turn before a landing wobble can become a page animation. + // Ambiguous trajectories retain the established 24px + 1.5x fallback. + // Once claimed, the existing `before-capture` lifecycle event explicitly + // transfers ownership to the layered turn. + #layeredDragStart(state, dx, dy) { + if (this.#vtDrag || this.#vtFinishing || this.#vtProgrammatic || !this.#scrollBounds) return + const style = this.#layeredTurn + if (!style) return + if (state.layeredGesture !== 'pending') return + + const absDx = Math.abs(state.dx) + const absDy = Math.abs(state.dy) + if (absDy >= LAYERED_VERTICAL_REJECT_PX && absDy > absDx) { + state.layeredGesture = 'rejected' + return + } + + const direction = Math.sign(dx) + const locallyHorizontal = direction !== 0 && Math.abs(dx) > Math.abs(dy) + const clearlyHorizontal = locallyHorizontal + && Math.abs(dx) >= Math.abs(dy) * LAYERED_FALLBACK_DOMINANCE + const cumulativelyHorizontal = absDx + >= absDy * LAYERED_FALLBACK_DOMINANCE + if (locallyHorizontal && cumulativelyHorizontal) { + const recentSample = state.layeredHorizontalSampleTime != null + && state.t - state.layeredHorizontalSampleTime + <= LAYERED_EARLY_SAMPLE_INTERVAL_MS + if (state.layeredHorizontalDirection === direction && recentSample) { + state.layeredHorizontalSamples++ + } else { + state.layeredHorizontalDirection = direction + state.layeredHorizontalSamples = 1 + } + state.layeredHorizontalSampleTime = state.t + } else { + state.layeredHorizontalDirection = 0 + state.layeredHorizontalSamples = 0 + state.layeredHorizontalSampleTime = null + } + + const edgeClaim = state.layeredEdgeDirection !== 0 + && !state.layeredEarlyClaimBlocked + && direction === state.layeredEdgeDirection + && Math.sign(state.dx) === state.layeredEdgeDirection + && clearlyHorizontal + && cumulativelyHorizontal + const earlyCenterClaim = state.layeredEdgeDirection === 0 + && !state.layeredEarlyClaimBlocked + && state.layeredHorizontalSamples >= 2 + && absDx >= LAYERED_EARLY_CLAIM_PX + && Math.sign(state.dx) === state.layeredHorizontalDirection + const fallbackClaim = absDx >= LAYERED_FALLBACK_CLAIM_PX + && absDx >= absDy * LAYERED_FALLBACK_DOMINANCE + if (!edgeClaim && !earlyCenterClaim && !fallbackClaim) return + + // Finger travel along the forward direction decides which neighbor + // page gets snapshotted. + const along = this.#rtl ? -state.dx : state.dx + const forward = along > 0 + state.layeredGesture = 'claimed' + // Gesture ownership is independent of whether an adjacent page exists. + // At a book boundary the turn cannot start, but the host must still + // suppress the browser's synthesized click for this horizontal drag. + // Keep this separate from layered-turn-state so the established + // snapshot lifecycle remains unchanged. + this.dispatchEvent(new CustomEvent('layered-turn-gesture-claimed', { + detail: { style, forward }, + })) + const pages = this.#renderedPages + const target = this.#renderedPage + (forward ? 1 : -1) + if (target < 0 || target >= pages) return + const offset = this.size * (this.#rtl && !this.#vertical ? -target : target) + ++this.#slideTurnId + this.#isAnimating = true + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'before-capture', style, forward }, + })) + const startPosition = this.containerPosition + let turnRoot + let transition + try { + turnRoot = this.#vtSetup(style, forward, true) + transition = document.startViewTransition(() => { + this.containerPosition = offset + if (!this.scrolled) { + this.#bgAnimContext = null + this.#replaceBackground() + } + // The old snapshot now owns the visible toolbar. Let the host hide + // the live copy synchronously before the new snapshot is captured, + // so its regular opacity transition cannot run beside the page. + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'covered', style, forward }, + })) + }) + } catch { + // A synchronous setup/capture failure must release both the global + // View Transition styling and the host's before-capture ownership. + // Reject the rest of this touch so touchend cannot reinterpret it + // as a legacy snap after the layered lifecycle has already ended. + state.layeredGesture = 'rejected' + this.containerPosition = startPosition + this.#vtCleanup() + this.#isAnimating = false + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'finished', style, forward, committed: false }, + })) + return + } + const drag = { + transition, offset, startPosition, forward, + style, progress: 0, anims: null, + visualOriginDistance: style === 'slide' + ? Math.max(0, forward ? along : -along) : 0, + // Progress must use the width of the actual named snapshot. The + // inner content container can be narrower because of page margins; + // using it makes the sheet gradually outrun the finger. + width: turnRoot.getBoundingClientRect().width + || this.#container.getBoundingClientRect().width, + } + this.#vtDrag = drag + transition.ready.then(() => { + if (this.#vtDrag !== drag && this.#vtFinishing !== drag) return + const anims = document.getAnimations().filter(a => + a.effect?.pseudoElement?.includes('(foliate-turn)')) + for (const a of anims) { + // CSS is authoritative for linear scrubbing. Keep this as a + // best-effort fallback for engines that expose mutable pseudo + // animation effects. + try { a.effect.updateTiming({ easing: 'linear' }) } catch { /* UA animation */ } + a.pause() + } + drag.anims = anims + this.#vtDragScrub(drag) + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'ready', style, forward }, + })) + }, () => { + // Capture failed or was skipped; release falls back to snap(). + if (this.#vtDrag === drag || this.#vtFinishing === drag) drag.failed = true + }) + } + #vtDragScrub(drag = this.#vtDrag) { + if (!drag?.anims) return + for (const a of drag.anims) { + const duration = a.effect.getTiming().duration + a.currentTime = drag.progress * 0.999 + * (typeof duration === 'number' ? duration : 300) + } + } + // Resolve a finger-tracked layered turn: play the paused animations to + // the end to commit, or reverse them and put the live content back to + // cancel. The scroll offset already sits on the target page during the + // drag (the snapshot hides it), so cancel restores it under the overlay + // before the transition is skipped. + async #finishLayeredDrag(drag, commit, playbackRate = 1) { + if (this.#vtFinishing === drag) return + this.#vtFinishing = drag + const { transition, offset, startPosition, style, forward } = drag + const { size } = this + const id = ++this.#slideTurnId + this.#isAnimating = true + try { + // The update callback owns the old/new snapshot boundary. Await it + // before any terminal event so `covered` can never arrive after a + // very fast release has already announced cancellation. + try { await transition.updateCallbackDone } catch { /* skipped */ } + try { await transition.ready } catch { /* capture failed */ } + if (id !== this.#slideTurnId) return + + const anims = drag.anims + if (anims) for (const a of anims) updatePlaybackRate(a, playbackRate) + if (commit) { + if (anims) for (const a of anims) a.play() + try { + await transition.finished + } catch { /* skipped */ } + } else { + if (anims) { + for (const a of anims) a.reverse() + try { + await Promise.all(anims.map(a => a.finished)) + } catch { /* superseded */ } + } + if (id !== this.#slideTurnId) return + // Restore the pre-turn page under the overlay, then drop it. + this.containerPosition = startPosition + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'cancelled', style, forward, committed: false }, + })) + // Give the host two rendering opportunities to paint restored + // chrome underneath the now-flat snapshot before it is removed. + await new Promise(resolve => requestAnimationFrame(() => + requestAnimationFrame(resolve))) + if (id !== this.#slideTurnId) return + try { transition.skipTransition() } catch { /* already done */ } + try { + await transition.finished + } catch { /* skipped */ } + } + if (id !== this.#slideTurnId) return + this.#vtCleanup() + this.#isAnimating = false + const finalPosition = commit ? offset : startPosition + this.containerPosition = finalPosition + this.#scrollBounds = [ + finalPosition, + this.atStart ? 0 : size, + this.atEnd ? 0 : size, + ] + this.#afterScroll('snap') + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'finished', style, forward, committed: commit }, + })) + } finally { + if (this.#vtFinishing === drag) this.#vtFinishing = null + } + } + #dragBy(dx) { + if (!this.#scrollBounds) return + const [, a, b] = this.#scrollBounds + const width = this.#container.getBoundingClientRect().width + // Exit direction of a forward turn: vertical-rl pages exit right. + const forwardSign = this.#rtl ? 1 : -1 + const max = (forwardSign > 0 ? b : a) > 0 ? width : 0 + const min = (forwardSign > 0 ? a : b) > 0 ? -width : 0 + this.#dragTranslateX = Math.max(min, Math.min(max, this.#dragTranslateX - dx)) + for (const el of this.#container.children) { + el.style.transition = 'none' + el.style.transform = `translateX(${this.#dragTranslateX}px)` + } + } + // Animate a released drag back to rest when the page did not turn, and + // drop the inline drag styles once the views are back in place. Pass + // instant=true to drop them without the settle animation. + #settleDrag(instant) { + const startX = this.#dragTranslateX + this.#dragTranslateX = 0 + const children = [...this.#container.children] + if (!children.some(el => el.style.transform)) return + const cleanup = () => { + for (const el of children) { + el.style.willChange = '' + el.style.transition = '' + el.style.transform = '' + } + } + if (!startX || instant) return cleanup() + const id = ++this.#slideTurnId + for (const el of children) { + el.style.transition = 'transform 150ms ease-out' + el.style.transform = 'translateX(0px)' + } + setTimeout(() => { + if (id !== this.#slideTurnId) return + cleanup() + }, 170) + } + #onTouchEnd(e) { + // Remove will-change hint to free GPU resources + // if (this.#view?.element) { + // this.#view.element.style.willChange = 'auto' + // } + + // The drag is over: drop the drag-time paint snapshot so the snap + // animation (or any other repaint) rebuilds a fresh context. + this.#bgAnimContext = null + const state = this.#touchState + if (state) state.active = false + if (state?.blocked) { + this.#touchScrolled = false + return + } + + if (!this.#touchScrolled) { + // A tap that never dragged may still have taken over a mid-flight + // transform in #onTouchStart; put the views back to rest. + if (this.#vertical && !this.scrolled && this.#dragTranslateX) this.#settleDrag() + return + } + this.#touchScrolled = false + if (this.scrolled) return + if (this.hasAttribute('no-swipe')) return + const layeredRejected = this.#layeredTurn + && state?.layeredGesture === 'rejected' + // Horizontal books have no block-axis page gesture to preserve. + if (layeredRejected && !this.#vertical) return + + // A finger that rested before lifting has no flick momentum; the + // last touchmove velocity is stale by the rest duration. + if (state && e && e.timeStamp - state.t > RELEASE_PAUSE_THRESHOLD_MS) { + state.vx = 0 + state.vy = 0 + } + const releaseTouch = e?.changedTouches?.[0] + let releaseDx = state?.dx ?? 0 + let releaseDy = state?.dy ?? 0 + if (state && releaseTouch) { + // A quick lift can carry the final sample only in changedTouches. + // Use that point consistently for Slide's velocity, progress, and + // whole-gesture horizontal-intent guard. + releaseDx += state.x - releaseTouch.screenX + releaseDy += state.y - releaseTouch.screenY + } + // Also stamp an unchanged final position. Besides making a deliberate + // pause velocity-free, this is defensive against non-standard UAs + // that omit touchend.changedTouches. + if (state) updateReleaseSample(state, releaseDx, e.timeStamp) + + // A finger-tracked layered turn resolves here. Slide projects recent + // release velocity onto its current progress; Curl keeps the existing + // last-move flick-or-halfway rule. A page-turn commit also requires + // the WHOLE gesture to be predominantly horizontal: a finger + // landing with a sideways wobble can start the drag before any + // vertical distance accumulates, and the lift-off flick velocity is + // jitter — judged alone they turned the page randomly on vertical + // toolbar-toggle swipes (Android WebView report). + const drag = this.#vtDrag + if (drag) { + this.#vtDrag = null + // Keep Curl's established whole-gesture guard exactly as-is; + // Slide uses the actual lift-off point required by its projection. + const gestureDx = drag.style === 'slide' ? releaseDx : (state?.dx ?? 0) + const gestureDy = drag.style === 'slide' ? releaseDy : (state?.dy ?? 0) + const gestureAligned = state + ? Math.abs(gestureDx) > Math.abs(gestureDy) : true + const alongV = this.#rtl ? -(state?.vx ?? 0) : (state?.vx ?? 0) + const recentVx = state ? getReleaseVelocity(state) : 0 + const recentAlongV = this.#rtl ? -recentVx : recentVx + const progressVelocity = recentAlongV * (drag.forward ? 1 : -1) + const releaseAlong = this.#rtl ? -releaseDx : releaseDx + const releaseDistance = drag.forward ? releaseAlong : -releaseAlong + const releaseProgress = Math.max(0, Math.min(1, + releaseDistance / drag.width)) + const releaseVisualProgress = Math.max(0, Math.min(1, + (releaseDistance - drag.visualOriginDistance) / drag.width)) + const projectedProgress = releaseProgress + + progressVelocity * SLIDE_RELEASE_PROJECTION_MS / drag.width + const curlFlick = Math.abs(alongV) > 0.3 + ? Math.sign(alongV) * (drag.forward ? 1 : -1) : 0 + const commit = gestureAligned && (drag.style === 'slide' + ? projectedProgress > 0.5 + : curlFlick > 0 ? true : curlFlick < 0 ? false : drag.progress > 0.5) + // Do not scrub a gesture that ended vertically: flashing its final + // horizontal component here would defeat the intent guard. A valid + // Slide release settles from the actual lift-off position; Curl's + // existing progress and commit mapping remain unchanged. + if (gestureAligned && drag.style === 'slide') { + drag.progress = releaseVisualProgress + this.#vtDragScrub(drag) + } + const targetDirection = (drag.forward ? 1 : -1) * (commit ? 1 : -1) + // Settle pacing uses a short release window; the decision above + // uses it for the lighter Slide gesture while Curl keeps its + // original last-sample commit rule. + const releaseSpeed = recentAlongV * targetDirection + const playbackRate = layeredSettlePlaybackRate(drag.style, releaseSpeed) + this.#finishLayeredDrag(drag, commit, playbackRate) + return + } + + // XXX: Firefox seems to report scale as 1... sometimes...? + // at this point I'm basically throwing `requestAnimationFrame` at + // anything that doesn't work + const snapState = state + requestAnimationFrame(() => { + if (globalThis.visualViewport.scale === 1 && snapState + && this.#touchState === snapState) { + const { vx, vy, dx, dy, dt } = snapState + // Direction ownership is final for this touch sequence. Once + // vertical wins the layered arena, discard later horizontal + // hooks while preserving block-axis paging in vertical books. + this.snap(layeredRejected ? 0 : vx, vy, + layeredRejected ? 0 : dx, dy, dt) + } + }) + } + #onTouchCancel() { + this.#bgAnimContext = null + const state = this.#touchState + if (state) state.active = false + if (state?.blocked) { + this.#touchScrolled = false + return + } + + const drag = this.#vtDrag + if (drag) { + this.#vtDrag = null + this.#touchScrolled = false + this.#finishLayeredDrag(drag, false) + return + } + + const wasScrolled = this.#touchScrolled + this.#touchScrolled = false + if (this.scrolled || this.hasAttribute('no-swipe')) return + if (this.#layeredTurn && state?.layeredGesture === 'rejected' + && !this.#vertical) return + if (this.#vertical) { + this.#settleDrag() + } else if (wasScrolled && this.#scrollBounds) { + this.#scrollTo(this.#scrollBounds[0], 'snap') + } + } + // allows one to process rects as if they were LTR and horizontal + #getRectMapper(view) { + if (this.scrolled) { + const size = view ? view.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize + const marginTop = this.#marginTop + const marginBottom = this.#marginBottom + return this.#vertical + ? ({ left, right }) => + ({ left: size - right - marginTop, right: size - left - marginBottom }) + : ({ top, bottom }) => ({ left: top - marginTop, right: bottom - marginBottom }) + } + // For RTL the mapper mirrors a rect within the iframe-local + // coordinate space of the *target view* (each view is a separate + // document with its own column layout), not across the whole + // container. Using `#renderedPages * size` (= total width of all + // loaded views) was correct only when a single view was loaded; + // once #fillVisibleArea pre-loads adjacent sections the total + // width grows but the per-view rect coordinates do not change, + // so the mapper would scroll the same anchor to a different + // (further-right) container offset on every re-anchor — driving + // the page off the user's saved position. Use the supplied + // view's width when available, falling back to the primary view. + const targetView = view ?? this.#primaryView + const viewSize = targetView + ? targetView.element.getBoundingClientRect()[this.sideProp] + : this.#renderedViewSize + // Vertical books map the block axis (top/bottom) onto the scroll + // axis regardless of page progression: vertical-rl is RTL but its + // scrollTop still grows forward, so the RTL mirror below only + // applies to horizontal writing. + return this.#vertical + ? ({ top, bottom }) => ({ left: top, right: bottom }) + : this.#rtl + ? ({ left, right }) => + ({ left: viewSize - right, right: viewSize - left }) + : f => f + } + async #scrollToRect(rect, reason) { + if (this.scrolled) { + // rect is in iframe-local coordinates; add view offset + // to convert to container scroll coordinates + const localOffset = this.#getRectMapper()(rect).left - 3 + const viewOffset = this.#getViewOffset(this.#primaryIndex) + return this.#scrollTo(viewOffset + localOffset, reason) + } + // rect is in iframe-local coordinates. Convert to container + // coordinates by adding the primary view's offset. + const localOffset = this.#getRectMapper()(rect).left + const viewOffset = this.#getViewOffset(this.#primaryIndex) + const containerOffset = viewOffset + localOffset + return this.#scrollToPage(Math.floor(containerOffset / this.size + 0.01), reason) + } + async #scrollTo(offset, reason, smooth) { + const { size } = this + // Near-equality, not exact: on fractional device-pixel-ratio screens + // (e.g. 2.75) the container scroll rests a sub-pixel off the page + // offset, and an exact check made every same-page settle miss this + // short-circuit and run a full animation — with the layered turn + // styles, a visible full-page view-transition flash on every + // vertical toolbar-toggle swipe. + if (Math.abs(this.containerPosition - offset) < 1) { + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + // A released drag that stays on the same page settles back to rest. + if (this.#vertical && !this.scrolled) this.#settleDrag() + this.#afterScroll(reason) + return + } + // FIXME: vertical-rl only, not -lr + if (this.scrolled && this.#vertical) offset = -offset + if ((reason === 'snap' || smooth) && this.hasAttribute('animated') && !this.hasAttribute('eink')) { + // Layered turn styles: snapshot the outgoing page and animate it + // over the live, stationary incoming page (readest#555). Works + // for every writing mode since the snapshot is axis-agnostic — + // but only for actual page changes: a sub-page settle must not + // snapshot and re-slide the page it is already resting on. + const turning = Math.abs(offset - this.containerPosition) > size / 2 + const layered = !this.scrolled && turning ? this.#layeredTurn : null + if (layered) return this.#viewTransitionTurn(offset, reason, layered) + const startPosition = this.containerPosition + this.#isAnimating = true + // Vertical paginated books page along scrollTop but read + // horizontally, so a scroll-axis slide would move perpendicular to + // the page turn. Run the two-phase horizontal slide instead: + // vertical-rl turns forward exit to the right (page progression), + // vertical-lr to the left (readest#624). + if (!this.scrolled && this.#vertical) { + this.#bgAnimContext = null + // Oversized sections would composite as one giant layer to + // animate the transform (the freeze rafAnimateScroll exists to + // avoid), and a native scroll animation cannot move + // horizontally here, so swap instantly. + if (!this.hasAttribute('gpu-composite') + && this.#renderedViewSize > RAF_ANIMATE_SCROLL_THRESHOLD) { + this.#isAnimating = false + this.#settleDrag(true) + this.containerPosition = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + return + } + const id = ++this.#slideTurnId + const forward = offset > startPosition + const exitSign = (this.#rtl ? 1 : -1) * (forward ? 1 : -1) + const width = this.#container.getBoundingClientRect().width + // Continue from a finger drag already in progress. + const dragStartX = this.#dragTranslateX + this.#dragTranslateX = 0 + return slideTurnAnimation( + this.#container, this.scrollProp, offset, exitSign, width, 300, + () => id !== this.#slideTurnId, + // Reposition the background segments for the new scroll + // offset while both pages are off-screen. + () => this.#replaceBackground(), + dragStartX, + ).then(() => { + if (id !== this.#slideTurnId) return + this.#isAnimating = false + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + } + // Snapshot the invariant paint inputs once; every per-frame + // #replaceBackground below reuses this instead of forcing a fresh + // style+layout read each frame (readest#4785). + this.#bgAnimContext = this.scrolled ? null : this.#computePaginatedBgContext() + // For a large section the CSS-transform animation blocks the UI while + // Blink composites the oversized layer; animate the native scroll + // offset instead (incremental/tiled, like a swipe), keeping the + // per-page backgrounds synced each frame. Hosts that composite large + // layers without that freeze (Apple WebKit, via the gpu-composite + // opt-in) skip this main-thread fallback and keep the smooth GPU + // cssAnimateScroll path even for large sections (readest#4768). + if (!this.hasAttribute('gpu-composite') + && this.#renderedViewSize > RAF_ANIMATE_SCROLL_THRESHOLD) { + return rafAnimateScroll(startPosition, offset, 300, easeOutQuad, x => { + this.#container[this.scrollProp] = x + if (!this.scrolled) this.#replaceBackground() + }).then(() => { + this.#isAnimating = false + this.#bgAnimContext = null + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + } + // Slide the per-view backgrounds in lockstep with the content. The + // content animates via a transform on each view; we re-sync the + // backgrounds to that animated offset every frame so each page's + // colour stays glued to its content as it slides. Pre-setting the + // destination instead made the outgoing page lose its background the + // instant the animation started, flashing the wrong colour across + // the part of the screen it still covered until it slid off. + if (!this.scrolled) { + this.#replaceBackground(startPosition) + const child = this.#container.children[0] + const syncBackground = () => { + if (!this.#isAnimating) return + const transform = child && getComputedStyle(child).transform + const tx = transform && transform !== 'none' + ? new DOMMatrix(transform)[this.#vertical ? 'm42' : 'm41'] : 0 + this.#replaceBackground(startPosition - tx) + requestAnimationFrame(syncBackground) + } + requestAnimationFrame(syncBackground) + } + // Use GPU-accelerated scroll animation for smoother experience on high refresh rate screens + return cssAnimateScroll( + this.#container, + this.scrollProp, + startPosition, + offset, + 300, + ).then(() => { + this.#isAnimating = false + this.#bgAnimContext = null + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + } else { + if (this.#vertical && !this.scrolled) this.#settleDrag(true) + this.containerPosition = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + } + } + // Turn the page with a View Transitions snapshot (readest#555): the live + // content jumps to the destination inside the transition callback, then + // the rasterized outgoing page slides away or curls open ON TOP of it, so + // the incoming page stays perfectly still underneath (Apple Books style). + // Forward turns move the old snapshot out; backward turns bring the new + // snapshot in over the still page. The choreography is selected with + // classes on the document root, where the ::view-transition pseudo tree + // lives. + async #viewTransitionTurn(offset, reason, style) { + // A layered transition has exclusive ownership of the document-level + // pseudo tree. Ignore overlapping navigation until its lifecycle has + // reached cleanup; otherwise the newer generation strands the older + // turn before it can dispatch `finished`. + if (this.#vtDrag || this.#vtFinishing || this.#vtProgrammatic) return + // An accepted keyboard/tap turn owns navigation for the remainder of + // any finger that is still down. Permanently reject a pending arena + // candidate so its later move cannot reuse the pre-turn start point + // and launch a second layered turn after this transition finishes. + if (this.#touchState?.active) this.#rejectLayeredGesture(this.#touchState) + const { size } = this + const startPosition = this.containerPosition + // RTL horizontal scroll coordinates are negative; compare magnitudes. + const forward = Math.abs(offset) > Math.abs(startPosition) + const id = ++this.#slideTurnId + this.#isAnimating = true + this.#settleDrag(true) + this.#vtSetup(style, forward) + const transition = document.startViewTransition(() => { + this.containerPosition = offset + if (!this.scrolled) { + this.#bgAnimContext = null + this.#replaceBackground() + } + }) + const active = { transition, id } + this.#vtProgrammatic = active + try { + try { + await transition.finished + } catch { + // Interrupted or skipped; the newer turn owns the cleanup. + } + if (id !== this.#slideTurnId) return + this.#vtCleanup() + this.#isAnimating = false + // A neighbor view finishing its load mid-transition re-anchors the + // container to the stale pre-turn anchor; re-assert the destination + // like the push animation does at its end. + this.containerPosition = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + } finally { + if (this.#vtProgrammatic === active) this.#vtProgrammatic = null + } + } + async #scrollToPage(page, reason, smooth) { + // Negative offsets are an artifact of RTL horizontal scroll + // coordinates; vertical books page along scrollTop, always positive. + const offset = this.size * (this.#rtl && !this.#vertical ? -page : page) + return this.#scrollTo(offset, reason, smooth) + } + async scrollToAnchor(anchor, select, smooth) { + return this.#scrollToAnchor(anchor, select ? 'selection' : 'navigation', smooth) + } + async #scrollToAnchor(anchor, reason = 'anchor', smooth = false) { + this.#anchor = anchor + const rects = uncollapse(anchor)?.getClientRects?.() + // if anchor is an element or a range + if (rects) { + // when the start of the range is immediately after a hyphen in the + // previous column, there is an extra zero width rect in that column + const rect = Array.from(rects) + .find(r => r.width > 0 && r.height > 0 && r.x >= 0 && r.y >= 0) || rects[0] + // An anchor can have no client rects at all: a range over content + // that is entirely absolutely positioned (e.g. a Duokan fullscreen + // cover pins its only image) has no in-flow boxes. Bailing without + // scrolling would leave #scrollBounds unseeded, and scrollBy/snap + // then silently drop every swipe on the page (#5263). Settle on + // the primary section's start instead. + if (!rect) return this.scrolled + ? this.#scrollTo(this.#getViewOffset(this.#primaryIndex), reason) + : this.#scrollToPage(this.#getPagesBeforeView(this.#primaryIndex), reason) + await this.#scrollToRect(rect, reason) + // focus the element when navigating with keyboard or screen reader + if (reason === 'navigation') { + let node = anchor.focus ? anchor : undefined + if (!node && anchor.startContainer) { + node = anchor.startContainer + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentElement + } + } + if (node && node.focus) { + node.tabIndex = -1 + node.style.outline = 'none' + node.focus({ preventScroll: true }) + } + } + return + } + // if anchor is a fraction + if (this.scrolled) { + // In scrolled mode with multi-view, offset to the primary view's position + const primaryOffset = this.#getViewOffset(this.#primaryIndex) + const primaryView = this.#primaryView + const primarySize = primaryView + ? primaryView.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize + await this.#scrollTo(primaryOffset + anchor * primarySize, reason, smooth) + return + } + // In paginated mode, account for pages before the primary section + const primaryView = this.#primaryView + if (!primaryView) return + const pagesBeforePrimary = this.#getPagesBeforeView(this.#primaryIndex) + const textPages = primaryView.contentPages + // Same as the rect-less bail above: a section that measured zero + // content pages must still settle so #scrollBounds gets seeded. + if (!textPages) return this.#scrollToPage(pagesBeforePrimary, reason) + // textPages is in column units; convert to spread page for scrolling + const newColumn = Math.round(anchor * (textPages - 1)) + const newSpreadPage = Math.floor(newColumn / this.columnCount) + await this.#scrollToPage(pagesBeforePrimary + newSpreadPage, reason, smooth) + } + // Get the pixel offset of a view within the container + #getViewOffset(index) { + let offset = 0 + for (const [i, view] of this.#sortedViews) { + if (i === index) return offset + offset += view.element.getBoundingClientRect()[this.sideProp] + } + return offset + } + // Get number of full pages (spreads) before a given view. + // Uses floor so the view's first column is always on or after + // the returned page — never rounded past it. The 0.01 tolerance + // absorbs sub-pixel drift on fractional-DPR devices where + // getBoundingClientRect() accumulates ~0.0001px errors. + #getPagesBeforeView(index) { + return Math.floor(this.#getViewOffset(index) / this.size + 0.01) + } + #getVisibleRange() { + const targetView = this.#primaryView + if (!targetView?.document) return + const viewOffset = this.#getViewOffset(this.#primaryIndex) + if (this.scrolled) { + // In scrolled mode several sections can share the viewport at a + // section boundary, and the primary view may even be scrolled out + // of view. Prefer the view that covers the viewport centre — that + // is the section the reader is actually reading, so its title is + // the one to show. Falling back to the first overlapping view (the + // old behaviour) would report a thin sliver at the top edge, whose + // chapter title no longer matches the dominant content + // (readest#4436). Keep that first valid range as a fallback for + // when no loaded view covers the centre (e.g. at the very top or + // bottom of the book). + const center = this.#renderedStart + this.size / 2 + let fallback + for (const [index, v] of this.#sortedViews) { + if (!v.document) continue + const off = this.#getViewOffset(index) + const vSize = v.element.getBoundingClientRect()[this.sideProp] + // Skip views entirely outside the viewport + if (off + vSize <= this.#renderedStart || off >= this.#renderedEnd) continue + const range = getVisibleRange(v.document, + this.#renderedStart - off, this.#renderedEnd - off, + this.#getRectMapper(v)) + if (!range || range.collapsed) continue + if (center >= off && center < off + vSize) return { range, index } + fallback ??= { range, index } + } + return fallback + } + const range = getVisibleRange(targetView.document, + this.#renderedStart - viewOffset, + this.#renderedEnd - viewOffset, + this.#getRectMapper(targetView)) + return range ? { range, index: this.#primaryIndex } : undefined + } + // Determine which view is primary based on scroll position + #detectPrimaryView() { + if (this.#views.size <= 1) return + const visibleStart = this.#renderedStart + let offset = 0 + for (const [index, view] of this.#sortedViews) { + const viewSize = view.element.getBoundingClientRect()[this.sideProp] + if (visibleStart < offset + viewSize - 1) { + if (index !== this.#primaryIndex) { + this.#primaryIndex = index + this.#syncA11y() + this.#trimDistantViews() + this.#replaceBackground() + this.#fillPromise = this.#preloadNext() + } + return + } + offset += viewSize + } + } + // Pre-load adjacent sections from the current primary so the + // next/prev sections are ready when the user paginates. + // Does NOT re-scroll to avoid fighting with the user's current + // scroll position. + async #preloadNext() { + if (this.noPreload || this.noContinuousScroll) return + this.#filling = true + try { + const { size } = this + const minPages = 5 + const maxSections = 8 + // Load forward sections until we have enough pages ahead + let iterations = 0 + while (this.#views.size < maxSections && iterations < maxSections) { + iterations++ + const pagesAhead = size > 0 + ? Math.floor((this.#renderedViewSize - this.#renderedEnd) / size) + : 0 + if (pagesAhead >= minPages) break + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] + if (lastIndex == null) break + const nextIdx = this.#adjacentIndex(1, lastIndex) + if (nextIdx == null) break + // Stop preloading at writing-mode boundaries + if (!this.#isSameDirection(nextIdx)) break + await this.#loadAdjacentSection(nextIdx) + if (!this.#views.has(nextIdx)) break + } + // Wait a frame so ResizeObserver callbacks fire while + // #filling is still true, preventing onExpand from + // re-scrolling to a stale anchor position. + await new Promise(r => requestAnimationFrame(r)) + } finally { + this.#filling = false + this.dispatchEvent(new Event('stabilized')) + } + } + #afterScroll(reason) { + // In multi-view, detect which section is primary + if (this.#views.size > 1 && reason !== 'anchor' && reason !== 'navigation') { + this.#detectPrimaryView() + // Scrolling can bring a previously off-screen view into the + // viewport (e.g. the next section's first column joining the + // current section's last column in a dual-page spread) without + // changing which view is primary. Re-sync a11y attributes so + // a newly visible view stops being aria-hidden. + this.#syncA11y() + } + const { range, index: visibleIndex } = this.#getVisibleRange() || {} + if (!range) return + this.#lastVisibleRange = range + // don't set new anchor if relocation was to scroll to anchor + if (reason !== 'selection' && reason !== 'navigation' && reason !== 'anchor') + this.#anchor = range + else this.#justAnchored = true + + const index = visibleIndex ?? this.#primaryIndex + const primaryView = this.#primaryView + const detail = { reason, range, index } + if (this.scrolled) { + // The relocated index may differ from #primaryIndex (the centre of + // the viewport can sit in a different view than its top edge), so + // size the fraction against the relocated view to keep it in sync. + const indexView = this.#views.get(index) ?? primaryView + const primaryOffset = this.#getViewOffset(index) + const primarySize = indexView + ? indexView.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize + detail.fraction = primarySize > 0 + ? Math.max(0, Math.min(1, (this.#renderedStart - primaryOffset) / primarySize)) : 0 + } else if (this.#renderedPages > 0 && primaryView) { + const page = this.#renderedPage + const pagesBeforePrimary = this.#getPagesBeforeView(index) + const textPages = primaryView.contentPages + this.#header.style.visibility = page > 0 ? 'visible' : 'hidden' + // page is in spread units, textPages is in column units + const localPage = page - pagesBeforePrimary + const localColumn = localPage * this.columnCount + detail.fraction = textPages > 0 ? Math.max(0, Math.min(1, localColumn / textPages)) : 0 + detail.size = textPages > 0 ? this.columnCount / textPages : 1 + if (reason === 'container-scroll' && localPage === 0) return + } + // Update per-column backgrounds for the current scroll position + if (!this.scrolled) this.#replaceBackground() + this.dispatchEvent(new CustomEvent('relocate', { detail })) + } + async #display(promise) { + this.#stabilizing = true + this.#container.style.opacity = '0' + const { index, src, data, anchor, onLoad, select } = await promise + this.#primaryIndex = index + this.#syncA11y() + const hasFocus = this.#primaryView?.document?.hasFocus() + if (src) { + const view = this.#createView(index) + const afterLoad = doc => { + if (doc.head) { + const $styleBefore = doc.createElement('style') + doc.head.prepend($styleBefore) + const $style = doc.createElement('style') + doc.head.append($style) + this.sections[index].spineProperties?.forEach( + prop => doc.documentElement.setAttribute('data-' + prop, '')) + this.#styleMap.set(doc, [$styleBefore, $style]) + } + onLoad?.({ doc, index }) + } + const beforeRender = this.#beforeRender.bind(this) + await view.load(src, data, afterLoad, beforeRender) + if (!view.document?.documentElement || !view.document.body) { + this.#destroyView(index) + this.#primaryIndex = this.#sortedViews[0]?.[0] ?? -1 + this.#container.style.opacity = '1' + this.#stabilizing = false + this.dispatchEvent(new Event('stabilized')) + return + } + // Cache direction for future preload boundary checks + if (view.document) { + const dir = getDirection(view.document) + this.#directionCache.set(index, dir.vertical) + } + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) + } + // Pre-load previous section when needed: + // - Short primary alignment (section shorter than one spread) + // - Scrolled mode with anchor in top half — so the user can + // scroll backward into the previous section immediately + const primaryView = this.#primaryView + if (!this.noPreload && !this.noContinuousScroll && primaryView) { + const needsPrev = (primaryView.contentPages > 0 && primaryView.contentPages < this.columnCount) + if (needsPrev || this.scrolled) { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] + if (firstIndex != null) { + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && this.#isSameDirection(prevIdx)) { + await this.#loadAdjacentSection(prevIdx) + } + } + } + } + const resolvedAnchor = (typeof anchor === 'function' + ? anchor(primaryView.document) : anchor) ?? 0 + await this.scrollToAnchor(resolvedAnchor, select) + if (hasFocus) this.focusView() + // Reveal content now that primary section is positioned + this.#container.style.opacity = '1' + this.#rendered = true + // Emit stabilized so listeners can react, but keep #stabilizing + // true until fill completes to prevent the debounced scroll + // handler from loading backward sections during rapid DOM changes. + this.dispatchEvent(new Event('stabilized')) + // Load remaining adjacent sections progressively (non-blocking). + // In scrolled mode, skip reanchor — browser scroll anchoring + // preserves position when content is added above/below. + this.#fillPromise = this.#fillVisibleArea( + { reanchor: !this.scrolled }) + this.#fillPromise.then(() => { this.#stabilizing = false }) + } + // Load an adjacent section without changing primary index + async #loadAdjacentSection(index) { + if (this.#views.has(index) || !this.#canGoToIndex(index)) return + const section = this.sections[index] + if (!section || section.linear === 'no') return + // Detect a prepend: a section being inserted *above* every currently + // loaded view in scrolled mode. The browser suppresses scroll + // anchoring while scrollTop is 0, so the inserted section would push + // the visible content down and the viewport would drift into the + // previous section (readest/readest#4112). Capture the scroll position + // before the insertion so it can be restored once the view renders. + const firstIndex = this.#sortedViews[0]?.[0] + const isPrepend = this.scrolled && firstIndex != null && index < firstIndex + const startBefore = isPrepend ? this.#renderedStart : 0 + try { + const src = await section.load() + const data = await section.loadContent?.() + const view = this.#createView(index) + const afterLoad = doc => { + if (doc.head) { + const $styleBefore = doc.createElement('style') + doc.head.prepend($styleBefore) + const $style = doc.createElement('style') + doc.head.append($style) + section.spineProperties?.forEach( + prop => doc.documentElement.setAttribute('data-' + prop, '')) + this.#styleMap.set(doc, [$styleBefore, $style]) + } + this.setStyles(this.#styles) + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) + } + // Adjacent sections reuse the primary view's cached layout + // — they must NOT call #beforeRender, which would modify + // global state (direction, CSS classes, dir attribute, etc.). + const cachedLayout = this.#lastLayout + const beforeRender = () => cachedLayout + await view.load(src, data, afterLoad, beforeRender) + if (!view.document?.documentElement || !view.document.body) { + this.#destroyView(index) + return + } + // Cache direction for future preload boundary checks + if (view.document) { + const dir = getDirection(view.document) + this.#directionCache.set(index, dir.vertical) + // Destroy views with a different writing-mode immediately. + // Mixed-direction views corrupt scroll/page calculations. + if (dir.vertical !== this.#vertical) { + this.#destroyView(index) + return + } + } + // Keep the previously visible content anchored: the new view added + // `addedSize` px above it, so the scroll position must grow by the + // same amount. This corrects the browser's scroll-anchoring + // suppression at scrollTop 0 and is a no-op when anchoring already + // handled the shift (correction ≈ 0). + if (isPrepend) { + const addedSize = view.element.getBoundingClientRect()[this.sideProp] + const correction = startBefore + addedSize - this.#renderedStart + if (Math.abs(correction) > 0.5) + this.containerPosition += (this.#vertical ? -1 : 1) * correction + } + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) + } catch (e) { + console.warn(e) + console.warn(new Error(`Failed to load adjacent section ${index}`)) + } + } + // Fill adjacent sections until at least `minPages` pages exist + // beyond the current viewport in each direction (forward always, + // backward only when the primary section is short). + // When reanchor is false (background pre-loading), skip re-scrolling + // to avoid fighting with the user's current scroll position. + async #fillVisibleArea({ reanchor = true } = {}) { + if (this.noPreload || this.noContinuousScroll || this.#filling) return + this.#filling = true + try { + const { size } = this + if (!size) return + const minPages = 5 + const maxSections = 8 + + // If the primary section is shorter than one spread and + // there's no section already loaded before it, load the + // previous section to fill the leading columns + const primaryView = this.#primaryView + if (primaryView && primaryView.contentPages > 0 + && primaryView.contentPages < this.columnCount) { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] + if (firstIndex != null && firstIndex >= this.#primaryIndex) { + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && this.#isSameDirection(prevIdx)) { + await this.#loadAdjacentSection(prevIdx) + } + } + } + + // Load forward sections until we have enough pages ahead + let iterations = 0 + while (this.#views.size < maxSections && iterations < maxSections) { + iterations++ + const pagesAhead = Math.floor( + (this.#renderedViewSize - this.#renderedEnd) / size) + if (pagesAhead >= minPages) break + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] + if (lastIndex == null) break + const nextIdx = this.#adjacentIndex(1, lastIndex) + if (nextIdx == null) break + // Stop at writing-mode boundaries + if (!this.#isSameDirection(nextIdx)) break + await this.#loadAdjacentSection(nextIdx) + if (!this.#views.has(nextIdx)) break + } + if (reanchor) this.#scrollToAnchor(this.#anchor) + } finally { + this.#filling = false + // Emit stabilized so post-layout processing (e.g. warichu) + // runs for newly loaded adjacent sections. + this.dispatchEvent(new Event('stabilized')) + } + } + // Trim views whose content is entirely more than 10 pages away + // from the current viewport. Only removes views AFTER the primary + // — removing views before would shift scroll position. + #trimDistantViews() { + const { size } = this + if (!size) return + const maxDistance = size * 10 + const viewportEnd = this.#renderedEnd + for (const [index, view] of this.#sortedViews) { + if (index <= this.#primaryIndex) continue + const offset = this.#getViewOffset(index) + if (offset - viewportEnd > maxDistance) { + this.#destroyView(index) + } + } + } + #canGoToIndex(index) { + return index >= 0 && index <= this.sections.length - 1 + } + async #goTo({ index, anchor, select }) { + const section = this.sections[index] + if (!section) return + // Check if the target section has a different writing-mode. + // If direction changes, we must destroy all views and do a full + // rebuild via #display — mixed-direction views cannot coexist. + let directionChanged = false + if (this.#views.has(index)) { + const view = this.#views.get(index) + if (view?.document) { + const { vertical } = getDirection(view.document) + directionChanged = vertical !== this.#vertical + } + } else if (this.#directionCache.has(index)) { + directionChanged = this.#directionCache.get(index) !== this.#vertical + } + // When direction is unknown (not cached), #beforeRender will + // detect and clean up stale views if a change actually occurs. + + if (this.#views.has(index) && !directionChanged) { + // View already loaded — reuse it without + // clearing/reloading. Just change primary and scroll. + this.#stabilizing = true + // Continuous scrolled mode keeps the target view rendered, so we + // scroll straight to it without fading the container — fading + // produced a hard blank-screen flash on adjacent navigation + // (readest/readest#4112 follow-up). Paginated mode and discrete + // no-continuous-scroll still fade to hide the page reposition. + const blank = !this.scrolled || this.noContinuousScroll + if (blank) this.#container.style.opacity = '0' + const hasFocus = this.#primaryView?.document?.hasFocus() + this.#primaryIndex = index + this.#syncA11y() + this.#trimDistantViews() + // In noContinuousScroll mode, destroy all non-primary views + if (this.noContinuousScroll) { + for (const [i] of this.#views) { + if (i !== index) this.#destroyView(i) + } + } + const primaryView = this.#primaryView + const resolvedAnchor = (typeof anchor === 'function' + ? anchor(primaryView.document) : anchor) ?? 0 + // Pre-load the previous section so the user can move backward right + // away: a short paginated primary needs it to fill the leading + // columns; scrolled mode needs it so scrolling up reveals the + // previous section instead of dead-ending at the top (the debounced + // backward-preload can't cover this — it bails while navigation is + // stabilizing). Paginated must load it before revealing; scrolled + // mode loads it after the scroll so the transition stays instant, + // with #loadAdjacentSection compensation keeping the viewport + // anchored as the section is inserted above. + const needsPrev = primaryView && primaryView.contentPages > 0 + && primaryView.contentPages < this.columnCount + const loadPrev = async () => { + if (this.noPreload || this.noContinuousScroll) return + if (!(needsPrev || this.scrolled)) return + const firstIndex = this.#sortedViews[0]?.[0] + if (firstIndex == null) return + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && this.#isSameDirection(prevIdx)) + await this.#loadAdjacentSection(prevIdx) + } + if (!this.scrolled) await loadPrev() + await this.scrollToAnchor(resolvedAnchor, select) + if (this.scrolled) await loadPrev() + if (blank) this.#container.style.opacity = '1' + if (hasFocus) this.focusView() + // Load remaining adjacent sections progressively; + // keep #stabilizing true until fill completes + this.#fillPromise = this.#fillVisibleArea() + this.#fillPromise.then(() => { this.#stabilizing = false }) + } else { + // When direction changes, clear ALL views — no reuse possible + // across writing-mode boundaries. When direction is unknown + // (not yet cached), keep nearby views; #beforeRender will + // clean up if the loaded section turns out to differ. + if (directionChanged) { + this.#destroyAllViews() + } else { + const keep = new Set([index]) + if (!this.noContinuousScroll) { + for (const [i] of this.#views) { + if (Math.abs(i - index) <= 2) keep.add(i) + } + } + this.#clearViewsExcept(keep) + } + const oldIndex = this.#primaryIndex + const onLoad = detail => { + if (oldIndex >= 0 && !this.#views.has(oldIndex)) + this.sections[oldIndex]?.unload?.() + this.setStyles(this.#styles) + this.dispatchEvent(new CustomEvent('load', { detail })) + } + await this.#display(Promise.resolve(section.load()) + .then(async src => { + const data = await section.loadContent?.() + return { index, src, data, anchor, onLoad, select } + }).catch(e => { + console.warn(e) + console.warn(new Error(`Failed to load section ${index}`)) + return {} + })) + } + } + async goTo(target) { + if (this.#locked) return + const resolved = await target + if (this.#canGoToIndex(resolved.index)) return this.#goTo(resolved) + } + #scrollPrev(distance) { + if (this.#views.size === 0) return true + if (this.scrolled) { + if (this.#renderedStart > 0) return this.#scrollTo( + Math.max(0, this.#renderedStart - (distance ?? this.size)), null, true) + return !this.atStart + } + if (this.atStart) return + const page = this.#renderedPage - 1 + // Out of range — skip animation, go straight to previous section + if (page < 0) return true + return this.#scrollToPage(page, 'page', true) + } + #scrollNext(distance) { + if (this.#views.size === 0) return true + if (this.scrolled) { + if (this.#renderedViewSize - this.#renderedEnd > 2) return this.#scrollTo( + Math.min(this.#renderedViewSize, distance ? this.#renderedStart + distance : this.#renderedEnd), null, true) + return !this.atEnd + } + if (this.atEnd) return + const page = this.#renderedPage + 1 + const pages = this.#renderedPages + // Out of range — skip animation, go straight to next section + if (page >= pages) return true + return this.#scrollToPage(page, 'page', true) + } + get atStart() { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] ?? this.#primaryIndex + if (this.scrolled) return this.#adjacentIndex(-1, firstIndex) == null && this.#renderedStart <= 0 + return this.#adjacentIndex(-1, firstIndex) == null && this.#renderedPage <= 0 + } + get atEnd() { + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + if (this.scrolled) return this.#adjacentIndex(1, lastIndex) == null && this.#renderedViewSize - this.#renderedEnd <= 2 + return this.#adjacentIndex(1, lastIndex) == null && this.#renderedPage >= this.#renderedPages - 1 + } + #adjacentIndex(dir, fromIndex) { + if (fromIndex === undefined) fromIndex = this.#primaryIndex + for (let index = fromIndex + dir; this.#canGoToIndex(index); index += dir) + if (this.sections[index]?.linear !== 'no') return index + } + async #turnPage(dir, distance) { + if (this.#locked) return + this.#locked = true + const prev = dir === -1 + const shouldGo = await (prev ? this.#scrollPrev(distance) : this.#scrollNext(distance)) + if (shouldGo) { + // Wait for any in-progress background pre-loading to complete — + // it may already be loading the section we need, so awaiting + // it lets #goTo reuse the view instead of loading from scratch + if (this.#fillPromise) await this.#fillPromise + const sorted = this.#sortedViews + const edgeIndex = prev + ? sorted[0]?.[0] ?? this.#primaryIndex + : sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + await this.#goTo({ + index: this.#adjacentIndex(dir, edgeIndex), + anchor: prev ? () => 1 : () => 0, + }) + } + if (shouldGo || !this.hasAttribute('animated')) await wait(100) + this.#locked = false + } + async prev(distance) { + return await this.#turnPage(-1, distance) + } + async next(distance) { + return await this.#turnPage(1, distance) + } + async pan(dx, dy) { + if (this.#locked) return + this.#locked = true + this.scrollBy(dx, dy) + this.#locked = false + } + prevSection() { + return this.goTo({ index: this.#adjacentIndex(-1) }) + } + nextSection() { + return this.goTo({ index: this.#adjacentIndex(1) }) + } + firstSection() { + const index = this.sections.findIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + lastSection() { + const index = this.sections.findLastIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + getContents() { + const contents = [] + for (const [index, view] of this.#sortedViews) { + if (view.document) contents.push({ + index, + overlayer: view.overlayer, + doc: view.document, + }) + } + return contents + } + setStyles(styles) { + this.#styles = styles + for (const [, view] of this.#views) { + const $$styles = this.#styleMap.get(view.document) + if (!$$styles) continue + const [$beforeStyle, $style] = $$styles + if (Array.isArray(styles)) { + const [beforeStyle, style] = styles + $beforeStyle.textContent = beforeStyle + $style.textContent = style + } else $style.textContent = styles + + // needed because the resize observer doesn't work in Firefox + view.document?.fonts?.ready?.then(() => view.expand()) + } + + // NOTE: needs `requestAnimationFrame` in Chromium + const primaryView = this.#primaryView + if (primaryView) { + requestAnimationFrame(() => this.#replaceBackground()) + } + } + focusView() { + this.#primaryView?.document?.defaultView?.focus() + } + showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) { + this.#primaryView?.showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) + } + hideLoupe() { + this.#primaryView?.hideLoupe() + } + destroyLoupe() { + this.#primaryView?.destroyLoupe() + } + destroy() { + const transition = (this.#vtDrag ?? this.#vtFinishing)?.transition + ?? this.#vtProgrammatic?.transition + this.#vtDrag = null + this.#vtFinishing = null + this.#vtProgrammatic = null + this.#slideTurnId++ + if (transition || this.#vtNamedHost) { + transition?.ready?.catch(() => {}) + transition?.updateCallbackDone?.catch(() => {}) + try { transition?.skipTransition() } catch { /* already done */ } + this.#vtCleanup() + this.#isAnimating = false + } + this.#observer.unobserve(this) + this.#destroyAllViews() + this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener) + } +} + +customElements.define('foliate-paginator', Paginator) diff --git a/frontend/src/lib/vendor/foliate-js/pdf.js b/frontend/src/lib/vendor/foliate-js/pdf.js new file mode 100644 index 0000000..09e2dee --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/pdf.js @@ -0,0 +1,11 @@ +// NOT upstream foliate-js. See README.chitai.md. +// +// Chitai renders PDFs with the pdf.js viewer vendored at static/pdfjs/, so +// foliate's PDF backend is not vendored. view.js still references this module +// from makeBook via a static-string dynamic import, which Rollup resolves at +// build time regardless of whether it executes — so the file has to exist. +// +// Throwing at module scope surfaces a legible message in the reader's error +// card if a PDF is ever routed to the EPUB reader by mistake, rather than a +// TypeError from `globalThis.pdfjsLib` being undefined. +throw new Error('foliate-js PDF rendering is not enabled in Chitai'); diff --git a/frontend/src/lib/vendor/foliate-js/progress.js b/frontend/src/lib/vendor/foliate-js/progress.js new file mode 100644 index 0000000..ab0edc1 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/progress.js @@ -0,0 +1,224 @@ +// assign a unique ID for each TOC item +const assignIDs = toc => { + let id = 0 + const assignID = item => { + item.id = id++ + if (item.subitems) for (const subitem of item.subitems) assignID(subitem) + } + for (const item of toc) assignID(item) + return toc +} + +const flatten = items => items + .map(item => item.subitems?.length + ? [item, flatten(item.subitems)].flat() + : item) + .flat() + +export class TOCProgress { + async init({ toc, ids, splitHref, getFragment }) { + assignIDs(toc) + const items = flatten(toc) + const grouped = new Map() + for (const [i, item] of items.entries()) { + const [id, fragment] = await splitHref(item?.href) ?? [] + const value = { fragment, item } + if (grouped.has(id)) grouped.get(id).items.push(value) + else grouped.set(id, { prev: items[i - 1], items: [value] }) + } + const map = new Map() + for (const [i, id] of ids.entries()) { + if (grouped.has(id)) map.set(id, grouped.get(id)) + else map.set(id, map.get(ids[i - 1])) + } + this.ids = ids + this.map = map + this.getFragment = getFragment + } + getProgress(index, range) { + if (!this.ids) return + const id = this.ids[index] + const obj = this.map.get(id) + if (!obj) return null + const { prev, items } = obj + if (!items) return prev + if (!range || items.length === 1 && !items[0].fragment) return items[0].item + + const doc = range.startContainer.getRootNode() + for (const [i, { fragment }] of items.entries()) { + const el = this.getFragment(doc, fragment) + if (!el) continue + if (range.comparePoint(el, 0) > 0) + return (items[i - 1]?.item ?? prev) + } + return items[items.length - 1].item + } +} + +export class PageProgress { + #book + #cache = new Map() + #resolveNavigation + + constructor(book, resolveNavigation) { + this.#book = book + this.#resolveNavigation = resolveNavigation + } + + async #getCache(index) { + let cached = this.#cache.get(index) + if (cached) return cached + + const section = this.#book.sections[index] + if (!section?.createDocument) return null + + const doc = await section.createDocument() + const root = doc.body ?? doc.documentElement + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT) + const nodes = [] + const offsets = [] + let total = 0 + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const len = node.nodeValue?.length ?? 0 + nodes.push(node) + offsets.push(total) + total += len + } + + cached = { doc, nodes, offsets, total } + this.#cache.set(index, cached) + return cached + } + + async getProgress(cfi) { + try { + const nav = this.#resolveNavigation(cfi) + if (!nav) return null + + const { index, anchor } = nav + if (index == null || !anchor) return null + + const cached = await this.#getCache(index) + if (!cached) return null + + const { doc, nodes, offsets, total } = cached + const frag = anchor(doc) + if (!frag) return null + + const isRange = frag instanceof Range + const range = isRange ? frag : doc.createRange() + if (!isRange) range.selectNodeContents(frag) + + const offset = this.#findOffset(range, nodes, offsets, total) + return { + fraction: total > 0 ? offset / total : 0, + index, + } + } catch (e) { + console.error(e) + return null + } + } + + #findOffset(range, nodes, offsets, total) { + if (!nodes.length) return 0 + const container = range.startContainer + // fast path: startContainer is a text node in the index + if (container.nodeType === Node.TEXT_NODE) { + const i = this.#bsearchNode(container, nodes) + if (i >= 0) return offsets[i] + range.startOffset + } + // element container: collapse to start and binary search + const collapsed = range.cloneRange() + collapsed.collapse(true) + const i = this.#bsearchCollapsed(collapsed, nodes) + return i >= 0 ? offsets[i] : total + } + + // binary search for an exact text node by document position + #bsearchNode(target, nodes) { + let low = 0, high = nodes.length - 1 + while (low <= high) { + const mid = (low + high) >> 1 + const node = nodes[mid] + if (node === target) return mid + const pos = node.compareDocumentPosition(target) + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) low = mid + 1 + else high = mid - 1 + } + return -1 + } + + // binary search for the first text node at or after a collapsed range point + // collapsed.comparePoint returns: -1 = node is before, 1 = node is at/after + #bsearchCollapsed(collapsed, nodes) { + let low = 0, high = nodes.length - 1, result = -1 + while (low <= high) { + const mid = (low + high) >> 1 + if (collapsed.comparePoint(nodes[mid], 0) > 0) { + result = mid + high = mid - 1 + } else { + low = mid + 1 + } + } + return result + } +} + +export class SectionProgress { + constructor(sections, sizePerLoc, sizePerTimeUnit) { + this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0) + this.sizePerLoc = sizePerLoc + this.sizePerTimeUnit = sizePerTimeUnit + this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0) + this.sectionFractions = this.#getSectionFractions() + } + #getSectionFractions() { + const { sizeTotal } = this + const results = [0] + let sum = 0 + for (const size of this.sizes) results.push((sum += size) / sizeTotal) + return results + } + // get progress given index of and fractions within a section + getProgress(index, fractionInSection, pageFraction = 0) { + const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this + const sizeInSection = sizes[index] ?? 0 + const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0) + const size = sizeBefore + fractionInSection * sizeInSection + const nextSize = size + pageFraction * sizeInSection + const remainingTotal = sizeTotal - size + const remainingSection = (1 - fractionInSection) * sizeInSection + return { + fraction: nextSize / sizeTotal, + section: { + current: index, + total: sizes.length, + }, + location: { + current: Math.floor(size / sizePerLoc), + next: Math.floor(nextSize / sizePerLoc), + total: Math.ceil(sizeTotal / sizePerLoc), + }, + time: { + section: remainingSection / sizePerTimeUnit, + total: remainingTotal / sizePerTimeUnit, + }, + } + } + // the inverse of `getProgress` + // get index of and fraction in section based on total fraction + getSection(fraction) { + if (fraction <= 0) return [0, 0] + if (fraction >= 1) return [this.sizes.length - 1, 1] + fraction = fraction + Number.EPSILON + const { sizeTotal } = this + let index = this.sectionFractions.findIndex(x => x > fraction) - 1 + if (index < 0) return [0, 0] + while (!this.sizes[index]) index++ + const fractionInSection = (fraction - this.sectionFractions[index]) + / (this.sizes[index] / sizeTotal) + return [index, fractionInSection] + } +} diff --git a/frontend/src/lib/vendor/foliate-js/search.js b/frontend/src/lib/vendor/foliate-js/search.js new file mode 100644 index 0000000..affc9bc --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/search.js @@ -0,0 +1,317 @@ +// length for context in excerpts +const CONTEXT_LENGTH = 50 + +const normalizeWhitespace = str => str.replace(/\s+/g, ' ') + +// Gather context preceding the match by walking back across text nodes until we +// have enough for the excerpt. A match can sit in its own text node (e.g. a word +// wrapped in //), leaving no context within the start node itself. +const collectBefore = (strs, index, offset) => { + let str = strs[index].slice(0, offset) + for (let i = index - 1; i >= 0 && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i--) + str = strs[i] + str + return str +} + +const collectAfter = (strs, index, offset) => { + let str = strs[index].slice(offset) + for (let i = index + 1; i < strs.length && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i++) + str += strs[i] + return str +} + +const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => { + const start = strs[startIndex] + const end = strs[endIndex] + const match = startIndex === endIndex + ? start.slice(startOffset, endOffset) + : start.slice(startOffset) + + strs.slice(startIndex + 1, endIndex).join('') + + end.slice(0, endOffset) + const trimmedStart = normalizeWhitespace(collectBefore(strs, startIndex, startOffset)).trimStart() + const trimmedEnd = normalizeWhitespace(collectAfter(strs, endIndex, endOffset)).trimEnd() + const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…' + const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…' + const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}` + const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}` + return { pre, match, post } +} + +// Cumulative character offsets of the joined `strs`, so a flat offset into +// `strs.join('')` can be mapped back to a (node index, in-node offset) pair. +const buildCum = strs => { + const cum = [0] + for (let i = 0; i < strs.length; i++) cum.push(cum[i] + strs[i].length) + return cum +} + +// Largest node i with cum[i] <= offset; clamps the end-of-text offset to the +// last node's end. Works for both range start and (exclusive) end positions. +const nodeAt = (cum, offset) => { + let lo = 0, hi = cum.length - 2 + if (hi < 0) return { index: 0, offset: 0 } + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (cum[mid] <= offset) lo = mid + else hi = mid - 1 + } + return { index: lo, offset: offset - cum[lo] } +} + +const rangeFromFlat = (cum, start, end) => { + const s = nodeAt(cum, start) + const e = nodeAt(cum, end) + return { startIndex: s.index, startOffset: s.offset, endIndex: e.index, endOffset: e.offset } +} + +const simpleSearch = function* (strs, query, options = {}) { + const { locales = 'en', sensitivity } = options + const matchCase = sensitivity === 'variant' + const haystack = strs.join('') + const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales) + const needle = matchCase ? query : query.toLocaleLowerCase(locales) + const needleLength = needle.length + let index = -1 + let strIndex = -1 + let sum = 0 + do { + index = lowerHaystack.indexOf(needle, index + 1) + if (index > -1) { + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + needleLength + while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + } while (index > -1) +} + +const segmenterSearch = function* (strs, query, options = {}) { + const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options + let segmenter, collator + try { + segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity }) + collator = new Intl.Collator(locales, { sensitivity }) + } catch (e) { + console.warn(e) + segmenter = new Intl.Segmenter('en', { usage: 'search', granularity }) + collator = new Intl.Collator('en', { sensitivity }) + } + const queryLength = Array.from(segmenter.segment(query)).length + + const substrArr = [] + let strIndex = 0 + let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + main: while (strIndex < strs.length) { + while (substrArr.length < queryLength) { + const { done, value } = segments.next() + if (done) { + // the current string is exhausted + // move on to the next string + strIndex++ + if (strIndex < strs.length) { + segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + continue + } else break main + } + const { index, segment } = value + // ignore formatting characters + if (!/[^\p{Format}]/u.test(segment)) continue + // normalize whitespace + if (/\s/u.test(segment)) { + if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment)) + substrArr.push({ strIndex, index, segment: ' ' }) + continue + } + value.strIndex = strIndex + substrArr.push(value) + } + const substr = substrArr.map(x => x.segment).join('') + if (collator.compare(query, substr) === 0) { + const endIndex = strIndex + const lastSeg = substrArr[substrArr.length - 1] + const endOffset = lastSeg.index + lastSeg.segment.length + const startIndex = substrArr[0].strIndex + const startOffset = substrArr[0].index + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + substrArr.shift() + } +} + +// Calibre-parity regex mode (#4560). Runs a JS RegExp over the joined text and +// maps each match back to a node range. Note: RegExp.exec is synchronous, so a +// catastrophic pattern can still stall this pass — true interruption (a Web +// Worker) is left to a follow-up; here we only cap match count and reject +// invalid patterns. The caller surfaces INVALID_REGEX as a calm inline error. +const MAX_REGEX_MATCHES = 10000 +const regexSearch = function* (strs, query, options = {}) { + const { matchCase } = options + const flags = matchCase ? 'g' : 'gi' + let re + try { + re = new RegExp(query, flags + 'u') + } catch { + // Some patterns are valid only without the unicode flag; fall back. + try { + re = new RegExp(query, flags) + } catch (e) { + const err = new Error(`Invalid regular expression: ${e.message}`) + err.code = 'INVALID_REGEX' + throw err + } + } + const haystack = strs.join('') + const cum = buildCum(strs) + let count = 0 + let m + while ((m = re.exec(haystack)) !== null) { + if (m[0].length === 0) { + // zero-width match: advance to avoid an infinite loop + re.lastIndex = m.index + 1 + continue + } + const range = rangeFromFlat(cum, m.index, m.index + m[0].length) + yield { range, excerpt: makeExcerpt(strs, range) } + if (++count >= MAX_REGEX_MATCHES) break + } +} + +// Segmented excerpt for nearby-words: emphasizes only the matched words inside +// the cluster window, leaving the gap text un-emphasized. `pre`/`match`/`post` +// stay populated for consumers that don't render segments. +const makeNearbyExcerpt = (haystack, matched) => { + const clusterStart = matched[0].start + const clusterEnd = matched[matched.length - 1].end + const trimmedStart = normalizeWhitespace(haystack.slice(0, clusterStart)).trimStart() + const trimmedEnd = normalizeWhitespace(haystack.slice(clusterEnd)).trimEnd() + const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…' + const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…' + const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}` + const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}` + const segments = [] + let cursor = clusterStart + for (const o of matched) { + if (o.start > cursor) { + const gap = normalizeWhitespace(haystack.slice(cursor, o.start)) + if (gap) segments.push({ text: gap, emphasized: false }) + } + segments.push({ text: normalizeWhitespace(haystack.slice(o.start, o.end)), emphasized: true }) + cursor = o.end + } + const match = normalizeWhitespace(haystack.slice(clusterStart, clusterEnd)) + return { pre, match, post, segments } +} + +// Calibre-parity nearby-words mode (#4560): matches places where all of the +// query's distinct whole words occur within `nearbyWords` words of each other. +// Distance is measured in words (not characters) and comes from the option, not +// from the query string, so trailing numbers stay literal search words. +const nearbyWordsSearch = function* (strs, query, options = {}) { + const { locales = 'en', sensitivity = 'base', nearbyWords = 10 } = options + const queryWords = [] + for (const w of query.split(/\s+/).filter(Boolean)) if (!queryWords.includes(w)) queryWords.push(w) + if (queryWords.length < 2) { + const err = new Error('Nearby words search needs at least two words') + err.code = 'NEARBY_NEEDS_TWO_WORDS' + throw err + } + let segmenter, collator + try { + segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity: 'word' }) + collator = new Intl.Collator(locales, { sensitivity }) + } catch (e) { + console.warn(e) + segmenter = new Intl.Segmenter('en', { usage: 'search', granularity: 'word' }) + collator = new Intl.Collator('en', { sensitivity }) + } + const haystack = strs.join('') + const cum = buildCum(strs) + const K = queryWords.length + + // Word occurrences of any query word, tagged with a global word index. + const occ = [] + let wordIndex = -1 + for (const seg of segmenter.segment(haystack)) { + if (!seg.isWordLike) continue + wordIndex++ + for (let q = 0; q < K; q++) { + if (collator.compare(queryWords[q], seg.segment) === 0) { + occ.push({ wordIndex, qIdx: q, start: seg.index, end: seg.index + seg.segment.length }) + break + } + } + } + + // Smallest window covering all K distinct query words, two-pointer scan. + const have = new Array(K).fill(0) + let distinct = 0 + let lo = 0 + const windows = [] + for (let hi = 0; hi < occ.length; hi++) { + if (have[occ[hi].qIdx]++ === 0) distinct++ + let minimal = null + while (distinct === K) { + minimal = { lo, hi } + if (--have[occ[lo].qIdx] === 0) distinct-- + lo++ + } + if (minimal && occ[minimal.hi].wordIndex - occ[minimal.lo].wordIndex <= nearbyWords) + windows.push(minimal) + } + + // One cluster per window; suppress windows overlapping an already-emitted one. + let lastHi = -1 + for (const w of windows) { + if (w.lo <= lastHi) continue + lastHi = w.hi + const matched = occ.slice(w.lo, w.hi + 1) + const excerpt = makeNearbyExcerpt(haystack, matched) + const range = rangeFromFlat(cum, matched[0].start, matched[matched.length - 1].end) + const subRanges = matched.map(o => rangeFromFlat(cum, o.start, o.end)) + yield { range, excerpt, subRanges } + } +} + +export const search = (strs, query, options) => { + const { mode } = options + if (mode === 'regex') return regexSearch(strs, query, options) + if (mode === 'nearby-words') return nearbyWordsSearch(strs, query, options) + const { granularity = 'grapheme', sensitivity = 'base' } = options + if (!Intl?.Segmenter || granularity === 'grapheme' + && (sensitivity === 'variant' || sensitivity === 'accent')) + return simpleSearch(strs, query, options) + return segmenterSearch(strs, query, options) +} + +export const searchMatcher = (textWalker, opts) => { + const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, mode, nearbyWords, acceptNode } = opts + const effectiveMode = mode ?? (matchWholeWords ? 'whole-words' : 'contains') + return function* (doc, query) { + const iter = textWalker(doc, function* (strs, makeRange) { + for (const result of search(strs, query, { + mode: effectiveMode, + nearbyWords, + matchCase, + locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en', + granularity: effectiveMode === 'whole-words' ? 'word' : 'grapheme', + sensitivity: matchDiacritics && matchCase ? 'variant' + : matchDiacritics && !matchCase ? 'accent' + : !matchDiacritics && matchCase ? 'case' + : 'base', + })) { + const { startIndex, startOffset, endIndex, endOffset } = result.range + result.range = makeRange(startIndex, startOffset, endIndex, endOffset) + if (result.subRanges) result.subRanges = result.subRanges.map( + r => makeRange(r.startIndex, r.startOffset, r.endIndex, r.endOffset)) + yield result + } + }, acceptNode) + for (const result of iter) yield result + } +} diff --git a/frontend/src/lib/vendor/foliate-js/text-walker.js b/frontend/src/lib/vendor/foliate-js/text-walker.js new file mode 100644 index 0000000..c1249b7 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/text-walker.js @@ -0,0 +1,43 @@ +const walkRange = (range, walker) => { + const nodes = [] + for (let node = walker.currentNode; node; node = walker.nextNode()) { + const compare = range.comparePoint(node, 0) + if (compare === 0) nodes.push(node) + else if (compare > 0) break + } + return nodes +} + +const walkDocument = (_, walker) => { + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + return nodes +} + +const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + | NodeFilter.SHOW_CDATA_SECTION + +const acceptNode = node => { + if (node.nodeType === 1) { + const name = node.tagName.toLowerCase() + if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT +} + +export const textWalker = function* (x, func, filterFunc) { + const root = x.commonAncestorContainer ?? x.body ?? x + const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode }) + const walk = x.commonAncestorContainer ? walkRange : walkDocument + const nodes = walk(x, walker) + const strs = nodes.map(node => node.nodeValue ?? '') + const makeRange = (startIndex, startOffset, endIndex, endOffset) => { + const range = document.createRange() + range.setStart(nodes[startIndex], startOffset) + range.setEnd(nodes[endIndex], endOffset) + return range + } + for (const match of func(strs, makeRange)) yield match +} diff --git a/frontend/src/lib/vendor/foliate-js/tts.js b/frontend/src/lib/vendor/foliate-js/tts.js new file mode 100644 index 0000000..05c9b5d --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/tts.js @@ -0,0 +1,502 @@ +const NS = { + XML: 'http://www.w3.org/XML/1998/namespace', + SSML: 'http://www.w3.org/2001/10/synthesis', +} + +const blockTags = new Set([ + 'article', 'aside', 'audio', 'blockquote', 'caption', + 'details', 'dialog', 'div', 'dl', 'dt', 'dd', + 'figure', 'footer', 'form', 'figcaption', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', + 'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr', +]) + +const getLang = el => { + const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getLang(el.parentElement) : null +} + +const getAlphabet = el => { + const x = el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null +} + +const getSegmenter = (lang, granularity = 'word') => { + const segmenter = new Intl.Segmenter(lang || undefined, { granularity }) + const granularityIsWord = granularity === 'word' + return function* (strs, makeRange) { + const str = strs.join('').replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ') + let name = 0 + let strIndex = -1 + let sum = 0 + const rawSegments = Array.from(segmenter.segment(str)) + const mergedSegments = [] + for (let i = 0, j = 0; i < rawSegments.length; i++) { + const current = rawSegments[i] + const segment = ' ' + current.segment + const endsWithAbbr = /\s([A-Z]{1,2}[a-z]{0,5}|[a-z]{1,3})\.\s*$/.test(segment) + if (!endsWithAbbr || i >= (rawSegments.length-1)) { + const mergedSegment = { + index: rawSegments[j].index, + segment: '', + isWordLike: (i == j) ? current.isWordLike : true, + } + while (j <= i) { + mergedSegment.segment += rawSegments[j++].segment + } + mergedSegments.push(mergedSegment) + } + } + + for (const { index, segment, isWordLike } of mergedSegments) { + if (granularityIsWord && !isWordLike) continue + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + segment.length - 1 + if (end < str.length) while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + 1 + yield [(name++).toString(), + makeRange(startIndex, startOffset, endIndex, endOffset)] + } + } +} + +const fragmentToSSML = (fragment, nodeFilter, inherited) => { + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + const { lang } = inherited + if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang) + + const convert = (node, parent, inheritedAlphabet) => { + if (!node) return + // Text nodes go through the filter too: the text walker that produces + // the marks already honours it, and content that is skipped there (a + // bare ruby base inside , say) must not reach the speech either, + // or the two disagree about what is being read. + if (node.nodeType === 3 || node.nodeType === 4) { + if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return + return node.nodeType === 3 + ? ssml.createTextNode(node.textContent) + : ssml.createCDATASection(node.textContent) + } + if (node.nodeType !== 1 && node.nodeType !== 11) return + if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return + + let el + const nodeName = node.nodeName.toLowerCase() + if (nodeName === 'foliate-mark') { + el = ssml.createElementNS(NS.SSML, 'mark') + el.setAttribute('name', node.dataset.name) + } + else if (nodeName === 'br') + el = ssml.createElementNS(NS.SSML, 'break') + else if (nodeName === 'em' || nodeName === 'strong') + el = ssml.createElementNS(NS.SSML, 'emphasis') + + const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang') + if (lang) { + if (!el) el = ssml.createElementNS(NS.SSML, 'lang') + el.setAttributeNS(NS.XML, 'lang', lang) + } + + const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet + if (!el) { + const ph = node.getAttributeNS?.(NS.SSML, 'ph') + if (ph) { + el = ssml.createElementNS(NS.SSML, 'phoneme') + if (alphabet) el.setAttribute('alphabet', alphabet) + el.setAttribute('ph', ph) + } + } + + if (!el) el = parent + + let child = node.firstChild + while (child) { + const childEl = convert(child, el, alphabet) + if (childEl && el !== childEl) el.append(childEl) + child = child.nextSibling + } + return el + } + convert(fragment, ssml.documentElement, inherited.alphabet) + return ssml +} + +const getFragmentWithMarks = (range, textWalker, nodeFilter, granularity) => { + const lang = getLang(range.commonAncestorContainer) + const alphabet = getAlphabet(range.commonAncestorContainer) + + const segmenter = getSegmenter(lang, granularity) + const fragment = range.cloneContents() + + // we need ranges on both the original document (for highlighting) + // and the document fragment (for inserting marks) + // so unfortunately need to do it twice, as you can't copy the ranges + const entries = [...textWalker(range, segmenter, nodeFilter)] + const fragmentEntries = [...textWalker(fragment, segmenter, nodeFilter)] + + for (const [name, range] of fragmentEntries) { + const mark = document.createElement('foliate-mark') + mark.dataset.name = name + range.insertNode(mark) + } + const ssml = fragmentToSSML(fragment, nodeFilter, { lang, alphabet }) + return { entries, ssml } +} + +const rangeIsEmpty = range => !range.toString().trim() + +// For PDF text layers, split content into sentence-level blocks so TTS +// reads one sentence at a time instead of the whole page in one block. +// Text nodes are split at sentence boundaries so that every block range +// aligns with node edges — this prevents the text walker from including +// text outside the sentence in word marks. +function* getPDFSentenceBlocks(doc, textLayer) { + const collectNodes = () => { + const w = doc.createTreeWalker(textLayer, NodeFilter.SHOW_TEXT) + const res = [] + for (let n = w.nextNode(); n; n = w.nextNode()) res.push(n) + return res + } + + let nodes = collectNodes() + if (!nodes.length) return + + const fullText = nodes.map(n => n.nodeValue).join('') + if (!fullText.trim()) return + + // Find sentence boundary positions + const lang = getLang(textLayer) || undefined + const segmenter = new Intl.Segmenter(lang, { granularity: 'sentence' }) + const boundaries = new Set() + for (const { index } of segmenter.segment(fullText)) + if (index > 0) boundaries.add(index) + + // Split text nodes at sentence boundaries so ranges align with node edges. + // Process in reverse order to preserve earlier character positions. + let cum = 0 + const nodeStarts = nodes.map(n => { const s = cum; cum += n.nodeValue.length; return s }) + + for (const pos of [...boundaries].sort((a, b) => b - a)) { + for (let i = 0; i < nodes.length; i++) { + const start = nodeStarts[i] + const end = start + nodes[i].nodeValue.length + if (pos > start && pos < end) { + nodes[i].splitText(pos - start) + break + } + } + } + + // Re-collect nodes after splits and group into sentence blocks + nodes = collectNodes() + cum = 0 + let groupStart = 0 + let blockCount = 0 + + for (let i = 0; i < nodes.length; i++) { + cum += nodes[i].nodeValue.length + const isEnd = i === nodes.length - 1 || boundaries.has(cum) + if (isEnd) { + const range = doc.createRange() + range.setStart(nodes[groupStart], 0) + range.setEnd(nodes[i], nodes[i].nodeValue.length) + if (!rangeIsEmpty(range)) { + blockCount++ + yield range + } + groupStart = i + 1 + } + } +} + +function* getBlocks(doc, nodeFilter) { + const root = doc.body + ?? doc.querySelector('body') + ?? doc.documentElement + + // For PDF text layers, yield sentence-level blocks + const textLayer = root.querySelector?.('.textLayer') + if (textLayer) { + yield* getPDFSentenceBlocks(doc, textLayer) + return + } + + let last + let sawBlock = false + let sawSkipped = false + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT) + let node = walker.nextNode() + while (node) { + const name = node.tagName.toLowerCase() + // A rejected block element (e.g. a footnote/endnote aside) must not be + // read: skip its whole subtree and end the preceding block before it + // so its text doesn't leak into the adjacent block. Inline rejects are + // left to the text walker in getFragmentWithMarks(). + if (blockTags.has(name) + && nodeFilter?.(node) === NodeFilter.FILTER_REJECT) { + sawSkipped = true + if (last) { + last.setEndBefore(node) + if (!rangeIsEmpty(last)) yield last + last = null + } + const skipped = node + do node = walker.nextNode() + while (node && (skipped.compareDocumentPosition(node) + & Node.DOCUMENT_POSITION_CONTAINED_BY)) + continue + } + if (blockTags.has(name)) { + if (last) { + last.setEndBefore(node) + if (!rangeIsEmpty(last)) yield last + } + last = doc.createRange() + last.setStart(node, 0) + sawBlock = true + } + node = walker.nextNode() + } + if (last) { + last.setEndAfter(root.lastChild ?? root) + if (!rangeIsEmpty(last)) yield last + } else if (!sawBlock && !sawSkipped) { + last = doc.createRange() + last.setStart(root.firstChild ?? root, 0) + last.setEndAfter(root.lastChild ?? root) + if (!rangeIsEmpty(last)) yield last + } +} + +// Enumerate every TTS segment of the document in order without touching any +// TTS instance state. blockIndex/markName match what a TTS instance produces +// for the same granularity, so callers (e.g. a playback timeline) can +// correlate the enumeration with live marks and use each range with from(). +export function* getSentences(doc, textWalker, nodeFilter, granularity = 'sentence') { + let blockIndex = 0 + for (const range of getBlocks(doc, nodeFilter)) { + const lang = getLang(range.commonAncestorContainer) + const segmenter = getSegmenter(lang, granularity) + for (const [name, segRange] of textWalker(range, segmenter, nodeFilter)) + yield { blockIndex, markName: name, range: segRange } + blockIndex++ + } +} + +class ListIterator { + #arr = [] + #iter + #index = -1 + #f + constructor(iter, f = x => x) { + this.#iter = iter + this.#f = f + } + current() { + if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index]) + } + first() { + const newIndex = 0 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + prev() { + const newIndex = this.#index - 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + next() { + const newIndex = this.#index + 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + } + find(f) { + const index = this.#arr.findIndex(x => f(x)) + if (index > -1) { + this.#index = index + return this.#f(this.#arr[index]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (f(value)) { + this.#index = this.#arr.length - 1 + return this.#f(value) + } + } + } +} + +export class TTS { + #list + #ranges + #lastMark + #serializer = new XMLSerializer() + constructor(doc, textWalker, nodeFilter, highlight, granularity) { + this.doc = doc + this.highlight = highlight + this.#list = new ListIterator(getBlocks(doc, nodeFilter), range => { + const { entries, ssml } = getFragmentWithMarks(range, textWalker, nodeFilter, granularity) + this.#ranges = new Map(entries) + return [ssml, range] + }) + } + #getMarkElement(doc, mark) { + if (!mark) return null + return doc.querySelector(`mark[name="${CSS.escape(mark)}"`) + } + #speak(doc, getNode) { + if (!doc) return + if (!getNode) return this.#serializer.serializeToString(doc) + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true)) + let node = getNode(ssml)?.previousSibling + while (node) { + const next = node.previousSibling ?? node.parentNode?.previousSibling + node.parentNode.removeChild(node) + node = next + } + const ssmlStr = this.#serializer.serializeToString(ssml) + return ssmlStr + } + start() { + this.#lastMark = null + const [doc] = this.#list.first() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + resume() { + const [doc] = this.#list.current() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + prev(paused) { + this.#lastMark = null + const [doc, range] = this.#list.prev() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + next(paused) { + this.#lastMark = null + const [doc, range] = this.#list.next() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + prevMark(paused) { + const marks = Array.from(this.#ranges.keys()) + if (marks.length === 0) return + + const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1 + if (currentIndex > 0) { + const prevMarkName = marks[currentIndex - 1] + const range = this.#ranges.get(prevMarkName) + if (range) { + this.#lastMark = prevMarkName + if (paused) this.highlight(range.cloneRange()) + + const [doc] = this.#list.current() ?? [] + return this.#speak(doc, ssml => this.#getMarkElement(ssml, prevMarkName)) + } + } else { + const [doc, range] = this.#list.prev() ?? [] + if (doc && range) { + const prevMarks = Array.from(this.#ranges.keys()) + if (prevMarks.length > 0) { + const lastMarkName = prevMarks[prevMarks.length - 1] + const lastMarkRange = this.#ranges.get(lastMarkName) + if (lastMarkRange) { + this.#lastMark = lastMarkName + if (paused) this.highlight(lastMarkRange.cloneRange()) + return this.#speak(doc, ssml => this.#getMarkElement(ssml, lastMarkName)) + } + } else { + this.#lastMark = null + if (paused) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + } + } + } + nextMark(paused) { + const marks = Array.from(this.#ranges.keys()) + if (marks.length === 0) return + + const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1 + if (currentIndex >= 0 && currentIndex < marks.length - 1) { + const nextMarkName = marks[currentIndex + 1] + const range = this.#ranges.get(nextMarkName) + if (range) { + this.#lastMark = nextMarkName + if (paused) this.highlight(range.cloneRange()) + const [doc] = this.#list.current() ?? [] + return this.#speak(doc, ssml => this.#getMarkElement(ssml, nextMarkName)) + } + } else { + const [doc, range] = this.#list.next() ?? [] + if (doc && range) { + const nextMarks = Array.from(this.#ranges.keys()) + if (nextMarks.length > 0) { + const firstMarkName = nextMarks[0] + const firstMarkRange = this.#ranges.get(firstMarkName) + if (firstMarkRange) { + this.#lastMark = firstMarkName + if (paused) this.highlight(firstMarkRange.cloneRange()) + return this.#speak(doc, ssml => this.#getMarkElement(ssml, firstMarkName)) + } + } else { + this.#lastMark = null + if (paused) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + } + } + } + from(range) { + this.#lastMark = null + const [doc] = this.#list.find(range_ => + range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0) + // Pick the mark whose sentence contains the selection: the last mark + // that begins at or before it. Taking the first mark beginning at or + // after the selection skipped to the next sentence whenever the + // selected word was not its sentence's first word. + let mark + for (const [name, range_] of this.#ranges.entries()) { + if (range.compareBoundaryPoints(Range.START_TO_START, range_) < 0) break + mark = name + } + return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark)) + } + getLastRange() { + if (this.#lastMark) { + const range = this.#ranges.get(this.#lastMark) + if (range) return range.cloneRange() + } + } + setMark(mark) { + const range = this.#ranges.get(mark) + if (range) { + this.#lastMark = mark + this.highlight(range.cloneRange()) + return range + } + } +} diff --git a/frontend/src/lib/vendor/foliate-js/vendor/fflate.js b/frontend/src/lib/vendor/foliate-js/vendor/fflate.js new file mode 100644 index 0000000..f275976 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/vendor/fflate.js @@ -0,0 +1 @@ +var r=Uint8Array,a=Uint16Array,e=Int32Array,n=new r([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new r([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),t=new r([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,n){for(var i=new a(31),t=0;t<31;++t)i[t]=n+=1<>1|(21845&d)<<1;w=(61680&(w=(52428&w)>>2|(13107&w)<<2))>>4|(3855&w)<<4,c[d]=((65280&w)>>8|(255&w)<<8)>>1}var b=function(r,e,n){for(var i=r.length,t=0,f=new a(e);t>l]=u}else for(o=new a(i),t=0;t>15-r[t]);return o},s=new r(288);for(d=0;d<144;++d)s[d]=8;for(d=144;d<256;++d)s[d]=9;for(d=256;d<280;++d)s[d]=7;for(d=280;d<288;++d)s[d]=8;var h=new r(32);for(d=0;d<32;++d)h[d]=5;var y=b(s,9,1),g=b(h,5,1),p=function(r){for(var a=r[0],e=1;ea&&(a=r[e]);return a},k=function(r,a,e){var n=a/8|0;return(r[n]|r[n+1]<<8)>>(7&a)&e},m=function(r,a){var e=a/8|0;return(r[e]|r[e+1]<<8|r[e+2]<<16)>>(7&a)},x=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,a,e){var n=new Error(a||x[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,T),!e)throw n;return n},E=function(a,e,f,o){var l=a.length,c=o?o.length:0;if(!l||e.f&&!e.l)return f||new r(0);var d=!f,w=d||2!=e.i,s=e.i;d&&(f=new r(3*l));var h=function(a){var e=f.length;if(a>e){var n=new r(Math.max(2*e,a));n.set(f),f=n}},x=e.f||0,E=e.p||0,z=e.b||0,A=e.l,U=e.d,D=e.m,F=e.n,M=8*l;do{if(!A){x=k(a,E,1);var S=k(a,E+1,3);if(E+=3,!S){var I=a[(N=4+((E+7)/8|0))-4]|a[N-3]<<8,O=N+I;if(O>l){s&&T(0);break}w&&h(z+I),f.set(a.subarray(N,O),z),e.b=z+=I,e.p=E=8*O,e.f=x;continue}if(1==S)A=y,U=g,D=9,F=5;else if(2==S){var j=k(a,E,31)+257,q=k(a,E+10,15)+4,B=j+k(a,E+5,31)+1;E+=14;for(var C=new r(B),G=new r(19),H=0;H>4)<16)C[H++]=N;else{var Q=0,R=0;for(16==N?(R=3+k(a,E,3),E+=2,Q=C[H-1]):17==N?(R=3+k(a,E,7),E+=3):18==N&&(R=11+k(a,E,127),E+=7);R--;)C[H++]=Q}}var V=C.subarray(0,j),W=C.subarray(j);D=p(V),F=p(W),A=b(V,D,1),U=b(W,F,1)}else T(1);if(E>M){s&&T(0);break}}w&&h(z+131072);for(var X=(1<>4;if((E+=15&Q)>M){s&&T(0);break}if(Q||T(2),$<256)f[z++]=$;else{if(256==$){Z=E,A=null;break}var _=$-254;if($>264){var rr=n[H=$-257];_=k(a,E,(1<>4;ar||T(3),E+=15&ar;W=u[er];if(er>3){rr=i[er];W+=m(a,E)&(1<M){s&&T(0);break}w&&h(z+131072);var nr=z+_;if(za.length)&&(n=a.length),new r(a.subarray(e,n))}(f,0,z):f.subarray(0,z)},z=new r(0);function A(r,a){return E(r.subarray((e=r,n=a&&a.dictionary,(8!=(15&e[0])||e[0]>>4>7||(e[0]<<8|e[1])%31)&&T(6,"invalid zlib data"),(e[1]>>5&1)==+!n&&T(6,"invalid zlib data: "+(32&e[1]?"need":"unexpected")+" dictionary"),2+(e[1]>>3&4)),-4),{i:2},a&&a.out,a&&a.dictionary);var e,n}var U="undefined"!=typeof TextDecoder&&new TextDecoder;try{U.decode(z,{stream:!0})}catch(r){}export{A as unzlibSync}; diff --git a/frontend/src/lib/vendor/foliate-js/vendor/zip.js b/frontend/src/lib/vendor/foliate-js/vendor/zip.js new file mode 100644 index 0000000..a65fd86 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/vendor/zip.js @@ -0,0 +1 @@ +const e=-2,t=-3,n=-5,i=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],r=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],a=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],s=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],o=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],l=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577],c=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],u=15;function d(){let e,i,r,a,d,f;function h(e,i,s,o,l,c,h,w,_,b,p){let m,g,y,x,k,v,S,z,A,U,D,E,T,F,O;U=0,k=s;do{r[e[i+U]]++,U++,k--}while(0!==k);if(r[0]==s)return h[0]=-1,w[0]=0,0;for(z=w[0],v=1;v<=u&&0===r[v];v++);for(S=v,zk&&(z=k),w[0]=z,F=1<E+z;){if(x++,E+=z,O=y-E,O=O>z?z:O,(g=1<<(v=S-E))>m+1&&(g-=m+1,T=S,v1440)return t;d[x]=D=b[0],b[0]+=O,0!==x?(f[x]=k,a[0]=v,a[1]=z,v=k>>>E-z,a[2]=D-d[x-1]-v,_.set(a,3*(d[x-1]+v))):h[0]=D}for(a[1]=S-E,U>=s?a[0]=192:p[U]>>E;v>>=1)k^=v;for(k^=v,A=(1<257?(g==t?m.msg="oversubscribed distance tree":g==n?(m.msg="incomplete distance tree",g=t):-4!=g&&(m.msg="empty distance tree with lengths",g=t),g):0)}}d.inflate_trees_fixed=function(e,t,n,i){return e[0]=9,t[0]=5,n[0]=r,i[0]=a,0};function f(){const n=this;let r,a,s,o,l=0,c=0,u=0,d=0,f=0,h=0,w=0,_=0,b=0,p=0;function m(e,n,r,a,s,o,l,c){let u,d,f,h,w,_,b,p,m,g,y,x,k,v,S,z;b=c.next_in_index,p=c.avail_in,w=l.bitb,_=l.bitk,m=l.write,g=m>=d[z+1],_-=d[z+1],16&h){for(h&=15,k=d[z+2]+(w&i[h]),w>>=h,_-=h;_<15;)p--,w|=(255&c.read_byte(b++))<<_,_+=8;for(u=w&x,d=s,f=o,z=3*(f+u),h=d[z];;){if(w>>=d[z+1],_-=d[z+1],16&h){for(h&=15;_>=h,_-=h,g-=k,m>=v)S=m-v,m-S>0&&2>m-S?(l.win[m++]=l.win[S++],l.win[m++]=l.win[S++],k-=2):(l.win.set(l.win.subarray(S,S+2),m),m+=2,S+=2,k-=2);else{S=m-v;do{S+=l.end}while(S<0);if(h=l.end-S,k>h){if(k-=h,m-S>0&&h>m-S)do{l.win[m++]=l.win[S++]}while(0!=--h);else l.win.set(l.win.subarray(S,S+h),m),m+=h,S+=h,h=0;S=0}}if(m-S>0&&k>m-S)do{l.win[m++]=l.win[S++]}while(0!=--k);else l.win.set(l.win.subarray(S,S+k),m),m+=k,S+=k,k=0;break}if(64&h)return c.msg="invalid distance code",k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,t;u+=d[z+2],u+=w&i[h],z=3*(f+u),h=d[z]}break}if(64&h)return 32&h?(k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,1):(c.msg="invalid literal/length code",k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,t);if(u+=d[z+2],u+=w&i[h],z=3*(f+u),0===(h=d[z])){w>>=d[z+1],_-=d[z+1],l.win[m++]=d[z+2],g--;break}}else w>>=d[z+1],_-=d[z+1],l.win[m++]=d[z+2],g--}while(g>=258&&p>=10);return k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,0}n.init=function(e,t,n,i,l,c){r=0,w=e,_=t,s=n,b=i,o=l,p=c,a=null},n.proc=function(n,g,y){let x,k,v,S,z,A,U,D=0,E=0,T=0;for(T=g.next_in_index,S=g.avail_in,D=n.bitb,E=n.bitk,z=n.write,A=z=258&&S>=10&&(n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,y=m(w,_,s,b,o,p,n,g),T=g.next_in_index,S=g.avail_in,D=n.bitb,E=n.bitk,z=n.write,A=z>>=a[k+1],E-=a[k+1],v=a[k],0===v){d=a[k+2],r=6;break}if(16&v){f=15&v,l=a[k+2],r=2;break}if(!(64&v)){u=v,c=k/3+a[k+2];break}if(32&v){r=7;break}return r=9,g.msg="invalid literal/length code",y=t,n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,n.inflate_flush(g,y);case 2:for(x=f;E>=x,E-=x,u=_,a=o,c=p,r=3;case 3:for(x=u;E>=a[k+1],E-=a[k+1],v=a[k],16&v){f=15&v,h=a[k+2],r=4;break}if(!(64&v)){u=v,c=k/3+a[k+2];break}return r=9,g.msg="invalid distance code",y=t,n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,n.inflate_flush(g,y);case 4:for(x=f;E>=x,E-=x,r=5;case 5:for(U=z-h;U<0;)U+=n.end;for(;0!==l;){if(0===A&&(z==n.end&&0!==n.read&&(z=0,A=z7&&(E-=8,S++,T--),n.write=z,y=n.inflate_flush(g,y),z=n.write,A=ze.avail_out&&(i=e.avail_out),0!==i&&t==n&&(t=0),e.avail_out-=i,e.total_out+=i,e.next_out.set(s.win.subarray(a,a+i),r),r+=i,a+=i,a==s.end&&(a=0,s.write==s.end&&(s.write=0),i=s.write-a,i>e.avail_out&&(i=e.avail_out),0!==i&&t==n&&(t=0),e.avail_out-=i,e.total_out+=i,e.next_out.set(s.win.subarray(a,a+i),r),r+=i,a+=i),e.next_out_index=r,s.read=a,t},s.proc=function(n,r){let a,f,x,k,v,S,z,A;for(k=n.next_in_index,v=n.avail_in,f=s.bitb,x=s.bitk,S=s.write,z=S>>1){case 0:f>>>=3,x-=3,a=7&x,f>>>=a,x-=a,l=1;break;case 1:U=[],D=[],E=[[]],T=[[]],d.inflate_trees_fixed(U,D,E,T),p.init(U[0],D[0],E[0],0,T[0],0),f>>>=3,x-=3,l=6;break;case 2:f>>>=3,x-=3,l=3;break;case 3:return f>>>=3,x-=3,l=9,n.msg="invalid block type",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r)}break;case 1:for(;x<32;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<>>16&65535)!=(65535&f))return l=9,n.msg="invalid stored block lengths",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);c=65535&f,f=x=0,l=0!==c?2:0!==m?7:0;break;case 2:if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);if(0===z&&(S==s.end&&0!==s.read&&(S=0,z=Sv&&(a=v),a>z&&(a=z),s.win.set(n.read_buf(k,a),S),k+=a,v-=a,S+=a,z-=a,0!=(c-=a))break;l=0!==m?7:0;break;case 3:for(;x<14;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<29||(a>>5&31)>29)return l=9,n.msg="too many length or distance symbols",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);if(a=258+(31&a)+(a>>5&31),!o||o.length>>=14,x-=14,w=0,l=4;case 4:for(;w<4+(u>>>10);){for(;x<3;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<>>=3,x-=3}for(;w<19;)o[h[w++]]=0;if(_[0]=7,a=y.inflate_trees_bits(o,_,b,g,n),0!=a)return(r=a)==t&&(o=null,l=9),s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);w=0,l=5;case 5:for(;a=u,!(w>=258+(31&a)+(a>>5&31));){let e,c;for(a=_[0];x>>=a,x-=a,o[w++]=c;else{for(A=18==c?7:c-14,e=18==c?11:3;x>>=a,x-=a,e+=f&i[A],f>>>=A,x-=A,A=w,a=u,A+e>258+(31&a)+(a>>5&31)||16==c&&A<1)return o=null,l=9,n.msg="invalid bit length repeat",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);c=16==c?o[A-1]:0;do{o[A++]=c}while(0!=--e);w=A}}if(b[0]=-1,F=[],O=[],C=[],W=[],F[0]=9,O[0]=6,a=u,a=y.inflate_trees_dynamic(257+(31&a),1+(a>>5&31),o,F,O,C,W,g,n),0!=a)return a==t&&(o=null,l=9),r=a,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);p.init(F[0],O[0],g,C[0],g,W[0]),l=6;case 6:if(s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,1!=(r=p.proc(s,n,r)))return s.inflate_flush(n,r);if(r=0,p.free(n),k=n.next_in_index,v=n.avail_in,f=s.bitb,x=s.bitk,S=s.write,z=S15?(i.inflateEnd(t),e):(i.wbits=n,t.istate.blocks=new w(t,1<>4)>o.wbits){o.mode=_,i.msg="invalid win size",o.marker=5;break}o.mode=1;case 1:if(0===i.avail_in)return a;if(a=r,i.avail_in--,i.total_in++,s=255&i.read_byte(i.next_in_index++),((o.method<<8)+s)%31!=0){o.mode=_,i.msg="incorrect header check",o.marker=5;break}if(!(32&s)){o.mode=7;break}o.mode=2;case 2:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need=(255&i.read_byte(i.next_in_index++))<<24&4278190080,o.mode=3;case 3:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need+=(255&i.read_byte(i.next_in_index++))<<16&16711680,o.mode=4;case 4:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need+=(255&i.read_byte(i.next_in_index++))<<8&65280,o.mode=5;case 5:return 0===i.avail_in?a:(a=r,i.avail_in--,i.total_in++,o.need+=255&i.read_byte(i.next_in_index++),o.mode=6,2);case 6:return o.mode=_,i.msg="need dictionary",o.marker=0,e;case 7:if(a=o.blocks.proc(i,a),a==t){o.mode=_,o.marker=0;break}if(0==a&&(a=r),1!=a)return a;a=r,o.blocks.reset(i,o.was),o.mode=12;case 12:return i.avail_in=0,1;case _:return t;default:return e}},i.inflateSetDictionary=function(t,n,i){let r=0,a=i;if(!t||!t.istate||6!=t.istate.mode)return e;const s=t.istate;return a>=1<>>1^3988292384:t>>>=1;C[e]=t}class W{constructor(e){this.crc=e||-1}append(e){let t=0|this.crc;for(let n=0,i=0|e.length;n>>8^C[255&(t^e[n])];this.crc=t}get(){return~this.crc}}class j extends TransformStream{constructor(){let e;const t=new W;super({transform(e,n){t.append(e),n.enqueue(e)},flush(){const n=new Uint8Array(4);new DataView(n.buffer).setUint32(0,t.get()),e.value=n}}),e=this}}const M={concat(e,t){if(0===e.length||0===t.length)return e.concat(t);const n=e[e.length-1],i=M.getPartial(n);return 32===i?e.concat(t):M._shiftRight(t,i,0|n,e.slice(0,e.length-1))},bitLength(e){const t=e.length;if(0===t)return 0;const n=e[t-1];return 32*(t-1)+M.getPartial(n)},clamp(e,t){if(32*e.length0&&t&&(e[n-1]=M.partial(t,e[n-1]&2147483648>>t-1,1)),e},partial:(e,t,n)=>32===e?t:(n?0|t:t<<32-e)+1099511627776*e,getPartial:e=>Math.round(e/1099511627776)||32,_shiftRight(e,t,n,i){for(void 0===i&&(i=[]);t>=32;t-=32)i.push(n),n=0;if(0===t)return i.concat(e);for(let r=0;r>>t),n=e[r]<<32-t;const r=e.length?e[e.length-1]:0,a=M.getPartial(r);return i.push(M.partial(t+a&31,t+a>32?n:i.pop(),1)),i}},L={bytes:{fromBits(e){const t=M.bitLength(e)/8,n=new Uint8Array(t);let i;for(let r=0;r>>24,i<<=8;return n},toBits(e){const t=[];let n,i=0;for(n=0;n9007199254740991)throw new Error("Cannot hash more than 2^53 - 1 bits");const a=new Uint32Array(n);let s=0;for(let e=t.blockSize+i-(t.blockSize+i&t.blockSize-1);e<=r;e+=t.blockSize)t._block(a.subarray(16*s,16*(s+1))),s+=1;return n.splice(0,16*s),t}finalize(){const e=this;let t=e._buffer;const n=e._h;t=M.concat(t,[M.partial(1,1)]);for(let e=t.length+2;15&e;e++)t.push(0);for(t.push(Math.floor(e._length/4294967296)),t.push(0|e._length);t.length;)e._block(t.splice(0,16));return e.reset(),n}_f(e,t,n,i){return e<=19?t&n|~t&i:e<=39?t^n^i:e<=59?t&n|t&i|n&i:e<=79?t^n^i:void 0}_S(e,t){return t<>>32-e}_block(e){const t=this,n=t._h,i=Array(80);for(let t=0;t<16;t++)i[t]=e[t];let r=n[0],a=n[1],s=n[2],o=n[3],l=n[4];for(let e=0;e<=79;e++){e>=16&&(i[e]=t._S(1,i[e-3]^i[e-8]^i[e-14]^i[e-16]));const n=t._S(5,r)+t._f(e,a,s,o)+l+i[e]+t._key[Math.floor(e/20)]|0;l=o,o=s,s=t._S(30,a),a=r,r=n}n[0]=n[0]+r|0,n[1]=n[1]+a|0,n[2]=n[2]+s|0,n[3]=n[3]+o|0,n[4]=n[4]+l|0}}},R={aes:class{constructor(e){const t=this;t._tables=[[[],[],[],[],[]],[[],[],[],[],[]]],t._tables[0][0][0]||t._precompute();const n=t._tables[0][4],i=t._tables[1],r=e.length;let a,s,o,l=1;if(4!==r&&6!==r&&8!==r)throw new Error("invalid aes key size");for(t._key=[s=e.slice(0),o=[]],a=r;a<4*r+28;a++){let e=s[a-1];(a%r==0||8===r&&a%r==4)&&(e=n[e>>>24]<<24^n[e>>16&255]<<16^n[e>>8&255]<<8^n[255&e],a%r==0&&(e=e<<8^e>>>24^l<<24,l=l<<1^283*(l>>7))),s[a]=s[a-r]^e}for(let e=0;a;e++,a--){const t=s[3&e?a:a-4];o[e]=a<=4||e<4?t:i[0][n[t>>>24]]^i[1][n[t>>16&255]]^i[2][n[t>>8&255]]^i[3][n[255&t]]}}encrypt(e){return this._crypt(e,0)}decrypt(e){return this._crypt(e,1)}_precompute(){const e=this._tables[0],t=this._tables[1],n=e[4],i=t[4],r=[],a=[];let s,o,l,c;for(let e=0;e<256;e++)a[(r[e]=e<<1^283*(e>>7))^e]=e;for(let u=s=0;!n[u];u^=o||1,s=a[s]||1){let a=s^s<<1^s<<2^s<<3^s<<4;a=a>>8^255&a^99,n[u]=a,i[a]=u,c=r[l=r[o=r[u]]];let d=16843009*c^65537*l^257*o^16843008*u,f=257*r[a]^16843008*a;for(let n=0;n<4;n++)e[n][u]=f=f<<24^f>>>8,t[n][a]=d=d<<24^d>>>8}for(let n=0;n<5;n++)e[n]=e[n].slice(0),t[n]=t[n].slice(0)}_crypt(e,t){if(4!==e.length)throw new Error("invalid aes block size");const n=this._key[t],i=n.length/4-2,r=[0,0,0,0],a=this._tables[t],s=a[0],o=a[1],l=a[2],c=a[3],u=a[4];let d,f,h,w=e[0]^n[0],_=e[t?3:1]^n[1],b=e[2]^n[2],p=e[t?1:3]^n[3],m=4;for(let e=0;e>>24]^o[_>>16&255]^l[b>>8&255]^c[255&p]^n[m],f=s[_>>>24]^o[b>>16&255]^l[p>>8&255]^c[255&w]^n[m+1],h=s[b>>>24]^o[p>>16&255]^l[w>>8&255]^c[255&_]^n[m+2],p=s[p>>>24]^o[w>>16&255]^l[_>>8&255]^c[255&b]^n[m+3],m+=4,w=d,_=f,b=h;for(let e=0;e<4;e++)r[t?3&-e:e]=u[w>>>24]<<24^u[_>>16&255]<<16^u[b>>8&255]<<8^u[255&p]^n[m++],d=w,w=_,_=b,b=p,p=d;return r}}},B={getRandomValues(e){const t=new Uint32Array(e.buffer),n=e=>{let t=987654321;const n=4294967295;return function(){t=36969*(65535&t)+(t>>16)&n;return(((t<<16)+(e=18e3*(65535&e)+(e>>16)&n)&n)/4294967296+.5)*(Math.random()>.5?1:-1)}};for(let i,r=0;r>24))e+=1<<24;else{let t=e>>16&255,n=e>>8&255,i=255&e;255===t?(t=0,255===n?(n=0,255===i?i=0:++i):++n):++t,e=0,e+=t<<16,e+=n<<8,e+=i}return e}incCounter(e){0===(e[0]=this.incWord(e[0]))&&(e[1]=this.incWord(e[1]))}calculate(e,t,n){let i;if(!(i=t.length))return[];const r=M.bitLength(t);for(let r=0;rnew N.hmacSha1(L.bytes.toBits(e)),pbkdf2(e,t,n,i){if(n=n||1e4,i<0||n<0)throw new Error("invalid params to pbkdf2");const r=1+(i>>5)<<2;let a,s,o,l,c;const u=new ArrayBuffer(r),d=new DataView(u);let f=0;const h=M;for(t=L.bytes.toBits(t),c=1;f<(r||1);c++){for(a=s=e.encrypt(h.concat(t,[c])),o=1;or&&(e=(new n).update(e).finalize());for(let t=0;tthis.resolveReady=e)),password:be(e,t),signed:n,strength:i-1,pending:new Uint8Array})},async transform(e,t){const n=this,{password:i,strength:a,resolveReady:s,ready:o}=n;i?(await async function(e,t,n,i){const r=await _e(e,t,n,me(i,0,$[t])),a=me(i,$[t]);if(r[0]!=a[0]||r[1]!=a[1])throw new Error(q)}(n,a,i,me(e,0,$[a]+2)),e=me(e,$[a]+2),r?t.error(new Error(K)):s()):await o;const l=new Uint8Array(e.length-te-(e.length-te)%G);t.enqueue(we(n,e,l,0,te,!0))},async flush(e){const{signed:t,ctr:n,hmac:i,pending:r,ready:a}=this;if(i&&n){await a;const s=me(r,0,r.length-te),o=me(r,r.length-te);let l=new Uint8Array;if(s.length){const e=ye(se,s);i.update(e);const t=n.update(e);l=ge(se,t)}if(t){const e=me(ge(se,i.digest()),0,te);for(let t=0;tthis.resolveReady=e)),password:be(e,t),strength:n-1,pending:new Uint8Array})},async transform(e,t){const n=this,{password:i,strength:r,resolveReady:a,ready:s}=n;let o=new Uint8Array;i?(o=await async function(e,t,n){const i=Z(new Uint8Array($[t])),r=await _e(e,t,n,i);return pe(i,r)}(n,r,i),a()):await s;const l=new Uint8Array(o.length+e.length-e.length%G);l.set(o,0),t.enqueue(we(n,e,l,o.length,0))},async flush(e){const{ctr:t,hmac:n,pending:r,ready:a}=this;if(n&&t){await a;let s=new Uint8Array;if(r.length){const e=t.update(ye(se,r));n.update(e),s=ge(se,e)}i.signature=ge(se,n.digest()).slice(0,te),e.enqueue(pe(s,i.signature))}}}),i=this}}function we(e,t,n,i,r,a){const{ctr:s,hmac:o,pending:l}=e,c=t.length-r;let u;for(l.length&&(t=pe(l,t),n=function(e,t){if(t&&t>e.length){const n=e;(e=new Uint8Array(t)).set(n,0)}return e}(n,c-c%G)),u=0;u<=c-G;u+=G){const e=ye(se,me(t,u,u+G));a&&o.update(e);const r=s.update(e);a||o.update(r),n.set(ge(se,r),u+i)}return e.pending=me(t,u),n}async function _e(e,t,n,i){e.password=null;const r=await async function(e,t,n,i,r){if(!ue)return N.importKey(t);try{return await re.importKey(e,t,n,i,r)}catch(e){return ue=!1,N.importKey(t)}}("raw",n,Q,!1,Y),a=await async function(e,t,n){if(!de)return N.pbkdf2(t,e.salt,X.iterations,n);try{return await re.deriveBits(e,t,n)}catch(i){return de=!1,N.pbkdf2(t,e.salt,X.iterations,n)}}(Object.assign({salt:i},X),r,8*(2*ee[t]+2)),s=new Uint8Array(a),o=ye(se,me(s,0,ee[t])),l=ye(se,me(s,ee[t],2*ee[t])),c=me(s,2*ee[t]);return Object.assign(e,{keys:{key:o,authentication:l,passwordVerification:c},ctr:new le(new oe(o),Array.from(ne)),hmac:new ce(l)}),c}function be(e,t){return t===S?function(e){if(typeof TextEncoder==z){e=unescape(encodeURIComponent(e));const t=new Uint8Array(e.length);for(let n=0;n>>24]),r=~e.crcKey2.get(),e.keys=[n,i,r]}function De(e){const t=2|e.keys[2];return Ee(Math.imul(t,1^t)>>>8)}function Ee(e){return 255&e}function Te(e){return 4294967295&e}const Fe="deflate-raw";class Oe extends TransformStream{constructor(e,{chunkSize:t,CompressionStream:n,CompressionStreamNative:i}){super({});const{compressed:r,encrypted:a,useCompressionStream:s,zipCrypto:o,signed:l,level:c}=e,u=this;let d,f,h=We(super.readable);a&&!o||!l||(d=new j,h=Le(h,d)),r&&(h=Me(h,s,{level:c,chunkSize:t},i,n)),a&&(o?h=Le(h,new ve(e)):(f=new he(e),h=Le(h,f))),je(u,h,(()=>{let e;a&&!o&&(e=f.signature),a&&!o||!l||(e=new DataView(d.value.buffer).getUint32(0)),u.signature=e}))}}class Ce extends TransformStream{constructor(e,{chunkSize:t,DecompressionStream:n,DecompressionStreamNative:i}){super({});const{zipCrypto:r,encrypted:a,signed:s,signature:o,compressed:l,useCompressionStream:c}=e;let u,d,f=We(super.readable);a&&(r?f=Le(f,new ke(e)):(d=new fe(e),f=Le(f,d))),l&&(f=Me(f,c,{chunkSize:t},i,n)),a&&!r||!s||(u=new j,f=Le(f,u)),je(this,f,(()=>{if((!a||r)&&s){const e=new DataView(u.value.buffer);if(o!=e.getUint32(0,!1))throw new Error(H)}}))}}function We(e){return Le(e,new TransformStream({transform(e,t){e&&e.length&&t.enqueue(e)}}))}function je(e,t,n){t=Le(t,new TransformStream({flush:n})),Object.defineProperty(e,"readable",{get:()=>t})}function Me(e,t,n,i,r){try{e=Le(e,new(t&&i?i:r)(Fe,n))}catch(i){if(!t)return e;try{e=Le(e,new r(Fe,n))}catch(t){return e}}return e}function Le(e,t){return e.pipeThrough(t)}const Pe="message",Re="start",Be="pull",Ie="data",Ne="close",Ve="inflate";class qe extends TransformStream{constructor(e,t){super({});const n=this,{codecType:i}=e;let r;i.startsWith("deflate")?r=Oe:i.startsWith(Ve)&&(r=Ce);let a=0,s=0;const o=new r(e,t),l=super.readable,c=new TransformStream({transform(e,t){e&&e.length&&(s+=e.length,t.enqueue(e))},flush(){Object.assign(n,{inputSize:s})}}),u=new TransformStream({transform(e,t){e&&e.length&&(a+=e.length,t.enqueue(e))},flush(){const{signature:e}=o;Object.assign(n,{signature:e,outputSize:a,inputSize:s})}});Object.defineProperty(n,"readable",{get:()=>l.pipeThrough(c).pipeThrough(o).pipeThrough(u)})}}class He extends TransformStream{constructor(e){let t;super({transform:function n(i,r){if(t){const e=new Uint8Array(t.length+i.length);e.set(t),e.set(i,t.length),i=e,t=null}i.length>e?(r.enqueue(i.slice(0,e)),n(i.slice(e),r)):t=i},flush(e){t&&t.length&&e.enqueue(t)}})}}let Ke=typeof Worker!=z;class Ze{constructor(e,{readable:t,writable:n},{options:i,config:r,streamOptions:a,useWebWorkers:s,transferStreams:o,scripts:l},c){const{signal:u}=a;return Object.assign(e,{busy:!0,readable:t.pipeThrough(new He(r.chunkSize)).pipeThrough(new Ge(t,a),{signal:u}),writable:n,options:Object.assign({},i),scripts:l,transferStreams:o,terminate:()=>new Promise((t=>{const{worker:n,busy:i}=e;n?(i?e.resolveTerminated=t:(n.terminate(),t()),e.interface=null):t()})),onTaskFinished(){const{resolveTerminated:t}=e;t&&(e.resolveTerminated=null,e.terminated=!0,e.worker.terminate(),t()),e.busy=!1,c(e)}}),(s&&Ke?Xe:Qe)(e,r)}}class Ge extends TransformStream{constructor(e,{onstart:t,onprogress:n,size:i,onend:r}){let a=0;super({async start(){t&&await Je(t,i)},async transform(e,t){a+=e.length,n&&await Je(n,a,i),t.enqueue(e)},async flush(){e.size=a,r&&await Je(r,a)}})}}async function Je(e,...t){try{await e(...t)}catch(e){}}function Qe(e,t){return{run:()=>async function({options:e,readable:t,writable:n,onTaskFinished:i},r){try{const i=new qe(e,r);await t.pipeThrough(i).pipeTo(n,{preventClose:!0,preventAbort:!0});const{signature:a,inputSize:s,outputSize:o}=i;return{signature:a,inputSize:s,outputSize:o}}finally{i()}}(e,t)}}function Xe(e,t){const{baseURL:n,chunkSize:i}=t;if(!e.interface){let r;try{r=function(e,t,n){const i={type:"module"};let r,a;typeof e==A&&(e=e());try{r=new URL(e,t)}catch(t){r=e}if(Ye)try{a=new Worker(r)}catch(e){Ye=!1,a=new Worker(r,i)}else a=new Worker(r,i);return a.addEventListener(Pe,(e=>async function({data:e},t){const{type:n,value:i,messageId:r,result:a,error:s}=e,{reader:o,writer:l,resolveResult:c,rejectResult:u,onTaskFinished:d}=t;try{if(s){const{message:e,stack:t,code:n,name:i}=s,r=new Error(e);Object.assign(r,{stack:t,code:n,name:i}),f(r)}else{if(n==Be){const{value:e,done:n}=await o.read();et({type:Ie,value:e,done:n,messageId:r},t)}n==Ie&&(await l.ready,await l.write(new Uint8Array(i)),et({type:"ack",messageId:r},t)),n==Ne&&f(null,a)}}catch(s){et({type:Ne,messageId:r},t),f(s)}function f(e,t){e?u(e):c(t),l&&l.releaseLock(),d()}}(e,n))),a}(e.scripts[0],n,e)}catch(n){return Ke=!1,Qe(e,t)}Object.assign(e,{worker:r,interface:{run:()=>async function(e,t){let n,i;const r=new Promise(((e,t)=>{n=e,i=t}));Object.assign(e,{reader:null,writer:null,resolveResult:n,rejectResult:i,result:r});const{readable:a,options:s,scripts:o}=e,{writable:l,closed:c}=function(e){let t;const n=new Promise((e=>t=e)),i=new WritableStream({async write(t){const n=e.getWriter();await n.ready,await n.write(t),n.releaseLock()},close(){t()},abort:t=>e.getWriter().abort(t)});return{writable:i,closed:n}}(e.writable),u=et({type:Re,scripts:o.slice(1),options:s,config:t,readable:a,writable:l},e);u||Object.assign(e,{reader:a.getReader(),writer:l.getWriter()});const d=await r;u||await l.getWriter().close();return await c,d}(e,{chunkSize:i})}})}return e.interface}let Ye=!0,$e=!0;function et(e,{worker:t,writer:n,onTaskFinished:i,transferStreams:r}){try{let{value:n,readable:i,writable:a}=e;const s=[];if(n&&(n.byteLength!e.busy));if(n)return at(n),new Ze(n,e,t,w);if(tt.lengthnt.push({resolve:n,stream:e,workerOptions:t})))}()).run();function w(e){if(nt.length){const[{resolve:t,stream:n,workerOptions:i}]=nt.splice(0,1);t(new Ze(e,n,i,w))}else e.worker?(at(e),function(e,t){const{config:n}=t,{terminateWorkerTimeout:i}=n;Number.isFinite(i)&&i>=0&&(e.terminated?e.terminated=!1:e.terminateTimeout=setTimeout((async()=>{tt=tt.filter((t=>t!=e));try{await e.terminate()}catch(e){}}),i))}(e,t)):tt=tt.filter((t=>t!=e))}}function at(e){const{terminateTimeout:t}=e;t&&(clearTimeout(t),e.terminateTimeout=null)}const st=65536,ot="writable";class lt{constructor(){this.size=0}init(){this.initialized=!0}}class ct extends lt{get readable(){const e=this,{chunkSize:t=st}=e,n=new ReadableStream({start(){this.chunkOffset=0},async pull(i){const{offset:r=0,size:a,diskNumberStart:s}=n,{chunkOffset:o}=this;i.enqueue(await pt(e,r+o,Math.min(t,a-o),s)),o+t>a?i.close():this.chunkOffset+=t}});return n}}class ut extends ct{constructor(e){super(),Object.assign(this,{blob:e,size:e.size})}async readUint8Array(e,t){const n=this,i=e+t,r=e||it&&(a=a.slice(e,i)),new Uint8Array(a)}}class dt extends lt{constructor(e){super();const t=new TransformStream,n=[];e&&n.push(["Content-Type",e]),Object.defineProperty(this,ot,{get:()=>t.writable}),this.blob=new Response(t.readable,{headers:n}).blob()}getData(){return this.blob}}class ft extends dt{constructor(e){super(e),Object.assign(this,{encoding:e,utf8:!e||"utf-8"==e.toLowerCase()})}async getData(){const{encoding:e,utf8:t}=this,n=await super.getData();if(n.text&&t)return n.text();{const t=new FileReader;return new Promise(((i,r)=>{Object.assign(t,{onload:({target:e})=>i(e.result),onerror:()=>r(t.error)}),t.readAsText(n,e)}))}}}class ht extends ct{constructor(e){super(),this.readers=e}async init(){const e=this,{readers:t}=e;e.lastDiskNumber=0,e.lastDiskOffset=0,await Promise.all(t.map((async(n,i)=>{await n.init(),i!=t.length-1&&(e.lastDiskOffset+=n.size),e.size+=n.size}))),super.init()}async readUint8Array(e,t,n=0){const i=this,{readers:r}=this;let a,s=n;-1==s&&(s=r.length-1);let o=e;for(;o>=r[s].size;)o-=r[s].size,s++;const l=r[s],c=l.size;if(o+t<=c)a=await pt(l,o,t);else{const r=c-o;a=new Uint8Array(t),a.set(await pt(l,o,r)),a.set(await i.readUint8Array(e+r,t-r,n),r)}return i.lastDiskNumber=Math.max(s,i.lastDiskNumber),a}}class wt extends lt{constructor(e,t=4294967295){super();const n=this;let i,r,a;Object.assign(n,{diskNumber:0,diskOffset:0,size:0,maxSize:t,availableSize:t});const s=new WritableStream({async write(t){const{availableSize:s}=n;if(a)t.length>=s?(await o(t.slice(0,s)),await l(),n.diskOffset+=i.size,n.diskNumber++,a=null,await this.write(t.slice(s))):await o(t);else{const{value:s,done:o}=await e.next();if(o&&!s)throw new Error("Writer iterator completed too soon");i=s,i.size=0,i.maxSize&&(n.maxSize=i.maxSize),n.availableSize=n.maxSize,await _t(i),r=s.writable,a=r.getWriter(),await this.write(t)}},async close(){await a.ready,await l()}});async function o(e){const t=e.length;t&&(await a.ready,await a.write(e),i.size+=t,n.size+=t,n.availableSize-=t)}async function l(){r.size=i.size,await a.close()}Object.defineProperty(n,ot,{get:()=>s})}}async function _t(e,t){if(!e.init||e.initialized)return Promise.resolve();await e.init(t)}function bt(e){return Array.isArray(e)&&(e=new ht(e)),e instanceof ReadableStream&&(e={readable:e}),e}function pt(e,t,n,i){return e.readUint8Array(t,n,i)}const mt="\0☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼ !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ".split(""),gt=256==mt.length;function yt(e,t){return t&&"cp437"==t.trim().toLowerCase()?function(e){if(gt){let t="";for(let n=0;nthis[t]=e[t]))}}const Lt="File format is not recognized",Pt="Zip64 extra field not found",Rt="Compression method not supported",Bt="Split zip file",It="utf-8",Nt="cp437",Vt=[[zt,g],[At,g],[Ut,g],[Dt,y]],qt={[y]:{getValue:tn,bytes:4},[g]:{getValue:nn,bytes:8}};class Ht{constructor(e,t={}){Object.assign(this,{reader:bt(e),options:t,config:T})}async*getEntriesGenerator(e={}){const t=this;let{reader:n}=t;const{config:i}=t;if(await _t(n),n.size!==S&&n.readUint8Array||(n=new ut(await new Response(n.readable).blob()),await _t(n)),n.size=0;e--)if(s[e]==a[0]&&s[e+1]==a[1]&&s[e+2]==a[2]&&s[e+3]==a[3])return{offset:r+e,buffer:s.slice(e,e+i).buffer}}}(n,101010256,n.size,v,1048560);if(!r){throw 134695760==tn(rn(await pt(n,0,4)))?new Error(Bt):new Error("End of central directory not found")}const a=rn(r);let s=tn(a,12),o=tn(a,16);const l=r.offset,c=en(a,20),u=l+v+c;let d=en(a,4);const f=n.lastDiskNumber||0;let h=en(a,6),w=en(a,8),_=0,b=0;if(o==g||s==g||w==y||h==y){const e=rn(await pt(n,r.offset-20,20));if(117853008==tn(e,0)){o=nn(e,8);let t=await pt(n,o,56,-1),i=rn(t);const a=r.offset-20-56;if(tn(i,0)!=k&&o!=a){const e=o;o=a,_=o-e,t=await pt(n,o,56,-1),i=rn(t)}if(tn(i,0)!=k)throw new Error("End of Zip64 central directory locator not found");d==y&&(d=tn(i,16)),h==y&&(h=tn(i,20)),w==y&&(w=nn(i,32)),s==g&&(s=nn(i,40)),o-=s}}if(o>=n.size&&(_=n.size-o-s-v,o=n.size-s-v),f!=d)throw new Error(Bt);if(o<0)throw new Error(Lt);let p=0,m=await pt(n,o,s,h),z=rn(m);if(s){const e=r.offset-s;if(tn(z,p)!=x&&o!=e){const t=o;o=e,_+=o-t,m=await pt(n,o,s,h),z=rn(m)}}const A=r.offset-o-(n.lastDiskOffset||0);if(s!=A&&A>=0&&(s=A,m=await pt(n,o,s,h),z=rn(m)),o<0||o>=n.size)throw new Error(Lt);const U=Qt(t,e,"filenameEncoding"),D=Qt(t,e,"commentEncoding");for(let r=0;ra.getData(e,j,t),p=g;const{onprogress:M}=e;if(M)try{await M(r+1,w,new Mt(a))}catch(e){}yield j}const E=Qt(t,e,"extractPrependedData"),T=Qt(t,e,"extractAppendedData");return E&&(t.prependedData=b>0?await pt(n,0,b):new Uint8Array),t.comment=c?await pt(n,l+v,c):new Uint8Array,T&&(t.appendedData=u>>8&255:d>>>24&255),signature:d,compressed:0!=l&&!g,encrypted:i.encrypted&&!g,useWebWorkers:Qt(i,n,"useWebWorkers"),useCompressionStream:Qt(i,n,"useCompressionStream"),transferStreams:Qt(i,n,"transferStreams"),checkPasswordOnly:D},config:c,streamOptions:{signal:U,size:v,onstart:T,onprogress:F,onend:O}};let W=0;try{({outputSize:W}=await rt({readable:z,writable:E},C))}catch(e){if(!D||e.message!=K)throw e}finally{const e=Qt(i,n,"preventClose");E.size+=W,e||E.locked||await E.getWriter().close()}return D?S:e.getData?e.getData():E}}function Zt(e,t,n){const i=e.rawBitFlag=en(t,n+2),r=!(1&~i),a=tn(t,n+6);Object.assign(e,{encrypted:r,version:en(t,n),bitFlag:{level:(6&i)>>1,dataDescriptor:!(8&~i),languageEncodingFlag:!(2048&~i)},rawLastModDate:a,lastModDate:Xt(a),filenameLength:en(t,n+22),extraFieldLength:en(t,n+24)})}async function Gt(e,t,n,i,r){const{rawExtraField:a}=t,s=t.extraField=new Map,o=rn(new Uint8Array(a));let l=0;try{for(;lt[e]==n));for(let r=0,a=0;r=5&&(a.push(Et),s.push(Tt));let o=1;a.forEach(((n,r)=>{if(e.data.length>=o+4){const a=tn(i,o);t[n]=e[n]=new Date(1e3*a);const l=s[r];e[l]=a}o+=4}))}(_,t,r),t.extraFieldExtendedTimestamp=_);const b=s.get(6534);b&&(t.extraFieldUSDZ=b)}async function Jt(e,t,n,i,r){const a=rn(e.data),s=new W;s.append(r[n]);const o=rn(new Uint8Array(4));o.setUint32(0,s.get(),!0);const l=tn(a,1);Object.assign(e,{version:$t(a,0),[t]:yt(e.data.subarray(5)),valid:!r.bitFlag.languageEncodingFlag&&l==tn(o,0)}),e.valid&&(i[t]=e[t],i[t+"UTF8"]=!0)}function Qt(e,t,n){return t[n]===S?e.options[n]:t[n]}function Xt(e){const t=(4294901760&e)>>16,n=65535&e;try{return new Date(1980+((65024&t)>>9),((480&t)>>5)-1,31&t,(63488&n)>>11,(2016&n)>>5,2*(31&n),0)}catch(e){}}function Yt(e){return new Date(Number(e/BigInt(1e4)-BigInt(116444736e5)))}function $t(e,t){return e.getUint8(t)}function en(e,t){return e.getUint16(t,!0)}function tn(e,t){return e.getUint32(t,!0)}function nn(e,t){return Number(e.getBigUint64(t,!0))}function rn(e){return new DataView(e.buffer)}F({Inflate:function(e){const t=new m,i=e&&e.chunkSize?Math.floor(2*e.chunkSize):131072,r=new Uint8Array(i);let a=!1;t.inflateInit(),t.next_out=r,this.append=function(e,s){const o=[];let l,c,u=0,d=0,f=0;if(0!==e.length){t.next_in_index=0,t.next_in=e,t.avail_in=e.length;do{if(t.next_out_index=0,t.avail_out=i,0!==t.avail_in||a||(t.next_in_index=0,a=!0),l=t.inflate(0),a&&l===n){if(0!==t.avail_in)throw new Error("inflating: bad input")}else if(0!==l&&1!==l)throw new Error("inflating: "+t.msg);if((a||1===l)&&t.avail_in===e.length)throw new Error("inflating: bad input");t.next_out_index&&(t.next_out_index===i?o.push(new Uint8Array(r)):o.push(r.subarray(0,t.next_out_index))),f+=t.next_out_index,s&&t.next_in_index>0&&t.next_in_index!=u&&(s(t.next_in_index),u=t.next_in_index)}while(t.avail_in>0||0===t.avail_out);return o.length>1?(c=new Uint8Array(f),o.forEach((function(e){c.set(e,d),d+=e.length}))):c=o[0]?new Uint8Array(o[0]):new Uint8Array,c}},this.flush=function(){t.inflateEnd()}}});export{ut as BlobReader,dt as BlobWriter,ft as TextWriter,Ht as ZipReader,F as configure}; diff --git a/frontend/src/lib/vendor/foliate-js/view.js b/frontend/src/lib/vendor/foliate-js/view.js new file mode 100644 index 0000000..2443fa3 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/view.js @@ -0,0 +1,704 @@ +import * as CFI from './epubcfi.js' +import { TOCProgress, SectionProgress, PageProgress } from './progress.js' +import { Overlayer } from './overlayer.js' +import { textWalker } from './text-walker.js' + +const SEARCH_PREFIX = 'foliate-search:' + +const NOTE_PREFIX = 'foliate-note:' + +const isZip = async file => { + const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) + return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 +} + +const isPDF = async file => { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) + return arr[0] === 0x25 + && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 + && arr[4] === 0x2d +} + +const isCBZ = ({ name, type }) => + type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') + +const isFB2 = ({ name, type }) => + type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') + +const isFBZ = ({ name, type }) => + type === 'application/x-zip-compressed-fb2' + || name.endsWith('.fb2.zip') || name.endsWith('.fbz') + +const makeZipLoader = async file => { + const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = + await import('./vendor/zip.js') + configure({ useWebWorkers: false }) + const reader = new ZipReader(new BlobReader(file)) + const entries = await reader.getEntries() + const map = new Map(entries.map(entry => [entry.filename, entry])) + const load = f => (name, ...args) => + map.has(name) ? f(map.get(name), ...args) : null + const loadText = load(entry => entry.getData(new TextWriter())) + const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) + const getSize = name => map.get(name)?.uncompressedSize ?? 0 + return { entries, loadText, loadBlob, getSize } +} + +const getFileEntries = async entry => entry.isFile ? entry + : (await Promise.all(Array.from( + await new Promise((resolve, reject) => entry.createReader() + .readEntries(entries => resolve(entries), error => reject(error))), + getFileEntries))).flat() + +const makeDirectoryLoader = async entry => { + const entries = await getFileEntries(entry) + const files = await Promise.all( + entries.map(entry => new Promise((resolve, reject) => + entry.file(file => resolve([file, entry.fullPath]), + error => reject(error))))) + const map = new Map(files.map(([file, path]) => + [path.replace(entry.fullPath + '/', ''), file])) + const decoder = new TextDecoder() + const decode = x => x ? decoder.decode(x) : null + const getBuffer = name => map.get(name)?.arrayBuffer() ?? null + const loadText = async name => decode(await getBuffer(name)) + const loadBlob = name => map.get(name) + const getSize = name => map.get(name)?.size ?? 0 + return { loadText, loadBlob, getSize } +} + +export class ResponseError extends Error {} +export class NotFoundError extends Error {} +export class UnsupportedTypeError extends Error {} + +const fetchFile = async url => { + const res = await fetch(url) + if (!res.ok) throw new ResponseError( + `${res.status} ${res.statusText}`, { cause: res }) + return new File([await res.blob()], new URL(res.url).pathname) +} + +export const makeBook = async file => { + if (typeof file === 'string') file = await fetchFile(file) + let book + if (file.isDirectory) { + const loader = await makeDirectoryLoader(file) + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + else if (!file.size) throw new NotFoundError('File not found') + else if (await isZip(file)) { + const loader = await makeZipLoader(file) + if (isCBZ(file)) { + const { makeComicBook } = await import('./comic-book.js') + book = makeComicBook(loader, file) + } + else if (isFBZ(file)) { + const { makeFB2 } = await import('./fb2.js') + const { entries } = loader + const entry = entries.find(entry => entry.filename.endsWith('.fb2')) + const blob = await loader.loadBlob((entry ?? entries[0]).filename) + book = await makeFB2(blob) + } + else { + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + } + else if (await isPDF(file)) { + const { makePDF } = await import('./pdf.js') + book = await makePDF(file) + } + else { + const { isMOBI, MOBI } = await import('./mobi.js') + if (await isMOBI(file)) { + const fflate = await import('./vendor/fflate.js') + book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) + } + else if (isFB2(file)) { + const { makeFB2 } = await import('./fb2.js') + book = await makeFB2(file) + } + } + if (!book) throw new UnsupportedTypeError('File type not supported') + return book +} + +class CursorAutohider { + #timeout + #el + #check + #state + constructor(el, check, state = {}) { + this.#el = el + this.#check = check + this.#state = state + if (this.#state.hidden) this.hide() + this.#el.addEventListener('mousemove', ({ screenX, screenY }) => { + // check if it actually moved + if (screenX === this.#state.x && screenY === this.#state.y) return + this.#state.x = screenX, this.#state.y = screenY + this.show() + if (this.#timeout) clearTimeout(this.#timeout) + if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000) + }, false) + } + cloneFor(el) { + return new CursorAutohider(el, this.#check, this.#state) + } + #hasSelection() { + const selection = this.#el.ownerDocument?.getSelection() + return selection ? !selection.isCollapsed : false + } + hide() { + // The pointer is what the reader aims a selection with, so leave it + // alone while one stands: a paused drag, or a double-click word + // select (which fires no mousemove at all), would otherwise blank it. + if (this.#hasSelection()) return + this.#el.style.cursor = 'none' + this.#state.hidden = true + } + show() { + this.#el.style.removeProperty('cursor') + this.#state.hidden = false + } +} + +class History extends EventTarget { + #arr = [] + #index = -1 + pushState(x) { + const last = this.#arr[this.#index] + if (last === x || last?.fraction && last.fraction === x.fraction) return + this.#arr[++this.#index] = x + this.#arr.length = this.#index + 1 + this.dispatchEvent(new Event('index-change')) + } + replaceState(x) { + const index = this.#index + this.#arr[index] = x + } + back() { + const index = this.#index + if (index <= 0) return + const detail = { state: this.#arr[index - 1] } + this.#index = index - 1 + this.dispatchEvent(new CustomEvent('popstate', { detail })) + this.dispatchEvent(new Event('index-change')) + } + forward() { + const index = this.#index + if (index >= this.#arr.length - 1) return + const detail = { state: this.#arr[index + 1] } + this.#index = index + 1 + this.dispatchEvent(new CustomEvent('popstate', { detail })) + this.dispatchEvent(new Event('index-change')) + } + get canGoBack() { + return this.#index > 0 + } + get canGoForward() { + return this.#index < this.#arr.length - 1 + } + clear() { + this.#arr = [] + this.#index = -1 + } +} + +const languageInfo = lang => { + if (!lang) return {} + try { + const canonical = Intl.getCanonicalLocales(lang)[0] + const locale = new Intl.Locale(canonical) + const isCJK = ['zh', 'ja', 'kr'].includes(locale.language) + const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction + return { canonical, locale, isCJK, direction } + } catch (e) { + console.warn(e) + return {} + } +} + +export class View extends HTMLElement { + #root = this.attachShadow({ mode: 'open' }) + #sectionProgress + #tocProgress + #pageProgress + #cfiProgress + #searchResults = new Map() + #cursorAutohider = new CursorAutohider(this, () => + this.hasAttribute('autohide-cursor')) + isFixedLayout = false + lastLocation + history = new History() + constructor() { + super() + this.history.addEventListener('popstate', ({ detail }) => { + const resolved = this.resolveNavigation(detail.state) + this.renderer.goTo(resolved) + }) + } + async open(book) { + if (typeof book === 'string' + || typeof book.arrayBuffer === 'function' + || book.isDirectory) book = await makeBook(book) + this.book = book + this.language = languageInfo(book.metadata?.language) + + if (book.splitTOCHref && book.getTOCFragment) { + const ids = book.sections.map(s => s.id) + this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600) + const splitHref = book.splitTOCHref.bind(book) + const getFragment = book.getTOCFragment.bind(book) + this.#tocProgress = new TOCProgress() + await this.#tocProgress.init({ + toc: book.toc ?? [], ids, splitHref, getFragment }) + this.#pageProgress = new TOCProgress() + await this.#pageProgress.init({ + toc: book.pageList ?? [], ids, splitHref, getFragment }) + } + this.#cfiProgress = new PageProgress(book, this.resolveNavigation.bind(this)) + + this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' + if (this.isFixedLayout) { + await import('./fixed-layout.js') + this.renderer = document.createElement('foliate-fxl') + } else { + await import('./paginator.js') + this.renderer = document.createElement('foliate-paginator') + } + this.renderer.setAttribute('exportparts', 'head,foot,filter,container') + this.renderer.addEventListener('load', e => this.#onLoad(e.detail)) + this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)) + this.renderer.addEventListener('create-overlayer', e => + e.detail.attach(this.#createOverlayer(e.detail))) + this.renderer.open(book) + this.#root.append(this.renderer) + + if (book.sections.some(section => section.mediaOverlay)) { + const activeClass = book.media.activeClass + const playbackActiveClass = book.media.playbackActiveClass + this.mediaOverlay = book.getMediaOverlay() + let lastActive + this.mediaOverlay.addEventListener('highlight', e => { + const resolved = this.resolveNavigation(e.detail.text) + this.renderer.goTo(resolved) + .then(() => { + const { doc } = this.renderer.getContents() + .find(x => x.index = resolved.index) + const el = resolved.anchor(doc) + el.classList.add(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.add(playbackActiveClass) + lastActive = new WeakRef(el) + }) + }) + this.mediaOverlay.addEventListener('unhighlight', () => { + const el = lastActive?.deref() + if (el) { + el.classList.remove(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.remove(playbackActiveClass) + } + }) + } + } + close() { + this.renderer?.destroy() + this.renderer?.remove() + this.#sectionProgress = null + this.#tocProgress = null + this.#pageProgress = null + this.#cfiProgress = null + this.#searchResults = new Map() + this.lastLocation = null + this.history.clear() + this.tts = null + this.mediaOverlay = null + } + goToTextStart() { + return this.goTo(this.book.landmarks + ?.find(m => m.type.includes('bodymatter') || m.type.includes('text')) + ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no')) + } + async init({ lastLocation, showTextStart }) { + const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null + if (resolved) { + await this.renderer.goTo(resolved) + this.history.pushState(lastLocation) + } + else if (showTextStart) await this.goToTextStart() + else { + this.history.pushState(0) + await this.next() + } + } + #emit(name, detail, cancelable) { + return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })) + } + #onRelocate({ reason, range, index, fraction, size }) { + const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {} + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + const cfi = this.getCFI(index, range) + this.lastLocation = { ...progress, tocItem, pageItem, cfi, range } + if (reason === 'snap' || reason === 'page' || reason === 'scroll') + this.history.replaceState(cfi) + this.#emit('relocate', this.lastLocation) + } + #onLoad({ doc, index }) { + // set language and dir if not already set + doc.documentElement.lang ||= this.language.canonical ?? '' + if (!this.language.isCJK) + doc.documentElement.dir ||= this.language.direction ?? '' + + this.#handleLinks(doc, index) + this.#cursorAutohider.cloneFor(doc.documentElement) + + this.#emit('load', { doc, index }) + } + #handleLinks(doc, index) { + const { book } = this + const section = book.sections[index] + doc.addEventListener('click', e => { + const a = e.target.closest('a[href]') + if (!a) return + e.preventDefault() + const href_ = a.getAttribute('href') + const href = section?.resolveHref?.(href_) ?? href_ + if (book?.isExternal?.(href)) + Promise.resolve(this.#emit('external-link', { a, href }, true)) + .then(x => x ? globalThis.open(href, '_blank') : null) + .catch(e => console.error(e)) + else { + let internalHref = href + if (!book.resolveHref(href)) { + const hashIndex = href_.indexOf('#') + if (hashIndex >= 0) { + const hash = href_.slice(hashIndex) + internalHref = section?.resolveHref?.(hash) ?? href + } + } + Promise.resolve(this.#emit('link', { a, href: internalHref }, true)) + .then(x => x ? this.goTo(internalHref) : null) + .catch(e => console.error(e)) + } + }) + } + async addAnnotation(annotation, remove) { + const { value } = annotation + if (value.startsWith(SEARCH_PREFIX)) { + const cfi = value.replace(SEARCH_PREFIX, '') + const { index, anchor } = await this.resolveNavigation(cfi) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + if (remove) { + overlayer.remove(value) + return + } + const range = doc ? anchor(doc) : anchor + if (range) overlayer.add(value, range, Overlayer.outline) + } + return + } else if (value.startsWith(NOTE_PREFIX)) { + const cfi = value.replace(NOTE_PREFIX, '') + const { index, anchor } = await this.resolveNavigation(cfi) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + if (remove) { + overlayer.remove(value) + return + } + const range = doc ? anchor(doc) : anchor + if (range) { + const draw = (func, opts) => overlayer.add(value, range, func, opts) + this.#emit('draw-annotation', { draw, annotation, doc, range }) + } + } + return + } + const { index, anchor } = await this.resolveNavigation(value) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + overlayer.remove(value) + if (!remove) { + const range = doc ? anchor(doc) : anchor + if (range) { + const draw = (func, opts) => overlayer.add(value, range, func, opts) + this.#emit('draw-annotation', { draw, annotation, doc, range }) + } + } + } + const label = this.#tocProgress?.getProgress(index)?.label ?? '' + return { index, label } + } + deleteAnnotation(annotation) { + return this.addAnnotation(annotation, true) + } + #getOverlayer(index) { + return this.renderer.getContents() + .find(x => x.index === index && x.overlayer) + } + #createOverlayer({ doc, index }) { + const overlayer = new Overlayer(doc) + doc.addEventListener('click', e => { + const [value, range, rect] = overlayer.hitTest(e) + if (value && !value.startsWith(SEARCH_PREFIX)) { + this.#emit('show-annotation', { value, index, range, rect }) + } + }, false) + + let lastHitTestTime = 0 + const THROTTLE_MS = 200 + const isAndroid = /Android/i.test(navigator.userAgent) + + doc.addEventListener('mousemove', (e) => { + if (isAndroid) return + const now = performance.now() + if (now - lastHitTestTime < THROTTLE_MS) return + lastHitTestTime = now + const [value] = overlayer.hitTest(e) + if (value && !value.startsWith(SEARCH_PREFIX)) { + doc.body.style.cursor = 'pointer' + } else { + doc.body.style.cursor = '' + } + }) + + const list = this.#searchResults.get(index) + if (list) for (const item of list) this.addAnnotation(item) + + this.#emit('create-overlay', { index }) + return overlayer + } + async showAnnotation(annotation) { + const { value } = annotation + const resolved = await this.goTo(value) + if (resolved) { + const { index, anchor } = resolved + const { doc } = this.#getOverlayer(index) + const range = anchor(doc) + this.#emit('show-annotation', { value, index, range }) + } + } + getCFI(index, range) { + const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index) + if (!range) return baseCFI + return CFI.joinIndir(baseCFI, CFI.fromRange(range)) + } + resolveCFI(cfi) { + if (this.book.resolveCFI) + return this.book.resolveCFI(cfi) + else { + const parts = CFI.parse(cfi) + const index = CFI.fake.toIndex((parts.parent ?? parts).shift()) + const anchor = doc => CFI.toRange(doc, parts) + return { index, anchor } + } + } + resolveNavigation(target) { + try { + if (typeof target === 'number') return { index: target } + if (typeof target.fraction === 'number') { + const [index, anchor] = this.#sectionProgress.getSection(target.fraction) + return { index, anchor } + } + if (CFI.isCFI.test(target)) return this.resolveCFI(target) + return this.book.resolveHref(target) + } catch (e) { + console.error(e) + console.error(`Could not resolve target ${target}`) + } + } + async goTo(target) { + const resolved = this.resolveNavigation(target) + try { + await this.renderer.goTo(resolved) + this.history.pushState(target) + return resolved + } catch(e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + async goToFraction(frac) { + const [index, anchor] = this.#sectionProgress.getSection(frac) + await this.renderer.goTo({ index, anchor }) + this.history.pushState({ fraction: frac }) + } + async select(target) { + try { + const obj = await this.resolveNavigation(target) + await this.renderer.goTo({ ...obj, select: true }) + this.history.pushState(target) + } catch(e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + deselect() { + for (const { doc } of this.renderer.getContents()) + doc.defaultView.getSelection().removeAllRanges() + } + getSectionFractions() { + return (this.#sectionProgress?.sectionFractions ?? []) + .map(x => x + Number.EPSILON) + } + getProgressOf(index, range) { + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + return { tocItem, pageItem } + } + async getCFIProgress(cfi) { + const progress = await this.#cfiProgress?.getProgress(cfi) + if (!progress || progress.index === -1) return null + return this.#sectionProgress?.getProgress(progress.index, progress.fraction) + } + async getTOCItemOf(target) { + try { + const { index, anchor } = await this.resolveNavigation(target) + const doc = await this.book.sections[index].createDocument() + const frag = anchor(doc) + const isRange = frag instanceof Range + const range = isRange ? frag : doc.createRange() + if (!isRange) range.selectNodeContents(frag) + return this.#tocProgress?.getProgress(index, range) + } catch(e) { + console.error(e) + console.error(`Could not get ${target}`) + } + } + async prev(distance) { + await this.renderer.prev(distance) + } + async next(distance) { + await this.renderer.next(distance) + } + async pan(dx, dy) { + await this.renderer.pan(dx, dy) + } + isOverflowX() { + return this.renderer.isOverflowX + } + isOverflowY() { + return this.renderer.isOverflowY + } + goLeft() { + return this.book.dir === 'rtl' ? this.next() : this.prev() + } + goRight() { + return this.book.dir === 'rtl' ? this.prev() : this.next() + } + // A matcher result carries a primary `range` (nav/excerpt anchor) and, for + // nearby-words, per-word `subRanges` to highlight each matched word. + #toSearchMatch(index, { range, excerpt, subRanges }) { + const cfi = this.getCFI(index, range) + if (subRanges?.length) + return { cfi, cfis: subRanges.map(r => this.getCFI(index, r)), excerpt } + return { cfi, excerpt } + } + async * #searchSection(matcher, query, index) { + const doc = await this.book.sections[index].createDocument() + for (const match of matcher(doc, query)) + yield this.#toSearchMatch(index, match) + } + async * #searchBook(matcher, query) { + const { sections } = this.book + for (const [index, { createDocument }] of sections.entries()) { + if (!createDocument) continue + const doc = await createDocument() + const subitems = Array.from(matcher(doc, query), match => this.#toSearchMatch(index, match)) + const progress = (index + 1) / sections.length + yield { progress } + if (subitems.length) yield { index, subitems } + } + } + async * search(opts) { + this.clearSearch() + const { searchMatcher } = await import('./search.js') + const { sections } = this.book + const { query, index, results } = opts + const matcher = searchMatcher(textWalker, + { defaultLocale: this.language, ...opts }) + + const iter = results?.length + ? (async function* () { + for (const result of results) { + if (result.subitems) { + const progress = (result.index + 1) / sections.length + yield { progress } + yield { index: result.index, subitems: result.subitems } + } else { + yield { cfi: result.cfi, cfis: result.cfis, excerpt: result.excerpt } + } + } + })() + : index != null + ? this.#searchSection(matcher, query, index) + : this.#searchBook(matcher, query) + + const list = [] + const seen = new Set() + this.#searchResults.set(index, list) + // Add one annotation per unique CFI (a nearby-words match carries several + // via `cfis`); dedupe so overlapping CFIs don't collide in #searchResults. + const addHighlights = (cfis, sink, sinkSeen) => { + for (const cfi of cfis) { + if (sinkSeen.has(cfi)) continue + sinkSeen.add(cfi) + const item = { value: SEARCH_PREFIX + cfi } + sink.push(item) + this.addAnnotation(item) + } + } + + for await (const result of iter) { + if (result.subitems){ + const sectionList = [] + const sectionSeen = new Set() + for (const item of result.subitems) + addHighlights(item.cfis ?? [item.cfi], sectionList, sectionSeen) + this.#searchResults.set(result.index, sectionList) + yield { + index: result.index, + label: this.#tocProgress?.getProgress(result.index)?.label ?? '', + subitems: result.subitems, + } + } + else { + if (result.cfi) addHighlights(result.cfis ?? [result.cfi], list, seen) + yield result + } + } + yield 'done' + } + clearSearch() { + for (const list of this.#searchResults.values()) + for (const item of list) this.deleteAnnotation(item) + this.#searchResults.clear() + } + async initTTS(granularity = 'word', nodeFilter, highlighter) { + const contents = this.renderer.getContents() + const primaryIndex = this.renderer.primaryIndex + const primary = contents.find(x => x.index === primaryIndex) ?? contents[0] + const doc = primary?.doc + if (!doc) return + if (this.tts && this.tts.doc === doc) return + const { TTS } = await import('./tts.js') + this.tts = new TTS(doc, textWalker, nodeFilter, highlighter || (range => + this.renderer.scrollToAnchor(range, true)), granularity) + } + startMediaOverlay() { + const contents = this.renderer.getContents() + const primaryIndex = this.renderer.primaryIndex + const primary = contents.find(x => x.index === primaryIndex) ?? contents[0] + const { index } = primary ?? {} + return this.mediaOverlay.start(index) + } +} + +customElements.define('foliate-view', View)