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
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:
parent
703052d1d2
commit
1edc941dbf
@ -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
|
||||||
|
|||||||
10
Manual.md
10
Manual.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) ── -->
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user