145 lines
4.2 KiB
Python
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))
|