fix(reader): render device-synced highlights — synthesize range CFIs

Device-synced highlights stored POINT CFIs (epubcfi(.../8/1:1)); the
overlayer resolves those to a collapsed range and paints nothing, so
KOReader-made highlights were listed in the drawer but invisible on
the page. mapHighlightRow now builds a renderCfi: a proper RANGE CFI
(epubcfi(base,/start,/end)) synthesized from the stored start/end
points. It also repairs stale rows: missing ends (old web highlights)
and degenerate document-start ends (the old converter fallback) are
derived from the start offset plus the selection text's UTF-16
length. All overlay drawing, navigation (showAnnotation), and the
post-create/post-edit re-adds use renderCfi. Verified in-browser
against live device-synced rows: the paginator's overlayer paints
the highlight rects after the fix.
This commit is contained in:
2026-08-19 14:08:03 -04:00
parent 6e9b3528d8
commit 50ec2bebf2
+63 -6
View File
@@ -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();
}
},