fix(reader): emit two-subpath range CFIs for multi-node highlights

toRangeCfi required stored start and end CFIs to share a base path and
otherwise fell back to a bare point CFI, which resolves to a collapsed
range and paints nothing - or painted from the wrong anchor after end
repair. A legitimate selection crossing inline elements anchors at two
different text nodes by definition, so requiring a shared base path
discarded correct endpoints.

Build the range parent from the endpoints' longest shared step prefix
(down to the bare spine step when needed) and emit each divergent
remainder per the EPUB CFI range grammar, epubcfi(parent,start,end).
Degenerate-end repair (missing end, or end before start) now only
applies when both sides terminate on the SAME node - equal remaining
step count with different step values is a valid two-node range, not a
degenerate pair; document order is verified for those instead.

Verified with foliate's own parser against the real chapter XHTML:
ranges resolve to exactly the selected text for same-node, two-node,
and repaired-degenerate cases.
This commit is contained in:
John O'Keefe committed 2026-10-12 20:45:41 -04:00
1 parent bb3aef517c
commit 93a468b435
1 file changed
+71 -26
+71 -26
View File
@@ -2382,41 +2382,86 @@ document.addEventListener("alpine:init", () => {
// converter used to fall back to a document-start CFI when the end // converter used to fall back to a document-start CFI when the end
// xpointer didn't resolve exactly. In both cases the end is derived // xpointer didn't resolve exactly. In both cases the end is derived
// from the start offset advanced by the text's UTF-16 length (EPUB // from the start offset advanced by the text's UTF-16 length (EPUB
// CFI offsets are UTF-16 code units); multi-node selections just fail // CFI offsets are UTF-16 code units).
// resolution harmlessly and fall back to the point CFI. //
// The endpoints may legitimately anchor at different nodes (a
// selection crossing inline spans lands in two text nodes), so the
// range parent is the endpoints' longest shared step prefix — down to
// the bare spine step if needed — and each side emits its divergent
// remainder, per the EPUB CFI range grammar
// epubcfi(parent,start,end).
toRangeCfi(start: string, end: string, text: string): string { toRangeCfi(start: string, end: string, text: string): string {
if (!start) return end || start; if (!start) return end || start;
if (start.includes(",")) return start; // already a range CFI if (start.includes(",")) return start; // already a range CFI
const re = const spineRe =
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/; /^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/;
const ms = re.exec(start); const parseCfi = (cfi: string) => {
if (!ms) return start; const m = spineRe.exec(cfi);
const base = ms[1]; if (!m) return null;
const startLocal = ms[2]; const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map(
const startOff = ms[3] ? parseInt(ms[3], 10) : -1; (s) => s,
);
return { spine: m[1], steps, off: m[3] ? parseInt(m[3], 10) : -1 };
};
const s = parseCfi(start);
if (!s || s.steps.length === 0) return start;
const utf16len = [...(text ?? "")].reduce( const utf16len = [...(text ?? "")].reduce(
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1), (n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
0, 0,
); );
let endLocal = ""; // Range with the parent one step short of the terminal, so both
if (end && !end.includes(",")) { // subpaths keep at least their terminal step; offsets ride on the
const me = re.exec(end); // subpath terminators.
if (me && me[1] === base) { const rem = (steps: string[], off: number) =>
const endOff = me[3] ? parseInt(me[3], 10) : -1; off >= 0 ? `${steps.join("")}:${off}` : steps.join("");
// Degenerate: end resolves to the document start (the old const advanceEnd = () => {
// converter fallback) or sits before the start offset. if (s.off < 0 || utf16len <= 0) return null;
const degenerate = const last = s.steps[s.steps.length - 1];
endOff === 0 || const parent = s.spine + s.steps.slice(0, -1).join("");
(startOff >= 0 && endOff >= 0 && endOff < startOff); return `${parent},${rem([last], s.off)},${rem([last], s.off + utf16len)})`;
if (!degenerate) endLocal = me[2]; };
const e = end && !end.includes(",") ? parseCfi(end) : null;
if (!e || e.spine !== s.spine || e.steps.length === 0) {
const adv = advanceEnd();
return adv ?? start; // point CFI
}
let common = 0;
const maxCommon = Math.min(s.steps.length, e.steps.length);
while (common < maxCommon && s.steps[common] === e.steps[common]) {
common++;
}
if (common === maxCommon) common = maxCommon - 1;
// Same terminal node only when the single remaining steps are
// EQUAL — one step per side with different step values is a
// legitimate two-node range. Degenerate ends (document-start
// fallback of the old converter, or an end before the start) are
// repaired via the UTF-16 advance.
if (
common === s.steps.length - 1 &&
common === e.steps.length - 1 &&
s.steps[common] === e.steps[common]
) {
const degenerate =
e.off === 0 || (s.off >= 0 && e.off >= 0 && e.off < s.off);
if (degenerate) {
const adv = advanceEnd();
if (adv) return adv;
}
} else {
// Different terminal nodes: require end after start in document
// order; otherwise repair.
const ai = parseInt(s.steps[common].slice(1), 10);
const bi = parseInt(e.steps[common].slice(1), 10);
if (bi < ai) {
const adv = advanceEnd();
return adv ?? start;
} }
} }
if (!endLocal) { const parent = s.spine + s.steps.slice(0, common).join("");
if (startOff < 0 || utf16len <= 0) return start; // point CFI return `${parent},${rem(s.steps.slice(common), s.off)},${rem(
const cut = startLocal.lastIndexOf(":"); e.steps.slice(common),
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`; e.off,
} )})`;
return `${base},${startLocal},${endLocal})`;
}, },
async refreshAnnotations() { async refreshAnnotations() {
const token = getToken(); const token = getToken();