From cea937556145e116d1ddedb3b8ef32450f08d793 Mon Sep 17 00:00:00 2001 From: John Factotum <50942278+johnfactotum@users.noreply.github.com> Date: Fri, 29 Sep 2023 23:52:56 +0800 Subject: [PATCH] CFI: add ability to ignore nodes Also test that comment nodes are ignore --- README.md | 14 ++++++++- epubcfi.js | 46 +++++++++++++++++----------- tests/epubcfi-tests.js | 68 +++++++++++++++++++++++++++++++----------- 3 files changed, 91 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7f50de6..8b12cde 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/epubcfi.js b/epubcfi.js index 77ea1b9..4c0200c 100644 --- a/epubcfi.js +++ b/epubcfi.js @@ -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() diff --git a/tests/epubcfi-tests.js b/tests/epubcfi-tests.js index 7b79307..ad322b6 100644 --- a/tests/epubcfi-tests.js +++ b/tests/epubcfi-tests.js @@ -53,6 +53,15 @@ const XHTML = str => parser.parseFromString(str, 'application/xhtml+xml') `) + 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(`
…
…
…
…
-xxxyyy4589
-…
-…
-…
-…
+…
…
…
…
+xxxyyy4589
+…
+…
+…
+…
`) - 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(` + +Also ignored
+…
…
…
…
+xxxyyyNote: 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.0123456789
…
+…
+…
+…
+