feat: improve magazine review navigation
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-09 17:36:27 +02:00
parent c1d86ec13d
commit 31b13a4e16
6 changed files with 182 additions and 16 deletions

View File

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

View File

@ -1 +1 @@
1.26.5
1.26.6

View File

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

View File

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

View File

@ -145,12 +145,33 @@ function renderLibrary() {
const thumbnail = '<img class="mag-card-thumb" src="/api/magazine/' + encodeURIComponent(d.id) +
'/pages/1" alt="First page of ' + esc(d.title || 'magazine') +
'" loading="lazy" onerror="this.hidden=true">';
return '<button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
return '<div class="mag-card-wrap"><button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
thumbnail + '<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>';
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>' +
'<button type="button" class="mag-card-delete" onclick="magDelete(\'' + d.id + '\')" ' +
'title="Delete this magazine" aria-label="Delete ' + esc(d.title || 'magazine') + '"><span class="mdi mdi-delete-outline"></span></button></div>';
}).join('');
}
async function magDelete(docId) {
const doc = state.docs.find((item) => item.id === docId);
const title = doc?.title || 'this magazine';
if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return;
try {
await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' });
if (state.doc?.id === docId) {
magForgetLastDoc();
state.doc = null;
state.article = null;
}
state.docs = state.docs.filter((item) => item.id !== docId);
renderLibrary();
say(`Deleted “${title}”`, 'ok');
} catch (e) {
say('Could not delete magazine: ' + e.message, 'err');
}
}
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
@ -388,9 +409,16 @@ async function magRefreshArticles() {
// ── Contents tree ────────────────────────────────────────────────────────────
function confidenceLabel(c) {
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents' };
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries' };
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article' };
// 0.55 is the backend's explicit heuristic marker: no reliable contents
// entry was available, so an article was started from a page headline. It
// is not a calibrated probability and a column of identical “55%” badges
// falsely suggests a measured OCR score.
if (Math.abs(Number(c) - 0.55) < 0.001) {
return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' };
}
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' };
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' };
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' };
}
function renderContents() {
@ -428,7 +456,7 @@ function renderContents() {
'<span>' + fmtPages(a.pages) + '</span>' +
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
Math.round(a.confidence * 100) + '%</span>' +
esc(conf.badge) + '</span>' +
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
'</div>' +
@ -494,6 +522,13 @@ function magReviewGoPage(page) {
renderReview();
}
function magReviewGoMagazinePage(page) {
const lastPage = Number(state.layout?.page_count || 0);
if (!Number.isInteger(page) || page < 1 || page > lastPage) return;
state.reviewPage = page;
renderReview();
}
// One glyph per region kind, shown centred in its box -- the fastest way to
// tell "a paragraph of text" from "a photo" from "furniture that will not be
// read" without reading the label. No table detection exists yet (P2), so a
@ -550,11 +585,14 @@ function renderReview() {
const spanIdx = state.reviewSpan.indexOf(page);
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null;
const issuePages = Number(state.layout?.page_count || 0);
const previousIssuePage = page > 1 ? page - 1 : null;
const nextIssuePage = page < issuePages ? page + 1 : null;
let html =
'<div class="mag-review-head">' +
'<label>Title<input id="mag-rev-title" type="text" value="' + esc(a.title) + '"></label>' +
'<label>Author<input id="mag-rev-author" type="text" value="' + esc(a.author) + '"></label>' +
'<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 class="mag-pill ' + conf.cls + '">' + esc(conf.text) + '</div>' +
'</div>' +
'<p class="muted">Untick anything that is not part of this article — a sidebar, an advert, ' +
@ -566,14 +604,21 @@ function renderReview() {
'<div class="mag-review-page-controls" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevPage ? '' : 'disabled') +
' onclick="magReviewGoPage(' + prevPage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextPage ? '' : 'disabled') +
'<strong class="mag-review-page-number">Article p. ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextPage ? '' : 'disabled') +
' onclick="magReviewGoPage(' + nextPage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-page-controls mag-review-issue-controls" role="group" aria-label="Whole magazine pages">' +
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (previousIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + previousIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button></div>' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw graphic</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-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
@ -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
? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' +
'<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' +
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>'
:
'<div class="mag-draw-panel-row">' +
'<label><input type="radio" name="mag-draw-kind" value="text" ' + (state.drawKind !== 'image' ? 'checked' : '') + '> Text</label>' +
'<label><input type="radio" name="mag-draw-kind" value="image" ' + (state.drawKind === 'image' ? 'checked' : '') + '> Image</label>' +
@ -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;

View File

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