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 += '