foliate-js
Version:
Render e-books in the browser
300 lines (280 loc) • 10.5 kB
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 = 'en', granularity = 'word') => {
const segmenter = new Intl.Segmenter(lang, { 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; i < rawSegments.length; i++) {
const current = rawSegments[i]
const next = rawSegments[i + 1]
const segment = current.segment.trim()
const nextSegment = next?.segment?.trim()
const endsWithAbbr = /(?:^|\s)([A-Z][a-z]{1,5})\.$/.test(segment)
const nextStartsWithCapital = /^[A-Z]/.test(nextSegment || '')
if (endsWithAbbr && nextStartsWithCapital) {
const mergedSegment = {
index: current.index,
segment: current.segment + (next?.segment || ''),
isWordLike: true,
}
mergedSegments.push(mergedSegment)
i++
} else {
mergedSegments.push(current)
}
}
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, 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) 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.firstChild, ssml.documentElement, inherited.alphabet)
return ssml
}
const getFragmentWithMarks = (range, textWalker, 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)]
const fragmentEntries = [...textWalker(fragment, segmenter)]
for (const [name, range] of fragmentEntries) {
const mark = document.createElement('foliate-mark')
mark.dataset.name = name
range.insertNode(mark)
}
const ssml = fragmentToSSML(fragment, { lang, alphabet })
return { entries, ssml }
}
const rangeIsEmpty = range => !range.toString().trim()
function* getBlocks(doc) {
let last
const walker = doc.createTreeWalker(doc.body, 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(doc.body.firstChild ?? doc.body, 0)
}
last.setEndAfter(doc.body.lastChild ?? doc.body)
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, highlight, granularity) {
this.doc = doc
this.highlight = highlight
this.#list = new ListIterator(getBlocks(doc), range => {
const { entries, ssml } = getFragmentWithMarks(range, textWalker, 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
}
return this.#serializer.serializeToString(ssml)
}
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)
}
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))
}
setMark(mark) {
const range = this.#ranges.get(mark)
if (range) {
this.#lastMark = mark
this.highlight(range.cloneRange())
}
}
}