Files
OCRmyPDF/webui/config.py
T

145 lines
4.2 KiB
Python

# 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))