Make text walker a separate module

This commit is contained in:
John Factotum
2023-10-01 18:19:13 +08:00
parent ecefeb86e1
commit 83cce01797
4 changed files with 68 additions and 72 deletions
+18
View File
@@ -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. - `.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. - `.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 ### 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. 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 ### 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. 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.
+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) {
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
}
+5 -47
View File
@@ -21,48 +21,6 @@ const getAlphabet = el => {
return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null 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 getSegmenter = (lang = 'en', granularity = 'word') => {
const segmenter = new Intl.Segmenter(lang, { granularity }) const segmenter = new Intl.Segmenter(lang, { granularity })
const granularityIsWord = granularity === 'word' const granularityIsWord = granularity === 'word'
@@ -138,7 +96,7 @@ const fragmentToSSML = (fragment, inherited) => {
return ssml return ssml
} }
const getFragmentWithMarks = (range, granularity) => { const getFragmentWithMarks = (range, textWalker, granularity) => {
const lang = getLang(range.commonAncestorContainer) const lang = getLang(range.commonAncestorContainer)
const alphabet = getAlphabet(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) // we need ranges on both the original document (for highlighting)
// and the document fragment (for inserting marks) // and the document fragment (for inserting marks)
// so unfortunately need to do it twice, as you can't copy the ranges // so unfortunately need to do it twice, as you can't copy the ranges
const entries = [...rangeWalker(range, segmenter)] const entries = [...textWalker(range, segmenter)]
const fragmentEntries = [...fragmentWalker(fragment, segmenter)] const fragmentEntries = [...textWalker(fragment, segmenter)]
for (const [name, range] of fragmentEntries) { for (const [name, range] of fragmentEntries) {
const mark = document.createElement('foliate-mark') const mark = document.createElement('foliate-mark')
@@ -249,11 +207,11 @@ export class TTS {
#ranges #ranges
#lastMark #lastMark
#serializer = new XMLSerializer() #serializer = new XMLSerializer()
constructor(doc, highlight) { constructor(doc, textWalker, highlight) {
this.doc = doc this.doc = doc
this.highlight = highlight this.highlight = highlight
this.#list = new ListIterator(getBlocks(doc), range => { this.#list = new ListIterator(getBlocks(doc), range => {
const { entries, ssml } = getFragmentWithMarks(range) const { entries, ssml } = getFragmentWithMarks(range, textWalker)
this.#ranges = new Map(entries) this.#ranges = new Map(entries)
return [ssml, range] return [ssml, range]
}) })
+2 -25
View File
@@ -1,6 +1,7 @@
import * as CFI from './epubcfi.js' import * as CFI from './epubcfi.js'
import { TOCProgress, SectionProgress } from './progress.js' import { TOCProgress, SectionProgress } from './progress.js'
import { Overlayer } from './overlayer.js' import { Overlayer } from './overlayer.js'
import { textWalker } from './text-walker.js'
const SEARCH_PREFIX = 'foliate-search:' 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 => { const languageInfo = lang => {
if (!lang) return {} if (!lang) return {}
try { try {
@@ -417,7 +394,7 @@ export class View extends HTMLElement {
const doc = this.renderer.getContents()[0].doc const doc = this.renderer.getContents()[0].doc
if (this.tts && this.tts.doc === doc) return if (this.tts && this.tts.doc === doc) return
const { TTS } = await import('./tts.js') const { TTS } = await import('./tts.js')
this.tts = new TTS(doc, range => this.tts = new TTS(doc, textWalker, range =>
this.renderer.scrollToAnchor(range, true)) this.renderer.scrollToAnchor(range, true))
} }
} }