890 lines
37 KiB
Python
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
|
|
]
|