Some checks are pending
Test and SonarQube / quality (push) Waiting to run
The preview carries each page's regions (percent of the page, type, reading order) for the reading pass and the labelling pass, drawn over the page image with a legend. A server restart that killed the import left it "running" for ever; the status route now reports it as failed. v1.42.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
651 lines
39 KiB
Markdown
651 lines
39 KiB
Markdown
# SOP — Magazine ingestion
|
||
|
||
Status: **P0 complete. P1 complete.** Studio -> Magazines has the full
|
||
library/contents/review/read/podcast flow, including figures shown during
|
||
playback and a cross-article play-all queue. Automatic sidebar exclusion,
|
||
word-level highlighting (estimated, not measured) and driving mode --
|
||
originally P2 items -- are also in. Remaining P2: image descriptions,
|
||
table/chart handling, EPUB 3 export, and a formal multi-engine OCR benchmark.
|
||
|
||
## Why magazines are not books
|
||
|
||
The audiobook pipeline assumes one continuous text in reading order. A magazine
|
||
breaks every part of that assumption: several unrelated articles share a page,
|
||
columns run in parallel, adverts and sidebars interrupt body copy, and a feature
|
||
routinely jumps twenty pages to its tail. Narrating a magazine page-by-page
|
||
produces audio that walks out of an article mid-sentence and into an advert.
|
||
|
||
**The canonical object is therefore the article, not the page.** Everything
|
||
below exists to reconstruct articles from a flat PDF.
|
||
|
||
## Pipeline
|
||
|
||
Implemented in [`core/magazine.py`](../core/magazine.py); driven by
|
||
[`routes/magazine.py`](../routes/magazine.py).
|
||
|
||
```
|
||
PDF
|
||
→ extract_pages() PyMuPDF: text blocks with bbox, font size/name, weight
|
||
→ vision_ocr_page() connected vision OCR engine, when a page needs OCR or its
|
||
text scores poorly (ocr_quality_score) -- native or
|
||
tesseract alike. Labels footer/title/page_number/caption
|
||
directly. Off unless configured; never fatal.
|
||
→ ocr_page() tesseract TSV fallback: no vision engine configured, or
|
||
the vision call failed/timed out/was unreachable
|
||
→ build_vision_blocks() vision regions -> Blocks; a direct label bypasses
|
||
classify_blocks entirely (see below)
|
||
→ snap_visual_bboxes() figure/chart/table/code boxes re-derived from the page
|
||
render, because grounding is the weakest part of a
|
||
vision engine's answer (see below)
|
||
→ classify_blocks() title / deck / byline / body / caption / pullquote /
|
||
sidebar / furniture / ad / figure -- for native/tesseract
|
||
blocks, and any vision region left ambiguous ("text")
|
||
→ mark_advert_pages() spread an "ADVERTISEMENT" label across its whole page
|
||
→ mark_pullquotes() demote display text that merely repeats body copy
|
||
→ assign_reading_order() column detection, then top-to-bottom within a column
|
||
→ cluster_running_furniture() fuzzy-match near-edge repeats tesseract's noisy
|
||
OCR defeated exact matching on (see below)
|
||
→ score/parse_contents() Stage A: locate and parse the table of contents
|
||
→ group_articles() Stage C: group blocks, link "continued on page N"
|
||
→ human review Stage D, in the UI — nothing is narrated until approved
|
||
```
|
||
|
||
### Why server-side, not in the browser
|
||
|
||
Text extraction for plain PDFs runs client-side (PDF.js), and that stays. Layout
|
||
analysis does not, for three reasons: PyMuPDF exposes real font metrics and
|
||
embedded-image rectangles that PDF.js does not; OCR coordinates from tesseract
|
||
merge with those bboxes in one coordinate space; and the result is expensive
|
||
enough (minutes for an issue) that it must be computed once and persisted, not
|
||
recomputed per browser session.
|
||
|
||
PDF.js remains responsible for *rendering* in the PDF view.
|
||
|
||
Docling/Unstructured were considered and rejected: they pull torch plus layout
|
||
model weights (multi-GB, ARM64 wheel risk on the DGX Spark, GPU contention with
|
||
the TTS stack) to improve on heuristics that already work on the cases we hit.
|
||
Revisit only if real issues show grouping failures the heuristics cannot fix.
|
||
|
||
## Decisions that are easy to get wrong
|
||
|
||
**Font baseline is document-wide, never per page.** `classify_blocks` judges
|
||
size as a ratio against `base_size`. A page carrying a full-bleed photograph and
|
||
one caption has no body copy of its own, so a per-page baseline makes that
|
||
caption its own reference and classifies it as body text. `extract_pages`
|
||
measures the baseline once across every block in the issue.
|
||
|
||
**Reading order is column-major.** Sorting blocks by `y` interleaves parallel
|
||
columns, splicing the left column's sentence into the right column's. Columns
|
||
are inferred from vertical whitespace (where blocks are *absent*), because
|
||
magazines change column count per page — a fixed grid is wrong. Blocks spanning
|
||
>60% of the page width sort ahead of both columns, so a full-width headline
|
||
precedes the body it introduces.
|
||
|
||
**Column boundary detection excludes unusually wide blocks, and the gutter
|
||
threshold is narrower than it looks.** Confirmed live on a real scanned c't
|
||
page, using the split-screen review's numbered boxes (built specifically to
|
||
make exactly this kind of error visible): a photo caption at 49.9% of the
|
||
page width — under the 60% "spans the page" threshold above, so not given
|
||
special handling — sat in the whitespace gap between two genuinely separate
|
||
columns and merged them into one detected column. Every block on the page
|
||
came back `column=0`, and reading order fell back to pure top-to-bottom,
|
||
splicing the two real columns' sentences together — the box numbered 6 in
|
||
review (a caption physically above the box numbered 1) was read *after* it.
|
||
Column boundaries are now detected from blocks close to the page's median
|
||
width only, and the real gutter measured on that page was itself only ~2.1%
|
||
of the page width, narrower than the 3.5% `detect_columns` started with — now
|
||
1.5%. A single block much narrower than the surrounding column (a stray
|
||
caption fragment) can still form a spurious extra column of its own; the
|
||
main failure (two real columns interleaved) is fixed, that residual
|
||
narrower one is not.
|
||
|
||
**Furniture is identified by repetition, not position.** A running head and a
|
||
headline both sit at the top of a page. What separates them is that the running
|
||
head recurs on many pages at the same edge; `find_repeated_lines` needs the
|
||
whole document, so classification waits until every page has been read.
|
||
|
||
**Exact-match repetition is not enough once OCR is involved.** Confirmed live
|
||
on a scanned c't magazine: the running footer "c't Wissen 2026" was read
|
||
differently by tesseract on nearly every page (`cE KI-WISSEN`, `ce Wissen
|
||
2136`, `cA Wissen 2036`, …), so `find_repeated_lines`'s exact match never
|
||
recognised them as the same line — each became its own spurious title and
|
||
started a new article, taking a 28-page scan from a handful of real sections
|
||
to 26 near-1-per-page "articles". `cluster_running_furniture` fuzzy-matches
|
||
near-edge blocks (digit-stripped, `difflib.SequenceMatcher` similarity ≥ 0.6,
|
||
requiring ≥3 occurrences) as a second pass over the whole document. A vision
|
||
OCR engine that labels the footer directly (see below) makes this pass
|
||
unnecessary for the pages it covers, but tesseract-sourced pages still need
|
||
it.
|
||
|
||
**OCR font size is a measurement for native text, an estimate for OCR text.**
|
||
PyMuPDF gives an exact digital font size per span. Tesseract only gives a line
|
||
bounding box, and its own line SEGMENTATION is unreliable on a scanned
|
||
magazine page — `ocr_page` groups by paragraph (matching PyMuPDF's own
|
||
grouping) and uses the MEDIAN of the paragraph's line heights, not its total
|
||
bbox height, which would scale with line count instead of type size. A vision
|
||
OCR engine gives one box per paragraph with no per-line data at all, so its
|
||
"text"-labeled (ambiguous) blocks get a synthesized estimate instead
|
||
(`estimate_font_size_from_area`, derived from the box's area and the text's
|
||
length) — good enough to feed the same title/deck/pullquote thresholds, not a
|
||
real measurement.
|
||
|
||
**A vision-engine label is trusted outright, never re-derived.** When the
|
||
engine labels a region `footer`/`title`/`page_number`/`caption` directly
|
||
(`build_vision_blocks`), `classify_blocks` skips it (`source ==
|
||
"vision_label"`) rather than reclassifying it from an estimated font size —
|
||
that would throw away a more direct signal than any heuristic can reconstruct
|
||
after the fact. Only an ambiguous `"text"` label (or one the engine invents)
|
||
goes through the normal classification pass.
|
||
|
||
**A page is claimed only if it contributes.** An advert between two features
|
||
starts no article, so without an explicit check it gets swallowed by the
|
||
preceding one and highlighted as part of it in the PDF view.
|
||
|
||
**Pull quotes are demoted.** Narrating one reads the same sentence twice a few
|
||
seconds apart. A display-sized block whose text already appears in the body copy
|
||
is a pull quote, not a deck.
|
||
|
||
## Confidence, and why review is mandatory
|
||
|
||
| Signal | Confidence |
|
||
|---|---|
|
||
| Anchored by a contents entry | 0.90 |
|
||
| Started by a headline, no contents entry | 0.55 |
|
||
| Article whose blocks contain no body copy | ×0.4 |
|
||
|
||
Nothing is narrated until a human sets `status: "approved"`. This is deliberate:
|
||
a five-second correction beats a 30-minute recording with a sidebar in the
|
||
middle. `POST …/podcast` refuses unapproved articles outright.
|
||
|
||
## Storage
|
||
|
||
Magazines live in the Read Aloud store and are distinguished by
|
||
`meta.json → kind: "magazine"`, so the existing library listing, cover, progress
|
||
and audio endpoints keep working.
|
||
|
||
```
|
||
<CONFIG_DIR>/reader_library/<doc_id>/
|
||
meta.json kind, title, issue, ingest status, default_voice,
|
||
podcast hosts, per-article progress
|
||
source.pdf the original
|
||
layout.json every classified block, with bbox + reading order
|
||
articles.json detected articles; status gates narration
|
||
pages/NNNN.png rendered pages (PDF view + OCR input)
|
||
images/*.png extracted figures ≥64px
|
||
podcast/<id>.json generated host scripts
|
||
audio/<idx>.mp3 synthesised audio (existing reader endpoints)
|
||
```
|
||
|
||
`layout.json` is the join table between audio, text, image and PDF coordinates:
|
||
every block carries `page` + `bbox`, so a sentence in the reflowed view can jump
|
||
to its source region on the rendered page.
|
||
|
||
## Podcast mode
|
||
|
||
Per article, alongside narration — not instead of it. An approved article's
|
||
narratable blocks are sent to the configured LLM (`llm_url` / `llm_model`,
|
||
reusing `_post_llm_chat_completion` from `routes/conversation.py`) with a system
|
||
prompt that forbids inventing facts and demands strict JSON. The parser tolerates
|
||
a markdown fence and surrounding prose, and reassigns unknown host labels rather
|
||
than dropping the turn — an unrecognised label would otherwise leave a line with
|
||
no voice. 2–3 hosts, each bound to a voice the user picks; synthesis reuses the
|
||
multi-speaker path the audiobook casting already uses.
|
||
|
||
## Frontend
|
||
|
||
`static/js/magazine.js` + `static/sections/s-magazine.html`, wired into the same
|
||
lazy-module system as the other sections (`static/module-manifest.json`,
|
||
`static/loader.js`, `static/nav.js`). Two independent section lists gate
|
||
visibility (`loader.js` fetches the HTML, `nav.js` decides `navTo` will reveal
|
||
it) — both needed updating; missing either one leaves the section perpetually
|
||
hidden even though its HTML loaded correctly.
|
||
|
||
State machine: `library -> contents -> review -> read`, plus a `pane` toggle
|
||
(`reflow` | `pdf`) within `read`. Sentences are split from narratable blocks
|
||
client-side, each tagged with its source `blockId`/`page`/`bbox`, so clicking a
|
||
sentence or switching to the PDF pane draws the highlight rectangle from the
|
||
same coordinate the layout pipeline recorded — no separate sync data.
|
||
|
||
Reuses `/api/tts-preview` for both narration and podcast turns; no new audio
|
||
pipeline. `magazine_articles` marks each article `has_podcast` so the read view
|
||
skips a doomed status probe when nothing was generated, rather than a routine
|
||
404 on every open.
|
||
|
||
The pane toggle is a button *group* with `aria-pressed`, not `role="tab"` —
|
||
this app's tab pattern would additionally require `aria-selected`, roving
|
||
`tabindex` and arrow-key navigation, none of which plain click switching
|
||
provides. `role="tab"` without that whole pattern fails WCAG 2.2 AA guidance
|
||
and, in practice, hides the buttons from `getByRole('tab')`-oriented tooling.
|
||
|
||
### Images during playback
|
||
|
||
`build_vision_blocks` aside, a figure has no sentence of its own to attach
|
||
to. `assign_images_to_articles` (server) links each extracted image to the
|
||
article whose column it falls in, using the nearer article's blocks when two
|
||
share a page; `image_reading_position` gives it an approximate slot in the
|
||
page's reading order. The client (`currentImageIndex` in magazine.js) then
|
||
shows "the last image reached so far" as playback advances past its
|
||
position — no separate sync data beyond that one `reading_order` number.
|
||
|
||
### Play all
|
||
|
||
`magPlayAll` builds `state.queue` from every *approved* article in listing
|
||
order and starts the first; `magPlay`'s completion path advances the queue
|
||
automatically rather than stopping, so listening runs across articles
|
||
without returning to the contents screen. `magRead(id, {fromStart: true})`
|
||
is what makes this a fresh start rather than resuming a queued article's own
|
||
saved progress, which would otherwise interrupt a continuous listen.
|
||
|
||
### Word-level highlighting is estimated, not measured
|
||
|
||
No forced-alignment or TTS timestamp source exists in this pipeline.
|
||
`estimateWordTimings` distributes a sentence's *real, measured* audio
|
||
duration (`audio.duration`, once loaded) across its words proportional to
|
||
character length — a common karaoke approximation, not true alignment, and
|
||
it can drift on a sentence with heavy punctuation-driven pauses. A checkbox
|
||
(`#mag-karaoke-cb`) lets it be turned off; sentence-level highlighting
|
||
(measured, not estimated — it just waits for `audio.onended`) always still
|
||
works regardless.
|
||
|
||
### Driving mode
|
||
|
||
`#mag-driving` is a full-screen overlay, not a separate playback path: it
|
||
reuses whatever is already playing or queued, so entering it never
|
||
interrupts an article in progress. Two oversized controls (skip, play/pause)
|
||
and no other interactive surface, matching the "no interaction required"
|
||
behaviour the manual originally specified as a P2 goal.
|
||
|
||
### Automatic sidebar/advert exclusion
|
||
|
||
`mark_sidebars` (server) reclassifies body text sitting in a column much
|
||
narrower than the page's regular grid as `BLOCK_SIDEBAR`; `group_articles`
|
||
excludes it from an article's narrated blocks the same way it already
|
||
excludes `BLOCK_FURNITURE`/`BLOCK_AD`. Full-width elements (a spanning
|
||
headline) are excluded from the column-width analysis itself — left in, one
|
||
bridges two genuinely separate columns into a single merged span and hides
|
||
a real narrow sidebar column entirely (confirmed by a failing test before
|
||
the fix). This is a geometry heuristic, not a certainty: review still shows
|
||
every sidebar block (unticked by default) and it can be included with one
|
||
click — nothing is silently discarded.
|
||
|
||
## Reading order (2026-09-21)
|
||
|
||
`assign_reading_order` reads a page the way a person does:
|
||
|
||
1. **Columns come from where column-width blocks start** (`column_anchors`: clustered left edges),
|
||
not from whitespace between columns. The whitespace signal failed on real scanned pages: OCR boxes
|
||
are loose, so a gutter shrank to 4-6 pt (page 38 of issue 204), and a pull-quote laid across the
|
||
gutter bridged the columns outright -- every block came back as column 0 and the two columns were
|
||
read interleaved. The column width is measured on body text only (a page-wide photo pushed it to
|
||
317 pt against real 210 pt columns), and columns never overlap.
|
||
2. **Bands.** Text across two or more columns *in a strip of its own* (a headline, a standfirst) is
|
||
read before what lies under it. A box lying over the column boxes (a duplicate region) does not
|
||
count. A photo with no running text beside it also separates the text above from the text below
|
||
(page 176: a caption beside a screenshot above the columns). Paragraph gaps are **never** used as
|
||
breaks: in neighbouring columns they often line up, and treating them as breaks interleaves the
|
||
columns again (tried, measured, reverted).
|
||
3. Short pull-quotes (≤ 260 chars, wrapped in quotation marks, or typed pullquote and short) are read
|
||
after the page's running text; types alone are not trusted (on page 38 the real pull-quote was
|
||
typed body, and a body paragraph was typed pullquote). Furniture last.
|
||
|
||
**Measured** over all 204 pages of a real issue with a text-only score -- a block that ends
|
||
mid-sentence must be followed by one that continues it in lower case, a split word ("hun-" /
|
||
"dertmal") by its other half: confirmed continuations 85 (old) → 89 (new). The two pages that score
|
||
lower both carry duplicate boxes stacked over the same text, where no order is right until the boxes
|
||
are fixed. The old detector had also counted full-page adverts and photo columns as text columns.
|
||
|
||
**Existing issues** get the new order with *Fix reading order* (`POST .../reading-order`): order
|
||
only, no OCR; text, boxes, types, articles and approvals unchanged. A page with `order_locked` (set
|
||
when the reader orders a page by hand) is always left alone -- by this action, by a type change and by
|
||
any other recompute.
|
||
|
||
## Reading order by hand
|
||
|
||
`PUT .../pages/{page}/reading-order {order: [ids]}` sets a page's order (text blocks and figures in one
|
||
sequence, which is also where a figure appears during playback; ids left out follow in their current
|
||
order) and sets `order_locked` on the page. `DELETE` on the same path clears the lock and recomputes.
|
||
While locked: *Fix reading order* and type changes skip the page, a newly drawn region is appended last,
|
||
and re-OCR of the page clears the lock (its boxes are all new). UI: an editable position number per row,
|
||
and an *Order by clicking* mode (clicked boxes are numbered live; clicking does not tick/untick them).
|
||
|
||
## Box clean-up (`clean_up_regions`)
|
||
|
||
Sized on a real 204-page issue (2 038 boxes) before any rule was written; every rule *re-types*, none
|
||
deletes (furniture + `region_label` `in_picture` / `ocr_noise` / `duplicate`: not narrated, visible,
|
||
reversible in the type picker).
|
||
|
||
| rule | found | left alone on purpose |
|
||
|---|---|---|
|
||
| text ≤ 80 chars, ≥ 90% inside a picture → `in_picture` | 71 | longer text in a picture: included real running text caught in a loose frame |
|
||
| ≤ 2 alphanumeric characters → `ocr_noise` | 25 | longer short strings: a phone number, a price, a citation |
|
||
| box ≥ 80% inside a bigger text box whose text contains ≥ 90% of it → `duplicate` | 38 | the other 333 overlapping pairs hold different text |
|
||
| author line running into the first sentence → split | 5 (4 split, 1 already on page) | a two-author byline; a sentence starting with "Von" |
|
||
|
||
The split sentence becomes its own paragraph (bbox: lower half of the author box), not text glued onto
|
||
"the next paragraph": on the five real cases the next paragraph in reading order was right once. The
|
||
new paragraph is inserted after the author line in every article that holds it; the author block keeps
|
||
`original_text`. Runs on import, on page re-OCR (that page), and via *Clean up boxes* (`POST
|
||
.../boxes/clean-up`, writes `layout.backup-<ts>.json` first). Idempotent. `_reorder_page` now writes
|
||
only position/column back, so fields the Block model does not carry (e.g. `original_text`) survive.
|
||
|
||
## Quick corrections from the review list
|
||
|
||
`PATCH .../regions/{id}` also takes `text` (trusted verbatim, like a hand-drawn region's typed text).
|
||
`POST .../regions/{id}/merge-next` joins a text region with the next one in reading order (first keeps id,
|
||
type and place; hyphenated line-end words are closed up; bbox is the union) and drops the folded region
|
||
from articles. `POST .../regions/{id}/split {at}` splits at a character position; the box is cut
|
||
horizontally in proportion to the text on each side, the second part gets a new id, is read directly
|
||
after the first and joins the same articles. Keyboard: ↑/↓ (j/k), Space, Enter (edit), R, M, Delete;
|
||
ignored while typing in the editor, a select or a position box; focus returns to the same row after the
|
||
list is redrawn.
|
||
|
||
## Many boxes at once, and undo instead of confirmation
|
||
|
||
`POST .../pages/{page}/regions/bulk {ids, action: delete|type, type?}` applies one action to a selection
|
||
(≤ 500 ids, all on the page; validated before anything changes) with one undo point, and moves article
|
||
membership like the single-region routes. Every destructive route (delete, type change, text edit, merge,
|
||
split, bulk) writes `undo.json` = {token, label, layout, articles} *after* validating and *before* saving
|
||
(the files on disk still hold the old state), and returns the token; `POST .../undo {token}` restores both
|
||
files only if that token is still the latest (409 otherwise), so an old Undo can never roll back a newer
|
||
change. One level. After the change is saved the snapshot is sealed with a digest of layout.json +
|
||
articles.json (`_seal_undo`); undo also requires the current state to match that digest, so a later change that
|
||
records no undo point (drawing, fix reading order, clean-up, approve) makes the old Undo invalid instead of being
|
||
rolled back with it. UI: *Select boxes* mode (click or drag a rectangle; a box ≥ 50% inside is selected),
|
||
a bulk bar, and an Undo bar replacing the per-delete confirmation dialog (per NN/g-style guidance: frequent,
|
||
reversible actions get Undo, not "are you sure?").
|
||
|
||
`fold_text_into_picture`: when a region becomes a picture (drawn, or changed into one), text ≤ 80 chars and
|
||
≥ 90% inside it becomes `in_picture` -- the same limits as the clean-up. A real chart page had 13 legend and
|
||
axis-label boxes that had to be deleted one by one after the chart was marked as an image.
|
||
|
||
Redraw race: the review list redraws asynchronously after a change; actions that build UI in the list (the
|
||
text editor) wait for a redraw in flight, and focus goes to the row focused when the redraw happens.
|
||
|
||
## Region types from the review list
|
||
|
||
Every region in the review list has a type picker and a delete button. `PATCH .../regions/{id}` takes
|
||
`type` (`REGION_TYPES`: body, title, heading, deck, byline, caption, pullquote, sidebar, page_number,
|
||
source, ad, image). Between text kinds only the type changes. Text → image crops the box into a figure;
|
||
image → text reads the box with OCR (placeholder if nothing is recognised). Crossing between text and
|
||
image gives the region a new id, and the route moves its **article membership** over (figures and text
|
||
are listed separately on an article), so a converted figure stays in the article being reviewed. The
|
||
page's order is recomputed afterwards (unless hand-ordered), since the type decides where a region is read.
|
||
|
||
## Live region boxes in the ingest preview
|
||
|
||
`ingest_preview.json` now carries `stage` ("reading" | "labelling") and `boxes`: each region in percent of the
|
||
page, its type (`page_number` for page-number furniture) and its reading-order number; pictures come from the
|
||
page's images (`_ingest_preview_boxes`, capped at 400). One writer, `_write_ingest_preview`, serves both stages:
|
||
the reader's progress hook (it now also passes the page size) and the labelling passes, which call `on_page`
|
||
after every page in a `try/finally`, so a page whose model call fails is still reported. The labelling pass holds
|
||
blocks as dicts; they are adapted to the attributes the text renderer reads (it previously failed silently on
|
||
dicts and wrote nothing). The browser fits the box layer to the part of the frame the image covers ("contain"
|
||
scaling leaves bars) and re-fits on resize; new boxes fade in (not with reduced motion); the panel stays
|
||
`aria-hidden` -- the status line carries the progress for screen readers.
|
||
|
||
**Interrupted runs.** A server restart kills the ingest job but left the magazine marked `running`, so the page
|
||
spun for ever. `GET .../ingest/status` now turns `running` + a finished job with an error into `failed` (with the
|
||
error), and the page offers a re-run.
|
||
|
||
## Tests
|
||
|
||
- [`tests/test_magazine.py`](../tests/test_magazine.py) — 100 tests on the pure
|
||
geometry/classification logic: column reading order, the document-baseline
|
||
regression, furniture, pull quotes, contents parsing, continuation linking,
|
||
advert pages, paragraph-grouped OCR, fuzzy running-furniture matching, the
|
||
vision OCR quality score / region parsing / block building / HTTP call
|
||
(network mocked), sidebar detection (including the full-width-headline
|
||
regression), image-to-article assignment, the whole-page vision fallback,
|
||
and the multi-engine fallback chain.
|
||
- [`tests/test_magazine_routes.py`](../tests/test_magazine_routes.py) — 10 tests: path
|
||
traversal rejection, review gating, LLM script parsing including malformed
|
||
output.
|
||
- [`tests/magazine.spec.js`](../tests/magazine.spec.js) — Playwright, backend mocked to the
|
||
real response contract: library listing, review pre-selection, approval,
|
||
sentence highlighting + PDF-page linking, the podcast approval gate, and a
|
||
full-flow console-error check.
|
||
|
||
Both run without PyMuPDF: the I/O functions import it lazily so the logic worth
|
||
testing needs no native dependency or sample PDF.
|
||
|
||
Verified end-to-end against a synthetic 8-page issue (contents page, two-column
|
||
feature with a byline and a page-8 jump, full-page advert, second feature,
|
||
reviews) and against an image-only rendering of the same file to exercise OCR.
|
||
|
||
## Vision OCR escalation
|
||
|
||
Engines → OCR & Document Parsing lists layout-aware parsers (KDL Frontier Parser
|
||
nano, Baidu Unlimited-OCR) as connection-probed cards; those cards are still
|
||
presentation/health-check only. Ingestion itself now dispatches to whichever
|
||
OpenAI-compatible vision engine is configured (`vision_ocr_url` /
|
||
`vision_ocr_model` in Settings, `vision_ocr_enabled` gate) — `vision_ocr_page`
|
||
sends the rendered page image with a plain transcription prompt and parses the
|
||
engine's own labeled-region reply (`parse_vision_ocr_regions`). Escalation
|
||
triggers per page, not for the whole document: a page needing OCR at all
|
||
(`native_chars < 120`), or one whose already-extracted text — native
|
||
*or* tesseract — scores below `ocr_quality_score`'s coarse garbage threshold.
|
||
Any failure (unreachable engine, timeout, unparseable output, or a decoding
|
||
loop — `_looks_like_repetition_loop` treats a short phrase repeated 8+ times
|
||
back-to-back as a failed call, confirmed live against a Qwen VL engine that
|
||
transcribed a chart as "das Produkt, das Produkt, ..." for its entire
|
||
response budget) returns `None` and the page falls back to tesseract exactly
|
||
as if vision OCR were not configured; ingestion never fails because of it.
|
||
|
||
**Verified live** against `ocr-baidu-unlimited-ocr` on a real 28-page scanned
|
||
c't magazine excerpt (Epson ES-580W, no scanner-side OCR) — every garbled
|
||
title tesseract had produced was replaced by the real headline:
|
||
|
||
| Before (tesseract) | After (vision OCR) |
|
||
|---|---|
|
||
| `K \| soll Arbeit abnehmen…` | `1. Irrtum: KI ist ein allgemeiner Jobkiller` |
|
||
| `. \|` | *(furniture, correctly excluded)* |
|
||
| `@)X` | *(furniture, correctly excluded)* |
|
||
| `Owns mee` | `Das Team: „Hive Mind" frisst Konzentration` |
|
||
| `Wie KI Entwickler-Jobs verandert` | `Wie KI Entwickler-Jobs verändert` |
|
||
|
||
Furniture detection went from 44 to 151 blocks on the same document, because
|
||
the engine labels running headers/footers/page numbers directly instead of
|
||
`cluster_running_furniture` having to recover them after the fact from noisy
|
||
OCR variants. 26 of 28 pages used the vision path; the remaining 2 fell back
|
||
to tesseract automatically (a transient failure or timeout — not investigated
|
||
further, since the fallback is exactly the intended behaviour). Article count
|
||
did not drop much (26 → 25): the residual "over-fragmentation" is now clearly
|
||
attributable to the document itself — a dossier of numbered "1. Irrtum" /
|
||
"2. Irrtum" sub-sections and an interview with `c't: …` question headers, all
|
||
of which read as headline-fallback article starts absent a contents page —
|
||
not to OCR quality, which the review screen is exactly the tool for.
|
||
|
||
`ocr_quality_score` is deliberately coarse: it discriminates "mostly symbol
|
||
noise" from "plausible prose" well enough to gate an expensive vision-OCR
|
||
call, but a single plausible-looking misread inside an otherwise normal
|
||
sentence (confirmed live: tesseract's "KI" → "K |" substitution, 7 of 8 words
|
||
still genuine German) scores just as high as clean text. Catching that class
|
||
of error needs a dictionary or language-model check, out of scope here.
|
||
|
||
### Second engine and fallback ladder
|
||
|
||
`vision_ocr_page_chain` tries each configured (url, model) pair in order,
|
||
first usable result wins — primary (`vision_ocr_url`/`_model`), then fallback
|
||
(`vision_ocr_fallback_url`/`_model`), then tesseract if every vision engine
|
||
fails or is unreachable. KDL Frontier Parser nano is wired as the fallback,
|
||
confirmed live: it answers markedly faster than Baidu (~12s vs ~40s per page
|
||
on the same test page) but returns **plain text with no coordinate markers
|
||
at all**, unlike Baidu's labeled `text [x0,y0,x1,y1]content` regions.
|
||
`_whole_page_region` is what makes that still usable rather than discarded:
|
||
the whole response becomes one ambiguous full-page "text" region (bbox =
|
||
the entire rendered page, read via PIL), gated by the same
|
||
`ocr_quality_score` threshold used for native/tesseract text so a genuinely
|
||
bad KDL response isn't accepted either. Column and reading-order structure
|
||
is lost for a page handled this way — a real text improvement over
|
||
tesseract, but a worse layout result than Baidu's, which is why it is the
|
||
fallback and not the default.
|
||
|
||
A formal cross-engine accuracy benchmark remains TODO P2.
|
||
|
||
### Figure bounds come from the render, not the engine
|
||
|
||
A vision OCR engine grounds a figure far less precisely than it transcribes
|
||
one. Measured over all 138 figure regions of `c't KI Wissen 2026` (204 pages,
|
||
Baidu Unlimited-OCR): text boxes matched the page render closely enough to
|
||
use as-is, while figure boxes were routinely offset, undersized, or landed on
|
||
the neighbouring text column — page 23's card grid was reported at
|
||
`x 0.24–0.54` of the page where the graphic actually runs `0.41–0.95`, and
|
||
page 20's full-width illustration was cut off at a third of its height. The
|
||
transcribed *contents* of those same regions were right, which is the useful
|
||
signal: the model reliably says **which** graphic it means, so the render is
|
||
asked **where** that graphic ends.
|
||
|
||
`snap_visual_bboxes()` (core/magazine.py) does that, on a coarse grid of the
|
||
rendered page — `page_ink_grid()` reduces it to the inked fraction of each
|
||
10 px cell, which separates graphics from type cleanly: body copy covers
|
||
roughly a quarter of the cells it runs through, a photo or filled panel
|
||
covers nearly all of them.
|
||
|
||
**Two passes, not one.** A strict "solid ink" pass (`_SNAP_SOLID = 0.55`)
|
||
finds and repositions onto a densely inked anchor — a photo, a filled UI
|
||
panel, a card in a tiled grid — which behaves exactly like a photo and needs
|
||
nothing more. A bar chart's thin bars, hairline gridlines and axis labels
|
||
rarely reach that density: measured on page 18 of the same issue, most bar
|
||
rows covered 0.25–0.5 of their cell, so the strict pass alone anchored only
|
||
on the one fully-solid element (the vertical zero-line) and returned a box
|
||
cropped to a third of the real chart. A second, looser pass
|
||
(`_grow_to_bounds`, `_SNAP_ANY_INK = 0.15`) grows that anchor — or, lacking
|
||
one, the seed itself — through any real mark at all, stopping at a blocked
|
||
cell or `_SNAP_MAX_BLANK_RUN` consecutive blank ones, which is what recovers
|
||
the rest of a sparse chart without also absorbing a neighbouring column.
|
||
|
||
**Connectivity is decided before an anchor is picked, not by iteratively
|
||
re-joining whichever component a seed happens to touch first.** The solid
|
||
mask is dilated by `_SNAP_GAP_CELLS` (fenced by the same blocked cells at
|
||
every step) and labelled *once*; the seed then picks whichever merged
|
||
component it overlaps most. This was a real, measured bug in the first
|
||
version of this pass: selecting "the component with the most overlap"
|
||
directly from the seed's own raw components meant a *small* seed touching
|
||
only the chart's zero-line picked that line as the anchor, while re-running
|
||
the same function on the box it had just produced — now large enough to
|
||
also touch the page's dense axis-label strip — picked *that* instead and
|
||
shrank an already-correct box back down. Dilating first and labelling once
|
||
makes the choice a property of the page's ink alone, not of how large the
|
||
seed happens to be when it's asked.
|
||
|
||
**A bounding box is a rectangle, not the component's actual shape**, so
|
||
merging two patches that flank a stretch of unrelated text can still enclose
|
||
that text even though neither patch's own cells ever touched it (confirmed
|
||
on page 22: a chart's zero-line and its axis-label strip, merged correctly,
|
||
produced a box that also covered two lines of an unrelated paragraph sitting
|
||
between them). `_trim_blocked_edges()` pulls each edge back to the nearest
|
||
row or column blocked across most of the box's width or height, searched
|
||
outward from the box's own centre.
|
||
|
||
**Adjacent figures fence each other, and that fencing must not oscillate.**
|
||
Blocking every *other* figure's own seed area (not just text) is what stops
|
||
one figure's loose growth from bridging into a neighbour's territory just
|
||
because the gutter between them is narrow and mostly blank — two
|
||
head-and-shoulders portraits on page 31 sit only ~12pt (1.8 grid cells)
|
||
apart, well inside what blank-run tolerance alone would cross. But fencing
|
||
figure *i* against figure *j*'s box, and vice versa, makes each one's result
|
||
depend on where the other currently sits: a mutual dependency a single pass
|
||
cannot resolve consistently, and in the worst measured case (two dark photos
|
||
sharing a caption strip, page 158) never settled at all, swinging by over
|
||
100pt call after call. `snap_visual_bboxes()` now re-seeds every visual on a
|
||
page from every other's last result for up to `_SNAP_JOINT_ROUNDS` rounds,
|
||
stopping early once nothing changes — which converges the common case and,
|
||
for the pair that doesn't truly converge, reaches the same deterministic
|
||
stopping point every time this function is called, because a later call
|
||
starts from exactly the state the loop stopped at rather than re-diverging.
|
||
Ambiguity between two seeds pointing at the *same* graphic is decided once,
|
||
fenced against the engine's original seeds before any of this iteration runs
|
||
— iterating first would let two disputed seeds mutually fence each other
|
||
into a tidy-looking split of one graphic instead of ever being recognised as
|
||
the same disputed claim.
|
||
|
||
Fences that keep the whole pass from making things worse, because a box
|
||
confidently snapped onto the wrong thing is worse than a loose one the user
|
||
can see:
|
||
|
||
- **Text blocks, and every other figure's own seed, are excluded from the
|
||
mask up front.** They are the one trustworthy boundary on the page.
|
||
- **Acceptance is explicit.** An ungrown result must be either solidly inked
|
||
(`_SNAP_MIN_FILL`) or framed on nearly all of its rows *and* columns — the
|
||
second test is what admits a UI screenshot, which is white inside and
|
||
inked only at its edge — and no result may shrink below
|
||
`_SNAP_MIN_SEED_AREA` of the engine's box. A *grown* result skips the
|
||
density re-test, which a chart's mostly-white interior would fail by
|
||
construction, but still enforces the area floor.
|
||
- **A result must reproduce itself.** Before being accepted, a candidate is
|
||
fed back through the same rules once more; if it does not come back
|
||
looking like itself (intersection over union below `_SNAP_STABLE_IOU`),
|
||
it is discarded rather than published as something the very next run
|
||
would replace with a substantially different box.
|
||
- **Two figures pulled onto one graphic revert.** Either the page is one
|
||
picture the engine read as two or the reverse; neither reading can be
|
||
trusted over the other.
|
||
|
||
Result on that issue: 98–119 of 147 figure regions corrected depending on
|
||
which pass of this work is measured, none made worse on visual inspection of
|
||
the sample, the rest left alone (pale charts on white, mostly, which the ink
|
||
test correctly declines — see the accompanying limitation in `Manual.md`).
|
||
The whole pass is idempotent by construction now, not merely by observation:
|
||
an already-settled box reproduces itself, and even the one pair confirmed to
|
||
never mathematically converge (page 158's dark photos) lands on the same
|
||
answer every time rather than continuing to drift. That is what lets it run
|
||
outside ingestion: `refresh_visual_regions()` re-snaps on every re-detection,
|
||
and `POST /api/magazine/{id}/figures/refine` re-snaps a whole issue as a
|
||
background job, so a document ingested before this pass existed — or before
|
||
a later fix to it — is corrected without paying for its transcription a
|
||
second time. It replaced `refine_picture_bounds()`, which only recognised
|
||
*coloured* panels and had no text fence.
|
||
|
||
The snap runs before `crop_visual_regions()` in both OCR paths: the figure
|
||
files shown during playback are cropped from these boxes, so a box corrected
|
||
late would leave a mis-framed image on disk.
|
||
|
||
The Active OCR Engine panel (Settings → Engines → OCR & Document Parsing)
|
||
mirrors the Active Language Model panel exactly — same endpoint/model
|
||
selects, same `/api/conversation/llm-models` fetch (it just proxies to
|
||
whatever URL it's given, not hardcoded to the LLM setting) — because an OCR
|
||
engine here is the same shape of problem: an OpenAI-compatible endpoint plus
|
||
a model name, just applied to `vision_ocr_url`/`vision_ocr_model` instead of
|
||
`llm_url`/`llm_model`. Local engine cards (`#dc-grid-ocr`) were already
|
||
supported by `renderLocalContainers` — that generic code handles all four
|
||
roles (`tts`/`stt`/`llm`/`ocr`) identically — but the OCR page had no
|
||
`.s-engines-page[data-page="ocr"]` markup at all until this change, so
|
||
`$('dc-grid-ocr')` was always null and the whole card grid silently never
|
||
rendered. Confirmed live: the page now shows both engines as ready-made
|
||
local cards (Connect/Start/Restart/Use as OCR/Show code snippet), matching
|
||
the LLM page, with no "Add Custom" dialog needed for either.
|
||
|
||
### A workflow-pipeline nav bar was bleeding into unrelated pages
|
||
|
||
`_wfUpdateHeaderNav` (utils.js) injects the audiobook pipeline's "Previous /
|
||
Next" bar and step hint into *every* `.section-head` in the document, with
|
||
Studio as the only named exclusion — confirmed live: the Engines page (LLM/
|
||
STT/TTS/OCR/Image Generation all share one `.section-head`) showed a
|
||
"Previous … Cast Audiobook ›" bar and a "STEP 1/8" hint that made no sense
|
||
there, since the pipeline's three destinations are `s-reader`, `s-library`
|
||
and `s-rehearser` only. Changed from a blocklist (name each unrelated
|
||
section as it's noticed) to an allowlist (`head.closest('#s-reader,
|
||
#s-library, #s-rehearser')`) so the same bleed-through cannot recur on
|
||
Magazines, Settings, or any future section.
|
||
|
||
## Known limitations
|
||
|
||
- Sidebar detection (`mark_sidebars`) is geometry-only (an anomalously narrow
|
||
column) — it has no notion of a boxed border or background tint, so a
|
||
false positive/negative is possible on an unusual layout. Review always
|
||
shows every sidebar block and lets it be ticked back in with one click.
|
||
- Word-level highlighting is an *estimate* (proportional to character length
|
||
within the sentence's real measured audio duration), not true forced
|
||
alignment — see "Word-level highlighting is estimated, not measured" above.
|
||
- `continued from page N` is parsed but not used for linking — only the forward
|
||
`continued on page N` reference drives it.
|
||
- Reviews-style pages with no headline are absorbed by the preceding article
|
||
when the contents page does not list them.
|
||
- KDL Frontier Parser nano (the OCR fallback engine) returns no layout
|
||
coordinates, so a page it handles loses column/reading-order structure
|
||
even though its text is usable — see "Second engine and fallback ladder".
|
||
- No formal cross-engine OCR accuracy benchmark exists; the engine
|
||
comparison above is one confirmed live test, not a systematic evaluation.
|
||
- Image descriptions, table/chart handling, and EPUB 3 Media Overlay export
|
||
remain P2 and are not started.
|