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
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:
parent
5adba6b4c0
commit
85080f4b82
11
CHANGELOG.md
11
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 `<input
|
||||
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
|
||||
|
||||
22
Manual.md
22
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
|
||||
|
||||
105
core/magazine.py
105
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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
'<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 === '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() {
|
||||
'</div>' +
|
||||
'<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 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">' +
|
||||
'<button type="button" class="btn-sm" ' + (prevPage ? '' : 'disabled') +
|
||||
@ -468,22 +530,30 @@ function renderReview() {
|
||||
|
||||
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">';
|
||||
if (page != null) {
|
||||
html += '<div class="mag-review-page-wrap">' +
|
||||
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">';
|
||||
pageBlocks.forEach((b, i) => {
|
||||
html += '<div class="mag-review-page-wrap" id="mag-review-page-wrap">' +
|
||||
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
|
||||
'<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>';
|
||||
regions.forEach((r, i) => {
|
||||
if (!pageMeta) return;
|
||||
const on = included.has(b.id);
|
||||
const left = (b.bbox[0] / pageMeta.width * 100).toFixed(2);
|
||||
const top = (b.bbox[1] / pageMeta.height * 100).toFixed(2);
|
||||
const w = (b.bbox[2] / pageMeta.width * 100).toFixed(2);
|
||||
const h = (b.bbox[3] / pageMeta.height * 100).toFixed(2);
|
||||
html += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + '" data-block="' + esc(b.id) +
|
||||
'" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h + '%" ' +
|
||||
'onclick="magReviewFocusBlock(\'' + esc(b.id) + '\')" title="' + esc(b.type) + '">' +
|
||||
'<span class="mag-review-box-num">' + (i + 1) + '</span></div>';
|
||||
const bbox = r.data.bbox;
|
||||
const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
|
||||
const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
|
||||
const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
|
||||
const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
|
||||
const isImage = r.kind === 'image';
|
||||
const on = isImage ? ownImages.has(r.id) : included.has(r.id);
|
||||
const owner = magBlockOwner(r.id);
|
||||
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>';
|
||||
} else {
|
||||
@ -491,20 +561,36 @@ function renderReview() {
|
||||
}
|
||||
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">';
|
||||
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 on = included.has(b.id);
|
||||
html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
|
||||
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||||
'<span class="mag-block-num">' + (i + 1) + '</span>' +
|
||||
'<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-text">' + esc(b.text.slice(0, 220)) +
|
||||
(b.text.length > 220 ? '…' : '') + '</span></label>';
|
||||
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.type) + '</span>' +
|
||||
'<span class="mag-block-text">' +
|
||||
(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>'; // /mag-review-split
|
||||
@ -515,6 +601,7 @@ function renderReview() {
|
||||
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
|
||||
'</div>';
|
||||
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 =
|
||||
'<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() {
|
||||
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;
|
||||
|
||||
@ -58,6 +58,16 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user