feat(magazine): live region boxes in the import preview; interrupted imports no longer spin for ever
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>
This commit is contained in:
mARTin-B78 2026-09-22 01:09:12 +02:00
parent 703052d1d2
commit 1edc941dbf
12 changed files with 454 additions and 153 deletions

View File

@ -7,6 +7,15 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
## [1.42.0] — 2026-09-22
### Added
- **Magazine import: watch the regions being found.** The live page preview draws every region over the page as it is recognised — coloured by type, numbered in reading order, with a legend — and follows the second pass (*Recognising paragraphs, headings and advertisements*) page by page, so you can see boxes change type; the progress line shows which page it is on.
### Fixed
- **An interrupted magazine import spun for ever.** When a server restart killed the import job, the magazine stayed "running" and the page kept showing "Recognising paragraphs…"; it now reports the interruption so the import can be started again.
- The live preview wrote nothing during the second pass (its regions were in a format the text preview could not read).
## [1.41.1] — 2026-09-22 ## [1.41.1] — 2026-09-22
### Fixed ### Fixed

View File

@ -97,7 +97,15 @@ turned into a podcast discussion between two or three voices you pick.
of watching a bare progress bar. This preview is a quick approximation, of watching a bare progress bar. This preview is a quick approximation,
not the final read: it runs before the layout is fully classified, so a not the final read: it runs before the layout is fully classified, so a
heading guess or an excluded marking here can still change once review heading guess or an excluded marking here can still change once review
opens. opens. The page image also shows the **regions as they are found**: each
box is drawn over the page, coloured by type (headline, paragraph,
caption, picture, advert, running head …, see the legend under the page)
and numbered in reading order. After all pages are read, the second pass
(*Recognising paragraphs, headings and advertisements*) walks the pages
again and the preview follows it, so you can watch boxes change type —
the progress bar then reads "… — page 12 of 204". If a run is interrupted
(for example when the server restarts), the status now says so instead
of spinning forever; start it again with *Re-run detection*.
3. **Review the articles.** This step is not optional and cannot be skipped. 3. **Review the articles.** This step is not optional and cannot be skipped.
Each proposed article shows its title, author, page range, estimated Each proposed article shows its title, author, page range, estimated
listening time and a confidence score. The review screen is split: the listening time and a confidence score. The review screen is split: the

View File

@ -1 +1 @@
1.41.1 1.42.0

View File

@ -371,6 +371,22 @@ image gives the region a new id, and the route moves its **article membership**
are listed separately on an article), so a converted figure stays in the article being reviewed. The 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. 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
- [`tests/test_magazine.py`](../tests/test_magazine.py) — 100 tests on the pure - [`tests/test_magazine.py`](../tests/test_magazine.py) — 100 tests on the pure

View File

@ -2137,6 +2137,9 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
# one side, recognised text on the other, filled in as each # one side, recognised text on the other, filled in as each
# page finishes) without waiting for the document-wide passes # page finishes) without waiting for the document-wide passes
# below. # below.
try:
progress(page_no, doc.page_count, blocks, page_images, page_size=(rect.width, rect.height))
except TypeError:
try: try:
progress(page_no, doc.page_count, blocks, page_images) progress(page_no, doc.page_count, blocks, page_images)
except TypeError: except TypeError:

View File

@ -120,6 +120,51 @@ async def magazine_create(request: Request):
_INGEST_PREVIEW_BUDGET = 8000 _INGEST_PREVIEW_BUDGET = 8000
def _ingest_preview_boxes(blocks: list, images: list | None, size: tuple[float, float] | None) -> list[dict]:
"""The page's regions as boxes in percent of the page, in reading order, for the live preview."""
if not size or not size[0] or not size[1]:
return []
w, h = float(size[0]), float(size[1])
def get(b, key, default=None):
return b.get(key, default) if isinstance(b, dict) else getattr(b, key, default)
boxes = []
ordered = sorted(blocks or [], key=lambda b: (get(b, "reading_order", 0) or 0))
for n, b in enumerate(ordered, 1):
x, y, bw, bh = (float(v) for v in get(b, "bbox", [0, 0, 0, 0]))
label = get(b, "region_label", "") or ""
kind = "page_number" if get(b, "type") == mag.BLOCK_FURNITURE and label == "page_number" else get(b, "type", "body")
boxes.append({"x": round(100 * x / w, 2), "y": round(100 * y / h, 2), "w": round(100 * bw / w, 2),
"h": round(100 * bh / h, 2), "type": str(kind)[:20], "n": n})
for img in images or []:
bbox = get(img, "bbox")
if not bbox:
continue
x, y, bw, bh = (float(v) for v in bbox)
boxes.append({"x": round(100 * x / w, 2), "y": round(100 * y / h, 2), "w": round(100 * bw / w, 2),
"h": round(100 * bh / h, 2), "type": "image", "n": None})
return boxes[:400]
def _write_ingest_preview(d: Path, page: int, blocks: list, images: list | None,
size: tuple[float, float] | None, stage: str) -> None:
"""The live "page being read" preview: the recognised text plus the page's boxes. Best-effort --
a write failure never aborts ingestion."""
from types import SimpleNamespace
# The labelling pass holds blocks as dicts; the text renderer reads attributes.
as_objects = [b if not isinstance(b, dict) else SimpleNamespace(
bbox=b.get("bbox") or [0, 0, 0, 0], type=b.get("type") or mag.BLOCK_BODY,
text=b.get("text") or "", font_size=float(b.get("font_size") or 0.0)) for b in blocks or []]
try:
(d / "ingest_preview.json").write_text(json.dumps({
"page": page, "stage": stage, "text": _render_ingest_preview_html(as_objects, images),
"boxes": _ingest_preview_boxes(blocks, images, size),
}, ensure_ascii=False), "utf-8")
except Exception:
logging.exception("Failed to write live ingest preview for page %s", page)
def _render_ingest_preview_html(blocks: list, images: list | None) -> str: def _render_ingest_preview_html(blocks: list, images: list | None) -> str:
"""Format one page's just-read blocks as simple HTML, in reading order. """Format one page's just-read blocks as simple HTML, in reading order.
@ -190,7 +235,8 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
if not pdf.is_file(): if not pdf.is_file():
raise FileNotFoundError("No source.pdf uploaded for this magazine") raise FileNotFoundError("No source.pdf uploaded for this magazine")
def progress(page: int, total: int, blocks: list, images: list | None = None) -> None: def progress(page: int, total: int, blocks: list, images: list | None = None,
page_size: tuple[float, float] | None = None) -> None:
background_job_update( background_job_update(
job_id, stage="extract", job_id, stage="extract",
detail=f"Analysing page {page} of {total}", detail=f"Analysing page {page} of {total}",
@ -198,15 +244,8 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
# Written to its own small file rather than the generic job # Written to its own small file rather than the generic job
# result: that field strips any key resembling "text"/"content" # result: that field strips any key resembling "text"/"content"
# (data-minimisation for other job kinds) and caps at 500 chars, # (data-minimisation for other job kinds) and caps at 500 chars,
# both wrong for this. Best-effort -- a write failure must never # both wrong for this.
# abort ingestion. _write_ingest_preview(d, page, blocks, images, page_size, "reading")
try:
preview_html = _render_ingest_preview_html(blocks, images)
(d / "ingest_preview.json").write_text(
json.dumps({"page": page, "text": preview_html}, ensure_ascii=False),
"utf-8")
except Exception:
logging.exception("Failed to write live ingest preview for page %s", page)
background_job_update(job_id, stage="extract", detail="Opening PDF") background_job_update(job_id, stage="extract", detail="Opening PDF")
layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, ocr_lang=ocr_lang, layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, ocr_lang=ocr_lang,
@ -216,9 +255,21 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_fallback_model=vision_ocr_fallback_model, vision_ocr_fallback_model=vision_ocr_fallback_model,
force_vision_ocr=force_vision_ocr, force_vision_ocr=force_vision_ocr,
progress=progress) progress=progress)
page_sizes = {p["page"]: (p.get("width"), p.get("height")) for p in layout.get("pages", [])}
total_pages = len(page_sizes)
def labelled(step: str):
def on_page(page_no: int) -> None:
background_job_update(job_id, stage="advertisements",
detail=f"{step} — page {page_no} of {total_pages}")
_write_ingest_preview(d, page_no, [b for b in layout.get("blocks", []) if b["page"] == page_no],
[i for i in layout.get("images", []) if i["page"] == page_no],
page_sizes.get(page_no), "labelling")
return on_page
background_job_update(job_id, stage="advertisements", detail="Recognising paragraphs, headings and advertisements") background_job_update(job_id, stage="advertisements", detail="Recognising paragraphs, headings and advertisements")
_recognise_advertisements(layout, _load_settings()) _recognise_advertisements(layout, _load_settings(), on_page=labelled("Finding advertisements"))
_recognise_semantic_layout(layout, _load_settings(), doc_dir=d) _recognise_semantic_layout(layout, _load_settings(), doc_dir=d, on_page=labelled("Recognising paragraphs and headings"))
mag.refresh_visual_regions(d, layout) mag.refresh_visual_regions(d, layout)
layout, _ = mag.clean_up_regions(layout) layout, _ = mag.clean_up_regions(layout)
mag.save_layout(d, layout) mag.save_layout(d, layout)
@ -318,6 +369,13 @@ async def magazine_ingest_status(doc_id: str):
d, meta = _require_magazine(doc_id) d, meta = _require_magazine(doc_id)
job_id = (meta.get("ingest") or {}).get("job_id") job_id = (meta.get("ingest") or {}).get("job_id")
job = background_job_get(job_id) if job_id else None job = background_job_get(job_id) if job_id else None
# A job that ended without finishing the ingest (a server restart kills it mid-run) left the
# magazine marked "running" for ever, and the page spun on "Recognising paragraphs…" with no
# way to see it had stopped. Report it as failed so it can be started again.
ingest = meta.get("ingest") or {}
if ingest.get("status") == "running" and job and job.get("done") and job.get("error"):
meta["ingest"] = {"status": "failed", "job_id": job_id, "error": str(job.get("error"))[:300]}
_write_meta(d, meta)
preview = None preview = None
try: try:
preview_path = d / "ingest_preview.json" preview_path = d / "ingest_preview.json"
@ -470,7 +528,7 @@ async def magazine_page_image(doc_id: str, page: int):
return FileResponse(png, media_type="image/png") return FileResponse(png, media_type="image/png")
def _recognise_advertisements(layout: dict, settings: dict, page: int | None = None) -> None: def _recognise_advertisements(layout: dict, settings: dict, page: int | None = None, on_page=None) -> None:
"""Use the configured LLM to identify advertising, region by region.""" """Use the configured LLM to identify advertising, region by region."""
from routes.conversation import _post_llm_chat_completion from routes.conversation import _post_llm_chat_completion
model = str(settings.get("llm_model") or "").strip() model = str(settings.get("llm_model") or "").strip()
@ -480,6 +538,7 @@ def _recognise_advertisements(layout: dict, settings: dict, page: int | None = N
pages = sorted({b["page"] for b in layout.get("blocks", []) if page is None or b["page"] == page}) pages = sorted({b["page"] for b in layout.get("blocks", []) if page is None or b["page"] == page})
failures = 0 failures = 0
for page_no in pages: for page_no in pages:
try:
blocks = [b for b in layout.get("blocks", []) if b["page"] == page_no] blocks = [b for b in layout.get("blocks", []) if b["page"] == page_no]
for start in range(0, len(blocks), 40): for start in range(0, len(blocks), 40):
batch = blocks[start:start + 40] batch = blocks[start:start + 40]
@ -521,6 +580,9 @@ def _recognise_advertisements(layout: dict, settings: dict, page: int | None = N
block.update(type=mag.BLOCK_AD, source="llm_ad", confidence=confidence) block.update(type=mag.BLOCK_AD, source="llm_ad", confidence=confidence)
except Exception: except Exception:
failures += 1 failures += 1
finally:
if on_page:
on_page(page_no)
ads = {b["id"] for b in layout.get("blocks", []) if b["type"] == mag.BLOCK_AD} ads = {b["id"] for b in layout.get("blocks", []) if b["type"] == mag.BLOCK_AD}
for image in layout.get("images", []): for image in layout.get("images", []):
if image.get("source_block") in ads: if image.get("source_block") in ads:
@ -528,7 +590,8 @@ def _recognise_advertisements(layout: dict, settings: dict, page: int | None = N
layout["advertisement_review"] = "partial_failure" if failures else "done" layout["advertisement_review"] = "partial_failure" if failures else "done"
def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None = None, doc_dir: Path | None = None) -> None: def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None = None, doc_dir: Path | None = None,
on_page=None) -> None:
from routes.conversation import _post_llm_chat_completion from routes.conversation import _post_llm_chat_completion
# OCR transcription and page-layout interpretation are different jobs. # OCR transcription and page-layout interpretation are different jobs.
# The latter needs a vision-language model because a column boundary, # The latter needs a vision-language model because a column boundary,
@ -543,6 +606,7 @@ def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None =
failures = 0 failures = 0
pages = sorted({b["page"] for b in layout.get("blocks", []) if page is None or b["page"] == page}) pages = sorted({b["page"] for b in layout.get("blocks", []) if page is None or b["page"] == page})
for page_no in pages: for page_no in pages:
try:
blocks = [b for b in layout["blocks"] if b["page"] == page_no] blocks = [b for b in layout["blocks"] if b["page"] == page_no]
content = [b for b in blocks if b["type"] != mag.BLOCK_FURNITURE and b.get("text", "").strip()] content = [b for b in blocks if b["type"] != mag.BLOCK_FURNITURE and b.get("text", "").strip()]
ad_blocks = [b for b in content if b["type"] == mag.BLOCK_AD] ad_blocks = [b for b in content if b["type"] == mag.BLOCK_AD]
@ -629,6 +693,9 @@ def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None =
layout["blocks"] = [b for b in layout["blocks"] if b["id"] not in ids] + grouped layout["blocks"] = [b for b in layout["blocks"] if b["id"] not in ids] + grouped
except Exception: except Exception:
failures += 1 failures += 1
finally:
if on_page:
on_page(page_no)
status = "partial_failure" if failures else "done" status = "partial_failure" if failures else "done"
layout["semantic_review"] = status layout["semantic_review"] = status
layout["advertisement_review"] = status layout["advertisement_review"] = status

View File

@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.41.1"> <link rel="stylesheet" href="/static/style.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.41.1"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.41.1"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.41.1"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.41.1"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.41.1"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.42.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -458,6 +458,60 @@ function magParsePageProgress(detail) {
return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) }; return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) };
} }
// ── Live boxes on the page being read ─────────────────────────────────────
// The regions of the page currently being read (and, in the labelling pass, being re-typed) are
// drawn over the page image as they arrive, coloured by type and numbered in reading order.
const MAG_PREVIEW_TYPES = {
title: 'Headline', heading: 'Subheading', deck: 'Standfirst', byline: 'Author', body: 'Paragraph',
caption: 'Caption', pullquote: 'Pull quote', sidebar: 'Sidenote', ad: 'Advertisement',
furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture',
figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code',
};
let _magPreviewSeen = { page: null, keys: new Set() };
// Fit the box layer to the part of the frame the page image actually covers ("contain" scaling
// leaves bars around it), so a box drawn at 50% sits at 50% of the page, not of the frame.
function magFitPreviewLayer() {
const img = $('mag-ingest-preview-img'), layer = $('mag-ingest-preview-boxes');
if (!img || !layer || !img.naturalWidth || !img.clientWidth) return;
const scale = Math.min(img.clientWidth / img.naturalWidth, img.clientHeight / img.naturalHeight);
const w = img.naturalWidth * scale, h = img.naturalHeight * scale;
layer.style.left = (img.offsetLeft + (img.clientWidth - w) / 2) + 'px';
layer.style.top = (img.offsetTop + (img.clientHeight - h) / 2) + 'px';
layer.style.width = w + 'px';
layer.style.height = h + 'px';
}
function magRenderPreviewBoxes(page, boxes, stage) {
const layer = $('mag-ingest-preview-boxes'), img = $('mag-ingest-preview-img');
if (!layer) return;
const stageEl = $('mag-ingest-preview-stage');
if (stageEl) stageEl.textContent = page ? '· ' + page + (stage === 'labelling' ? ' — labelling regions' : ' — reading') : '';
if (_magPreviewSeen.page !== page) _magPreviewSeen = { page, keys: new Set() };
const key = (b) => [b.x, b.y, b.w, b.h].join(',');
layer.innerHTML = boxes.map((b) => {
const isNew = !_magPreviewSeen.keys.has(key(b));
const type = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body';
return '<div class="mag-preview-box mpt-' + type + (isNew ? ' is-new' : '') + '" style="left:' + b.x + '%;top:' + b.y +
'%;width:' + b.w + '%;height:' + b.h + '%" title="' + esc(MAG_PREVIEW_TYPES[type]) + '">' +
(b.n ? '<span class="mag-preview-box-num">' + b.n + '</span>' : '') + '</div>';
}).join('');
boxes.forEach((b) => _magPreviewSeen.keys.add(key(b)));
const legend = $('mag-ingest-preview-legend');
if (legend) {
const present = [...new Set(boxes.map((b) => (MAG_PREVIEW_TYPES[b.type] ? b.type : 'body')))];
legend.innerHTML = present.map((t) => '<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + t + '"></span>' +
esc(MAG_PREVIEW_TYPES[t]) + '</span>').join('') + (boxes.length ? '<span class="mag-preview-legend-count">' + boxes.length + ' regions</span>' : '');
}
if (img && !img.dataset.fitBound) {
img.dataset.fitBound = '1';
img.addEventListener('load', magFitPreviewLayer);
if (typeof ResizeObserver === 'function') new ResizeObserver(magFitPreviewLayer).observe(img);
}
magFitPreviewLayer();
}
async function pollIngest() { async function pollIngest() {
const bar = $('mag-ingest-status'); const bar = $('mag-ingest-status');
const preview = $('mag-ingest-preview'); const preview = $('mag-ingest-preview');
@ -504,6 +558,7 @@ async function pollIngest() {
const img = $('mag-ingest-preview-img'); const img = $('mag-ingest-preview-img');
if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page; if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page;
} }
magRenderPreviewBoxes(data.preview.page, data.preview.boxes || [], data.preview.stage);
const textEl = $('mag-ingest-preview-text'); const textEl = $('mag-ingest-preview-text');
// Server-built HTML (see _render_ingest_preview_html in // Server-built HTML (see _render_ingest_preview_html in
// routes/magazine.py) -- every text node in it is already escaped, // routes/magazine.py) -- every text node in it is already escaped,
@ -2873,6 +2928,7 @@ root.magSelectToggle = magSelectToggle;
root.magSelectNone = magSelectNone; root.magSelectNone = magSelectNone;
root.magBulk = magBulk; root.magBulk = magBulk;
root.magUndo = magUndo; root.magUndo = magUndo;
root.magRenderPreviewBoxes = magRenderPreviewBoxes;
root.magRowReocr = magRowReocr; root.magRowReocr = magRowReocr;
root.magRowMerge = magRowMerge; root.magRowMerge = magRowMerge;
root.magListKey = magListKey; root.magListKey = magListKey;

View File

@ -76,8 +76,12 @@
needs; this panel adds nothing beyond that but noisy churn. --> needs; this panel adds nothing beyond that but noisy churn. -->
<div id="mag-ingest-preview" class="mag-ingest-preview" hidden aria-hidden="true"> <div id="mag-ingest-preview" class="mag-ingest-preview" hidden aria-hidden="true">
<div class="mag-ingest-preview-side"> <div class="mag-ingest-preview-side">
<span class="mag-ingest-preview-label">Page</span> <span class="mag-ingest-preview-label">Page <span id="mag-ingest-preview-stage"></span></span>
<div class="mag-ingest-preview-canvas">
<img id="mag-ingest-preview-img" alt="Page currently being read"> <img id="mag-ingest-preview-img" alt="Page currently being read">
<div id="mag-ingest-preview-boxes" class="mag-ingest-preview-boxes"></div>
</div>
<div id="mag-ingest-preview-legend" class="mag-ingest-preview-legend"></div>
</div> </div>
<div class="mag-ingest-preview-resize" id="mag-ingest-preview-resize" role="separator" aria-orientation="vertical" aria-label="Resize page and recognised text panes" title="Drag to resize page and recognised text panes"></div> <div class="mag-ingest-preview-resize" id="mag-ingest-preview-resize" role="separator" aria-orientation="vertical" aria-label="Resize page and recognised text panes" title="Drag to resize page and recognised text panes"></div>
<div class="mag-ingest-preview-side"> <div class="mag-ingest-preview-side">

View File

@ -5189,3 +5189,22 @@ input[list="lang-codes"] {
.mag-review-box.is-selected { outline: 3px solid var(--warning, #d97706); outline-offset: -1px; .mag-review-box.is-selected { outline: 3px solid var(--warning, #d97706); outline-offset: -1px;
background: color-mix(in srgb, var(--warning, #d97706) 18%, transparent); } background: color-mix(in srgb, var(--warning, #d97706) 18%, transparent); }
.mag-blocks [data-block-row].is-selected { border-color: var(--warning, #d97706); box-shadow: inset 3px 0 0 var(--warning, #d97706); } .mag-blocks [data-block-row].is-selected { border-color: var(--warning, #d97706); box-shadow: inset 3px 0 0 var(--warning, #d97706); }
/* Live region boxes on the page being read (magazine ingestion preview) */
.mag-ingest-preview-canvas { position: relative; flex: 1 1 auto; min-height: 0; display: flex; }
.mag-ingest-preview-canvas img { width: 100%; height: 100%; object-fit: contain; }
.mag-ingest-preview-boxes { position: absolute; pointer-events: none; }
.mag-preview-box { position: absolute; border: 2px solid var(--c, #3b82f6); border-radius: 2px;
background: color-mix(in srgb, var(--c, #3b82f6) 14%, transparent); }
.mag-preview-box.is-new { animation: mag-preview-in .45s ease-out; }
@keyframes mag-preview-in { from { opacity: 0; transform: scale(1.04); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .mag-preview-box.is-new { animation: none; } }
.mag-preview-box-num { position: absolute; left: -2px; top: -2px; min-width: 16px; padding: 0 3px; font: 600 10px/16px system-ui, sans-serif;
color: #fff; background: var(--c, #3b82f6); border-radius: 2px 0 2px 0; text-align: center; }
.mag-ingest-preview-legend { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 11px; color: #b6bac2; }
.mag-preview-legend-item { display: inline-flex; align-items: center; gap: 4px; }
.mag-preview-swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--c); }
.mag-preview-legend-count { margin-left: auto; color: #8a8f98; }
.mpt-title { --c: #a855f7; } .mpt-heading { --c: #d8b4fe; } .mpt-deck { --c: #06b6d4; } .mpt-byline { --c: #10b981; } .mpt-body { --c: #3b82f6; }
.mpt-caption { --c: #14b8a6; } .mpt-pullquote { --c: #ec4899; } .mpt-sidebar { --c: #f59e0b; } .mpt-ad { --c: #ef4444; }
.mpt-furniture, .mpt-page_number { --c: #9ca3af; } .mpt-image, .mpt-figure, .mpt-chart, .mpt-table, .mpt-code { --c: #f97316; }

View File

@ -641,3 +641,43 @@ test('an action without its own undo point clears the Undo button', async ({ pag
await page.getByRole('alertdialog').getByRole('button', { name: 'Fix reading order', exact: true }).click(); await page.getByRole('alertdialog').getByRole('button', { name: 'Fix reading order', exact: true }).click();
await expect(page.locator('#mag-undo-bar')).toBeEmpty(); await expect(page.locator('#mag-undo-bar')).toBeEmpty();
}); });
test('the live preview draws the page regions over the page image as they arrive', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
// A 100x200 page image in a frame much wider than the page: the boxes must follow the image,
// not the frame (contain scaling leaves bars left and right).
await page.evaluate(async () => {
const preview = document.getElementById('mag-ingest-preview');
preview.hidden = false;
preview.style.width = '800px';
const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200;
const img = document.getElementById('mag-ingest-preview-img');
img.style.height = '400px';
await new Promise(r => { img.onload = r; img.src = canvas.toDataURL('image/png'); });
});
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
{ x: 0, y: 20, w: 50, h: 50, type: 'body', n: 2 },
], 'reading'));
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box')).toHaveCount(2);
await expect(page.locator('#mag-ingest-preview-stage')).toContainText('5 — reading');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('Headline');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('2 regions');
const [imgBox, layerBox] = await Promise.all([
page.locator('#mag-ingest-preview-img').boundingBox(), page.locator('#mag-ingest-preview-boxes').boundingBox()]);
// The layer has the page's 1:2 shape and is centred in the wider frame.
expect(Math.abs(layerBox.height - 2 * layerBox.width)).toBeLessThan(2);
expect(Math.abs((layerBox.x + layerBox.width / 2) - (imgBox.x + imgBox.width / 2))).toBeLessThan(2);
// A region that appears on the next poll is marked new; the ones already shown are not.
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
{ x: 0, y: 20, w: 50, h: 50, type: 'body', n: 2 },
{ x: 50, y: 20, w: 50, h: 50, type: 'ad', n: 3 },
], 'labelling'));
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box.is-new')).toHaveCount(1);
await expect(page.locator('#mag-ingest-preview-stage')).toContainText('labelling regions');
});

View File

@ -6,6 +6,7 @@ import os
import shutil import shutil
import tempfile import tempfile
import unittest import unittest
from pathlib import Path
_CONFIG = tempfile.mkdtemp(prefix="ttvc-magazine-routes-test-") _CONFIG = tempfile.mkdtemp(prefix="ttvc-magazine-routes-test-")
os.environ.setdefault("CONFIG_DIR", _CONFIG) os.environ.setdefault("CONFIG_DIR", _CONFIG)
@ -1165,3 +1166,81 @@ class UndoSafetyAndExtraFieldTests(BulkUndoFoldTests):
def test_blocks_without_the_field_do_not_gain_it(self): def test_blocks_without_the_field_do_not_gain_it(self):
from core.magazine import Block from core.magazine import Block
self.assertNotIn("original_text", Block(id="x", page=1, bbox=[0, 0, 1, 1], type="body", text="t").to_json()) self.assertNotIn("original_text", Block(id="x", page=1, bbox=[0, 0, 1, 1], type="body", text="t").to_json())
class LivePreviewBoxTests(unittest.TestCase):
"""The ingest preview carries the page's boxes, so they can be drawn as they are made."""
def test_boxes_are_in_percent_of_the_page_and_numbered_in_reading_order(self):
from core.magazine import Block
from routes.magazine import _ingest_preview_boxes
blocks = [Block(id="b", page=1, bbox=[300, 400, 150, 100], type="body", text="t", reading_order=1),
Block(id="a", page=1, bbox=[0, 0, 600, 80], type="title", text="t", reading_order=0),
Block(id="n", page=1, bbox=[280, 780, 40, 10], type="furniture", text="12", reading_order=2,
region_label="page_number")]
boxes = _ingest_preview_boxes(blocks, [{"bbox": [60, 100, 240, 300]}], (600, 800))
self.assertEqual([(b["type"], b["n"]) for b in boxes], [("title", 1), ("body", 2), ("page_number", 3), ("image", None)])
self.assertEqual({k: boxes[1][k] for k in ("x", "y", "w", "h")}, {"x": 50.0, "y": 50.0, "w": 25.0, "h": 12.5})
def test_dict_blocks_from_the_labelling_pass_work_too(self):
from routes.magazine import _ingest_preview_boxes
boxes = _ingest_preview_boxes([{"bbox": [0, 0, 100, 100], "type": "ad", "reading_order": 0}], [], (200, 200))
self.assertEqual(boxes[0]["type"], "ad")
def test_no_page_size_means_no_boxes_rather_than_an_error(self):
from routes.magazine import _ingest_preview_boxes
self.assertEqual(_ingest_preview_boxes([{"bbox": [0, 0, 1, 1], "type": "body"}], [], None), [])
def test_the_preview_file_holds_page_stage_text_and_boxes(self):
import json
from pathlib import Path
from routes.magazine import _write_ingest_preview
d = Path(tempfile.mkdtemp(prefix="ttvc-preview-"))
try:
_write_ingest_preview(d, 7, [{"bbox": [0, 0, 50, 50], "type": "body", "text": "Hallo", "reading_order": 0}],
[], (100, 100), "labelling")
data = json.loads((d / "ingest_preview.json").read_text("utf-8"))
self.assertEqual((data["page"], data["stage"], len(data["boxes"])), (7, "labelling", 1))
self.assertIn("Hallo", data["text"])
finally:
shutil.rmtree(d, ignore_errors=True)
def test_the_labelling_pass_reports_every_page_even_when_its_model_call_fails(self):
from unittest import mock
import routes.magazine as rm
layout = {"pages": [{"page": 1, "width": 1, "height": 1}, {"page": 2, "width": 1, "height": 1}],
"blocks": [{"id": "a", "page": 1, "bbox": [0, 0, 1, 1], "type": "body", "text": "x"},
{"id": "b", "page": 2, "bbox": [0, 0, 1, 1], "type": "body", "text": "y"}], "images": []}
seen = []
with mock.patch("routes.conversation._post_llm_chat_completion", side_effect=RuntimeError("down")):
rm._recognise_advertisements(layout, {"llm_model": "m", "llm_url": "http://x"}, on_page=seen.append)
self.assertEqual(seen, [1, 2])
class InterruptedIngestTests(unittest.TestCase):
def test_an_ingest_killed_by_a_restart_is_reported_as_failed_not_running_for_ever(self):
import asyncio
from unittest.mock import patch
from routes.magazine import magazine_ingest_status
meta = {"kind": "magazine", "ingest": {"status": "running", "job_id": "j1"}}
written = {}
with patch('routes.magazine._require_magazine', return_value=(Path(tempfile.mkdtemp()), meta)), \
patch('routes.magazine.background_job_get', return_value={"done": True, "stage": "error",
"error": "Background job interrupted by server restart"}), \
patch('routes.magazine._write_meta', side_effect=lambda d, m: written.update(m)):
out = asyncio.run(magazine_ingest_status('a' * 32))
self.assertEqual(out["ingest"]["status"], "failed")
self.assertIn("server restart", out["ingest"]["error"])
self.assertEqual(written["ingest"]["status"], "failed")
def test_a_job_still_running_stays_running(self):
import asyncio
from unittest.mock import patch
from routes.magazine import magazine_ingest_status
meta = {"kind": "magazine", "ingest": {"status": "running", "job_id": "j1"}}
with patch('routes.magazine._require_magazine', return_value=(Path(tempfile.mkdtemp()), meta)), \
patch('routes.magazine.background_job_get', return_value={"done": False, "stage": "extract"}), \
patch('routes.magazine._write_meta') as write:
out = asyncio.run(magazine_ingest_status('a' * 32))
self.assertEqual(out["ingest"]["status"], "running")
write.assert_not_called()