From 85080f4b82e61d64beab53c2dad9338d3b5580c0 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 7 Sep 2026 18:52:21 +0200 Subject: [PATCH] feat: add live ingest preview, region icons/ownership badges, manual region drawing to magazine review Ingestion now streams each page's image and recognised text into a console-styled preview panel (Baidu Unlimited-OCR demo style) as pages finish, instead of a bare progress bar -- raw text is written to a per-job ingest_preview.json file since the background-job result field strips text-like keys for data minimization. Split-screen review boxes and their matching list rows now show a centred icon for the region's kind and, when a region on a shared page belongs to a different article, a badge naming that article, so a claimed region is never mistaken for unclaimed text. Added a "Draw it" mode: click-drag a box directly on the page image to add a region the layout engine missed, as typed/OCRed text or as a new figure (add_manual_region, POST /api/magazine/{doc_id}/pages/{page}/regions). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 11 ++ Manual.md | 22 ++- core/magazine.py | 105 +++++++++++- routes/magazine.py | 88 +++++++++- static/js/magazine.js | 274 +++++++++++++++++++++++++++++--- static/sections/s-magazine.html | 10 ++ static/style.css | 52 +++++- tests/test_magazine.py | 108 +++++++++++++ 8 files changed, 641 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feb2955..b9cc40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi outside the zone can no longer trigger the browser's default action either. ### Added +- **Live Ingest Preview, Region Type Icons, Article-Ownership Badges, Manual Region Drawing.** Modelled on the + Baidu Unlimited-OCR demo UI: while a magazine is being ingested, a dark console-styled panel now streams the + page image alongside its recognised text as each page finishes, instead of a bare progress bar (the raw text is + written to a per-job `ingest_preview.json` file rather than the background-job `result` field, which strips + text-like keys and caps content for data minimization). In split-screen review, every numbered box and its + matching list entry now carries a centred glyph for its kind (title/deck/byline/body/caption/figure/…) and, when + a region on a shared page belongs to a *different* article, a small badge naming that article — previously a + region already claimed elsewhere looked identical to an unclaimed one. When the layout engine misses a region + entirely, a **"Draw it"** toggle lets the user click-drag a box directly on the page image; on release it prompts + for text-or-image and either uses typed text, OCRs the crop through the configured vision engine, or (image) crops + and saves it as a new figure — added via `add_manual_region()` / `POST /api/magazine/{doc_id}/pages/{page}/regions`. - **Drag-and-Drop Dropzone For Adding a Magazine.** The "Add a magazine or paper" form used a plain `` where the Book/Source screen already has a proper drop target; now reuses the exact same `reader-dropzone` component (same CSS, no new styling needed beyond the surrounding grid) — drag a PDF onto the diff --git a/Manual.md b/Manual.md index 49b953d..ef20ae4 100644 --- a/Manual.md +++ b/Manual.md @@ -31,12 +31,26 @@ turned into a podcast discussion between two or three voices you pick. 2. **Ingest.** Ingestion runs in the background — you can close the browser. It renders every page, extracts text (falling back to OCR on scanned pages), classifies each region of the layout, finds the contents page, and proposes - articles with page ranges. + articles with page ranges. While it runs, a live preview shows each page's + image next to its recognised text as soon as that page finishes, so you can + see ingestion actually working page by page instead of watching a bare + progress bar. 3. **Review the articles.** This step is not optional and cannot be skipped. Each proposed article shows its title, author, page range, estimated - listening time and a confidence score. Sidebars and pull-out boxes are - already unticked for you — tick one back in if you actually want it read. - Confirm the boundaries, correct the title, then **Approve**. + listening time and a confidence score. The review screen is split: the + scanned page on the left with a numbered, icon-labelled box per detected + region (title, byline, body text, caption, figure…), and the same regions + as text on the right — click either side to highlight the other. Sidebars + and pull-out boxes are already unticked for you — tick one back in if you + actually want it read. A region already claimed by a different article on + the same page is marked with that article's number, so it is never + mistaken for unclaimed text. If OCR quality is poor, **"OCR again"** + re-reads just the current page through the configured vision engine + (or **"OCR again (whole magazine)"** from the contents screen for every + page, which also resets any articles already reviewed). 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**. 4. **Listen.** An approved article can be narrated with the default voice, or sent to podcast mode. Use **Play all articles** (on the contents screen) to listen through every approved article back to back without returning to diff --git a/core/magazine.py b/core/magazine.py index d6ebe95..1c99c76 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -1179,7 +1179,13 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, "images": page_images, }) if progress: - progress(page_no, doc.page_count) + # Raw block text, pre-classification -- good enough for a + # live "here is what was just read" preview during ingestion + # (mirrors ocr-baidu-unlimited-ocr's own demo: source page on + # one side, recognised text on the other, filled in as each + # page finishes) without waiting for the document-wide passes + # below. + progress(page_no, doc.page_count, blocks) # Furniture can only be identified across the whole document, so # classification waits until every page has been read. @@ -1412,6 +1418,103 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int, return layout +def _crop_page_png(doc_dir: Path, page_no: int, page_meta: dict[str, Any], + bbox: Sequence[float], dest: Path) -> None: + """Crop one region (PDF-point bbox) out of a page's rendered PNG.""" + from PIL import Image + png = doc_dir / "pages" / f"{page_no:04d}.png" + with Image.open(png) as im: + sx, sy = im.width / page_meta["width"], im.height / page_meta["height"] + box_px = ( + max(0, int(bbox[0] * sx)), max(0, int(bbox[1] * sy)), + min(im.width, int((bbox[0] + bbox[2]) * sx)), + min(im.height, int((bbox[1] + bbox[3]) * sy)), + ) + im.crop(box_px).save(dest) + + +def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int, + bbox: Sequence[float], kind: str, text: str = "", + 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) -> dict[str, Any]: + """Add a region the user drew by hand -- for text or a figure OCR/layout + detection missed entirely, not for correcting one already found (use + review's tick/untick or reocr_page for that). + + kind "image" crops the region into its own figure file and adds it to + layout["images"]. kind "text" adds a new Block: if `text` is supplied it + is used verbatim (the user typed it themselves), otherwise the crop is + sent through the vision OCR chain if one is configured -- best-effort, + the block is still added with a placeholder if that returns nothing, + since the user drawing this region at all means they judged the text + important enough to include even without a clean read. + + Deliberately does not re-run classify_blocks on the new region: the user + is drawing it precisely because automatic detection already failed on + it, so re-applying the same heuristics would not be trustworthy. It is + trusted as stated (BLOCK_BODY, narratable) and only given a fresh + reading-order position on its page. + """ + 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 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) + + if kind == "image": + images_dir = doc_dir / "images" + images_dir.mkdir(parents=True, exist_ok=True) + existing = list(images_dir.glob(f"p{page_no:04d}-manual-*.png")) + name = f"p{page_no:04d}-manual-{len(existing):03d}" + _crop_page_png(doc_dir, page_no, page_meta, bbox, images_dir / f"{name}.png") + images = list(layout.get("images", [])) + # Placed after every auto-detected image on the page by reading + # order -- a reasonable default for a figure the pipeline missed; + # 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 + images.append({"id": name, "page": page_no, "bbox": list(bbox), + "filename": f"{name}.png", "reading_order": next_ro}) + layout["images"] = images + return layout + + if kind != "text": + raise ValueError("kind must be 'text' or 'image'") + + final_text = text.strip() + if not final_text and vision_ocr_url and vision_ocr_model: + crop_path = doc_dir / "pages" / f".manual-crop-{page_no}.png" + try: + _crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path) + engines = [(vision_ocr_url, vision_ocr_model), + (vision_ocr_fallback_url, vision_ocr_fallback_model)] + regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout) + if regions: + final_text = " ".join(r["text"] for r in regions).strip() + finally: + crop_path.unlink(missing_ok=True) + if not final_text: + final_text = "(no text recognised -- click to edit)" + + blocks = [Block(**b) for b in layout.get("blocks", [])] + existing_ids = {b.id for b in blocks} + i = 0 + while f"m{page_no:04d}-{i:03d}" in existing_ids: + i += 1 + new_block = Block(id=f"m{page_no:04d}-{i:03d}", page=page_no, bbox=list(bbox), + type=BLOCK_BODY, text=final_text, confidence=0.5, source="manual") + + page_blocks = [b for b in blocks if b.page == page_no] + [new_block] + 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 + + 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 3a462e8..096032d 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -120,11 +120,25 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int, if not pdf.is_file(): raise FileNotFoundError("No source.pdf uploaded for this magazine") - def progress(page: int, total: int) -> None: + def progress(page: int, total: int, blocks: list) -> None: background_job_update( job_id, stage="extract", detail=f"Analysing page {page} of {total}", ) + # A live "here is what was just read" preview (page image + + # recognised text), mirroring ocr-baidu-unlimited-ocr's own demo + # UI. Written to its own small file rather than the generic job + # result: that field strips any key resembling "text"/"content" + # (data-minimisation for other job kinds) and caps at 500 chars, + # both wrong for this. Best-effort -- a write failure must never + # abort ingestion. + try: + preview_text = " ".join(b.text for b in blocks).strip()[:4000] + (d / "ingest_preview.json").write_text( + json.dumps({"page": page, "text": preview_text}, ensure_ascii=False), + "utf-8") + except Exception: + pass background_job_update(job_id, stage="extract", detail="Opening PDF") layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, ocr_lang=ocr_lang, @@ -228,10 +242,17 @@ async def magazine_ingest(doc_id: str, request: Request): @router.get("/api/magazine/{doc_id}/ingest/status") async def magazine_ingest_status(doc_id: str): - _, meta = _require_magazine(doc_id) + d, meta = _require_magazine(doc_id) job_id = (meta.get("ingest") or {}).get("job_id") job = background_job_get(job_id) if job_id else None - return {"ingest": meta.get("ingest") or {"status": "empty"}, "job": job} + preview = None + try: + preview_path = d / "ingest_preview.json" + if preview_path.is_file(): + preview = json.loads(preview_path.read_text("utf-8")) + except (OSError, ValueError): + pass + return {"ingest": meta.get("ingest") or {"status": "empty"}, "job": job, "preview": preview} # ── Layout and articles ────────────────────────────────────────────────────── @@ -430,6 +451,67 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request): return {"job_id": job_id} +def _add_region_sync(doc_id: str, job_id: str, page: int, bbox: list[float], kind: str, + text: str, vision_ocr_url: str | None, vision_ocr_model: str | None, + vision_ocr_fallback_url: str | None, vision_ocr_fallback_model: str | None) -> None: + """Add a hand-drawn region -- restart-safe for the same reason re-OCR is: + a text region with no typed text triggers a vision OCR call.""" + d = _LIB / doc_id + try: + layout = mag.load_layout(d) + if not layout: + raise FileNotFoundError("No layout yet -- run ingestion first") + updated = mag.add_manual_region(d, layout, page, bbox, kind, text, + vision_ocr_url, vision_ocr_model, + vision_ocr_fallback_url, vision_ocr_fallback_model) + mag.save_layout(d, updated) + background_job_update(job_id, stage="done", done=True, detail="Region added", + result={"page": page, "kind": kind}) + except Exception as exc: # noqa: BLE001 - surfaced to UI + background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) + + +@router.post("/api/magazine/{doc_id}/pages/{page}/regions") +async def magazine_add_region(doc_id: str, page: int, request: Request): + """Add a region the user drew by hand -- for text or a figure that + automatic detection missed entirely. Not for correcting a region already + found (tick/untick it in review, or use "OCR again" for a bad read). + """ + d, _ = _require_magazine(doc_id) + if page < 1 or page > 10000: + raise HTTPException(400, "Bad page") + if not (d / "pages" / f"{page:04d}.png").is_file(): + raise HTTPException(404, "Page not rendered -- run ingestion first") + + body = await request.json() + bbox = body.get("bbox") + if 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]") + kind = str(body.get("kind") or "text") + if kind not in ("text", "image"): + raise HTTPException(400, "kind must be 'text' or 'image'") + text = str(body.get("text") or "")[:20000] + + vision_ocr_url = vision_ocr_model = None + vision_ocr_fallback_url = vision_ocr_fallback_model = None + if kind == "text" and not text.strip() and body.get("ocr"): + settings = _load_settings() + vision_ocr_url = str(body.get("vision_ocr_url") or settings.get("vision_ocr_url") or "").strip() or None + vision_ocr_model = str(body.get("vision_ocr_model") or 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 + + job_id = uuid.uuid4().hex + background_job_create(job_id, "magazine-add-region", fingerprint=f"{doc_id}:{page}") + asyncio.create_task( + asyncio.to_thread(_add_region_sync, doc_id, job_id, page, [float(v) for v in bbox], kind, text, + vision_ocr_url, vision_ocr_model, + vision_ocr_fallback_url, vision_ocr_fallback_model) + ) + return {"job_id": job_id} + + @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 63e52eb..8314b05 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -33,6 +33,9 @@ const state = { queueIndex: -1, driving: false, // large-control, minimal-interaction view karaoke: true, // word-level highlighting (approximate, see estimateWordTimings) + drawMode: false, // manually drawing a region OCR/layout missed, in review + reviewSpan: [], + reviewPage: null, }; MAG.magazine = state; @@ -268,7 +271,9 @@ function magParsePageProgress(detail) { async function pollIngest() { const bar = $('mag-ingest-status'); + const preview = $('mag-ingest-preview'); if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; } + let lastPreviewPage = null; for (;;) { let data; try { @@ -299,6 +304,23 @@ async function pollIngest() { '' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected'; } } + // Live "source page / recognised text" preview, side by side -- same + // idea as ocr-baidu-unlimited-ocr's own demo UI. Only swaps the image + // when the page actually changed, so it doesn't flicker on every poll. + if (preview) { + if (ing.status === 'running' && data.preview && data.preview.page) { + preview.hidden = false; + if (data.preview.page !== lastPreviewPage) { + lastPreviewPage = data.preview.page; + const img = $('mag-ingest-preview-img'); + if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page; + } + const textEl = $('mag-ingest-preview-text'); + if (textEl) textEl.textContent = data.preview.text || ''; + } else if (ing.status !== 'running') { + preview.hidden = true; + } + } if (ing.status !== 'running') { if (ing.status === 'done') await magRefreshArticles(); break; @@ -430,6 +452,35 @@ function magReviewGoPage(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 +// table currently reads as its own text region like any other paragraph. +function magBlockIcon(type) { + if (type === 'image') return 'mdi-image-outline'; + if (type === 'furniture' || type === 'ad') return 'mdi-close-box-outline'; + if (type === 'caption') return 'mdi-image-text'; + return 'mdi-text-box-outline'; +} + +// A page a feature spans can share space with a neighbouring piece (the +// tail of the previous article, the start of the next). Blocks belonging to +// the article being reviewed need no extra label -- their checkbox already +// says so -- but a block claimed by a DIFFERENT already-detected article is +// otherwise easy to mistake for stray, unclaimed text. Returns null for the +// current article or for a block no article has claimed. +function magBlockOwner(blockId) { + const current = state.article; + for (let i = 0; i < state.articles.length; i++) { + const art = state.articles[i]; + if (art.id === current.id) continue; + if ((art.blocks || []).includes(blockId) || (art.images || []).includes(blockId)) { + return { index: i + 1, title: art.title }; + } + } + return null; +} + function renderReview() { const host = $('mag-review'); const a = state.article; @@ -439,6 +490,14 @@ function renderReview() { const page = state.reviewPage; const pageMeta = (state.layout.pages || []).find((p) => p.page === page); const pageBlocks = page == null ? [] : blocksForPages([page]); + const pageImages = page == null ? [] : (state.layout.images || []) + .filter((img) => img.page === page).sort((x, y) => (x.reading_order || 0) - (y.reading_order || 0)); + const ownImages = new Set(a.images || []); + // One merged, reading-order-sorted list so numbering on both sides matches + // regardless of whether an item is text or a figure. + const regions = pageBlocks.map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order, 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); const spanIdx = state.reviewSpan.indexOf(page); const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null; @@ -452,7 +511,10 @@ function renderReview() { '' + '

Untick anything that is not part of this article — a sidebar, an advert, ' + 'or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, ' + - 'or its matching entry on the right, to line the two up.

'; + 'or its matching entry on the right, to line the two up. A region labelled with another ' + + 'article belongs to a different piece sharing this page. Can\'t find a region the text is ' + + 'clearly on the page?

'; html += '
' + '
'; - // Right: the same blocks as text, numbered to match. + // Right: the same regions as text/figures, numbered to match. html += '
'; - pageBlocks.forEach((b, i) => { + regions.forEach((r, i) => { + const isImage = r.kind === 'image'; + const owner = magBlockOwner(r.id); + const icon = magBlockIcon(isImage ? 'image' : r.data.type); + if (isImage) { + const on = ownImages.has(r.id); + html += '
' + + '' + (i + 1) + '' + + ' Figure' + + '' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title) + : on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '' + + '
'; + return; + } + const b = r.data; const narratable = ['title', 'deck', 'byline', 'body'].includes(b.type); const on = included.has(b.id); html += ''; + ' ' + esc(b.type) + '' + + '' + + (owner ? 'Article ' + owner.index + ': ' + esc(owner.title) + ' ' : '') + + esc(b.text.slice(0, 220)) + (b.text.length > 220 ? '…' : '') + ''; }); - if (!pageBlocks.length) html += '

Nothing detected on this page.

'; + if (!regions.length) html += '

Nothing detected on this page.

'; html += '
'; html += ''; // /mag-review-split @@ -515,6 +601,7 @@ function renderReview() { '' + ''; host.innerHTML = html; + setupMagDrawing(); } // Tracks every ticked block across ALL reviewed pages of this article, not @@ -544,6 +631,150 @@ function magReviewHighlightBox(blockId) { if (box) box.classList.add('is-linked'); } +// ── Draw a region by hand ──────────────────────────────────────────────── +// Escape hatch for when the OCR/layout pipeline missed a region entirely -- +// not for correcting one it already found (tick/untick or "OCR again" +// cover that). Percent-of-image coordinates while dragging, converted to +// PDF-point page coordinates (matching every other bbox in the layout) only +// once the user commits the region. +function magReviewToggleDraw() { + state.drawMode = !state.drawMode; + const wrap = $('mag-review-page-wrap'); + if (wrap) wrap.classList.toggle('is-drawing', state.drawMode); + const btn = $('mag-draw-toggle'); + if (btn) btn.classList.toggle('active', state.drawMode); + magReviewCancelDrawPanel(); +} + +let _magDrawState = null; // { startXPct, startYPct } while dragging + +let _magDrawMouseupInstalled = false; + +function setupMagDrawing() { + const wrap = $('mag-review-page-wrap'); + if (!wrap) return; + if (state.drawMode) wrap.classList.add('is-drawing'); + // wrap itself is rebuilt fresh by every renderReview() (host.innerHTML + // replaces the whole subtree), so listeners on it never accumulate -- + // only the window-level one below needs the one-time guard, since window + // is never replaced. + wrap.addEventListener('mousedown', (e) => { + if (!state.drawMode || e.target.closest('.mag-draw-panel')) return; + magReviewCancelDrawPanel(); + const rect = wrap.getBoundingClientRect(); + _magDrawState = { + startXPct: (e.clientX - rect.left) / rect.width * 100, + startYPct: (e.clientY - rect.top) / rect.height * 100, + }; + e.preventDefault(); + }); + wrap.addEventListener('mousemove', (e) => { + if (!_magDrawState) return; + const rect = wrap.getBoundingClientRect(); + const xPct = (e.clientX - rect.left) / rect.width * 100; + const yPct = (e.clientY - rect.top) / rect.height * 100; + const box = $('mag-draw-rect'); + if (!box) return; + const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct)); + const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct)); + const w = Math.abs(xPct - _magDrawState.startXPct); + const h = Math.abs(yPct - _magDrawState.startYPct); + box.hidden = false; + box.style.left = left + '%'; box.style.top = top + '%'; + box.style.width = w + '%'; box.style.height = h + '%'; + }); + if (!_magDrawMouseupInstalled) { + _magDrawMouseupInstalled = true; + // $('mag-draw-rect') is looked up fresh on every mouseup, so this one + // listener keeps working correctly across every renderReview() rebuild + // without needing to be re-attached. + window.addEventListener('mouseup', () => { + if (!_magDrawState) return; + const box = $('mag-draw-rect'); + _magDrawState = null; + if (!box || box.hidden) return; + const w = parseFloat(box.style.width), h = parseFloat(box.style.height); + if (w < 1 || h < 1) { box.hidden = true; return; } // too small to be intentional + magReviewOpenDrawPanel(box); + }); + } +} + +function magReviewCancelDrawPanel() { + const box = $('mag-draw-rect'); + if (box) box.hidden = true; + const panel = document.querySelector('.mag-draw-panel'); + if (panel) panel.remove(); +} + +function magReviewOpenDrawPanel(box) { + const wrap = $('mag-review-page-wrap'); + if (!wrap) return; + box.hidden = false; + const panel = document.createElement('div'); + panel.className = 'mag-draw-panel'; + panel.innerHTML = + '
' + + '' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '
'; + wrap.appendChild(panel); + const top = parseFloat(box.style.top), left = parseFloat(box.style.left); + panel.style.top = Math.min(85, top + parseFloat(box.style.height) + 1) + '%'; + panel.style.left = Math.min(60, left) + '%'; + wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => { + r.addEventListener('change', () => { + const ta = $('mag-draw-text'); + if (ta) ta.hidden = r.value === 'image' && r.checked; + }); + }); +} + +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 text = ($('mag-draw-text')?.value || '').trim(); + const bbox = [ + parseFloat(box.style.left) / 100 * pageMeta.width, + parseFloat(box.style.top) / 100 * pageMeta.height, + parseFloat(box.style.width) / 100 * pageMeta.width, + parseFloat(box.style.height) / 100 * pageMeta.height, + ]; + magReviewCancelDrawPanel(); + say('Adding region…', ''); + let jobId; + try { + const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bbox, kind, text, ocr: kind === 'text' && !text }), + }); + jobId = r.job_id; + } catch (e) { + return say('Could not add region: ' + e.message, 'err'); + } + for (;;) { + let job; + try { job = await api('/api/jobs/' + jobId); } catch (_) { break; } + if (job.done) { + if (job.error) say('Could not add region: ' + job.error, 'err'); + else say('Region added', 'ok'); + break; + } + await new Promise((r) => setTimeout(r, 1500)); + } + state.layout = null; + await ensureLayout(); + renderReview(); +} + function selectedBlockIds() { if (_magReviewChecked) return Array.from(_magReviewChecked); return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')) @@ -1188,6 +1419,9 @@ root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewToggleBox = magReviewToggleBox; root.magReocrPage = magReocrPage; +root.magReviewToggleDraw = magReviewToggleDraw; +root.magReviewCancelDrawPanel = magReviewCancelDrawPanel; +root.magReviewSubmitDraw = magReviewSubmitDraw; root.magReocrMagazine = magReocrMagazine; root.magApprove = magApprove; root.magSetStatus = magSetStatus; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 9cfd6e3..02f919c 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -58,6 +58,16 @@ +
diff --git a/static/style.css b/static/style.css index e72c78b..381cc5b 100644 --- a/static/style.css +++ b/static/style.css @@ -4789,6 +4789,21 @@ input[list="lang-codes"] { .mag-ingest-fill { transition: none; } } +/* Live "source page / recognised text" preview during ingestion -- source + page on one side, recognised text filled in on the other as each page + finishes. Dark, console-like treatment on purpose: distinct from the rest + of the page so it clearly reads as a live process, not settled content. */ +.mag-ingest-preview { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; + background: var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 14px; } +.mag-ingest-preview-side { background: #14161a; padding: 10px; display: flex; flex-direction: column; gap: 8px; min-height: 220px; } +.mag-ingest-preview-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; } +.mag-ingest-preview-side img { max-width: 100%; max-height: 340px; object-fit: contain; margin: 0 auto; border-radius: 4px; } +.mag-ingest-preview-text { color: #d7dae0; font-size: 13px; line-height: 1.6; white-space: pre-wrap; + overflow-y: auto; max-height: 340px; font-family: ui-monospace, 'SF Mono', Consolas, monospace; } +@media (max-width: 860px) { + .mag-ingest-preview { grid-template-columns: 1fr; } +} + .mag-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: var(--panel); color: var(--subtext); border: 1px solid var(--border); } .mag-pill.ok { background: rgba(22,163,74,.12); color: var(--green); border-color: transparent; } @@ -4834,6 +4849,36 @@ input[list="lang-codes"] { background: var(--accent); color: var(--on-accent); font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; border-radius: 0 0 4px 0; } .mag-review-box.is-excluded .mag-review-box-num { background: var(--subtext); } +/* Centred glyph identifying the region kind (text/figure/furniture) at a + glance -- see magBlockIcon. Sized against the box, not fixed, so it stays + legible on both a full-page figure and a two-line caption. */ +.mag-review-box-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + font-size: clamp(12px, 40%, 22px); color: var(--accent); opacity: .55; pointer-events: none; } +.mag-review-box.is-excluded .mag-review-box-icon { color: var(--subtext); } +/* A region already claimed by a DIFFERENT article sharing this page -- + easy to mistake for stray unclaimed text without this. */ +.mag-review-box-owner { position: absolute; bottom: -1px; right: -1px; padding: 0 4px; + background: var(--mauve); color: #fff; font-size: 9px; font-weight: 700; + line-height: 15px; border-radius: 4px 0 0 0; } +.mag-review-box.has-owner { border-style: dashed; } + +/* Manual "draw it" escape hatch, for a region the OCR/layout pipeline + missed entirely. Crosshair cursor cues that a click-drag starts a new + box; the box itself is styled distinctly (solid green, dashed outline) + so it never reads as an already-detected region while being drawn. */ +#mag-review-page-wrap.is-drawing { cursor: crosshair; } +#mag-review-page-wrap.is-drawing .mag-review-box:not(.mag-draw-rect) { pointer-events: none; } +.mag-draw-rect { border: 2px dashed var(--green); background: rgba(22,163,74,.14); cursor: crosshair; + transition: none; z-index: 3; } +#mag-draw-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } +.mag-draw-panel { position: absolute; z-index: 4; display: flex; flex-direction: column; gap: 6px; + padding: 8px; min-width: 220px; background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,.25); } +.mag-draw-panel-row { display: flex; gap: 10px; align-items: center; justify-content: space-between; } +.mag-draw-panel-row label { display: flex; align-items: center; gap: 4px; font-size: 12px; } +.mag-draw-panel textarea { width: 100%; resize: vertical; font: inherit; font-size: 12px; + padding: 4px 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); + color: var(--text); } .mag-block { display: grid; grid-template-columns: auto auto 90px 1fr; gap: 10px; align-items: start; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); @@ -4841,7 +4886,12 @@ input[list="lang-codes"] { .mag-block.is-aux { opacity: .68; } .mag-block:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.14); } .mag-block-num { font-size: 11px; font-weight: 700; color: var(--accent); min-width: 1.4em; } -.mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); } +.mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); + display: flex; align-items: center; gap: 3px; } +.mag-block-type .mdi { font-size: 13px; } +.mag-block-owner { display: inline-block; padding: 1px 6px; margin-right: 6px; border-radius: 999px; + background: var(--mauve); color: #fff; font-size: 10px; font-weight: 700; } +.mag-block-image { cursor: default; grid-template-columns: auto 90px 1fr; } .mag-block-text { font-size: 14px; line-height: 1.45; } .mag-review-foot { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; position: sticky; bottom: 0; padding: 12px 0; background: var(--bg); } diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 157203a..0cf580f 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -39,6 +39,7 @@ from core.magazine import ( build_vision_blocks, classify_blocks, reocr_page, + add_manual_region, detect_columns, estimate_font_size_from_area, estimate_listen_seconds, @@ -1154,5 +1155,112 @@ class ReocrPageTests(unittest.TestCase): self.assertEqual(engines_arg, [("http://primary", "m1"), ("http://fallback", "m2")]) +class ManualRegionTests(unittest.TestCase): + """Drawing a region by hand is the escape hatch for when OCR/layout + detection misses something entirely -- the user has already judged + automatic detection unreliable here, so this must never silently + re-apply the same heuristics or discard what they drew.""" + + def setUp(self): + from PIL import Image + self.tmp = tempfile.mkdtemp(prefix="ttvc-manualregion-test-") + self.doc_dir = pathlib.Path(self.tmp) + (self.doc_dir / "pages").mkdir() + Image.new("RGB", (595, 842), "white").save(self.doc_dir / "pages" / "0003.png") + self.layout = { + "page_count": 10, + "pages": [{"page": 3, "width": 595.0, "height": 842.0}], + "blocks": [ + {"id": "b0003-000", "page": 3, "bbox": [50, 60, 300, 40], + "type": BLOCK_TITLE, "text": "Existing title", "reading_order": 0, + "confidence": 0.9, "font_size": 20.0, "font_name": "", "bold": True, + "column": 0, "source": "native"}, + ], + "images": [], + } + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_unknown_page_raises(self): + with self.assertRaises(ValueError): + add_manual_region(self.doc_dir, self.layout, 99, [10, 10, 50, 50], "text") + + def test_degenerate_bbox_raises(self): + with self.assertRaises(ValueError): + add_manual_region(self.doc_dir, self.layout, 3, [10, 10, 0, 0], "text") + + def test_bad_kind_raises(self): + with self.assertRaises(ValueError): + add_manual_region(self.doc_dir, self.layout, 3, [10, 10, 50, 50], "table") + + def test_manual_text_with_typed_content_is_used_verbatim(self): + out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], + "text", text="Hand-typed caption the OCR missed.") + new = [b for b in out["blocks"] if b["source"] == "manual"] + self.assertEqual(len(new), 1) + self.assertEqual(new[0]["text"], "Hand-typed caption the OCR missed.") + self.assertEqual(new[0]["type"], BLOCK_BODY) + + def test_manual_text_is_never_reclassified_by_heuristics(self): + """A short, large region drawn by hand would classify as a title + under classify_blocks' ratio rule -- must stay BLOCK_BODY regardless, + since the whole point is the user already knows what it is.""" + out = add_manual_region(self.doc_dir, self.layout, 3, [50, 300, 500, 100], + "text", text="X") + new = [b for b in out["blocks"] if b["source"] == "manual"][0] + self.assertEqual(new["type"], BLOCK_BODY) + + def test_manual_text_with_no_content_and_no_engine_gets_a_placeholder(self): + out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], "text") + new = [b for b in out["blocks"] if b["source"] == "manual"][0] + self.assertIn("no text recognised", new["text"]) + + def test_manual_text_triggers_ocr_when_no_text_supplied(self): + with mock.patch("core.magazine.vision_ocr_page_chain") as mocked: + mocked.return_value = [{"label": "text", "bbox": [0, 0, 10, 10], "text": "Recognised from the crop."}] + out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], + "text", vision_ocr_url="http://e", vision_ocr_model="m") + new = [b for b in out["blocks"] if b["source"] == "manual"][0] + self.assertEqual(new["text"], "Recognised from the crop.") + self.assertEqual(mocked.call_count, 1) + + def test_existing_blocks_on_the_page_are_preserved(self): + out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], + "text", text="New region.") + ids = {b["id"] for b in out["blocks"]} + self.assertIn("b0003-000", ids) + + def test_blocks_on_other_pages_are_untouched(self): + layout = dict(self.layout) + layout["blocks"] = layout["blocks"] + [ + {"id": "b0007-000", "page": 7, "bbox": [10, 10, 100, 20], "type": BLOCK_BODY, + "text": "Other page.", "reading_order": 0, "confidence": 0.8, "font_size": 10.0, + "font_name": "", "bold": False, "column": 0, "source": "native"}, + ] + out = add_manual_region(self.doc_dir, layout, 3, [50, 200, 200, 40], "text", text="New.") + other = [b for b in out["blocks"] if b["page"] == 7][0] + self.assertEqual(other["text"], "Other page.") + + def test_manual_image_crops_and_saves_a_new_figure(self): + out = add_manual_region(self.doc_dir, self.layout, 3, [100, 400, 150, 100], "image") + self.assertEqual(len(out["images"]), 1) + img = out["images"][0] + self.assertEqual(img["page"], 3) + self.assertEqual(img["bbox"], [100, 400, 150, 100]) + self.assertTrue((self.doc_dir / "images" / img["filename"]).is_file()) + + def test_manual_image_does_not_touch_blocks(self): + out = add_manual_region(self.doc_dir, self.layout, 3, [100, 400, 150, 100], "image") + self.assertEqual(len(out["blocks"]), len(self.layout["blocks"])) + + def test_two_manual_images_get_distinct_filenames(self): + out1 = add_manual_region(self.doc_dir, self.layout, 3, [100, 400, 150, 100], "image") + out2 = add_manual_region(self.doc_dir, out1, 3, [300, 400, 150, 100], "image") + self.assertEqual(len(out2["images"]), 2) + names = {i["filename"] for i in out2["images"]} + self.assertEqual(len(names), 2) + + if __name__ == "__main__": unittest.main()