Skip to content

OCR (Optical Character Recognition)

Extract text from images and scanned PDFs. Xberg automatically determines when OCR is needed — images always require it, scanned PDFs trigger it per-page, and hybrid PDFs only OCR the pages that lack a text layer. Set force_ocr=True to OCR all pages regardless.

See the OcrConfig reference for all configuration options.

Eight OCR backends — pick based on platform, accuracy needs, and language coverage.

All four Candle backends drop an output line that reads as noise rather than recognized text: a line written mostly in a script your configured ocr.language list does not cover (for example, a CJK run on an English-only document), or a plain-text OCR line that is mostly bare LaTeX markup with no ordinary word in it. A $$/``` fenced block, and a line produced by an explicit table/formula/chart task, are never touched. A dropped line adds a ProcessingWarning naming how many lines were removed.

Tesseract PaddleOCR Sceptre Candle GLM-OCR Candle TrOCR Candle DeepSeek-OCR Candle PaddleOCR-VL VLM
Speed Fast Model-dependent Fast on CPU Moderate Moderate Moderate Moderate Slow (API latency)
Accuracy Good Excellent Good Excellent Good Excellent Excellent Highest
Languages 100+ 80+ (11 script families) 8 EasyOCR gen2 groups All 100+ 20+ (CJK + Latin) 20+ (CJK + Latin) All (provider-dependent)
Installation System package Built-in (native) or Python package Built-in or Cargo feature Built-in Built-in Built-in Built-in API key only
Model size ~10 MB Mobile ~8 MB, Server ~120 MB Downloaded on first use ~3 GB ~250 MB ~4 GB ~2.5 GB None (cloud-hosted)
GPU support No Yes No (CPU-only in Xberg 1.1) Yes (Metal/CUDA) Yes (Metal/CUDA) Yes (Metal/CUDA) Yes (Metal/CUDA) N/A (server-side)
Platform All (including Wasm) All except Wasm Native desktop/server only Native only Native only Native only Native only All
Cost Free Free Free Free Free Free Free Per-token API cost
Backend Strengths Trade-offs Choose it when
Tesseract Broad language and platform support; low runtime overhead; hierarchical hOCR output Requires a system package and separately installed language data; less reliable on scene text and difficult scans You need the default backend, WebAssembly support, or a small deployment footprint
PaddleOCR Strong recognition quality; mobile and server model tiers; GPU support; strong CJK coverage Larger model downloads; server models can be slow and memory-intensive on CPU Recognition quality is the priority, especially for CJK documents, and you can budget for the model/runtime cost
Sceptre CRAFT detection and CRNN recognition; eight EasyOCR Gen2 language groups CPU-only; tract mobile builds and opt-in worker-hosted WASM have larger artifacts You need EasyOCR Gen2 recognition without a Python runtime

The remaining backends cover larger-model and hosted use cases:

  • Candle GLM-OCR — Excellent accuracy with VLM-level reasoning on 0.9B-param GLM model. Pure Rust, GPU-accelerated (Metal on macOS, CUDA on Linux). Region-aware layout dispatch. First download ~3 GB.
  • Candle TrOCR — Smaller model footprint (~250 MB) with solid accuracy across languages. Pure Rust, GPU-accelerated. Good balance of speed and quality.
  • Candle DeepSeek-OCR — Deep learning-based OCR combining SAM + CLIP + Qwen2 + DeepSeek MoE. Multilingual with strong CJK coverage. Pure Rust, GPU-accelerated. First download 6.7 GB (BF16 weights).
  • Candle PaddleOCR-VL — SigLIP vision encoder + Ernie-4.5 text decoder. Lightweight multilingual model with CJK and Latin support. Pure Rust, GPU-accelerated. First download ~2.5 GB.
  • VLM — Best for handwritten text, poor scans, Arabic/Farsi, and complex layouts. Requires an API key and incurs per-token costs. See LLM Integration for full details.
Terminal
brew install tesseract

Additional language packs:

Terminal
# macOS — all languages
brew install tesseract-lang
# Ubuntu/Debian — individual languages
sudo apt-get install tesseract-ocr-deu # German
sudo apt-get install tesseract-ocr-fra # French
# Verify installed languages
tesseract --list-langs

The Wasm package has no packaged helper that wires up a Tesseract backend for you (there is no enableOcr() export). The primitive it does export is registerOcrBackend() — you supply a JavaScript object implementing the OcrBackend trait bridge and register it under the name "tesseract-wasm" before calling extract with { ocr: { backend: "tesseract-wasm" } }. See Trait Bridges in the WASM API Reference for the registration API.

Run one OCR extraction to confirm that your binding finds Tesseract. force_ocr makes the pipeline run OCR even on a PDF that already has a text layer:

C
#include <xberg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
const char *config_json =
"{"
"\"force_ocr\": true,"
"\"ocr\": {\"backend\": \"tesseract\", \"language\": \"eng\"}"
"}";
XBERGAlefHandle config = xberg_extraction_config_from_json(config_json);
if (config == 0) {
fprintf(stderr, "config init failed (code %d): %s\n",
xberg_last_error_code(),
xberg_last_error_context());
return 1;
}
XBERGAlefHandle input = xberg_extract_input_from_uri("scanned.pdf");
if (input == 0) {
fprintf(stderr, "Failed to create input (code %d): %s\n",
xberg_last_error_code(),
xberg_last_error_context());
xberg_extraction_config_free(config);
return 1;
}
XBERGAlefHandle result = xberg_extract(input, config);
if (result == 0) {
fprintf(stderr, "extraction failed (code %d): %s\n",
xberg_last_error_code(),
xberg_last_error_context());
xberg_extract_input_free(input);
xberg_extraction_config_free(config);
return 1;
}
char *content = xberg_extraction_result_results(result);
printf("%s\n", content ? content : "(empty)");
xberg_free_string(content);
xberg_extract_input_free(input);
xberg_extraction_result_free(result);
xberg_extraction_config_free(config);
return 0;
}

Built in via the paddle-ocr feature flag. Models download automatically on first use — no extra installation needed.

Cargo.toml (Rust example)
[dependencies]
xberg = { version = "1", features = ["paddle-ocr"] }

ONNX Runtime cannot link on wasm32 or the Android x86_64 emulator. On those targets, enable paddle-ocr-tract instead of paddle-ocr-ort (or the paddle-ocr alias) to run PaddleOCR through the pure-Rust tract engine, with no ONNX Runtime in the dependency graph. It runs the same DBNet detector, CRNN recognizer, and PP-LCNet text-line orientation classifier. paddle-ocr-tract is included in android-target. See Pure-Rust Inference (tract) for how the engine handles PaddleOCR’s shape requirements.

Cargo.toml
[dependencies]
xberg = { version = "1", features = ["paddle-ocr-tract"] }

Do not enable both an ORT PaddleOCR feature and paddle-ocr-tract in the same build — as with auto-rotate / auto-rotate-tract and layout-detection / layout-tract, the ORT and tract variants of a feature are mutually exclusive.

Limitations on tract:

  • CPU-only. GPU/execution-provider settings apply to the ONNX Runtime path only and are ignored on tract.
  • Slower than ONNX Runtime. tract trades throughput for portability on targets where ONNX Runtime cannot link at all; see Latency for measured ratios on other models.
  • Detection builds a plan per page shape. DBNet’s shape cannot be left symbolic under tract, so its plan is pinned to the exact dimensions each page resizes to and cached (four plans, least-recently-used eviction). Pages of a document nearly always share one extent, so the plan is built once; a new extent costs one build. Detection results are identical to the ONNX Runtime path — see PaddleOCR under tract for why the page is never padded into a fixed canvas.

Detection cost grows steeply with page size. Measured on macOS arm64 (single run, indicative, not a benchmark):

Model 640² 960² 1280²
v6 det medium 821 ms / 518 MiB 1650 ms / 981 MiB 4038 ms / 1652 MiB
v2 det mobile 140 ms / 90 MiB 224 ms / 175 MiB 706 ms / 306 MiB

det_limit_side_len defaults to 1024, at which the medium detector extrapolates to roughly 2 s and ~1.1 GiB per page under tract. On tract targets, prefer the mobile detection tier and lower det_limit_side_len — around 640 — to keep per-page latency and memory in check. The ORT default is unchanged.

Sceptre is included in published native desktop/server packages and the CLI. These builds use ONNX Runtime and download checksum-pinned models to the Hugging Face cache on first use. For a custom desktop/server Rust build, enable sceptre-ocr; add auto-rotate if you also need automatic orientation correction.

Cargo.toml
[dependencies]
xberg = { version = "1", features = ["sceptre-ocr"] }

Android and iOS builds use the pure-Rust tract engine through sceptre-ocr-tract; use auto-rotate-tract for orientation correction. Mobile builds exclude Sceptre’s Hugging Face downloader. Resolve packaged application assets to accessible filesystem paths and set both backend_options.model.detector_path and backend_options.model.recognizer_path. WebAssembly support is excluded from the published default bundle because of model and runtime size. Source builds can enable the separate byte-fed class with wasm-pack build crates/xberg-wasm --target web --features sceptre-wasm; application code must instantiate and call that synchronous class inside its own Web Worker. The JavaScript host fetches the CRAFT and recognizer models and supplies their bytes. All Sceptre paths are CPU-only.

Python
import asyncio
from xberg import ExtractInput, extract, ExtractionConfig, OcrConfig
async def main() -> None:
config: ExtractionConfig = ExtractionConfig(
ocr=OcrConfig(backend="tesseract", language=["eng"])
)
result = await extract(ExtractInput(uri="scanned.pdf"), config)
content: str = result.results[0].content
preview: str = content[:100]
total_length: int = len(content)
print(f"Extracted content (preview): {preview}")
print(f"Total characters: {total_length}")
asyncio.run(main())

Specify multiple language codes separated by + (Tesseract) or as a list (PaddleOCR and VLM backends):

Python
import asyncio
from xberg import ExtractInput, extract, ExtractionConfig, OcrConfig
async def main() -> None:
config: ExtractionConfig = ExtractionConfig(
ocr=OcrConfig(backend="tesseract", language=["eng", "deu", "fra"])
)
result = await extract(ExtractInput(uri="multilingual.pdf"), config)
content: str = result.results[0].content
preview: str = content[:100]
total_length: int = len(content)
print(f"Extracted content (preview): {preview}")
print(f"Total characters: {total_length}")
asyncio.run(main())

Native Khmer numbers with an English primary

Section titled “Native Khmer numbers with an English primary”

For eligible automatic native PDF OCR, provisioning khm.traineddata beside the selected eng.traineddata automatically enables bounded Khmer-number recovery. There is no separate recovery flag. An all-model deployment can therefore produce different text and perform extra verification work even when ocr.language remains eng.

Without the auxiliary file, the English read stays unchanged and an accepted eligible page receives an OCR warning about its unverified native Khmer numbers. Recovery never downloads or materializes an auxiliary model, searches another root for it, or changes the primary OCR language. Other primary languages, including deu, khm and eng+khm, retain their ordinary OCR behavior without this recovery step.

This capability is limited to corroborated source crops with intact raster evidence and a unique line owner. It inserts verified numbers while retaining existing OCR tokens; a misread neighbor can remain. Forced/custom OCR, layout, explicit pipelines, transformed or detached stages, unsupported glyph evidence, and lines already containing numbers are excluded. A selected read whose serialized text and geometry exceed the 256 KiB proof limit is retained with a verification-gap warning.

Eligible pages bypass extraction-result caching, with or without the model. When the auxiliary model is available, their primary OCR read also bypasses OCR caching to observe the actual preprocessing geometry. See ADR 0014 for the activation decision, costs and limits. This is partial support for issue 2408; the original reporter page has not been verified.

The initial source proof admits simple embedded glyphs and small page content. Tagged or ActualText content, Forms, custom graphics-state resources, composite outlines and unsupported encodings retain the ordinary OCR read. Source text and character memory are admitted before private extraction, which reuses one owned character buffer. See the fixture README for the exact font, content and work limits. Source preparation and crop recognition run serially on blocking workers sharing one document budget: at most eight attempted pages, 64 crops and 128 MiB of accounted verifier work, reduced by caller limits. Exhaustion retains accepted OCR reads and warns. Implicit deferred per-page pipelines (including the usual full feature route) are excluded from both recovery and its missing-model warning; the bounded direct route remains eligible.

Source preparation reserves its remaining document allowance before any font work. Only completed verification refunds unused allowance; an unmeasured failure may consume the remainder and stop numeric recovery while retaining accepted OCR text.

Process PDFs with OCR even when they have a text layer:

Python
import asyncio
from xberg import ExtractInput, extract, ExtractionConfig, OcrConfig
async def main() -> None:
config: ExtractionConfig = ExtractionConfig(
ocr=OcrConfig(backend="tesseract"),
force_ocr=True,
)
result = await extract(ExtractInput(uri="document.pdf"), config)
content: str = result.results[0].content
preview: str = content[:100]
total_length: int = len(content)
print(f"Extracted content (preview): {preview}")
print(f"Total characters: {total_length}")
asyncio.run(main())

When disable_ocr is set, image files return empty content instead of raising MissingDependencyError:

disable_ocr.py
from xberg import ExtractInput, ExtractionConfig, extract
config = ExtractionConfig(disable_ocr=True)
output = await extract(ExtractInput(kind="uri", uri="scanned.png"), config=config)
result = output.results[0]
# result.content will be empty — OCR was skipped
Python
import asyncio
from xberg import ExtractInput, extract, ExtractionConfig, OcrConfig
async def main() -> None:
config: ExtractionConfig = ExtractionConfig(
ocr=OcrConfig(backend="paddleocr", language=["en"]) # model_tier="server" for max accuracy
)
result = await extract(ExtractInput(uri="scanned.pdf"), config)
content: str = result.results[0].content
preview: str = content[:100]
total_length: int = len(content)
print(f"Extracted content (preview): {preview}")
print(f"Total characters: {total_length}")
asyncio.run(main())

Select the backend with backend = "sceptre". Put Sceptre’s detection, recognition, concurrency, and model sections directly under backend_options; do not add a nested sceptre key.

xberg.toml
[ocr]
backend = "sceptre"
language = ["eng", "deu"]
[ocr.backend_options.recognition]
batch_size = 4
[ocr.backend_options.concurrency]
max_threads = 2
Path Purpose
backend_options.detection.* CRAFT thresholds, canvas size, magnification, box size, and line grouping
backend_options.recognition.* Decoder, beam width, batch size, allow/block lists, contrast retry, and confidence filtering
backend_options.concurrency.max_threads Cap Sceptre’s worker and inference thread pools
backend_options.model.cache_dir Override the Hugging Face cache root on desktop/server ORT builds
backend_options.model.registry_owner Use an approved mirror of the checksum-pinned model repositories
backend_options.model.detector_path Required CRAFT model path on Android/iOS tract builds
backend_options.model.recognizer_path Required selected Gen2 recognizer path on Android/iOS tract builds

Set languages with ocr.language; Xberg overrides backend_options.model.languages. Xberg also selects ORT or tract for the build target, so portable configuration should not set backend_options.model.backend.

xberg.toml (Android/iOS)
[ocr]
backend = "sceptre"
language = ["deu"]
[ocr.backend_options.model]
detector_path = "/app/models/craft_mlt_25k.onnx"
recognizer_path = "/app/models/latin_g2.onnx"

The paths must be resolved by the application from its bundle or asset system before extraction starts.

Sceptre provides all eight EasyOCR Gen2 recognition groups: english, latin, chinese_simplified, japanese, korean, cyrillic, telugu, and kannada. Xberg accepts group tokens and ISO aliases such as en/eng, de/deu, zh/zho, ja/jpn, ko/kor, ru/rus, te/tel, and kn/kan. English may be combined with one other group; two distinct non-English groups require different recognizers and fail validation before OCR.

Sceptre emits line-level quadrilaterals and recognition confidence. It does not provide Tesseract’s word/symbol hierarchy or a separate detection-confidence value. Tract inference uses a fixed CRAFT canvas and can require more memory than the default browser OCR path; run WebAssembly inference in a worker.

Ships in the published package by default on native desktop/server platforms — no feature flag or custom build needed. Set ocr.backend = "candle-glm-ocr" and the GLM-OCR model downloads automatically on first use (~3 GB), cached at ~/.cache/huggingface/. Not available on WebAssembly, Android, iOS, Dart, or Swift.

For a custom Rust crate build, the model is gated behind the candle-glm-ocr feature (or the candle-vlm-ocr umbrella feature):

Cargo.toml (Rust example)
[dependencies]
xberg = { version = "1", features = ["candle-glm-ocr"] }

GPU support:

  • Metal (macOS) — Default, F32 dtype (BF16 matmul unavailable in candle 0.10)
  • CUDA (Linux/Windows with NVIDIA GPU) — Auto-detected
  • CPU fallback — Slowest, but always available

Candle GLM-OCR dispatches by detected layout region using PP-DocLayout-V3. Each region runs through the appropriate task prompt (ocr/table/formula/chart/caption) and outputs are merged into reading-order markdown.

candle_glm_ocr.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, extract
# Paired mode: per-region dispatch (default)
config = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(
backend="candle-glm-ocr",
language=["en"],
backend_options={"layout_mode": "paired"},
),
)
output = await extract(ExtractInput(kind="uri", uri="document.pdf"), config=config)
result = output.results[0]
print(result.content)
# Whole-page mode: single OCR pass over entire page
config_whole = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(
backend="candle-glm-ocr",
language=["en"],
backend_options={"layout_mode": "whole_page"},
),
)
whole_page_output = await extract(
ExtractInput(kind="uri", uri="document.pdf"),
config=config_whole,
)
result_whole_page = whole_page_output.results[0]

Backend options:

Option Values Description
layout_mode "paired" (default), "whole_page" Paired: dispatch per-region via PP-DocLayout-V3. Whole-page: single OCR pass on entire page.
task "ocr" (default), "table", "formula", "chart", "caption" Task prompt for whole-page mode only; ignored in paired mode where the region type determines the prompt.
device "auto" (default), "cpu", "metal", "cuda" Device selection. Auto detects Metal on macOS, CUDA on Linux, CPU fallback.

DeepSeek-OCR — combination of SAM + CLIP encoder fused with Qwen2 decoder and DeepSeek V2 MoE for comprehensive multilingual document understanding. Markdown output.

Ships in the published package by default on native desktop/server platforms — no feature flag or custom build needed. Set ocr.backend = "candle-deepseek-ocr" and the weights download automatically on first use from a checksum-pinned revision of deepseek-ai/DeepSeek-OCR (6.7 GB, BF16) into the standard Hugging Face cache (HF_HUB_CACHE / HUGGINGFACE_HUB_CACHE / HF_HOME, or ~/.cache/huggingface/ by default) — no backend_options.model_path needed. Not available on WebAssembly, Android, iOS, Dart, or Swift.

For a custom Rust crate build, the model is gated behind the candle-deepseek-ocr feature (or the candle-vlm-ocr umbrella feature):

Cargo.toml (Rust example)
[dependencies]
xberg = { version = "1", features = ["candle-deepseek-ocr"] }

GPU support: dtype follows the compute device (backend_options.dtype = "auto" by default; a wrong dtype fails the load hard rather than silently falling back, so override it only when you know the target device’s kernel coverage).

  • CUDA (Linux/Windows with NVIDIA GPU) — Auto-detected, BF16
  • Metal (macOS) — F16 (BF16 kernel coverage is incomplete on Metal)
  • CPU fallback — F32, slowest but always available
candle_deepseek_ocr.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, extract
config = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(
backend="candle-deepseek-ocr",
language=["en"],
backend_options={"device": "auto"},
),
)
output = await extract(ExtractInput(kind="uri", uri="document.pdf"), config=config)
result = output.results[0]
print(result.content)

Supported languages: English, Chinese, Japanese, Korean, French, German, Spanish, Italian, Portuguese, Russian, Arabic, Hindi, Thai, Vietnamese, and others.

Model source: deepseek-ai/DeepSeek-OCR on the Hugging Face Hub, auto-downloaded at a checksum-pinned revision. Pass backend_options.model_id to use a different repository (requires an explicit hf_revision) or backend_options.model_path to point at pre-staged local weights.

PaddleOCR-VL 1.6 — SigLIP vision encoder + Ernie-4.5 text decoder for lightweight multilingual document understanding. Markdown output.

Ships in the published package by default on native desktop/server platforms — no feature flag or custom build needed. Set ocr.backend = "candle-paddleocr-vl" and the model downloads automatically on first use (~2.5 GB), cached at ~/.cache/huggingface/. Not available on WebAssembly, Android, iOS, Dart, or Swift.

For a custom Rust crate build, the model is gated behind the candle-paddleocr-vl feature (or the candle-vlm-ocr umbrella feature):

Cargo.toml (Rust example)
[dependencies]
xberg = { version = "1", features = ["candle-paddleocr-vl"] }

GPU support:

  • Metal (macOS) — Default, F32 dtype
  • CUDA (Linux/Windows with NVIDIA GPU) — Auto-detected
  • CPU fallback — Slowest, but always available
candle_paddleocr_vl.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, extract
config = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(
backend="candle-paddleocr-vl",
language=["en"],
backend_options={"device": "auto", "model_path": "~/.cache/huggingface/"},
),
)
output = await extract(ExtractInput(kind="uri", uri="document.pdf"), config=config)
result = output.results[0]
print(result.content)

Supported languages: English, Chinese, Japanese, Korean, French, German, Spanish, Italian, Portuguese, Russian, and others.

Model source: Download from PaddlePaddle Hub.

Use a vision-language model (e.g. GPT-4o, Claude) as the OCR backend — each page is rendered and sent to the VLM. Cloud providers need an API key; local engines (Ollama, etc.) use the ollama/ prefix — see Local LLM Support.

Python
import asyncio
from xberg import ExtractInput, extract, ExtractionConfig, OcrConfig, LlmConfig
async def main() -> None:
config = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(
backend="vlm",
vlm_config=LlmConfig(model="openai/gpt-4o-mini"),
),
)
result = await extract(ExtractInput(uri="scan.pdf"), config)
print(result.results[0].content)
asyncio.run(main())

For more on VLM OCR, including custom prompts, supported providers, and API key configuration, see LLM Integration.

Higher DPI improves accuracy but increases processing time and memory.

DPI Trade-off
150 Fastest — lower accuracy, less memory
300 (default) Balanced — good accuracy, reasonable speed
600 Best accuracy — slower, more memory
Python
import asyncio
from xberg import (
ExtractInput,
extract,
ExtractionConfig,
OcrConfig,
TesseractConfig,
ImagePreprocessingConfig,
)
async def main() -> None:
config: ExtractionConfig = ExtractionConfig(
ocr=OcrConfig(
backend="tesseract",
tesseract_config=TesseractConfig(
preprocessing=ImagePreprocessingConfig(target_dpi=300),
),
),
)
result = await extract(ExtractInput(uri="scanned.pdf"), config)
content_length: int = len(result.results[0].content)
table_count: int = len(result.results[0].tables)
print(f"Content length: {content_length} characters")
print(f"Tables detected: {table_count}")
asyncio.run(main())

Beyond backend and DPI selection, OcrConfig and ExtractionConfig expose finer control over when OCR runs, page orientation correction, the native-text-to-OCR fallback decision, multi-backend fallback, and structured element output. See the OcrConfig reference for every field.

Set force_ocr_pages on ExtractionConfig to OCR only the listed pages (1-indexed). Unlisted pages use native text extraction. PDF only. Ignored when force_ocr is true; duplicates are deduplicated. Provide an ocr config for backend and language selection — defaults are used if absent.

force_ocr_pages.py
from xberg import ExtractInput, ExtractionConfig, extract
config = ExtractionConfig(force_ocr_pages=[1, 3, 5])
output = await extract(ExtractInput(kind="uri", uri="document.pdf"), config=config)
result = output.results[0]

Set auto_rotate on OcrConfig to detect page orientation (0/90/180/270 degrees) with Tesseract’s orientation-and-script detection before recognition. Pages rotated with high confidence are corrected before OCR — important for rotated scans. true always rotates, false never rotates and never retries, and leaving it unset (the default) keeps the automatic orientation retry for a plain Tesseract block.

auto_rotate.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, extract
config = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(backend="tesseract", auto_rotate=True),
)
output = await extract(ExtractInput(kind="uri", uri="rotated_scan.pdf"), config=config)
result = output.results[0]

The native-text-to-OCR fallback decision (should a PDF page with an embedded text layer be re-OCR’d?) and multi-backend stage acceptance are governed by quality_thresholds (OcrQualityThresholds) on OcrConfig. When unset, compiled defaults apply. Frequently tuned fields:

Field Default Purpose
pipeline_min_quality 0.5 Minimum quality score (0.0-1.0) for a pipeline stage result to be accepted; below this, the next backend runs.
min_meaningful_words 3 Minimum count of meaningful words before native text is accepted instead of OCR.
min_alnum_ratio 0.3 Minimum alphanumeric ratio of non-whitespace characters.
critical_fragmented_word_ratio 0.80 Fraction of 1-2 character words that forces OCR regardless of other signals.
max_ocr_output_fragmented_word_ratio 0.35 Fraction of short words that reports OCR output as suspected recognition noise.
min_ocr_mean_confidence 75.0 Calibrated backend confidence below which OCR output is reported as suspected noise.
discard_suspected_ocr_noise false Discard non-empty OCR output when a recognition-noise signal fires. By default, Xberg retains the text and adds a processing warning.
min_undecodable_ratio 0.5 Minimum fraction of non-whitespace characters that must be undecodable (PUA, replacement, or control garbage) before a page’s text layer is treated as unreadable and routed to OCR. See Automatic OCR for Undecodable Text Layers.
min_reliable_language_chunk_ratio 0.10 Minimum fraction of a page’s prose chunks whatlang must classify as a reliable language before the page is trusted as legible. See Automatic OCR for Language/Dictionary-Implausible Text.

Inspect processing_warnings for pages flagged by fragmented-word, calibrated-confidence, or configured dictionary-invalid-word signals. To preserve the pre-1.1 destructive behavior, set discard_suspected_ocr_noise = true; blank OCR pages remain blank without a recognition-noise warning.

quality_thresholds.py
from xberg import ExtractionConfig, OcrConfig, OcrQualityThresholds, extract
config = ExtractionConfig(
ocr=OcrConfig(
quality_thresholds=OcrQualityThresholds(pipeline_min_quality=0.6),
),
)

When OCR fails on one page of a PDF, Xberg keeps the other pages and reports the failure in two places. processing_warnings gets a message. ocr_page_failures gets one record for the page, so that code does not have to read the message text.

Field Type Meaning
page integer Page number, starting at 1.
error string Error text that the OCR backend returned.
recovered boolean true when the page still has content from native text or from its embedded images.

Repeated failures on one page make one record. When an automatic OCR run of selected pages fails as a whole, there is one warning for the run and one record for each selected page. When the OCR fallback for the whole document fails, there is one warning and one record for each page that has no native text. ocr_page_failures is absent (None in Python, null or omitted in JSON) when there is no record; it is never an empty list. The records are not in metadata.additional.

ocr_page_failures.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, extract
config = ExtractionConfig(ocr=OcrConfig(backend="tesseract"))
output = await extract(ExtractInput(kind="uri", uri="scan.pdf"), config=config)
result = output.results[0]
for failure in result.ocr_page_failures or []:
state = "recovered" if failure.recovered else "empty"
print(f"page {failure.page}: {failure.error} ({state})")

When Tesseract recovers a PDF page from a single embedded image, metadata.additional.ocr_page_segmentation_modes can record the page number and the segmentation mode used for that read. This also applies to standalone scans.

When Xberg sends a PDF page to OCR, the OCR text replaces the text layer in the page content. This is correct for a scan with a stamp. For a born-digital page with one short line, the result then has an OCR reading of the line and no copy of the exact characters.

To get both texts, set keep_native_content on PageConfig. extract_pages must be on, because Xberg returns the kept text on pages[].

keep_native_content.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, PageConfig, extract
config = ExtractionConfig(
pages=PageConfig(extract_pages=True, keep_native_content=True),
ocr=OcrConfig(backend="tesseract", language=["eng"]),
)
output = await extract(ExtractInput(kind="uri", uri="short-text-page.pdf"), config=config)
for page in output.results[0].pages:
print(page.content) # OCR text when the page was sent to OCR
print(page.native_content) # text layer of that page, or None

native_content is set on a page only when OCR text replaced its content, and the text layer is not blank and differs from the OCR text. The rule is the same for every OCR route: force_ocr, force_ocr_pages, and the automatic routes. It is absent (None in Python, omitted in JSON) on every other page. page.native_content or page.content gives the text layer of a page that has one.

The setting is off by default, and the default output does not change. The content of the document and of each page stays OCR text.

PDFs with a font whose CID/glyph indices have no ToUnicode mapping (common with subset Identity-H fonts) often “extract” successfully but decode to garbage — Unicode Private Use Area (PUA) codepoints, replacement characters (U+FFFD), or non-whitespace control characters instead of legible text. Previously this garbage passed the native-text quality check and OCR never ran. Xberg now detects this case and routes the page to OCR automatically, the same as a scanned page (issue #1254).

A page is treated as having an undecodable text layer, and routed to OCR under OcrStrategy.Auto, when:

  • it has at least min_total_non_whitespace (default 64) non-whitespace characters — short snippets with a stray symbol don’t trip this check, and
  • at least min_undecodable_ratio (default 0.5) of those non-whitespace characters are undecodable (PUA, U+FFFD, or non-whitespace control characters).

Tune the ratio via OcrQualityThresholds.min_undecodable_ratio on OcrConfig.quality_thresholds:

undecodable_text_layer.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, OcrQualityThresholds, extract
config = ExtractionConfig(
ocr=OcrConfig(
quality_thresholds=OcrQualityThresholds(min_undecodable_ratio=0.3),
),
)
output = await extract(ExtractInput(kind="uri", uri="identity-h-subset-font.pdf"), config=config)
result = output.results[0]
# Pages whose text layer decoded to >=30% PUA/replacement/control garbage were OCR'd.

Automatic OCR for Language/Dictionary-Implausible Text

Section titled “Automatic OCR for Language/Dictionary-Implausible Text”

A /ToUnicode CMap (or other mapping tier) can resolve every glyph to a character, but consistently the WRONG one — a ROT-shifted mapping is the clearest example. The decoded text is structurally indistinguishable from real prose: normal alphanumeric ratio, normal word lengths, no fragmentation, and no undecodable (PUA/replacement/control) characters, so neither the character-shape checks above nor the undecodable-text-layer check can see the problem. Xberg detects this by checking whether the decoded text reads as any real, detectable language at all (issue #1696).

A page is treated as language/dictionary-implausible, and routed to OCR under OcrStrategy.Auto, when:

  • it has enough prose content to judge in the first place — pages that are mostly numeric tables, formulas, or code abstain rather than guess;
  • less than min_reliable_language_chunk_ratio (default 0.10) of its prose chunks are classified as a reliable language; and
  • the mean detection confidence across those chunks is also low, so a genuinely legible page in a close language pair (e.g. Danish vs. Norwegian Bokmål) is not misclassified.

A page the character-shape or undecodable-text-layer checks already route to OCR is not independently re-flagged by this signal. PdfMetadata.implausible_text_pages reports the 1-indexed pages this check flagged; it is None when enable_plausibility_ocr_routing is false. If no page holds enough prose to judge, the check reaches no verdict and the list stays empty for that reason, not because the text layer is sound. Xberg then records a processing warning naming how many pages it examined, so an empty list is not mistaken for a clean result (issue #1709).

Tune the ratio via OcrQualityThresholds.min_reliable_language_chunk_ratio on OcrConfig.quality_thresholds, or disable the check with enable_plausibility_ocr_routing:

implausible_text_layer.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, OcrQualityThresholds, extract
config = ExtractionConfig(
ocr=OcrConfig(
quality_thresholds=OcrQualityThresholds(min_reliable_language_chunk_ratio=0.2),
),
)
output = await extract(ExtractInput(kind="uri", uri="wrong-mapping.pdf"), config=config)
result = output.results[0]
# Pages whose decoded text did not read as any real language were OCR'd.

Set pipeline (OcrPipelineConfig) to try several backends in priority order (highest first) with quality-based fallback. Each stage’s output is scored; if it meets pipeline_min_quality, it is accepted, otherwise the next stage runs. Each stage (OcrPipelineStage) has a backend and priority (default 100), plus optional language, tesseract_config, paddle_ocr_config, vlm_config, and backend_options.

When pipeline is set, the top-level backend, vlm_fallback, and backend_options are ignored — configure each stage directly instead.

ocr_pipeline.py
from xberg import OcrConfig, OcrPipelineConfig, OcrPipelineStage, OcrQualityThresholds
ocr = OcrConfig(
pipeline=OcrPipelineConfig(
stages=[
OcrPipelineStage(backend="tesseract", priority=100),
OcrPipelineStage(backend="paddleocr", priority=50),
],
quality_thresholds=OcrQualityThresholds(pipeline_min_quality=0.6),
),
)

vlm_fallback (VlmFallbackPolicy) is ergonomic sugar over an explicit pipeline. When set and pipeline is None, an equivalent pipeline is synthesised automatically. It requires vlm_config to be set. When pipeline is explicitly set, vlm_fallback is ignored.

  • disabled (default) — single-backend mode.
  • on_low_quality — run the classical backend first; if the result scores below quality_threshold, retry the page with the VLM.
  • always — skip the classical backend; send every page to the VLM.
vlm_fallback.py
from xberg import LlmConfig, OcrConfig, VlmFallbackPolicy
ocr = OcrConfig(
vlm_fallback=VlmFallbackPolicy.on_low_quality(0.6),
vlm_config=LlmConfig(model="openai/gpt-4o-mini"),
)

OCR Elements and Word-Level Bounding Boxes

Section titled “OCR Elements and Word-Level Bounding Boxes”

OCR elements are opt-in. Unless include_elements is true, ocr_elements is not set, even when OCR ran and content holds the text. For word elements, also set min_level to word. In Node and TypeScript, set ocr.elementConfig.includeElements and ocr.elementConfig.minLevel, and read results[0].ocrElements.

Set element_config (OcrElementConfig) on OcrConfig to emit structured OCR elements with spatial and confidence data. When include_elements is true, the result’s ocr_elements field is populated with OcrElement entries — each carries text, geometry (a rectangle with left/top/width/height in pixels, or a 4-point quadrilateral for rotated text), confidence (detection and recognition, 0.0-1.0), level, optional rotation, and a 1-indexed page_number.

  • include_elements — populate ocr_elements. Defaults to false.
  • min_level — minimum hierarchy level to include: word, line (default), block, or page. Elements below this level are dropped.
  • min_confidence — drop elements with recognition confidence below this (0.0-1.0).
  • build_hierarchy — populate parent_id from spatial containment (Tesseract only).
ocr_elements.py
from xberg import (
ExtractInput,
ExtractionConfig,
OcrConfig,
OcrElementConfig,
OcrElementLevel,
extract,
)
config = ExtractionConfig(
force_ocr=True,
ocr=OcrConfig(
element_config=OcrElementConfig(
include_elements=True,
min_level=OcrElementLevel.WORD,
min_confidence=0.5,
),
),
)
output = await extract(ExtractInput(kind="uri", uri="scan.png"), config=config)
result = output.results[0]
for element in result.ocr_elements or []:
print(element.text, element.confidence.recognition, element.page_number)

PP-OCRv6 is the default PaddleOCR model generation (model_version defaults to "pp-ocrv6"). It adds a unified CJK+Latin+Japanese/Korean recognition model with three size tiers, selected via model_tier:

Tier Notes
medium ~62MB detection, full 18,708-char CJK+Latin+JA/KO dictionary. Highest accuracy, substantially slower on CPU.
small Default. ~9.9MB detection, same dictionary as medium.
tiny ~1.8MB detection, but a reduced 6,904-char (~zh/en) dictionary — it cannot read the scripts the other tiers cover.

model_tier defaults to "mobile", which auto-resolves to the v6 "small" tier — you don’t need to set model_tier explicitly to get v6’s default behavior. A legacy "server" tier value, or any unrecognised value, resolves to "medium".

PaddleOCR pages do not run concurrently — the ONNX session is held behind a mutex, so the thread budget goes to intra-op parallelism and wall time scales with page count times per-page inference. On a long document the tier choice, not the thread count, is what governs how long extraction takes.

Scripts outside v6’s unified coverage — Arabic, Cyrillic, Devanagari, Greek, Tamil, Telugu, and Thai — transparently fall back to the PP-OCRv5 per-script recognition models; no configuration change is needed to use those languages under v6.

To pin the legacy PP-OCRv5 fleet (per-script + unified recognition models, mobile/server tiers), set model_version to "pp-ocrv5" explicitly:

pin_pp_ocrv5.py
from xberg import ExtractInput, ExtractionConfig, OcrConfig, extract
config = ExtractionConfig(
ocr=OcrConfig(
backend="paddleocr",
backend_options={"model_version": "pp-ocrv5", "model_tier": "mobile"},
),
)
output = await extract(ExtractInput(kind="uri", uri="scanned.pdf"), config=config)

The candle-paddleocr-vl backend now defaults to PaddleOCR-VL 1.6 (SigLIP vision encoder + Ernie-4.5 text decoder), auto-downloading xberg-io/paddleocr-vl-1.6 — a checksum-pinned mirror of PaddlePaddle/PaddleOCR-VL-1.6 — on first use. It runs one of four tasks via backend_options.task: ocr (default), table, formula, or chart. See Candle PaddleOCR-VL above for installation and usage.

80+ languages across 11 script families (PP-OCRv5). Recognition models are downloaded on demand from HuggingFace:

Family Languages
English English, numbers, punctuation
Chinese Simplified/Traditional Chinese, Japanese
Latin French, German, Spanish, Portuguese, Italian, Polish, Dutch, Turkish, Vietnamese, and so on.
Korean Korean (Hangul)
Slavic Russian, Ukrainian, Belarusian, Bulgarian, Serbian, and so on.
Thai Thai script
Greek Greek script
Arabic Arabic, Persian, Urdu
Devanagari Hindi, Marathi, Sanskrit, Nepali
Tamil Tamil script
Telugu Telugu script

Models are cached locally after first download, so subsequent runs start immediately.

Terminal
# Basic OCR extraction
xberg extract scanned.pdf --ocr true
# Specific language
xberg extract french_doc.pdf --ocr true --ocr-language fra
# Specific backend
xberg extract chinese_doc.pdf --ocr true --ocr-backend paddle-ocr --ocr-language ch
# Sceptre on a native desktop or server
xberg extract german_doc.pdf --ocr true --ocr-backend sceptre --ocr-language deu
# Force OCR on all pages
xberg extract document.pdf --force-ocr true
# VLM OCR backend
xberg extract handwritten.pdf --force-ocr true --vlm-model openai/gpt-4o-mini
# Use a config file
xberg extract scanned.pdf --config xberg.toml --ocr true
Flag Description
--ocr true Enable OCR processing
--ocr-language <code> Language code (eng, deu, fra, ch, ja, ru, etc.)
--ocr-backend <backend> Engine: tesseract, paddle-ocr, sceptre, a candle-* backend, or vlm
--force-ocr true OCR all pages regardless of text layer
--vlm-model <model> VLM model for OCR (for example, openai/gpt-4o-mini). Implies --ocr-backend vlm