Release v1.26.11: Enhanced layout-aware OCR element recognition, grounding parsing, and preview thumbnails
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-10 13:08:53 +02:00
parent 29dc913ed1
commit f5f887bb1d
7 changed files with 194 additions and 122 deletions

View File

@ -5,7 +5,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [Unreleased] ## [1.26.11] — 2026-09-10
### Added
- **Comprehensive OCR Element & Grounding Recognition.** Full support for layout-aware vision OCR models (Baidu Unlimited-OCR, DeepSeek-OCR, DeepSeek-OCR-2, PaddleOCR).
- Explicit element parsing for Headlines, Paragraphs, Sidenotes/Sidebars, Page Numbers/Folios, Images/Figures, Graphics/Charts/Maps/Treemaps/Diagrams, Tables, and Code blocks.
- Native support for grounding coordinate tokens (`<|ref|>...<|/ref|>`, `<|det|>...<|/det|>`, single/double brackets, normalized `0..1000`/`0..1` scale, and pixel space).
- **Ingestion Preview & Visual Thumbnail Cards.** The live ingestion preview formats non-text visual regions (`[CHART]`, `[MAP]`, `[TABLE]`, `[SIDENOTE]`, `[CODE]`) with clean badges instead of blank gaps, and the magazine review sidebar renders interactive cropped image thumbnails (`mag-block-thumb`) for detected visual figures.
--- ---

View File

@ -1 +1 @@
1.26.10 1.26.11

View File

@ -738,75 +738,124 @@ def _require_fitz():
# engine invents) is treated as ambiguous body copy and still goes through # engine invents) is treated as ambiguous body copy and still goes through
# classify_blocks() for finer distinctions (byline, deck, ...). # classify_blocks() for finer distinctions (byline, deck, ...).
_VISION_LABEL_TYPES = { _VISION_LABEL_TYPES = {
"footer": BLOCK_FURNITURE, # Headlines / Titles / Headers
"header": BLOCK_FURNITURE, "title": BLOCK_TITLE,
"headline": BLOCK_TITLE,
"article_headline": BLOCK_TITLE,
"main_title": BLOCK_TITLE,
"heading": BLOCK_HEADING,
"header": BLOCK_HEADING,
"section_heading": BLOCK_HEADING,
"subheading": BLOCK_HEADING,
"subtitle": BLOCK_HEADING,
"deck": BLOCK_HEADING,
# Paragraphs / Text / Body
"paragraph": BLOCK_BODY,
"body": BLOCK_BODY,
"content": BLOCK_BODY,
"list": BLOCK_BODY,
"list_item": BLOCK_BODY,
"author": BLOCK_BYLINE,
"byline": BLOCK_BYLINE,
# Sidenotes / Sidebars / Quotes / Footnotes
"sidenote": BLOCK_SIDEBAR,
"sidebar": BLOCK_SIDEBAR,
"aside_text": BLOCK_SIDEBAR,
"aside": BLOCK_SIDEBAR,
"footnote": BLOCK_SIDEBAR,
"footnotes": BLOCK_SIDEBAR,
"note": BLOCK_SIDEBAR,
"commentary": BLOCK_SIDEBAR,
"quote": BLOCK_PULLQUOTE,
"pullquote": BLOCK_PULLQUOTE,
# Page numbers / Furniture / Folios / Sources
"page_number": BLOCK_FURNITURE, "page_number": BLOCK_FURNITURE,
"pagenumber": BLOCK_FURNITURE,
"page": BLOCK_FURNITURE,
"header_text": BLOCK_FURNITURE,
"footer": BLOCK_FURNITURE,
"running_header": BLOCK_FURNITURE,
"running_footer": BLOCK_FURNITURE,
"folio": BLOCK_FURNITURE,
"head": BLOCK_FURNITURE,
"source": BLOCK_FURNITURE, "source": BLOCK_FURNITURE,
"sources": BLOCK_FURNITURE, "sources": BLOCK_FURNITURE,
"citation": BLOCK_FURNITURE, "citation": BLOCK_FURNITURE,
"citations": BLOCK_FURNITURE, "citations": BLOCK_FURNITURE,
"footnote": BLOCK_FURNITURE,
"footnotes": BLOCK_FURNITURE, # Images / Figures / Photos
"title": BLOCK_TITLE,
"heading": BLOCK_HEADING,
"caption": BLOCK_CAPTION,
"image_caption": BLOCK_CAPTION,
"table_caption": BLOCK_CAPTION,
"headline": BLOCK_TITLE,
"article_headline": BLOCK_TITLE,
"paragraph": BLOCK_BODY,
"section_heading": BLOCK_HEADING,
"author": BLOCK_BYLINE,
"byline": BLOCK_BYLINE,
"sidenote": BLOCK_SIDEBAR,
"quote": BLOCK_PULLQUOTE,
"pullquote": BLOCK_PULLQUOTE,
"image": BLOCK_FIGURE, "image": BLOCK_FIGURE,
"figure": BLOCK_FIGURE, "figure": BLOCK_FIGURE,
"picture": BLOCK_FIGURE, "picture": BLOCK_FIGURE,
"photo": BLOCK_FIGURE,
"illustration": BLOCK_FIGURE,
"non_text": BLOCK_FIGURE,
"nontext": BLOCK_FIGURE,
"drawing": BLOCK_FIGURE,
# Graphics / Charts / Diagrams / Maps / Treemaps
"chart": BLOCK_CHART, "chart": BLOCK_CHART,
"graphic": BLOCK_CHART, "graphic": BLOCK_CHART,
"infographic": BLOCK_CHART, "infographic": BLOCK_CHART,
"statistics": BLOCK_CHART, "statistics": BLOCK_CHART,
"diagram": BLOCK_CHART, "diagram": BLOCK_CHART,
"map": BLOCK_CHART,
"treemap": BLOCK_CHART,
"plot": BLOCK_CHART,
"flowchart": BLOCK_CHART,
# Tables
"table": BLOCK_TABLE, "table": BLOCK_TABLE,
"formula": BLOCK_CODE, "grid": BLOCK_TABLE,
"list": BLOCK_BODY, "matrix": BLOCK_TABLE,
# Code / Formulas / Math
"code": BLOCK_CODE, "code": BLOCK_CODE,
"code_block": BLOCK_CODE, "code_block": BLOCK_CODE,
"formula": BLOCK_CODE,
"equation": BLOCK_CODE,
"math": BLOCK_CODE,
# Captions
"caption": BLOCK_CAPTION,
"image_caption": BLOCK_CAPTION,
"figure_caption": BLOCK_CAPTION,
"table_caption": BLOCK_CAPTION,
# Ads
"advertisement": BLOCK_AD, "advertisement": BLOCK_AD,
"advert": BLOCK_AD, "advert": BLOCK_AD,
"ad": BLOCK_AD, "ad": BLOCK_AD,
"aside_text": BLOCK_SIDEBAR,
} }
# One label + its four-number bbox, anchoring a region; content runs until # One label + its four-number bbox, anchoring a region; content runs until
# the next such marker. Confirmed live against Baidu Unlimited-OCR's actual # the next such marker. Tolerates DeepSeek-OCR / Unlimited-OCR / PaddleOCR shapes
# output shape: "text [241, 90, 588, 249]Battery technology is entering...". # like "text [241, 90, 588, 249]", "<|ref|>title<|/ref|><|det|>[[x0, y0, x1, y1]]<|/det|>", etc.
_VISION_REGION = re.compile(r"(?m)^(\w+)\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]") _VISION_REGION = re.compile(
# Native Baidu Unlimited-OCR document-parsing output. The model card emits r"(?m)"
# one marker per detected region, such as r"(?:<\|ref\|>\s*(?P<ref>[^<]+?)\s*<\|/ref\|>\s*)?"
# ``<|det|>table [0,0,999,999]<|/det|>...``. Its categories are precisely r"(?:<\|det\|>\s*)?"
# what we need to retain charts/tables/images as visual blocks instead of r"(?P<label>[a-zA-Z0-9_\- ]+?)\s*"
# treating their axis labels as article prose. r"\[\[?\s*(?P<x0>-?\d+(?:\.\d+)?)\s*,\s*(?P<y0>-?\d+(?:\.\d+)?)\s*,\s*(?P<x1>-?\d+(?:\.\d+)?)\s*,\s*(?P<y1>-?\d+(?:\.\d+)?)\s*\]\]?"
r"(?:\s*<\|/det\|>)?"
r"\s*:?"
)
_UNLIMITED_OCR_REGION = re.compile( _UNLIMITED_OCR_REGION = re.compile(
r"(?m)^<\|det\|>\s*(.*?)\s*(?:\[\s*(-?\d+(?:\.\d+)?)\s*,\s*" r"(?m)"
r"(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*" r"(?:<\|ref\|>\s*(?P<ref>[^<]+?)\s*<\|/ref\|>\s*)?"
r"(-?\d+(?:\.\d+)?)\s*\])?\s*<\|/det\|>") r"<\|det\|>\s*"
r"(?P<label>[a-zA-Z0-9_\- ]+?)\s*"
r"(?:\[\[?\s*(?P<x0>-?\d+(?:\.\d+)?)\s*,\s*(?P<y0>-?\d+(?:\.\d+)?)\s*,\s*(?P<x1>-?\d+(?:\.\d+)?)\s*,\s*(?P<y1>-?\d+(?:\.\d+)?)\s*\]\]?)?"
r"\s*<\|/det\|>"
)
def ocr_quality_score(text: str) -> float: def ocr_quality_score(text: str) -> float:
"""Rough plausibility that OCR/extracted text is real prose, 0..1. """Rough plausibility that OCR/extracted text is real prose, 0..1."""
Coarse by design: it catches text that is mostly symbol noise (confirmed
live on tesseract line-height artifacts -- ". |", "@)X" scored 0.0/0.33)
well enough to gate an expensive vision-OCR escalation, but it cannot and
is not meant to catch a plausible-looking character substitution inside
an otherwise normal sentence (confirmed live: "K | soll Arbeit abnehmen"
-- a single misread "KI" -- still scored 0.81, since 7 of its 8 words are
genuine German). Catching that class of error needs a dictionary or
language-model check, which is out of scope for a cheap heuristic.
"""
text = (text or "").strip() text = (text or "").strip()
if not text: if not text:
return 0.0 return 0.0
@ -827,17 +876,7 @@ def ocr_quality_score(text: str) -> float:
def estimate_font_size_from_area(width: float, height: float, text_length: int, def estimate_font_size_from_area(width: float, height: float, text_length: int,
char_width_ratio: float = 0.5, char_width_ratio: float = 0.5,
line_height_ratio: float = 1.2) -> float: line_height_ratio: float = 1.2) -> float:
"""Estimate a representative font size for a paragraph-level box with no """Estimate a representative font size for a paragraph-level box."""
per-line data (a vision OCR engine gives one box per paragraph, not per
line, unlike tesseract -- see ocr_page()).
Derived from the standard area relationship for a wrapped paragraph:
box_height ~= (text_length / (box_width / (char_width_ratio*size))) *
(line_height_ratio*size), which solves to size ~= sqrt(area / (text_length
* char_width_ratio * line_height_ratio)). Using total bbox height directly
(as if it were one line) would scale with paragraph length instead of
type size -- the same bug already fixed for tesseract's line grouping.
"""
if width <= 0 or height <= 0 or text_length <= 0: if width <= 0 or height <= 0 or text_length <= 0:
return 0.0 return 0.0
denom = text_length * char_width_ratio * line_height_ratio denom = text_length * char_width_ratio * line_height_ratio
@ -845,20 +884,17 @@ def estimate_font_size_from_area(width: float, height: float, text_length: int,
def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]: def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]:
"""Parse a vision OCR engine's labeled-region transcription. """Parse a vision OCR engine's labeled-region transcription."""
Tolerates a preamble the model sometimes prepends before the first region
(confirmed live: instruction-echoing junk, and the literal decoy phrase
"[No text detected]" ahead of real output) by only ever anchoring on the
"label [x0, y0, x1, y1]" marker -- anything before the first match, or
between calls with zero matches, is discarded rather than misread as
content.
"""
matches = list(_VISION_REGION.finditer(raw or "")) matches = list(_VISION_REGION.finditer(raw or ""))
regions = [] regions = []
for i, m in enumerate(matches): for i, m in enumerate(matches):
label = m.group(1).lower() gd = m.groupdict()
x0, y0, x1, y1 = (int(m.group(g)) for g in (2, 3, 4, 5)) raw_label = (gd.get("ref") or gd.get("label") or "text").strip().strip("[]")
label = raw_label.lower().replace("-", "_").replace(" ", "_")
try:
x0, y0, x1, y1 = (float(gd[k]) for k in ("x0", "y0", "x1", "y1"))
except (ValueError, TypeError):
continue
start = m.end() start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "") end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "")
text = (raw or "")[start:end].strip() text = (raw or "")[start:end].strip()
@ -866,37 +902,33 @@ def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]:
continue continue
regions.append({ regions.append({
"label": label, "label": label,
"bbox": [float(x0), float(y0), float(x1 - x0), float(y1 - y0)], "bbox": [x0, y0, x1 - x0, y1 - y0],
"text": text, "text": text,
}) })
return regions return regions
def parse_unlimited_ocr_regions(raw: str) -> list[dict[str, Any]]: def parse_unlimited_ocr_regions(raw: str) -> list[dict[str, Any]]:
"""Parse Unlimited-OCR's native ``<|det|>`` document-layout markers. """Parse Unlimited-OCR's native ``<|det|>`` document-layout markers."""
Bounding boxes are in its 0–999-ish image coordinate space; callers mark
them for conversion to the rendered page's pixels before building blocks.
A `[Non-Text]` region is a real visual object even when it has no text.
"""
matches = list(_UNLIMITED_OCR_REGION.finditer(raw or "")) matches = list(_UNLIMITED_OCR_REGION.finditer(raw or ""))
regions = [] regions = []
for i, match in enumerate(matches): for i, match in enumerate(matches):
raw_label = (match.group(1) or "").strip().strip("[]") gd = match.groupdict()
raw_label = (gd.get("ref") or gd.get("label") or "").strip().strip("[]")
label = raw_label.lower().replace("-", "_").replace(" ", "_") label = raw_label.lower().replace("-", "_").replace(" ", "_")
coords = match.groups()[1:] try:
if len(coords) != 4 or any(v is None for v in coords): x0, y0, x1, y1 = (float(gd[k]) for k in ("x0", "y0", "x1", "y1"))
except (ValueError, TypeError):
continue continue
x0, y0, x1, y1 = (float(v) for v in coords)
if x1 <= x0 or y1 <= y0: if x1 <= x0 or y1 <= y0:
continue continue
start = match.end() start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "") end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "")
text = (raw or "")[start:end].strip() text = (raw or "")[start:end].strip()
# The model's generic Non-Text label is an image/figure. It must be if label in {"non_text", "nontext", "photo", "drawing", "picture"}:
# retained even empty, so crop_visual_regions can preserve it.
if label in {"non_text", "nontext"}:
label = "image" label = "image"
elif label in {"map", "graphic", "infographic", "diagram", "treemap", "statistics"}:
label = "chart"
if not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES: if not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES:
continue continue
regions.append({ regions.append({
@ -980,17 +1012,19 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str,
base_url += "/v1" base_url += "/v1"
unlimited_ocr = "unlimited-ocr" in model.lower() unlimited_ocr = "unlimited-ocr" in model.lower()
prompt = "document parsing." if unlimited_ocr else ( prompt = "<|grounding|>Convert the document to markdown." if unlimited_ocr else (
"Recognise the page layout and transcribe visible text verbatim. " "Recognise the page layout and transcribe visible text verbatim. "
"Return one region per headline, paragraph, image, chart, table, " "Return one region per headline, title, paragraph, sidenote, sidebar, "
"code box, caption, header, footer or page number. Use the format " "image, figure, chart, graphic, map, diagram, table, code box, "
"caption, header, footer, or page number. Use the format "
"label [x0, y0, x1, y1]transcription with pixel coordinates in the " "label [x0, y0, x1, y1]transcription with pixel coordinates in the "
"supplied image, one region starting on each new line. Labels: " "supplied image, one region starting on each new line. Labels: "
"title, paragraph, image, chart, table, code, caption, header, " "title, heading, paragraph, sidenote, sidebar, image, figure, chart, "
"footer, page_number, aside_text. Enclose a whole chart or table " "graphic, map, table, code, caption, header, footer, page_number. "
"in one box, not separate axis labels or cells. Preserve table " "Enclose a whole chart, diagram, map, graphic, or table in one box, "
"rows and columns as Markdown and code indentation and line breaks. " "not separate axis labels or cells. Preserve table rows and columns "
"Images may have empty transcription; do not invent descriptions. " "as Markdown and code indentation and line breaks. Images, figures, and "
"charts may have empty transcription; do not invent descriptions. "
"Do not merge neighbouring articles or include commentary." "Do not merge neighbouring articles or include commentary."
) )
payload = { payload = {

View File

@ -134,7 +134,17 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
# both wrong for this. Best-effort -- a write failure must never # both wrong for this. Best-effort -- a write failure must never
# abort ingestion. # abort ingestion.
try: try:
preview_text = " ".join(b.text for b in blocks).strip()[:4000] lines = []
for b in blocks:
b_type = getattr(b, "type", "")
label = getattr(b, "region_label", None) or b_type or "region"
txt = (getattr(b, "text", "") or "").strip()
if b_type in mag.VISUAL_TYPES or b_type in (mag.BLOCK_FURNITURE, mag.BLOCK_SIDEBAR, mag.BLOCK_PULLQUOTE, mag.BLOCK_AD):
line_str = f"[{label.upper()}] {txt}".strip()
lines.append(line_str)
elif txt:
lines.append(txt)
preview_text = "\n\n".join(lines)[:4000]
(d / "ingest_preview.json").write_text( (d / "ingest_preview.json").write_text(
json.dumps({"page": page, "text": preview_text}, ensure_ascii=False), json.dumps({"page": page, "text": preview_text}, ensure_ascii=False),
"utf-8") "utf-8")

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.26.10"> <meta name="app-version" content="1.26.11">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.26.10"> <link rel="stylesheet" href="/static/style.css?v=1.26.11">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.26.10"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.26.11">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.26.10"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.26.11">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.26.10"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.26.11">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.26.10"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.26.11">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.26.10"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.26.11">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -682,29 +682,35 @@ function renderReview() {
const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null; const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null;
const issuePages = Number(state.layout?.page_count || 0); const issuePages = Number(state.layout?.page_count || 0);
const previousIssuePage = page > 1 ? page - 1 : null; const previousIssuePage = page > 1 ? page - 1 : null;
const nextIssuePage = page < issuePages ? page + 1 : null; if (!host) return;
const page = state.reviewPage;
const pageMeta = page != null ? (state.layout.pages || []).find((p) => p.page === page) : null;
const article = state.article;
const issuePages = (state.layout || {}).page_count || '—';
const nextIssuePage = page != null && page < (state.layout || {}).page_count ? page + 1 : null;
const prevIssuePage = page != null && page > 1 ? page - 1 : null;
let html = const included = new Set(article ? article.blocks || [] : []);
'<div class="mag-review-head">' + const ownImages = new Set(article ? article.images || [] : []);
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'<div class="mag-pill ' + conf.cls + '">' + esc(conf.text) + '</div>' +
'</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. A region labelled with another ' +
'article belongs to a different piece sharing this page. Draw a missing text or image region below.</p>';
const pageBlocks = (state.layout.blocks || []).filter((b) => b.page === page);
const pageImages = (state.layout.images || []).filter((i) => i.page === page);
const regions = [
...pageBlocks.map((b) => ({ kind: 'block', id: b.id, data: b, ro: b.reading_order ?? 0 })),
...pageImages.map((i) => ({ kind: 'image', id: i.id, data: i, ro: i.reading_order ?? 0 })),
].sort((a, b) => a.ro - b.ro);
let html = '';
html += '<div class="mag-review-pagenav">' + html += '<div class="mag-review-pagenav">' +
'<div class="mag-review-page-controls" role="group" aria-label="Review pages">' + '<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevPage ? '' : 'disabled') + '<button type="button" class="btn-sm" aria-label="Previous page" ' + (state.reviewPageIdx > 0 ? '' : 'disabled') +
' onclick="magReviewGoPage(' + prevPage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' + ' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' + '<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextPage ? '' : 'disabled') + '<button type="button" class="btn-sm" aria-label="Next page" ' + (state.reviewPageIdx < (article?.pages || []).length - 1 ? '' : 'disabled') +
' onclick="magReviewGoPage(' + nextPage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' + ' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-page-controls mag-review-issue-controls" role="group" aria-label="Whole magazine pages">' + '<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (previousIssuePage ? '' : 'disabled') + '<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + previousIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' + ' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' + '<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') + '<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' + ' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
@ -726,7 +732,20 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' + '<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' + '<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<span id="mag-reocr-status" class="muted"></span>' + '<span id="mag-reocr-status" class="muted"></span>' +
'</div><div id="mag-draw-hint" role="status"></div>'; '</div>';
html += '<div class="mag-review-fields">';
const a = state.article || {};
html += '<div class="mag-review-field-row">' +
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'</div>' +
'<span id="mag-draw-hint" class="muted"></span>';
html += '</div>';
const conf = confidenceLabel(article ? article.confidence : 1.0);
html += '<span class="mag-review-conf ' + conf.cls + '">' + esc(conf.text) + '</span>';
html += '<p class="muted">Untick anything that is not part of this article &mdash; 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. A region labelled with another article belongs to a different piece sharing this page. Draw a missing text or image region below.</p>';
if (state.layout.semantic_review === 'partial_failure') { if (state.layout.semantic_review === 'partial_failure') {
html += '<p class="muted" role="status">Some regions could not be grouped automatically. Check paragraph boundaries before approving.</p>'; html += '<p class="muted" role="status">Some regions could not be grouped automatically. Check paragraph boundaries before approving.</p>';
@ -739,7 +758,6 @@ function renderReview() {
html += '<div class="mag-review-split">'; html += '<div class="mag-review-split">';
// 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" id="mag-review-page-wrap">' + html += '<div class="mag-review-page-wrap" id="mag-review-page-wrap">' +
@ -770,7 +788,6 @@ function renderReview() {
} }
html += '</div>'; html += '</div>';
// Right: the same regions as text/figures, numbered to match.
html += '<div class="mag-blocks">'; html += '<div class="mag-blocks">';
regions.forEach((r, i) => { regions.forEach((r, i) => {
const isImage = r.kind === 'image'; const isImage = r.kind === 'image';
@ -778,13 +795,18 @@ function renderReview() {
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type); const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
if (isImage) { if (isImage) {
const on = ownImages.has(r.id); const on = ownImages.has(r.id);
const imgFilename = r.data.filename || (r.id + '.png');
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename);
html += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(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)">' + 'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' + '<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' + '<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(r.data.type || 'image') + '</span>' + '<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(r.data.type || 'image') + '</span>' +
'<div class="mag-block-thumb-wrap">' +
'<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' +
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title) '<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>' + : on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '</span>' +
'</div>' +
'</div>'; '</div>';
return; return;
} }

View File

@ -833,7 +833,7 @@ class VisionOcrPageCallTests(unittest.TestCase):
self.assertEqual(payload["ngram_size"], 35) self.assertEqual(payload["ngram_size"], 35)
self.assertEqual(payload["window_size"], 128) self.assertEqual(payload["window_size"], 128)
self.assertEqual(payload["images_config"], {"image_mode": "gundam"}) self.assertEqual(payload["images_config"], {"image_mode": "gundam"})
self.assertEqual(payload["messages"][0]["content"][0]["text"], "document parsing.") self.assertEqual(payload["messages"][0]["content"][0]["text"], "<|grounding|>Convert the document to markdown.")
def test_other_vision_engines_do_not_receive_unlimited_ocr_options(self): def test_other_vision_engines_do_not_receive_unlimited_ocr_options(self):
body = {"choices": [{"message": {"content": body = {"choices": [{"message": {"content":