diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f3a6f..d27ce0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] +## [1.42.3] — 2026-09-22 + +### Fixed +- **Magazine import preview: picture, chart and table boxes were drawn in the wrong place, and the recognised-text panel never showed them.** Both came from the same cause: the boxes were only snapped to the real picture edges and cropped to actual files once, after the whole magazine had finished importing — during the import itself the preview drew the vision model's raw, often-imprecise guess, and the text panel could only show a generic "Figure" placeholder because no crop file existed yet. Pictures, charts and tables are now snapped and cropped as soon as each page is labelled, so the live preview's boxes are accurate and the recognised-text panel shows the actual picture instead of a placeholder, while the import is still running. + ## [1.42.2] — 2026-09-22 ### Fixed diff --git a/Manual.md b/Manual.md index a28e5d0..6048a14 100644 --- a/Manual.md +++ b/Manual.md @@ -107,7 +107,12 @@ turned into a podcast discussion between two or three voices you pick. tables and code are only recognised in this second pass (the first pass only reads text, and says so under the page); they are drawn as dashed, filled boxes in their own colours — picture orange, chart green, table - yellow, code grey — so they stand out from the text boxes. The page + yellow, code grey — so they stand out from the text boxes, and fitted + to the actual picture as soon as that page is labelled (not only once + the whole magazine has finished), so the box does not lag behind the + real edges of the picture for long. The recognised-text panel shows the + actual cropped picture, chart or table for the same reason, rather than + a plain "Figure" placeholder, as soon as it has been cropped. The page scan itself (a scanned magazine is one picture per page) is not framed. 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*. diff --git a/VERSION b/VERSION index 2259c77..9754920 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.42.2 +1.42.3 diff --git a/routes/magazine.py b/routes/magazine.py index 25ece74..618701d 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -123,6 +123,11 @@ _INGEST_PREVIEW_BUDGET = 8000 _SCAN_IMAGE_SHARE = 0.85 # an embedded image covering this much of the page is the page scan itself +def _is_scan_sized_image(bbox, w: float, h: float) -> bool: + _, _, bw, bh = (float(v) for v in bbox) + return bw * bh >= _SCAN_IMAGE_SHARE * w * h + + 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]: @@ -146,11 +151,11 @@ def _ingest_preview_boxes(blocks: list, images: list | None, size: tuple[float, # A crop of a region already drawn above would be the same box twice. if not bbox or get(img, "source_block") in drawn - {None}: continue - x, y, bw, bh = (float(v) for v in bbox) # A scanned magazine is one embedded image per page: framing the whole page as a # "picture" hides the real pictures, charts and tables found on it. - if bw * bh >= _SCAN_IMAGE_SHARE * w * h: + if _is_scan_sized_image(bbox, w, h): 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] @@ -165,16 +170,27 @@ def _write_ingest_preview(d: Path, page: int, blocks: list, images: list | None, 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 []] + # The page scan itself (a scanned magazine is one embedded image per page) is not a + # picture on the page -- shown as a "Figure" it would swamp the real ones. + shown_images = images or [] + if size and size[0] and size[1]: + w, h = float(size[0]), float(size[1]) + shown_images = [i for i in shown_images if not _is_scan_sized_image(i.get("bbox") or [0, 0, 0, 0], w, h)] 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), + "page": page, "stage": stage, + "text": _render_ingest_preview_html(as_objects, shown_images, doc_id=d.name), + "boxes": _ingest_preview_boxes(blocks, shown_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: +_INGEST_PREVIEW_FIGURE_LABELS = {"figure": "Picture", "image": "Picture", "chart": "Chart", + "table": "Table", "code": "Code"} + + +def _render_ingest_preview_html(blocks: list, images: list | None, doc_id: str = "") -> str: """Format one page's just-read blocks as simple HTML, in reading order. Best-effort presentation only, for the live "here is what was just @@ -195,6 +211,11 @@ def _render_ingest_preview_html(blocks: list, images: list | None) -> str: that vision labelling is working -- but never promoted to a heading, regardless of its font size. + Once a picture, chart or table has been cropped to its own file (see + refresh_visual_regions, run per page as labelling finishes it) the + placeholder becomes the actual thumbnail instead of a bare icon, so a + reader can tell what was found without switching to the page image. + Every text node is escaped; the caller writes the result straight into the page as innerHTML. """ @@ -210,9 +231,16 @@ def _render_ingest_preview_html(blocks: list, images: list | None) -> str: budget_used = 0 for _, kind, item in items: if kind == "image": - line = ('
Chart", html)
+
+ def test_a_bad_filename_falls_back_to_the_placeholder_rather_than_a_bad_src(self):
+ from routes.magazine import _render_ingest_preview_html
+ bad = {"bbox": [0, 5, 50, 50], "type": "table", "filename": "../../etc/passwd"}
+ html = _render_ingest_preview_html([], [bad], doc_id="doc1")
+ self.assertNotIn("