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.
503 lines
19 KiB
JavaScript
Vendored
503 lines
19 KiB
JavaScript
Vendored
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
|
|
}
|
|
}
|
|
}
|