Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions docling/.agents/skills/docling/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Decision guide:
| Document | Use |
|---|---|
| Born-digital PDF (text selectable) | Standard (fast, no GPU) |
| Text looks right on the page but copies out as garbage | Standard with `--replace-broken-text-layer` |
| Scanned / image-only PDF | Standard with OCR, or `--pipeline vlm` for best quality |
| Complex/multi-column layout, dense tables | `--pipeline vlm` |
| Handwriting or formulas | `--pipeline vlm` (standard OCR won't handle these) |
Expand All @@ -96,6 +97,7 @@ docling scan.pdf --ocr-engine rapidocr --output /tmp/ # lightweight
docling scan.pdf --ocr-engine tesserocr --output /tmp/ # needs system Tesseract
docling scan.pdf --ocr-engine ocrmac --output /tmp/ # macOS Vision (mac only)
docling scan.pdf --force-ocr --output /tmp/ # re-OCR even extractable text
docling report.pdf --replace-broken-text-layer --output /tmp/ # OCR only pages whose text layer is broken
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:`
Expand Down
14 changes: 14 additions & 0 deletions docling/.agents/skills/docling/references/python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ opts = PdfPipelineOptions(do_ocr=True, ocr_options=OcrMacOptions()) # macOS

Each engine is an optional dependency — see [slim-packaging.md](slim-packaging.md).

When a PDF looks right but its text copies out as garbage (a font whose glyphs
were not mapped to real characters), let OCR replace the text of just those pages.
Off by default; such pages then report `parse_score` 0 in the confidence report:

```python
opts = PdfPipelineOptions(
do_ocr=True, ocr_options=RapidOcrOptions(replace_broken_text_layer=True)
)
```

It does not detect text that uses the right alphabet but the wrong letters, or
the right letters in the wrong order (e.g. `Cartifecita` for `Certificate`).
Such pages keep their PDF text; `mode=OcrMode.FULL_PAGE` OCRs every page.

OCR can also run on a remote KServe v2 / Triton server. Page crops are sent to
that server, so `enable_remote_services=True` is required:

Expand Down
16 changes: 16 additions & 0 deletions docling/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,16 @@ def convert( # noqa: C901
help="Which document regions are fed to the OCR engine.",
),
] = OcrMode.DEFAULT,
replace_broken_text_layer: Annotated[
bool,
typer.Option(
...,
help=(
"Replace a page's PDF text with OCR when its text layer is broken, "
"e.g. from a font whose glyphs were not mapped to real characters."
),
),
] = False,
tables: Annotated[
bool,
typer.Option(
Expand Down Expand Up @@ -1577,12 +1587,18 @@ def _resolve_pdf_backend() -> tuple[type[PdfDocumentBackend], PdfBackendOptions]
"mode": "--ocr-mode",
"lang": "--ocr-lang",
"psm": "--psm",
"replace_broken_text_layer": "--replace-broken-text-layer",
}
# `--ocr-mode` always has a value; count it as set only when it differs
# from the default, so `--ocr-option mode=...` works on its own.
explicit_ocr_fields = (
{"mode"} if force_ocr or ocr_mode != OcrMode.DEFAULT else set()
)
# Like `--ocr-mode`, count the flag as set only when it is on, so
# `--ocr-option replace_broken_text_layer=true` works on its own.
if replace_broken_text_layer:
ocr_kwargs["replace_broken_text_layer"] = True
explicit_ocr_fields.add("replace_broken_text_layer")
ocr_lang_list = _split_list(ocr_lang)
# `_split_list` returns None only when the option was not given, so an
# explicitly empty value reaches the engine as `lang=[]`: "your default".
Expand Down
3 changes: 3 additions & 0 deletions docling/datamodel/base_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,9 @@ class Page(BaseModel):
# conversion results.
_shape_lines: list[BoundingBox] | None = PrivateAttr(default=None)
_shape_bounding_boxes: list[BoundingBox] | None = PrivateAttr(default=None)
# Whether the page's PDF text layer is broken; read by the OCR stage to replace
# it (OcrOptions.replace_broken_text_layer).
_broken_text_layer: bool = PrivateAttr(default=False)
_default_image_scale: float = 1.0 # Default image scale for external usage.
_image_cache: dict[
float, Image
Expand Down
17 changes: 17 additions & 0 deletions docling/datamodel/pipeline_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,23 @@ class OcrOptions(BaseOptions):
),
] = 3.0

replace_broken_text_layer: Annotated[
bool,
Field(
description=(
"Replace a broken PDF text layer with OCR, page by page. When enabled,"
" Docling checks the visible text layer of each page. A page whose"
" text layer is broken, for example from a font whose glyphs were not"
" mapped to real characters, is read with full-page OCR, and its OCR"
" text is used instead of its PDF text for that page only. Its"
" `parse_score` drops to 0 and keeps describing the PDF text layer. A"
" low `parse_score` alone does not replace a page. Other pages, and"
" pages without a text layer, keep the usual behavior."
),
examples=[False, True],
),
] = False

model_config = ConfigDict(
validate_assignment=True,
validate_default=True,
Expand Down
77 changes: 57 additions & 20 deletions docling/models/base_ocr_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ class _MergeCellsPriority(str, Enum):
_PDF_CELLS_POST_PROCESSING_SEGREGATION = True


def _segregate_by_visibility(
def segregate_by_visibility(
cells: Iterable[TextCell],
) -> tuple[list[TextCell], list[TextCell]]:
"""Split cells into (visible, invisible), preserving the order within each group.
Expand All @@ -129,16 +129,32 @@ def _segregate_by_visibility(
visible: list[TextCell] = []
invisible: list[TextCell] = []
for cell in cells:
if (
isinstance(cell, PdfTextCell)
and cell.rendering_mode in _INVISIBLE_RENDERING_MODES
):
if is_invisible(cell):
invisible.append(cell)
else:
visible.append(cell)
return visible, invisible


def is_invisible(cell: TextCell) -> bool:
"""Whether a PDF cell is drawn with a text rendering mode that paints no ink."""
return (
isinstance(cell, PdfTextCell)
and cell.rendering_mode in _INVISIBLE_RENDERING_MODES
)


def _ocr_full_page(options: OcrOptions, page: Page) -> bool:
"""Whether a page is OCR'd in full and its PDF cells dropped.

True in `OcrMode.FULL_PAGE`, and, with `replace_broken_text_layer`, for a page
whose text layer page preprocessing found broken.
"""
if options.mode == OcrMode.FULL_PAGE:
return True
return options.replace_broken_text_layer and page._broken_text_layer


class BaseOcrModel(BasePageModel, BaseModelWithOptions):
r"""
Base class for all OCR models.
Expand Down Expand Up @@ -243,15 +259,13 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]:
# Compute the OCR rects according to the mode
ocr_rects: list[BoundingBox]

# Both DEFAULT and PDF_AWARE_LAYOUT_REGIONS make OCR input as layout detections eliminated by PDF cells
if (
self.options.mode == OcrMode.DEFAULT
or self.options.mode == OcrMode.PDF_AWARE_LAYOUT_REGIONS
):
ocr_rects = self._find_pdf_aware_layout_ocr_rects(page)
elif self.options.mode == OcrMode.LAYOUT_REGIONS:
ocr_rects = self._find_layout_ocr_rects(page)
elif self.options.mode == OcrMode.FULL_PAGE:
if _ocr_full_page(self.options, page):
if self.options.mode != OcrMode.FULL_PAGE:
_log.info(
"Page %s: the text layer is broken;"
" replacing it with full-page OCR",
page.page_no,
)
# A big bbox covering the entire page
ocr_rects = [
BoundingBox(
Expand All @@ -262,6 +276,14 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]:
coord_origin=CoordOrigin.TOPLEFT,
)
]
# Both DEFAULT and PDF_AWARE_LAYOUT_REGIONS make OCR input as layout detections eliminated by PDF cells
elif (
self.options.mode == OcrMode.DEFAULT
or self.options.mode == OcrMode.PDF_AWARE_LAYOUT_REGIONS
):
ocr_rects = self._find_pdf_aware_layout_ocr_rects(page)
elif self.options.mode == OcrMode.LAYOUT_REGIONS:
ocr_rects = self._find_layout_ocr_rects(page)
return ocr_rects

def _find_layout_ocr_rects(self, page: Page) -> list[BoundingBox]:
Expand Down Expand Up @@ -604,7 +626,10 @@ 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, or with `replace_broken_text_layer` the page's text layer is
broken (`is_broken_text_layer`):
Any existing PDF cells are ignored and only the OCR cells are used.
A page replaced for `replace_broken_text_layer` keeps its invisible 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
Expand All @@ -615,8 +640,16 @@ def post_process_cells(
existing_cells = page.cells

# Combine existing and OCR cells with overlap filtering
if self.options.mode == OcrMode.FULL_PAGE:
full_page = _ocr_full_page(self.options, page)
# A replaced broken text layer keeps its invisible cells, as in the merge
keep_invisible = full_page and self.options.mode != OcrMode.FULL_PAGE
if full_page:
final_cells = ocr_cells
if keep_invisible:
_, invisible_cells = segregate_by_visibility(existing_cells)
final_cells = self._merge_cells_by_priority(
final_cells, invisible_cells
)
else:
if priority is None:
priority = (
Expand Down Expand Up @@ -645,12 +678,16 @@ 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:
page.parsed_page.word_cells = [
c for c in page.parsed_page.word_cells if c.from_ocr
c
for c in page.parsed_page.word_cells
if c.from_ocr or (keep_invisible and is_invisible(c))
]
page.parsed_page.char_cells = [
c for c in page.parsed_page.char_cells if c.from_ocr
c
for c in page.parsed_page.char_cells
if c.from_ocr or (keep_invisible and is_invisible(c))
]
page.parsed_page.has_words = len(page.parsed_page.word_cells) > 0
page.parsed_page.has_chars = len(page.parsed_page.char_cells) > 0
Expand Down Expand Up @@ -678,7 +715,7 @@ def _merge_ocr_and_pdf_cells(
visible_cells: list[TextCell]
invisible_cells: list[TextCell]
if segregate_pdf_cells:
visible_cells, invisible_cells = _segregate_by_visibility(pdf_cells)
visible_cells, invisible_cells = segregate_by_visibility(pdf_cells)
else:
visible_cells, invisible_cells = list(pdf_cells), []

Expand Down
15 changes: 15 additions & 0 deletions docling/models/stages/ocr/auto_ocr_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ def __init__(
options=OcrMacOptions(
mode=self.options.mode,
lang=self.options.lang,
replace_broken_text_layer=(
self.options.replace_broken_text_layer
),
),
accelerator_options=accelerator_options,
)
Expand All @@ -99,6 +102,9 @@ def __init__(
options=NemotronOcrOptions(
mode=self.options.mode,
lang=self.options.lang,
replace_broken_text_layer=(
self.options.replace_broken_text_layer
),
),
accelerator_options=accelerator_options,
)
Expand Down Expand Up @@ -130,6 +136,9 @@ def __init__(
backend="onnxruntime",
mode=self.options.mode,
lang=self.options.lang,
replace_broken_text_layer=(
self.options.replace_broken_text_layer
),
),
accelerator_options=accelerator_options,
)
Expand Down Expand Up @@ -159,6 +168,9 @@ def __init__(
options=EasyOcrOptions(
mode=self.options.mode,
lang=self.options.lang,
replace_broken_text_layer=(
self.options.replace_broken_text_layer
),
),
accelerator_options=accelerator_options,
)
Expand All @@ -184,6 +196,9 @@ def __init__(
backend="torch",
mode=self.options.mode,
lang=self.options.lang,
replace_broken_text_layer=(
self.options.replace_broken_text_layer
),
),
accelerator_options=accelerator_options,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
from docling.datamodel.pipeline_options import OcrMode, PdfPipelineOptions
from docling.datamodel.settings import settings
from docling.models.base_model import BasePageModel
from docling.models.base_ocr_model import segregate_by_visibility
from docling.utils.profiling import TimeRecorder
from docling.utils.text_quality import is_broken_text_layer


def resolve_skip_cell_extraction(pipeline_options: PdfPipelineOptions) -> bool:
Expand All @@ -42,6 +44,8 @@ class PagePreprocessingOptions(BaseModel):
False # Skip text cell extraction for VLM-only processing
)
capture_reading_order_separators: bool = False
# Score a broken text layer as poor, for OcrOptions.replace_broken_text_layer.
detect_broken_text_layer: bool = False


class PagePreprocessingModel(BasePageModel):
Expand Down Expand Up @@ -114,12 +118,23 @@ def _parse_page_cells(self, conv_res: ConversionResult, page: Page) -> Page:
warnings.filterwarnings(
"ignore", "Mean of empty slice", RuntimeWarning, "numpy"
)
conv_res.confidence.pages[page.page_no].parse_score = float(
parse_score = float(
np.nanquantile(
text_scores, q=0.10
) # To emphasise problems in the parse_score, we take the 10% percentile score of all text cells.
)

# Some broken text layers only show across the whole page, such as an
# unmapped font encoding. Judged on visible text, only when the OCR may
# replace the layer. Only this check replaces a page: a low per-cell score
# also comes from a few unmapped glyphs, such as bullets, on a good page.
if self.options.detect_broken_text_layer and not np.isnan(parse_score):
visible_cells, _ = segregate_by_visibility(page.cells)
if is_broken_text_layer("\n".join(c.text for c in visible_cells)):
page._broken_text_layer = True
parse_score = 0.0
conv_res.confidence.pages[page.page_no].parse_score = parse_score

def draw_cell_boxes(
image: Image,
text_cells: Iterable[TextCell],
Expand Down
4 changes: 4 additions & 0 deletions docling/pipeline/standard_pdf_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,10 @@ def _init_models(self) -> None:
capture_reading_order_separators=(
self.pipeline_options.use_reading_order_separators
),
detect_broken_text_layer=(
self.pipeline_options.do_ocr
and self.pipeline_options.ocr_options.replace_broken_text_layer
),
)
)
self.ocr_model = self._make_ocr_model(art_path)
Expand Down
Loading
Loading