diff --git a/README.md b/README.md index 8b12cde..553f05f 100644 --- a/README.md +++ b/README.md @@ -257,10 +257,28 @@ An overlayer object implements the following interface for the consumption of re - `.element`: the DOM element of the overlayer. This element will be inserted, resized, and positioned automatically by the renderer on top of the page. - `.redraw()`: called by the renderer when the overlay needs to be redrawn. +### The Text Walker + +Not a particularly descriptive name, but essentially, `text-walker.js` is a small DOM utility that allows you to + +1. Gather all text nodes in a `Range`, `Document` or `DocumentFragment` into an array of strings. +2. Perform splitting or matching on the strings. +3. Get back the results of these string operations as `Range`s. + +E.g. you can join all the text nodes together, use `Intl.Segmenter` to segment the string into words, and get the results in DOM Ranges, so you can mark up those words in the original document. + +In foliate-js, this is used for searching and TTS. + ### Searching It provides a search module, which can in fact be used as a standalone module for searching across any array of strings. There's no limit on the number of strings a match is allowed to span. It's based on `Intl.Collator` and `Intl.Segmenter`, to support ignoring diacritics and matching whole words only. It's extrenely slow, and you'd probably want to load results incrementally. +### Text-to-Speech (TTS) + +The TTS module doesn't directly handle speech output. Rather, its methods return SSML documents (as strings), which you can then feed to your speech synthesizer. + +The SSML attributes `ssml:ph` and `ssml:alphabet` are supported. There's no support for PLS and CSS Speech. + ### Supported Browsers The main use of the library is for use in [Foliate](https://github.com/johnfactotum/foliate), which uses WebKitGTK. As such it's the only engine that has been tested extensively. But it should also work in Chromium and Firefox. diff --git a/text-walker.js b/text-walker.js new file mode 100644 index 0000000..3e2c44e --- /dev/null +++ b/text-walker.js @@ -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) { + const root = x.commonAncestorContainer ?? x.body ?? x + const walker = document.createTreeWalker(root, filter, { 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 +} diff --git a/tts.js b/tts.js index 931f593..0089ed3 100644 --- a/tts.js +++ b/tts.js @@ -21,48 +21,6 @@ const getAlphabet = el => { return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null } -const getWalker = (getRoot, walk) => function* (x, func) { - const root = getRoot(x) - const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT - | NodeFilter.SHOW_CDATA_SECTION - const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter - const acceptNode = node => { - if (node.nodeType === 1) { - const name = node.tagName.toLowerCase() - if (name === 'script' || name === 'style') return FILTER_REJECT - return FILTER_SKIP - } - return FILTER_ACCEPT - } - const walker = document.createTreeWalker(root, filter, { acceptNode }) - 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 -} - -const rangeWalker = getWalker(x => x.commonAncestorContainer, (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 fragmentWalker = getWalker(x => x, (range, walker) => { - const nodes = [] - for (let node = walker.nextNode(); node; node = walker.nextNode()) - nodes.push(node) - return nodes -}) - const getSegmenter = (lang = 'en', granularity = 'word') => { const segmenter = new Intl.Segmenter(lang, { granularity }) const granularityIsWord = granularity === 'word' @@ -138,7 +96,7 @@ const fragmentToSSML = (fragment, inherited) => { return ssml } -const getFragmentWithMarks = (range, granularity) => { +const getFragmentWithMarks = (range, textWalker, granularity) => { const lang = getLang(range.commonAncestorContainer) const alphabet = getAlphabet(range.commonAncestorContainer) @@ -148,8 +106,8 @@ const getFragmentWithMarks = (range, granularity) => { // 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 = [...rangeWalker(range, segmenter)] - const fragmentEntries = [...fragmentWalker(fragment, segmenter)] + const entries = [...textWalker(range, segmenter)] + const fragmentEntries = [...textWalker(fragment, segmenter)] for (const [name, range] of fragmentEntries) { const mark = document.createElement('foliate-mark') @@ -249,11 +207,11 @@ export class TTS { #ranges #lastMark #serializer = new XMLSerializer() - constructor(doc, highlight) { + constructor(doc, textWalker, highlight) { this.doc = doc this.highlight = highlight this.#list = new ListIterator(getBlocks(doc), range => { - const { entries, ssml } = getFragmentWithMarks(range) + const { entries, ssml } = getFragmentWithMarks(range, textWalker) this.#ranges = new Map(entries) return [ssml, range] }) diff --git a/view.js b/view.js index ca74e8d..4979006 100644 --- a/view.js +++ b/view.js @@ -1,6 +1,7 @@ import * as CFI from './epubcfi.js' import { TOCProgress, SectionProgress } from './progress.js' import { Overlayer } from './overlayer.js' +import { textWalker } from './text-walker.js' const SEARCH_PREFIX = 'foliate-search:' @@ -46,30 +47,6 @@ class History extends EventTarget { } } -const textWalker = function* (doc, func) { - const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT - | NodeFilter.SHOW_CDATA_SECTION - const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter - const acceptNode = node => { - const name = node.localName?.toLowerCase() - if (name === 'script' || name === 'style') return FILTER_REJECT - if (node.nodeType === 1) return FILTER_SKIP - return FILTER_ACCEPT - } - const walker = doc.createTreeWalker(doc.body, filter, { acceptNode }) - const nodes = [] - for (let node = walker.nextNode(); node; node = walker.nextNode()) - nodes.push(node) - const strs = nodes.map(node => node.nodeValue) - const makeRange = (startIndex, startOffset, endIndex, endOffset) => { - const range = doc.createRange() - range.setStart(nodes[startIndex], startOffset) - range.setEnd(nodes[endIndex], endOffset) - return range - } - for (const match of func(strs, makeRange)) yield match -} - const languageInfo = lang => { if (!lang) return {} try { @@ -417,7 +394,7 @@ export class View extends HTMLElement { const doc = this.renderer.getContents()[0].doc if (this.tts && this.tts.doc === doc) return const { TTS } = await import('./tts.js') - this.tts = new TTS(doc, range => + this.tts = new TTS(doc, textWalker, range => this.renderer.scrollToAnchor(range, true)) } }