mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
CFI: add ability to ignore nodes
Also test that comment nodes are ignore
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
+43
-11
@@ -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,7 +81,7 @@ 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>
|
||||
@@ -80,7 +89,7 @@ const XHTML = str => parser.parseFromString(str, 'application/xhtml+xml')
|
||||
</head>
|
||||
<body id="body01">
|
||||
<p>…</p><p>…</p><p>…</p><p>…</p>
|
||||
<p id="para05">xxx<em>yyy</em><![CDATA[0123]]>45<![CDATA[67]]>89</p>
|
||||
<p id="para05">xxx<em>yyy</em><![CDATA[]]><!--comment1--><![CDATA[0123]]>4<!--comment2-->5<![CDATA[67]]>89</p>
|
||||
<p>…</p>
|
||||
<p>…</p>
|
||||
<img id="svgimg" src="foo.svg" alt="…"/>
|
||||
@@ -89,13 +98,32 @@ const XHTML = str => parser.parseFromString(str, 'application/xhtml+xml')
|
||||
</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)
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user