|
| 1 | +# Per-line layout: restore Yomitan/Migaku text continuity (issue #254) |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +Issue #254: since v1.7.5, Yomitan can no longer scan a word that spans two |
| 6 | +lines of an OCR text box, and mining to Anki captures only one line in the |
| 7 | +Sentence field. The same class of bug was previously #124. |
| 8 | + |
| 9 | +### Root cause |
| 10 | + |
| 11 | +The per-line auto layout shipped in v1.7.5 (PR #243) renders each OCR line as |
| 12 | +its own `position: absolute` span (`.positionedLine` in `TextBoxes.svelte`). |
| 13 | + |
| 14 | +Yomitan's DOM text scanner (`ext/js/dom/dom-text-scanner.js#getElementSeekInfo`) |
| 15 | +decides line/sentence boundaries **entirely from computed CSS + DOM traversal |
| 16 | +order — never from geometry** — and it inspects `style.position` _before_ |
| 17 | +`display`: |
| 18 | + |
| 19 | +```js |
| 20 | +switch (style.position) { |
| 21 | + case 'absolute': |
| 22 | + case 'fixed': |
| 23 | + case 'sticky': |
| 24 | + newlines = 2; // hard paragraph break |
| 25 | +} |
| 26 | +``` |
| 27 | + |
| 28 | +So every per-line span boundary injects `\n\n`. That newline (a) caps the |
| 29 | +forward term match, so a word split across lines can't be matched, and (b) |
| 30 | +terminates sentence extraction at the first line (`sentenceTerminateAtNewlines`, |
| 31 | +default on). These are exactly the two reported symptoms. |
| 32 | + |
| 33 | +Two facts established from Yomitan source that shape the fix: |
| 34 | + |
| 35 | +- The `.textBox` `::after { content: '\A' }` "continuity trick" never affected |
| 36 | + Yomitan. Yomitan walks only real text nodes and ignores generated content; |
| 37 | + the `\A` is purely the human-visible newline in a revealed box. Legacy auto |
| 38 | + mode stayed continuous because its line spans were plain `display: inline` |
| 39 | + (no `position`), so Yomitan read them as one run. |
| 40 | +- The **block-level** `position: absolute` on `.textBox` (one per OCR block) is |
| 41 | + correct and must stay: it makes separate speech bubbles separate sentences. |
| 42 | + The regression is the _second_, per-line layer of `absolute` inside the block. |
| 43 | + |
| 44 | +`inline-block`, `transform`, and `position: relative` are all confirmed against |
| 45 | +Yomitan source to keep text continuous (`inline-block` truncates to `inline` in |
| 46 | +`doesCSSDisplayChangeLayout`; `transform` is never read; `relative` is not in |
| 47 | +the `position` switch). |
| 48 | + |
| 49 | +## Goal |
| 50 | + |
| 51 | +Keep the per-line placement improvement (each line rendered at its detected |
| 52 | +`lines_coords` quad with a geometry-fitted font size, the no-overlap invariant, |
| 53 | +per-line sizing) **and** restore Yomitan/Migaku continuity within a block. |
| 54 | + |
| 55 | +## Approach — transform-repositioned inline lines |
| 56 | + |
| 57 | +Stop giving line spans `position: absolute`. Render each line as |
| 58 | +`display: inline-block` in normal flow, then snap it onto its quad with a |
| 59 | +per-line `transform: translate(dx, dy)` computed from a measured natural |
| 60 | +position. Exactly one `position: absolute` per block (`.textBox`) remains. |
| 61 | + |
| 62 | +There is no way to get exact placement _and_ inline continuity without |
| 63 | +measuring: inline elements inherently advance the flow, and every zero-advance |
| 64 | +trick (absolute, float) re-blockifies and re-breaks Yomitan. Measurement is the |
| 65 | +price of keeping both, and it has direct precedent in the reader's zoom |
| 66 | +architecture (measurement-based correction). |
| 67 | + |
| 68 | +### DOM / CSS changes (`TextBoxes.svelte` only) |
| 69 | + |
| 70 | +- `.positionedLine`: drop `position: absolute` → `display: inline-block`. The |
| 71 | + `left`/`top` inline styles are replaced by `transform: translate(dx, dy)` |
| 72 | + set by the measurement action (below). |
| 73 | +- Wrapped lines keep explicit `width`/`height` + `white-space: normal` |
| 74 | + (inline-block honors both). |
| 75 | +- Drop the `::after { content: '\A' }` rule in per-line mode — each line is now |
| 76 | + positioned explicitly, so the visible newline is unnecessary, and Yomitan |
| 77 | + never saw it anyway. |
| 78 | +- `layoutLines` and `LineLayout { left, top, fontSize, wrap, width, height, |
| 79 | +hidden }` are **unchanged**. `left`/`top` are already the target quad origin |
| 80 | + relative to the block box. `enforceNoOverlap`, block dedupe, and per-line |
| 81 | + sizing are all untouched. |
| 82 | + |
| 83 | +### Measurement mechanism (the new part) |
| 84 | + |
| 85 | +A Svelte action on `.textBox` performs a **batched read-then-write** pass: |
| 86 | + |
| 87 | +1. Read every line span's natural in-flow origin (`offsetLeft`/`offsetTop` |
| 88 | + relative to `.textBox`, which is the offsetParent because the box is |
| 89 | + `position: absolute`). These are layout px = image px and **zoom-invariant** |
| 90 | + — the reader applies zoom as an ancestor transform, so the box's internal |
| 91 | + coordinate space stays in image px; no dividing by scale. |
| 92 | +2. Apply every transform: `translate(LineLayout.left − offsetLeft, |
| 93 | +LineLayout.top − offsetTop)`. |
| 94 | + |
| 95 | +Reading all before writing avoids layout thrash and is correct: transforms are |
| 96 | +paint-only and do not re-layout, so natural positions are stable once read. |
| 97 | + |
| 98 | +**When it runs:** on mount when the box has layout (`displayOCR` on), re-run if |
| 99 | +the box toggles out of `display:none`. It runs while the box is still |
| 100 | +`visibility:hidden`, so glyphs are already on their quads before hover reveals |
| 101 | +them (no visible jump). Reveal is also when Yomitan needs hit-testable glyphs, |
| 102 | +and `transform` participates in hit-testing, so `caretRangeFromPoint` lands on |
| 103 | +the correct glyph. |
| 104 | + |
| 105 | +### Edge cases |
| 106 | + |
| 107 | +- Hidden lines (intra-block overlap dupes) stay omitted from the DOM; their text |
| 108 | + is subsumed by a kept line, so continuity is unaffected. |
| 109 | +- Interleaved split-ruby DOM order is a pre-existing data-order quirk (identical |
| 110 | + to legacy) — out of scope. |
| 111 | +- `lineLayouts === null` (pre-`lines_coords` / image-only imports) → unchanged |
| 112 | + legacy hover-fit path. |
| 113 | +- `offsetLeft/Top` round to integer px. If that proves visibly coarser than the |
| 114 | + current fractional placement, fall back to `getBoundingClientRect()` ÷ |
| 115 | + measured zoom scale. Decide empirically during verification. |
| 116 | + |
| 117 | +## Testing / verification |
| 118 | + |
| 119 | +- jsdom has no real layout (`offsetLeft`/`getBoundingClientRect` return 0), so |
| 120 | + the measurement itself can't be unit-tested there. |
| 121 | +- Regression guard (cheap): assert per-line spans are **not** |
| 122 | + `position: absolute`. |
| 123 | +- Playwright (real browser layout): each line's painted rect lands on its quad; |
| 124 | + no rendered rects overlap. |
| 125 | +- Acceptance test (real Yomitan): hover a word split across two lines → it |
| 126 | + scans; mine to Anki → Sentence field contains the whole block. Run via the |
| 127 | + `verify` skill / browser automation with Yomitan installed before declaring |
| 128 | + the issue fixed. |
| 129 | + |
| 130 | +## Scope |
| 131 | + |
| 132 | +- `src/lib/components/Reader/TextBoxes.svelte`: per-line render + new |
| 133 | + measurement action + CSS. |
| 134 | +- New/updated tests as above. |
| 135 | +- The pure `line-coords-layout.ts` module is untouched. |
| 136 | +- Branch: `fix/254-yomitan-line-continuity` off `develop`. |
0 commit comments