feat: add live ingest preview, region icons/ownership badges, manual region drawing to magazine review
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-07 18:52:21 +02:00
parent 5adba6b4c0
commit 85080f4b82
8 changed files with 641 additions and 29 deletions

View File

@ -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. outside the zone can no longer trigger the browser's default action either.
### Added ### 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 `<input - **Drag-and-Drop Dropzone For Adding a Magazine.** The "Add a magazine or paper" form used a plain `<input
type="file">` where the Book/Source screen already has a proper drop target; now reuses the exact same type="file">` 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 `reader-dropzone` component (same CSS, no new styling needed beyond the surrounding grid) — drag a PDF onto the

View File

@ -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 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), renders every page, extracts text (falling back to OCR on scanned pages),
classifies each region of the layout, finds the contents page, and proposes 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. 3. **Review the articles.** This step is not optional and cannot be skipped.
Each proposed article shows its title, author, page range, estimated Each proposed article shows its title, author, page range, estimated
listening time and a confidence score. Sidebars and pull-out boxes are listening time and a confidence score. The review screen is split: the
already unticked for you — tick one back in if you actually want it read. scanned page on the left with a numbered, icon-labelled box per detected
Confirm the boundaries, correct the title, then **Approve**. 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 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 sent to podcast mode. Use **Play all articles** (on the contents screen) to
listen through every approved article back to back without returning to listen through every approved article back to back without returning to

View File

@ -1179,7 +1179,13 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
"images": page_images, "images": page_images,
}) })
if progress: 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 # Furniture can only be identified across the whole document, so
# classification waits until every page has been read. # 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 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: def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None:
(doc_dir / "layout.json").write_text( (doc_dir / "layout.json").write_text(
json.dumps(layout, ensure_ascii=False), "utf-8") json.dumps(layout, ensure_ascii=False), "utf-8")

View File

@ -120,11 +120,25 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
if not pdf.is_file(): if not pdf.is_file():
raise FileNotFoundError("No source.pdf uploaded for this magazine") 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( background_job_update(
job_id, stage="extract", job_id, stage="extract",
detail=f"Analysing page {page} of {total}", 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") background_job_update(job_id, stage="extract", detail="Opening PDF")
layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, ocr_lang=ocr_lang, 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") @router.get("/api/magazine/{doc_id}/ingest/status")
async def magazine_ingest_status(doc_id: str): 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_id = (meta.get("ingest") or {}).get("job_id")
job = background_job_get(job_id) if job_id else None 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 ────────────────────────────────────────────────────── # ── Layout and articles ──────────────────────────────────────────────────────
@ -430,6 +451,67 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request):
return {"job_id": job_id} 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}") @router.get("/api/magazine/{doc_id}/images/{name}")
async def magazine_image(doc_id: str, name: str): async def magazine_image(doc_id: str, name: str):
d, _ = _require_magazine(doc_id) d, _ = _require_magazine(doc_id)

View File

@ -33,6 +33,9 @@ const state = {
queueIndex: -1, queueIndex: -1,
driving: false, // large-control, minimal-interaction view driving: false, // large-control, minimal-interaction view
karaoke: true, // word-level highlighting (approximate, see estimateWordTimings) 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; MAG.magazine = state;
@ -268,7 +271,9 @@ function magParsePageProgress(detail) {
async function pollIngest() { async function pollIngest() {
const bar = $('mag-ingest-status'); const bar = $('mag-ingest-status');
const preview = $('mag-ingest-preview');
if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; } if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
let lastPreviewPage = null;
for (;;) { for (;;) {
let data; let data;
try { try {
@ -299,6 +304,23 @@ async function pollIngest() {
'<span>' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected</span></div>'; '<span>' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected</span></div>';
} }
} }
// 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 !== 'running') {
if (ing.status === 'done') await magRefreshArticles(); if (ing.status === 'done') await magRefreshArticles();
break; break;
@ -430,6 +452,35 @@ function magReviewGoPage(page) {
renderReview(); 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() { function renderReview() {
const host = $('mag-review'); const host = $('mag-review');
const a = state.article; const a = state.article;
@ -439,6 +490,14 @@ function renderReview() {
const page = state.reviewPage; const page = state.reviewPage;
const pageMeta = (state.layout.pages || []).find((p) => p.page === page); const pageMeta = (state.layout.pages || []).find((p) => p.page === page);
const pageBlocks = page == null ? [] : blocksForPages([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 spanIdx = state.reviewSpan.indexOf(page);
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null; const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
@ -452,7 +511,10 @@ function renderReview() {
'</div>' + '</div>' +
'<p class="muted">Untick anything that is not part of this article — a sidebar, an advert, ' + '<p class="muted">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 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.</p>'; '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? <button type="button" class="btn-sm" onclick="magReviewToggleDraw()" ' +
'id="mag-draw-toggle"><span class="mdi mdi-vector-square-edit"></span> Draw it</button></p>';
html += '<div class="mag-review-pagenav">' + html += '<div class="mag-review-pagenav">' +
'<button type="button" class="btn-sm" ' + (prevPage ? '' : 'disabled') + '<button type="button" class="btn-sm" ' + (prevPage ? '' : 'disabled') +
@ -468,22 +530,30 @@ function renderReview() {
html += '<div class="mag-review-split">'; html += '<div class="mag-review-split">';
// Left: the rendered page with a numbered box per block. // Left: the rendered page with a numbered box per region.
html += '<div class="mag-review-page">'; html += '<div class="mag-review-page">';
if (page != null) { if (page != null) {
html += '<div class="mag-review-page-wrap">' + html += '<div class="mag-review-page-wrap" id="mag-review-page-wrap">' +
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">'; '<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
pageBlocks.forEach((b, i) => { '<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>';
regions.forEach((r, i) => {
if (!pageMeta) return; if (!pageMeta) return;
const on = included.has(b.id); const bbox = r.data.bbox;
const left = (b.bbox[0] / pageMeta.width * 100).toFixed(2); const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
const top = (b.bbox[1] / pageMeta.height * 100).toFixed(2); const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
const w = (b.bbox[2] / pageMeta.width * 100).toFixed(2); const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
const h = (b.bbox[3] / pageMeta.height * 100).toFixed(2); const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
html += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + '" data-block="' + esc(b.id) + const isImage = r.kind === 'image';
'" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h + '%" ' + const on = isImage ? ownImages.has(r.id) : included.has(r.id);
'onclick="magReviewFocusBlock(\'' + esc(b.id) + '\')" title="' + esc(b.type) + '">' + const owner = magBlockOwner(r.id);
'<span class="mag-review-box-num">' + (i + 1) + '</span></div>'; const icon = magBlockIcon(isImage ? 'image' : r.data.type);
html += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + (owner ? ' has-owner' : '') +
'" data-block="' + esc(r.id) + '" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h +
'%" onclick="magReviewFocusBlock(\'' + esc(r.id) + '\')" title="' + esc(isImage ? 'figure' : r.data.type) + '">' +
'<span class="mag-review-box-num">' + (i + 1) + '</span>' +
'<span class="mag-review-box-icon mdi ' + icon + '"></span>' +
(owner ? '<span class="mag-review-box-owner">Art. ' + owner.index + '</span>' : '') +
'</div>';
}); });
html += '</div>'; html += '</div>';
} else { } else {
@ -491,20 +561,36 @@ function renderReview() {
} }
html += '</div>'; html += '</div>';
// Right: the same blocks as text, numbered to match. // Right: the same regions as text/figures, numbered to match.
html += '<div class="mag-blocks">'; html += '<div class="mag-blocks">';
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 += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> Figure</span>' +
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title)
: on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '</span>' +
'</div>';
return;
}
const b = r.data;
const narratable = ['title', 'deck', 'byline', 'body'].includes(b.type); const narratable = ['title', 'deck', 'byline', 'body'].includes(b.type);
const on = included.has(b.id); const on = included.has(b.id);
html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' + html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' + 'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' + '<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' + '<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type">' + esc(b.type) + '</span>' + '<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.type) + '</span>' +
'<span class="mag-block-text">' + esc(b.text.slice(0, 220)) + '<span class="mag-block-text">' +
(b.text.length > 220 ? '…' : '') + '</span></label>'; (owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
esc(b.text.slice(0, 220)) + (b.text.length > 220 ? '…' : '') + '</span></label>';
}); });
if (!pageBlocks.length) html += '<p class="muted">Nothing detected on this page.</p>'; if (!regions.length) html += '<p class="muted">Nothing detected on this page.</p>';
html += '</div>'; html += '</div>';
html += '</div>'; // /mag-review-split html += '</div>'; // /mag-review-split
@ -515,6 +601,7 @@ function renderReview() {
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' + '<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
'</div>'; '</div>';
host.innerHTML = html; host.innerHTML = html;
setupMagDrawing();
} }
// Tracks every ticked block across ALL reviewed pages of this article, not // 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'); 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 =
'<div class="mag-draw-panel-row">' +
'<label><input type="radio" name="mag-draw-kind" value="text" checked> Text</label>' +
'<label><input type="radio" name="mag-draw-kind" value="image"> Image</label>' +
'</div>' +
'<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
'<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-plus"></span> Add region</button>' +
'</div>';
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() { function selectedBlockIds() {
if (_magReviewChecked) return Array.from(_magReviewChecked); if (_magReviewChecked) return Array.from(_magReviewChecked);
return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')) return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked'))
@ -1188,6 +1419,9 @@ root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox; root.magReviewToggleBox = magReviewToggleBox;
root.magReocrPage = magReocrPage; root.magReocrPage = magReocrPage;
root.magReviewToggleDraw = magReviewToggleDraw;
root.magReviewCancelDrawPanel = magReviewCancelDrawPanel;
root.magReviewSubmitDraw = magReviewSubmitDraw;
root.magReocrMagazine = magReocrMagazine; root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove; root.magApprove = magApprove;
root.magSetStatus = magSetStatus; root.magSetStatus = magSetStatus;

View File

@ -58,6 +58,16 @@
</div> </div>
</div> </div>
<div id="mag-ingest-status" class="mag-status" hidden></div> <div id="mag-ingest-status" class="mag-status" hidden></div>
<div id="mag-ingest-preview" class="mag-ingest-preview" hidden>
<div class="mag-ingest-preview-side">
<span class="mag-ingest-preview-label">Page</span>
<img id="mag-ingest-preview-img" alt="Page currently being read">
</div>
<div class="mag-ingest-preview-side">
<span class="mag-ingest-preview-label">Recognised text</span>
<div id="mag-ingest-preview-text" class="mag-ingest-preview-text"></div>
</div>
</div>
<div id="mag-contents"></div> <div id="mag-contents"></div>
</div> </div>

View File

@ -4789,6 +4789,21 @@ input[list="lang-codes"] {
.mag-ingest-fill { transition: none; } .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; .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); } 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; } .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; 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; } line-height: 16px; text-align: center; border-radius: 0 0 4px 0; }
.mag-review-box.is-excluded .mag-review-box-num { background: var(--subtext); } .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; .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); 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.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: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-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-block-text { font-size: 14px; line-height: 1.45; }
.mag-review-foot { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; .mag-review-foot { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px;
position: sticky; bottom: 0; padding: 12px 0; background: var(--bg); } position: sticky; bottom: 0; padding: 12px 0; background: var(--bg); }

View File

@ -39,6 +39,7 @@ from core.magazine import (
build_vision_blocks, build_vision_blocks,
classify_blocks, classify_blocks,
reocr_page, reocr_page,
add_manual_region,
detect_columns, detect_columns,
estimate_font_size_from_area, estimate_font_size_from_area,
estimate_listen_seconds, estimate_listen_seconds,
@ -1154,5 +1155,112 @@ class ReocrPageTests(unittest.TestCase):
self.assertEqual(engines_arg, [("http://primary", "m1"), ("http://fallback", "m2")]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()