From 2b2a97c2fa32e3aaaf7a02ed3856dbd927b8b856 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:02:19 +0000 Subject: [PATCH] fix(ocr): ensure Tesseract language data for embed_text_layer regardless of active OCR provider Root cause: ensure_ocr_languages_from_settings() only downloaded tessdata when the 'tesseract' provider was active, but embed_text_layer() uses ocrmypdf (which needs tessdata) as a fallback for ALL OCR providers. - embed_text_layer(): call ensure_tesseract_languages(language) after confirming ocrmypdf is on PATH, so language data is present before ocrmypdf is invoked (prevents exit code 3 for fra/deu/etc.) - ensure_ocr_languages_from_settings(): extend the condition from 'tesseract' in active_providers to also trigger when ocrmypdf is on PATH, enabling proactive pre-download at startup for any config - Tests: mock shutil.which and ensure_tesseract_languages in affected test cases; rename azure-only test and add new test for ocrmypdf case Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/utils/ocr_language_manager.py | 13 +++++++++++- app/utils/ocr_provider.py | 13 ++++++++++++ tests/test_ocr_language_manager.py | 32 +++++++++++++++++++++++++++--- tests/test_ocr_processing.py | 5 +++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/utils/ocr_language_manager.py b/app/utils/ocr_language_manager.py index 2527cf86..2cea0635 100644 --- a/app/utils/ocr_language_manager.py +++ b/app/utils/ocr_language_manager.py @@ -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) diff --git a/app/utils/ocr_provider.py b/app/utils/ocr_provider.py index c47b8ee7..4cf1ef0b 100644 --- a/app/utils/ocr_provider.py +++ b/app/utils/ocr_provider.py @@ -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 diff --git a/tests/test_ocr_language_manager.py b/tests/test_ocr_language_manager.py index 6b276439..ad0f3861 100644 --- a/tests/test_ocr_language_manager.py +++ b/tests/test_ocr_language_manager.py @@ -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 diff --git a/tests/test_ocr_processing.py b/tests/test_ocr_processing.py index 3329613f..1dbadf7e 100644 --- a/tests/test_ocr_processing.py +++ b/tests/test_ocr_processing.py @@ -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), ):