diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db7930..74f3a6f 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.2] — 2026-09-22 + +### Fixed +- **Magazine import preview: pictures, charts and tables were hard to see.** On a scanned magazine every page is one embedded picture, so the preview framed the whole page as a "Picture" in the same orange as the real pictures on it. The page scan is no longer framed, pictures, charts, tables and code each get their own colour as dashed, filled boxes, the legend lists each label once, and during the first pass the legend says that pictures are marked in the second pass. + ## [1.42.1] — 2026-09-22 ### Fixed diff --git a/Manual.md b/Manual.md index 7972d2f..a28e5d0 100644 --- a/Manual.md +++ b/Manual.md @@ -103,7 +103,12 @@ turned into a podcast discussion between two or three voices you pick. 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 + the progress bar then reads "… — page 12 of 204". Pictures, charts, + 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 + 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*. 3. **Review the articles.** This step is not optional and cannot be skipped. diff --git a/VERSION b/VERSION index e640847..2259c77 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.42.1 +1.42.2 diff --git a/routes/magazine.py b/routes/magazine.py index 1f65c66..25ece74 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -120,6 +120,9 @@ async def magazine_create(request: Request): _INGEST_PREVIEW_BUDGET = 8000 +_SCAN_IMAGE_SHARE = 0.85 # an embedded image covering this much of the page is the page scan itself + + 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]: @@ -137,11 +140,17 @@ def _ingest_preview_boxes(blocks: list, images: list | None, size: tuple[float, 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}) + drawn = {get(b, "id") for b in ordered} for img in images or []: bbox = get(img, "bbox") - if not bbox: + # 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: + continue 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] diff --git a/static/index.html b/static/index.html index 226a5ea..5c71111 100644 --- a/static/index.html +++ b/static/index.html @@ -27,12 +27,12 @@ - - - - - - + + + + + + diff --git a/static/js/magazine.js b/static/js/magazine.js index d5bd50f..859cb79 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -489,6 +489,9 @@ function magRenderPreviewBoxes(page, boxes, stage) { if (!layer) return; const stageEl = $('mag-ingest-preview-stage'); if (stageEl) stageEl.textContent = page ? '· ' + page + (stage === 'labelling' ? ' — labelling regions' : ' — reading') : ''; + // A page-sized embedded picture is the page scan itself, not a picture on the page (the server + // drops it too; this also covers a preview written by an older server). + boxes = (boxes || []).filter((b) => !(b.type === 'image' && b.w * b.h >= 8500)); 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) => { @@ -501,9 +504,14 @@ function magRenderPreviewBoxes(page, boxes, stage) { 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) => '' + - esc(MAG_PREVIEW_TYPES[t]) + '').join('') + (boxes.length ? '' + boxes.length + ' regions' : ''); + // One entry per label: a picture region and an embedded picture are both "Picture". + const byLabel = new Map(); + boxes.forEach((b) => { const t = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body'; if (!byLabel.has(MAG_PREVIEW_TYPES[t])) byLabel.set(MAG_PREVIEW_TYPES[t], t); }); + legend.innerHTML = [...byLabel].map(([label, t]) => '' + + esc(label) + '').join('') + + // The first pass only reads the text; pictures, charts and tables are recognised in the second. + (stage === 'reading' ? 'Pictures, charts and tables are marked in the second pass' : '') + + (boxes.length ? '' + boxes.length + ' regions' : ''); } if (img && !img.dataset.fitBound) { img.dataset.fitBound = '1'; diff --git a/static/style.css b/static/style.css index 42c4784..ebf721e 100644 --- a/static/style.css +++ b/static/style.css @@ -5208,4 +5208,9 @@ input[list="lang-codes"] { .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; } +.mpt-furniture, .mpt-page_number { --c: #9ca3af; } +/* Visual regions: their own colours and a stronger fill, so a picture reads as an area, not a text line. */ +.mpt-image, .mpt-figure { --c: #f97316; } .mpt-chart { --c: #84cc16; } .mpt-table { --c: #eab308; } .mpt-code { --c: #94a3b8; } +.mag-preview-box.mpt-image, .mag-preview-box.mpt-figure, .mag-preview-box.mpt-chart, .mag-preview-box.mpt-table, +.mag-preview-box.mpt-code { border-width: 3px; border-style: dashed; background: color-mix(in srgb, var(--c) 24%, transparent); } +.mag-preview-legend-hint { color: #8a8f98; font-style: italic; } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index e800876..b8fa09b 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -667,6 +667,7 @@ test('the live preview draws the page regions over the page image as they arrive 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'); + await expect(page.locator('#mag-ingest-preview-legend')).toContainText('marked in the second pass'); 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, is centred across the wider frame and sits at its top. @@ -681,4 +682,17 @@ test('the live preview draws the page regions over the page image as they arrive ], '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'); + await expect(page.locator('#mag-ingest-preview-legend')).not.toContainText('second pass'); + // Pictures, charts and tables each get their own entry; two kinds of picture share one. + await page.evaluate(() => window.magRenderPreviewBoxes(5, [ + { x: 0, y: 0, w: 50, h: 30, type: 'figure', n: 1 }, { x: 50, y: 0, w: 50, h: 30, type: 'image', n: null }, + { x: 0, y: 40, w: 50, h: 30, type: 'chart', n: 2 }, { x: 50, y: 40, w: 50, h: 30, type: 'table', n: 3 }, + { x: 0, y: 0, w: 100, h: 100, type: 'image', n: null }, + ], 'labelling')); + await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box')).toHaveCount(4); // not the page scan + const legend = page.locator('#mag-ingest-preview-legend .mag-preview-legend-item'); + await expect(legend).toHaveText(['Picture', 'Chart', 'Table']); + const colour = (sel) => page.locator(sel).evaluate((el) => getComputedStyle(el).borderTopColor); + const [pic, chart, table] = await Promise.all(['.mpt-figure', '.mpt-chart', '.mpt-table'].map((t) => colour('#mag-ingest-preview-boxes ' + t))); + expect(new Set([pic, chart, table]).size).toBe(3); }); diff --git a/tests/test_magazine_routes.py b/tests/test_magazine_routes.py index b3c36f4..e87de7b 100644 --- a/tests/test_magazine_routes.py +++ b/tests/test_magazine_routes.py @@ -1182,6 +1182,21 @@ class LivePreviewBoxTests(unittest.TestCase): 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_the_page_scan_is_not_drawn_as_a_picture(self): + from routes.magazine import _ingest_preview_boxes + figure = {"id": "f", "bbox": [50, 400, 300, 200], "type": "figure", "reading_order": 0} + scan = {"bbox": [0, 0, 600, 800]} + boxes = _ingest_preview_boxes([figure], [scan], (600, 800)) + self.assertEqual([b["type"] for b in boxes], ["figure"]) + + def test_a_crop_of_a_drawn_region_is_not_drawn_twice(self): + from routes.magazine import _ingest_preview_boxes + chart = {"id": "c", "bbox": [50, 400, 300, 200], "type": "chart", "reading_order": 0} + crop = {"bbox": [50, 400, 300, 200], "source_block": "c", "type": "chart"} + other = {"bbox": [400, 50, 100, 100]} + boxes = _ingest_preview_boxes([chart], [crop, other], (600, 800)) + self.assertEqual([b["type"] for b in boxes], ["chart", "image"]) + 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))