diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 4c94e29..bcec0e7 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -413,6 +413,7 @@ document.addEventListener("alpine:init", () => { color: string; cfi: string; cfiEnd: string; + renderCfi: string; percentage: number; pdfPage: number; pdfRects: number[][]; @@ -1122,7 +1123,7 @@ document.addEventListener("alpine:init", () => { } else { this.view ?.addAnnotation({ - value: hl.cfi, + value: hl.renderCfi || hl.cfi, color: hl.color, note: hl.note, id: hl.id, @@ -1154,18 +1155,68 @@ document.addEventListener("alpine:init", () => { /* not ours; leave as-is */ } } + const cfiEnd = r.epubcfi_end ?? ""; return { id: r.id, text: r.selection_text ?? "", note: r.note_text ?? "", color: r.color ?? "#ffff00", cfi, - cfiEnd: r.epubcfi_end ?? "", + cfiEnd, + // Rendering/navigating anchor: device-synced highlights store + // POINT CFIs (epubcfi(/6/N!/4/2[id]/8/1:1)), which resolve to a + // collapsed range and paint nothing. Foliate's overlayer needs a + // RANGE CFI — same shape getCFI() produces natively + // (epubcfi(/6/N!/4/2[id],/8/1:1,/8/1:67)) — synthesized here from + // the stored start and end points when both share a base path. + renderCfi: this.toRangeCfi(cfi, cfiEnd, r.selection_text ?? ""), percentage: r.percentage_start ?? 0, pdfPage, pdfRects, }; }, + // Build a foliate-renderable RANGE CFI from stored (possibly point) + // CFIs. Repairs two stale shapes using the selection text: a missing + // end (old web highlights), and a degenerate end — the device-push + // 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. + 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 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]; + } + } + 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})`; + }, async refreshAnnotations() { const token = getToken(); if (!token || !this.mediaItemId) return; @@ -1245,7 +1296,10 @@ document.addEventListener("alpine:init", () => { } } else { this.view?.addAnnotation({ - value: p.cfi, + value: + p.pdfPage >= 0 + ? "" + : this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, color, note: "", id: row.id, @@ -1298,7 +1352,7 @@ document.addEventListener("alpine:init", () => { }); } else { this.view?.addAnnotation({ - value: p.cfi, + value: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, color: p.color, note: p.note, id: p.id, @@ -1340,6 +1394,7 @@ document.addEventListener("alpine:init", () => { }, goToHighlight(hl: { cfi: string; + renderCfi: string; pdfPage: number; }) { if (hl.pdfPage >= 0) { @@ -1347,9 +1402,11 @@ document.addEventListener("alpine:init", () => { this.pushBackStack(); this.view?.goTo?.(hl.pdfPage); this.closeDrawers(); - } else if (hl.cfi) { + } else if (hl.renderCfi || hl.cfi) { this.pushBackStack(); - this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {}); + this.view + ?.showAnnotation({ value: hl.renderCfi || hl.cfi }) + ?.catch?.(() => {}); this.closeDrawers(); } },