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:
1 parent
bb3aef517c
commit
93a468b435
1 file changed
+71
-26
+71
-26
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user