mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Make text walker a separate module
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user