CFI: add ability to ignore nodes

Also test that comment nodes are ignore
This commit is contained in:
John Factotum
2023-09-29 23:58:55 +08:00
parent 87605c5235
commit cea9375561
3 changed files with 91 additions and 37 deletions
+13 -1
View File
@@ -233,7 +233,19 @@ A range CFI is an object `{ parent, start, end }`, each property being the same
The parser uses a state machine rather than regex, and should handle assertions that contain escaped characters correctly (see tests for examples of this).
It can parse and stringify spatial and temporal offsets, as well as text location assertions and side bias, but there's no support for employing them when rendering yet. It's also missing the ability to ignore certain nodes (which is needed if you want to inject your own nodes into the document).
It has the ability ignore nodes, which is needed if you want to inject your own nodes into the document without affecting CFIs. To do this, you need to pass the optional filter function that works similarily to the filter function of [`TreeWalker`s](https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker):
```js
const filter = node => node.nodeType !== 1 ? NodeFilter.FILTER_ACCEPT
: node.matches('.reject') ? NodeFilter.FILTER_REJECT
: node.matches('.skip') ? NodeFilter.FILTER_SKIP
: NodeFilter.FILTER_ACCEPT
CFI.toRange(doc, 'epubcfi(...)', filter)
CFI.fromRange(range, filter)
```
It can parse and stringify spatial and temporal offsets, as well as text location assertions and side bias, but there's no support for employing them when rendering yet.
### Highlighting Text
+28 -18
View File
@@ -188,15 +188,25 @@ export const compare = (a, b) => {
const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4
const isElementNode = ({ nodeType }) => nodeType === 1
const getChildNodes = (node, filter) => {
const nodes = Array.from(node.childNodes)
// "content other than element and character data is ignored"
.filter(node => isTextNode(node) || isElementNode(node))
return filter ? nodes.map(node => {
const accept = filter(node)
if (accept === NodeFilter.FILTER_REJECT) return null
else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter)
else return node
}).flat().filter(x => x) : nodes
}
// child nodes are organized such that the result is always
// [element, text, element, text, ..., element],
// regardless of the actual structure in the document;
// so multiple text nodes need to be combined, and nonexistent ones counted;
// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec
const indexChildNodes = node => {
const nodes = Array.from(node.childNodes)
// "content other than element and character data is ignored"
.filter(node => isTextNode(node) || isElementNode(node))
const indexChildNodes = (node, filter) => {
const nodes = getChildNodes(node, filter)
.reduce((arr, node) => {
let last = arr[arr.length - 1]
if (!last) arr.push(node)
@@ -221,16 +231,14 @@ const indexChildNodes = node => {
return nodes
}
const getNodeByIndex = (node, index) => node ? indexChildNodes(node)[index] : null
const partsToNode = (node, parts) => {
const partsToNode = (node, parts, filter) => {
const { id } = parts[parts.length - 1]
if (id) {
const el = node.ownerDocument.getElementById(id)
if (el) return { node: el, offset: 0 }
}
for (const { index } of parts) {
const newNode = getNodeByIndex(node, index)
const newNode = node ? indexChildNodes(node, filter)[index] : null
// handle non-existent nodes
if (newNode === 'first') return { node: node.firstChild ?? node }
if (newNode === 'last') return { node: node.lastChild ?? node }
@@ -249,9 +257,9 @@ const partsToNode = (node, parts) => {
}
}
const nodeToParts = (node, offset) => {
const nodeToParts = (node, offset, filter) => {
const { parentNode, id } = node
const indexed = indexChildNodes(parentNode)
const indexed = indexChildNodes(parentNode, filter)
const index = indexed.findIndex(x =>
Array.isArray(x) ? x.some(x => x === node) : x === node)
// adjust offset as if merging the text nodes in the chunk
@@ -267,25 +275,27 @@ const nodeToParts = (node, offset) => {
offset = sum
}
const part = { id, index, offset }
return parentNode !== node.ownerDocument.documentElement
? nodeToParts(parentNode).concat(part) : [part]
return (parentNode !== node.ownerDocument.documentElement
? nodeToParts(parentNode, null, filter).concat(part) : [part])
// remove ignored nodes
.filter(x => x.index !== -1)
}
export const fromRange = range => {
export const fromRange = (range, filter) => {
const { startContainer, startOffset, endContainer, endOffset } = range
const start = nodeToParts(startContainer, startOffset)
const start = nodeToParts(startContainer, startOffset, filter)
if (range.collapsed) return toString([start])
const end = nodeToParts(endContainer, endOffset)
const end = nodeToParts(endContainer, endOffset, filter)
return buildRange([start], [end])
}
export const toRange = (doc, parts) => {
export const toRange = (doc, parts, filter) => {
const startParts = collapse(parts)
const endParts = collapse(parts, true)
const root = doc.documentElement
const start = partsToNode(root, startParts[0])
const end = partsToNode(root, endParts[0])
const start = partsToNode(root, startParts[0], filter)
const end = partsToNode(root, endParts[0], filter)
const range = doc.createRange()
+50 -18
View File
@@ -53,6 +53,15 @@ const XHTML = str => parser.parseFromString(str, 'application/xhtml+xml')
</package>`)
const a = opf.getElementById('chap01ref')
const b = CFI.toElement(opf, CFI.parse('/6/4[chap01ref]')[0])
const c = CFI.toElement(opf, CFI.parse('/6/4')[0])
console.assert(a === b)
console.assert(a === c)
}
{
// example from EPUB CFI spec
const page = XHTML(`<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>…</title>
@@ -72,30 +81,49 @@ const XHTML = str => parser.parseFromString(str, 'application/xhtml+xml')
</body>
</html>`)
// the exact same page with some text nodes removed, CDATA sections added,
// the exact same page with some text nodes removed, CDATA & comment added,
// and characters changed to entities
const page2 = XHTML(`<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>…</title>
<title>…</title>
</head>
<body id="body01">
<p>…</p><p>…</p><p>…</p><p>…</p>
<p id="para05">xxx<em>yyy</em><![CDATA[0123]]>45<![CDATA[67]]>&#56;&#57;</p>
<p>…</p>
<p>…</p>
<img id="svgimg" src="foo.svg" alt="…"/>
<p>…</p>
<p>…</p>
<p>…</p><p>…</p><p>…</p><p>…</p>
<p id="para05">xxx<em>yyy</em><![CDATA[]]><!--comment1--><![CDATA[0123]]>4<!--comment2-->5<![CDATA[67]]>&#56;&#57;</p>
<p>…</p>
<p>…</p>
<img id="svgimg" src="foo.svg" alt="…"/>
<p>…</p>
<p>…</p>
</body>
</html>`)
const a = opf.getElementById('chap01ref')
const b = CFI.toElement(opf, CFI.parse('/6/4[chap01ref]')[0])
const c = CFI.toElement(opf, CFI.parse('/6/4')[0])
console.assert(a === b)
console.assert(a === c)
// the exact same page with nodes are to be ignored
const page3 = XHTML(`<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>…</title>
</head>
<body id="body01">
<h1 class="reject">This is ignored!</h1>
<section class="skip">
<p class="reject">Also ignored</p>
<p>…</p><p>…</p><p>…</p><p>…</p>
<p id="para05">xxx<em>yyy</em><span class="reject">Note: we put ignored text in this span but not the other ones because although the CFI library should ignore them, they won't be ignored by DOM Ranges, which will break the tests.</span><span class="skip">0<span class="skip"><span class="reject"><![CDATA[]]></span>123</span></span>45<span class="reject"><img src="icon.svg"/></span>6789</p>
<p>…</p>
<p>…</p>
<img id="svgimg" src="foo.svg" alt="…"/>
<p>…</p>
<p>…</p>
</section>
</body>
</html>`)
const test = page => {
const filter = node => node.nodeType !== 1 ? NodeFilter.FILTER_ACCEPT
: node.matches('.reject') ? NodeFilter.FILTER_REJECT
: node.matches('.skip') ? NodeFilter.FILTER_SKIP
: NodeFilter.FILTER_ACCEPT
const test = (page, filter) => {
for (const cfi of [
'/4[body01]/10[para05]/3:10',
'/4[body01]/16[svgimg]',
@@ -103,20 +131,24 @@ const XHTML = str => parser.parseFromString(str, 'application/xhtml+xml')
'/4[body01]/10[para05]/2/1:0',
'/4[body01]/10[para05]/2/1:3',
]) {
const range = CFI.toRange(page, CFI.parse(cfi))
const a = CFI.fromRange(range)
const range = CFI.toRange(page, CFI.parse(cfi), filter)
const a = CFI.fromRange(range, filter)
const b = `epubcfi(${cfi})`
console.assert(a === b, `expected ${b}, got ${a}`)
}
for (let i = 0; i < 10; i++) {
const cfi = `/4/10,/3:${i},/3:${i+1}`
const range = CFI.toRange(page, CFI.parse(cfi))
const range = CFI.toRange(page, CFI.parse(cfi), filter)
const n = `${i}`
console.assert(range.toString() === n, `expected ${n}, got ${range}`)
}
}
test(page)
test(page2)
test(page, filter)
test(page2, filter)
test(page3, filter)
}
{