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.
|
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
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
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,
|
"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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user