From 5adba6b4c08ba30dbd9ca39a7a8e954951b1a409 Mon Sep 17 00:00:00 2001
From: mARTin-B78
Date: Mon, 7 Sep 2026 14:00:34 +0200
Subject: [PATCH] feat: split-screen article review with numbered regions,
per-page/whole-magazine "OCR again", and a real column-interleaving fix it
found
Split-screen review: the rendered page (left) with a numbered box per
detected block, the extracted text (right) with matching numbers.
Clicking either side highlights the other. Checkboxes moved from a flat
list into this layout without changing their semantics (still gate
what gets narrated).
"OCR again": reocr_page() forces one page through the configured
vision engine again, replacing only that page's blocks -- deliberately
does NOT call build_articles(), so an already-approved article is not
silently re-grouped. A magazine-level option re-reads every page and
re-runs detection (with a confirmation, since that does reset article
status). extract_pages() gained force_vision_ocr to skip its quality
gate for this case.
Building the numbered-box review immediately did what it was built to
do -- surfaced a real bug on live production data (real magazine, real
scan, not a synthetic fixture): a photo caption at 49.9% of the page
width sat in the gutter between two genuinely separate columns and
merged them into one, so every block on the page came back column=0
and reading order fell back to pure top-to-bottom, interleaving the
two columns' sentences. Fixed two compounding causes: column boundary
detection now excludes blocks much wider than the page's median width,
and detect_columns' gutter threshold (3.5%) was itself narrower than
some real magazine gutters (~2.1% measured) -- lowered to 1.5%.
10 new/updated tests, including one built directly from the real
measured page-17 block coordinates that exposed the bug.
Co-Authored-By: Claude Sonnet 5
---
CHANGELOG.md | 27 ++++
architecture/magazine-ingestion.md | 18 +++
core/magazine.py | 92 +++++++++++++-
routes/magazine.py | 78 +++++++++++-
static/js/magazine.js | 191 ++++++++++++++++++++++++++---
static/sections/s-magazine.html | 3 +
static/style.css | 57 ++++++++-
tests/test_magazine.py | 135 ++++++++++++++++++++
8 files changed, 573 insertions(+), 28 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd2868e..feb2955 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,34 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
+### Added
+- **Split-Screen Article Review, "OCR Again", Reading-Order Fixes.** Review shows the rendered page on the left with a
+ numbered box per detected region and the extracted text on the right with matching numbers — clicking either side
+ highlights the other. Building this immediately surfaced a real bug on live production data (see Fixed below).
+ A per-page **"OCR again"** button forces that one page through the configured vision engine again without touching
+ article grouping or approvals (`reocr_page`); a magazine-level **"OCR again (whole magazine)"** re-reads every page
+ and re-runs detection, with a confirmation when articles were already reviewed since that resets their status.
+
### Fixed
+- **Two Real Magazine Columns Interleaved Into One.** Found live using the new split-screen review's numbered boxes on
+ a real scanned c't magazine page: a photo caption at 49.9% of the page width sat in the gutter between two genuinely
+ separate columns and merged them into a single detected column, so every block on the page came back `column=0` and
+ reading order fell back to pure top-to-bottom — splicing the two columns' sentences together (a box the review
+ numbered 6, physically above the box numbered 1, was read after it). Column boundary detection now excludes blocks
+ much wider than the page's median block width; separately, the real gutter measured on that page was itself only
+ ~2.1% of the page width, narrower than `detect_columns`' 3.5% threshold — lowered to 1.5%.
+- **Magazine "Forgets" the Open Document.** A full page reload or the browser's own Back button tears down all
+ in-memory state; the magazine section always landed back on the empty library with no way back to what was open.
+ Now persists the last-opened magazine id and reopens it automatically. The actual root cause of a reported duplicate:
+ Studio/Reader's book library and Library → Books both listed every document with no `kind` filter, so the magazine
+ was already showing up there disguised as a plain book — it had not vanished, it was misfiled, which is what led to
+ a re-upload and a genuine duplicate under a different kind. Both listings now exclude `kind: "magazine"`.
+- **Confidence Badge Layout (Studio Characters Review).** The badge rendered on its own line far below the speaker
+ name because both the name and the article text were `display: block`, forcing a line break regardless of markup
+ order. Wrapped the name, badge and check button in a shared flex row so the badge now sits directly beside the name.
+- **docker-socket-proxy Crash Loop.** `read_only: true` left the container's own entrypoint nowhere to write its
+ `haproxy.cfg`, crash-looping it and leaving the app without Docker access. `cap_drop: ALL`, `no-new-privileges` and
+ the `:ro` socket mount already provide the real hardening, so `read_only` was removed for this one service.
- **Magazine Dropzone Opened the Dropped File in the Browser Instead of Accepting It.** `dragover`'s `preventDefault()`
was gated on `dataTransfer.types` including `'Files'`, which Chromium does not reliably populate synchronously on
every `dragover` event during a real OS file drag — the condition failed often enough that the element never
diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md
index 80451be..e6ce877 100644
--- a/architecture/magazine-ingestion.md
+++ b/architecture/magazine-ingestion.md
@@ -78,6 +78,24 @@ magazines change column count per page — a fixed grid is wrong. Blocks spannin
>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
diff --git a/core/magazine.py b/core/magazine.py
index da75e1d..d6ebe95 100644
--- a/core/magazine.py
+++ b/core/magazine.py
@@ -141,13 +141,20 @@ def bbox_overlaps(a: Sequence[float], b: Sequence[float], tol: float = 0.0) -> b
def detect_columns(blocks: Sequence[Block], page_width: float,
- min_gap_ratio: float = 0.035) -> list[tuple[float, float]]:
+ min_gap_ratio: float = 0.015) -> list[tuple[float, float]]:
"""Infer the column grid from where blocks are *absent*.
Magazines vary the column count per page, so a fixed grid is wrong. The
reliable signal is vertical whitespace running the height of the page: we
project every block onto the x axis, then split wherever the uncovered gap
is wider than a few percent of the page. Returns (left, right) spans.
+
+ 1.5%, not the rounder 3.5% this started at: measured on a real scanned
+ c't magazine page, the actual gutter between two genuine columns was
+ only ~2.1% of the page width -- narrower than the old threshold, which
+ made detect_columns see one merged column and, combined with a wide
+ caption bridging the same gap (see assign_reading_order), produced
+ every block on the page landing in column=0.
"""
spans = sorted(
((b.bbox[0], b.bbox[0] + b.bbox[2]) for b in blocks if b.bbox[2] > 1),
@@ -171,8 +178,23 @@ def assign_reading_order(blocks: list[Block], page_width: float) -> list[Block]:
Sorting purely by y interleaves parallel columns, which is precisely the
failure that splices one article's sentence into another's.
+
+ Column BOUNDARIES are detected from blocks close to the page's typical
+ width only. Confirmed live on a real scanned magazine: a caption running
+ under a photo, at 49.9% of the page width -- under the "spans the whole
+ page" threshold below, so not given special handling -- sat squarely in
+ the whitespace gap between two real text columns and bridged them into
+ one merged span, which meant EVERY block on the page got column=0 and
+ reading order fell back to pure top-to-bottom, splicing the two
+ columns' sentences together (the exact failure this function exists to
+ prevent). Every block, wide ones included, is still assigned to
+ whichever column its centre or overlap lands in once boundaries are
+ known -- only the boundary DETECTION excludes them.
"""
- columns = detect_columns(blocks, page_width)
+ widths = sorted(b.bbox[2] for b in blocks if b.bbox[2] > 1)
+ median_w = widths[len(widths) // 2] if widths else 0
+ narrow = [b for b in blocks if median_w <= 0 or b.bbox[2] <= median_w * 1.4]
+ columns = detect_columns(narrow if len(narrow) >= 2 else blocks, page_width)
def column_of(b: Block) -> int:
centre = b.bbox[0] + b.bbox[2] / 2
@@ -1003,6 +1025,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
vision_ocr_fallback_url: str | None = None,
vision_ocr_fallback_model: str | None = None,
vision_ocr_timeout: float = 150.0,
+ force_vision_ocr: bool = False,
progress: Any = None) -> dict[str, Any]:
"""Run the full extraction for one PDF, writing page renders and images.
@@ -1111,7 +1134,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
ocr_quality_score(" ".join(b.text for b in blocks))
if not needs_ocr else 0.0
)
- needs_escalation = needs_ocr or native_quality < _VISION_QUALITY_THRESHOLD
+ needs_escalation = force_vision_ocr or needs_ocr or native_quality < _VISION_QUALITY_THRESHOLD
vision_blocks: list[Block] | None = None
if vision_enabled and needs_escalation:
@@ -1326,6 +1349,69 @@ def build_articles(layout: dict[str, Any]) -> list[dict[str, Any]]:
return out
+def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
+ vision_ocr_url: str, vision_ocr_model: str,
+ vision_ocr_fallback_url: str | None = None,
+ vision_ocr_fallback_model: str | None = None,
+ timeout: float = 150.0) -> dict[str, Any] | None:
+ """Force one page through the vision OCR chain again with the engine the
+ user picked, replacing only that page's blocks in an already-ingested
+ layout.
+
+ Unlike extract_pages()'s automatic escalation, this always calls the
+ engine regardless of the page's existing text quality -- a user pressing
+ "OCR again" has already judged the result unusable and is not asking for
+ another quality gate. Deliberately does NOT call build_articles(): an
+ article the user already reviewed and approved must not be silently
+ re-grouped and lose that approval just because one of its pages was
+ re-read. The caller decides whether to re-run detection separately.
+
+ Returns the updated layout, or None if the page has no rendered PNG or
+ the engine returned nothing usable (network failure, empty response) --
+ the caller keeps the existing blocks in that case, never discards good
+ data for a failed retry.
+ """
+ fitz = _require_fitz()
+ png = doc_dir / "pages" / f"{page_no:04d}.png"
+ if not png.is_file():
+ return None
+
+ page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
+ if not page_meta:
+ return None
+
+ engines = [(vision_ocr_url, vision_ocr_model),
+ (vision_ocr_fallback_url, vision_ocr_fallback_model)]
+ regions = vision_ocr_page_chain(png, engines, timeout=timeout)
+ if not regions:
+ return None
+
+ scale_x = page_meta["width"] / max(1.0, _png_width(png, fitz))
+ new_blocks = build_vision_blocks(regions, page_no, scale_x, id_prefix="r")
+
+ all_blocks = [Block(**b) for b in layout.get("blocks", [])]
+ kept = [b for b in all_blocks if b.page != page_no]
+
+ # The baseline must come from the rest of the document, not this page
+ # alone -- see classify_blocks' docstring on why a single page (which may
+ # have no body copy of its own) cannot be its own reference.
+ doc_base = body_font_size(kept) or body_font_size(all_blocks)
+ classify_blocks(new_blocks, page_meta["height"], page_meta["width"], base_size=doc_base)
+ mark_advert_pages(new_blocks)
+ mark_pullquotes(new_blocks)
+ assign_reading_order(new_blocks, page_meta["width"])
+ mark_sidebars(new_blocks, page_meta["width"])
+
+ merged = kept + new_blocks
+ page_heights = {p["page"]: p["height"] for p in layout.get("pages", [])}
+ cluster_running_furniture(merged, page_heights)
+
+ layout = dict(layout)
+ layout["blocks"] = [b.to_json() for b in merged]
+ layout["vision_ocr_pages"] = int(layout.get("vision_ocr_pages") or 0) + 1
+ return layout
+
+
def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None:
(doc_dir / "layout.json").write_text(
json.dumps(layout, ensure_ascii=False), "utf-8")
diff --git a/routes/magazine.py b/routes/magazine.py
index 7ab4cc9..3a462e8 100644
--- a/routes/magazine.py
+++ b/routes/magazine.py
@@ -111,7 +111,8 @@ async def magazine_create(request: Request):
def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_url: str | None, vision_ocr_model: str | None,
vision_ocr_fallback_url: str | None = None,
- vision_ocr_fallback_model: str | None = None) -> None:
+ vision_ocr_fallback_model: str | None = None,
+ force_vision_ocr: bool = False) -> None:
"""Run the extraction pipeline; recorded as a restart-safe job."""
d = _LIB / doc_id
pdf = d / "source.pdf"
@@ -131,6 +132,7 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_model=vision_ocr_model,
vision_ocr_fallback_url=vision_ocr_fallback_url,
vision_ocr_fallback_model=vision_ocr_fallback_model,
+ force_vision_ocr=force_vision_ocr,
progress=progress)
mag.save_layout(d, layout)
@@ -203,6 +205,13 @@ async def magazine_ingest(doc_id: str, request: Request):
if not (vision_ocr_fallback_url and vision_ocr_fallback_model):
vision_ocr_fallback_url = vision_ocr_fallback_model = None
+ # "OCR again" for the whole magazine: skip the quality gate and send
+ # every page through the vision engine regardless of how good its
+ # existing text already looks. Only meaningful with vision OCR actually
+ # enabled -- silently ignored otherwise, since there is no engine to
+ # force a page through.
+ force_vision_ocr = bool(body.get("force_vision_ocr")) and vision_ocr_url is not None
+
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-ingest", fingerprint=doc_id)
meta["ingest"] = {"status": "running", "job_id": job_id}
@@ -211,7 +220,8 @@ async def magazine_ingest(doc_id: str, request: Request):
asyncio.create_task(
asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi,
vision_ocr_url, vision_ocr_model,
- vision_ocr_fallback_url, vision_ocr_fallback_model)
+ vision_ocr_fallback_url, vision_ocr_fallback_model,
+ force_vision_ocr)
)
return {"job_id": job_id}
@@ -356,6 +366,70 @@ async def magazine_page_image(doc_id: str, page: int):
return FileResponse(png, media_type="image/png")
+def _reocr_page_sync(doc_id: str, job_id: str, page: int,
+ vision_ocr_url: str, vision_ocr_model: str,
+ vision_ocr_fallback_url: str | None,
+ vision_ocr_fallback_model: str | None) -> None:
+ """"OCR again" on one page with the engine the user picked. Restart-safe
+ job for the same reason ingestion is: a vision OCR call can take up to
+ ~40s (confirmed live), too long for a plain request/response cycle.
+ """
+ d = _LIB / doc_id
+ try:
+ layout = mag.load_layout(d)
+ if not layout:
+ raise FileNotFoundError("No layout yet -- run ingestion first")
+ background_job_update(job_id, stage="reocr", detail=f"Re-reading page {page}")
+ updated = mag.reocr_page(d, layout, page, vision_ocr_url, vision_ocr_model,
+ vision_ocr_fallback_url, vision_ocr_fallback_model)
+ if updated is None:
+ raise RuntimeError("The OCR engine returned nothing usable for this page")
+ mag.save_layout(d, updated)
+ background_job_update(job_id, stage="done", done=True,
+ detail=f"Page {page} re-read", result={"page": page})
+ except Exception as exc: # noqa: BLE001 - surfaced to UI
+ background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
+
+
+@router.post("/api/magazine/{doc_id}/pages/{page}/reocr")
+async def magazine_reocr_page(doc_id: str, page: int, request: Request):
+ """Force one page through the configured vision OCR engine again,
+ regardless of its current text quality -- for when the user has already
+ judged the result unusable. Only replaces that page's blocks; article
+ boundaries and approvals are left untouched (call the ingest endpoint's
+ detection separately if the article needs re-grouping too).
+ """
+ d, _ = _require_magazine(doc_id)
+ if page < 1 or page > 10000:
+ raise HTTPException(400, "Bad page")
+ if not (d / "pages" / f"{page:04d}.png").is_file():
+ raise HTTPException(404, "Page not rendered -- run ingestion first")
+
+ body = {}
+ try:
+ body = await request.json()
+ except Exception:
+ pass
+ settings = _load_settings()
+ vision_ocr_url = str(body.get("vision_ocr_url") or settings.get("vision_ocr_url") or "").strip()
+ vision_ocr_model = str(body.get("vision_ocr_model") or settings.get("vision_ocr_model") or "").strip()
+ if not (vision_ocr_url and vision_ocr_model):
+ raise HTTPException(400, "No vision OCR engine configured -- set one in Settings")
+ vision_ocr_fallback_url = str(
+ body.get("vision_ocr_fallback_url") or settings.get("vision_ocr_fallback_url") or "").strip() or None
+ vision_ocr_fallback_model = str(
+ body.get("vision_ocr_fallback_model") or settings.get("vision_ocr_fallback_model") or "").strip() or None
+
+ job_id = uuid.uuid4().hex
+ background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}")
+ asyncio.create_task(
+ asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page,
+ vision_ocr_url, vision_ocr_model,
+ vision_ocr_fallback_url, vision_ocr_fallback_model)
+ )
+ return {"job_id": job_id}
+
+
@router.get("/api/magazine/{doc_id}/images/{name}")
async def magazine_image(doc_id: str, name: str):
d, _ = _require_magazine(doc_id)
diff --git a/static/js/magazine.js b/static/js/magazine.js
index 0f73fdb..63e52eb 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -233,6 +233,30 @@ async function magIngest() {
}
}
+// "OCR again" for the whole issue: forces every page through the vision
+// engine regardless of its current quality, then re-detects articles --
+// which is why this asks for confirmation. A single page's "OCR again" in
+// review does not touch article grouping; re-reading the whole document
+// does, since detection runs again over the freshly re-read text.
+async function magReocrMagazine() {
+ if (!state.doc) return;
+ const hasReviewed = state.articles.some((a) => a.status !== 'detected');
+ if (hasReviewed && !confirm(
+ 'Re-reading the whole magazine runs article detection again. ' +
+ 'Any articles you already approved or rejected will need reviewing again. Continue?'
+ )) return;
+ try {
+ await api('/api/magazine/' + state.doc.id + '/ingest', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ force_vision_ocr: true }),
+ });
+ pollIngest();
+ } catch (e) {
+ say('Could not start OCR: ' + e.message, 'err');
+ }
+}
+
// "Analysing page 28 of 204" -> a real percentage, not just spinner text.
function magParsePageProgress(detail) {
const m = /page (\d+) of (\d+)/i.exec(detail || '');
@@ -364,6 +388,11 @@ async function magReview(articleId) {
if (!article) return;
state.article = article;
await ensureLayout();
+ // The whole review is one page at a time -- showing every page of a
+ // 10-page feature stacked at once was unreadable and made the boxes tiny.
+ state.reviewSpan = magReviewSpan(article);
+ state.reviewPage = state.reviewSpan[0] || null;
+ _magReviewChecked = null;
magShow('review');
renderReview();
}
@@ -385,18 +414,35 @@ function blocksForPages(pages) {
.sort((a, b) => a.page - b.page || a.reading_order - b.reading_order);
}
+// Offer neighbouring pages too, so a boundary that started a page late (or a
+// jump tail that should have been included) can be corrected without
+// leaving the screen.
+function magReviewSpan(article) {
+ if (!article.pages.length) return [];
+ return Array.from(new Set(article.pages.concat(
+ [article.pages[0] - 1, article.pages[article.pages.length - 1] + 1])))
+ .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y);
+}
+
+function magReviewGoPage(page) {
+ if (!state.reviewSpan.includes(page)) return;
+ state.reviewPage = page;
+ renderReview();
+}
+
function renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
const included = new Set(a.blocks || []);
- // Offer neighbouring pages too, so a boundary that started a page late can be
- // corrected without leaving the screen.
- const span = a.pages.length
- ? Array.from(new Set(a.pages.concat([a.pages[0] - 1, a.pages[a.pages.length - 1] + 1])))
- .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y)
- : [];
const conf = confidenceLabel(a.confidence);
+ const page = state.reviewPage;
+ const pageMeta = (state.layout.pages || []).find((p) => p.page === page);
+ const pageBlocks = page == null ? [] : blocksForPages([page]);
+
+ const spanIdx = state.reviewSpan.indexOf(page);
+ const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
+ const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null;
let html =
'
' +
@@ -405,27 +451,65 @@ function renderReview() {
'
' + esc(conf.text) + '
' +
'
' +
'
Untick anything that is not part of this article — a sidebar, an advert, ' +
- 'or a neighbouring piece. Only ticked text is read aloud.
';
+ 'or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, ' +
+ 'or its matching entry on the right, to line the two up.