From baab49dbdf5e741b3080d4547624039147b9a9b2 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 21 Sep 2026 11:58:37 +0200 Subject: [PATCH] feat(magazine): set a page's reading order by hand (type a position or click in order) A hand-ordered page is locked: Fix reading order, type changes and newly drawn boxes leave it alone until Automatic order. v1.36.0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 + Manual.md | 10 +- TODO.md | 2 +- VERSION | 2 +- architecture/magazine-ingestion.md | 9 ++ core/magazine.py | 59 +++++++++++- routes/magazine.py | 36 ++++++++ static/index.html | 12 +-- static/js/magazine.js | 141 ++++++++++++++++++++++++++++- static/style.css | 10 ++ tests/magazine.spec.js | 54 +++++++++++ tests/test_magazine_routes.py | 75 +++++++++++++++ 12 files changed, 402 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760578f..0ad8327 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.36.0] — 2026-09-21 + +### Added +- **Magazine review: set the reading order yourself.** Type a position number into any row to move that region, or press *Order by clicking* and click the boxes on the page in reading order (with *Undo last*; skipped boxes follow in their current order). A hand-ordered page is marked as such and kept: *Fix reading order*, type changes and newly drawn boxes no longer reshuffle it, until *Automatic order* hands it back. + ## [1.35.0] — 2026-09-21 ### Added diff --git a/Manual.md b/Manual.md index a4842be..8f51c89 100644 --- a/Manual.md +++ b/Manual.md @@ -147,7 +147,15 @@ turned into a podcast discussion between two or three voices you pick. It changes only the order: text, boxes and approvals stay as they are, although an approved article may now be read in a different (correct) order. Short pull-quotes are read at the end of the page instead of in - the middle of a sentence. Use + the middle of a sentence. To **set the order yourself**, either type a new + number into the position box at the front of a row (the region moves + there), or press **"Order by clicking"** and click the boxes on the page + in the order they should be read — each clicked box shows its new number, + *Undo last* takes one back, and boxes you skip follow in their current + order — then *Save order*. A page ordered by hand says so above the list + and is never reshuffled by *Fix reading order*, a type change or a newly + drawn box (a new box is read last until you move it); **"Automatic + order"** hands the page back to the automatic column order. Use **"Two-page spread"** to see this page next to its facing page (e.g. 22 and 23), each with its own OCR-results list directly beneath it — useful for lining up a figure or a jump that crosses the gutter. Only one page diff --git a/TODO.md b/TODO.md index 162504b..861dcdc 100644 --- a/TODO.md +++ b/TODO.md @@ -75,7 +75,7 @@ not part of this order. design into the existing podcast mode and voice it with this app's own TTS voices. Prefer (a) if its API exposes script generation cleanly, since the voices would still come from here. -- [ ] **M — Magazine: let the reader decide the region order.** On the boundary-review page, +- [x] **M — Magazine: let the reader decide the region order.** Done 2026-09-21 (type a position, or Order by clicking; hand-ordered pages are locked). Original note: On the boundary-review page, give each region an explicit reading position: click the regions in the order they should be read, or type a number into each one. The order drives playback and the OCR-results list. Today the order is whatever the layout analysis produced, and a page with two text columns diff --git a/VERSION b/VERSION index a2d8722..d70132e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.35.0 \ No newline at end of file +1.36.0 \ No newline at end of file diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index 5f7b17c..66f52ce 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -299,6 +299,15 @@ only, no OCR; text, boxes, types, articles and approvals unchanged. A page with when the reader orders a page by hand) is always left alone -- by this action, by a type change and by any other recompute. +## Reading order by hand + +`PUT .../pages/{page}/reading-order {order: [ids]}` sets a page's order (text blocks and figures in one +sequence, which is also where a figure appears during playback; ids left out follow in their current +order) and sets `order_locked` on the page. `DELETE` on the same path clears the lock and recomputes. +While locked: *Fix reading order* and type changes skip the page, a newly drawn region is appended last, +and re-OCR of the page clears the lock (its boxes are all new). UI: an editable position number per row, +and an *Order by clicking* mode (clicked boxes are numbered live; clicking does not tick/untick them). + ## Region types from the review list Every region in the review list has a type picker and a delete button. `PATCH .../regions/{id}` takes diff --git a/core/magazine.py b/core/magazine.py index 1950b07..db5adb5 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2369,6 +2369,9 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int, if detected_images: layout["images"] = [i for i in layout.get("images", []) if i["page"] != page_no or "-manual-" in i["id"]] + detected_images layout["vision_ocr_pages"] = int(layout.get("vision_ocr_pages") or 0) + 1 + # The page's boxes are all new, so an order set by hand for the old ones no longer applies. + layout["pages"] = [{k: v for k, v in p.items() if k != "order_locked"} if p["page"] == page_no else p + for p in layout.get("pages", [])] return layout @@ -2578,6 +2581,11 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int, # the user can still see and move past it like any other figure. page_images = [i for i in images if i["page"] == page_no] next_ro = max([i.get("reading_order", 0) for i in page_images], default=0) + 1 + if page_meta.get("order_locked"): + # A hand-ordered page: the new figure goes last rather than into a guessed position. + next_ro = 1 + max([i.get("reading_order", 0) for i in page_images] + + [b.get("reading_order", 0) for b in layout.get("blocks", []) if b["page"] == page_no], + default=-1) images.append({"id": name, "page": page_no, "bbox": list(bbox), "filename": f"{name}.png", "reading_order": next_ro}) layout["images"] = images @@ -2618,7 +2626,13 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int, region_label=region_label) page_blocks = [b for b in blocks if b.page == page_no] + [new_block] - assign_reading_order(page_blocks, page_meta["width"]) + if page_meta.get("order_locked"): + # A hand-ordered page keeps its order; the new region is read last until the reader moves it. + new_block.reading_order = 1 + max([b.reading_order for b in page_blocks[:-1]] + + [i.get("reading_order", 0) for i in layout.get("images", []) + if i["page"] == page_no], default=-1) + else: + assign_reading_order(page_blocks, page_meta["width"]) others = [b for b in blocks if b.page != page_no] layout["blocks"] = [b.to_json() for b in others + page_blocks] return layout @@ -2785,6 +2799,49 @@ def change_region_type(doc_dir: Path, layout: dict[str, Any], page_no: int, regi return _reorder_page(layout, page_no), new_id +def set_page_order(layout: dict[str, Any], page_no: int, order: Sequence[str]) -> dict[str, Any]: + """Set a page's reading order by hand and lock it, so nothing automatic reshuffles it again. + + `order` lists region ids (text blocks and figures) in the order they are read. Regions left + out keep their current relative order after the listed ones, so ordering only the first few + boxes is enough. Text and figures share one sequence, which is also where a figure is shown + during playback. + """ + page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None) + if not page_meta: + raise ValueError("Unknown page") + blocks = [b for b in layout.get("blocks", []) if b["page"] == page_no] + images = [i for i in layout.get("images", []) if i["page"] == page_no] + current = sorted(blocks + images, key=lambda r: (r.get("reading_order", 0), 0 if r in blocks else 1)) + known = {r["id"] for r in current} + unknown = [rid for rid in order if rid not in known] + if unknown: + raise ValueError(f"Not on page {page_no}: {', '.join(map(str, unknown[:5]))}") + listed: list[str] = [] + for rid in order: + if rid not in listed: + listed.append(rid) + sequence = listed + [r["id"] for r in current if r["id"] not in listed] + position = {rid: i for i, rid in enumerate(sequence)} + layout = dict(layout) + layout["blocks"] = [dict(b, reading_order=position[b["id"]]) if b["page"] == page_no else b + for b in layout.get("blocks", [])] + layout["images"] = [dict(i, reading_order=position[i["id"]]) if i["page"] == page_no else i + for i in layout.get("images", [])] + layout["pages"] = [dict(p, order_locked=True) if p["page"] == page_no else p for p in layout.get("pages", [])] + return layout + + +def clear_page_order(layout: dict[str, Any], page_no: int) -> dict[str, Any]: + """Undo a hand-made order: unlock the page and let the automatic reading order take over.""" + if not any(p["page"] == page_no for p in layout.get("pages", [])): + raise ValueError("Unknown page") + layout = dict(layout) + layout["pages"] = [{k: v for k, v in p.items() if k != "order_locked"} if p["page"] == page_no else p + for p in layout.get("pages", [])] + return _reorder_page(layout, page_no) + + def recompute_reading_order(layout: dict[str, Any]) -> tuple[dict[str, Any], list[int]]: """Re-derive the reading order of every page in an issue, without any OCR. diff --git a/routes/magazine.py b/routes/magazine.py index 69e0db7..08197f1 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -735,6 +735,42 @@ def _refine_figures_sync(doc_id: str, job_id: str) -> None: background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) +@router.put("/api/magazine/{doc_id}/pages/{page}/reading-order") +async def magazine_set_page_order(doc_id: str, page: int, request: Request): + """Set one page's reading order by hand. Body: {order: [region id, ...]} -- text blocks and + figures, first to last; regions left out keep their order after the listed ones. The page is + then locked against automatic reordering until DELETE on the same path.""" + d, _ = _require_magazine(doc_id) + layout = mag.load_layout(d) + if not layout: + raise HTTPException(404, "No layout yet -- run ingestion first") + body = await request.json() + order = body.get("order") if isinstance(body, dict) else None + if not isinstance(order, list) or not order or len(order) > 2000 or not all(isinstance(i, str) for i in order): + raise HTTPException(400, "order must be a non-empty list of region ids") + try: + layout = mag.set_page_order(layout, page, order) + except ValueError as exc: + raise HTTPException(400, str(exc)) + mag.save_layout(d, layout) + return {"ok": True, "locked": True} + + +@router.delete("/api/magazine/{doc_id}/pages/{page}/reading-order") +async def magazine_clear_page_order(doc_id: str, page: int): + """Undo a hand-made order on one page: back to the automatic column order.""" + d, _ = _require_magazine(doc_id) + layout = mag.load_layout(d) + if not layout: + raise HTTPException(404, "No layout yet -- run ingestion first") + try: + layout = mag.clear_page_order(layout, page) + except ValueError as exc: + raise HTTPException(400, str(exc)) + mag.save_layout(d, layout) + return {"ok": True, "locked": False} + + @router.post("/api/magazine/{doc_id}/reading-order") async def magazine_recompute_reading_order(doc_id: str): """Re-derive the reading order of every page (column by column), without re-OCR. diff --git a/static/index.html b/static/index.html index 73c7602..4df074c 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 90ea5a7..a4280d6 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -765,6 +765,7 @@ function magReviewPageColumn(page, wrapId, interactive) { const regions = pageBlocks.filter(b => !visualBlockIds.has(b.id)).map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order ?? 0, data: b })) .concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order ?? 0, data: img }))) .sort((x, y) => x.ro - y.ro); + if (interactive) state.reviewRegionOrder = regions.map((r) => r.id); let pageHtml = ''; if (page != null) { @@ -812,7 +813,7 @@ function magReviewPageColumn(page, wrapId, interactive) { const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename); listHtml += '
' + - '' + (i + 1) + '' + + magOrderNumber(r.id, i + 1, regions.length, interactive) + '' + '' + magRegionControls(r, i + 1) + '' + '
' + @@ -829,7 +830,7 @@ function magReviewPageColumn(page, wrapId, interactive) { const on = included.has(b.id); listHtml += '
' + '' + '' + '' + '
' + - ''; + '' + + '
' + magOrderBarHtml() + '
'; html += ''; html += '' + esc(conf.text) + ''; @@ -994,6 +1001,10 @@ function renderReview() { '' + ''; host.innerHTML = html; + if (state.orderMode) { + $('mag-review-page-wrap')?.classList.add('is-ordering'); + magOrderRefreshBar(); + } setupMagDrawing(); setupMagBoxEdit(); } @@ -1109,7 +1120,126 @@ function magReviewClearCurrentPage() { say('All fields on this page cleared — select only the article content to keep.', 'ok'); } +// ── Reading order by hand ───────────────────────────────────────────────── +// The number in front of each row is its reading position. Typing a new number moves the region +// there; "Order by clicking" lets the reader click the boxes on the page in reading order. Either +// way the page is then locked against automatic reordering until "Automatic order" is pressed. + +function magOrderNumber(id, number, total, editable) { + if (!editable) return '' + number + ''; + return ''; +} + +function magReviewPageLocked() { + const meta = (state.layout?.pages || []).find((p) => p.page === state.reviewPage); + return !!meta?.order_locked; +} + +function magOrderBarHtml() { + if (state.orderMode) { + const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length; + return ' ' + + 'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' + + ' ' + + ' ' + + ''; + } + if (magReviewPageLocked()) { + return ' Reading order on this page was set by hand and is kept. ' + + ''; + } + return ''; +} + +function magOrderRefreshBar() { + const bar = $('mag-order-bar'); + if (bar) bar.innerHTML = magOrderBarHtml(); + document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => { + const at = (state.orderSeq || []).indexOf(box.dataset.block); + box.classList.toggle('is-ordered', state.orderMode && at >= 0); + const num = box.querySelector('.mag-review-box-num'); + if (num && state.orderMode) num.textContent = at >= 0 ? String(at + 1) : '·'; + }); +} + +function magOrderToggle() { + state.orderMode = !state.orderMode; + state.orderSeq = []; + state.orderPage = state.reviewPage; + if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind); + if (state.orderMode && state.editMode) magReviewToggleEdit(); + const btn = $('mag-order-toggle'); + if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); } + const wrap = $('mag-review-page-wrap'); + if (wrap) wrap.classList.toggle('is-ordering', state.orderMode); + if (!state.orderMode) return renderReview(); // restore the real numbers + magOrderRefreshBar(); +} + +function magOrderPick(id) { + const seq = state.orderSeq || (state.orderSeq = []); + if (!seq.includes(id)) seq.push(id); + magOrderRefreshBar(); +} + +function magOrderUndo() { + (state.orderSeq || []).pop(); + magOrderRefreshBar(); +} + +async function magOrderPut(order) { + await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }), + }); + state.layout = null; + await ensureLayout(); +} + +async function magOrderSave() { + const seq = (state.orderSeq || []).slice(); + if (!seq.length) return; + try { + await magOrderPut(seq); + } catch (e) { + return say('Could not save the reading order: ' + e.message, 'err'); + } + state.orderMode = false; + state.orderSeq = []; + say('Reading order saved for this page', 'ok'); + renderReview(); +} + +async function magOrderMove(id, value) { + const order = (state.reviewRegionOrder || []).filter((x) => x !== id); + const to = Math.min(Math.max(1, parseInt(value, 10) || 1), order.length + 1) - 1; + order.splice(to, 0, id); + try { + await magOrderPut(order); + } catch (e) { + say('Could not change the reading order: ' + e.message, 'err'); + return renderReview(); + } + say('Moved to position ' + (to + 1), 'ok'); + renderReview(); +} + +async function magOrderReset() { + try { + await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', { method: 'DELETE' }); + } catch (e) { + return say('Could not reset the reading order: ' + e.message, 'err'); + } + state.layout = null; + await ensureLayout(); + say('Automatic reading order restored for this page', 'ok'); + renderReview(); +} + function magReviewFocusBlock(blockId) { + if (state.orderMode) return magOrderPick(blockId); const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]'); if (state.drawMode || state.editMode) return; if (cb) { cb.checked = !cb.checked; magReviewToggleBox(cb); } @@ -2417,6 +2547,11 @@ root.magReviewSubmitEdit = magReviewSubmitEdit; root.magReviewDeleteRegion = magReviewDeleteRegion; root.magReviewChangeType = magReviewChangeType; root.magFixReadingOrder = magFixReadingOrder; +root.magOrderToggle = magOrderToggle; +root.magOrderSave = magOrderSave; +root.magOrderUndo = magOrderUndo; +root.magOrderMove = magOrderMove; +root.magOrderReset = magOrderReset; root.magReocrMagazine = magReocrMagazine; root.magApprove = magApprove; root.magSetStatus = magSetStatus; diff --git a/static/style.css b/static/style.css index 5818731..c6cbdd9 100644 --- a/static/style.css +++ b/static/style.css @@ -5161,3 +5161,13 @@ input[list="lang-codes"] { .lcd-form-flag.is-ok { color: var(--green, #16a34a); } .lcd-form-add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; } .lcd-forms-manage :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Reading order by hand (magazine review) */ +.mag-block-order { width: 3.2em; min-height: 28px; padding: 2px 4px; font: inherit; font-size: 12px; + text-align: center; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } +.mag-order-bar:empty { display: none; } +.mag-order-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; padding: 6px 10px; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); font-size: 13px; } +.mag-review-page-wrap.is-ordering .mag-review-box { cursor: pointer; } +.mag-review-box.is-ordered { outline: 3px solid var(--accent, #2563eb); outline-offset: -1px; } +.mag-review-box.is-ordered .mag-review-box-num { background: var(--accent, #2563eb); color: #fff; } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 4da6b23..742afc3 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -397,3 +397,57 @@ test('fix reading order asks first, then reports how many pages changed', async await expect.poll(() => called).toBe(true); await expect(page.getByText(/Reading order fixed on 2 pages \(1 hand-ordered page kept\)/)).toBeVisible(); }); + +test('typing a position moves a region and saves the whole page order', async ({ page }) => { + await mockBackend(page); + let sent = null; + await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => { + expect(route.request().method()).toBe('PUT'); + sent = route.request().postDataJSON().order; + return route.fulfill({ json: { ok: true, locked: true } }); + }); + await openReview(page); + const position = page.getByRole('spinbutton', { name: /Reading position of region 3/ }); + await position.fill('1'); + await position.press('Enter'); + await expect.poll(() => sent).toEqual(['b3', 'b1', 'b2']); +}); + +test('order by clicking: boxes clicked on the page are saved in click order', async ({ page }) => { + await mockBackend(page); + let sent = null; + await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => { + sent = route.request().postDataJSON().order; + return route.fulfill({ json: { ok: true, locked: true } }); + }); + await openReview(page); + const b1Checked = await page.locator('#mag-review input[data-block="b1"]').isChecked(); + await page.getByRole('button', { name: 'Order by clicking', exact: true }).click(); + await expect(page.locator('#mag-order-bar')).toContainText('0 of 3 set'); + await page.locator('#mag-review-page-wrap [data-block="b2"]').click(); + await page.locator('#mag-review-page-wrap [data-block="b1"]').click(); + await expect(page.locator('#mag-order-bar')).toContainText('2 of 3 set'); + await expect(page.locator('#mag-review-page-wrap [data-block="b2"] .mag-review-box-num')).toHaveText('1'); + // Clicking boxes to order them must not tick or untick them. + await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked({ checked: b1Checked }); + await page.getByRole('button', { name: 'Save order', exact: true }).click(); + await expect.poll(() => sent).toEqual(['b2', 'b1']); +}); + +test('a hand-ordered page says so and can go back to the automatic order', async ({ page }) => { + await mockBackend(page); + let reset = false; + await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ + json: { ...LAYOUT, pages: [{ ...LAYOUT.pages[0], order_locked: !reset }] }, + })); + await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => { + expect(route.request().method()).toBe('DELETE'); + reset = true; + return route.fulfill({ json: { ok: true, locked: false } }); + }); + await openReview(page); + await expect(page.locator('#mag-order-bar')).toContainText('set by hand'); + await page.getByRole('button', { name: 'Automatic order', exact: true }).click(); + await expect.poll(() => reset).toBe(true); + await expect(page.locator('#mag-order-bar')).not.toContainText('set by hand'); +}); diff --git a/tests/test_magazine_routes.py b/tests/test_magazine_routes.py index a7d7cda..21d1dab 100644 --- a/tests/test_magazine_routes.py +++ b/tests/test_magazine_routes.py @@ -715,3 +715,78 @@ class RecomputeReadingOrderTests(unittest.TestCase): after, _ = recompute_reading_order(before) strip = lambda bs: sorted((b["id"], b["text"], b["type"], tuple(b["bbox"])) for b in bs) self.assertEqual(strip(after["blocks"]), strip(before["blocks"])) + + +class HandOrderTests(unittest.TestCase): + """Ordering a page by hand, and every other path leaving that order alone.""" + + def layout(self): + return { + "pages": [{"page": 1, "width": 595.0, "height": 842.0}], + "blocks": [{"id": i, "page": 1, "bbox": [x, y, 200, 80], "type": "body", "text": i, "reading_order": ro} + for i, x, y, ro in (("l1", 50, 100, 0), ("l2", 50, 200, 1), ("r1", 320, 100, 2), ("r2", 320, 200, 3))], + "images": [{"id": "img", "page": 1, "bbox": [320, 400, 200, 100], "filename": "img.png", "reading_order": 4}], + } + + def order(self, layout): + items = [r for r in layout["blocks"] + layout["images"] if r["page"] == 1] + return [r["id"] for r in sorted(items, key=lambda r: r["reading_order"])] + + def test_the_listed_order_is_kept_and_the_page_is_locked(self): + from core.magazine import set_page_order + layout = set_page_order(self.layout(), 1, ["r1", "img", "l1", "l2", "r2"]) + self.assertEqual(self.order(layout), ["r1", "img", "l1", "l2", "r2"]) + self.assertTrue(layout["pages"][0]["order_locked"]) + + def test_regions_left_out_follow_in_their_old_order(self): + from core.magazine import set_page_order + layout = set_page_order(self.layout(), 1, ["r2"]) + self.assertEqual(self.order(layout), ["r2", "l1", "l2", "r1", "img"]) + + def test_an_id_from_another_page_is_rejected(self): + from core.magazine import set_page_order + with self.assertRaises(ValueError): + set_page_order(self.layout(), 1, ["l1", "elsewhere"]) + + def test_fix_reading_order_and_type_changes_leave_a_hand_ordered_page_alone(self): + from core.magazine import recompute_reading_order, set_page_order + layout = set_page_order(self.layout(), 1, ["r1", "r2", "l1", "l2"]) + layout, changed = recompute_reading_order(layout) + self.assertEqual(changed, []) + self.assertEqual(self.order(layout)[:4], ["r1", "r2", "l1", "l2"]) + + def test_a_new_region_on_a_hand_ordered_page_goes_last(self): + from pathlib import Path + from PIL import Image + from core.magazine import add_manual_region, set_page_order + d = Path(tempfile.mkdtemp(prefix="ttvc-handorder-")) + (d / "pages").mkdir() + Image.new("RGB", (600, 800), "white").save(d / "pages" / "0001.png") + try: + layout = set_page_order(self.layout(), 1, ["r1", "r2", "l1", "l2"]) + layout = add_manual_region(d, layout, 1, [50, 20, 400, 40], "text", "New headline") + order = self.order(layout) + self.assertEqual(order[:4], ["r1", "r2", "l1", "l2"]) + self.assertTrue(order[-1].startswith("m0001-")) + finally: + shutil.rmtree(d, ignore_errors=True) + + def test_clearing_the_hand_order_goes_back_to_columns(self): + from core.magazine import clear_page_order, set_page_order + layout = clear_page_order(set_page_order(self.layout(), 1, ["r1", "r2", "l1", "l2"]), 1) + self.assertNotIn("order_locked", layout["pages"][0]) + blocks = [b["id"] for b in sorted(layout["blocks"], key=lambda b: b["reading_order"])] + self.assertEqual(blocks, ["l1", "l2", "r1", "r2"]) + + def test_route_validates_the_body(self): + import asyncio + from unittest.mock import AsyncMock, patch + from routes.magazine import magazine_set_page_order + for body in ({}, {"order": []}, {"order": "l1"}, {"order": [1, 2]}): + with self.subTest(body=body), \ + patch('routes.magazine._require_magazine', return_value=(None, {})), \ + patch('routes.magazine.mag.load_layout', return_value=self.layout()): + request = AsyncMock(); request.json.return_value = body + with self.assertRaises(HTTPException) as err: + asyncio.run(magazine_set_page_order('a' * 32, 1, request)) + self.assertEqual(err.exception.status_code, 400)