From a2a4c6fc9a077a80d1877a441f61f72c5e611e58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:47:12 +0000 Subject: [PATCH] feat(ocr): add multi-language OCR support with per-pipeline language override - Add OCR_LANGUAGES constant (28 languages, EN/DE/FR/ES/IT/PT/RU/ZH/JA/KO/AR/etc.) - Add TESSERACT_TO_EASYOCR mapping for automatic code translation - Add optional language constructor arg to TesseractOCRProvider/EasyOCRProvider - Update get_ocr_providers() to accept and pass per-call language override - Add language parameter to process_with_ocr Celery task - Add _get_pipeline_ocr_language() helper to resolve OCR language from pipeline step config - Update process_document to look up and pass pipeline OCR language to process_with_ocr - Add ocr_language select config field (28 options) to pipeline OCR step schema - Add language dropdown to pipeline UI (pipelines.html) - Update docs/UserGuide.md and docs/API.md with language override documentation - Add 27 new tests covering language constants, provider overrides, and pipeline lookup Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/pipelines.py | 43 ++++- app/tasks/process_document.py | 81 +++++++++- app/tasks/process_with_ocr.py | 23 ++- app/utils/ocr_provider.py | 146 ++++++++++++++++- docs/API.md | 32 +++- docs/UserGuide.md | 39 ++++- frontend/templates/pipelines.html | 47 ++++++ tests/test_ocr_provider_coverage.py | 238 ++++++++++++++++++++++++++++ tests/test_process_document.py | 177 +++++++++++++++++++++ 9 files changed, 808 insertions(+), 18 deletions(-) diff --git a/app/api/pipelines.py b/app/api/pipelines.py index 3980d71d..8175832f 100644 --- a/app/api/pipelines.py +++ b/app/api/pipelines.py @@ -51,7 +51,48 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = { "type": "boolean", "default": False, "description": "Always use cloud OCR even if the PDF already has embedded text.", - } + }, + "ocr_language": { + "type": "select", + "default": "auto", + "description": ( + "Language(s) used for OCR text extraction. Applies to Tesseract and EasyOCR " + "providers; Azure and Mistral perform auto-detection by default. " + "Use Tesseract codes such as 'eng', 'deu', or 'eng+deu' for multi-language " + "documents. 'auto' falls back to the global system setting." + ), + "options": [ + {"value": "auto", "label": "Auto (use system default)"}, + {"value": "ara", "label": "Arabic"}, + {"value": "chi_sim", "label": "Chinese (Simplified)"}, + {"value": "chi_tra", "label": "Chinese (Traditional)"}, + {"value": "ces", "label": "Czech"}, + {"value": "dan", "label": "Danish"}, + {"value": "nld", "label": "Dutch"}, + {"value": "eng", "label": "English"}, + {"value": "fin", "label": "Finnish"}, + {"value": "fra", "label": "French"}, + {"value": "deu", "label": "German"}, + {"value": "ell", "label": "Greek"}, + {"value": "heb", "label": "Hebrew"}, + {"value": "hin", "label": "Hindi"}, + {"value": "hun", "label": "Hungarian"}, + {"value": "ita", "label": "Italian"}, + {"value": "jpn", "label": "Japanese"}, + {"value": "kor", "label": "Korean"}, + {"value": "nor", "label": "Norwegian"}, + {"value": "pol", "label": "Polish"}, + {"value": "por", "label": "Portuguese"}, + {"value": "ron", "label": "Romanian"}, + {"value": "rus", "label": "Russian"}, + {"value": "spa", "label": "Spanish"}, + {"value": "swe", "label": "Swedish"}, + {"value": "tha", "label": "Thai"}, + {"value": "tur", "label": "Turkish"}, + {"value": "ukr", "label": "Ukrainian"}, + {"value": "vie", "label": "Vietnamese"}, + ], + }, }, }, "extract_metadata": { diff --git a/app/tasks/process_document.py b/app/tasks/process_document.py index 6eb2da13..3d51a400 100644 --- a/app/tasks/process_document.py +++ b/app/tasks/process_document.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import json import logging import mimetypes import os @@ -12,7 +13,7 @@ from pypdf.errors import PdfReadError from app.celery_app import celery from app.config import settings from app.database import SessionLocal -from app.models import FileRecord +from app.models import FileRecord, Pipeline, PipelineStep from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt from app.tasks.process_with_ocr import process_with_ocr from app.tasks.retry_config import BaseTaskWithRetry @@ -23,6 +24,69 @@ from app.utils.text_quality import check_text_quality, detect_pdf_text_source logger = logging.getLogger(__name__) +def _get_pipeline_ocr_language(db, file_record: FileRecord, owner_id: str | None) -> str | None: + """Look up the OCR language override from the file's pipeline OCR step config. + + Resolution order: + 1. Explicit pipeline assigned to the file (``file_record.pipeline_id``). + 2. User's own default pipeline (``owner_id``, ``is_default=True``). + 3. System default pipeline (``owner_id=NULL``, ``is_default=True``). + + Returns the ``ocr_language`` value from the pipeline's OCR step config, or + ``None`` when no override is configured. + """ + pipeline = None + + if file_record.pipeline_id: + pipeline = db.query(Pipeline).filter(Pipeline.id == file_record.pipeline_id).first() + + if pipeline is None and owner_id: + pipeline = ( + db.query(Pipeline) + .filter( + Pipeline.owner_id == owner_id, + Pipeline.is_default.is_(True), + Pipeline.is_active.is_(True), + ) + .first() + ) + + if pipeline is None: + pipeline = ( + db.query(Pipeline) + .filter( + Pipeline.owner_id.is_(None), + Pipeline.is_default.is_(True), + Pipeline.is_active.is_(True), + ) + .first() + ) + + if pipeline is None: + return None + + ocr_step = ( + db.query(PipelineStep) + .filter( + PipelineStep.pipeline_id == pipeline.id, + PipelineStep.step_type == "ocr", + PipelineStep.enabled.is_(True), + ) + .first() + ) + + if ocr_step is None or not ocr_step.config: + return None + + try: + step_config = json.loads(ocr_step.config) + lang = step_config.get("ocr_language") or None + # "auto" is treated as no override + return lang if lang and lang != "auto" else None + except Exception: + return None + + @celery.task(base=BaseTaskWithRetry, bind=True) def process_document( self, @@ -109,6 +173,7 @@ def process_document( ) # Acquire DB session in the task + ocr_language: str | None = None # Pipeline OCR language override resolved inside DB session with SessionLocal() as db: # When file_id is provided, we are reprocessing an existing file. # Skip the duplicate check and reuse the existing record. @@ -305,6 +370,14 @@ def process_document( new_record.local_filename = new_local_path db.commit() + # Look up pipeline OCR language override before the session closes. + # This reads the OCR step config from the file's assigned pipeline (or + # the user/system default pipeline) so the language is available when + # dispatching process_with_ocr below. + ocr_language = _get_pipeline_ocr_language(db, new_record, owner_id) + if ocr_language: + logger.info(f"[{task_id}] Pipeline OCR language override: {ocr_language!r}") + # Store file_id before session closes to avoid DetachedInstanceError file_id = new_record.id @@ -334,7 +407,7 @@ def process_document( "Queued for forced OCR processing", file_id=file_id, ) - process_with_ocr.delay(new_filename, file_id) + process_with_ocr.delay(new_filename, file_id, language=ocr_language) return {"file": new_local_path, "status": "Queued for forced OCR", "file_id": file_id} # If the file is not a PDF, skip embedded text check and convert to PDF first @@ -491,7 +564,7 @@ def process_document( "Queued for OCR (text quality too low)", file_id=file_id, ) - process_with_ocr.delay(new_filename, file_id, extracted_text) + process_with_ocr.delay(new_filename, file_id, extracted_text, language=ocr_language) return { "file": new_local_path, "status": "Queued for OCR (poor embedded text quality)", @@ -564,5 +637,5 @@ def process_document( "Queued for OCR processing", file_id=file_id, ) - process_with_ocr.delay(new_filename, file_id) + process_with_ocr.delay(new_filename, file_id, language=ocr_language) return {"file": new_local_path, "status": "Queued for OCR", "file_id": file_id} diff --git a/app/tasks/process_with_ocr.py b/app/tasks/process_with_ocr.py index 8d4817ce..8447ec1f 100644 --- a/app/tasks/process_with_ocr.py +++ b/app/tasks/process_with_ocr.py @@ -33,7 +33,13 @@ logger = logging.getLogger(__name__) @celery.task(base=OcrTaskWithRetry, bind=True) -def process_with_ocr(self, filename: str, file_id: Optional[int] = None, original_text: Optional[str] = None): +def process_with_ocr( + self, + filename: str, + file_id: Optional[int] = None, + original_text: Optional[str] = None, + language: Optional[str] = None, +): """Run the configured OCR providers on *filename* and continue the pipeline. When multiple OCR providers are configured the results are merged using the @@ -47,6 +53,10 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina filename: Base name of the file inside ``/tmp/``. file_id: Optional database record ID passed through to downstream tasks. original_text: Optional original embedded text for head-to-head comparison. + language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``) + to override the global OCR language settings for this specific run. + Pass ``None`` or ``"auto"`` to use the global settings. This + enables per-pipeline language configuration. """ task_id = self.request.id log_task_progress( @@ -62,7 +72,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina if not os.path.exists(tmp_file_path): raise FileNotFoundError(f"Local file not found: {tmp_file_path}") - providers = get_ocr_providers() + providers = get_ocr_providers(language=language) provider_names = [p.name for p in providers] logger.info(f"[{task_id}] Running {len(providers)} OCR provider(s): {provider_names}") @@ -122,7 +132,12 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina # PDF with ocrmypdf to embed an invisible text layer so the output is # selectable/searchable in PDF viewers. if searchable_pdf_path is None: - lang = getattr(settings, "tesseract_language", None) or "eng" + # Use the per-call language override; fall back to global setting + embed_lang = ( + language + if language and language != "auto" + else (getattr(settings, "tesseract_language", None) or "eng") + ) log_task_progress( task_id, "embed_text_layer", @@ -130,7 +145,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina "Embedding searchable text layer into PDF", file_id=file_id, ) - embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=lang) + embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=embed_lang) if embedded: searchable_pdf_path = tmp_file_path log_task_progress( diff --git a/app/utils/ocr_provider.py b/app/utils/ocr_provider.py index 4cf1ef0b..41bdbee7 100644 --- a/app/utils/ocr_provider.py +++ b/app/utils/ocr_provider.py @@ -192,6 +192,95 @@ class OCRResult: ) +# --------------------------------------------------------------------------- +# Multi-language support +# --------------------------------------------------------------------------- + +#: Canonical list of supported OCR languages for pipeline configuration. +#: Keys are display names; values are Tesseract language code(s). +#: Tesseract codes are used as the canonical format because they are the most +#: widely applicable across self-hosted providers (Tesseract + ocrmypdf). +#: "auto" falls back to the global ``tesseract_language`` / ``easyocr_languages`` +#: settings (i.e. no per-call override). +OCR_LANGUAGES: Dict[str, str] = { + "Auto (use system default)": "auto", + "Arabic": "ara", + "Chinese (Simplified)": "chi_sim", + "Chinese (Traditional)": "chi_tra", + "Czech": "ces", + "Danish": "dan", + "Dutch": "nld", + "English": "eng", + "Finnish": "fin", + "French": "fra", + "German": "deu", + "Greek": "ell", + "Hebrew": "heb", + "Hindi": "hin", + "Hungarian": "hun", + "Italian": "ita", + "Japanese": "jpn", + "Korean": "kor", + "Norwegian": "nor", + "Polish": "pol", + "Portuguese": "por", + "Romanian": "ron", + "Russian": "rus", + "Spanish": "spa", + "Swedish": "swe", + "Thai": "tha", + "Turkish": "tur", + "Ukrainian": "ukr", + "Vietnamese": "vie", +} + +#: Mapping from Tesseract language codes to EasyOCR language codes. +#: Used when ``TesseractOCRProvider``-style codes are specified but EasyOCR is +#: the active provider. Codes not present in this map are passed through as-is +#: (EasyOCR accepts its own ISO 639-1 codes such as ``"en"`` or ``"de"``). +TESSERACT_TO_EASYOCR: Dict[str, str] = { + "ara": "ar", + "ces": "cs", + "chi_sim": "ch_sim", + "chi_tra": "ch_tra", + "dan": "da", + "deu": "de", + "ell": "el", + "eng": "en", + "fin": "fi", + "fra": "fr", + "heb": "he", + "hin": "hi", + "hun": "hu", + "ita": "it", + "jpn": "ja", + "kor": "ko", + "nld": "nl", + "nor": "no", + "pol": "pl", + "por": "pt", + "ron": "ro", + "rus": "ru", + "spa": "es", + "swe": "sv", + "tha": "th", + "tur": "tr", + "ukr": "uk", + "vie": "vi", +} + + +def _tesseract_codes_to_easyocr(tesseract_lang: str) -> List[str]: + """Convert a Tesseract language string (e.g. ``"eng+deu"``) to a list of + EasyOCR language codes (e.g. ``["en", "de"]``). + + Unknown codes are passed through unchanged, so native EasyOCR codes such + as ``"en"`` also work transparently. + """ + codes = [part.strip() for part in tesseract_lang.split("+") if part.strip()] + return [TESSERACT_TO_EASYOCR.get(code, code) for code in codes] + + class OCRProvider(ABC): """Abstract base class for OCR providers. @@ -290,10 +379,24 @@ class TesseractOCRProvider(OCRProvider): - ``tesseract_cmd`` – path to the ``tesseract`` binary (optional). - ``tesseract_language`` – Tesseract language code(s), e.g. ``"eng"`` or ``"eng+deu"`` (default: ``"eng"``). + + The optional *language* constructor argument overrides the global + ``tesseract_language`` setting for this specific provider instance, enabling + per-pipeline language configuration. """ name = "tesseract" + def __init__(self, language: Optional[str] = None) -> None: + """Initialise the Tesseract provider. + + Args: + language: Optional Tesseract language code(s) to use instead of the + global ``tesseract_language`` setting (e.g. ``"eng+deu"``). + Pass ``None`` or ``"auto"`` to use the global setting. + """ + self._language_override: Optional[str] = language if language and language != "auto" else None + def process(self, file_path: str) -> OCRResult: try: import pytesseract @@ -308,7 +411,7 @@ class TesseractOCRProvider(OCRProvider): if tesseract_cmd: pytesseract.pytesseract.tesseract_cmd = tesseract_cmd - lang = getattr(settings, "tesseract_language", None) or "eng" + lang = self._language_override or getattr(settings, "tesseract_language", None) or "eng" # Ensure language data files are present; attempt download if missing. from app.utils.ocr_language_manager import ensure_tesseract_languages # noqa: PLC0415 @@ -349,10 +452,26 @@ class EasyOCRProvider(OCRProvider): - ``easyocr_languages`` – comma-separated list of language codes (default: ``"en"``). - ``easyocr_gpu`` – whether to use GPU acceleration (default: ``False``). + + The optional *language* constructor argument accepts a Tesseract-style + language string (e.g. ``"eng+deu"``) which is automatically translated to + EasyOCR codes (e.g. ``["en", "de"]``), overriding the global + ``easyocr_languages`` setting for this provider instance. """ name = "easyocr" + def __init__(self, language: Optional[str] = None) -> None: + """Initialise the EasyOCR provider. + + Args: + language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``) + or a comma-separated EasyOCR language list (e.g. ``"en,de"``). + Pass ``None`` or ``"auto"`` to use the global ``easyocr_languages`` + setting. + """ + self._language_override: Optional[str] = language if language and language != "auto" else None + def process(self, file_path: str) -> OCRResult: try: import easyocr @@ -363,8 +482,12 @@ class EasyOCRProvider(OCRProvider): "Install them with: pip install easyocr pdf2image" ) from exc - lang_str = getattr(settings, "easyocr_languages", None) or "en" - langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()] + if self._language_override: + # Convert Tesseract-style codes to EasyOCR codes + langs = _tesseract_codes_to_easyocr(self._language_override) + else: + lang_str = getattr(settings, "easyocr_languages", None) or "en" + langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()] gpu = getattr(settings, "easyocr_gpu", False) logger.info(f"[EasyOCR] Processing {os.path.basename(file_path)} (langs={langs}, gpu={gpu})") @@ -679,23 +802,36 @@ KNOWN_OCR_PROVIDERS: List[str] = sorted(_PROVIDER_MAP.keys()) MAX_OCR_TEXT_FOR_AI_MERGE = 4000 -def get_ocr_providers() -> List[OCRProvider]: +def get_ocr_providers(language: Optional[str] = None) -> List[OCRProvider]: """Return a list of configured OCR provider instances. Reads ``settings.ocr_providers`` (comma-separated provider names) and returns one instantiated provider per entry. Falls back to ``["azure"]`` when the setting is absent. + + Args: + language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``) + to override the global language settings for providers that support + per-call language configuration (Tesseract and EasyOCR). Pass + ``None`` or ``"auto"`` to use the global settings. """ raw = getattr(settings, "ocr_providers", None) or "azure" provider_names = [name.strip().lower() for name in raw.split(",") if name.strip()] + # Normalise "auto" to None so providers fall back to global settings + effective_language = language if language and language != "auto" else None + providers: List[OCRProvider] = [] for name in provider_names: cls = _PROVIDER_MAP.get(name) if cls is None: logger.warning(f"Unknown OCR provider '{name}' in OCR_PROVIDERS – skipping.") continue - providers.append(cls()) + # Pass language override to providers that support per-call language config + if effective_language is not None and name in ("tesseract", "easyocr"): + providers.append(cls(language=effective_language)) + else: + providers.append(cls()) logger.debug(f"Registered OCR provider: {name}") if not providers: diff --git a/docs/API.md b/docs/API.md index 7766e8d9..05e168cc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1785,12 +1785,27 @@ Returns the catalogue of built-in step types. "label": "OCR Processing", "description": "Extract text using Azure Document Intelligence or local Tesseract.", "config_schema": { - "force_cloud_ocr": { "type": "boolean", "default": false } + "force_cloud_ocr": { "type": "boolean", "default": false }, + "ocr_language": { + "type": "select", + "default": "auto", + "description": "Language(s) for OCR. Overrides the global setting for Tesseract/EasyOCR. Azure/Mistral auto-detect.", + "options": [ + { "value": "auto", "label": "Auto (use system default)" }, + { "value": "eng", "label": "English" }, + { "value": "deu", "label": "German" }, + { "value": "fra", "label": "French" }, + { "value": "spa", "label": "Spanish" }, + "..." + ] + } } } } ``` +The `ocr_language` field accepts Tesseract language codes (e.g. `"eng"`, `"deu"`, `"eng+deu"` for multi-language) or `"auto"` to fall back to the global system setting. The full list of 28 supported language codes is returned by the step-types endpoint. + ### List pipelines ```bash @@ -1883,12 +1898,23 @@ Content-Type: application/json { "step_type": "ocr", - "label": "Cloud OCR", - "config": { "force_cloud_ocr": true }, + "label": "German OCR", + "config": { "force_cloud_ocr": false, "ocr_language": "deu" }, "enabled": true } ``` +Multi-language (Tesseract `+`-separated codes): + +```bash +{ + "step_type": "ocr", + "config": { "ocr_language": "eng+deu" } +} +``` + +Use `"ocr_language": "auto"` (or omit the field) to fall back to the global system language setting. + ### Update step ```bash diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 7ad1776d..15da09b3 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -519,13 +519,50 @@ Processing pipelines let you define exactly what happens to your documents when |-----------|-------------| | `convert_to_pdf` | Convert non-PDF files to PDF using Gotenberg | | `check_duplicates` | Detect duplicate files by content hash | -| `ocr` | Extract text with Azure Document Intelligence or local Tesseract | +| `ocr` | Extract text with OCR (supports multi-language configuration, see below) | | `extract_metadata` | Extract structured metadata (type, sender, tags) with AI | | `embed_metadata` | Write extracted metadata into the PDF document properties | | `compute_embedding` | Compute semantic embeddings for similarity search | | `send_to_destinations` | Upload the processed document to all configured storage destinations | | `classify` | Classify the document type with AI | +#### OCR step options + +The `ocr` step supports two optional configuration fields: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `force_cloud_ocr` | boolean | `false` | Always run cloud OCR even if the PDF already has embedded text | +| `ocr_language` | string | `"auto"` | Language(s) to use for OCR text extraction (see below) | + +**`ocr_language` — per-pipeline language override** + +This option enables manual language control per pipeline, overriding the global Tesseract/EasyOCR language settings for all documents processed by that pipeline. The following values are supported (28 languages total): + +| Value | Language | Value | Language | +|-------|----------|-------|----------| +| `auto` | Auto (use system default) | `jpn` | Japanese | +| `ara` | Arabic | `kor` | Korean | +| `chi_sim` | Chinese (Simplified) | `nor` | Norwegian | +| `chi_tra` | Chinese (Traditional) | `pol` | Polish | +| `ces` | Czech | `por` | Portuguese | +| `dan` | Danish | `ron` | Romanian | +| `nld` | Dutch | `rus` | Russian | +| `eng` | English | `spa` | Spanish | +| `fin` | Finnish | `swe` | Swedish | +| `fra` | French | `tha` | Thai | +| `deu` | German | `tur` | Turkish | +| `ell` | Greek | `ukr` | Ukrainian | +| `heb` | Hebrew | `vie` | Vietnamese | +| `hin` | Hindi | | | +| `hun` | Hungarian | | | +| `ita` | Italian | | | + +> **Notes:** +> - The language override applies to **Tesseract** and **EasyOCR** providers. **Azure Document Intelligence** and **Mistral OCR** perform automatic language detection regardless of this setting. +> - For multi-language documents with Tesseract, combine codes with `+`, e.g. `eng+deu`. +> - Setting `ocr_language` to `auto` or leaving it unset uses the global `TESSERACT_LANGUAGE` / `EASYOCR_LANGUAGES` environment variables. + ### Assigning a pipeline to a file You can assign (or change) the pipeline for an individual document via the file detail page or the API: diff --git a/frontend/templates/pipelines.html b/frontend/templates/pipelines.html index 92d04b07..9bd718a8 100644 --- a/frontend/templates/pipelines.html +++ b/frontend/templates/pipelines.html @@ -404,6 +404,53 @@ + + +