From cb2853c283ab148c050dac2463013908b778d352 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:42:48 +0530 Subject: [PATCH 1/4] fix(ocr): restore OCR of scanned pages only with page_bitmap_area_threshold Since #3710, `bitmap_area_threshold` raises a ValidationError, and no OcrMode can skip OCR on pages that carry only small bitmaps. Add `OcrOptions.page_bitmap_area_threshold`: a page is OCRed only when bitmaps cover at least that fraction of it. The pages below it keep their native text, also in FULL_PAGE mode. Accept `bitmap_area_threshold` as a deprecated alias, and pass the field on from OcrAutoOptions. Resolves #4834 Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- .../.agents/skills/docling/references/cli.md | 1 + docling/datamodel/pipeline_options.py | 38 ++++- docling/models/base_ocr_model.py | 39 ++++- docling/models/stages/ocr/auto_ocr_model.py | 5 + .../page_preprocessing_model.py | 10 +- docs/concepts/OCR.md | 24 ++++ tests/test_ocr_page_bitmap_area_threshold.py | 134 ++++++++++++++++++ tests/test_skip_cell_extraction.py | 5 +- 8 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 tests/test_ocr_page_bitmap_area_threshold.py diff --git a/docling/.agents/skills/docling/references/cli.md b/docling/.agents/skills/docling/references/cli.md index 5408deab20..86ea4c8c11 100644 --- a/docling/.agents/skills/docling/references/cli.md +++ b/docling/.agents/skills/docling/references/cli.md @@ -111,6 +111,7 @@ docling report.pdf --no-ocr --output /tmp/ # skip OCR (faster) docling scan.pdf --ocr-lang eng,deu --output /tmp/ # the engine's own codes docling scan.pdf --ocr-lang iso:en,iso:de --output /tmp/ # BCP-47 tags behind `iso:` docling scan.pdf --ocr-engine rapidocr --ocr-option model_size=tiny --output /tmp/ # engine-specific field +docling mixed.pdf --ocr-mode full_page --ocr-option page_bitmap_area_threshold=0.97 --output /tmp/ # OCR only scanned pages ``` `--ocr-option KEY=VALUE` (repeatable) sets any field of the selected OCR engine. diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index 910b11cca4..b9509d3de1 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -198,7 +198,7 @@ class OcrOptions(BaseOptions): Defines the common interface shared by all OCR engine implementations. Subclasses provide engine-specific parameters while inheriting the shared - language selection, full-page OCR toggle, and bitmap area threshold. + language selection, OCR mode, and page bitmap area threshold. See Also: `OcrAutoOptions`: Automatic engine selection based on availability. @@ -260,6 +260,22 @@ class OcrOptions(BaseOptions): ), ] = 3.0 + page_bitmap_area_threshold: Annotated[ + float | None, + Field( + description=( + "Minimum fraction of the page area (0.0-1.0) that bitmaps must cover " + "for the page to be processed with OCR. Pages below it skip OCR, " + "whatever the `mode`. With `mode=OcrMode.FULL_PAGE`, a high value " + "such as 0.97 OCRs only scanned pages and skips programmatic pages " + "that only carry logos or photos. `None` processes every page." + ), + examples=[None, 0.97], + ge=0.0, + le=1.0, + ), + ] = None + model_config = ConfigDict( validate_assignment=True, validate_default=True, @@ -293,6 +309,26 @@ def _accept_force_full_page_ocr(cls, data: Any) -> Any: data["mode"] = OcrMode.FULL_PAGE return data + @model_validator(mode="before") + @classmethod + def _accept_bitmap_area_threshold(cls, data: Any) -> Any: + r""" + Accept the removed `bitmap_area_threshold` keyword as the page-level + gate it is closest to, so configurations written before `OcrMode` keep + skipping OCR on pages with little bitmap content instead of failing. + """ + if not isinstance(data, dict) or "bitmap_area_threshold" not in data: + return data + warnings.warn( + "`bitmap_area_threshold` is deprecated; set " + "`page_bitmap_area_threshold` instead.", + DeprecationWarning, + stacklevel=2, + ) + threshold = data.pop("bitmap_area_threshold") + data.setdefault("page_bitmap_area_threshold", threshold) + return data + # Deprecated: superseded by `OcrMode.FULL_PAGE`. Kept for backwards # compatibility as a view over `mode`, so the two can never drift apart. @computed_field( # type: ignore[prop-decorator] diff --git a/docling/models/base_ocr_model.py b/docling/models/base_ocr_model.py index 640be66351..edf407839e 100644 --- a/docling/models/base_ocr_model.py +++ b/docling/models/base_ocr_model.py @@ -240,6 +240,9 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]: """ assert page.size is not None + if not self._has_enough_bitmap_area(page): + return [] + # Compute the OCR rects according to the mode ocr_rects: list[BoundingBox] @@ -264,6 +267,33 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]: ] return ocr_rects + def _has_enough_bitmap_area(self, page: Page) -> bool: + r""" + Whether bitmaps cover at least `page_bitmap_area_threshold` of the page. + + Without a threshold every page qualifies. So does a page without a + backend: its bitmaps cannot be measured, and skipping OCR on it could + lose its only text. + """ + threshold = self.options.page_bitmap_area_threshold + if threshold is None or page._backend is None: + return True + assert page.size is not None + + coverage, _ = self._deduplicate_rects( + page.size, page._backend.get_bitmap_rects() + ) + if coverage < threshold: + _log.debug( + "Page %s: bitmaps cover %.3f of the page, below " + "page_bitmap_area_threshold=%.3f; skipping OCR.", + page.page_no, + coverage, + threshold, + ) + return False + return True + def _find_layout_ocr_rects(self, page: Page) -> list[BoundingBox]: r""" 1. Collect the bboxes of all layout clusters. @@ -604,7 +634,8 @@ def post_process_cells( r""" Post-process the OCR cells and update the page object according to the algorithm: - - If FULL_PAGE: Any existing PDF cells are ignored and only the OCR cells are used. + - If FULL_PAGE: Any existing PDF cells are ignored and only the OCR cells are used, + unless `page_bitmap_area_threshold` skipped OCR on the page: it keeps its PDF cells. - If LAYOUT_REGIONS or PDF_AWARE_LAYOUT_REGIONS and the priority parameter is None, the priority is auto-selected based on the OcrMode: - OCR_FIRST when LAYOUT_REGIONS @@ -616,7 +647,11 @@ def post_process_cells( # Combine existing and OCR cells with overlap filtering if self.options.mode == OcrMode.FULL_PAGE: - final_cells = ocr_cells + # A page that `page_bitmap_area_threshold` kept from OCR has no + # OCR cells to replace its PDF cells with + final_cells = ( + ocr_cells if self._has_enough_bitmap_area(page) else existing_cells + ) else: if priority is None: priority = ( diff --git a/docling/models/stages/ocr/auto_ocr_model.py b/docling/models/stages/ocr/auto_ocr_model.py index 3ff1151f5c..b10c3e6f85 100644 --- a/docling/models/stages/ocr/auto_ocr_model.py +++ b/docling/models/stages/ocr/auto_ocr_model.py @@ -74,6 +74,7 @@ def __init__( options=OcrMacOptions( mode=self.options.mode, lang=self.options.lang, + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -99,6 +100,7 @@ def __init__( options=NemotronOcrOptions( mode=self.options.mode, lang=self.options.lang, + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -130,6 +132,7 @@ def __init__( backend="onnxruntime", mode=self.options.mode, lang=self.options.lang, + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -159,6 +162,7 @@ def __init__( options=EasyOcrOptions( mode=self.options.mode, lang=self.options.lang, + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -184,6 +188,7 @@ def __init__( backend="torch", mode=self.options.mode, lang=self.options.lang, + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) diff --git a/docling/models/stages/page_preprocessing/page_preprocessing_model.py b/docling/models/stages/page_preprocessing/page_preprocessing_model.py index 26a188fb7e..3e0e128869 100644 --- a/docling/models/stages/page_preprocessing/page_preprocessing_model.py +++ b/docling/models/stages/page_preprocessing/page_preprocessing_model.py @@ -30,9 +30,15 @@ def resolve_skip_cell_extraction(pipeline_options: PdfPipelineOptions) -> bool: vector-dense pages (CAD/wiring schematics drawn as 100k+ path segments) that decode costs multiple GiB regardless of cell content levels. See https://github.com/docling-project/docling/issues/4058. + + A `page_bitmap_area_threshold` keeps the decode: the pages below it skip + OCR, so their native text cells are the only text they have. """ - return bool(pipeline_options.do_ocr) and ( - pipeline_options.ocr_options.mode == OcrMode.FULL_PAGE + ocr_options = pipeline_options.ocr_options + return ( + bool(pipeline_options.do_ocr) + and ocr_options.mode == OcrMode.FULL_PAGE + and ocr_options.page_bitmap_area_threshold is None ) diff --git a/docs/concepts/OCR.md b/docs/concepts/OCR.md index 8ddcdfad60..15badb2a49 100644 --- a/docs/concepts/OCR.md +++ b/docs/concepts/OCR.md @@ -151,6 +151,30 @@ Engines that run one language at a time (RapidOCR, Nemotron-OCR) take the **firs about the rest. The KServe client also sends only the first entry. +## OCR only scanned pages + +`OcrOptions.page_bitmap_area_threshold` OCRs a page only when bitmaps cover at least that fraction +of it (0.0-1.0). The pages below it skip OCR and keep their native text, whatever the `mode`. The +default `None` processes every page. + +With `mode=OcrMode.FULL_PAGE` and a high value, a mixed document gets full-page OCR on its scanned +pages, while its programmatic pages, including the ones that carry logos or photos, are read from +the PDF text layer: + +```python +from docling.datamodel.pipeline_options import OcrMode, TesseractCliOcrOptions + +options = TesseractCliOcrOptions(mode=OcrMode.FULL_PAGE, page_bitmap_area_threshold=0.97) +``` + +```sh +docling convert --ocr-mode full_page --ocr-option page_bitmap_area_threshold=0.97 document.pdf +``` + +`bitmap_area_threshold`, the field this replaces, is still accepted with a `DeprecationWarning` and +sets `page_bitmap_area_threshold`. It does not set the `mode`: to OCR the scanned pages in full, as +it did before `OcrMode`, also set `mode=OcrMode.FULL_PAGE`. + ## RapidOCR The engine's own vocabulary, in its own codes, is listed in diff --git a/tests/test_ocr_page_bitmap_area_threshold.py b/tests/test_ocr_page_bitmap_area_threshold.py new file mode 100644 index 0000000000..6ad8d8f9df --- /dev/null +++ b/tests/test_ocr_page_bitmap_area_threshold.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""`page_bitmap_area_threshold` OCRs only the pages that bitmaps mostly cover. + +It restores the "OCR only scanned pages" use case of the `bitmap_area_threshold` +removed with the introduction of `OcrMode` (#4834). A page below the threshold +skips OCR, so it must keep its native text, also in `FULL_PAGE` mode, where OCR +cells normally replace the native cells and their extraction is skipped. +""" + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace + +import pytest +from docling_core.types.doc import BoundingBox + +from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend +from docling.datamodel.accelerator_options import AcceleratorOptions +from docling.datamodel.base_models import ConfidenceReport, InputFormat, Page +from docling.datamodel.document import ConversionResult, InputDocument +from docling.datamodel.pipeline_options import ( + EasyOcrOptions, + OcrMode, + OcrOptions, + PdfPipelineOptions, + RapidOcrOptions, + TesseractOcrOptions, +) +from docling.models.base_ocr_model import BaseOcrModel +from docling.models.stages.page_preprocessing.page_preprocessing_model import ( + resolve_skip_cell_extraction, +) + +OCR_OPTION_CLASSES = [EasyOcrOptions, RapidOcrOptions, TesseractOcrOptions] + +# First pages: a scan (bitmaps cover all of it) and a programmatic page whose +# pictures cover about 15% of it. +SCANNED_PDF = Path("tests/data/ocr/sources/ocr_test.pdf") +PROGRAMMATIC_PDF = Path("tests/data/pdf/sources/picture_classification.pdf") + + +class _OcrRectsOnlyModel(BaseOcrModel): + """Minimal concrete `BaseOcrModel`: only the page selection is under test.""" + + def __call__( + self, conv_res: ConversionResult, page_batch: Iterable[Page] + ) -> Iterable[Page]: + raise NotImplementedError + + @classmethod + def get_options_type(cls) -> type[OcrOptions]: + return OcrOptions + + +def _model(mode: OcrMode, threshold: float | None) -> _OcrRectsOnlyModel: + return _OcrRectsOnlyModel( + enabled=True, + artifacts_path=None, + options=OcrOptions( + kind="test", lang=["en"], mode=mode, page_bitmap_area_threshold=threshold + ), + accelerator_options=AcceleratorOptions(), + ) + + +def _first_page(pdf: Path) -> Page: + in_doc = InputDocument( + path_or_stream=pdf, format=InputFormat.PDF, backend=PyPdfiumDocumentBackend + ) + page = Page(page_no=1) + page._backend = in_doc._backend.load_page(0) + page.size = page._backend.get_size() + page.parsed_page = page._backend.get_segmented_page() + return page + + +def _ocr_rects(pdf: Path, threshold: float | None) -> list[BoundingBox]: + return _model(OcrMode.FULL_PAGE, threshold).get_ocr_rects(_first_page(pdf)) + + +@pytest.mark.parametrize("options_cls", OCR_OPTION_CLASSES) +def test_removed_bitmap_area_threshold_is_accepted_as_deprecated(options_cls): + """Configurations written before `OcrMode` must not fail validation.""" + with pytest.deprecated_call(match="page_bitmap_area_threshold"): + options = options_cls(bitmap_area_threshold=0.97) + + assert options.page_bitmap_area_threshold == 0.97 + + +def test_explicit_threshold_wins_over_the_deprecated_one(): + with pytest.deprecated_call(): + options = EasyOcrOptions( + bitmap_area_threshold=0.05, page_bitmap_area_threshold=0.97 + ) + + assert options.page_bitmap_area_threshold == 0.97 + + +def test_scanned_page_is_ocred_above_the_threshold(): + assert len(_ocr_rects(SCANNED_PDF, threshold=0.97)) == 1 + + +def test_programmatic_page_skips_ocr_below_the_threshold(): + assert _ocr_rects(PROGRAMMATIC_PDF, threshold=0.97) == [] + + +def test_without_threshold_every_page_is_ocred(): + assert len(_ocr_rects(PROGRAMMATIC_PDF, threshold=None)) == 1 + + +def test_full_page_ocr_keeps_the_native_text_of_a_skipped_page(): + """With no OCR cells, a skipped page would otherwise lose all its text.""" + page = _first_page(PROGRAMMATIC_PDF) + native_text = [cell.text for cell in page.cells] + assert native_text + + model = _model(OcrMode.FULL_PAGE, threshold=0.97) + model.post_process_cells([], page, SimpleNamespace(confidence=ConfidenceReport())) + + assert [cell.text for cell in page.cells] == native_text + + +def test_full_page_ocr_with_threshold_extracts_the_native_text(): + """The pages below the threshold need the native cells that FULL_PAGE skips.""" + options = PdfPipelineOptions( + do_ocr=True, + ocr_options=EasyOcrOptions( + mode=OcrMode.FULL_PAGE, page_bitmap_area_threshold=0.97 + ), + ) + + assert resolve_skip_cell_extraction(options) is False diff --git a/tests/test_skip_cell_extraction.py b/tests/test_skip_cell_extraction.py index eed0fdbf5c..28cb8ad8d6 100644 --- a/tests/test_skip_cell_extraction.py +++ b/tests/test_skip_cell_extraction.py @@ -65,7 +65,10 @@ def test_post_process_cells_tolerates_missing_parsed_page() -> None: assert page.cells == [] cell = _ocr_cell("part 42-A", confidence=0.9) - ocr_model = SimpleNamespace(options=SimpleNamespace(mode=OcrMode.FULL_PAGE)) + ocr_model = SimpleNamespace( + options=SimpleNamespace(mode=OcrMode.FULL_PAGE), + _has_enough_bitmap_area=lambda page: True, + ) conv_res = SimpleNamespace(confidence=ConfidenceReport()) BaseOcrModel.post_process_cells(ocr_model, [cell], page, conv_res) From 07260b0df94fd51c9a8b1019b699dc3d1888f647 Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:02:17 +0530 Subject: [PATCH 2/4] fix(ocr): keep native word and char cells of pages skipped by the threshold In FULL_PAGE mode, post_process_cells removed every word and char cell that is not from OCR, also on a page that page_bitmap_area_threshold kept from OCR. Filter them only when full-page OCR ran on the page. Test with the default docling-parse backend, which gives word cells. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- docling/models/base_ocr_model.py | 19 +++++++++------ tests/test_ocr_page_bitmap_area_threshold.py | 25 +++++++++++++++----- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docling/models/base_ocr_model.py b/docling/models/base_ocr_model.py index edf407839e..4841c6530f 100644 --- a/docling/models/base_ocr_model.py +++ b/docling/models/base_ocr_model.py @@ -645,13 +645,18 @@ def post_process_cells( # Get existing cells from the read-only property existing_cells = page.cells + # A page that `page_bitmap_area_threshold` kept from OCR has no OCR + # cells to replace its PDF cells with, also in FULL_PAGE mode + full_page_ocr = ( + self.options.mode == OcrMode.FULL_PAGE + and self._has_enough_bitmap_area(page) + ) + # Combine existing and OCR cells with overlap filtering - if self.options.mode == OcrMode.FULL_PAGE: - # A page that `page_bitmap_area_threshold` kept from OCR has no - # OCR cells to replace its PDF cells with - final_cells = ( - ocr_cells if self._has_enough_bitmap_area(page) else existing_cells - ) + if full_page_ocr: + final_cells = ocr_cells + elif self.options.mode == OcrMode.FULL_PAGE: + final_cells = existing_cells else: if priority is None: priority = ( @@ -680,7 +685,7 @@ def post_process_cells( page.parsed_page.has_lines = len(final_cells) > 0 # In OcrMode.FULL_PAGE, PDF-extracted word/char cells are unreliable. Keep only OCR cells - if self.options.mode == OcrMode.FULL_PAGE: + if full_page_ocr: page.parsed_page.word_cells = [ c for c in page.parsed_page.word_cells if c.from_ocr ] diff --git a/tests/test_ocr_page_bitmap_area_threshold.py b/tests/test_ocr_page_bitmap_area_threshold.py index 6ad8d8f9df..fbda022033 100644 --- a/tests/test_ocr_page_bitmap_area_threshold.py +++ b/tests/test_ocr_page_bitmap_area_threshold.py @@ -5,8 +5,9 @@ It restores the "OCR only scanned pages" use case of the `bitmap_area_threshold` removed with the introduction of `OcrMode` (#4834). A page below the threshold -skips OCR, so it must keep its native text, also in `FULL_PAGE` mode, where OCR -cells normally replace the native cells and their extraction is skipped. +skips OCR, so it must keep its native text, line, word and character cells, also +in `FULL_PAGE` mode, where OCR cells normally replace the native cells and their +extraction is skipped. """ from collections.abc import Iterable @@ -16,7 +17,7 @@ import pytest from docling_core.types.doc import BoundingBox -from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend +from docling.backend.docling_parse_backend import ThreadedDoclingParseDocumentBackend from docling.datamodel.accelerator_options import AcceleratorOptions from docling.datamodel.base_models import ConfidenceReport, InputFormat, Page from docling.datamodel.document import ConversionResult, InputDocument @@ -28,6 +29,7 @@ RapidOcrOptions, TesseractOcrOptions, ) +from docling.datamodel.settings import DocumentLimits from docling.models.base_ocr_model import BaseOcrModel from docling.models.stages.page_preprocessing.page_preprocessing_model import ( resolve_skip_cell_extraction, @@ -66,11 +68,17 @@ def _model(mode: OcrMode, threshold: float | None) -> _OcrRectsOnlyModel: def _first_page(pdf: Path) -> Page: + """The first page, parsed by the default backend, with its word cells.""" in_doc = InputDocument( - path_or_stream=pdf, format=InputFormat.PDF, backend=PyPdfiumDocumentBackend + path_or_stream=pdf, + format=InputFormat.PDF, + backend=ThreadedDoclingParseDocumentBackend, + limits=DocumentLimits(page_range=(1, 1)), ) + doc_backend = in_doc._backend page = Page(page_no=1) - page._backend = in_doc._backend.load_page(0) + page._backend = next(doc_backend.iter_pages()) + doc_backend.unload() page.size = page._backend.get_size() page.parsed_page = page._backend.get_segmented_page() return page @@ -113,13 +121,18 @@ def test_without_threshold_every_page_is_ocred(): def test_full_page_ocr_keeps_the_native_text_of_a_skipped_page(): """With no OCR cells, a skipped page would otherwise lose all its text.""" page = _first_page(PROGRAMMATIC_PDF) + assert page.parsed_page is not None native_text = [cell.text for cell in page.cells] - assert native_text + native_words = [cell.text for cell in page.parsed_page.word_cells] + native_chars = [cell.text for cell in page.parsed_page.char_cells] + assert native_text and native_words model = _model(OcrMode.FULL_PAGE, threshold=0.97) model.post_process_cells([], page, SimpleNamespace(confidence=ConfidenceReport())) assert [cell.text for cell in page.cells] == native_text + assert [cell.text for cell in page.parsed_page.word_cells] == native_words + assert [cell.text for cell in page.parsed_page.char_cells] == native_chars def test_full_page_ocr_with_threshold_extracts_the_native_text(): From 654ce1a67c034f31a6afb94a4db08101cff3c10b Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:12:55 +0530 Subject: [PATCH 3/4] fix(ocr): accept bitmap_area_threshold on assignment and show its warning Code written before OcrMode also set bitmap_area_threshold on a built options object, which still raised a ValidationError. Add a deprecated property that sets page_bitmap_area_threshold. It stays out of dumps. Point the deprecation warning at the caller. Python hides a DeprecationWarning that points to library code, so users did not see it. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- docling/datamodel/pipeline_options.py | 32 +++++++++++++++---- docling/models/base_ocr_model.py | 8 ++--- .../page_preprocessing_model.py | 3 +- tests/test_ocr_page_bitmap_area_threshold.py | 25 +++++++++++++++ 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index b9509d3de1..e321fa60b3 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -193,6 +193,11 @@ class GraniteVisionTableStructureOptions(BaseTableStructureOptions): kind: ClassVar[str] = "granite_vision_table" +_BITMAP_AREA_THRESHOLD_DEPRECATION = ( + "`bitmap_area_threshold` is deprecated; set `page_bitmap_area_threshold` instead." +) + + class OcrOptions(BaseOptions): """Base configuration for Optical Character Recognition engines. @@ -313,17 +318,13 @@ def _accept_force_full_page_ocr(cls, data: Any) -> Any: @classmethod def _accept_bitmap_area_threshold(cls, data: Any) -> Any: r""" - Accept the removed `bitmap_area_threshold` keyword as the page-level - gate it is closest to, so configurations written before `OcrMode` keep - skipping OCR on pages with little bitmap content instead of failing. + Accept the deprecated `bitmap_area_threshold` constructor keyword and + translate it into `page_bitmap_area_threshold`. """ if not isinstance(data, dict) or "bitmap_area_threshold" not in data: return data warnings.warn( - "`bitmap_area_threshold` is deprecated; set " - "`page_bitmap_area_threshold` instead.", - DeprecationWarning, - stacklevel=2, + _BITMAP_AREA_THRESHOLD_DEPRECATION, DeprecationWarning, stacklevel=3 ) threshold = data.pop("bitmap_area_threshold") data.setdefault("page_bitmap_area_threshold", threshold) @@ -347,6 +348,23 @@ def force_full_page_ocr(self, value: bool) -> None: if value: self.mode = OcrMode.FULL_PAGE + # Deprecated: superseded by `page_bitmap_area_threshold`. A plain property, + # not a computed field, so it stays out of dumps. + @property + def bitmap_area_threshold(self) -> float | None: + warnings.warn( + _BITMAP_AREA_THRESHOLD_DEPRECATION, DeprecationWarning, stacklevel=2 + ) + return self.page_bitmap_area_threshold + + @bitmap_area_threshold.setter + def bitmap_area_threshold(self, value: float | None) -> None: + # stacklevel=4: pydantic calls the setter through two frames + warnings.warn( + _BITMAP_AREA_THRESHOLD_DEPRECATION, DeprecationWarning, stacklevel=4 + ) + self.page_bitmap_area_threshold = value + class OcrAutoOptions(OcrOptions): """Automatic OCR engine selection based on system availability. diff --git a/docling/models/base_ocr_model.py b/docling/models/base_ocr_model.py index 4841c6530f..e4ad213635 100644 --- a/docling/models/base_ocr_model.py +++ b/docling/models/base_ocr_model.py @@ -270,10 +270,7 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]: def _has_enough_bitmap_area(self, page: Page) -> bool: r""" Whether bitmaps cover at least `page_bitmap_area_threshold` of the page. - - Without a threshold every page qualifies. So does a page without a - backend: its bitmaps cannot be measured, and skipping OCR on it could - lose its only text. + True without a threshold, or without a backend to measure the bitmaps. """ threshold = self.options.page_bitmap_area_threshold if threshold is None or page._backend is None: @@ -645,8 +642,7 @@ def post_process_cells( # Get existing cells from the read-only property existing_cells = page.cells - # A page that `page_bitmap_area_threshold` kept from OCR has no OCR - # cells to replace its PDF cells with, also in FULL_PAGE mode + # False on a FULL_PAGE page that `page_bitmap_area_threshold` kept from OCR full_page_ocr = ( self.options.mode == OcrMode.FULL_PAGE and self._has_enough_bitmap_area(page) diff --git a/docling/models/stages/page_preprocessing/page_preprocessing_model.py b/docling/models/stages/page_preprocessing/page_preprocessing_model.py index 3e0e128869..f122ebe0a2 100644 --- a/docling/models/stages/page_preprocessing/page_preprocessing_model.py +++ b/docling/models/stages/page_preprocessing/page_preprocessing_model.py @@ -31,8 +31,7 @@ def resolve_skip_cell_extraction(pipeline_options: PdfPipelineOptions) -> bool: that decode costs multiple GiB regardless of cell content levels. See https://github.com/docling-project/docling/issues/4058. - A `page_bitmap_area_threshold` keeps the decode: the pages below it skip - OCR, so their native text cells are the only text they have. + A `page_bitmap_area_threshold` keeps the decode: the pages below it skip OCR. """ ocr_options = pipeline_options.ocr_options return ( diff --git a/tests/test_ocr_page_bitmap_area_threshold.py b/tests/test_ocr_page_bitmap_area_threshold.py index fbda022033..de14dd7d4d 100644 --- a/tests/test_ocr_page_bitmap_area_threshold.py +++ b/tests/test_ocr_page_bitmap_area_threshold.py @@ -97,6 +97,31 @@ def test_removed_bitmap_area_threshold_is_accepted_as_deprecated(options_cls): assert options.page_bitmap_area_threshold == 0.97 +@pytest.mark.parametrize("options_cls", OCR_OPTION_CLASSES) +def test_removed_bitmap_area_threshold_is_accepted_on_assignment(options_cls): + """Code written before `OcrMode` also set the field on a built object.""" + options = options_cls() + + with pytest.deprecated_call(match="page_bitmap_area_threshold"): + options.bitmap_area_threshold = 0.97 + + assert options.page_bitmap_area_threshold == 0.97 + assert "bitmap_area_threshold" not in options.model_dump() + + +def test_deprecation_warning_points_to_the_caller(): + """Python hides a DeprecationWarning that points to library code.""" + options = EasyOcrOptions() + + with pytest.deprecated_call() as constructor_warnings: + EasyOcrOptions(bitmap_area_threshold=0.97) + with pytest.deprecated_call() as assignment_warnings: + options.bitmap_area_threshold = 0.97 + + for caught in (constructor_warnings, assignment_warnings): + assert [w.filename for w in caught] == [__file__] + + def test_explicit_threshold_wins_over_the_deprecated_one(): with pytest.deprecated_call(): options = EasyOcrOptions( From dc3c8309c65961a9388a5f4ea8dbbbb914311dbf Mon Sep 17 00:00:00 2001 From: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:24:17 +0530 Subject: [PATCH 4/4] fix(cli): wrap long option types in the docling options table The page_bitmap_area_threshold key widens the Key column, which cannot wrap, so at 80 columns the Type column cut enum choices such as [tiny|small|medium]. Fold long values instead, and give the test a wide console: the rich console reads its width at import, so terminal_width did not apply. Signed-off-by: Yashika Malhotra <106444881+yashikam19@users.noreply.github.com> --- docling/cli/main.py | 2 +- tests/test_cli.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docling/cli/main.py b/docling/cli/main.py index ca835aabf2..db2899981a 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -2216,7 +2216,7 @@ def show_engine_options( raise table = rich.table.Table(title=f"Options of the {stage.value} engine {engine!r}") table.add_column("Key", style="bold", no_wrap=True) - table.add_column("Type") + table.add_column("Type", overflow="fold") table.add_column("Default") table.add_column("Description") for row in describe_options_class(options_cls): diff --git a/tests/test_cli.py b/tests/test_cli.py index 4d4e58f81c..15780d61a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,6 +21,7 @@ from typer.main import get_command from typer.testing import CliRunner +import docling.cli.main as cli_main from docling.cli.export_utils import ( _parse_page_range, _should_generate_export_images, @@ -1743,7 +1744,9 @@ def test_cli_deprecated_psm_still_sets_tesseract_psm(tmp_path, monkeypatch): assert ocr_options.psm == 4 -def test_cli_options_lists_engine_fields(): +def test_cli_options_lists_engine_fields(monkeypatch): + # The rich console reads its width at import, so terminal_width does not reach it + monkeypatch.setattr(cli_main.console, "width", 200) result = runner.invoke(app, ["options", "ocr", "rapidocr"], terminal_width=200) assert result.exit_code == 0, result.output assert "model_size" in result.output