* docs: define ebook architecture matching audiobooks * docs: plan ebook audiobook-parity implementation * feat: add ebook scanner parser foundation * fix: harden ebook scanner foundation * fix: handle ebook isbn labels * fix: guard ebook subtree scans * feat: scan ebook libraries in core * fix: preserve ebook scan people credits * fix: refresh ebook scan metadata safely * feat: persist ebook series membership * test: cover ebook series persistence decisions * fix: address ebook scanner PR review * docs: clarify ebook foundation PR scope * feat: add ebook metadata enricher * fix: harden ebook poster cache * feat: wire ebook metadata sync task * feat: expose ebook library metadata setup * feat: add ebook catalog scope support * feat: add ebook detail view * feat: label ebook file versions by format * feat: use file-size copy for downloads * feat: use file language in download dialog * test: cover ebook detail authors and downloads * fix: drop narrator credits from ebook scanner merges * fix: align ebook collection filters with book media * fix: drop asin provider ids from ebook enrichment * fix: force ebook people refresh for stale narrators * chore: omit ebook planning docs from branch * feat: add ebook detail related content * feat: add ebook reader file entrypoint * feat: render ebooks with foliate reader * feat: persist ebook reader progress * feat: add ebook reader controls * feat: extract ebook pdf metadata * feat: favor scanner isbn during ebook enrichment * feat: extract fbz ebook metadata * feat: count cbz ebook pages * feat: show ebook file page counts * feat: show ebook download summaries * feat: switch ebook reader files * feat: prefer epub for ebook read action * feat: surface ebook reader progress * feat: sync ebook reader progress cache * feat: hide ebook read action for unsupported files * feat: filter ebook reader file selector * fix: serve fbz ebook archives with reader mime type * fix: detect fbz ebooks from compound filename * fix: authorize fbz ebooks from compound filename * fix: scope ebook catalog facets * fix: reject narrator queries for ebooks * fix: build ebook recommendation text from authors * fix: include ebooks in embedding eligibility * fix: include ebooks in recommendation media mix * fix: include ebooks in recently added recommendations * feat: include ebook progress in recommendation signals * feat: include ebooks in continue watching sections * feat: include ebooks in catalog progress metrics * fix: read ebook isbn from epub metadata * fix: filter ebook asin provider aliases * fix: fall back from unsupported ebook reader files * fix: sort ebook catalogs by reader progress * fix: filter ebook catalogs by reader progress * fix: include ebooks in last watched catalog filters * feat: reflect ebook reader progress in item user state * feat: share ebook progress state across item surfaces * feat: report ebook scan progress * fix: include ebook activity in recommendations * fix: expose ebook reader progress on item detail * fix: support ebook subtree scans * fix: honor profile header for ebook item progress * fix: add ebook library default sections * fix: route ebook continue cards to reader * fix: hide watched toggle for ebooks * fix: route ebook watch tonight cards to reader * fix: route ebook hero actions to reader * fix: detect archive ebook reader formats by filename * feat: cache embedded ebook covers during scan * fix: encode ebook hero reader links * fix: persist non-epub ebook reader progress * fix: scope narrator catalog badges to audiobooks * fix: merge ebook reader progress during item repair * fix: label ebook progress filters as read * fix: show ebook related rails as book covers * fix: remove txt ebook reader support * fix: reject txt ebook reader files * fix: label ebook advanced filters as read * fix: label ebook personalized sorts as read * fix: remove plain text reader loader path * test: cover ebook unread catalog rules * fix: preserve ebook reader library context * fix: link ebook genres with library scope * fix: encode related rail item links * fix: encode catalog card item links * fix: encode hero and continue item links * fix: encode watch tonight item links * fix: encode recommendation and search item links * test: cover ebook scan format set * fix: label ebook search results clearly * fix: make global search prompt media neutral * fix: encode catalog read API ids * fix: encode item API ids * fix: include ebook reader vendor in docker build * fix: make ebook reader build clean * fix: clean ebook embedded descriptions * docs: plan ebook reader shell parity * feat: add ebook reader shell controls * fix: widen ebook scrolled reader flow * fix: remove scrolled reader content width cap * docs: plan ebook reader full parity * feat: persist ebook reader config * feat: add ebook annotations and bookmarks * feat: add ebook reader tools and aids * feat: add ebook advanced reader settings * fix: keep ebook reader panel in viewport * fix: use foliate sizing units for ebook scroll flow * fix: keep ebook settings controls readable * fix: simplify ebook reader settings controls * feat(ebooks): extract local covers during scan (#98) * feat(ebooks): extract local covers during scan * fix(ebooks): read nullable poster paths during cover scan * fix(catalog): coalesce nullable media artwork fields * fix(ebooks): group sibling formats by book identity * fix(ebooks): tolerate legacy ebook metadata encodings * fix(ebooks): decode PDF hex metadata strings * fix(ebooks): harden local cover extraction and format grouping Address review findings on the local cover scan: - Restrict generic sidecar covers (cover.jpg, folder.png, ...) to single-book directories, always accept images named after the book file, and apply exactly one cover per reconcile with sidecar taking precedence over the embedded cover. - Replace the read-then-write poster update with an atomic conditional UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is never clobbered by concurrent writers, and refresh locally owned posters when the extracted cover bytes change (thumbhash compare). - Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of forcing everything through Windows-1252; the cp1252 fallback now only applies to non-UTF-8 bytes. - Select EPUB covers by manifest media-type with properties="cover-image" outranking the EPUB2 meta name="cover" id, so XHTML cover pages no longer shadow the real image. - Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/) when picking the cover page, via a single O(n) min-scan. - Bump the ebook content group key scheme to version 2 and reprocess rows written under older versions so pre-existing libraries gain sibling-format grouping instead of accumulating duplicates. - Group different formats only (a same-format sibling with colliding sparse metadata stays a separate item) and stop a joining sibling's embedded metadata from overwriting a provider-matched item. - Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r, shift_jis, ...) via x/net/html/charset, and wire the charset reader into FB2 parsing which previously had none. - Strip the full .fb2.zip double extension from filename-derived titles and group keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat(ebooks): add reader profiles and ruler (#99) * feat(ebooks): extract local covers during scan * fix(ebooks): read nullable poster paths during cover scan * fix(catalog): coalesce nullable media artwork fields * fix(ebooks): group sibling formats by book identity * fix(ebooks): tolerate legacy ebook metadata encodings * fix(ebooks): decode PDF hex metadata strings * feat(ebooks): add reader profiles and ruler * fix(ebooks): address reader ruler and profile review findings - skip renderer setStyles/render when computed styles and attributes are unchanged, so ruler position updates no longer re-style the book view - drag the ruler via a local draft that commits on release, with the surface rect cached at pointer-down - migrate font values persisted before the generic stacks (Inter, Georgia, Merriweather, legacy serif) so the font select never renders blank, with a Custom fallback option for unknown values - make the ruler band click-through and move dragging to a dedicated keyboard-accessible slider handle so links and text selection keep working under the band - share font stacks between options and profiles via READER_FONT_STACKS - surface the active reading profile, move presets to the top of the settings panel, and drop the redundant profile button aria-labels Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ebooks): resolve prefer-const lint error in readest document lib `pnpm run lint` failed on the branch because `direction` is never reassigned in getDirection; split the destructure so only the reassigned `writingMode` stays mutable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * Merge branch 'main' into work/ebooks-reader-base Brings the ebook integration branch up to date with main (audiobook library redesign, continue-watching rework and card affordances, quic-go bump, jellycompat fixes). Conflict resolutions favor main's generalized mechanisms and register ebooks with them: - media scope validation goes through IsValidMediaScope (now including "ebook" alongside main's "video" group scope), in Go and in the web filter/search types - continue-watching uses main's typed rails; reading-type sections pull resume points from ebook_reader_progress and the ebook library default section is wired to ContinueTypeConfig(ContinueTypeReading) - item_repo keeps main's derived select-list machinery (itemColumnExpr) and both poster accessors (GetPoster/SetLocalPoster for ebook covers, GetPosterPath for audiobook covers) - web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers, which now route ebooks to /reader/ebook and encode content ids; ebook affordances (BookOpen icon, Read verb, percent-read subtitle) carry over onto main's reworked components - LibraryForm ebook support ported into main's refactored useLibraryForm/libraryTypes modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs the vendor directory before the lockfile install layer. The production Dockerfile already copies it; the dev image was missed, breaking make dev-deploy with ENOENT on /app/web/vendor/foliate-js. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ebooks): render Continue Reading sections as upright poster cards All-ebook continue sections previously fell through to the horizontal 16:9 wide card; include ebooks in the poster-variant check so book covers render in their natural 2:3 framing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): stop related-rail highlight ring clipping on detail pages Move the current-item ring onto the cover artwork with a themed ring-offset color (matching the sidebar profile highlight) and give the scroll container top headroom so the ring is not cut off by overflow-x-auto. Applies to both ebook and audiobook detail rails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scanner): harden ebook scanning against data loss and bad metadata - Reconcile missing ebook files like video/audio, with real per-root walk failure tracking (failed/unmounted roots are excluded from deletion), symlinked-root support via the shared logical walker, and the empty-root cleanup allowance before any destructive reconciliation. - Create ebook items as 'pending' so enrichment can promote them to 'matched' (backfill migration included), and protect matched items from re-scan clobbering: title/year skipped, people/series fill-empty only. - PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info dict at the end), require proper key delimiters, head values win. - Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format. - gofmt internal/scanner/audiobook.go (pre-existing drift). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ebooks): make enrichment failures non-terminal with dedicated backoff state - Provider errors now record a failure (capped retries) instead of stamping last_refreshed, which permanently excluded items after transient outages. - Unconfigured metadata chains and the scan-window membership race skip the item without stamping or burning a retry. - Failure tracking moves to a new ebook_enrichment_state table, decoupling it from media_items.refresh_failures (shared with metadata refresh debt). - Preserve non-author people credits when persisting enrichment results. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(catalog): gate ebook progress on hidden history and centralize threshold - Apply user_history_hidden_items gating (video semantics) to the ebook watched/in-progress filters, progress sort plan, and Continue Reading. - Continue Reading pages past dismissed items via the shared collector and dedupes items across pages (also fixes the video path's latent exposure). - Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold with a single SQL-interpolated mirror in catalog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(recommendations): correct watcher counting and wire ebook taste signals - itemWatchersQuery dedupes to distinct (watcher, item) rows so one binge-watcher can no longer satisfy minWatchers; the eligibility floor now counts distinct accounts rather than profiles. - Hidden-history gating on GetEbookReaderProgressForUser (signal reader). - Ebook reading produces canonical implicit taste signals (weighted like the equivalent movie progress ratio); ebooks join taste-seed candidates. - Stale GetRecentlyAddedItems doc comment corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): harden ebook reader endpoints and serve a Content-Security-Policy - Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from malicious book content (sandbox alone is defeated by the WebKit allow-scripts requirement). Threat model documented on the constant. - X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file responses; MIME resolution can no longer fall through to octet-stream for an admitted ebook file. - Annotation PATCH: presence-aware field semantics (absent keeps, present sets/clears), invariant re-validation on the merged row, and an atomic SELECT ... FOR UPDATE read-merge-write. - Request size caps (413) on progress/config/annotation writes; Content-Disposition via mime.FormatMediaType; hidden-history gating in the shared ebook progress lister; FK-cascade indexes for reader tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): native read-state endpoints for ebooks - POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts progress 1.0 preserving the reader's file/location (or picks the preferred reader file for never-opened books); mark unread mirrors video unwatch semantics and deletes the progress row. - /history/remove accepts ebooks: hides via user_history_hidden_items without touching the reading position (hidden != unread; next reading activity resurfaces the book, mirroring video re-watch). - Access-filter checks match the video branch; shared logic lives in ebook_read_state.go. Sort metrics/user-state thresholds use the shared constant; profile-header fallback deduplicated. Clients: response is {type: "ebook", affected_count: 1, played: bool}; the existing watched SSE event fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): harden the ebook reader UI - Open-flow race: cancellation checked after every await with full stale-run teardown (no wrong-file progress saves, no leaked views/blob URLs); book.destroy() on cleanup. - Progress: monotonic stale-response guard; visibilitychange flush uses the refresh-capable client, pagehide uses keepalive; per-book cross-format progress documented as deliberate. - Settings: side effects out of the setState updater; local edits no longer clobbered by late server config; pending saves flushed on unmount/pagehide. - TTS: generation token so Stop actually stops (Chromium/Firefox synthetic events); Media Session uninstalled on unmount. - External book links: http(s) only, opened with noopener,noreferrer. - apiBlob 512 MiB guard with a user-facing error; fraction bookmarks navigable; search-result key collisions fixed; dead e-ink code removed; getLibrarySortRelevanceScope deduplicated; md format dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): mark read/unread affordances for ebooks - Item detail gets a Mark Read/Unread button; card menus drop the ebook gate and share type-aware labels/toasts (also dedupes audiobook wording). - Watched-state invalidation includes the reader progress query key so the Continue button and percent refresh after toggling. - Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes item IDs (ebook content IDs can contain reserved characters). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the PR #124 review and hardening pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
451 lines
16 KiB
JavaScript
451 lines
16 KiB
JavaScript
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
|
|
if (node.nodeType === 3) return ssml.createTextNode(node.textContent)
|
|
if (node.nodeType === 4) return 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) {
|
|
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
|
|
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
const name = node.tagName.toLowerCase()
|
|
if (blockTags.has(name)) {
|
|
if (last) {
|
|
last.setEndBefore(node)
|
|
if (!rangeIsEmpty(last)) yield last
|
|
}
|
|
last = doc.createRange()
|
|
last.setStart(node, 0)
|
|
}
|
|
}
|
|
if (!last) {
|
|
last = doc.createRange()
|
|
last.setStart(root.firstChild ?? root, 0)
|
|
}
|
|
last.setEndAfter(root.lastChild ?? root)
|
|
if (!rangeIsEmpty(last)) yield last
|
|
}
|
|
|
|
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), 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)
|
|
let mark
|
|
for (const [name, range_] of this.#ranges.entries())
|
|
if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) {
|
|
mark = name
|
|
break
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|