# SPDX-FileCopyrightText: 2026 James R. Barlow # SPDX-License-Identifier: AGPL-3.0-or-later """Runtime configuration for the OCRmyPDF batch web interface. Everything is driven by environment variables so the container can be tuned without rebuilding the image. """ from __future__ import annotations import os import shutil import subprocess from dataclasses import dataclass from functools import lru_cache from pathlib import Path ENV_PREFIX = "OCRMYPDF_WEBUI_" #: Input types OCRmyPDF can consume. Anything else is rejected up front. ALLOWED_SUFFIXES = frozenset( { ".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp", ".heic", ".heif", } ) def _env(name: str, default: str) -> str: return os.environ.get(ENV_PREFIX + name, default) def _env_int(name: str, default: int, *, minimum: int = 1) -> int: raw = os.environ.get(ENV_PREFIX + name) if raw is None or raw.strip() == "": return default try: value = int(raw) except ValueError as exc: raise ValueError( f"{ENV_PREFIX + name} must be an integer, got {raw!r}" ) from exc if value < minimum: raise ValueError(f"{ENV_PREFIX + name} must be >= {minimum}, got {value}") return value @dataclass(frozen=True) class Settings: """Resolved settings for one process.""" work_dir: Path """Scratch directory holding uploads and results.""" max_upload_bytes: int """Largest accepted size for a single uploaded file.""" max_files_per_batch: int """Largest number of files accepted in one submission.""" workers: int """How many files are OCR'd concurrently.""" ocr_jobs: int """Value passed to ``ocrmypdf --jobs`` for each file.""" batch_ttl_seconds: int """How long a finished batch is retained before deletion.""" job_timeout_seconds: int """Wall-clock limit for OCR of a single file.""" max_log_lines: int """Number of ocrmypdf output lines retained per file.""" @classmethod def from_env(cls) -> Settings: """Build settings from ``OCRMYPDF_WEBUI_*`` environment variables.""" cpus = os.cpu_count() or 2 # Split the machine between concurrent files and OCRmyPDF's own # per-file parallelism, rather than letting both claim every core. default_workers = max(1, min(4, cpus // 2)) default_ocr_jobs = max(1, cpus // default_workers) work_dir = Path(_env("WORK_DIR", "/var/tmp/ocrmypdf-webui")) return cls( work_dir=work_dir, max_upload_bytes=_env_int("MAX_UPLOAD_MB", 500) * 1024 * 1024, max_files_per_batch=_env_int("MAX_FILES", 50), workers=_env_int("WORKERS", default_workers), ocr_jobs=_env_int("OCR_JOBS", default_ocr_jobs), batch_ttl_seconds=_env_int("BATCH_TTL_SECONDS", 3600, minimum=60), job_timeout_seconds=_env_int("JOB_TIMEOUT_SECONDS", 1800, minimum=30), max_log_lines=_env_int("MAX_LOG_LINES", 200, minimum=10), ) @lru_cache(maxsize=1) def get_settings() -> Settings: """Return the process-wide settings, resolved once.""" return Settings.from_env() @lru_cache(maxsize=1) def installed_languages() -> tuple[str, ...]: """Return the Tesseract language packs installed in this container. The result doubles as an allowlist: a language is only ever forwarded to ocrmypdf if it appears here, so user input can never reach the command line verbatim. """ tesseract = shutil.which("tesseract") if not tesseract: return ("eng",) try: proc = subprocess.run( [tesseract, "--list-langs"], capture_output=True, text=True, timeout=30, check=False, ) except (OSError, subprocess.SubprocessError): return ("eng",) # First line is a header ("List of available languages ..."); the rest are # language codes. "osd" is orientation detection, not a real language. langs = { line.strip() for line in proc.stdout.splitlines()[1:] if line.strip() and line.strip() != "osd" and line.strip().isascii() } if not langs: return ("eng",) return tuple(sorted(langs))