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:
parent
04c75b2ea3
commit
baab49dbdf
@ -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
|
||||
|
||||
10
Manual.md
10
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
|
||||
|
||||
2
TODO.md
2
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) ── -->
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user