Merge pull request #399 from christianlouis/copilot/fix-ocr-language-data-issue

fix(ocr): ensure Tesseract language data is available for embed_text_layer regardless of active OCR provider
This commit is contained in:
Christian Krakau-Louis
2026-02-24 19:13:32 +01:00
committed by GitHub
4 changed files with 59 additions and 4 deletions
+12 -1
View File
@@ -279,6 +279,12 @@ def ensure_ocr_languages_from_settings() -> dict[str, list[str]]:
present. Missing Tesseract tessdata files are downloaded automatically;
missing EasyOCR models are downloaded via the library's built-in mechanism.
Tesseract language data is always ensured when ``ocrmypdf`` is available on
the system, regardless of which OCR providers are active. This is required
because :func:`~app.utils.ocr_provider.embed_text_layer` uses ``ocrmypdf``
(and therefore Tesseract) as a post-processing fallback for **all** OCR
providers not only the ``tesseract`` provider.
This function is idempotent calling it multiple times is safe.
Returns:
@@ -297,7 +303,12 @@ def ensure_ocr_languages_from_settings() -> dict[str, list[str]]:
providers_raw = getattr(settings, "ocr_providers", None) or "azure"
active_providers = {p.strip().lower() for p in providers_raw.split(",") if p.strip()}
if "tesseract" in active_providers:
# Always ensure Tesseract language data when ocrmypdf is on the system PATH.
# embed_text_layer() calls ocrmypdf as a text-layer post-processor for every
# OCR provider that does not natively produce a searchable PDF (azure,
# easyocr, mistral, google_docai, aws_textract). If the tessdata files are
# absent, ocrmypdf exits with code 3 and the text layer is silently skipped.
if "tesseract" in active_providers or shutil.which("ocrmypdf") is not None:
lang_str = getattr(settings, "tesseract_language", None) or "eng"
logger.info("Ensuring Tesseract language data for configured languages: %s", lang_str)
result["tesseract_missing"] = ensure_tesseract_languages(lang_str)
+13
View File
@@ -93,6 +93,19 @@ def embed_text_layer(input_pdf_path: str, output_pdf_path: str, *, language: str
)
return False
# Ensure Tesseract language data is present before invoking ocrmypdf.
# ocrmypdf uses Tesseract internally regardless of which OCR provider is
# active, so we must guarantee the tessdata files exist here.
from app.utils.ocr_language_manager import ensure_tesseract_languages # noqa: PLC0415
missing_langs = ensure_tesseract_languages(language)
if missing_langs:
logger.warning(
"[embed_text_layer] Missing Tesseract language data for: %s "
"text-layer embedding may fail or produce degraded results.",
", ".join(missing_langs),
)
in_place = input_pdf_path == output_pdf_path
if in_place:
import tempfile
+29 -3
View File
@@ -344,7 +344,10 @@ class TestEnsureOCRLanguagesFromSettings:
assert result == {"tesseract_missing": [], "easyocr_failed": []}
def test_runs_easyocr_check_when_provider_active(self):
"""Calls ensure_easyocr_models when 'easyocr' is in ocr_providers."""
"""Calls ensure_easyocr_models when 'easyocr' is in ocr_providers.
When ocrmypdf is absent, only easyocr language checks run.
"""
from app.utils.ocr_language_manager import ensure_ocr_languages_from_settings
mock_settings = self._mock_settings(providers="easyocr", easyocr_langs="en,de")
@@ -352,6 +355,7 @@ class TestEnsureOCRLanguagesFromSettings:
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]) as mock_tess,
patch("app.utils.ocr_language_manager.ensure_easyocr_models", return_value=[]) as mock_easy,
patch("app.config.settings", mock_settings),
patch("shutil.which", return_value=None),
):
result = ensure_ocr_languages_from_settings()
@@ -359,8 +363,8 @@ class TestEnsureOCRLanguagesFromSettings:
mock_easy.assert_called_once_with(["en", "de"])
assert result["easyocr_failed"] == []
def test_skips_both_when_only_azure(self):
"""Neither Tesseract nor EasyOCR checks run when only Azure is configured."""
def test_skips_both_when_only_azure_and_no_ocrmypdf(self):
"""Neither Tesseract nor EasyOCR checks run when only Azure is configured and ocrmypdf is absent."""
from app.utils.ocr_language_manager import ensure_ocr_languages_from_settings
mock_settings = self._mock_settings(providers="azure")
@@ -368,6 +372,7 @@ class TestEnsureOCRLanguagesFromSettings:
patch("app.utils.ocr_language_manager.ensure_tesseract_languages") as mock_tess,
patch("app.utils.ocr_language_manager.ensure_easyocr_models") as mock_easy,
patch("app.config.settings", mock_settings),
patch("shutil.which", return_value=None),
):
result = ensure_ocr_languages_from_settings()
@@ -375,6 +380,27 @@ class TestEnsureOCRLanguagesFromSettings:
mock_easy.assert_not_called()
assert result == {"tesseract_missing": [], "easyocr_failed": []}
def test_runs_tesseract_when_ocrmypdf_available_with_non_tesseract_provider(self):
"""Tesseract language check runs when ocrmypdf is on PATH even without the tesseract provider.
embed_text_layer() uses ocrmypdf internally regardless of the active OCR
provider. Pre-downloading language data on startup prevents code-3 failures.
"""
from app.utils.ocr_language_manager import ensure_ocr_languages_from_settings
mock_settings = self._mock_settings(providers="azure", tesseract_lang="eng+deu+fra")
with (
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]) as mock_tess,
patch("app.utils.ocr_language_manager.ensure_easyocr_models") as mock_easy,
patch("app.config.settings", mock_settings),
patch("shutil.which", return_value="/usr/local/bin/ocrmypdf"),
):
result = ensure_ocr_languages_from_settings()
mock_tess.assert_called_once_with("eng+deu+fra")
mock_easy.assert_not_called()
assert result == {"tesseract_missing": [], "easyocr_failed": []}
def test_runs_both_when_both_providers_active(self):
"""Both checks run when both 'tesseract' and 'easyocr' are in ocr_providers."""
from app.utils.ocr_language_manager import ensure_ocr_languages_from_settings
+5
View File
@@ -1075,6 +1075,7 @@ class TestEmbedTextLayer:
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
patch("subprocess.run", return_value=mock_proc) as mock_run,
):
result = embed_text_layer(pdf, output, language="eng")
@@ -1097,6 +1098,7 @@ class TestEmbedTextLayer:
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
patch("subprocess.run", return_value=mock_proc),
):
result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))
@@ -1113,6 +1115,7 @@ class TestEmbedTextLayer:
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600)),
):
result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))
@@ -1140,6 +1143,7 @@ class TestEmbedTextLayer:
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
patch("subprocess.run", side_effect=fake_run),
):
result = embed_text_layer(pdf, pdf)
@@ -1172,6 +1176,7 @@ class TestEmbedTextLayer:
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
patch("subprocess.run", return_value=mock_proc),
patch("tempfile.mkstemp", side_effect=fake_mkstemp),
):