Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions docling/models/base_ocr_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: Cell merging applies text quality filtering to code and formula cells without layout label awareness

While _find_pdf_aware_layout_ocr_rects() skips code and formula clusters, _merge_ocr_and_pdf_cells() filters all visible PDF cells through rate_text_quality(). Code and formula cells containing backslashes, carets, pipes, or hash symbols are marked as corrupt. When OCR cells exist for the page, OCR text overwrites these native PDF code and formula cells. Preserve programmatic text in code and formula regions or avoid cell-level quality filtering where layout structure marks the region as code or formula.

]
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
87 changes: 87 additions & 0 deletions docling/models/utils/text_quality.py
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: Intra-word mojibake regex classifies valid symbols and short tokens as corrupt

The regex MOJIBAKE_SYMBOL_RE matches common characters between alphanumeric characters, such as carets (x^2), backslashes (C:\dir), pipes (a|b), and hashes (item#1). When rate_text_quality() processes single-word or short text cells containing these patterns, mojibake_count / len(tokens) meets the 0.25 threshold and scores the text as 0.0. In _merge_ocr_and_pdf_cells(), valid PDF cells with these characters are treated as corrupted and replaced by OCR. In PagePreprocessingModel._parse_page_cells(), page parse confidence drops to zero. Refine the heuristic to avoid flagging math exponents, Windows paths, identifiers, and common technical punctuation.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: Standalone PUA bullet point and icon cells trigger false positive corruption

PDF parsers frequently emit bullet points and symbols as isolated single-character text cells (for example \uf0b7 or \uf002). For a single-character cell, len(pua_chars) / len(non_space) is 1.0 (100%), which satisfies >= 0.20. _has_corrupt_pua() returns True and rate_text_quality() marks the cell as corrupt (score 0.0). In _merge_ocr_and_pdf_cells(), standard bullet and icon cells are treated as corrupt PDF cells and overwritten by OCR. Require a minimum character count or cluster length before applying percentage-based PUA thresholds, or exempt single-codepoint bullet characters.

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)
Loading
Loading