Files
pdf/gateway/app/services/convert/ocr/rebuild.py
T

890 lines
37 KiB
Python

"""Merge OCR into IDM for scanned / hybrid / empty / encoding-broken pages."""
from __future__ import annotations
import io
import logging
import os
import time
from app.services.convert import cancellation
from app.services.convert.idm.model import BBox, Block, BlockType, Document, PageKind
from app.services.convert.layout.text_quality import (
arabic_char_ratio,
is_cmap_garbled_line,
is_garbled_text,
ocr_text_quality_score,
)
from app.services.convert.ocr import mistral_hook, rapid_adapter
from app.services.convert.options import OcrEngineChoice, OcrPolicy, get_options
from app.services.convert.pdf_bridge import render_page_png
from app.services.convert.text.arabic_logical import contains_arabic
from app.services.convert.validation import ocr_page_timeout_seconds, run_with_timeout
logger = logging.getLogger(__name__)
# Validation accepts documents up to 200 pages. The old default of 50 silently
# left later scan pages with their broken/empty text layer, so use the validated
# ceiling and report an explicit warning when callers choose a smaller cap.
DEFAULT_OCR_PAGE_CAP = 200
DEFAULT_OCR_IMAGE_MAX_BYTES = 15_000_000
DEFAULT_FIGURE_MAX_BYTES = 4_000_000
# OCR output below this quality never replaces an existing digital text layer.
MIN_OCR_REPLACE_SCORE = 0.45
# Stop starting new pages once the remaining budget is under this multiple of
# the measured per-page cost, so the run ends by choice with a complete
# document rather than by a deadline that discards every page already done.
BUDGET_SAFETY_FACTOR = 1.25
# Cost assumed for the first page, before anything has been measured.
INITIAL_PAGE_COST_ESTIMATE = 8.0
# A page carrying at least this much readable text has told us what script it
# is in, and a second recognition pass over it is spent for nothing.
ARABIC_DECISION_MIN_CHARS = 120
def _needs_ocr(page, *, broken: set[int], route_needing: set[int]) -> bool:
"""Whether this page is a candidate for OCR at all."""
opts = get_options()
if opts.ocr_policy == OcrPolicy.never:
return False
force_replace = page.index in broken
has_text = any(b.plain_text().strip() for b in page.blocks)
usable = sum(len(b.plain_text().strip()) for b in page.blocks)
blob = " ".join(b.plain_text() for b in page.blocks)
# A clean digital page in a mixed document must never be OCR'd just because
# a neighbouring page is a scan — including when auto_policy set ocr_policy=force
# for the rest of the file.
if (
page.kind == PageKind.digital
and usable >= 40
and not is_garbled_text(blob)
and not force_replace
):
return False
if opts.ocr_policy == OcrPolicy.force:
return True
if force_replace:
return True
# A usable, non-garbled text layer must not be rebuilt from OCR — even
# when a mixed document's router listed this page, or classified it as
# hybrid because a neighbouring page is a scan. OCR over good text
# copies scan furniture onto the page and can only make the text worse.
#
# Exception: a scan/hybrid whose extract is Latin-only (broken cmap that
# still looks like English vowels, or Arabic ink never in the layer) must
# still be recognised. Skipping those pages is how bilingual RFPs stayed
# ~0.6% Arabic.
looks_clean = (
usable >= 40
and page.kind != PageKind.blank
and not is_garbled_text(blob)
and not is_cmap_garbled_line(blob)
and ocr_text_quality_score([blob]) >= MIN_OCR_REPLACE_SCORE
)
if looks_clean:
missing_arabic = arabic_char_ratio(blob) < 0.01
scan_like = page.kind == PageKind.scan
if not (missing_arabic and scan_like):
return False
route_ocr = page.index in route_needing
if has_text and page.kind == PageKind.digital and not route_ocr:
return False
return not (
page.kind not in (PageKind.scan, PageKind.hybrid, PageKind.blank)
and has_text
and not route_ocr
)
def _ocr_priority(page, *, broken: set[int], route_needing: set[int]) -> tuple[int, int]:
"""Sort key deciding which pages get the budget first.
A page with no text at all gains everything from OCR and risks nothing. A
page whose text layer is merely broken still has *something* to fall back
on, and OCR may not even beat it. When the budget cannot cover every
candidate, spending it on empty pages first is strictly better.
"""
has_text = any(b.plain_text().strip() for b in page.blocks)
if not has_text:
rank = 0
elif page.kind in (PageKind.scan, PageKind.blank):
rank = 1
elif page.index in broken:
rank = 2
elif page.index in route_needing:
rank = 3
else:
rank = 4
return (rank, page.index)
def _bbox_overlap_ratio(a, b) -> float:
if a.w <= 0 or a.h <= 0 or b.w <= 0 or b.h <= 0:
return 0.0
ax1, ay1, ax2, ay2 = a.x, a.y, a.x + a.w, a.y + a.h
bx1, by1, bx2, by2 = b.x, b.y, b.x + b.w, b.y + b.h
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter <= 0:
return 0.0
return inter / max(a.w * a.h, 1e-6)
JUNK_TEXT_FLOOR = 0.15
def _clean_digital_block(block: Block) -> Block | None:
"""Strip broken-ToUnicode text from a digital block before a hybrid merge.
Returns the block (possibly with junk lines removed), or None when nothing
usable is left and OCR should own the region.
A hybrid page keeps its digital text and adds OCR around it. On a document
whose cmap is broken, that digital text is garbage — ``fgoERAI. AUTHOf,ITY
IOR IDENIITY,`` — and the merge glued it to the OCR that had already read
the same banner correctly. ``force_replace`` drops junk wholesale, but only
on pages routed as fully broken; a page that mixes real English headings
with corrupted Arabic never reached that branch.
Whole-block deletion is wrong here: these blocks mix junk with real prose,
and dropping one to remove its junk prefix would take the sentence with it.
Junk is removed line by line, and the surviving lines stay in the body.
"""
if block.type in (BlockType.figure,) or block.image_png:
return block
if block.type == BlockType.table:
# Judge the row as a whole: one cell rarely carries enough tokens for
# the shape test to decide, but the row it belongs to does.
rows = [
row
for row in (block.cells or [])
if not is_cmap_garbled_line(" ".join(c for c in row if (c or "").strip()))
]
if not rows:
return None
if len(rows) != len(block.cells or []):
block.cells = rows
return block
text = block.plain_text()
if not text.strip():
return block
lines = text.splitlines()
clean = [ln for ln in lines if not is_cmap_garbled_line(ln)]
if not clean:
return None
if len(clean) != len(lines):
# Rebuild from the surviving lines. Spans carried per-line styling that
# no longer maps onto the shortened text, so they are dropped rather
# than left pointing at removed words.
joined = "\n".join(clean).strip()
if not joined:
return None
block.text = joined
block.spans = []
text = joined
# Nothing salvageable line by line, but the whole still reads as garbage.
if ocr_text_quality_score([text]) <= JUNK_TEXT_FLOOR:
return None
return block
def _merge_hybrid(existing: list[Block], ocr_blocks: list[Block]) -> list[Block]:
"""Keep *usable* digital text; add OCR blocks that do not heavily overlap it.
The digital side is cleaned first, so OCR is preferred exactly where the
text layer is broken and kept out of the way everywhere else.
"""
cleaned: list[Block] = []
for block in existing:
survivor = _clean_digital_block(block)
if survivor is not None:
cleaned.append(survivor)
existing = cleaned
kept = list(existing)
order = max((b.reading_order for b in kept), default=-1) + 1
for ob in ocr_blocks:
overlaps = False
for eb in existing:
if not eb.plain_text().strip():
continue
if _bbox_overlap_ratio(ob.bbox, eb.bbox) > 0.35:
overlaps = True
break
if ob.plain_text().strip().lower() == eb.plain_text().strip().lower():
overlaps = True
break
if overlaps:
continue
ob.reading_order = order
kept.append(ob)
order += 1
return kept
def _compact_image_bytes(image_bytes: bytes, *, max_bytes: int) -> bytes:
"""Downsample/compress a raster when a consumer imposes a byte budget.
Dropping a large figure is a content-loss bug: page-sized scans and maps
are often several megabytes even at 150--200 DPI. This helper first tries
lossless PNG at progressively smaller dimensions, then a high-quality
JPEG as a last resort. If decoding fails, the original bytes are retained
so the caller can still preserve the artwork rather than silently deleting
it.
"""
if not image_bytes or max_bytes <= 0 or len(image_bytes) <= max_bytes:
return image_bytes
try:
from PIL import Image, ImageOps
with Image.open(io.BytesIO(image_bytes)) as source:
source = ImageOps.exif_transpose(source)
source.load()
original_size = source.size
# Keep the first candidate at the source dimensions. A highly
# compressible monochrome page may fit without any resampling.
side_limits = [
max(original_size),
5000,
4200,
3600,
3000,
2600,
2200,
1800,
1400,
1100,
900,
700,
500,
350,
250,
180,
]
candidates: list[bytes] = []
seen_sides: set[int] = set()
for side in side_limits:
side = max(1, int(side))
if side in seen_sides:
continue
seen_sides.add(side)
image = source.copy()
if max(image.size) > side:
image.thumbnail((side, side), Image.Resampling.LANCZOS)
# Preserve alpha/line art with PNG first.
png_buf = io.BytesIO()
png_image = image
if png_image.mode not in ("1", "L", "LA", "RGB", "RGBA"):
png_image = png_image.convert("RGBA" if "A" in png_image.mode else "RGB")
png_image.save(png_buf, format="PNG", optimize=True)
png = png_buf.getvalue()
candidates.append(png)
if len(png) <= max_bytes:
return png
# A JPEG candidate is useful for photographic pages. Never
# use it for a transparent image unless it has been flattened
# explicitly, otherwise logos lose their background semantics.
has_alpha = "A" in image.mode or "transparency" in image.info
if not has_alpha:
jpg_buf = io.BytesIO()
jpg_image = image if image.mode in ("L", "RGB") else image.convert("RGB")
jpg_image.save(jpg_buf, format="JPEG", quality=88, optimize=True)
jpg = jpg_buf.getvalue()
candidates.append(jpg)
if len(jpg) <= max_bytes:
return jpg
# If the requested limit is unusually small, return the smallest
# usable candidate rather than the original; content remains
# inspectable and no data is silently discarded.
return min(candidates, key=len) if candidates else image_bytes
except Exception as exc:
logger.warning("OCR image compaction failed; retaining original (%s)", exc)
return image_bytes
def _strip_heavy_figures(blocks: list[Block]) -> list[Block]:
"""Compact large figures while retaining every image block.
The old implementation removed every figure over 350 KB. That threshold
is smaller than many legitimate screenshots and effectively deleted page
content. Formatting layers can still apply their own output-specific
compression; this pass only bounds pathological in-memory payloads and
never drops a figure.
"""
try:
max_bytes = int(os.environ.get("CONVERT_FIGURE_MAX_BYTES", str(DEFAULT_FIGURE_MAX_BYTES)))
except (TypeError, ValueError):
max_bytes = DEFAULT_FIGURE_MAX_BYTES
max_bytes = max(256_000, max_bytes)
for block in blocks:
if block.type != BlockType.figure or not block.image_png:
continue
if len(block.image_png) > max_bytes:
block.image_png = _compact_image_bytes(block.image_png, max_bytes=max_bytes)
return blocks
def _raster_size(page_png: bytes) -> tuple[int, int] | None:
try:
import io as _io
from PIL import Image
with Image.open(_io.BytesIO(page_png)) as im:
return im.size
except Exception:
return None
def _normalise_ocr_geometry(page, ocr_blocks: list[Block], page_png: bytes) -> None:
"""Move OCR block bboxes from raster pixels into the page's own space.
The OCR adapter works in raster pixels and negates y so that reading order
comes out right against layout code that assumes PDF coordinates. Those
values then reach the IDM unchanged, so a page rebuilt from OCR carried
geometry in a different origin *and* scale from every digital page — and,
after a hybrid merge, from the digital blocks sitting beside it on the same
page.
Anything downstream that reasons about position across a document is wrong
under that mixture: header/footer bands measured against the page height
matched nothing on scanned pages, and figure placement could not be
compared with text. Converting once, here, means the rest of the pipeline
sees one coordinate system.
"""
if not ocr_blocks:
return
size = _raster_size(page_png)
if not size:
return
raster_w, raster_h = size
if raster_w <= 0 or raster_h <= 0:
return
page_w = float(page.width or 0)
page_h = float(page.height or 0)
if page_w <= 0 or page_h <= 0:
return
installed = {id(b) for b in page.blocks}
# Only convert blocks that still look like negated pixel space; a second
# pass over an already-converted block would shrink it again.
#
# Judged per block, not per page. When one odd block disabled the whole
# pass, every block on that page stayed in pixel coordinates — the page
# then had no comparable geometry at all, and header/footer bands measured
# against the page height matched nothing on it. One stray bbox must not
# be able to do that to a page.
targets = [
b
for b in ocr_blocks
if id(b) in installed and b.bbox and (b.bbox.y <= 0 or b.bbox.h <= 0)
]
if not targets:
return
sx, sy = page_w / raster_w, page_h / raster_h
for block in targets:
bb = block.bbox
px_top = -(bb.y + bb.h)
bb.x = bb.x * sx
bb.w = bb.w * sx
bb.h = bb.h * sy
# PDF y grows upwards from the bottom of the page.
bb.y = page_h - (px_top * sy) - bb.h
def _ocr_boxes_in_pixels(
ocr_blocks: list[Block],
) -> list[tuple[float, float, float, float]]:
"""OCR block bboxes as raster pixels, top-left origin.
The OCR adapter negates y when it builds lines, so the layout code (which
assumes PDF coordinates increasing upwards) orders pages correctly. The
raster those boxes came from has y increasing downwards, so masking with
the stored values unflipped would mask the mirror image of the text and
leave the words standing while erasing the logo.
"""
boxes = [b for b in ocr_blocks if b.bbox and b.bbox.w > 0 and b.bbox.h > 0]
if not boxes:
return []
negated = all(b.bbox.y <= 0 for b in boxes)
out: list[tuple[float, float, float, float]] = []
for b in boxes:
top = -(b.bbox.y + b.bbox.h) if negated else b.bbox.y
out.append((b.bbox.x, top, b.bbox.w, b.bbox.h))
return out
# Marks artwork segmented out of a page raster's masthead band. The DOCX writer
# routes blocks carrying it into the Word header instead of the body, so a
# letterhead mark appears once per page like a letterhead, not once in the flow.
MASTHEAD_SOURCE = "masthead"
def _add_masthead_logo(document: Document, page, page_png: bytes, ocr_blocks: list[Block]) -> None:
"""Recover the page-one emblem when the PDF embeds only full-page rasters.
A page-sized bitmap is the page, not a figure, so the figure extractor
declines it — correctly, but that loses the masthead artwork entirely. The
emblem is segmented back out of the raster here, where both the render and
the OCR text boxes are already in hand.
Page one only: a running banner repeated on every page belongs in the Word
header, not as twenty-six copies of the same logo in the body.
"""
if page.index != 0:
return
if any(b.type == BlockType.figure and b.image_png for b in page.blocks):
return
from app.services.convert.layout.logo_crop import extract_masthead_logos
crops = extract_masthead_logos(page_png, _ocr_boxes_in_pixels(ocr_blocks))
if not crops:
return
page_w = float(page.width or 612.0)
page_h = float(page.height or 792.0)
for block in page.blocks:
block.reading_order += len(crops)
for i, crop in enumerate(crops):
x, y, w, h = crop.bbox_points(page_w, page_h)
page.blocks.insert(
i,
Block(
type=BlockType.figure,
# No caption. "[Logo]" is not in the document; printing it puts
# a word on the page that the customer never wrote.
text="",
image_png=crop.png,
# Real geometry, so the writer sizes the emblem from the page
# rather than falling back to a full-width default.
bbox=BBox(x=x, y=y, w=w, h=h),
reading_order=i,
source=MASTHEAD_SOURCE,
),
)
total_kb = sum(len(c.png) for c in crops) // 1024
document.warnings.append(
f"Page 1: {len(crops)} masthead mark(s) recovered from the page raster "
f"({total_kb} KB) and placed in the document header."
)
def enrich_scanned_pages(document: Document, pdf_bytes: bytes) -> None:
raw_cap = os.environ.get("CONVERT_OCR_PAGE_CAP", str(DEFAULT_OCR_PAGE_CAP))
try:
cap = max(0, int(raw_cap))
except (TypeError, ValueError):
cap = DEFAULT_OCR_PAGE_CAP
document.warnings.append(
f"Invalid CONVERT_OCR_PAGE_CAP={raw_cap!r}; using {DEFAULT_OCR_PAGE_CAP}."
)
page_timeout = ocr_page_timeout_seconds(45.0)
ocr_pages = 0
broken = set((document.meta or {}).get("encoding_broken_pages") or [])
route = (document.meta or {}).get("pdf_route") or {}
route_needing = set(route.get("pages_needing_ocr") or [])
# Dual-pass Arabic OCR is expensive. Run it when the document already
# contains Arabic, when the cmap is broken (script may be hidden even if
# the file is named rfp.pdf), or when pages are scans/hybrids. In adaptive
# mode, unknown scan pages pass ``None`` through to RapidOCR: its EN result
# then decides whether the AR recogniser is needed. This avoids paying a
# second inference pass for clean Latin scans while retaining a recovery
# path for Arabic pages whose EN output is empty or garbled.
from app.services.convert.layout.text_quality import filename_suggests_arabic
from app.services import ocr as ocr_service
source_name = str((document.meta or {}).get("source_filename") or "")
scan_like = any(
p.kind in (PageKind.scan, PageKind.hybrid, PageKind.blank) for p in document.pages
)
adaptive_arabic = False
try:
adaptive_arabic = ocr_service.arabic_pass_adaptive_enabled()
except Exception:
pass
doc_has_arabic = (
any(contains_arabic(b.plain_text()) for p in document.pages for b in p.blocks)
or bool(broken)
or filename_suggests_arabic(source_name)
or (scan_like and not adaptive_arabic)
)
# A bilingual document is not Arabic on every page. Running the second
# recogniser over all 26 pages of an RFP whose Arabic is on nine of them
# doubles the cost of the other seventeen for nothing, and recognition is
# the whole cost of a scanned conversion. Where a page already carries
# readable text of its own, that text answers the question exactly; only
# where it does not does the document-wide guess stand in.
pages_with_arabic = {
page.index
for page in document.pages
if any(contains_arabic(b.plain_text()) for b in page.blocks)
}
def _page_wants_arabic(page) -> bool | None:
if adaptive_arabic and not doc_has_arabic:
# Let the recogniser inspect the EN pass first. ``None`` is a
# deliberate tri-state value; ``False`` would suppress AR even
# when the page is an Arabic-only scan.
return None
if not doc_has_arabic:
return False
if page.index in pages_with_arabic:
return True
if page.index in broken:
# The cmap is broken here, so the page's own text proves nothing.
return True
readable = "".join(b.plain_text() for b in page.blocks).strip()
if len(readable) >= ARABIC_DECISION_MIN_CHARS:
# The page said what it contains, and it was not Arabic.
return False
return True
candidates = [
page
for page in document.pages
if _needs_ocr(page, broken=broken, route_needing=route_needing)
]
# Budget order, not page order: see _ocr_priority. Results are written back
# onto the page objects, so document order is unaffected.
candidates.sort(key=lambda p: _ocr_priority(p, broken=broken, route_needing=route_needing))
page_cost = INITIAL_PAGE_COST_ESTIMATE
skipped_for_budget: list[int] = []
skipped_for_cap: list[int] = []
for position, page in enumerate(candidates):
force_replace = page.index in broken
has_text = any(b.plain_text().strip() for b in page.blocks)
if ocr_pages >= cap:
document.warnings.append(
f"OCR truncated after {cap} pages (CONVERT_OCR_PAGE_CAP)."
)
skipped_for_cap.extend(p.index for p in candidates[position:])
break
# A cancelled job still aborts; an exhausted *budget* degrades below.
# Deliberately not cancellation.check(): that raises on an expired
# deadline too, so one page overrunning the budget would throw away
# every page already recovered — the exact failure this loop exists to
# prevent.
cancellation.check_cancelled("OCR")
deadline = cancellation.get_deadline()
# The first page is always attempted: page_cost is a guess until a page
# has actually been timed, and refusing to start on a guess could skip
# OCR entirely on hardware where pages are far cheaper than assumed.
# After that the estimate is measured, so the check is trustworthy.
out_of_budget = (
ocr_pages > 0
and deadline is not None
and deadline.seconds > 0
and deadline.remaining < page_cost * BUDGET_SAFETY_FACTOR
)
if out_of_budget:
skipped_for_budget.extend(p.index for p in candidates[position:])
break
started = time.monotonic()
try:
png = run_with_timeout(
lambda idx=page.index: render_page_png(pdf_bytes, idx, dpi=200),
min(page_timeout, 20.0),
label=f"render page {page.index + 1}",
)
except Exception as exc:
msg = str(exc)
if "NO_PAGE_RENDER" in msg:
document.warnings.append(
f"Page {page.index + 1}: skipped OCR — no page raster (engine/image missing)."
)
else:
document.warnings.append(f"Page {page.index + 1}: render for OCR failed.")
continue
# Keep the original render for fidelity fallbacks and logo extraction,
# but feed a bounded raster to OCR. Previously a >15 MB page was
# skipped entirely, leaving a scan with no text and no replacement
# image. Compaction is explicit and measurable instead of silent loss.
ocr_png = png
try:
image_limit = int(
os.environ.get("CONVERT_OCR_IMAGE_MAX_BYTES", str(DEFAULT_OCR_IMAGE_MAX_BYTES))
)
except (TypeError, ValueError):
image_limit = DEFAULT_OCR_IMAGE_MAX_BYTES
image_limit = max(256_000, image_limit)
if len(png) > image_limit:
ocr_png = _compact_image_bytes(png, max_bytes=image_limit)
if len(ocr_png) < len(png):
document.warnings.append(
f"Page {page.index + 1}: OCR raster compacted "
f"({len(png) // 1024} KB -> {len(ocr_png) // 1024} KB)."
)
else:
document.warnings.append(
f"Page {page.index + 1}: OCR raster exceeds configured limit "
f"({len(png) // 1024} KB); retained for OCR."
)
# The ML detector's table rectangles are hints, not a substitute for
# reading the page. They used to *replace* the OCR input — the page was
# cropped to those rectangles and only the crops were recognised —
# which cost the document everything outside a detected table, clipped
# the leading character of every row whose rectangle sat a few pixels
# inside the text ("R-01" arriving as "-01"), and handed the grid
# builder crop-local coordinates it could not assemble across.
#
# Full-page recognition costs one pass and keeps the whole page. The
# rectangles remain on ``document.meta`` for the table-structure hook,
# which uses them the way a hint should be used: to sharpen a decision,
# not to decide what gets read.
ml_tables = (document.meta or {}).get("ml_table_regions_by_page") or {}
if ml_tables.get(page.index) or ml_tables.get(str(page.index)):
document.warnings.append(
f"Page {page.index + 1}: ML table rectangles kept as hints; "
"OCR reads the whole page."
)
ocr_pages += 1
wants_arabic = _page_wants_arabic(page)
blocks: list[Block] = []
ocr_inputs = [ocr_png]
def _run_mistral(ocr_inputs: list[bytes] = ocr_inputs) -> list[Block]:
out: list[Block] = []
order = 0
for chunk in ocr_inputs:
part = mistral_hook.ocr_image_to_blocks_mistral(chunk, start_order=order)
out.extend(part)
order = max((b.reading_order for b in out), default=-1) + 1
return out
def _run_rapid(ocr_inputs: list[bytes] = ocr_inputs) -> list[Block]:
out: list[Block] = []
order = 0
for chunk in ocr_inputs:
part = rapid_adapter.ocr_image_to_blocks(
chunk, start_order=order, try_arabic=wants_arabic
)
out.extend(part)
order = max((b.reading_order for b in out), default=-1) + 1
return out
# Why recognition produced nothing, when it produced nothing. Both
# engines used to collapse every failure to an empty list, so a crash,
# a blown page timeout and a genuinely blank page were indistinguishable
# downstream and in the warning the customer eventually read.
ocr_failure: str = ""
if mistral_hook.mistral_configured() and get_options().ocr_engine != OcrEngineChoice.rapid:
try:
blocks = run_with_timeout(
_run_mistral, page_timeout, label=f"Mistral OCR page {page.index + 1}"
)
except Exception as exc:
blocks = []
ocr_failure = f"Mistral OCR {type(exc).__name__}: {exc}"
if blocks:
document.warnings.append(
f"Page {page.index + 1}: OCR via Mistral product feature."
)
elif get_options().ocr_engine == OcrEngineChoice.mistral:
document.warnings.append(
f"Page {page.index + 1}: ocr_engine=mistral produced no text; falling back to RapidOCR."
)
elif get_options().ocr_engine == OcrEngineChoice.mistral:
document.warnings.append(
f"Page {page.index + 1}: ocr_engine=mistral requested but not configured; using RapidOCR."
)
if not blocks:
try:
blocks = run_with_timeout(
_run_rapid, page_timeout, label=f"RapidOCR page {page.index + 1}"
)
except Exception as exc:
blocks = []
ocr_failure = f"RapidOCR {type(exc).__name__}: {exc}"
if blocks:
ocr_failure = ""
ar_used = False
try:
from app.services import ocr as ocr_service
ar_used = bool(ocr_service.get_ocr_ar_used_last())
except Exception:
ar_used = False
if ar_used:
document.warnings.append(
f"Page {page.index + 1}: OCR via RapidOCR (en+ar)."
)
else:
document.warnings.append(f"Page {page.index + 1}: OCR via RapidOCR.")
if not blocks:
if page.kind == PageKind.hybrid and has_text and not force_replace:
continue
# OCR recovered nothing, so the page image below carries the
# content. A broken text layer must not ride along beside it:
# unreadable cmap garbage helps no reader, and the table detector
# had been building grids out of it ("IIOERAI. | AUTHORITY | IOR").
if has_text:
page_text = [b.plain_text() for b in page.blocks if b.plain_text().strip()]
if ocr_text_quality_score(page_text) <= JUNK_TEXT_FLOOR:
survivors = [_clean_digital_block(b) for b in page.blocks]
page.blocks = [b for b in survivors if b is not None]
document.warnings.append(
f"Page {page.index + 1}: dropped unreadable text layer "
"(no OCR text recovered; page image kept)."
)
# Keep a bounded copy for downstream package writers. Unlike the
# old ``_strip_heavy_figures`` behaviour, a large page image is
# never removed; at worst it is downsampled/compressed.
figure_png = _compact_image_bytes(png, max_bytes=DEFAULT_FIGURE_MAX_BYTES)
page.blocks.append(
Block(
type=BlockType.figure,
text=f"[Page {page.index + 1} image]",
image_png=figure_png,
reading_order=max((b.reading_order for b in page.blocks), default=-1) + 1,
)
)
document.warnings.append(
f"Page {page.index + 1}: no OCR text; embedded page image."
+ (f" Recognition failed — {ocr_failure}." if ocr_failure else "")
)
continue
ocr_score = ocr_text_quality_score([b.plain_text() for b in blocks])
existing_texts = [b.plain_text() for b in page.blocks if b.plain_text().strip()]
existing_score = ocr_text_quality_score(existing_texts) if existing_texts else 0.0
if not has_text:
# Nothing to lose: any OCR output is an improvement over an empty page.
page.blocks = blocks
page.kind = PageKind.scan
document.warnings.append(
f"Page {page.index + 1}: text recovered by OCR (quality~{ocr_score:.2f})."
)
elif force_replace:
# Broken ToUnicode (NSE): if the digital layer is junk (≤0.15), any
# non-empty OCR is better than keeping IIOERAI-class garbage.
junk_digital = existing_score <= 0.15
ocr_nonempty = any(b.plain_text().strip() for b in blocks)
if junk_digital and ocr_nonempty:
page.blocks = blocks
page.kind = PageKind.scan
document.warnings.append(
f"Page {page.index + 1}: replaced junk digital text with OCR "
f"(existing~{existing_score:.2f}, ocr~{ocr_score:.2f})."
)
elif ocr_score >= MIN_OCR_REPLACE_SCORE and ocr_score >= existing_score:
page.blocks = blocks
page.kind = PageKind.scan
document.warnings.append(
f"Page {page.index + 1}: replaced broken text layer with OCR "
f"(ocr~{ocr_score:.2f} > existing~{existing_score:.2f})."
)
else:
document.warnings.append(
f"Page {page.index + 1}: kept original text layer — OCR quality "
f"{ocr_score:.2f} did not beat existing {existing_score:.2f}."
)
elif page.kind != PageKind.hybrid:
if ocr_score >= existing_score:
page.blocks = blocks
page.kind = PageKind.scan
document.warnings.append(
f"Page {page.index + 1}: replaced text layer with OCR "
f"(quality~{ocr_score:.2f})."
)
else:
document.warnings.append(
f"Page {page.index + 1}: kept original text layer — OCR quality "
f"{ocr_score:.2f} did not beat existing {existing_score:.2f}."
)
else:
page.blocks = _merge_hybrid(page.blocks, blocks)
page.blocks = _strip_heavy_figures(page.blocks)
# Logo segmentation needs the raster's own pixel space, so it runs
# before the blocks are moved into page coordinates.
_add_masthead_logo(document, page, png, blocks)
_normalise_ocr_geometry(page, blocks, png)
# Feed the measured cost back into the budget check. An exponential
# moving average tracks pages that get slower (dense scans) without
# letting one outlier stop the run.
elapsed = time.monotonic() - started
page_cost = elapsed if ocr_pages == 1 else (0.6 * page_cost + 0.4 * elapsed)
# Always expose the counters so callers can distinguish a complete run
# from one that was capped or budget-truncated, even when no warning was
# emitted for a zero-candidate document.
meta = document.meta if document.meta is not None else {}
meta["ocr_page_cap"] = cap
meta["ocr_candidate_pages"] = len(candidates)
meta["ocr_pages_done"] = ocr_pages
meta.setdefault("ocr_page_cap_reached", False)
meta.setdefault("ocr_budget_exhausted", False)
document.meta = meta
if skipped_for_cap:
pages_1based = sorted(i + 1 for i in skipped_for_cap)
meta["ocr_page_cap_reached"] = True
meta["ocr_pages_skipped"] = pages_1based
shown = ", ".join(str(p) for p in pages_1based[:12])
if len(pages_1based) > 12:
shown += f", ... (+{len(pages_1based) - 12} more)"
# The cap is an operator policy, not a timeout. Keep this warning
# separate so dashboards do not misclassify intentional truncation as
# an exhausted wall-clock budget.
document.warnings.insert(
0,
f"OCR page cap reached after {ocr_pages} page(s): page(s) {shown} "
"were not OCR'd; raise CONVERT_OCR_PAGE_CAP to process them.",
)
if skipped_for_budget:
pages_1based = sorted(i + 1 for i in skipped_for_budget)
shown = ", ".join(str(p) for p in pages_1based[:12])
if len(pages_1based) > 12:
shown += f", … (+{len(pages_1based) - 12} more)"
document.warnings.insert(
0,
f"OCR budget exhausted after {ocr_pages} page(s) (~{page_cost:.1f}s each): "
f"page(s) {shown} kept their original PDF text instead. "
"Raise CONVERT_TIMEOUT_MAX_SECONDS or split the document to OCR them.",
)
meta = document.meta if document.meta is not None else {}
meta["ocr_budget_exhausted"] = True
meta["ocr_pages_skipped"] = pages_1based
meta["ocr_pages_done"] = ocr_pages
document.meta = meta
for page in document.pages:
if page.index not in skipped_for_budget or page.index not in broken:
continue
# Encoding-broken pages we did not OCR would otherwise keep
# ToUnicode salad. Empty them so the page-image floor can own them.
page.blocks = [
b for b in page.blocks if b.type == BlockType.figure or b.image_png
]