From 1c209408cc222f8112d46859093df619ea85e12d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 11 Oct 2026 13:01:16 -0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20PDF=20adjust=20=E2=80=94=20three=20?= =?UTF-8?q?compounding=20coordinate/table=20bugs,=20found=20by=20instrumen?= =?UTF-8?q?ted=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report (desktop Brave, fresh private window): the endpoint handle "jumped several lines down and stuck". The instrumented drag exposed the chain: 1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the dot's pointermove listener passed raw e.clientX/clientY PIXELS. Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space picks the bottom-right-most glyph — the extent leaped to the page's end and never moved again (stuck). Engine-independent: failed identically on Chrome/Brave/Firefox desktops; my synthetic tests masked it by passing pre-converted fractions. handleMove now maps host px → fractions internally (one contract). 2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas rect with the host iframe rect. Whether that combination is valid depends on the engine's cross-document getBoundingClientRect semantics. Replaced with the page WRAPPER element (the iframe's parent — the same box the fork's rect-annotation overlay fills), measured in a single host context. Handles now align with the painted highlights by construction. 3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line gap tall). Dedupe at a full box height ate the line-below char in the same x-column (silent character loss); clustering at full box height never split lines (merged 0.45-tall "line" boxes). Dedupe now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of the char's own height (inter-line gap ≈ 1.0× breaks, baseline variance ≈ 0.1× stays). Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117) → span extends line 1 → line 2, 2 per-line rects saved, clean multi-line text. Direct-fraction probes and the pointer path now agree on the same load. --- web/src/reader/reader.ts | 421 +++++++++++++++++++++------------------ 1 file changed, 222 insertions(+), 199 deletions(-) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index ba2702f6..0b72449d 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -1553,7 +1553,7 @@ document.addEventListener("alpine:init", () => { } this.adjustContext = null; }, - // ----- PDF highlight extent adjustment (rect-edge, v2: endpoints) ----- + // ----- PDF highlight extent adjustment (v2: endpoint handles) ----- /** Format router for the popover's Adjust button: PDF highlights * adjust with two endpoint handles, reflowable ones with range * handles. */ @@ -1561,12 +1561,17 @@ document.addEventListener("alpine:init", () => { if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust(); else this.startAdjust(); }, - /** Page→host mapping for a fixed-layout index. The canvas's - * post-transform rect IS the visible page (documentElement is dpr× - * too small — pdf.js scales by 1/devicePixelRatio); iframe - * client px map to host px by the frame's own rect ratios (the - * fork's convertPoint math). Computed fresh per call so zoom/pan - * mid-session never works from stale geometry. */ + /** Page→host mapping for a fixed-layout index. Basis: the page's + * WRAPPER element (the iframe's parent — the same box the fork's + * own rect-annotation overlay fills with percentage-positioned + * children). Measuring it is a single same-context host measurement: + * the previous version combined the iframe-internal canvas rect + * with the host iframe rect, and whether that mix is valid depends + * on which coordinate space a given engine returns for + * cross-document getBoundingClientRect — on desktop Brave it + * mismatched, so drags re-anchored "several lines down" and stuck. + * The wrapper is also what the highlights are painted against, so + * handle positions align with the paint by construction. */ pdfPageMapping(index: number) { // The fixed-layout renderer's getContents() carries no index — map // through the load-event registry, keeping only live frames. @@ -1578,19 +1583,14 @@ document.addEventListener("alpine:init", () => { d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement, ); if (!doc) return null; - const canvas = - (doc.querySelector("#canvas canvas") as HTMLElement) || - (doc.querySelector("img") as HTMLElement); - const dr = canvas?.getBoundingClientRect(); const frame = doc.defaultView?.frameElement as HTMLElement | null; - const fr = frame?.getBoundingClientRect(); - if (!dr || !dr.width || !dr.height || !fr) return null; - const scaleX = fr.width / (doc.documentElement.clientWidth || 1); - const scaleY = fr.height / (doc.documentElement.clientHeight || 1); - const left = fr.left + dr.left * scaleX; - const top = fr.top + dr.top * scaleY; - const width = dr.width * scaleX; - const height = dr.height * scaleY; + const wrapper = frame?.parentElement; + const wr = wrapper?.getBoundingClientRect(); + if (!wr || !wr.width || !wr.height) return null; + const left = wr.left; + const top = wr.top; + const width = wr.width; + const height = wr.height; return { doc: doc as Document, toHost(fx: number, fy: number) { @@ -1602,23 +1602,23 @@ document.addEventListener("alpine:init", () => { fy: (clientY - top) / height, }; }, - /** Doc-local px for caretRangeFromPoint probes. */ - toDoc(fx: number, fy: number) { - return { x: dr.left + fx * dr.width, y: dr.top + fy * dr.height }; - }, }; }, - /** Character glyph boxes (page fractions) whose center sits inside a - * y-band of the text layer, reading order. One Range per character - * over each span's text node — caret-granularity snap targets, the - * same vocabulary the reflowable adjust snaps with. Spans outside - * the band are prefiltered by their own box to keep the per-char - * range work line-sized. */ - pdfCharBoxes( - doc: Document, - bandTop: number, - bandBottom: number, - ): { x0: number; x1: number; y0: number; y1: number; ch: string }[] { + /** Character glyph boxes (page fractions) in reading order, with + * dedupe: pdf.js re-renders (zoom settle, scale updates) append a + * second span set into the same .textLayer without clearing — char + * boxes come back duplicated, and the copies can sit SUB-PIXEL + * SHIFTED (a doubled-letter text recompute once slipped 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 never reach that. */ + pdfCharBoxes(doc: Document): { + x0: number; + x1: number; + y0: number; + y1: number; + ch: string; + }[] { const canvas = (doc.querySelector("#canvas canvas") as HTMLElement) || (doc.querySelector("img") as HTMLElement); @@ -1626,21 +1626,9 @@ document.addEventListener("alpine:init", () => { if (!dr || !dr.width || !dr.height) return []; const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] = []; - // pdf.js re-renders (zoom settle, scale updates) append a second - // span set into the same .textLayer without clearing — char boxes - // come back duplicated, and the copies can sit SUB-PIXEL SHIFTED - // from the originals (a doubled-letter text recompute once slipped - // 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 spans = doc.querySelectorAll(".textLayer span"); spans.forEach((span) => { - const sr = span.getBoundingClientRect(); - const sTop = (sr.top - dr.top) / dr.height; - const sBottom = (sr.bottom - dr.top) / dr.height; - if (sBottom < bandTop || sTop > bandBottom) return; const node = span.firstChild; if (!node || node.nodeType !== Node.TEXT_NODE) return; const text = node.nodeValue ?? ""; @@ -1650,61 +1638,84 @@ document.addEventListener("alpine:init", () => { range.setEnd(node, i + 1); const r = range.getBoundingClientRect(); if (!r.width && !r.height) continue; - const x0 = (r.left - dr.left) / dr.width; - const x1 = (r.right - dr.left) / dr.width; - const y0 = (r.top - dr.top) / dr.height; - const y1 = (r.bottom - dr.top) / dr.height; - const cy = (y0 + y1) / 2; - if (cy < bandTop || cy > bandBottom) continue; - const w = x1 - x0; + const box = { + x0: (r.left - dr.left) / dr.width, + x1: (r.right - dr.left) / dr.width, + y0: (r.top - dr.top) / dr.height, + y1: (r.bottom - dr.top) / dr.height, + ch: text[i], + }; + if (box.x1 <= 0 || box.x0 >= 1 || box.y1 <= 0 || box.y0 >= 1) continue; + const w = box.x1 - box.x0; + const h = box.y1 - box.y0; const dup = out.some((k) => { - if (Math.abs(k.y0 - y0) > (k.y1 - k.y0) * 0.5) return false; + // A duplicate is the SAME glyph re-rendered at a drifted + // position: y within 0.75 of the SHORTER box (per-char boxes + // are LINE-HEIGHT boxes — a full-height tolerance let a + // line-1 char eat the line-2 char beneath it in the same + // x-column), x overlapping more than half the narrower box. + // The staircase of offset re-render copies steps by ~0.004 — + // well inside this tolerance; real inter-line gaps (~0.0196) + // are outside it. + const kh = k.y1 - k.y0; + const tolY = Math.min(kh, h) * 0.75; + if (Math.abs(k.y0 - box.y0) > tolY) return false; const kw = k.x1 - k.x0; - const overlap = Math.min(k.x1, x1) - Math.max(k.x0, x0); + const overlap = Math.min(k.x1, box.x1) - Math.max(k.x0, box.x0); return overlap > Math.min(kw, w) * 0.5; }); if (dup) continue; - out.push({ x0, x1, y0, y1, ch: text[i] }); + out.push(box); } }); out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0); return out; }, /** Enter PDF rect-edge adjust mode from the edit popover: hide it, - * reconstruct the extent's range from the stored rects via caret - * probes, put the two endpoint handles up, and arm the cancel - * paths. */ + * build the page's character table (once — every drag reads the + * cached table in pure fraction space; no DOM reads, no + * coordinate-space hazards — portable to any browser engine), map + * the stored rects to a word... char range, put the two endpoint + * handles up, and arm the cancel paths. */ startPdfAdjust() { const p = this.selectionPopover; if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return; const map = this.pdfPageMapping(p.pdfPage); if (!map) return; - const rects = p.pdfRects; - const first = rects[0]; - const last = rects[rects.length - 1]; - // Reconstruct the extent's range: carets just inside the first - // rect's left-middle and the last rect's right-middle. - const startPt = map.toDoc(first[0] + 0.002, first[1] + first[3] / 2); - const endPt = map.toDoc( - last[0] + last[2] - 0.002, - last[1] + last[3] / 2, - ); - const doc = map.doc; - const sc = doc.caretRangeFromPoint?.(startPt.x, startPt.y) ?? null; - const ec = doc.caretRangeFromPoint?.(endPt.x, endPt.y) ?? null; - if (!sc || !ec) return; - const range = doc.createRange(); - range.setStart(sc.startContainer, sc.startOffset); - range.setEnd(ec.startContainer, ec.startOffset); - if (range.collapsed) return; + const chars = this.pdfCharBoxes(map.doc); + if (!chars.length) return; + // The extent's char range: chars whose centers sit inside the + // stored rects (creation- and adjust-made extents both cover their + // own glyphs). + let startI = -1; + let endI = -1; + chars.forEach((c, i) => { + const cx = (c.x0 + c.x1) / 2; + const cy = (c.y0 + c.y1) / 2; + if ( + p.pdfRects.some( + (r) => + cx >= r[0] && + cx <= r[0] + r[2] && + cy >= r[1] && + cy <= r[1] + r[3], + ) + ) { + if (startI < 0) startI = i; + endI = i; + } + }); + if (startI < 0) return; this.pdfAdjust = { id: p.id, index: p.pdfPage, page: p.pdfPage, color: p.color, - rects, + rects: p.pdfRects.map((r) => [...r]), original: p.pdfRects.map((r) => [...r]), - range, + chars, + startI, + endI, committed: false, }; this.hideSelectionPopover(); @@ -1722,8 +1733,10 @@ document.addEventListener("alpine:init", () => { this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom); }, /** (Re)draw the two endpoint handles, positioned through the live - * page mapping. Reuses the elements so drag-capture survives a - * repaint. */ + * page mapping. The dots are created ONCE per session — every later + * call only repositions the existing elements (an innerHTML rebuild + * mid-drag destroys the dot being dragged; it loses pointer capture + * and the drag dies silently — user report). */ renderPdfAdjustHandles() { const ctx = this.pdfAdjust; if (!ctx) return; @@ -1731,7 +1744,6 @@ document.addEventListener("alpine:init", () => { if (!layer) return; const map = this.pdfPageMapping(ctx.index); if (!map) return; - layer.innerHTML = ""; const first = ctx.rects[0]; const last = ctx.rects[ctx.rects.length - 1]; if (!first || !last) return; @@ -1742,10 +1754,6 @@ document.addEventListener("alpine:init", () => { { which: "end", fx: last[0] + last[2], fy: last[1] + last[3] / 2 }, ]; for (const pt of pts) { - // Create each dot ONCE per session; every later call only - // REPOSITIONS the existing element. An innerHTML rebuild here - // destroys the dot mid-drag — it loses pointer capture and the - // drag dies silently ("handles not moving", user report). let dot = layer.querySelector( `.adjust-handle[data-which="${pt.which}"]`, ); @@ -1777,7 +1785,6 @@ document.addEventListener("alpine:init", () => { this.pdfAdjustRelease(); }); dot.addEventListener("pointercancel", () => { - if (!dot!.dataset.dragging) return; delete dot!.dataset.dragging; }); layer.appendChild(dot); @@ -1787,10 +1794,49 @@ document.addEventListener("alpine:init", () => { dot.style.top = `${host.y - 13}px`; } }, - /** Endpoint drag: the finger (host px) re-anchors the grabbed end at - * the caret under it — character-precise (a trailing period or - * line-break hyphen can be taken or shed), crossing lines freely — - * and the rects rebuild from the range's own line boxes. */ + /** Line boxes (page fractions) of a contiguous char-table span: + * cluster the glyphs by center-y. Per-char boxes are LINE-HEIGHT + * boxes (~one line gap tall), so the break threshold must sit + * clearly below the inter-line gap yet above intra-line baseline + * variance — 0.6 of the char's own height does that (gap ≈ 1.0 × + * height breaks; variance ≈ 0.1 × height stays). */ + charSpanRects( + chars: { x0: number; x1: number; y0: number; y1: number }[], + ): number[][] { + const rects: number[][] = []; + let line: typeof chars = []; + let lastCy = -9; + for (const c of chars) { + const cy = (c.y0 + c.y1) / 2; + const tol = (c.y1 - c.y0) * 0.6; + if (line.length && Math.abs(cy - lastCy) > tol) { + const x0 = Math.min(...line.map((w) => w.x0)); + const y0 = Math.min(...line.map((w) => w.y0)); + const x1 = Math.max(...line.map((w) => w.x1)); + const y1 = Math.max(...line.map((w) => w.y1)); + rects.push([x0, y0, x1 - x0, y1 - y0]); + line = []; + } + line.push(c); + lastCy = cy; + } + if (line.length) { + const x0 = Math.min(...line.map((w) => w.x0)); + const y0 = Math.min(...line.map((w) => w.y0)); + const x1 = Math.max(...line.map((w) => w.x1)); + const y1 = Math.max(...line.map((w) => w.y1)); + rects.push([x0, y0, x1 - x0, y1 - y0]); + } + return rects; + }, + /** Endpoint drag: the finger (HOST px) maps to page fractions via + * the live mapping, then re-anchors the grabbed end at the nearest + * glyph in the cached char table — character-precise (a trailing + * period or line-break hyphen can be taken or shed), crossing + * lines freely — and the rects rebuild from the new char span as + * per-line unions. Pure fraction space after the mapping: no + * caretRangeFromPoint, no coordinate-space conversion, works on + * any browser engine (Firefox included). */ pdfAdjustHandleMove( which: "start" | "end", clientX: number, @@ -1801,68 +1847,29 @@ document.addEventListener("alpine:init", () => { const map = this.pdfPageMapping(ctx.index); if (!map) return; const { fx, fy } = map.toFraction(clientX, clientY); - const pt = map.toDoc(fx, fy); - const caret = map.doc.caretRangeFromPoint?.(pt.x, pt.y) ?? null; - if (!caret) return; - const working = map.doc.createRange(); - try { - if (which === "start") { - working.setStart(caret.startContainer, caret.startOffset); - working.setEnd(ctx.range.endContainer, ctx.range.endOffset); - } else { - working.setStart(ctx.range.startContainer, ctx.range.startOffset); - working.setEnd(caret.startContainer, caret.startOffset); - } - } catch { - return; - } - // Handles cannot cross: clamp the moving end at the opposite - // boundary. - if (working.collapsed) { - try { - if (which === "start") - working.setStart(ctx.range.endContainer, ctx.range.endOffset); - else working.setEnd(ctx.range.startContainer, ctx.range.startOffset); - } catch { - return; + const chars = ctx.chars; + if (!chars.length) return; + let best = 0; + let bestD = Infinity; + for (let i = 0; i < chars.length; i++) { + const cx = (chars[i].x0 + chars[i].x1) / 2; + const cy = (chars[i].y0 + chars[i].y1) / 2; + const d = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy); + if (d < bestD) { + bestD = d; + best = i; } } - ctx.range = working; - ctx.rects = this.rectsForRange(map.doc, working); + let s = ctx.startI; + let e = ctx.endI; + if (which === "start") s = Math.min(best, e); + else e = Math.max(best, s); + if (s > e) return; + ctx.startI = s; + ctx.endI = e; + ctx.rects = this.charSpanRects(chars.slice(s, e + 1)); this.schedulePdfAdjustPreview(); }, - /** Line boxes of a range (page fractions), duplicate copy boxes - * collapsed: pdf.js re-render span sets duplicate glyphs at - * sub-pixel shifts, so a naive getClientRects sweep returns the - * same line twice — harmless to paint, noise in the payload. */ - rectsForRange(doc: Document, range: Range): number[][] { - const canvas = - (doc.querySelector("#canvas canvas") as HTMLElement) || - (doc.querySelector("img") as HTMLElement); - const dr = canvas?.getBoundingClientRect(); - if (!dr || !dr.width || !dr.height) return []; - const out: number[][] = []; - for (const r of Array.from(range.getClientRects())) { - const box = [ - (r.left - dr.left) / dr.width, - (r.top - dr.top) / dr.height, - r.width / dr.width, - r.height / dr.height, - ]; - if (box[2] <= 0 || box[3] <= 0) continue; - const cx = box[0] + box[2] / 2; - const cy = box[1] + box[3] / 2; - const dup = out.some( - (k) => - cx >= k[0] - 0.003 && - cx <= k[0] + k[2] + 0.003 && - cy >= k[1] - 0.003 && - cy <= k[1] + k[3] + 0.003, - ); - if (!dup) out.push(box); - } - return out; - }, /** Preview scheduling: coalesce pointermove bursts into one overlay * repaint per frame. The preview replaces the highlight's OWN * overlay entry (addRectAnnotation is replace-by-key), so there is @@ -1882,48 +1889,13 @@ document.addEventListener("alpine:init", () => { this.renderPdfAdjustHandles(); }); }, - /** Release: the range IS the extent — serialize its rects, recompute - * the selection text from the deduped glyph boxes, and save through - * the popover's own PUT path. */ + /** Release: the extent's chars ARE the text — join them (line gaps + * re-insert spaces, matching the creation path's normalized + * selection text) and save through the popover's own PUT path. */ async pdfAdjustRelease() { const ctx = this.pdfAdjust; if (!ctx) return; - const map = this.pdfPageMapping(ctx.index); - const text = map ? this.pdfTextForRects(map.doc, ctx.rects) : ""; - const { id, page, rects } = ctx; - ctx.committed = true; - this.cancelPdfAdjust(); - if (!text) { - showToast("Highlights can't be empty", "error"); - return; - } - // Route through the popover state: saveHighlightChanges serializes - // the anchor from p.pdfRects, PUTs, and re-renders the overlay. - const p = this.selectionPopover; - p.id = id; - p.pdfPage = page; - p.pdfRects = rects; - p.text = text; - await this.saveHighlightChanges(); - }, - /** Reading-order text of the characters whose glyph boxes sit inside - * any working rect — the adjusted extent's selection text. Lines join - * with a space and in-line word gaps re-insert the space, matching - * the creation path's normalized selection text. */ - pdfTextForRects(doc: Document, rects: number[][]) { - const top = Math.min(...rects.map((r) => r[1])); - const bottom = Math.max(...rects.map((r) => r[1] + r[3])); - const chars = this.pdfCharBoxes(doc, top, bottom).filter((c) => { - const cy = (c.y0 + c.y1) / 2; - const cx = (c.x0 + c.x1) / 2; - return rects.some( - (r) => - cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3], - ); - }); - if (!chars.length) return ""; - const widths = chars.map((c) => c.x1 - c.x0).sort((a, b) => a - b); - const medW = widths[widths.length >> 1] || 0.001; + const chars = ctx.chars.slice(ctx.startI, ctx.endI + 1); let text = ""; let prev: (typeof chars)[number] | null = null; for (const c of chars) { @@ -1932,12 +1904,26 @@ document.addEventListener("alpine:init", () => { const cc = (c.y0 + c.y1) / 2; const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4; if (Math.abs(cc - pc) > half) text += " "; - else if (c.x0 - prev.x1 > medW * 0.3) text += " "; + else if (c.x0 - prev.x1 > 0.004) text += " "; } text += c.ch; prev = c; } - return text.replace(/\s+/g, " ").trim(); + text = text.replace(/\s+/g, " ").trim(); + const rects = ctx.rects; + const { id, page } = ctx; + ctx.committed = true; + this.cancelPdfAdjust(); + if (!text) { + showToast("Highlights can't be empty", "error"); + return; + } + const p = this.selectionPopover; + p.id = id; + p.pdfPage = page; + p.pdfRects = rects; + p.text = text; + await this.saveHighlightChanges(); }, /** Leave rect adjust: restore the stored paint unless the adjustment * was committed (cancel runs after finalize too), and drop the mode. */ @@ -2069,36 +2055,73 @@ document.addEventListener("alpine:init", () => { if (!text) return; // Adjust re-selection (v2): a selection made during a rect-edge // session REPLACES the working extent — any lines, partial lines - // (the exact gap the grips could not express). Rects rebuild from - // the selection's line boxes, text re-reads, saved immediately: - // the grips' release-to-save parity. + // (the exact gap the handles alone could not express from stale + // geometry). Chars inside the selection's line boxes become the + // new extent; text reads from them; saved immediately: the + // handles' release-to-save parity. if (this.pdfAdjust) { const canvasRect = (doc.querySelector("#canvas canvas") as HTMLElement) || (doc.querySelector("img") as HTMLElement); const dr = canvasRect?.getBoundingClientRect(); if (!dr || !dr.width || !dr.height) return; - const rects: number[][] = []; + const selRects: number[][] = []; for (const r of range.getClientRects()) { const x = (r.left - dr.left) / dr.width; const y = (r.top - dr.top) / dr.height; const w = r.width / dr.width; const h = r.height / dr.height; - if (w > 0 && h > 0) rects.push([x, y, w, h]); + if (w > 0 && h > 0) selRects.push([x, y, w, h]); } - if (!rects.length) return; - const t = this.pdfTextForRects(doc, rects); - if (!t) { + if (!selRects.length) return; + const chars = this.pdfAdjust.chars; + const picked: number[] = []; + chars.forEach((c, i) => { + const cx = (c.x0 + c.x1) / 2; + const cy = (c.y0 + c.y1) / 2; + if ( + selRects.some( + (r) => + cx >= r[0] && + cx <= r[0] + r[2] && + cy >= r[1] && + cy <= r[1] + r[3], + ) + ) + picked.push(i); + }); + if (!picked.length) { showToast("Highlights can't be empty", "error"); return; } const session = this.pdfAdjust; + const startI = picked[0]; + const endI = picked[picked.length - 1]; + const extentRects = this.charSpanRects(chars.slice(startI, endI + 1)); + let t = ""; + let prev: (typeof chars)[number] | null = null; + for (const c of chars.slice(startI, endI + 1)) { + if (prev) { + const pc = (prev.y0 + prev.y1) / 2; + const cc = (c.y0 + c.y1) / 2; + const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4; + if (Math.abs(cc - pc) > half) t += " "; + else if (c.x0 - prev.x1 > 0.004) t += " "; + } + t += c.ch; + prev = c; + } + t = t.replace(/\s+/g, " ").trim(); + if (!t) { + showToast("Highlights can't be empty", "error"); + return; + } session.committed = true; this.cancelPdfAdjust(); const p = this.selectionPopover; p.id = session.id; p.pdfPage = session.page; - p.pdfRects = rects; + p.pdfRects = extentRects; p.text = t; this.saveHighlightChanges(); try {