feat(ocr): embed searchable text layer for providers without native PDF output

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-24 13:19:52 +00:00
parent e0c28e834f
commit fec032643b
7 changed files with 561 additions and 3 deletions
+32 -1
View File
@@ -21,7 +21,7 @@ from app.config import settings
from app.tasks.retry_config import BaseTaskWithRetry
from app.tasks.rotate_pdf_pages import rotate_pdf_pages
from app.utils import log_task_progress
from app.utils.ocr_provider import OCRResult, get_ocr_providers, merge_ocr_results
from app.utils.ocr_provider import OCRResult, embed_text_layer, get_ocr_providers, merge_ocr_results
logger = logging.getLogger(__name__)
@@ -107,6 +107,37 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None):
f"rotations={len(rotation_data)}"
)
# If no provider produced a searchable PDF, post-process the original
# 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"
log_task_progress(
task_id,
"embed_text_layer",
"in_progress",
"Embedding searchable text layer into PDF",
file_id=file_id,
)
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=lang)
if embedded:
searchable_pdf_path = tmp_file_path
log_task_progress(
task_id,
"embed_text_layer",
"success",
"Searchable text layer embedded via ocrmypdf",
file_id=file_id,
)
else:
log_task_progress(
task_id,
"embed_text_layer",
"skipped",
"ocrmypdf unavailable PDF will not have a searchable text layer",
file_id=file_id,
)
log_task_progress(
task_id,
"process_with_ocr",
+119
View File
@@ -10,10 +10,42 @@ Provider selection is controlled by the ``OCR_PROVIDERS`` environment variable
(comma-separated list, e.g. ``azure,tesseract``). When multiple providers are
specified, all enabled providers run in parallel and the results are
cross-checked by the configured AI model to produce the best final output.
**Searchable PDF support by provider**:
+---------------------+---------------------------+-----------------------------+
| Provider | Embeds text layer in PDF? | Notes |
+=====================+===========================+=============================+
| azure | Yes | Returns PDF/A with text |
| | | layer from Document |
| | | Intelligence. |
+---------------------+---------------------------+-----------------------------+
| tesseract | No (text only) | Falls back to |
| | | ``embed_text_layer``. |
+---------------------+---------------------------+-----------------------------+
| easyocr | No (text only) | Falls back to |
| | | ``embed_text_layer``. |
+---------------------+---------------------------+-----------------------------+
| mistral | No (text only) | Falls back to |
| | | ``embed_text_layer``. |
+---------------------+---------------------------+-----------------------------+
| google_docai | No (text only) | Falls back to |
| | | ``embed_text_layer``. |
+---------------------+---------------------------+-----------------------------+
| aws_textract | No (text only) | Falls back to |
| | | ``embed_text_layer``. |
+---------------------+---------------------------+-----------------------------+
Providers that do **not** embed a text layer return ``searchable_pdf_path=None``
in their :class:`OCRResult`. The :func:`embed_text_layer` helper can be used
as a post-processing step to add a searchable text layer via ``ocrmypdf``
(which uses Tesseract for layout analysis and text positioning).
"""
import logging
import os
import shutil
import subprocess
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple
@@ -22,6 +54,93 @@ from app.config import settings
logger = logging.getLogger(__name__)
def embed_text_layer(input_pdf_path: str, output_pdf_path: str, *, language: str = "eng") -> bool:
"""Embed a searchable text layer into a PDF using ``ocrmypdf``.
This function is used as a post-processing step for OCR providers that
return plain text only (Tesseract, EasyOCR, Mistral, Google DocAI, AWS
Textract). It calls ``ocrmypdf --skip-text`` which runs Tesseract under
the hood to detect text regions and embed an invisible text layer that
makes the PDF content selectable and searchable in PDF viewers.
Pages that already contain embedded text (e.g. from Azure Document
Intelligence) are skipped automatically by ``--skip-text``.
Args:
input_pdf_path: Absolute path to the source PDF file.
output_pdf_path: Absolute path where the searchable PDF is written.
If equal to *input_pdf_path* the file is overwritten in-place.
language: Tesseract language code(s) passed to ``ocrmypdf`` via
``-l``. Defaults to ``"eng"``. Use ``+``-separated codes for
multi-language documents, e.g. ``"eng+deu"``.
Returns:
``True`` when a searchable PDF was written successfully, ``False``
when ``ocrmypdf`` is not available or the process fails (a warning is
logged in the latter case so callers can degrade gracefully).
Raises:
FileNotFoundError: If *input_pdf_path* does not exist.
"""
if not os.path.exists(input_pdf_path):
raise FileNotFoundError(f"embed_text_layer: input file not found: {input_pdf_path}")
ocrmypdf_bin = shutil.which("ocrmypdf")
if ocrmypdf_bin is None:
logger.warning(
"ocrmypdf not found on PATH skipping text-layer embedding. "
"Install ocrmypdf (and tesseract-ocr) to enable searchable PDF output."
)
return False
in_place = input_pdf_path == output_pdf_path
if in_place:
import tempfile
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".pdf", dir=os.path.dirname(input_pdf_path))
os.close(tmp_fd)
final_output = tmp_path
else:
final_output = output_pdf_path
cmd = [
ocrmypdf_bin,
"--skip-text", # skip pages that already have a text layer (e.g. Azure output)
"--quiet", # suppress progress output; errors still appear on stderr
"-l",
language,
input_pdf_path,
final_output,
]
logger.info(f"[embed_text_layer] Running: {' '.join(cmd)}")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, check=False) # noqa: S603
except subprocess.TimeoutExpired:
logger.warning("[embed_text_layer] ocrmypdf timed out after 600 s; skipping text-layer embedding")
if in_place and os.path.exists(final_output):
os.remove(final_output)
return False
if proc.returncode != 0:
stderr_snippet = proc.stderr.strip()[:500]
logger.warning(
f"[embed_text_layer] ocrmypdf exited with code {proc.returncode}; "
f"skipping text-layer embedding. stderr: {stderr_snippet}"
)
if in_place and os.path.exists(final_output):
os.remove(final_output)
return False
if in_place:
# Atomically replace the original file with the processed output.
os.replace(final_output, input_pdf_path)
logger.info(f"[embed_text_layer] Searchable PDF written to {output_pdf_path}")
return True
class OCRResult:
"""Container for a single OCR provider's output.