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.
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.
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.
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.
Related
- Scanning and OCR Processing with Python — the complete OCR workflow
- Fix Tesseract Not Found Error — installing Tesseract itself
- Fix OCR Garbage on Rotated Scans — the other common cause of nonsense output
- Fixing Encoding Errors in CSV Files — keeping accented OCR output intact when exporting