chore: vendor foliate-js
foliate-js has no npm release and no build step; upstream recommends a git submodule. Copy it instead: this repo has no submodules and already vendors pdf.js the same way under static/pdfjs, and a submodule would pull in 231 files / 13 MB of which 191 files / 12 MB is a bundled pdf.js build that Chitai does not use. Only the import closure reachable from view.js is vendored — 15 files, 584K. pdf.js is replaced by a stub that throws: view.js reaches it through a static-string dynamic import inside makeBook, which Rollup resolves at build time even though Chitai serves PDFs from static/pdfjs/web/viewer.html, and upstream's version opens with a bare `import '@pdfjs/pdf.min.mjs'` that does not resolve here. scripts/vendor-foliate.sh pins the commit and makes the next update a one-line change. fixed-layout.js needs construct-style-sheets-polyfill, so add it.
This commit is contained in:
@@ -1,2 +1,5 @@
|
|||||||
# Mark pdfjs as vendored code
|
# Mark pdfjs as vendored code
|
||||||
frontend/static/pdfjs/** linguist-vendored
|
frontend/static/pdfjs/** linguist-vendored
|
||||||
|
|
||||||
|
# Mark foliate-js as vendored code
|
||||||
|
frontend/src/lib/vendor/** linguist-vendored
|
||||||
@@ -11,3 +11,6 @@ coverage
|
|||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/static/
|
/static/
|
||||||
|
|
||||||
|
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
|
||||||
|
/src/lib/vendor/
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
|
// Vendored third-party source. Tracked, so .gitignore does not cover it.
|
||||||
|
{ ignores: ['src/lib/vendor/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"construct-style-sheets-polyfill": "^3.1.0",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"svelte-sonner": "^1.0.8",
|
"svelte-sonner": "^1.0.8",
|
||||||
|
|||||||
Generated
+8
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
construct-style-sheets-polyfill:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
epubjs:
|
epubjs:
|
||||||
specifier: ^0.3.93
|
specifier: ^0.3.93
|
||||||
version: 0.3.93
|
version: 0.3.93
|
||||||
@@ -1166,6 +1169,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==}
|
resolution: {integrity: sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
construct-style-sheets-polyfill@3.1.0:
|
||||||
|
resolution: {integrity: sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw==}
|
||||||
|
|
||||||
cookie@0.6.0:
|
cookie@0.6.0:
|
||||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3346,6 +3352,8 @@ snapshots:
|
|||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
uint8array-extras: 1.5.0
|
uint8array-extras: 1.5.0
|
||||||
|
|
||||||
|
construct-style-sheets-polyfill@3.1.0: {}
|
||||||
|
|
||||||
cookie@0.6.0: {}
|
cookie@0.6.0: {}
|
||||||
|
|
||||||
core-js@3.48.0: {}
|
core-js@3.48.0: {}
|
||||||
|
|||||||
Executable
+129
@@ -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" <<EOF
|
||||||
|
# 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 | <https://github.com/readest/foliate-js> (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/^/ /'
|
||||||
+21
@@ -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.
|
||||||
@@ -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 | <https://github.com/readest/foliate-js> (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.
|
||||||
+140
@@ -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([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}"></body></html>`], { 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
|
||||||
|
}
|
||||||
+1345
File diff suppressed because it is too large
Load Diff
+369
@@ -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 <div> 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))
|
||||||
|
}
|
||||||
+356
@@ -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
|
||||||
|
// `<image l:href="#img1.jpg" id="img1.jpg" />`
|
||||||
|
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 => `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head><link href="${style}" rel="stylesheet" type="text/css"/></head>
|
||||||
|
<body>${html}</body>
|
||||||
|
</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 `<sequence name="…" number="…"/>` 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
|
||||||
|
}
|
||||||
+1815
File diff suppressed because it is too large
Load Diff
+1279
File diff suppressed because it is too large
Load Diff
+437
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+3794
File diff suppressed because it is too large
Load Diff
+11
@@ -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');
|
||||||
+224
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
+317
@@ -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 <i>/<em>/<b>), 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+502
@@ -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 <ruby>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<<r[t-1];var f=new e(i[30]);for(t=1;t<30;++t)for(var o=i[t];o<i[t+1];++o)f[o]=o-i[t]<<5|t;return{b:i,r:f}},o=f(n,2),v=o.b,l=o.r;v[28]=258,l[258]=28;for(var u=f(i,0).b,c=new a(32768),d=0;d<32768;++d){var w=(43690&d)>>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<i;++t)r[t]&&++f[r[t]-1];var o,v=new a(e);for(t=1;t<e;++t)v[t]=v[t-1]+f[t-1]<<1;if(n){o=new a(1<<e);var l=15-e;for(t=0;t<i;++t)if(r[t])for(var u=t<<4|r[t],d=e-r[t],w=v[r[t]-1]++<<d,b=w|(1<<d)-1;w<=b;++w)o[c[w]>>l]=u}else for(o=new a(i),t=0;t<i;++t)r[t]&&(o[t]=c[v[r[t]-1]++]>>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;e<r.length;++e)r[e]>a&&(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<q;++H)G[t[H]]=k(a,E+3*H,7);E+=3*q;var J=p(G),K=(1<<J)-1,L=b(G,J,1);for(H=0;H<B;){var N,P=L[k(a,E,K)];if(E+=15&P,(N=P>>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<<D)-1,Y=(1<<F)-1,Z=E;;Z=E){var $=(Q=A[m(a,E)&X])>>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<<rr)-1)+v[H],E+=rr}var ar=U[m(a,E)&Y],er=ar>>4;ar||T(3),E+=15&ar;W=u[er];if(er>3){rr=i[er];W+=m(a,E)&(1<<rr)-1,E+=rr}if(E>M){s&&T(0);break}w&&h(z+131072);var nr=z+_;if(z<W){var ir=c-W,tr=Math.min(W,nr);for(ir+z<0&&T(3);z<tr;++z)f[z]=o[ir+z]}for(;z<nr;++z)f[z]=f[z-W]}}e.l=A,e.p=Z,e.b=z,e.f=x,A&&(x=1,e.m=D,e.d=U,e.n=F)}while(!x);return z!=f.length&&d?function(a,e,n){return(null==n||n>a.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};
|
||||||
File diff suppressed because one or more lines are too long
+704
@@ -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)
|
||||||
Reference in New Issue
Block a user