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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-21 11:58:37 +02:00
parent 04c75b2ea3
commit baab49dbdf
12 changed files with 402 additions and 13 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1 +1 @@
1.35.0
1.36.0

View File

@ -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

View File

@ -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,6 +2626,12 @@ 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]
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]
@ -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.

View File

@ -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.

View File

@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.35.0">
<link rel="stylesheet" href="/static/style.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.36.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -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 += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
magOrderNumber(r.id, i + 1, regions.length, interactive) +
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</span>' +
'<div class="mag-block-thumb-wrap">' +
@ -829,7 +830,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
const on = included.has(b.id);
listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
magOrderNumber(b.id, i + 1, regions.length, interactive) +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</span>' +
'<span class="mag-block-text">' +
@ -880,6 +881,8 @@ function renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
// A half-built click order belongs to the page it was started on.
if (state.orderMode && state.orderPage !== state.reviewPage) { state.orderMode = false; state.orderSeq = []; }
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageCount = Number(state.layout?.page_count || 0);
@ -925,6 +928,9 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-edit-toggle" aria-pressed="false" ' +
'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' +
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
'<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' +
'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' +
'<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</button>' +
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' +
'<span class="mag-review-pagenav-spacer"></span>' +
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
@ -943,7 +949,8 @@ function renderReview() {
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'</div>' +
'<span id="mag-draw-hint" class="muted"></span>';
'<span id="mag-draw-hint" class="muted"></span>' +
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>';
html += '</div>';
html += '<span class="mag-review-conf ' + conf.cls + '">' + esc(conf.text) + '</span>';
@ -994,6 +1001,10 @@ function renderReview() {
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
'</div>';
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 '<span class="mag-block-num">' + number + '</span>';
return '<input type="number" class="mag-block-num mag-block-order" min="1" max="' + total + '" value="' + number + '" ' +
'aria-label="Reading position of region ' + number + ' (1 to ' + total + ')" ' +
'onclick="event.stopPropagation()" onkeydown="if (event.key === \'Enter\') { event.preventDefault(); this.blur(); }" ' +
'onchange="magOrderMove(\'' + esc(id) + '\', this.value)">';
}
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 '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button> ' +
'<button type="button" class="btn-sm" onclick="magOrderToggle()">Cancel</button>';
}
if (magReviewPageLocked()) {
return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' +
'<button type="button" class="btn-sm" onclick="magOrderReset()">Automatic order</button>';
}
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;

View File

@ -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; }

View File

@ -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');
});

View File

@ -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)