fix(web): PDF adjust text recompute — overlap-based glyph dedupe

User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.

Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.

The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.

Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
This commit is contained in:
John O'Keefe committed 2026-10-10 14:25:48 -04:00
1 parent 8acaeddda5
commit dbeefb395c
1 file changed
+14 -8
+14 -8
View File
@@ -1634,9 +1634,12 @@ document.addEventListener("alpine:init", () => {
[]; [];
// pdf.js re-renders (zoom settle, scale updates) append a second // pdf.js re-renders (zoom settle, scale updates) append a second
// span set into the same .textLayer without clearing — char boxes // span set into the same .textLayer without clearing — char boxes
// come back duplicated at identical positions. Dedupe by box key so // come back duplicated, and the copies can sit SUB-PIXEL SHIFTED
// snap tables and recomputed text see each glyph once. // from the originals (a doubled-letter text recompute once slipped
const seen = new Set<string>(); // through an exact-position key). Dedupe by overlap instead: a
// candidate is a duplicate when a kept glyph on the same line
// overlaps more than half the narrower glyph. Distinct glyphs
// (including tight pairs like "il") never reach that.
const range = doc.createRange(); const range = doc.createRange();
const spans = doc.querySelectorAll(".textLayer span"); const spans = doc.querySelectorAll(".textLayer span");
spans.forEach((span) => { spans.forEach((span) => {
@@ -1659,11 +1662,14 @@ document.addEventListener("alpine:init", () => {
const y1 = (r.bottom - dr.top) / dr.height; const y1 = (r.bottom - dr.top) / dr.height;
const cy = (y0 + y1) / 2; const cy = (y0 + y1) / 2;
if (cy < bandTop || cy > bandBottom) continue; if (cy < bandTop || cy > bandBottom) continue;
// 3 decimals ≈ sub-pixel tolerance: distinct glyphs are never const w = x1 - x0;
// this close, but re-render copies jitter less than this. const dup = out.some((k) => {
const key = `${x0.toFixed(3)}:${y0.toFixed(3)}`; if (Math.abs(k.y0 - y0) > (k.y1 - k.y0) * 0.5) return false;
if (seen.has(key)) continue; const kw = k.x1 - k.x0;
seen.add(key); const overlap = Math.min(k.x1, x1) - Math.max(k.x0, x0);
return overlap > Math.min(kw, w) * 0.5;
});
if (dup) continue;
out.push({ x0, x1, y0, y1, ch: text[i] }); out.push({ x0, x1, y0, y1, ch: text[i] });
} }
}); });