Skip to content

Commit 88a035c

Browse files
authored
Merge develop into main: release v1.8.2
Release v1.8.2
2 parents 5135740 + 83dd8c1 commit 88a035c

12 files changed

Lines changed: 313 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## [1.8.2] - 2026-07-09
4+
5+
### Fixed
6+
7+
- Fixed Yomitan sentence capture stopping at OCR line breaks (#254)
8+
9+
### Changed
10+
11+
- Refreshed cloud provider descriptions (Drive quota and auto re-auth)
12+
313
## [1.8.1] - 2026-07-06
414

515
### Fixed

CLAUDE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ src/
6262
│ ├── util/ # Utilities
6363
│ │ └── sync/ # Multi-provider cloud sync
6464
│ │ └── providers/
65+
│ │ ├── filesystem/
6566
│ │ ├── google-drive/
6667
│ │ ├── mega/
68+
│ │ ├── onedrive/
6769
│ │ └── webdav/
6870
│ ├── views/ # Top-level view components
6971
│ └── workers/ # Web Workers for background tasks
@@ -89,11 +91,13 @@ src/
8991

9092
Located in `src/lib/util/sync/`, the app supports multiple cloud storage providers:
9193

92-
| Provider | Auth Method | Status |
93-
| ------------ | -------------------- | ------------ |
94-
| Google Drive | OAuth2 implicit flow | Full support |
95-
| MEGA | Email/password | Full support |
96-
| WebDAV | URL + credentials | Full support |
94+
| Provider | Auth Method | Status |
95+
| ------------ | ------------------------------- | --------------------- |
96+
| Google Drive | OAuth2 implicit flow | Full support |
97+
| MEGA | Email/password (+ optional 2FA) | Full support |
98+
| WebDAV | URL + credentials | Full support |
99+
| OneDrive | MSAL (OAuth2 auth code + PKCE) | Full support |
100+
| Local Folder | Directory picker (no account) | Desktop Chromium only |
97101

98102
**Architecture:**
99103

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ https://github.com/Gnathonic/mokuro-reader/assets/39561296/45a214a8-3f69-461c-87
3131
### ☁️ Cloud Integration
3232

3333
- **Google Drive Sync** - Full integration with automatic token refresh and reconnection
34-
- **MEGA Support** - Alternative cloud storage option
34+
- **MEGA, OneDrive & WebDAV Support** - More cloud options, including self-hosted WebDAV servers
35+
- **Local Folder Access** - Bulk import/export through a folder on your device (desktop Chromium)
3536
- **Automatic Progress Sync** - Seamlessly sync read progress and stats across devices
36-
- **Easy Backup** - Backup your entire library to Google Drive, MEGA, or WebDAV
37+
- **Easy Backup** - Backup your entire library to any connected provider
3738
- **Smart Placeholder System** - Backed up volumes appear as downloadable placeholders in your catalog
3839
- **One-Tap Downloads** - Download cloud volumes directly from your catalog on your other devices
3940
- **Cross-Device Continuity** - Pick up exactly where you left off on any device
@@ -119,7 +120,7 @@ pip install mokuro
119120

120121
### Cloud Sync Setup
121122

122-
Connect to **Google Drive**, **MEGA**, or **WebDAV** from the Cloud page in settings. All three providers support:
123+
Connect to **Google Drive**, **MEGA**, **OneDrive**, or **WebDAV** from the Cloud page in settings (a **Local Folder** option is also available on desktop Chromium browsers). All providers support:
123124

124125
- Automatic progress and profile sync across devices
125126
- Volume backup with one-tap restore on other devices
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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`.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mokuro-reader",
3-
"version": "1.8.1",
3+
"version": "1.8.2",
44
"private": true,
55
"scripts": {
66
"dev": "vite dev",

src/lib/components/Reader/TextBoxes.svelte

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,64 @@
342342
};
343343
}
344344
345+
// Auto (per-line) mode: each line renders as an inline-block kept in normal
346+
// flow, so DOM text scanners (Yomitan/Migaku) read the whole block as one
347+
// continuous run — a per-line `position: absolute` would inject a hard break
348+
// at every line and split words/sentences across lines (issue #254). We then
349+
// translate each line onto its lines_coords quad. Measurement is required:
350+
// an inline element's natural flow position is only knowable after layout.
351+
//
352+
// offsetLeft/offsetTop are measured against the .textBox (the span's
353+
// offsetParent, since the box is position:absolute) and are in image px —
354+
// zoom is applied as an ancestor transform, so this coordinate space is
355+
// zoom-invariant. Both offsetLeft and the target `left` reference the box's
356+
// padding edge, so `target - offsetLeft` is the exact translate.
357+
function positionPerLine(container: HTMLDivElement, _signature: string) {
358+
let raf = 0;
359+
360+
const apply = () => {
361+
const spans = container.querySelectorAll<HTMLElement>('.positionedLine');
362+
if (spans.length === 0) return;
363+
// display:none box (OCR hidden) → no offsetParent; measurement would read
364+
// 0. Skip and re-run on reveal (mouseenter/touchstart) or update.
365+
if (spans[0].offsetParent === null) return;
366+
367+
// Read every natural origin first (one layout), then write every
368+
// transform (compositor-only, no reflow) — avoids layout thrash.
369+
const naturals: Array<[number, number]> = [];
370+
for (const span of spans) naturals.push([span.offsetLeft, span.offsetTop]);
371+
372+
spans.forEach((span, i) => {
373+
const targetLeft = Number(span.dataset.targetLeft);
374+
const targetTop = Number(span.dataset.targetTop);
375+
if (!Number.isFinite(targetLeft) || !Number.isFinite(targetTop)) return;
376+
span.style.transform = `translate(${targetLeft - naturals[i][0]}px, ${targetTop - naturals[i][1]}px)`;
377+
});
378+
};
379+
380+
const schedule = () => {
381+
cancelAnimationFrame(raf);
382+
raf = requestAnimationFrame(apply);
383+
};
384+
385+
schedule();
386+
// Fonts change glyph advance → re-measure once the real font is ready.
387+
document.fonts?.ready?.then(schedule);
388+
// Box may have been display:none at mount; catch first reveal.
389+
container.addEventListener('mouseenter', schedule);
390+
container.addEventListener('touchstart', schedule, { passive: true });
391+
392+
return {
393+
// _signature changes on displayOCR toggle or font-size setting change.
394+
update: schedule,
395+
destroy() {
396+
cancelAnimationFrame(raf);
397+
container.removeEventListener('mouseenter', schedule);
398+
container.removeEventListener('touchstart', schedule);
399+
}
400+
};
401+
}
402+
345403
function getImageUrlFromElement(element: HTMLElement): string | null {
346404
// Traverse up to find the MangaPage div with background-image
347405
let current: HTMLElement | null = element;
@@ -555,6 +613,7 @@
555613
{@const usePerLine = lineLayouts !== null}
556614
<div
557615
use:handleTextBoxHover={[index, fontSize]}
616+
use:positionPerLine={`${display}|${$settings.fontSize}`}
558617
class="textBox"
559618
class:originalMode={isOriginalMode}
560619
class:perLine={usePerLine}
@@ -582,8 +641,8 @@
582641
{#each lines as line, lineIndex}{#if !lineLayouts[lineIndex].hidden}<span
583642
class="ocr-line positionedLine"
584643
class:wrappedLine={lineLayouts[lineIndex].wrap}
585-
style:left={`${lineLayouts[lineIndex].left}px`}
586-
style:top={`${lineLayouts[lineIndex].top}px`}
644+
data-target-left={lineLayouts[lineIndex].left}
645+
data-target-top={lineLayouts[lineIndex].top}
587646
style:width={lineLayouts[lineIndex].wrap
588647
? `${lineLayouts[lineIndex].width}px`
589648
: undefined}
@@ -668,15 +727,19 @@
668727
white-space: nowrap;
669728
}
670729
671-
/* Original mode with lines_coords: each line is placed at its detected quad
672-
with a geometry-derived font size. line-height 1 keeps the column/row no
673-
thicker than the font size; letter-spacing 0 because the print's tracking
674-
is already baked into the quad length the size was fitted to. */
730+
/* Auto mode with lines_coords: each line is placed at its detected quad with
731+
a geometry-derived font size. The line stays inline-block IN NORMAL FLOW
732+
(not position:absolute) so DOM text scanners read the block as one
733+
continuous run (#254); a measurement action then translates it onto the
734+
quad. line-height 1 keeps the column/row no thicker than the font size;
735+
letter-spacing 0 because the print's tracking is already baked into the
736+
quad length the size was fitted to. */
675737
.textBox.perLine .ocr-line.positionedLine {
676-
position: absolute;
738+
display: inline-block;
677739
line-height: 1;
678740
letter-spacing: 0;
679741
white-space: nowrap;
742+
/* transform (translate onto the quad) is set by positionPerLine */
680743
}
681744
682745
/* A quad that captured multiple print columns (base text + furigana):
@@ -687,10 +750,12 @@
687750
line-break: anywhere;
688751
}
689752
690-
/* Use CSS-generated newline instead of <br/> so DOM walkers
691-
(Migaku/Yomitan) see one continuous text node per textbox
692-
and don't treat line breaks as sentence boundaries. */
693-
.textBox .ocr-line:not(:last-child)::after {
753+
/* Legacy/manual modes: use a CSS-generated newline instead of <br/> so DOM
754+
walkers (Migaku/Yomitan) see one continuous text run per textbox and don't
755+
treat line breaks as sentence boundaries. Per-line (auto) mode positions
756+
each line explicitly and needs no visible newline; the generated content
757+
was never seen by Yomitan anyway. */
758+
.textBox:not(.perLine) .ocr-line:not(:last-child)::after {
694759
content: '\A';
695760
white-space: pre;
696761
}

0 commit comments

Comments
 (0)