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