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.