diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97e835e..cc8317c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.26.6] — 2026-09-09
+
+### Added
+- **Safer Magazine Deletion.** Each magazine card now offers a confirmed delete action that removes its source PDF,
+ OCR/layout data, articles and generated assets together.
+- **Rectangle-to-Metadata Extraction.** In article review, **Pick from page** beside Title and Author OCRs a drawn
+ rectangle directly into that metadata field without adding it to narratable article content.
+- **Whole-Issue Page Navigation.** Review now has a separate **Magazine p. N / total** navigator, allowing every
+ PDF page to be inspected across article boundaries while keeping the article-only navigator available.
+
+### Changed
+- **Review Selection Controls Are Clearer.** **Draw image** is now **Draw graphic**, and **Clear all fields** resets
+ all selected text and graphics on the current page for clean manual article-boundary review.
+- **Headline Detection Is Not Pretended to Be a Percentage.** The fixed 0.55 fallback now displays as
+ **headline detected** rather than a misleading repeated `55%` confidence badge.
+
+---
+
## [1.26.5] — 2026-09-09
### Fixed
diff --git a/VERSION b/VERSION
index 8fe00a5..f250c8f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.26.5
+1.26.6
diff --git a/core/magazine.py b/core/magazine.py
index 38ee4f5..0ab9123 100644
--- a/core/magazine.py
+++ b/core/magazine.py
@@ -32,6 +32,7 @@ import math
import re
import statistics
import subprocess
+import tempfile
import unicodedata
import requests
@@ -1860,6 +1861,39 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
return layout
+def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
+ bbox: Sequence[float], 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) -> str:
+ """OCR a user-selected rectangle without changing the saved layout.
+
+ Used for metadata fields such as article title/author: unlike a manual
+ region, the selection is a one-off extraction and must not become prose
+ that is later narrated.
+ """
+ page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
+ if not page_meta or len(bbox) != 4 or bbox[2] <= 0 or bbox[3] <= 0:
+ raise ValueError("Invalid page selection")
+ with tempfile.NamedTemporaryFile(prefix=f".extract-{page_no}-", suffix=".png",
+ dir=doc_dir / "pages", delete=False) as tmp:
+ crop_path = Path(tmp.name)
+ try:
+ _crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
+ regions = None
+ if vision_ocr_url and vision_ocr_model:
+ regions = vision_ocr_page_chain(crop_path, [
+ (vision_ocr_url, vision_ocr_model),
+ (vision_ocr_fallback_url, vision_ocr_fallback_model),
+ ], timeout=timeout)
+ if regions:
+ return " ".join(r.get("text", "") for r in regions if r.get("text")).strip()
+ return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
+ finally:
+ crop_path.unlink(missing_ok=True)
+
+
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 7b0bf6b..ca91f71 100644
--- a/routes/magazine.py
+++ b/routes/magazine.py
@@ -686,6 +686,27 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
return {"job_id": job_id}
+@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."""
+ d, _ = _require_magazine(doc_id)
+ body = await request.json()
+ bbox = body.get("bbox")
+ if page < 1 or 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]")
+ layout = mag.load_layout(d)
+ settings = _load_settings()
+ text = await asyncio.to_thread(
+ mag.extract_region_text, d, layout, page, [float(v) for v in bbox],
+ str(settings.get("vision_ocr_url") or "").strip() or None,
+ str(settings.get("vision_ocr_model") or "").strip() or None,
+ str(settings.get("vision_ocr_fallback_url") or "").strip() or None,
+ str(settings.get("vision_ocr_fallback_model") or "").strip() or None,
+ )
+ return {"text": text}
+
+
@router.get("/api/magazine/{doc_id}/images/{name}")
async def magazine_image(doc_id: str, name: str):
d, _ = _require_magazine(doc_id)
diff --git a/static/js/magazine.js b/static/js/magazine.js
index 3ffb8f1..c423dcb 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -145,12 +145,33 @@ function renderLibrary() {
const thumbnail = '
';
- return '' +
+ ' Clear all fields' +
'' +
'' +
' OCR again (this page)' +
@@ -693,6 +738,18 @@ function magReviewToggleImage(id, checked) {
}
}
+function magReviewClearCurrentPage() {
+ if (state.reviewPage == null) return;
+ if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
+ const page = state.reviewPage;
+ (state.layout.blocks || []).filter((block) => block.page === page)
+ .forEach((block) => _magReviewChecked.delete(block.id));
+ const imageIds = new Set((state.layout.images || []).filter((image) => image.page === page).map((image) => image.id));
+ state.article.images = (state.article.images || []).filter((id) => !imageIds.has(id));
+ renderReview();
+ say('All fields on this page cleared — select only the article content to keep.', 'ok');
+}
+
function magReviewFocusBlock(blockId) {
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
if (state.drawMode) return;
@@ -718,7 +775,10 @@ function magReviewToggleDraw(kind = 'text') {
state.drawMode = state.drawMode && state.drawKind === kind ? false : true;
state.drawKind = kind;
const hint = $('mag-draw-hint');
- if (hint) hint.textContent = state.drawMode ? ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.' : '';
+ const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : '';
+ if (hint) hint.textContent = state.drawMode
+ ? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.`
+ : ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.') : '';
const wrap = $('mag-review-page-wrap');
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
const btn = $('mag-draw-toggle');
@@ -801,7 +861,13 @@ function magReviewOpenDrawPanel(box) {
box.hidden = false;
const panel = document.createElement('div');
panel.className = 'mag-draw-panel';
- panel.innerHTML =
+ const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
+ panel.innerHTML = field
+ ? '
Extract ' + field + '
' +
+ 'OCR the selected rectangle and use its recognised text for the article ' + field + '.
' +
+ 'Cancel' +
+ ' Use recognised text
'
+ :
'' +
'' +
'' +
@@ -830,7 +896,8 @@ async function magReviewSubmitDraw() {
const box = $('mag-draw-rect');
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
if (!box || !pageMeta) return;
- const kind = document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text';
+ const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
+ const kind = field ? 'text' : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text');
const text = ($('mag-draw-text')?.value || '').trim();
const bbox = [
parseFloat(box.style.left) / 100 * pageMeta.width,
@@ -847,6 +914,22 @@ async function magReviewSubmitDraw() {
if (!_magReviewChecked) _magReviewChecked = new Set(article.blocks || []);
magReviewCancelDrawPanel();
state.drawMode = false;
+ if (field) {
+ magReviewCancelDrawPanel();
+ say(`Reading selected ${field}…`, '');
+ try {
+ const result = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/extract', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox }),
+ });
+ const value = String(result.text || '').replace(/\s+/g, ' ').trim();
+ if (!value) return say('No text found in that selection', 'err');
+ article[field] = value;
+ const input = $(field === 'title' ? 'mag-rev-title' : 'mag-rev-author');
+ if (input) input.value = value;
+ say(`${field === 'title' ? 'Title' : 'Author'} filled from selected text`, 'ok');
+ } catch (e) { say(`Could not read selected ${field}: ` + e.message, 'err'); }
+ return;
+ }
say('Adding region…', '');
let jobId;
try {
@@ -1547,10 +1630,12 @@ async function magInit() {
}
root.magOpen = magOpen;
+root.magDelete = magDelete;
root.magCreate = magCreate;
root.magIngest = magIngest;
root.magReview = magReview;
root.magReviewGoPage = magReviewGoPage;
+root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
diff --git a/static/style.css b/static/style.css
index e871a1a..4a9616a 100644
--- a/static/style.css
+++ b/static/style.css
@@ -4777,11 +4777,16 @@ input[list="lang-codes"] {
.mag-new-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
.mag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
-.mag-card { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; text-align: left;
+.mag-card-wrap { position: relative; min-width: 0; }
+.mag-card { display: flex; width: 100%; min-height: 100%; flex-direction: column; gap: 6px; align-items: flex-start; text-align: left;
padding: 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text); cursor: pointer; }
.mag-card:hover { border-color: var(--accent); }
.mag-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.18); }
+.mag-card-delete { position: absolute; top: 7px; right: 7px; width: 32px; height: 32px; display: grid; place-items: center;
+ border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--surface) 88%, transparent);
+ color: var(--subtext); cursor: pointer; opacity: .82; }
+.mag-card-delete:hover, .mag-card-delete:focus-visible { color: var(--error); border-color: var(--error); background: var(--surface); opacity: 1; }
.mag-card-thumb { display: block; width: 100%; height: 190px; object-fit: contain; object-position: center;
background: var(--panel); border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); }
.mag-card-title { font-weight: 600; }
@@ -4856,6 +4861,9 @@ input[list="lang-codes"] {
.mag-review-head { display: grid; grid-template-columns: 2fr 1fr auto; gap: 12px; align-items: end; margin-bottom: 10px; }
.mag-review-head label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); }
+.mag-review-field-control { display: flex; gap: 6px; align-items: center; }
+.mag-review-field-control input { flex: 1 1 auto; min-width: 0; }
+.mag-review-field-control .btn-sm { white-space: nowrap; }
.mag-review-head input { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text); }
.mag-page-head { margin: 16px 0 6px; font-size: 13px; color: var(--subtext); }