diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6dfb3a8..89feb56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.30.0] — 2026-09-15
+
+### Added
+- **Edit boxes in Magazine review.** In article review, a new "Edit boxes" mode lets you drag an already-detected region to move it, drag its corner handle to resize it, or press its × to delete it — for correcting a box the layout engine got wrong, not for one it missed entirely ("Draw it" still covers that). Moving or resizing opens a small confirm panel: "Save" keeps the region at its new position, and for text regions "Save & re-OCR" also reads the corrected area again and replaces its text. Deleting a region removes it from the page entirely.
+- New `core/magazine.py` `update_region()`/`delete_region()` and `PATCH`/`DELETE /api/magazine/{doc}/pages/{page}/regions/{block_id}` routes. An image region is re-cropped from the page render whenever its bbox changes, since its saved file is exactly that crop.
+
+### Verified
+- Live against the real ingested c't KI Wissen 2026 magazine: added a throwaway region, moved it (bbox persisted, text unchanged), moved it again with re-OCR (correctly preserved the old text when the corrected area had no readable text — the same known engine limitation from 1.29.0, not a new bug), deleted it (confirmed gone; repeat delete correctly 404s).
+
+---
+
## [1.29.1] — 2026-09-14
### Fixed
diff --git a/Manual.md b/Manual.md
index 6649c86..b1c2892 100644
--- a/Manual.md
+++ b/Manual.md
@@ -62,7 +62,16 @@ turned into a podcast discussion between two or three voices you pick.
place confidently are left exactly as they were. If the layout
engine missed a region entirely, **"Draw it"** lets you click-drag a box
directly on the page image and add it as text (typed or OCRed) or as a
- new figure. Confirm the boundaries, correct the title, then **Approve**.
+ new figure. If a region was found but its box is misplaced, the wrong
+ size, or shouldn't exist at all, **"Edit boxes"** lets you drag an
+ existing box to move it, drag its corner handle to resize it, or press
+ its **×** to delete it outright. Moving or resizing opens a small confirm
+ panel — **Save** keeps the region's existing text at its new position;
+ for a text region, **Save & re-OCR** also reads the corrected area again
+ and replaces the text with that fresh reading (useful when the box was
+ too small or too large to read cleanly the first time). Deleting a
+ region removes it from the page entirely, not just from the article
+ currently under review. Confirm the boundaries, correct the title, then **Approve**.
Optionally, click **"Describe"** under a figure, chart, table or code block
to have the configured vision engine generate a one- or two-sentence
description for listeners who cannot see the image — e.g. what a chart
@@ -127,6 +136,7 @@ unreviewed boundaries may include a neighbouring article or an advert.
| "Fix figure boundaries" reports nothing corrected | Every box already matched its page, or none could be placed with confidence. | Nothing is wrong; correct any remaining box by drawing it. |
| "No vision OCR engine configured" (Describe) | `vision_ocr_url`/`vision_ocr_model` are empty. | Set a vision engine in Settings. |
| "The vision engine returned nothing usable for this figure" | The configured engine (and fallback, if set) returned an empty or degenerate response for this image. Some vision models are transcription-only and cannot produce open-ended descriptions at all, however the prompt is phrased — confirmed with a pure-OCR model that answers its own "Text Recognition:" prompt but returns nothing for any descriptive one. | Configure a genuinely caption-capable vision model as the fallback (`vision_ocr_fallback_url`/`vision_ocr_fallback_model`), or write the description by hand. |
+| "Save & re-OCR" on an edited box leaves the old text in place | The corrected area produced an empty or unreadable OCR result — the same engine limitation that can affect figure descriptions applies here too. | Press **Save** to keep the corrected box position with the existing text, then correct the text by hand if needed, or try "OCR again (this page)" once the box position is saved. |
### Privacy
diff --git a/TODO.md b/TODO.md
index 2d59384..940e086 100644
--- a/TODO.md
+++ b/TODO.md
@@ -365,6 +365,9 @@ casting. The original PDF remains the visual source of truth while approved arti
- [x] Add OCR-engine selection from Engines > OCR & Document Parsing, with page-image input and structured output.
- [x] Detect contents/index pages and propose article titles, authors, and page ranges.
- [x] Build a review screen where users approve or correct article boundaries before TTS.
+ *(includes "Edit boxes" — user-requested 2026-09-14: drag an already-detected region to move or resize it,
+ or delete it outright, with an optional re-OCR of the corrected area for text regions. "Draw it" remains the
+ separate tool for a region detection missed entirely.)*
- [x] Preserve source coordinates for every extracted block, image, caption, and page reference.
- [x] Generate per-article podcast scripts from a connected LLM with two or three user-picked host voices,
diff --git a/VERSION b/VERSION
index eaeb236..ff6d09b 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.29.1
\ No newline at end of file
+1.30.0
\ No newline at end of file
diff --git a/core/magazine.py b/core/magazine.py
index 36193a8..6868477 100644
--- a/core/magazine.py
+++ b/core/magazine.py
@@ -2542,6 +2542,77 @@ def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
crop_path.unlink(missing_ok=True)
+def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id: str,
+ bbox: Sequence[float] | None, reocr: bool = False,
+ vision_ocr_url: str | None = None,
+ vision_ocr_model: str | None = None,
+ vision_ocr_fallback_url: str | None = None,
+ vision_ocr_fallback_model: str | None = None,
+ timeout: float = 150.0) -> tuple[dict[str, Any], str | None]:
+ """Move/resize a region the layout engine already found, after the user
+ dragged its box to correct a misplaced or mis-sized detection.
+
+ Not for a region detection missed entirely (add_manual_region covers
+ that) -- block_id must already exist in this page's blocks or images.
+ A text block can also be re-OCRed at its corrected geometry (`reocr`);
+ an image region is always re-cropped from the page render when its bbox
+ changes, since its saved file is exactly that crop. Returns the updated
+ layout and, when reocr produced text, that text (None otherwise, so the
+ caller can tell "no re-OCR requested" apart from "re-OCR found nothing").
+ """
+ page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
+ if not page_meta:
+ raise ValueError("Unknown page")
+ if bbox is not None and (len(bbox) != 4 or bbox[2] <= 0 or bbox[3] <= 0):
+ raise ValueError("bbox must be [x, y, width, height] with a positive size")
+ layout = dict(layout)
+
+ blocks = list(layout.get("blocks", []))
+ block_idx = next((i for i, b in enumerate(blocks) if b["id"] == block_id), None)
+ if block_idx is not None:
+ block = dict(blocks[block_idx])
+ if bbox is not None:
+ block["bbox"] = list(bbox)
+ new_text = None
+ if reocr:
+ new_text = extract_region_text(
+ doc_dir, layout, page_no, block["bbox"],
+ vision_ocr_url, vision_ocr_model,
+ vision_ocr_fallback_url, vision_ocr_fallback_model, timeout)
+ if new_text:
+ block["text"] = new_text
+ blocks[block_idx] = block
+ layout["blocks"] = blocks
+ return layout, new_text
+
+ images = list(layout.get("images", []))
+ image_idx = next((i for i, im in enumerate(images) if im["id"] == block_id), None)
+ if image_idx is None:
+ raise ValueError("Region not found on this page")
+ image = dict(images[image_idx])
+ if bbox is not None:
+ image["bbox"] = list(bbox)
+ filename = image.get("filename") or f"{block_id}.png"
+ (doc_dir / "images").mkdir(parents=True, exist_ok=True)
+ _crop_page_png(doc_dir, page_no, page_meta, bbox, doc_dir / "images" / filename)
+ image["filename"] = filename
+ images[image_idx] = image
+ layout["images"] = images
+ return layout, None
+
+
+def delete_region(layout: dict[str, Any], block_id: str) -> dict[str, Any]:
+ """Remove a region the layout engine got wrong entirely (not just
+ misplaced). An article referencing this id in its own blocks/images
+ list is left as-is -- article_text() and the review screen already
+ treat an id no longer present in the layout as simply absent, the same
+ way a page re-OCR can already drop ids an article still lists."""
+ layout = dict(layout)
+ layout["blocks"] = [b for b in layout.get("blocks", []) if b["id"] != block_id]
+ layout["images"] = [im for im in layout.get("images", []) if im["id"] != block_id]
+ return layout
+
+
def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None:
(doc_dir / "layout.json").write_text(
json.dumps(layout, ensure_ascii=False), "utf-8")
diff --git a/routes/magazine.py b/routes/magazine.py
index acf6f46..414cfaf 100644
--- a/routes/magazine.py
+++ b/routes/magazine.py
@@ -811,6 +811,59 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
return {"job_id": job_id}
+@router.patch("/api/magazine/{doc_id}/pages/{page}/regions/{block_id}")
+async def magazine_update_region(doc_id: str, page: int, block_id: str, request: Request):
+ """Move/resize a region the layout engine already detected -- for
+ correcting a box that's misplaced or mis-sized, not for adding one
+ that's missing entirely (POST .../regions covers that).
+ """
+ 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()
+ bbox = body.get("bbox")
+ if bbox is not None and not (isinstance(bbox, list) and len(bbox) == 4
+ and all(isinstance(v, (int, float)) for v in bbox)):
+ raise HTTPException(400, "bbox must be [x, y, width, height]")
+ reocr = bool(body.get("reocr"))
+
+ settings = _load_settings()
+ vision_ocr_url = str(settings.get("vision_ocr_url") or "").strip() or None
+ vision_ocr_model = str(settings.get("vision_ocr_model") or "").strip() or None
+ vision_ocr_fallback_url = str(settings.get("vision_ocr_fallback_url") or "").strip() or None
+ vision_ocr_fallback_model = str(settings.get("vision_ocr_fallback_model") or "").strip() or None
+
+ try:
+ layout, text = await asyncio.to_thread(
+ mag.update_region, d, layout, page, block_id,
+ [float(v) for v in bbox] if bbox is not None else None, reocr,
+ vision_ocr_url, vision_ocr_model, vision_ocr_fallback_url, vision_ocr_fallback_model,
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc))
+ mag.save_layout(d, layout)
+ return {"ok": True, "text": text}
+
+
+@router.delete("/api/magazine/{doc_id}/pages/{page}/regions/{block_id}")
+async def magazine_delete_region(doc_id: str, page: int, block_id: str):
+ """Remove a region the layout engine detected wrongly -- an artefact,
+ a duplicate, or a box that doesn't correspond to real content at all.
+ """
+ d, _ = _require_magazine(doc_id)
+ layout = mag.load_layout(d)
+ if not layout:
+ raise HTTPException(404, "No layout yet -- run ingestion first")
+ if not any(b["id"] == block_id for b in layout.get("blocks", [])) and \
+ not any(im["id"] == block_id for im in layout.get("images", [])):
+ raise HTTPException(404, "Region not found")
+ layout = mag.delete_region(layout, block_id)
+ mag.save_layout(d, layout)
+ return {"ok": True}
+
+
@router.post("/api/magazine/{doc_id}/pages/{page}/extract")
async def magazine_extract_region(doc_id: str, page: int, request: Request):
"""OCR a selected page rectangle for a metadata field without saving it."""
diff --git a/static/js/magazine.js b/static/js/magazine.js
index 8185918..a8a0393 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -741,6 +741,9 @@ function renderReview() {
' Source / footer' +
'' +
+ '' +
'' +
'' +
'