diff --git a/docling/models/base_ocr_model.py b/docling/models/base_ocr_model.py index 59adbed670..e75ffcfc3e 100644 --- a/docling/models/base_ocr_model.py +++ b/docling/models/base_ocr_model.py @@ -10,7 +10,7 @@ from typing import ClassVar import numpy as np -from docling_core.types.doc import BoundingBox, CoordOrigin, Size +from docling_core.types.doc import BoundingBox, CoordOrigin, DocItemLabel, Size from docling_core.types.doc.page import ( BoundingRectangle, PdfCellRenderingMode, @@ -29,6 +29,7 @@ from docling.datamodel.spatial import BoundingBoxSpatialIndex from docling.exceptions import OcrLanguageNotSupportedError from docling.models.base_model import BaseModelWithOptions, BasePageModel +from docling.models.utils.text_quality import rate_text_quality from docling.utils.ocr_language import ( OcrLanguage, OcrLanguageResolver, @@ -453,7 +454,8 @@ def _find_pdf_aware_layout_ocr_rects(self, page: Page) -> list[BoundingBox]: ocr_rects.append(cluster_bbox) continue - # Of the rest, only the clusters without any programmatic text need OCR. + # Of the rest, only the clusters without any programmatic text need OCR, + # or clusters whose extracted text is corrupt/garbled (e.g. broken font CMap). if use_backend_queries: has_text = backend.has_content_in( bbox=cluster_bbox, chars=True, shapes=False, bitmaps=False @@ -465,6 +467,11 @@ def _find_pdf_aware_layout_ocr_rects(self, page: Page) -> list[BoundingBox]: if not has_text: ocr_rects.append(cluster_bbox) continue + elif cluster.label not in (DocItemLabel.CODE, DocItemLabel.FORMULA): + cluster_text = backend.get_text_in_rect(cluster_bbox) + if cluster_text and rate_text_quality(cluster_text) < 0.5: + ocr_rects.append(cluster_bbox) + continue # The cluster carries programmatic text, but that text layer need not # account for all of it: glyphs drawn as filled paths are invisible to it. @@ -658,11 +665,19 @@ def _merge_ocr_and_pdf_cells( # The prioritized cells are always kept # the secondary cells are added only where they don't overlap a prioritized cell. if priority == _MergeCellsPriority.PDF_FIRST: - prioritized_cells, secondary_cells = visible_cells, ocr_cells + clean_pdf_cells = [ + c for c in visible_cells if rate_text_quality(c.text) >= 0.5 + ] + corrupt_pdf_cells = [ + c for c in visible_cells if rate_text_quality(c.text) < 0.5 + ] + merged_cells = self._merge_cells_by_priority(clean_pdf_cells, ocr_cells) + if corrupt_pdf_cells: + merged_cells = self._merge_cells_by_priority( + merged_cells, corrupt_pdf_cells + ) else: - prioritized_cells, secondary_cells = ocr_cells, visible_cells - - merged_cells = self._merge_cells_by_priority(prioritized_cells, secondary_cells) + merged_cells = self._merge_cells_by_priority(ocr_cells, visible_cells) # Put the invisible cells back if invisible_cells: diff --git a/docling/models/stages/page_preprocessing/page_preprocessing_model.py b/docling/models/stages/page_preprocessing/page_preprocessing_model.py index 26a188fb7e..982ca85d6f 100644 --- a/docling/models/stages/page_preprocessing/page_preprocessing_model.py +++ b/docling/models/stages/page_preprocessing/page_preprocessing_model.py @@ -19,6 +19,7 @@ from docling.datamodel.pipeline_options import OcrMode, PdfPipelineOptions from docling.datamodel.settings import settings from docling.models.base_model import BasePageModel +from docling.models.utils.text_quality import rate_text_quality from docling.utils.profiling import TimeRecorder @@ -48,14 +49,6 @@ class PagePreprocessingModel(BasePageModel): def __init__(self, options: PagePreprocessingOptions): self.options = options - # Pre-compiled regex patterns for efficiency - self.GLYPH_RE = re.compile(r"GLYPH<[0-9A-Fa-f]+>") - self.SLASH_G_RE = re.compile(r"(?:/G\d+){2,}") - self.FRAG_RE = re.compile(r"\b[A-Za-z](?:/[a-z]{1,3}\.[a-z]{1,3}){2,}\b") - self.SLASH_NUMBER_GARBAGE_RE = re.compile( - r"(?:/\w+\s*){2,}" - ) # Two or more "/token " sequences - def __call__( self, conv_res: ConversionResult, page_batch: Iterable[Page] ) -> Iterable[Page]: @@ -177,28 +170,4 @@ def draw_cell_boxes( return page def rate_text_quality(self, text: str) -> float: - # Hard errors: if any of these patterns are found, return 0.0 immediately. - blacklist_chars = ["�"] - if ( - any(text.find(c) >= 0 for c in blacklist_chars) - or self.GLYPH_RE.search(text) - or self.SLASH_G_RE.search(text) - or self.SLASH_NUMBER_GARBAGE_RE.match( - text - ) # Check if text is mostly slash-number pattern - ): - return 0.0 - - penalty = 0.0 - - # Apply a penalty only if the fragmented words pattern occurs at least three times. - frag_matches = self.FRAG_RE.findall(text) - if len(frag_matches) >= 3: - penalty += 0.1 * len(frag_matches) - - # Additional heuristic: if the average token length is below 2, add a penalty. - # tokens = text.split() - # if tokens and (sum(map(len, tokens)) / len(tokens)) < 2: - # penalty += 0.2 - - return max(1.0 - penalty, 0.0) + return rate_text_quality(text) diff --git a/docling/models/utils/text_quality.py b/docling/models/utils/text_quality.py new file mode 100644 index 0000000000..5e554c5e70 --- /dev/null +++ b/docling/models/utils/text_quality.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import re +import unicodedata + +# Core PDF dump artifact regexes +GLYPH_RE = re.compile(r"GLYPH<[0-9A-Fa-f]+>") +SLASH_G_RE = re.compile(r"(?:/G\d+){2,}") +FRAG_RE = re.compile(r"\b[A-Za-z](?:/[a-z]{1,3}\.[a-z]{1,3}){2,}\b") +SLASH_NUMBER_GARBAGE_RE = re.compile(r"(?:/\w+\s*){2,}") + +# Unprintable control characters (excluding standard whitespace \t, \n, \r) +CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]") + +# Genuinely corrupt intra-word mojibake noise (e.g. backslash, caret, tilde, pipe, backtick inside words) +# Legitimate characters like &, +, =, -, *, /, $, % are explicitly permitted +MOJIBAKE_SYMBOL_RE = re.compile( + r"[A-Za-z0-9\u00C0-\u024F\u0400-\u04FF\u0600-\u06FF\u0900-\u0D7F\u4E00-\u9FFF][\\^~|`#][A-Za-z0-9\u00C0-\u024F\u0400-\u04FF\u0600-\u06FF\u0900-\u0D7F\u4E00-\u9FFF]" +) + + +def _is_pua(ch: str) -> bool: + """Check if a character belongs to Unicode Private Use Area.""" + return unicodedata.category(ch) == "Co" or ( + "\ue000" <= ch <= "\uf8ff" + or "\U000f0000" <= ch <= "\U000ffffd" + or "\U00100000" <= ch <= "\U0010fffd" + ) + + +def _has_corrupt_pua(text: str) -> bool: + """Detect unmapped PUA font dumps vs benign presentation bullet/icon fonts.""" + non_space = [ch for ch in text if not ch.isspace()] + if not non_space: + return False + pua_chars = [ch for ch in non_space if _is_pua(ch)] + if not pua_chars: + return False + + # Icons / bullets repeat 1 or 2 unique code points; unmapped font alphabets vary + if len(set(pua_chars)) >= 3: + return True + + # If PUA chars dominate the text block (>= 20%), it is an unmapped font dump + if len(pua_chars) / len(non_space) >= 0.20: + return True + + return False + + +def rate_text_quality(text: str) -> float: + """Rate extracted text quality and detect font corruption/mojibake. + + Returns a score between 0.0 (severely corrupted) and 1.0 (clean text). + """ + if not text: + return 1.0 + + # 1. Hard parser errors: replacement chars, control characters, GLYPH dumps, slash-garbage + if ( + "\ufffd" in text + or CONTROL_CHAR_RE.search(text) + or GLYPH_RE.search(text) + or SLASH_G_RE.search(text) + or SLASH_NUMBER_GARBAGE_RE.match(text) + ): + return 0.0 + + # 2. Corrupt unmapped Private Use Area alphabets (while allowing single/repeated bullet icons) + if _has_corrupt_pua(text): + return 0.0 + + # 3. Mojibake intra-word noise (e.g., d\b^c~e) + tokens = text.split() + if tokens: + mojibake_count = sum(1 for t in tokens if MOJIBAKE_SYMBOL_RE.search(t)) + if (mojibake_count / len(tokens)) >= 0.25: + return 0.0 + + # 4. Repeated font fragment paths penalty + penalty = 0.0 + frag_matches = FRAG_RE.findall(text) + if len(frag_matches) >= 3: + penalty += 0.1 * len(frag_matches) + + return max(1.0 - penalty, 0.0) diff --git a/tests/test_text_quality_and_font_sanity.py b/tests/test_text_quality_and_font_sanity.py new file mode 100644 index 0000000000..7d34b09a35 --- /dev/null +++ b/tests/test_text_quality_and_font_sanity.py @@ -0,0 +1,300 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +from typing import Iterable, Optional + +import pytest +from docling_core.types.doc import ( + BoundingBox, + CoordOrigin, + DocItemLabel, + Size, +) +from docling_core.types.doc.page import ( + BoundingRectangle, + SegmentedPdfPage, + TextCell, +) +from PIL import Image + +from docling.backend.pdf_backend import PdfPageBackend +from docling.datamodel.accelerator_options import AcceleratorOptions +from docling.datamodel.base_models import ( + Cluster, + LayoutPrediction, + Page, +) +from docling.datamodel.pipeline_options import ( + EasyOcrOptions, + OcrMode, +) +from docling.models.base_ocr_model import BaseOcrModel, _MergeCellsPriority +from docling.models.stages.page_preprocessing.page_preprocessing_model import ( + PagePreprocessingModel, + PagePreprocessingOptions, +) +from docling.models.utils.text_quality import rate_text_quality + + +def _make_text_cell( + text: str, + left: float, + bottom: float, + right: float, + top: float, + from_ocr: bool = False, +) -> TextCell: + rect = BoundingRectangle( + r_x0=left, + r_y0=bottom, + r_x1=right, + r_y1=bottom, + r_x2=right, + r_y2=top, + r_x3=left, + r_y3=top, + coord_origin=CoordOrigin.BOTTOMLEFT, + ) + return TextCell( + index=0, + text=text, + orig=text, + rect=rect, + from_ocr=from_ocr, + confidence=0.99 if from_ocr else 1.0, + ) + + +@pytest.mark.parametrize( + "valid_text", + [ + "", # Empty string + "The quick brown fox jumps over the lazy dog.", # Standard English + "AT&T Inc.", # Ampersand abbreviation + "R&D expenses for FY2026", # Ampersand abbreviation + "S&P 500 Index", # Financial index + "x+y=z", # Math formula + "a*b <= 100 and c >= 50", # Math comparison + "Q1 Revenue ($M): $1,250.50 (up +15%)", # Financial symbols and currency + "C++ / C# / Python 3.12 & Rust", # Programming languages with symbols + "\uf002 Jahresumsatz 55 Millionen Euro", # Slide bullet icon (single PUA) + "\uf0b7 Item A \uf0b7 Item B", # Multiple bullet icons + "L'extraction d'information n'est pas facile pour les documents complexes.", # French with apostrophes + "Die Überprüfung der Jahresabrechnung für Großprojekte ist abgeschlossen.", # German umlauts + "Học viện Công nghệ Bưu chính Viễn thông thông báo lịch thi.", # Vietnamese diacritics + "अनुक्रमणिका: महाराष्ट्र शासन निर्णय क्रमांक २०२४", # Devanagari Hindi/Marathi + "这是一个中文测试页面。", # Chinese + "مرحبا بكم في هذا المستند.", # Arabic + "Привет мир, тестирование русского текста.", # Cyrillic Russian + "Revenue grew by 25.5% to $1.2B in Q3 ($1,200.50 per share).", # Financial & numbers + "Chapter 1: An in-depth overview (see Table 2.1 & Section 4).", # Natural punctuation + ], +) +def test_rate_text_quality_valid_multilingual(valid_text: str): + score = rate_text_quality(valid_text) + assert score == 1.0 + + +@pytest.mark.parametrize( + "corrupt_text", + [ + "Corrupted document with \ufffd replacement character", # Unicode replacement char + "\ue001\ue002\ue003\ue004\ue005 unmapped font dump", # PUA unmapped font run + "\ue001 \ue002 \ue003 \ue004 unmapped PUA alphabet", # PUA unmapped font density + "Text with unprintable \x03 control byte", # Control character + "Text with \x1b ANSI escape code", # Control character + "Document segment GLYPH<0A12F> broken font", # Glyph tag + "/G102/G304/G506 broken encoding", # Slash-G font token sequence + "/token1 /token2 /token3 garbage", # Slash number pattern + "w\\o^r~d m`o#j!i noise", # Mojibake intra-word noise + ], +) +def test_rate_text_quality_corrupted_patterns(corrupt_text: str): + score = rate_text_quality(corrupt_text) + assert score == 0.0 + + +def test_rate_text_quality_fragmented_words_penalty(): + # Fragmented pattern e.g. a/bc.de/fg.hi (repeated pattern) + frag_text = "a/bc.de/fg.hi j/kl.mn/op.qr s/tu.vw/xy.za" + score = rate_text_quality(frag_text) + assert 0.0 <= score < 1.0 + + +def test_page_preprocessing_model_delegates_to_rate_text_quality(): + model = PagePreprocessingModel(options=PagePreprocessingOptions(images_scale=None)) + assert model.rate_text_quality("Clean document") == 1.0 + assert model.rate_text_quality("Corrupt \ufffd document") == 0.0 + + +class DummyOcrModel(BaseOcrModel): + @classmethod + def get_options_type(cls): + return EasyOcrOptions + + def __call__(self, conv_res, page_batch): + return page_batch + + +class DummyPageBackend(PdfPageBackend): + def __init__(self, text_in_rect: str = ""): + self._text_in_rect = text_in_rect + + @property + def page_no(self) -> int: + return 0 + + def is_valid(self) -> bool: + return True + + def get_size(self) -> Size: + return Size(width=612, height=792) + + def get_page_image( + self, scale: float = 1, cropbox: Optional[BoundingBox] = None + ) -> Image.Image: + return Image.new("RGB", (100, 100)) + + def unload(self) -> None: + pass + + def has_content_in( + self, + *, + bbox: BoundingBox, + chars: bool = False, + shapes: bool = True, + bitmaps: bool = True, + ) -> bool: + if chars: + return True + return False + + def get_text_in_rect(self, bbox: BoundingBox) -> str: + return self._text_in_rect + + def get_segmented_page(self) -> Optional[SegmentedPdfPage]: + return None + + def get_text_cells(self) -> Iterable[TextCell]: + return [] + + def get_bitmap_rects(self, scale: float = 1) -> Iterable[BoundingBox]: + return [] + + +def test_merge_ocr_and_pdf_cells_prioritizes_clean_pdf_and_ocr_over_corrupt_pdf(): + model = DummyOcrModel( + enabled=True, + artifacts_path=None, + options=EasyOcrOptions(mode=OcrMode.PDF_AWARE_LAYOUT_REGIONS), + accelerator_options=AcceleratorOptions(), + ) + + clean_pdf = _make_text_cell("Annual Report 2024", 0, 70, 100, 90, from_ocr=False) + corrupt_pdf = _make_text_cell( + "w\\o^r~d m`o#j!i noise", 0, 10, 100, 30, from_ocr=False + ) + ocr_cell_clean = _make_text_cell("Financial Summary", 0, 10, 100, 30, from_ocr=True) + + merged = model._merge_ocr_and_pdf_cells( + ocr_cells=[ocr_cell_clean], + pdf_cells=[clean_pdf, corrupt_pdf], + priority=_MergeCellsPriority.PDF_FIRST, + ) + + merged_texts = [c.text for c in merged] + # Clean PDF text must be preserved + assert "Annual Report 2024" in merged_texts + # OCR text must replace corrupt PDF text + assert "Financial Summary" in merged_texts + # Corrupt PDF text must not be present + assert "w\\o^r~d m`o#j!i noise" not in merged_texts + + +def test_merge_ocr_and_pdf_cells_fallback_keeps_non_overlapping_corrupt_pdf(): + model = DummyOcrModel( + enabled=True, + artifacts_path=None, + options=EasyOcrOptions(mode=OcrMode.PDF_AWARE_LAYOUT_REGIONS), + accelerator_options=AcceleratorOptions(), + ) + + corrupt_pdf = _make_text_cell( + "w\\o^r~d m`o#j!i noise", 0, 10, 100, 30, from_ocr=False + ) + # OCR produced no cells for this region + merged = model._merge_ocr_and_pdf_cells( + ocr_cells=[], + pdf_cells=[corrupt_pdf], + priority=_MergeCellsPriority.PDF_FIRST, + ) + # Non-overlapping corrupt cell kept as fallback + assert len(merged) == 1 + assert merged[0].text == "w\\o^r~d m`o#j!i noise" + + +def test_find_pdf_aware_layout_ocr_rects_includes_corrupt_clusters(): + model = DummyOcrModel( + enabled=True, + artifacts_path=None, + options=EasyOcrOptions(mode=OcrMode.PDF_AWARE_LAYOUT_REGIONS), + accelerator_options=AcceleratorOptions(), + ) + + cluster_bbox = BoundingBox( + l=10, t=10, r=200, b=100, coord_origin=CoordOrigin.TOPLEFT + ) + cluster = Cluster( + id=0, + label=DocItemLabel.TEXT, + bbox=cluster_bbox, + confidence=1.0, + cells=[], + ) + + page = Page(page_no=0, size=Size(width=612, height=792)) + page._backend = DummyPageBackend( + text_in_rect="\ue001 \ue002 \ue003 \ue004 corrupt PUA cluster" + ) + page.predictions.layout = LayoutPrediction(clusters=[cluster]) + + ocr_rects = model._find_pdf_aware_layout_ocr_rects(page) + # The cluster with corrupt text must be routed for OCR + assert len(ocr_rects) == 1 + assert ocr_rects[0].l <= cluster_bbox.l + assert ocr_rects[0].r >= cluster_bbox.r + + +def test_find_pdf_aware_layout_ocr_rects_skips_clean_clusters_and_code(): + model = DummyOcrModel( + enabled=True, + artifacts_path=None, + options=EasyOcrOptions(mode=OcrMode.PDF_AWARE_LAYOUT_REGIONS), + accelerator_options=AcceleratorOptions(), + ) + + clean_cluster = Cluster( + id=0, + label=DocItemLabel.TEXT, + bbox=BoundingBox(l=10, t=10, r=200, b=100, coord_origin=CoordOrigin.TOPLEFT), + confidence=1.0, + cells=[], + ) + code_cluster = Cluster( + id=1, + label=DocItemLabel.CODE, + bbox=BoundingBox(l=10, t=120, r=200, b=200, coord_origin=CoordOrigin.TOPLEFT), + confidence=1.0, + cells=[], + ) + + page = Page(page_no=0, size=Size(width=612, height=792)) + page._backend = DummyPageBackend( + text_in_rect="Clean English paragraph with AT&T Inc." + ) + page.predictions.layout = LayoutPrediction(clusters=[clean_cluster, code_cluster]) + + ocr_rects = model._find_pdf_aware_layout_ocr_rects(page) + # Clean text and code clusters should not generate OCR rects + assert len(ocr_rects) == 0