Compare commits

..

No commits in common. "197696e653964c811d05475a68f6d734ffe93b02" and "1edc941dbf16507104cdf2da115bc65f67494ddd" have entirely different histories.

9 changed files with 18 additions and 87 deletions

View File

@ -7,16 +7,6 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [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
- **Magazine import preview: the page sat at the bottom of its pane.** The page image (and the region boxes drawn over it) is now aligned to the top of the preview, so it stays in view next to a long recognised-text column.
## [1.42.0] — 2026-09-22 ## [1.42.0] — 2026-09-22
### Added ### Added

View File

@ -103,12 +103,7 @@ 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 and numbered in reading order. After all pages are read, the second pass
(*Recognising paragraphs, headings and advertisements*) walks the pages (*Recognising paragraphs, headings and advertisements*) walks the pages
again and the preview follows it, so you can watch boxes change type — again and the preview follows it, so you can watch boxes change type —
the progress bar then reads "… — page 12 of 204". Pictures, charts, the progress bar then reads "… — page 12 of 204". If a run is interrupted
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 (for example when the server restarts), the status now says so instead
of spinning forever; start it again with *Re-run detection*. 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.

View File

@ -1 +1 @@
1.42.2 1.42.0

View File

@ -120,9 +120,6 @@ async def magazine_create(request: Request):
_INGEST_PREVIEW_BUDGET = 8000 _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]: 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.""" """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]: if not size or not size[0] or not size[1]:
@ -140,17 +137,11 @@ 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") 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), 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}) "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 []: for img in images or []:
bbox = get(img, "bbox") bbox = get(img, "bbox")
# A crop of a region already drawn above would be the same box twice. if not bbox:
if not bbox or get(img, "source_block") in drawn - {None}:
continue continue
x, y, bw, bh = (float(v) for v in bbox) 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), 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}) "h": round(100 * bh / h, 2), "type": "image", "n": None})
return boxes[:400] return boxes[:400]

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.42.2"> <link rel="stylesheet" href="/static/style.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.42.2"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.42.2"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.42.2"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.42.2"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.42.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.42.2"> <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

@ -471,15 +471,14 @@ const MAG_PREVIEW_TYPES = {
let _magPreviewSeen = { page: null, keys: new Set() }; let _magPreviewSeen = { page: null, keys: new Set() };
// Fit the box layer to the part of the frame the page image actually covers ("contain" scaling // Fit the box layer to the part of the frame the page image actually covers ("contain" scaling
// leaves bars beside or below it -- the page is top-aligned), so a box drawn at 50% sits at 50% of // leaves bars around it), so a box drawn at 50% sits at 50% of the page, not of the frame.
// the page, not of the frame.
function magFitPreviewLayer() { function magFitPreviewLayer() {
const img = $('mag-ingest-preview-img'), layer = $('mag-ingest-preview-boxes'); const img = $('mag-ingest-preview-img'), layer = $('mag-ingest-preview-boxes');
if (!img || !layer || !img.naturalWidth || !img.clientWidth) return; if (!img || !layer || !img.naturalWidth || !img.clientWidth) return;
const scale = Math.min(img.clientWidth / img.naturalWidth, img.clientHeight / img.naturalHeight); const scale = Math.min(img.clientWidth / img.naturalWidth, img.clientHeight / img.naturalHeight);
const w = img.naturalWidth * scale, h = img.naturalHeight * scale; const w = img.naturalWidth * scale, h = img.naturalHeight * scale;
layer.style.left = (img.offsetLeft + (img.clientWidth - w) / 2) + 'px'; layer.style.left = (img.offsetLeft + (img.clientWidth - w) / 2) + 'px';
layer.style.top = img.offsetTop + 'px'; // the page is drawn at the top of its frame (object-position: top) layer.style.top = (img.offsetTop + (img.clientHeight - h) / 2) + 'px';
layer.style.width = w + 'px'; layer.style.width = w + 'px';
layer.style.height = h + 'px'; layer.style.height = h + 'px';
} }
@ -489,9 +488,6 @@ function magRenderPreviewBoxes(page, boxes, stage) {
if (!layer) return; if (!layer) return;
const stageEl = $('mag-ingest-preview-stage'); const stageEl = $('mag-ingest-preview-stage');
if (stageEl) stageEl.textContent = page ? '· ' + page + (stage === 'labelling' ? ' — labelling regions' : ' — reading') : ''; 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() }; if (_magPreviewSeen.page !== page) _magPreviewSeen = { page, keys: new Set() };
const key = (b) => [b.x, b.y, b.w, b.h].join(','); const key = (b) => [b.x, b.y, b.w, b.h].join(',');
layer.innerHTML = boxes.map((b) => { layer.innerHTML = boxes.map((b) => {
@ -504,14 +500,9 @@ function magRenderPreviewBoxes(page, boxes, stage) {
boxes.forEach((b) => _magPreviewSeen.keys.add(key(b))); boxes.forEach((b) => _magPreviewSeen.keys.add(key(b)));
const legend = $('mag-ingest-preview-legend'); const legend = $('mag-ingest-preview-legend');
if (legend) { if (legend) {
// One entry per label: a picture region and an embedded picture are both "Picture". const present = [...new Set(boxes.map((b) => (MAG_PREVIEW_TYPES[b.type] ? b.type : 'body')))];
const byLabel = new Map(); legend.innerHTML = present.map((t) => '<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + t + '"></span>' +
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); }); esc(MAG_PREVIEW_TYPES[t]) + '</span>').join('') + (boxes.length ? '<span class="mag-preview-legend-count">' + boxes.length + ' regions</span>' : '');
legend.innerHTML = [...byLabel].map(([label, t]) => '<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + t + '"></span>' +
esc(label) + '</span>').join('') +
// The first pass only reads the text; pictures, charts and tables are recognised in the second.
(stage === 'reading' ? '<span class="mag-preview-legend-hint">Pictures, charts and tables are marked in the second pass</span>' : '') +
(boxes.length ? '<span class="mag-preview-legend-count">' + boxes.length + ' regions</span>' : '');
} }
if (img && !img.dataset.fitBound) { if (img && !img.dataset.fitBound) {
img.dataset.fitBound = '1'; img.dataset.fitBound = '1';

View File

@ -5192,8 +5192,7 @@ input[list="lang-codes"] {
/* Live region boxes on the page being read (magazine ingestion preview) */ /* 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 { position: relative; flex: 1 1 auto; min-height: 0; display: flex; }
.mag-ingest-preview-canvas { align-items: flex-start; } .mag-ingest-preview-canvas img { width: 100%; height: 100%; object-fit: contain; }
.mag-ingest-preview-canvas img { width: 100%; height: 100%; object-fit: contain; object-position: center top; }
.mag-ingest-preview-boxes { position: absolute; pointer-events: none; } .mag-ingest-preview-boxes { position: absolute; pointer-events: none; }
.mag-preview-box { position: absolute; border: 2px solid var(--c, #3b82f6); border-radius: 2px; .mag-preview-box { position: absolute; border: 2px solid var(--c, #3b82f6); border-radius: 2px;
background: color-mix(in srgb, var(--c, #3b82f6) 14%, transparent); } background: color-mix(in srgb, var(--c, #3b82f6) 14%, transparent); }
@ -5208,9 +5207,4 @@ input[list="lang-codes"] {
.mag-preview-legend-count { margin-left: auto; color: #8a8f98; } .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-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-caption { --c: #14b8a6; } .mpt-pullquote { --c: #ec4899; } .mpt-sidebar { --c: #f59e0b; } .mpt-ad { --c: #ef4444; }
.mpt-furniture, .mpt-page_number { --c: #9ca3af; } .mpt-furniture, .mpt-page_number { --c: #9ca3af; } .mpt-image, .mpt-figure, .mpt-chart, .mpt-table, .mpt-code { --c: #f97316; }
/* 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; }

View File

@ -656,7 +656,7 @@ test('the live preview draws the page regions over the page image as they arrive
preview.style.width = '800px'; preview.style.width = '800px';
const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200; const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200;
const img = document.getElementById('mag-ingest-preview-img'); const img = document.getElementById('mag-ingest-preview-img');
img.style.height = '1400px'; img.style.height = '400px';
await new Promise(r => { img.onload = r; img.src = canvas.toDataURL('image/png'); }); await new Promise(r => { img.onload = r; img.src = canvas.toDataURL('image/png'); });
}); });
await page.evaluate(() => window.magRenderPreviewBoxes(5, [ await page.evaluate(() => window.magRenderPreviewBoxes(5, [
@ -667,13 +667,11 @@ 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-stage')).toContainText('5 — reading');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('Headline'); 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('2 regions');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('marked in the second pass');
const [imgBox, layerBox] = await Promise.all([ const [imgBox, layerBox] = await Promise.all([
page.locator('#mag-ingest-preview-img').boundingBox(), page.locator('#mag-ingest-preview-boxes').boundingBox()]); 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. // 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.height - 2 * layerBox.width)).toBeLessThan(2);
expect(Math.abs((layerBox.x + layerBox.width / 2) - (imgBox.x + imgBox.width / 2))).toBeLessThan(2); expect(Math.abs((layerBox.x + layerBox.width / 2) - (imgBox.x + imgBox.width / 2))).toBeLessThan(2);
expect(Math.abs(layerBox.y - imgBox.y)).toBeLessThan(2);
// A region that appears on the next poll is marked new; the ones already shown are not. // A region that appears on the next poll is marked new; the ones already shown are not.
await page.evaluate(() => window.magRenderPreviewBoxes(5, [ await page.evaluate(() => window.magRenderPreviewBoxes(5, [
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 }, { x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
@ -682,17 +680,4 @@ test('the live preview draws the page regions over the page image as they arrive
], 'labelling')); ], 'labelling'));
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box.is-new')).toHaveCount(1); 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-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);
}); });

View File

@ -1182,21 +1182,6 @@ 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([(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}) 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): def test_dict_blocks_from_the_labelling_pass_work_too(self):
from routes.magazine import _ingest_preview_boxes from routes.magazine import _ingest_preview_boxes
boxes = _ingest_preview_boxes([{"bbox": [0, 0, 100, 100], "type": "ad", "reading_order": 0}], [], (200, 200)) boxes = _ingest_preview_boxes([{"bbox": [0, 0, 100, 100], "type": "ad", "reading_order": 0}], [], (200, 200))