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
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
This commit is contained in:
parent
29dc913ed1
commit
f5f887bb1d
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
208
core/magazine.py
208
core/magazine.py
@ -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 = {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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) ── -->
|
||||||
|
|||||||
@ -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 — 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user