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
// 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
// CFI offsets are UTF-16 code units); multi-node selections just fail
// resolution harmlessly and fall back to the point CFI.
// CFI offsets are UTF-16 code units).
//
// 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 {
if (!start) return end || start;
if (start.includes(",")) return start; // already a range CFI
const re =
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/;
const ms = re.exec(start);
if (!ms) return start;
const base = ms[1];
const startLocal = ms[2];
const startOff = ms[3] ? parseInt(ms[3], 10) : -1;
const spineRe =
/^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/;
const parseCfi = (cfi: string) => {
const m = spineRe.exec(cfi);
if (!m) return null;
const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map(
(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(
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
0,
);
let endLocal = "";
if (end && !end.includes(",")) {
const me = re.exec(end);
if (me && me[1] === base) {
const endOff = me[3] ? parseInt(me[3], 10) : -1;
// Degenerate: end resolves to the document start (the old
// converter fallback) or sits before the start offset.
const degenerate =
endOff === 0 ||
(startOff >= 0 && endOff >= 0 && endOff < startOff);
if (!degenerate) endLocal = me[2];
// Range with the parent one step short of the terminal, so both
// subpaths keep at least their terminal step; offsets ride on the
// subpath terminators.
const rem = (steps: string[], off: number) =>
off >= 0 ? `${steps.join("")}:${off}` : steps.join("");
const advanceEnd = () => {
if (s.off < 0 || utf16len <= 0) return null;
const last = s.steps[s.steps.length - 1];
const parent = s.spine + s.steps.slice(0, -1).join("");
return `${parent},${rem([last], s.off)},${rem([last], s.off + utf16len)})`;
};
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) {
if (startOff < 0 || utf16len <= 0) return start; // point CFI
const cut = startLocal.lastIndexOf(":");
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`;
}
return `${base},${startLocal},${endLocal})`;
const parent = s.spine + s.steps.slice(0, common).join("");
return `${parent},${rem(s.steps.slice(common), s.off)},${rem(
e.steps.slice(common),
e.off,
)})`;
},
async refreshAnnotations() {
const token = getToken();