From 3666f655a48ece11f8ee154e77a85cbe4b8e22dd Mon Sep 17 00:00:00 2001 From: dobi02 <88780600+dobi02@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:31:50 +0900 Subject: [PATCH 1/2] fix: allow full-page OCR after text decode errors --- .../page_preprocessing_model.py | 46 ++++++++++++++- .../table_structure/table_structure_model.py | 4 +- .../pipeline/legacy_standard_pdf_pipeline.py | 5 ++ docling/pipeline/standard_pdf_pipeline.py | 7 ++- tests/test_page_preprocessing_model.py | 58 +++++++++++++++++++ 5 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/test_page_preprocessing_model.py diff --git a/docling/models/stages/page_preprocessing/page_preprocessing_model.py b/docling/models/stages/page_preprocessing/page_preprocessing_model.py index df1b11dd9a..c389dd1ec2 100644 --- a/docling/models/stages/page_preprocessing/page_preprocessing_model.py +++ b/docling/models/stages/page_preprocessing/page_preprocessing_model.py @@ -1,3 +1,4 @@ +import logging import re import warnings from collections.abc import Iterable @@ -5,6 +6,13 @@ from typing import Literal, Optional import numpy as np +from docling_core.types.doc import BoundingBox, CoordOrigin +from docling_core.types.doc.page import ( + BoundingRectangle, + PdfPageBoundaryType, + PdfPageGeometry, + SegmentedPdfPage, +) from PIL import ImageDraw from pydantic import BaseModel @@ -14,12 +22,15 @@ from docling.models.base_model import BasePageModel from docling.utils.profiling import TimeRecorder +_log = logging.getLogger(__name__) + class PagePreprocessingOptions(BaseModel): images_scale: Optional[float] skip_cell_extraction: bool = ( False # Skip text cell extraction for VLM-only processing ) + allow_empty_cells_on_decode_error: bool = False class PagePreprocessingModel(BasePageModel): @@ -69,7 +80,40 @@ def _populate_page_images(self, page: Page) -> Page: def _parse_page_cells(self, conv_res: ConversionResult, page: Page) -> Page: assert page._backend is not None - page.parsed_page = page._backend.get_segmented_page() + try: + page.parsed_page = page._backend.get_segmented_page() + except UnicodeDecodeError: + if not self.options.allow_empty_cells_on_decode_error: + raise + + assert page.size is not None + bbox = BoundingBox( + l=0.0, + t=0.0, + r=float(page.size.width), + b=float(page.size.height), + coord_origin=CoordOrigin.BOTTOMLEFT, + ) + page.parsed_page = SegmentedPdfPage( + dimension=PdfPageGeometry( + angle=0.0, + rect=BoundingRectangle.from_bounding_box(bbox), + boundary_type=PdfPageBoundaryType.CROP_BOX, + art_bbox=bbox, + bleed_bbox=bbox, + crop_bbox=bbox, + media_bbox=bbox, + trim_bbox=bbox, + ), + char_cells=[], + word_cells=[], + textline_cells=[], + ) + _log.warning( + "Native text decoding failed on page %s; continuing with " + "an empty text layer for full-page OCR", + page.page_no, + ) assert page.parsed_page is not None # Rate the text quality from the PDF parser, and aggregate on page diff --git a/docling/models/stages/table_structure/table_structure_model.py b/docling/models/stages/table_structure/table_structure_model.py index c7c9219b1e..2ce13d5f76 100644 --- a/docling/models/stages/table_structure/table_structure_model.py +++ b/docling/models/stages/table_structure/table_structure_model.py @@ -225,8 +225,8 @@ def predict_tables( } for table_cluster, tbl_box in in_tables: - # Check if word-level cells are available from backend: - sp = page._backend.get_segmented_page() + # Use the parsed page because OCR may have replaced backend cells. + sp = page.parsed_page if sp is not None: tcells = sp.get_cells_in_bbox( cell_unit=TextCellUnit.WORD, diff --git a/docling/pipeline/legacy_standard_pdf_pipeline.py b/docling/pipeline/legacy_standard_pdf_pipeline.py index ea44408c07..1cc731c63a 100644 --- a/docling/pipeline/legacy_standard_pdf_pipeline.py +++ b/docling/pipeline/legacy_standard_pdf_pipeline.py @@ -15,6 +15,7 @@ LayoutObjectDetectionOptions, LayoutOptions, LayoutPostprocessorOptions, + OcrMode, PdfPipelineOptions, ) from docling.datamodel.settings import settings @@ -114,6 +115,10 @@ def __init__(self, pipeline_options: PdfPipelineOptions): PagePreprocessingModel( options=PagePreprocessingOptions( images_scale=pipeline_options.images_scale, + allow_empty_cells_on_decode_error=( + pipeline_options.do_ocr + and pipeline_options.ocr_options.mode == OcrMode.FULL_PAGE + ), ) ), # OCR diff --git a/docling/pipeline/standard_pdf_pipeline.py b/docling/pipeline/standard_pdf_pipeline.py index dbc4392b1c..d07b5b2c5f 100644 --- a/docling/pipeline/standard_pdf_pipeline.py +++ b/docling/pipeline/standard_pdf_pipeline.py @@ -50,6 +50,7 @@ LayoutObjectDetectionOptions, LayoutOptions, LayoutPostprocessorOptions, + OcrMode, ThreadedPdfPipelineOptions, ) from docling.datamodel.settings import settings @@ -597,7 +598,11 @@ def _init_models(self) -> None: ) self.preprocessing_model = PagePreprocessingModel( options=PagePreprocessingOptions( - images_scale=self.pipeline_options.images_scale + images_scale=self.pipeline_options.images_scale, + allow_empty_cells_on_decode_error=( + self.pipeline_options.do_ocr + and self.pipeline_options.ocr_options.mode == OcrMode.FULL_PAGE + ), ) ) self.ocr_model = self._make_ocr_model(art_path) diff --git a/tests/test_page_preprocessing_model.py b/tests/test_page_preprocessing_model.py new file mode 100644 index 0000000000..c64e3f1546 --- /dev/null +++ b/tests/test_page_preprocessing_model.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace +from typing import cast + +import pytest +from docling_core.types.doc import Size + +from docling.backend.pdf_backend import PdfPageBackend +from docling.datamodel.base_models import Page +from docling.datamodel.document import ConversionResult +from docling.models.stages.page_preprocessing.page_preprocessing_model import ( + PagePreprocessingModel, + PagePreprocessingOptions, +) + + +class _InvalidTextBackend: + def get_segmented_page(self): + raise UnicodeDecodeError("utf-8", b"\xdf", 0, 1, "invalid start byte") + + +def _page() -> Page: + page = Page(page_no=1, size=Size(width=595, height=842)) + page._backend = cast(PdfPageBackend, _InvalidTextBackend()) + return page + + +def _conversion_result() -> ConversionResult: + return cast( + ConversionResult, + SimpleNamespace( + confidence=SimpleNamespace( + pages={1: SimpleNamespace(parse_score=None)}, + ) + ), + ) + + +def test_native_text_decode_error_falls_back_to_empty_page() -> None: + model = PagePreprocessingModel( + PagePreprocessingOptions( + images_scale=None, + allow_empty_cells_on_decode_error=True, + ) + ) + + page = model._parse_page_cells(_conversion_result(), _page()) + + assert page.parsed_page is not None + assert page.parsed_page.dimension.width == 595 + assert page.parsed_page.dimension.height == 842 + assert page.cells == [] + + +def test_native_text_decode_error_is_not_hidden_by_default() -> None: + model = PagePreprocessingModel(PagePreprocessingOptions(images_scale=None)) + + with pytest.raises(UnicodeDecodeError): + model._parse_page_cells(_conversion_result(), _page()) From 85d0352643168399f604fe635cbedfbe0ef34a94 Mon Sep 17 00:00:00 2001 From: dobi02 <88780600+dobi02@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:37:43 +0900 Subject: [PATCH 2/2] DCO Remediation Commit for dobi02 <88780600+dobi02@users.noreply.github.com> I, dobi02 <88780600+dobi02@users.noreply.github.com>, hereby add my Signed-off-by to this commit: 3666f655a48ece11f8ee154e77a85cbe4b8e22dd Signed-off-by: dobi02 <88780600+dobi02@users.noreply.github.com>