diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index a1eb3185..4cc4965c 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -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();