Merge pull request #568 from christianlouis/copilot/add-multi-language-ocr-support

feat(ocr): per-pipeline language override for multi-language OCR
This commit is contained in:
Christian Krakau-Louis
2026-03-08 23:11:41 +01:00
committed by GitHub
9 changed files with 816 additions and 19 deletions
+42 -1
View File
@@ -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": {
+83 -4
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import logging
import mimetypes
import os
import shutil
import uuid
from typing import TYPE_CHECKING
import pypdf # Upgraded from PyPDF2 to fix CVE-2023-36464
from pypdf.errors import PdfReadError
@@ -12,7 +16,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
@@ -20,9 +24,75 @@ from app.utils import get_unique_filepath_with_counter, hash_file, log_task_prog
from app.utils.step_manager import initialize_file_steps
from app.utils.text_quality import check_text_quality, detect_pdf_text_source
if TYPE_CHECKING:
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _get_pipeline_ocr_language(db: "Session", 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")
# "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 +179,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 +376,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 +413,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 +570,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 +643,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}
+19 -5
View File
@@ -17,7 +17,6 @@ task with a multi-engine OCR pipeline that:
import logging
import os
from typing import Optional
from app.celery_app import celery
from app.config import settings
@@ -33,7 +32,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: int | None = None,
original_text: str | None = None,
language: str | None = 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 +52,10 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
filename: Base name of the file inside ``<workdir>/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 +71,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 +131,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 +144,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(
+141 -5
View File
@@ -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: