Back to blog

Docling OCR: Scanned PDFs to Markdown and Missing-Text Fixes

Convert scanned PDFs to Markdown with Docling OCR. Tested Python code, a sample PDF and real output, plus fixes for missing text and language settings.

Docling enables OCR by default, but that does not mean it reads every part of every page. When text goes missing, check which regions it processed, which engine ran, and which language that engine used.

This guide is pinned to Docling 2.126.0. It covers why enabled OCR can still miss text, a tested script you can download, the exact output it produced, and a checklist for incomplete results.

Why OCR is on and text is still missing

PdfPipelineOptions.do_ocr defaults to True, and ocr_options defaults to OcrAutoOptions() (source). Three details behind those defaults are useful when diagnosing missing text.

The default mode doesn’t OCR the whole page. OcrMode.DEFAULT currently runs PDF_AWARE_LAYOUT_REGIONS. The layout model proposes regions first. A region that overlaps only embedded PDF text is skipped. A region with no text, or one that overlaps an image or vector shape, is sent to OCR. Where OCR and embedded text overlap, the embedded text wins (source). That makes sense for digital PDFs, but it leads to two failures:

  • If the PDF already has a wrong text layer, such as broken font encoding or a poor OCR layer from scanner software, Docling keeps that text instead of re-reading the page.
  • If the layout model misses a region, nothing sends that region to OCR.

Auto selection depends on what’s installed. In 2.126.0, OcrAutoOptions picks the first available engine from this list: macOS Vision (only if ocrmac is installed, on macOS), then Nemotron (only on Linux with a compatible CUDA runtime), then RapidOCR on ONNX Runtime, EasyOCR, and RapidOCR on Torch (source). Tesseract is never chosen automatically, even though the class docstring still says it is. The same code can run a different engine on your laptop and in CI.

Auto doesn’t let you set a language. It uses the chosen engine’s default language. RapidOCR’s default is chinese. To control the language, pick an engine explicitly.

Install

Docling needs Python 3.10 or newer. Pin the version and install the rapidocr extra:

python3 -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install "docling[rapidocr]==2.126.0"

Plain docling already includes RapidOCR, but not ONNX Runtime, which RapidOCR’s default backend needs. The rapidocr extra adds it (package metadata). Without it, auto selection falls through to other engines, as described above.

A tested scanned-PDF example

The fixture is a synthetic one-page order: a single 216 DPI raster image with no embedded text and no real customer data.

curl -O https://parsebridge.com/downloads/docling/ocr/scanned-order.pdf
curl -O https://parsebridge.com/downloads/docling/ocr/convert_scanned.py
python convert_scanned.py

This is the exact script we ran:

from pathlib import Path

from docling.datamodel.accelerator_options import AcceleratorDevice, AcceleratorOptions
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import OcrMode, PdfPipelineOptions, RapidOcrOptions
from docling.document_converter import DocumentConverter, PdfFormatOption

options = PdfPipelineOptions(
    do_ocr=True,
    do_table_structure=True,
    ocr_options=RapidOcrOptions(
        backend="onnxruntime",
        lang=["en"],
        mode=OcrMode.FULL_PAGE,
    ),
    accelerator_options=AcceleratorOptions(device=AcceleratorDevice.CPU, num_threads=4),
)
converter = DocumentConverter(
    format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=options)}
)
result = converter.convert("scanned-order.pdf")
markdown = result.document.export_to_markdown()
Path("scanned-order.md").write_text(markdown, encoding="utf-8")
print(markdown)

The script explicitly chooses the engine, backend, language, mode, and device. On this fixture, our control run with OcrMode.DEFAULT also recovered the order number and total; disabling OCR returned empty Markdown. FULL_PAGE demonstrates the override for cases where the default misses regions or trusts a bad text layer. This clean scan did not require it. CPU with four threads avoids depending on an available GPU.

What came out

This is the complete output. We didn’t correct anything:

## SAMPLE ORDER

Synthetic OCR test fixture - no real customer data

Order number: PB-1042

Order date: 2026-09-11

## Ship to

Example Research Lab 42 Sample Street Zurich, Switzerland

| Item            |   Quantity | Amount    |
|-----------------|------------|-----------|
| Archive folders |          3 | CHF 18.00 |
| Storage boxes   |          2 | CHF 24.00 |

Total: CHF 42.00

## Delivery note

Leave the package at reception.

This PDF contains one raster image and no embedded text.

The order number, date, both item rows, quantities, amounts, and total match the page. The bordered table became a real Markdown table because do_table_structure=True rebuilds rows and cells from the recognized text. The three address lines merged into one paragraph, so the output is not layout-identical. If downstream code expects one address line per row, handle that separately.

This fixture is clean: sharp synthetic text, English only, no skew, no noise, and no handwriting. It shows that the pipeline works, not how accurate Docling is on your documents. Test with your worst real scans before relying on the result.

Also note that the output is Markdown. Docling doesn’t add a text layer to the input PDF, so if you need a searchable PDF, use a different tool for that step.

Test notes: macOS arm64, Python 3.12.14, CPU only. Docling 2.126.0, RapidOCR 3.9.2, ONNX Runtime 1.30.0. The first run downloaded the models and printed the complete Markdown, then exited with code 134 (libc++abi: ... recursive_mutex lock failed) during interpreter shutdown. An identical rerun and a further run with a fresh Hugging Face model cache both exited cleanly and produced byte-identical output. We have not established the cause of the initial shutdown error. We haven’t tested GPU, non-English, noisy, or handwritten inputs.

Default, full-page, and disabled OCR

SettingWhat gets OCR’dUse it for
do_ocr=True (default mode)Layout regions with no embedded text, or regions overlapping images or shapes. Embedded text wins where both exist.Mixed corpora of digital and scanned PDFs
mode=OcrMode.FULL_PAGEThe whole page. Embedded PDF text is discarded.Wrong text layers or regions the default mode missed
do_ocr=FalseNothingPDFs where you trust the embedded text

FULL_PAGE does more work. On clean digital PDFs it can also lower fidelity, because exact embedded text is replaced with recognized text. Use it for the documents that need it rather than setting it globally.

mode belongs to the OCR options, not the pipeline options: RapidOcrOptions(mode=OcrMode.FULL_PAGE). Older code may use force_full_page_ocr=True. That still works as a deprecated alias for the same mode (source). Over HTTP, Docling Serve uses a different option, force_ocr. The Docling Serve guide covers the request shapes.

Choosing an engine and language

Each engine expects its own language-code format:

EngineOptions classInstallLanguage exampleNotes
RapidOCRRapidOcrOptionsdocling[rapidocr]["en"]One language per run. Extra values are ignored with a warning.
EasyOCREasyOcrOptionsdocling[easyocr]["en", "de"]Picks a model that covers every language listed, so extra languages can switch to a broader model.
Tesseract CLITesseractCliOcrOptionsSystem tesseract plus language data["eng", "deu"]Run tesseract --list-langs to check installed data.
tesserocrTesseractOcrOptionsdocling[tesserocr] plus the system library["eng", "deu"]A Python binding. It’s a separate install from the CLI.
macOS VisionOcrMacOptionsdocling[ocrmac], macOS only["en-US"]The recognizers come with the OS.

Sources: option classes, OCR engine notes, installation. We ran only RapidOCR. The other rows are based on the source, not on our own runs.

Choose an engine explicitly in production, even if it’s the one auto selection would pick, so a new dependency can’t quietly change your OCR output. We haven’t compared engine accuracy, so this table doesn’t rank them. Run your own documents through two engines and compare the output.

Checklist for incomplete output

  1. Check for an existing text layer. Run pdftotext file.pdf - (Poppler) or another text extractor. If it returns text that’s wrong, the default mode will keep that text. Use OcrMode.FULL_PAGE.
  2. Check which engine ran. Call logging.basicConfig(level=logging.INFO) before converting. Auto selection logs a line like Auto OCR model selected rapidocr with onnxruntime. If you see No OCR engine found, pages went through with no OCR at all.
  3. Check the language. Make sure the codes match the engine’s format. RapidOCR reads only the first entry, and Tesseract fails without the matching language data.
  4. Check resolution before tuning. ocr_options.scale defaults to 3, which renders pages at 216 DPI for OCR. That’s the setting to change, not images_scale, which controls exported images. Scaling can’t restore detail missing from a low-resolution scan, and the option’s docs suggest lowering it when the source is already high resolution. Rotate or deskew bad scans before conversion.
  5. Check tables separately. Recognizing text and rebuilding tables are separate steps. If the numbers are right but the grid is wrong, OCR worked and table structure didn’t. Inspect the cells instead of changing OCR settings.
  6. Check model availability. The first local conversion downloads model weights. For offline machines, download them ahead of time with docling-tools models download. For containers, see the Docker guide.
  7. Check the GPU separately. OCR backends have their own GPU requirements, apart from the layout and table models. The Serve guide covers the common ONNX Runtime fallback.
  8. Compare against the page image. Check identifiers, dates, and totals against the scan. Output that looks right can still contain wrong values.

Using a managed API instead

If you’d rather not manage OCR dependencies and model downloads, Parsebridge runs Docling as a hosted API. It’s our product, so here is its exact scope: it converts PDFs to Markdown through its own API, and that API doesn’t expose OCR engine, language, or mode settings. Don’t expect it to match a custom local configuration like the one above, and don’t assume it handles handwriting or poor scans well. New accounts include 50 free pages, so you can test it on your own difficult scans before deciding.