OCR Non-English Documents with Tesseract

Invoices from German, French and Polish suppliers go through the same OCR pipeline as English ones, and the German text comes out as Rechnungsbetrag fur Lieferung vom 12.09. — umlaut gone — while Polish Należność becomes Nalezno$c and French guillemets turn into <<. Supplier names no longer match the master data, keyword rules for Mehrwertsteuer miss, and someone suggests replacing accents in the reference data to make matching work.

>>> pytesseract.image_to_string(img)                  # default language
'Rechnungsbetrag fur Lieferung vom 12.09.2026'
>>> pytesseract.image_to_string(img, lang="deu")
pytesseract.pytesseract.TesseractError: (1, 'Error opening data file /usr/share/tesseract-ocr/5/tessdata/deu.traineddata ... Failed loading language 'deu'')

Root Cause

Tesseract recognises text with a language-specific model (*.traineddata), and by default it uses English only. The English model has no character classes for ü, ł or ß, so it outputs the nearest English characters, and its language model biases word choices toward English spellings. Other languages must be installed separately — distribution packages ship only English by default — and requested explicitly with lang. Mixed-language documents need several models combined (deu+eng), and scripts other than Latin — Cyrillic, Greek, Arabic, CJK — need both the language and often a different page segmentation approach. Finally, Tesseract comes with three model flavours: tessdata_fast (smaller, faster), the default tessdata and tessdata_best (slower, more accurate), and distribution packages usually install the fast or standard variant.

Minimal Diagnostic

List what Tesseract has installed and where it looks for models, then OCR one sample with each candidate language combination and compare accented-character counts and confidence.

# pip install pytesseract pillow
import re
from pathlib import Path
import pytesseract
from PIL import Image

SAMPLE = Path("scans/supplier-invoice-de.png")
ACCENTED = re.compile(r"[À-ÖØ-öø-ÿĀ-žȘ-țẞ]")

def diagnose(path: Path) -> None:
    try:
        langs = pytesseract.get_languages(config="")
    except pytesseract.TesseractNotFoundError:
        raise SystemExit("tesseract is not installed")
    print("tesseract", pytesseract.get_tesseract_version(), "| installed:", sorted(langs))
    image = Image.open(path).convert("L")
    for lang in ("eng", "deu", "deu+eng"):
        if not all(l in langs for l in lang.split("+")):
            print(f"{lang:<8} not installed")
            continue
        data = pytesseract.image_to_data(image, lang=lang, output_type=pytesseract.Output.DICT)
        words = [t for t in data["text"] if t.strip()]
        confs = [float(c) for c, t in zip(data["conf"], data["text"]) if t.strip() and float(c) >= 0]
        text = " ".join(words)
        print(f"{lang:<8} words {len(words):>4}  accented chars {len(ACCENTED.findall(text)):>3}  "
              f"mean conf {sum(confs) / max(len(confs), 1):.1f}")

if __name__ == "__main__":
    diagnose(SAMPLE)
tesseract 5.3.4 | installed: ['eng', 'osd']
eng      words  212  accented chars   0  mean conf 81.4
deu      not installed
deu+eng  not installed

Only English and OSD are installed; zero accented characters on a German invoice confirms the English model is replacing them.

English model versus German model on the same invoice The left panel shows English model output with accents lost and words altered, such as fur instead of für, Mehrwertsteuer split incorrectly and Straße read as StraBe. The right panel shows German model output with umlauts and sharp s preserved and higher mean confidence, so supplier names and keywords match reference data. lang="eng" Rechnungsbetrag fur Lieferung Mehrwert steuer 19 % HauptstraBe 12, Munchen Zahlbar innerhalb 14 Tagen mean confidence 81.4 lang="deu+eng" Rechnungsbetrag für Lieferung Mehrwertsteuer 19 % Hauptstraße 12, München Zahlbar innerhalb 14 Tagen mean confidence 90.2

Fix: Install Language Models and Request Them Explicitly

Install the languages your documents use, then pass them to every OCR call. Combine the document language with English when invoices mix both, putting the dominant language first. Changed lines carry comments.

# Debian / Ubuntu: one package per language
sudo apt-get install -y tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-pol
# macOS: all languages
brew install tesseract-lang
# check
tesseract --list-langs
# pip install pytesseract pillow
from pathlib import Path
import pytesseract
from PIL import Image

SUPPLIER_LANG = {                                          # changed: language per supplier, not global
    "northwind-de": "deu+eng",
    "contoso-fr": "fra+eng",
    "fabrikam-pl": "pol+eng",
}

def ocr_invoice(path: Path, supplier: str) -> str:
    lang = SUPPLIER_LANG.get(supplier, "eng")
    installed = set(pytesseract.get_languages(config=""))
    missing = [l for l in lang.split("+") if l not in installed]
    if missing:
        raise RuntimeError(f"Tesseract language(s) not installed: {missing}")   # changed: fail loudly
    image = Image.open(path).convert("L")
    return pytesseract.image_to_string(
        image,
        lang=lang,                                          # changed: explicit models
        config="--oem 1 --psm 3",                            # LSTM engine, automatic layout
    )

if __name__ == "__main__":
    print(ocr_invoice(Path("scans/supplier-invoice-de.png"), "northwind-de")[:200])

Order matters in combined languages: Tesseract tries models in the order given, and the first acts as the primary. Two or three languages are fine; long combinations slow recognition and increase confusion between similar words. Failing loudly when a model is missing prevents the silent fallback to English that caused the original problem — a pipeline deployed to a new server without the language packages would otherwise produce degraded text with no error.

Variant Fix 1: Unknown Language — Detect Script First

For inboxes that receive documents in many languages, detect the script with OSD, OCR once with a broad model, then detect the language from the text and rerun with the specific model:

# pip install pytesseract pillow langdetect
import re
import pytesseract
from langdetect import DetectorFactory, detect_langs
from PIL import Image

ISO_TO_TESS = {"de": "deu", "fr": "fra", "pl": "pol", "es": "spa", "it": "ita", "nl": "nld",
               "en": "eng", "ru": "rus", "uk": "ukr", "el": "ell"}
SCRIPT_TO_BROAD = {"Latin": "eng+deu+fra+pol", "Cyrillic": "rus+ukr", "Greek": "ell"}

def detect_and_ocr(image: Image.Image) -> tuple[str, str]:
    osd = pytesseract.image_to_osd(image)
    script = re.search(r"Script: (\w+)", osd).group(1)
    broad = SCRIPT_TO_BROAD.get(script, "eng")
    first_pass = pytesseract.image_to_string(image, lang=broad)
    candidates = detect_langs(first_pass) if len(first_pass.split()) > 20 else []
    best = candidates[0].lang if candidates and candidates[0].prob > 0.8 else None
    lang = ISO_TO_TESS.get(best, broad)
    if lang == broad:
        return broad, first_pass
    return lang + "+eng", pytesseract.image_to_string(image, lang=lang + "+eng")

Two passes double OCR time, so cache the detected language per sender or per document series once it is known. langdetect needs a reasonable amount of text; for short documents, keep the broad-model result rather than trusting a low-probability guess.

Language detection for mixed inboxes The page image goes to Tesseract OSD, which reports the script such as Latin or Cyrillic. A first OCR pass uses a broad combination of models for that script. langdetect estimates the language from the first pass text. If the estimate is confident, OCR is rerun with that language plus English, otherwise the broad result is kept. The chosen language is cached per sender to skip detection next time. OSD script = Latin Broad pass eng+deu+fra+pol langdetect de, p = 0.97 Specific pass deu+eng Cache language per sender Low probability keep broad result

Variant Fix 2: Better Accuracy with tessdata_best

For documents where a wrong character is expensive — names, amounts, IBANs — the best models are noticeably more accurate than the packaged ones at roughly two to three times the recognition time. Download them into a separate directory and point Tesseract at it:

# pip install pytesseract pillow requests
from pathlib import Path
import requests
import pytesseract
from PIL import Image

BEST_DIR = Path("/opt/tessdata_best")
BASE_URL = "https://github.com/tesseract-ocr/tessdata_best/raw/main/{lang}.traineddata"

def ensure_best(langs: list[str]) -> None:
    BEST_DIR.mkdir(parents=True, exist_ok=True)
    for lang in langs + ["osd"]:
        target = BEST_DIR / f"{lang}.traineddata"
        if target.exists():
            continue
        url = BASE_URL.format(lang=lang) if lang != "osd" else \
            "https://github.com/tesseract-ocr/tessdata/raw/main/osd.traineddata"
        resp = requests.get(url, timeout=120)
        resp.raise_for_status()
        target.write_bytes(resp.content)

def ocr_best(image: Image.Image, lang: str) -> str:
    return pytesseract.image_to_string(image, lang=lang, config=f'--tessdata-dir "{BEST_DIR}" --oem 1')

Download models at image build time, not in the job — a scheduled run should never depend on GitHub being reachable. The best models support only the LSTM engine (--oem 1), and OSD still needs the standard osd.traineddata, which is why it is fetched from the main tessdata repository.

Tesseract model flavours tessdata_fast models are smallest and fastest with lower accuracy, suited to bulk search indexing. Standard tessdata models, usually installed by distribution packages, balance speed and accuracy. tessdata_best models are largest and slowest, two to three times the recognition time, with the highest accuracy, suited to names, amounts and identifiers where errors are costly. Models Accuracy Speed Use for tessdata_fast lower fastest bulk indexing tessdata (packaged) good fast general OCR tessdata_best highest 2-3x slower amounts and names

Normalising Text for Matching Without Losing Accents

Keep OCR output accurate, and handle matching tolerance in comparison code rather than by stripping accents from stored data. Compare with a folded key while storing the original text:

# stdlib only
import unicodedata

def match_key(text: str) -> str:
    """Case- and accent-insensitive key for matching; never store it in place of the text."""
    decomposed = unicodedata.normalize("NFKD", text)
    no_marks = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
    return " ".join(no_marks.replace("ß", "ss").replace("ł", "l").casefold().split())

assert match_key("Hauptstraße 12, München") == match_key("HAUPTSTRASSE 12, Munchen")

ß and ł are not decomposable into a base letter plus a mark, so they need explicit replacements. Using a key like this for supplier lookup tolerates OCR accent errors and inconsistent master data, while invoices and registers keep correctly spelled names — which matters when those names are printed on payments or sent back to suppliers.

Verification

Build a small ground-truth set per language — a few lines typed by hand from real documents — and measure character accuracy, with accented characters counted separately.

# pip install pytesseract pillow
import difflib
from pathlib import Path
import pytesseract
from PIL import Image

def char_accuracy(expected: str, actual: str) -> float:
    return difflib.SequenceMatcher(None, " ".join(expected.split()), " ".join(actual.split())).ratio()

def verify_language(samples: dict[str, str], lang: str, min_accuracy: float = 0.97) -> None:
    for image_name, truth in samples.items():
        text = pytesseract.image_to_string(Image.open(Path("truth") / image_name).convert("L"), lang=lang)
        accuracy = char_accuracy(truth, text)
        accented_truth = [c for c in truth if not c.isascii()]
        accented_found = sum(1 for c in accented_truth if c in text)
        assert accuracy >= min_accuracy, f"{image_name}: accuracy {accuracy:.3f}"
        assert accented_found >= 0.9 * len(accented_truth), \
            f"{image_name}: only {accented_found}/{len(accented_truth)} accented characters recognised"
    print(f"{lang}: {len(samples)} sample(s) meet accuracy targets")

if __name__ == "__main__":
    verify_language({"de-line-01.png": "Rechnungsbetrag für Lieferung vom 12.09.2026 – Hauptstraße 12, München"},
                    "deu+eng")

Tracking accented characters separately is what catches a missing language pack: overall accuracy can stay above 95 percent while every umlaut is wrong, because accents are a small fraction of characters.

FAQ

What are the language codes? ISO 639-2/T three-letter codes: deu German, fra French, pol Polish, spa Spanish, ita Italian, nld Dutch, rus Russian, chi_sim Simplified Chinese.

Can I use script/Latin instead of individual languages? Yes — script models cover all languages written in a script and are useful for mixed inboxes, at some cost in accuracy compared with the specific language.

Why does OCRmyPDF need language codes too? It passes them to Tesseract. Use the same -l deu+eng there, as in make scanned PDFs searchable with OCRmyPDF.

How do I OCR documents with right-to-left scripts such as Arabic or Hebrew? Install ara or heb models and request them explicitly. Tesseract outputs logical order, which may look reversed in terminals that do not support bidirectional text; check the result in an editor with proper RTL rendering before assuming it is wrong.

Can I restrict recognition to certain characters?-c tessedit_char_whitelist=0123456789,. works with the legacy engine but is unreliable with LSTM models. For numeric fields, OCR normally and validate with a regular expression instead.

Do I need special fonts for accented output? Not for OCR text. Fonts matter only when rendering the text into new documents, as covered in fix ReportLab Unicode font errors.

Part of Scanning and OCR Processing with Python.

/html>