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:
2026-08-11 21:59:33 -04:00
parent a1281f129c
commit dd65e34869
24 changed files with 11543 additions and 1 deletions
+4 -1
View File
@@ -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
+3
View File
@@ -11,3 +11,6 @@ coverage
# Miscellaneous # Miscellaneous
/static/ /static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/src/lib/vendor/
+2
View File
@@ -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,
+1
View File
@@ -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",
+8
View File
@@ -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: {}
+129
View File
@@ -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
View File
@@ -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.
+37
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+437
View File
@@ -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
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+43
View File
@@ -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
View File
@@ -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
}
}
}
+1
View File
@@ -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
View File
@@ -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)