diff --git a/.docker/Dockerfile b/.docker/Dockerfile index e9f8bf16..184b2ea5 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -55,7 +55,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen \ - --extra webservice --extra watcher --no-dev \ + --extra webservice --extra watcher --extra webui --no-dev \ --no-install-package pyarrow FROM base @@ -107,9 +107,22 @@ chown app:app /app # paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`). # The webservice/watcher are run by absolute path (/app/*.py), unaffected by this. RUN mkdir -p /data && chown app:app /data + +# Scratch space for the batch web interface (webui/). Uploads and results live +# here and are deleted once their batch expires; nothing in it needs to +# survive a restart, so it is a good candidate for a tmpfs mount. +RUN mkdir -p /var/tmp/ocrmypdf-webui && chown app:app /var/tmp/ocrmypdf-webui + WORKDIR /data ENV PATH="/app/.venv/bin:${PATH}" +# webui/ is a top-level package in the source tree rather than part of the +# installed ocrmypdf distribution, so it has to be on the import path. +ENV PYTHONPATH="/app" + +# Batch web interface. Not published by the default entrypoint; start it with +# docker run -p 8000:8000 --entrypoint python3 -m webui +EXPOSE 8000 # Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when # overridden) as the unprivileged app user. Override with `--user root` if you diff --git a/.docker/Dockerfile.alpine b/.docker/Dockerfile.alpine index 0ec390a0..76c8f904 100644 --- a/.docker/Dockerfile.alpine +++ b/.docker/Dockerfile.alpine @@ -39,7 +39,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen \ - --extra webservice --extra watcher --no-dev \ + --extra webservice --extra watcher --extra webui --no-dev \ --no-install-package pyarrow FROM base @@ -84,9 +84,22 @@ RUN rm -rf /app/.git && \ # paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`). # The webservice/watcher are run by absolute path (/app/*.py), unaffected by this. RUN mkdir -p /data && chown app:app /data + +# Scratch space for the batch web interface (webui/). Uploads and results live +# here and are deleted once their batch expires; nothing in it needs to +# survive a restart, so it is a good candidate for a tmpfs mount. +RUN mkdir -p /var/tmp/ocrmypdf-webui && chown app:app /var/tmp/ocrmypdf-webui + WORKDIR /data ENV PATH="/app/.venv/bin:${PATH}" +# webui/ is a top-level package in the source tree rather than part of the +# installed ocrmypdf distribution, so it has to be on the import path. +ENV PYTHONPATH="/app" + +# Batch web interface. Not published by the default entrypoint; start it with +# docker run -p 8000:8000 --entrypoint python3 -m webui +EXPOSE 8000 # Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when # overridden) as the unprivileged app user. Override with `--user root` if you diff --git a/docs/docker.md b/docs/docker.md index e3f24ec4..18cfc448 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -310,3 +310,55 @@ Affero GPLv3 (AGPLv3) since Ghostscript is also licensed in this way. In addition to the above, please read our `general remarks on using OCRmyPDF as a service `{.interpreted-text role="ref"}. + +Using the batch web interface +----------------------------- + +The Docker image also includes a batch web interface, in `webui/`. It +accepts many files in one submission, OCRs them in the background, and +returns the results individually or as a single zip archive. Start it +with: + +:::{code} bash +docker run --rm -p 8000:8000 --entrypoint python3 jbarlow83/ocrmypdf -m webui +::: + +Then open . + +A Compose file with sensible resource limits is provided: + +:::{code} bash +docker compose -f misc/docker-compose.webui.yml up --build +::: + +It is configured entirely through environment variables: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `OCRMYPDF_WEBUI_PORT` | `8000` | Port to listen on | +| `OCRMYPDF_WEBUI_HOST` | `0.0.0.0` | Address to bind | +| `OCRMYPDF_WEBUI_WORKERS` | half the CPUs, max 4 | Files OCR'd at once | +| `OCRMYPDF_WEBUI_OCR_JOBS` | CPUs ÷ workers | `--jobs` for each file | +| `OCRMYPDF_WEBUI_MAX_FILES` | `50` | Files allowed per submission | +| `OCRMYPDF_WEBUI_MAX_UPLOAD_MB` | `500` | Size limit per file | +| `OCRMYPDF_WEBUI_BATCH_TTL_SECONDS` | `3600` | Retention before deletion | +| `OCRMYPDF_WEBUI_JOB_TIMEOUT_SECONDS` | `1800` | Limit for one file | +| `OCRMYPDF_WEBUI_WORK_DIR` | `/var/tmp/ocrmypdf-webui` | Scratch space | + +`WORKERS × OCR_JOBS` should be roughly the number of cores available to +the container. + +:::{warning} +Like the Streamlit webservice above, the batch web interface has **no +authentication and no rate limiting**. Run it on a trusted network, or +behind a reverse proxy that terminates TLS and authenticates users. +Uploaded files and their results are readable by anyone who can reach +the server until their batch expires. +::: + +Because batch state is held in memory, the server must run as a single +process. To handle more load, increase `OCRMYPDF_WEBUI_WORKERS` rather +than starting additional server workers. + +This interface is also licensed under the Affero GPLv3, for the same +reason as the webservice above. diff --git a/docs/installation.md b/docs/installation.md index a70d8329..aec1ec82 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -788,7 +788,8 @@ User features are available as optional dependencies. Install them with `uv` (re ```bash # Using uv (recommended) uv sync --extra watcher # File watching service -uv sync --extra webservice # Streamlit web UI +uv sync --extra webservice # Streamlit web UI (single file) +uv sync --extra webui # Batch web interface (multi-file upload) uv sync --extra watcher --extra webservice # Multiple features ``` diff --git a/misc/docker-compose.webui.yml b/misc/docker-compose.webui.yml new file mode 100644 index 00000000..4052ca81 --- /dev/null +++ b/misc/docker-compose.webui.yml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: MIT +# +# Batch web interface for OCRmyPDF. +# +# docker compose -f misc/docker-compose.webui.yml up --build +# +# Then open http://localhost:8000/ +# +# There is no authentication. Run this on a trusted network, or put it behind +# a reverse proxy that handles TLS and access control. +--- +services: + ocrmypdf-webui: + build: + context: .. + dockerfile: .docker/Dockerfile + image: ocrmypdf-webui + container_name: ocrmypdf-webui + restart: unless-stopped + + # The image's default entrypoint is the ocrmypdf CLI; override it to start + # the web server instead. + entrypoint: ["/app/.venv/bin/python3", "-m", "webui"] + + ports: + - "8772:8000" + + environment: + # Files OCR'd concurrently. Each one also uses OCR_JOBS threads + # internally, so WORKERS x OCR_JOBS should be roughly your core count. + OCRMYPDF_WEBUI_WORKERS: "2" + OCRMYPDF_WEBUI_OCR_JOBS: "2" + # Limits on what a single submission may contain. + OCRMYPDF_WEBUI_MAX_FILES: "50" + OCRMYPDF_WEBUI_MAX_UPLOAD_MB: "500" + # Uploads and results are deleted this many seconds after the batch was + # submitted, whether or not they were downloaded. + OCRMYPDF_WEBUI_BATCH_TTL_SECONDS: "3600" + # Give up on any single file that takes longer than this. + OCRMYPDF_WEBUI_JOB_TIMEOUT_SECONDS: "1800" + + # Uploads and results are transient, so keep them in RAM and out of the + # container's writable layer. Size this above the largest batch you expect; + # drop this block to use ordinary container storage instead. + tmpfs: + - /var/tmp/ocrmypdf-webui:size=4g,mode=1777 + + healthcheck: + test: + - CMD + - /app/.venv/bin/python3 + - "-c" + - "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/healthz').read()" + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + security_opt: + - no-new-privileges:true diff --git a/pyproject.toml b/pyproject.toml index 354451a1..112668ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,10 +55,24 @@ Changelog = "https://github.com/ocrmypdf/OCRmyPDF/tree/main/docs/releasenotes" # User-installable features - use `uv sync --extra ` or `pip install ocrmypdf[name]` watcher = ["watchdog>=1.0.2", "cyclopts>=3", "python-dotenv"] webservice = ["streamlit>=1.41.0"] +# Batch web interface (webui/): multi-file upload, background OCR, zip download +# Plain uvicorn rather than uvicorn[standard]: uvloop/httptools have no musl +# wheels and would have to be compiled for the Alpine image, and the event +# loop is never the bottleneck here — Tesseract is. +webui = [ + "fastapi>=0.115", + "uvicorn>=0.30", + "python-multipart>=0.0.18", +] [project.scripts] ocrmypdf = "ocrmypdf.__main__:run" +[tool.hatch.build.targets.wheel] +# Stated explicitly so top-level directories that are not part of the +# distribution (webui/, misc/, tests/) never get picked up by autodetection. +packages = ["src/ocrmypdf"] + [tool.distutils.bdist_wheel] python-tag = "py311" @@ -144,7 +158,7 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["ocrmypdf"] +known-first-party = ["ocrmypdf", "webui"] required-imports = ["from __future__ import annotations"] [tool.ruff.lint.flake8-import-conventions] diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 00000000..95361829 --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,401 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""End-to-end tests for the batch web interface in ``webui/``. + +These drive a real uvicorn server over real HTTP and run real OCR, so they are +slow. They are skipped unless the ``webui`` extra is installed:: + + uv sync --extra webui --group test +""" + +from __future__ import annotations + +import json +import mimetypes +import socket +import sys +import threading +import time +import urllib.error +import urllib.request +import uuid +import zipfile +from io import BytesIO +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi", reason="webui extra not installed") +pytest.importorskip("uvicorn", reason="webui extra not installed") +pytest.importorskip("multipart", reason="python-multipart not installed") + +# webui/ lives at the repo root and is deliberately not part of the installed +# ocrmypdf distribution, so put the repo root on the path explicitly. +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +RESOURCES = Path(__file__).parent / "resources" + +pytestmark = pytest.mark.slow + + +# --------------------------------------------------------------- HTTP helpers + + +def encode_multipart( + files: list[tuple[str, str, bytes]], fields: dict[str, str] +) -> tuple[bytes, str]: + """Build a multipart/form-data body without pulling in a HTTP library.""" + boundary = f"----ocrmypdf{uuid.uuid4().hex}" + buffer = BytesIO() + for name, value in fields.items(): + buffer.write(f"--{boundary}\r\n".encode()) + buffer.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()) + buffer.write(value.encode() + b"\r\n") + for field_name, filename, content in files: + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + buffer.write(f"--{boundary}\r\n".encode()) + buffer.write( + f'Content-Disposition: form-data; name="{field_name}"; ' + f'filename="{filename}"\r\n'.encode() + ) + buffer.write(f"Content-Type: {content_type}\r\n\r\n".encode()) + buffer.write(content + b"\r\n") + buffer.write(f"--{boundary}--\r\n".encode()) + return buffer.getvalue(), f"multipart/form-data; boundary={boundary}" + + +class Client: + """The smallest HTTP client that can exercise the API.""" + + def __init__(self, base_url: str): + """Bind this client to a running server.""" + self.base_url = base_url + + def request(self, method: str, path: str, data=None, content_type=None): + request = urllib.request.Request(self.base_url + path, data=data, method=method) + if content_type: + request.add_header("Content-Type", content_type) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.status, response.read(), dict(response.headers) + except urllib.error.HTTPError as error: + return error.code, error.read(), dict(error.headers) + + def get_json(self, path: str): + status, body, _ = self.request("GET", path) + assert status == 200, body + return json.loads(body) + + def post_batch(self, files: list[tuple[str, bytes]], options: dict | None = None): + body, content_type = encode_multipart( + [("files", name, content) for name, content in files], + {"options": json.dumps(options or {})}, + ) + status, raw, _ = self.request("POST", "/api/batches", body, content_type) + return status, (json.loads(raw) if raw else None) + + +# ------------------------------------------------------------------- fixtures + + +@pytest.fixture(scope="module") +def server(tmp_path_factory): + """Run the real ASGI app on a real port for the duration of the module.""" + import uvicorn + + work_dir = tmp_path_factory.mktemp("webui-work") + # A module-scoped fixture cannot use the function-scoped monkeypatch. + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setenv("OCRMYPDF_WEBUI_WORK_DIR", str(work_dir)) + monkeypatch.setenv("OCRMYPDF_WEBUI_MAX_UPLOAD_MB", "2") + monkeypatch.setenv("OCRMYPDF_WEBUI_MAX_FILES", "4") + monkeypatch.setenv("OCRMYPDF_WEBUI_WORKERS", "2") + monkeypatch.setenv("OCRMYPDF_WEBUI_OCR_JOBS", "1") + monkeypatch.setenv("OCRMYPDF_WEBUI_BATCH_TTL_SECONDS", "600") + + from webui.config import get_settings, installed_languages + + get_settings.cache_clear() + installed_languages.cache_clear() + + from webui.app import app + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + uv_server = uvicorn.Server(config) + thread = threading.Thread(target=uv_server.run, daemon=True) + thread.start() + + deadline = time.monotonic() + 30 + while not uv_server.started: + if time.monotonic() > deadline: + raise RuntimeError("uvicorn did not start") + time.sleep(0.05) + + yield Client(f"http://127.0.0.1:{port}") + + uv_server.should_exit = True + thread.join(timeout=30) + get_settings.cache_clear() + monkeypatch.undo() + + +def wait_for_batch(client: Client, batch_id: str, timeout: float = 600.0) -> dict: + """Poll until every file in the batch reaches a terminal state.""" + deadline = time.monotonic() + timeout + while True: + batch = client.get_json(f"/api/batches/{batch_id}") + if batch["finished"]: + return batch + if time.monotonic() > deadline: + pytest.fail(f"batch did not finish in {timeout}s: {batch}") + time.sleep(0.5) + + +def pdf_text(data: bytes) -> str: + """Extract text from an in-memory PDF.""" + from pdfminer.high_level import extract_text + + return extract_text(BytesIO(data)) + + +# ---------------------------------------------------------------------- tests + + +def test_config_advertises_capabilities(server): + config = server.get_json("/api/config") + assert "eng" in config["languages"] + assert ".pdf" in config["accepted_extensions"] + assert config["max_files"] == 4 + assert config["max_upload_bytes"] == 2 * 1024 * 1024 + assert config["defaults"]["mode"] == "skip-text" + + +def test_health(server): + status, body, _ = server.request("GET", "/healthz") + assert status == 200 + assert body == b"ok" + + +def test_index_page_is_served(server): + status, body, headers = server.request("GET", "/") + assert status == 200 + assert b"OCRmyPDF" in body + assert "text/html" in headers["content-type"] + + +def test_batch_of_several_files_ocrs_and_downloads(server): + """The core flow: many files in, searchable PDFs out, plus a zip.""" + inputs = [ + ("linn.png", (RESOURCES / "linn.png").read_bytes()), + ("ccitt.pdf", (RESOURCES / "ccitt.pdf").read_bytes()), + ("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes()), + ] + status, batch = server.post_batch( + inputs, {"languages": ["eng"], "mode": "skip-text", "output_type": "pdf"} + ) + assert status == 201, batch + assert batch["total"] == 3 + assert {file["name"] for file in batch["files"]} == { + "linn.png", + "ccitt.pdf", + "trivial.pdf", + } + + batch = wait_for_batch(server, batch["id"]) + by_name = {file["name"]: file for file in batch["files"]} + + # An image of text must come back as a PDF that actually contains that text. + linn = by_name["linn.png"] + assert linn["status"] == "succeeded", linn + assert linn["output_name"] == "linn.pdf" + status, pdf, headers = server.request( + "GET", f"/api/batches/{batch['id']}/files/{linn['id']}" + ) + assert status == 200 + assert pdf.startswith(b"%PDF") + assert "linn.pdf" in headers["content-disposition"] + assert "linnsequencer" in pdf_text(pdf).lower() + + # A blank page is not an error; it just yields no text. + assert by_name["trivial.pdf"]["status"] == "succeeded" + assert by_name["ccitt.pdf"]["status"] == "succeeded" + assert batch["downloadable"] == 3 + assert batch["completed"] == 3 + + # The zip must contain one entry per successful file. + status, archive, headers = server.request( + "GET", f"/api/batches/{batch['id']}/download" + ) + assert status == 200 + assert headers["content-type"] == "application/zip" + with zipfile.ZipFile(BytesIO(archive)) as zf: + assert sorted(zf.namelist()) == ["ccitt.pdf", "linn.pdf", "trivial.pdf"] + assert zf.read("linn.pdf").startswith(b"%PDF") + + +def test_duplicate_names_are_disambiguated_in_zip(server): + content = (RESOURCES / "trivial.pdf").read_bytes() + status, batch = server.post_batch( + [("same.pdf", content), ("same.pdf", content)], {"output_type": "pdf"} + ) + assert status == 201 + batch = wait_for_batch(server, batch["id"]) + assert batch["downloadable"] == 2 + _, archive, _ = server.request("GET", f"/api/batches/{batch['id']}/download") + with zipfile.ZipFile(BytesIO(archive)) as zf: + assert sorted(zf.namelist()) == ["same (1).pdf", "same.pdf"] + + +def test_file_with_existing_text_fails_helpfully_in_normal_mode(server): + """A per-file failure must be reported per file, not as a batch error.""" + status, batch = server.post_batch( + [ + ("graph_ocred.pdf", (RESOURCES / "graph_ocred.pdf").read_bytes()), + ("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes()), + ], + {"mode": "normal", "output_type": "pdf"}, + ) + assert status == 201 + batch = wait_for_batch(server, batch["id"]) + by_name = {file["name"]: file for file in batch["files"]} + + failed = by_name["graph_ocred.pdf"] + assert failed["status"] == "failed" + assert "already contains text" in failed["error"] + # The healthy file in the same batch still succeeds and is downloadable. + assert by_name["trivial.pdf"]["status"] == "succeeded" + assert batch["downloadable"] == 1 + + status, _, _ = server.request( + "GET", f"/api/batches/{batch['id']}/files/{failed['id']}" + ) + assert status == 409 + + status, log, _ = server.request( + "GET", f"/api/batches/{batch['id']}/files/{failed['id']}/log" + ) + assert status == 200 + assert len(log) > 0 + + +def test_skip_text_mode_handles_the_same_file(server): + status, batch = server.post_batch( + [("graph_ocred.pdf", (RESOURCES / "graph_ocred.pdf").read_bytes())], + {"mode": "skip-text", "output_type": "pdf"}, + ) + assert status == 201 + batch = wait_for_batch(server, batch["id"]) + assert batch["files"][0]["status"] == "succeeded" + + +@pytest.mark.parametrize( + "options,expected_status", + [ + ({"languages": ["eng; rm -rf /"]}, 422), # command injection attempt + ({"languages": ["klingon"]}, 422), # uninstalled language + ({"optimize": 9}, 422), # out of range + ({"mode": "--evil"}, 422), # not a member of the enum + ({"image_dpi": -1}, 422), # out of range + ({"unknown_option": True}, 422), # extra fields forbidden + ], +) +def test_invalid_options_are_rejected(server, options, expected_status): + status, body = server.post_batch( + [("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes())], options + ) + assert status == expected_status, body + + +def test_unsupported_file_type_is_rejected(server): + status, body = server.post_batch([("evil.exe", b"MZ" + b"\0" * 100)]) + assert status == 415 + assert "unsupported type" in body["detail"] + + +def test_oversized_file_is_rejected(server): + status, body = server.post_batch([("big.pdf", b"%PDF-1.7\n" + b"x" * 3_000_000)]) + assert status == 413 + assert "limit" in body["detail"] + + +def test_empty_file_is_rejected(server): + status, body = server.post_batch([("empty.pdf", b"")]) + assert status == 400 + + +def test_too_many_files_is_rejected(server): + content = (RESOURCES / "trivial.pdf").read_bytes() + status, body = server.post_batch( + [(f"file{n}.pdf", content) for n in range(5)] # limit is 4 + ) + assert status == 413 + assert "Too many files" in body["detail"] + + +def test_path_traversal_in_filename_is_neutralized(server): + status, batch = server.post_batch( + [("../../etc/passwd.pdf", (RESOURCES / "trivial.pdf").read_bytes())], + {"output_type": "pdf"}, + ) + assert status == 201 + assert batch["files"][0]["name"] == "passwd.pdf" + batch = wait_for_batch(server, batch["id"]) + _, archive, _ = server.request("GET", f"/api/batches/{batch['id']}/download") + with zipfile.ZipFile(BytesIO(archive)) as zf: + assert zf.namelist() == ["passwd.pdf"] + + +def test_delete_removes_the_batch_and_its_files(server): + status, batch = server.post_batch( + [("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes())], + {"output_type": "pdf"}, + ) + assert status == 201 + batch_id = batch["id"] + wait_for_batch(server, batch_id) + + from webui.config import get_settings + + directory = get_settings().work_dir / batch_id + assert directory.exists() + + status, _, _ = server.request("DELETE", f"/api/batches/{batch_id}") + assert status == 204 + assert not directory.exists() + + status, _, _ = server.request("GET", f"/api/batches/{batch_id}") + assert status == 404 + + +def test_unknown_batch_returns_404(server): + status, _, _ = server.request("GET", "/api/batches/" + "0" * 32) + assert status == 404 + + +def test_sweep_removes_expired_batches(server): + """The TTL reaper must delete batches whether or not they were downloaded.""" + status, batch = server.post_batch( + [("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes())], + {"output_type": "pdf"}, + ) + assert status == 201 + batch_id = batch["id"] + wait_for_batch(server, batch_id) + + from webui.app import app + + manager = app.state.manager + directory = manager.settings.work_dir / batch_id + assert directory.exists() + + # Pretend enough time has passed for the TTL to lapse. + manager.sweep(now=time.time() + manager.settings.batch_ttl_seconds + 1) + assert not directory.exists() + assert manager.get_batch(batch_id) is None diff --git a/uv.lock b/uv.lock index 24d56620..50991df2 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -599,6 +608,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954 }, +] + [[package]] name = "fonttools" version = "4.63.0" @@ -1560,6 +1585,11 @@ watcher = [ webservice = [ { name = "streamlit" }, ] +webui = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [ @@ -1599,6 +1629,7 @@ test = [ [package.metadata] requires-dist = [ { name = "cyclopts", marker = "extra == 'watcher'", specifier = ">=3" }, + { name = "fastapi", marker = "extra == 'webui'", specifier = ">=0.115" }, { name = "fpdf2", specifier = ">=2.8.0" }, { name = "img2pdf", specifier = ">=0.5" }, { name = "packaging", specifier = ">=20" }, @@ -1610,13 +1641,15 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, { name = "pypdfium2", specifier = ">=5.0.0" }, { name = "python-dotenv", marker = "extra == 'watcher'" }, + { name = "python-multipart", marker = "extra == 'webui'", specifier = ">=0.0.18" }, { name = "rich", specifier = ">=13" }, { name = "streamlit", marker = "extra == 'webservice'", specifier = ">=1.41.0" }, { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.12" }, { name = "uharfbuzz", specifier = ">=0.53.2" }, + { name = "uvicorn", marker = "extra == 'webui'", specifier = ">=0.30" }, { name = "watchdog", marker = "extra == 'watcher'", specifier = ">=1.0.2" }, ] -provides-extras = ["watcher", "webservice"] +provides-extras = ["watcher", "webservice", "webui"] [package.metadata.requires-dev] dev = [ diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 00000000..68096023 --- /dev/null +++ b/webui/README.md @@ -0,0 +1,117 @@ + + +# OCRmyPDF batch web interface + +A small FastAPI application that lets people drop several PDFs or images +onto a web page, OCRs them in the background, and hands back the results +individually or as one zip archive. + +This is separate from `misc/_webservice.py`, the Streamlit app, which +handles one file at a time and exposes every OCRmyPDF option. This one +trades option coverage for batch throughput. + +## Running it + +With Docker (recommended — Tesseract, Ghostscript and friends are already +installed): + +```bash +docker compose -f misc/docker-compose.webui.yml up --build +# then open http://localhost:8000/ +``` + +From a source checkout: + +```bash +uv sync --extra webui +uv run python -m webui +``` + +## Configuration + +All settings come from environment variables; see the table in +`docs/docker.md`. The two that matter most: + +- `OCRMYPDF_WEBUI_WORKERS` — how many files are OCR'd at the same time. +- `OCRMYPDF_WEBUI_OCR_JOBS` — `ocrmypdf --jobs` for each of those files. + +Their product should be about the number of cores available. Defaults +split the machine automatically. + +## How it works + +``` +browser ──POST /api/batches──> FastAPI ──> ThreadPoolExecutor + │ │ + │ └─> subprocess: python -m ocrmypdf + └──GET /api/batches/{id} (poll ~1.5s)──> per-file status + log tail +``` + +- A **batch** is one submission: N files plus one set of options. Each + file becomes a job with its own status, so one bad file does not sink + the batch. +- OCRmyPDF runs **out of process**. A crash, hang, or runaway allocation + stays in a child process that the server can time out and kill. +- Batches are deleted from disk once their TTL lapses, whether or not + they were downloaded. A background thread sweeps every 60 seconds and + also removes directories left behind by a previous process. +- State lives in memory, so the server runs as **one process**. Scale + with `OCRMYPDF_WEBUI_WORKERS`, not with server workers. + +## Notes on input handling + +User input never reaches a command line unchecked: + +- Options are parsed by a Pydantic model with `extra="forbid"`; modes and + output types are enums, numbers are bounded, and languages must appear + in `tesseract --list-langs` output for this container. +- Uploaded filenames are used only as display text and as the + `filename` of a download. Files on disk get generated names, and the + positional arguments to `ocrmypdf` are preceded by `--`. +- Uploads are streamed to disk and aborted past the size limit, so an + oversized request is not buffered in memory. + +There is deliberately **no authentication**. Put this behind a reverse +proxy if it needs to be reachable from anywhere untrusted. + +## API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/config` | Limits, installed languages, defaults | +| `POST` | `/api/batches` | Upload files (`files`) + options (`options`, JSON) | +| `GET` | `/api/batches/{id}` | Poll batch and per-file status | +| `DELETE` | `/api/batches/{id}` | Cancel and delete immediately | +| `GET` | `/api/batches/{id}/files/{n}` | Download one result | +| `GET` | `/api/batches/{id}/files/{n}/log` | ocrmypdf output for one file | +| `GET` | `/api/batches/{id}/download` | Zip of all successful results | +| `GET` | `/healthz` | Liveness probe | + +Interactive docs are at `/api/docs`. + +Example: + +```bash +curl -sS -X POST http://localhost:8000/api/batches \ + -F files=@scan1.pdf -F files=@scan2.pdf \ + -F 'options={"languages":["eng","fra"],"mode":"skip-text","deskew":true}' +``` + +## Tests + +`tests/test_webui.py` starts a real uvicorn server and runs real OCR. It +skips itself unless the `webui` extra is installed: + +```bash +uv sync --extra webui --group test +uv run pytest tests/test_webui.py +``` + +## Licensing + +OCRmyPDF uses Ghostscript, which is AGPLv3+. This subpackage is +distributed under AGPLv3+ (rather than OCRmyPDF's MPL-2.0) to make it +plain that SaaS deployments must comply with Ghostscript's terms. diff --git a/webui/__init__.py b/webui/__init__.py new file mode 100644 index 00000000..de1d1f85 --- /dev/null +++ b/webui/__init__.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Batch web interface for OCRmyPDF. + +This package provides a small FastAPI application that lets users upload +several files at once, runs OCRmyPDF over each of them in the background, and +offers the results for download individually or as a single zip archive. + +OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While OCRmyPDF +itself is under MPL-2.0, this subpackage is distributed under AGPLv3+ to +emphasize that SaaS deployments must comply with Ghostscript's license too. +""" + +from __future__ import annotations + +__all__ = ["__version__"] + +__version__ = "1.0.0" diff --git a/webui/__main__.py b/webui/__main__.py new file mode 100644 index 00000000..aae5113c --- /dev/null +++ b/webui/__main__.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Launch the OCRmyPDF batch web interface. + +Usage:: + + python -m webui + +Configuration is read from ``OCRMYPDF_WEBUI_*`` environment variables; see +``webui/README.md``. +""" + +from __future__ import annotations + +import os +import sys + + +def main() -> int: + """Run the ASGI server until interrupted.""" + try: + import uvicorn + except ImportError: + sys.stderr.write( + "The web interface needs its extra dependencies:\n" + " uv sync --extra webui\n" + " # or: pip install 'fastapi' 'uvicorn[standard]' 'python-multipart'\n" + ) + return 1 + + host = os.environ.get("OCRMYPDF_WEBUI_HOST", "0.0.0.0") # noqa: S104 + port = int(os.environ.get("OCRMYPDF_WEBUI_PORT", "8000")) + + # Deliberately single-process: batch state lives in this process's memory, + # so a second worker would not see batches created by the first. Scale by + # raising OCRMYPDF_WEBUI_WORKERS (OCR worker threads), not server workers. + uvicorn.run( + "webui.app:app", + host=host, + port=port, + workers=1, + access_log=False, + proxy_headers=True, + forwarded_allow_ips=os.environ.get("OCRMYPDF_WEBUI_FORWARDED_ALLOW_IPS", ""), + timeout_keep_alive=75, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/webui/app.py b/webui/app.py new file mode 100644 index 00000000..9052f0a3 --- /dev/null +++ b/webui/app.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""FastAPI application exposing OCRmyPDF as a batch web service.""" + +from __future__ import annotations + +import json +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import ( + FileResponse, + JSONResponse, + PlainTextResponse, + Response, +) +from fastapi.staticfiles import StaticFiles +from pydantic import ValidationError +from starlette.concurrency import run_in_threadpool + +from webui import __version__ +from webui.config import ALLOWED_SUFFIXES, get_settings, installed_languages +from webui.jobs import Batch, JobManager, JobStatus, safe_display_name +from webui.options import Mode, OcrOptions, OutputType + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" +) +log = logging.getLogger("webui") + +STATIC_DIR = Path(__file__).parent / "static" + +#: Bytes pulled from the request body per iteration while saving an upload. +CHUNK_SIZE = 1024 * 1024 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Start the job manager with the app and tear it down cleanly.""" + settings = get_settings() + log.info( + "Starting OCRmyPDF web UI %s (workers=%d, ocr_jobs=%d, work_dir=%s)", + __version__, + settings.workers, + settings.ocr_jobs, + settings.work_dir, + ) + manager = JobManager(settings) + manager.sweep() # clear anything a previous container left behind + app.state.manager = manager + try: + yield + finally: + manager.shutdown() + + +app = FastAPI( + title="OCRmyPDF Web UI", + version=__version__, + lifespan=lifespan, + docs_url="/api/docs", + openapi_url="/api/openapi.json", +) + + +def get_manager(request: Request) -> JobManager: + """Return the job manager created for this app instance.""" + return request.app.state.manager + + +def require_batch(request: Request, batch_id: str) -> Batch: + """Look up a batch, raising 404 if it has expired or never existed.""" + batch = get_manager(request).get_batch(batch_id) + if batch is None: + raise HTTPException( + status_code=404, detail="This batch has expired or does not exist." + ) + return batch + + +@app.get("/healthz", include_in_schema=False) +async def healthz() -> PlainTextResponse: + """Liveness probe for container orchestration.""" + return PlainTextResponse("ok") + + +@app.get("/api/config") +async def api_config() -> JSONResponse: + """Describe what this deployment supports, so the UI can adapt to it.""" + settings = get_settings() + return JSONResponse( + { + "version": __version__, + "languages": list(installed_languages()), + "modes": [mode.value for mode in Mode], + "output_types": [output.value for output in OutputType], + "max_files": settings.max_files_per_batch, + "max_upload_bytes": settings.max_upload_bytes, + "batch_ttl_seconds": settings.batch_ttl_seconds, + "accepted_extensions": sorted(ALLOWED_SUFFIXES), + "defaults": OcrOptions().model_dump(mode="json"), + } + ) + + +@app.post("/api/batches", status_code=201) +async def create_batch( + request: Request, + # FastAPI's dependency markers are meant to be evaluated in the signature. + files: list[UploadFile] = File(...), # noqa: B008 + options: str = Form("{}"), # noqa: B008 +) -> JSONResponse: + """Accept a set of files plus shared options and begin processing.""" + settings = get_settings() + manager = get_manager(request) + + try: + raw_options = json.loads(options or "{}") + if not isinstance(raw_options, dict): + raise ValueError("options must be a JSON object") + parsed = OcrOptions.model_validate(raw_options) + except (ValueError, ValidationError) as exc: + raise HTTPException(status_code=422, detail=f"Invalid options: {exc}") from exc + + if not files: + raise HTTPException(status_code=400, detail="No files were uploaded.") + if len(files) > settings.max_files_per_batch: + raise HTTPException( + status_code=413, + detail=( + f"Too many files: {len(files)} sent, " + f"limit is {settings.max_files_per_batch} per batch." + ), + ) + + batch = manager.create_batch(parsed) + try: + for index, upload in enumerate(files): + display_name = safe_display_name(upload.filename, f"file-{index + 1}.pdf") + suffix = Path(display_name).suffix.lower() + if suffix not in ALLOWED_SUFFIXES: + raise HTTPException( + status_code=415, + detail=( + f"“{display_name}” has an unsupported type. Accepted: " + + ", ".join(sorted(ALLOWED_SUFFIXES)) + ), + ) + job = manager.add_file(batch, display_name, suffix) + written = await _save_upload( + upload, job.input_path, settings.max_upload_bytes + ) + if written == 0: + raise HTTPException( + status_code=400, detail=f"“{display_name}” is empty." + ) + job.size_bytes = written + except HTTPException: + manager.delete_batch(batch.id) + raise + except Exception: + manager.delete_batch(batch.id) + log.exception("Failed to accept upload batch") + raise HTTPException( + status_code=500, detail="Could not store the uploaded files." + ) from None + + manager.start(batch) + log.info("Batch %s accepted with %d file(s)", batch.id, len(batch.jobs)) + return JSONResponse(batch.to_dict(), status_code=201) + + +async def _save_upload(upload: UploadFile, destination: Path, limit: int) -> int: + """Stream one upload to disk, aborting if it exceeds ``limit`` bytes.""" + written = 0 + with destination.open("wb") as out: + while chunk := await upload.read(CHUNK_SIZE): + written += len(chunk) + if written > limit: + out.close() + destination.unlink(missing_ok=True) + raise HTTPException( + status_code=413, + detail=( + f"“{safe_display_name(upload.filename, 'A file')}” exceeds " + f"the {limit // (1024 * 1024)} MB per-file limit." + ), + ) + out.write(chunk) + await upload.close() + return written + + +@app.get("/api/batches/{batch_id}") +async def get_batch(request: Request, batch_id: str) -> JSONResponse: + """Poll the status of a batch and of every file in it.""" + return JSONResponse(require_batch(request, batch_id).to_dict()) + + +@app.delete("/api/batches/{batch_id}", status_code=204) +async def delete_batch(request: Request, batch_id: str) -> Response: + """Cancel a batch and delete its files immediately.""" + if not get_manager(request).delete_batch(batch_id): + raise HTTPException(status_code=404, detail="No such batch.") + return Response(status_code=204) + + +@app.get("/api/batches/{batch_id}/files/{job_id}") +async def download_file(request: Request, batch_id: str, job_id: str) -> FileResponse: + """Download one finished PDF.""" + found = get_manager(request).get_job(batch_id, job_id) + if found is None: + raise HTTPException(status_code=404, detail="No such file in this batch.") + _, job = found + if job.status is not JobStatus.succeeded or not job.output_path.exists(): + raise HTTPException(status_code=409, detail="This file is not ready.") + return FileResponse( + job.output_path, media_type="application/pdf", filename=job.output_name + ) + + +@app.get("/api/batches/{batch_id}/files/{job_id}/log") +async def file_log(request: Request, batch_id: str, job_id: str) -> PlainTextResponse: + """Return the ocrmypdf output captured for one file.""" + found = get_manager(request).get_job(batch_id, job_id) + if found is None: + raise HTTPException(status_code=404, detail="No such file in this batch.") + _, job = found + return PlainTextResponse("\n".join(job.log_lines) or "(no output)") + + +@app.get("/api/batches/{batch_id}/download") +async def download_zip(request: Request, batch_id: str) -> FileResponse: + """Download every finished PDF in the batch as one zip archive.""" + batch = require_batch(request, batch_id) + manager = get_manager(request) + zip_path = await run_in_threadpool(manager.build_zip, batch) + if zip_path is None: + raise HTTPException( + status_code=409, detail="No files in this batch finished successfully." + ) + return FileResponse( + zip_path, media_type="application/zip", filename="ocrmypdf-results.zip" + ) + + +app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") diff --git a/webui/config.py b/webui/config.py new file mode 100644 index 00000000..04487714 --- /dev/null +++ b/webui/config.py @@ -0,0 +1,144 @@ +# 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)) diff --git a/webui/jobs.py b/webui/jobs.py new file mode 100644 index 00000000..e14923b8 --- /dev/null +++ b/webui/jobs.py @@ -0,0 +1,469 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Batch job tracking and execution for the OCRmyPDF web interface. + +A *batch* is one submission from the browser: several input files plus a +single set of OCR options. Each file becomes a *job* that runs ``ocrmypdf`` +in a subprocess. Jobs from all batches share one bounded thread pool, so a +large batch cannot monopolize the machine. + +Running OCRmyPDF out-of-process (rather than calling ``ocrmypdf.ocr()``) keeps +a crash, a hang, or a runaway memory allocation contained in a child process +that we can time out and kill. +""" + +from __future__ import annotations + +import logging +import re +import shutil +import subprocess +import sys +import threading +import time +import uuid +import zipfile +from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path + +from webui.config import Settings +from webui.options import OcrOptions + +log = logging.getLogger(__name__) + +#: ``ocrmypdf.exceptions.ExitCode`` values, phrased for someone who is looking +#: at a web page rather than a terminal. Kept as literals so the web +#: interface does not have to import ocrmypdf at startup. +EXIT_CODE_MESSAGES = { + 1: "A bad or missing argument was supplied.", # bad_args + 2: "The input file is not a valid PDF or image.", # input_file + 3: "A required external program is missing.", # missing_dependency + 4: "The output PDF was created but is not valid.", # invalid_output_pdf + 5: "The file could not be read or written.", # file_access_error + 6: ( # already_done_ocr + "This file already contains text. Choose “Skip them” or " + "“Redo the existing OCR” and try again." + ), + 7: "An external program failed while processing this file.", # child_process + 8: "This PDF is encrypted. Remove the password and try again.", # encrypted_pdf + 9: "The requested combination of options is not valid.", # invalid_config + 10: ( # pdfa_conversion_failed + "The PDF was produced but could not be converted to PDF/A. " + "Try the standard PDF output format." + ), + 15: "An unexpected error occurred while processing this file.", # other_error + 130: "Processing was interrupted.", # ctrl_c +} + +#: Strip ANSI escapes that rich may emit even when not attached to a terminal. +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]") + + +class JobStatus(StrEnum): + """Lifecycle of a single file.""" + + pending = "pending" + running = "running" + succeeded = "succeeded" + failed = "failed" + cancelled = "cancelled" + + +TERMINAL_STATUSES = frozenset( + {JobStatus.succeeded, JobStatus.failed, JobStatus.cancelled} +) + + +def safe_display_name(raw: str | None, fallback: str) -> str: + """Reduce a browser-supplied filename to something safe to echo back. + + The result is only ever used as display text and as the ``filename`` of a + download; it never touches the filesystem or a command line. + """ + if not raw: + return fallback + # Browsers may send a full path (notably older Edge and some drag sources). + name = raw.replace("\\", "/").split("/")[-1] + name = name.replace("\x00", "").strip().strip(".") + # Control characters and quotes would corrupt the Content-Disposition + # header; anything else is left alone so unicode filenames survive. + name = "".join(ch for ch in name if ch.isprintable() and ch not in '"\r\n') + name = name[:200].strip() + return name or fallback + + +@dataclass +class FileJob: + """One input file and its result.""" + + id: str + display_name: str + input_path: Path + output_path: Path + size_bytes: int + status: JobStatus = JobStatus.pending + error: str | None = None + started_at: float | None = None + finished_at: float | None = None + log_lines: deque[str] = field(default_factory=lambda: deque(maxlen=200)) + _process: subprocess.Popen | None = field(default=None, repr=False) + + @property + def is_image(self) -> bool: + """True when the input is an image rather than a PDF.""" + return self.input_path.suffix.lower() != ".pdf" + + @property + def output_name(self) -> str: + """Filename offered to the browser for this job's result.""" + stem = Path(self.display_name).stem or "output" + return f"{stem}.pdf" + + def to_dict(self) -> dict: + """Serialize this job for the status API.""" + return { + "id": self.id, + "name": self.display_name, + "output_name": self.output_name, + "size_bytes": self.size_bytes, + "status": self.status.value, + "error": self.error, + "output_size_bytes": ( + self.output_path.stat().st_size + if self.status is JobStatus.succeeded and self.output_path.exists() + else None + ), + "duration_seconds": ( + round(self.finished_at - self.started_at, 1) + if self.started_at and self.finished_at + else None + ), + "log_tail": list(self.log_lines)[-8:], + } + + +@dataclass +class Batch: + """One submission: several files sharing a set of options.""" + + id: str + directory: Path + options: OcrOptions + jobs: list[FileJob] + created_at: float = field(default_factory=time.time) + cancelled: bool = False + _futures: list[Future] = field(default_factory=list, repr=False) + + @property + def finished(self) -> bool: + """True once no job in this batch can still change state.""" + return all(job.status in TERMINAL_STATUSES for job in self.jobs) + + @property + def succeeded_jobs(self) -> list[FileJob]: + """Jobs whose output is on disk and downloadable.""" + return [job for job in self.jobs if job.status is JobStatus.succeeded] + + def to_dict(self) -> dict: + """Serialize this batch and all of its jobs for the status API.""" + jobs = [job.to_dict() for job in self.jobs] + counts = {status.value: 0 for status in JobStatus} + for job in self.jobs: + counts[job.status.value] += 1 + return { + "id": self.id, + "created_at": self.created_at, + "finished": self.finished, + "cancelled": self.cancelled, + "counts": counts, + "total": len(self.jobs), + "completed": sum(counts[status.value] for status in TERMINAL_STATUSES), + "downloadable": len(self.succeeded_jobs), + "files": jobs, + } + + +class JobManager: + """Owns the batch registry, the worker pool, and the reaper thread.""" + + def __init__(self, settings: Settings): + """Create the pool and start the background reaper.""" + self.settings = settings + self._batches: dict[str, Batch] = {} + self._lock = threading.RLock() + self._pool = ThreadPoolExecutor( + max_workers=settings.workers, thread_name_prefix="ocr-worker" + ) + self._stopping = threading.Event() + self._reaper = threading.Thread( + target=self._reap_loop, name="ocr-reaper", daemon=True + ) + settings.work_dir.mkdir(parents=True, exist_ok=True) + self._reaper.start() + + # -- lifecycle --------------------------------------------------------- + + def shutdown(self) -> None: + """Stop accepting work, cancel everything, and clean the work dir.""" + self._stopping.set() + with self._lock: + batch_ids = list(self._batches) + for batch_id in batch_ids: + self.delete_batch(batch_id) + self._pool.shutdown(wait=False, cancel_futures=True) + + # -- batch management -------------------------------------------------- + + def create_batch(self, options: OcrOptions) -> Batch: + """Register an empty batch and create its scratch directory.""" + batch_id = uuid.uuid4().hex + directory = self.settings.work_dir / batch_id + (directory / "in").mkdir(parents=True, exist_ok=True) + (directory / "out").mkdir(parents=True, exist_ok=True) + batch = Batch(id=batch_id, directory=directory, options=options, jobs=[]) + with self._lock: + self._batches[batch_id] = batch + return batch + + def add_file(self, batch: Batch, display_name: str, suffix: str) -> FileJob: + """Allocate an on-disk slot for one uploaded file. + + The caller streams bytes into ``job.input_path`` and then calls + :meth:`start`. Filenames on disk are generated, never derived from + user input. + """ + index = len(batch.jobs) + job_id = f"{index:04d}" + job = FileJob( + id=job_id, + display_name=display_name, + input_path=batch.directory / "in" / f"{job_id}{suffix}", + output_path=batch.directory / "out" / f"{job_id}.pdf", + size_bytes=0, + ) + job.log_lines = deque(maxlen=self.settings.max_log_lines) + batch.jobs.append(job) + return job + + def start(self, batch: Batch) -> None: + """Queue every file in the batch for processing.""" + with self._lock: + batch._futures = [ + self._pool.submit(self._run_job, batch, job) for job in batch.jobs + ] + + def get_batch(self, batch_id: str) -> Batch | None: + """Return a batch by id, or None if it is unknown or expired.""" + with self._lock: + return self._batches.get(batch_id) + + def get_job(self, batch_id: str, job_id: str) -> tuple[Batch, FileJob] | None: + """Return the (batch, job) pair for an id pair, or None.""" + batch = self.get_batch(batch_id) + if batch is None: + return None + for job in batch.jobs: + if job.id == job_id: + return batch, job + return None + + def delete_batch(self, batch_id: str) -> bool: + """Cancel any running work and remove the batch from disk.""" + with self._lock: + batch = self._batches.pop(batch_id, None) + if batch is None: + return False + batch.cancelled = True + for future in batch._futures: + future.cancel() + for job in batch.jobs: + proc = job._process + if proc is not None and proc.poll() is None: + self._terminate(proc) + if job.status in (JobStatus.pending, JobStatus.running): + job.status = JobStatus.cancelled + shutil.rmtree(batch.directory, ignore_errors=True) + return True + + # -- results ----------------------------------------------------------- + + def build_zip(self, batch: Batch) -> Path | None: + """Bundle every successful output into one archive. + + The archive is written once and reused; a batch is immutable after its + jobs finish. + """ + jobs = batch.succeeded_jobs + if not jobs: + return None + zip_path = batch.directory / "ocrmypdf-results.zip" + if zip_path.exists(): + return zip_path + + used: dict[str, int] = {} + tmp_path = zip_path.with_suffix(".zip.part") + # ZIP_STORED: the members are already-compressed PDFs, so deflating + # them again costs CPU we'd rather spend on OCR. + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_STORED) as archive: + for job in jobs: + name = job.output_name + if name in used: + used[name] += 1 + stem = Path(name).stem + name = f"{stem} ({used[name]}).pdf" + else: + used[name] = 0 + archive.write(job.output_path, arcname=name) + tmp_path.replace(zip_path) + return zip_path + + # -- execution --------------------------------------------------------- + + def _run_job(self, batch: Batch, job: FileJob) -> None: + if batch.cancelled or self._stopping.is_set(): + job.status = JobStatus.cancelled + return + + job.status = JobStatus.running + job.started_at = time.time() + args = [ + sys.executable, + "-u", + "-m", + "ocrmypdf", + *batch.options.to_args(jobs=self.settings.ocr_jobs, is_image=job.is_image), + "--", + str(job.input_path), + str(job.output_path), + ] + + try: + self._execute(job, args) + except Exception: # noqa: BLE001 - a worker must never die silently + log.exception("Unexpected failure processing %s", job.display_name) + job.status = JobStatus.failed + job.error = "An internal error occurred while processing this file." + finally: + job.finished_at = time.time() + job._process = None + if job.status is JobStatus.running: # defensive + job.status = JobStatus.failed + job.error = job.error or "Processing ended unexpectedly." + if job.status is not JobStatus.succeeded: + job.output_path.unlink(missing_ok=True) + # The input is not needed once we have a result; drop it early so + # a big batch doesn't hold twice its size on disk. + job.input_path.unlink(missing_ok=True) + + def _execute(self, job: FileJob, args: list[str]) -> None: + proc = subprocess.Popen( + args, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + # Never inherit the parent's stdin; ocrmypdf must not block on it. + stdin=subprocess.DEVNULL, + text=True, + errors="replace", + bufsize=1, + ) + job._process = proc + deadline = time.monotonic() + self.settings.job_timeout_seconds + timed_out = False + + assert proc.stderr is not None + for line in proc.stderr: + clean = _ANSI_RE.sub("", line).rstrip() + if clean: + job.log_lines.append(clean) + if time.monotonic() > deadline: + timed_out = True + self._terminate(proc) + break + + try: + returncode = proc.wait(timeout=30) + except subprocess.TimeoutExpired: + self._terminate(proc) + returncode = proc.wait(timeout=10) + + if timed_out: + job.status = JobStatus.failed + job.error = ( + f"Timed out after {self.settings.job_timeout_seconds // 60} minutes." + ) + return + + if returncode != 0: + job.status = JobStatus.failed + job.error = EXIT_CODE_MESSAGES.get( + returncode, f"ocrmypdf exited with code {returncode}." + ) + return + + if not job.output_path.exists() or job.output_path.stat().st_size == 0: + job.status = JobStatus.failed + job.error = "ocrmypdf reported success but produced no output." + return + + job.status = JobStatus.succeeded + + @staticmethod + def _terminate(proc: subprocess.Popen) -> None: + """Ask a child to stop, then insist.""" + try: + proc.terminate() + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + except OSError: + pass + + # -- retention --------------------------------------------------------- + + def _reap_loop(self) -> None: + """Delete batches that have outlived their TTL.""" + while not self._stopping.wait(timeout=60): + try: + self.sweep() + except Exception: # noqa: BLE001 - the reaper must keep running + log.exception("Error during batch sweep") + + def sweep(self, now: float | None = None) -> int: + """Remove expired batches. Returns the number deleted.""" + now = now if now is not None else time.time() + ttl = self.settings.batch_ttl_seconds + with self._lock: + expired = [ + batch.id + for batch in self._batches.values() + # Unfinished batches are kept alive by their age too, so a + # wedged job can never pin a directory forever. + if now - batch.created_at > ttl + ] + for batch_id in expired: + log.info("Reaping expired batch %s", batch_id) + self.delete_batch(batch_id) + self._sweep_orphans(now) + return len(expired) + + def _sweep_orphans(self, now: float) -> None: + """Remove directories left behind by a previous process.""" + with self._lock: + known = set(self._batches) + try: + entries = list(self.settings.work_dir.iterdir()) + except OSError: + return + for entry in entries: + if entry.name in known or not entry.is_dir(): + continue + try: + age = now - entry.stat().st_mtime + except OSError: + continue + if age > self.settings.batch_ttl_seconds: + shutil.rmtree(entry, ignore_errors=True) diff --git a/webui/options.py b/webui/options.py new file mode 100644 index 00000000..8c73773a --- /dev/null +++ b/webui/options.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Validated OCR options for the batch web interface. + +The whole point of this module is that *nothing* the browser sends is ever +interpolated into a command line unchecked. Every option is either a fixed +enum, a bounded integer, or a language code drawn from the set of language +packs actually installed in the image. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from webui.config import installed_languages + + +class Mode(StrEnum): + """What to do about pages that already contain text.""" + + normal = "normal" + skip_text = "skip-text" + force_ocr = "force-ocr" + redo_ocr = "redo-ocr" + + +class OutputType(StrEnum): + """Requested output conformance level.""" + + pdfa = "pdfa" + pdfa_1 = "pdfa-1" + pdfa_2 = "pdfa-2" + pdfa_3 = "pdfa-3" + pdf = "pdf" + + +class OcrOptions(BaseModel): + """Options applied to every file in a batch.""" + + model_config = ConfigDict(extra="forbid", use_enum_values=False) + + languages: list[str] = Field(default_factory=lambda: ["eng"], max_length=8) + mode: Mode = Mode.skip_text + output_type: OutputType = OutputType.pdfa + optimize: int = Field(default=1, ge=0, le=3) + deskew: bool = False + clean: bool = False + rotate_pages: bool = False + image_dpi: int = Field(default=300, ge=1, le=5000) + + @field_validator("languages") + @classmethod + def _known_languages(cls, value: list[str]) -> list[str]: + if not value: + return ["eng"] + available = set(installed_languages()) + unknown = [lang for lang in value if lang not in available] + if unknown: + raise ValueError( + "unknown or uninstalled language(s): " + ", ".join(sorted(unknown)) + ) + # Preserve caller order (Tesseract weights the first language most) + # while dropping duplicates. + seen: set[str] = set() + ordered = [] + for lang in value: + if lang not in seen: + seen.add(lang) + ordered.append(lang) + return ordered + + def to_args(self, *, jobs: int, is_image: bool) -> list[str]: + """Render these options as ``ocrmypdf`` command line arguments. + + Args: + jobs: Value for ``--jobs``, OCRmyPDF's internal worker count. + is_image: True when the input is an image rather than a PDF, in + which case ``--image-dpi`` is meaningful. + """ + args = [ + f"--language={'+'.join(self.languages)}", + f"--output-type={self.output_type.value}", + f"--optimize={self.optimize}", + f"--jobs={jobs}", + ] + if self.mode is not Mode.normal: + args.append(f"--{self.mode.value}") + if self.deskew: + args.append("--deskew") + if self.clean: + args.append("--clean") + if self.rotate_pages: + args.append("--rotate-pages") + if is_image: + args.append(f"--image-dpi={self.image_dpi}") + # A soft render error on one page shouldn't sink an unattended batch. + args.append("--continue-on-soft-render-error") + return args diff --git a/webui/static/app.js b/webui/static/app.js new file mode 100644 index 00000000..509bf833 --- /dev/null +++ b/webui/static/app.js @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: 2026 James R. Barlow +// SPDX-License-Identifier: AGPL-3.0-or-later + +'use strict'; + +const $ = (id) => document.getElementById(id); + +const el = { + uploadView: $('upload-view'), + resultsView: $('results-view'), + dropzone: $('dropzone'), + dropzoneHint: $('dropzone-hint'), + fileInput: $('file-input'), + queueWrap: $('queue-wrap'), + queue: $('queue'), + queueCount: $('queue-count'), + clearQueue: $('clear-queue'), + form: $('options-form'), + languageList: $('language-list'), + run: $('run'), + uploadStatus: $('upload-status'), + uploadProgress: $('upload-progress'), + uploadProgressBar: $('upload-progress-bar'), + results: $('results'), + resultsProgress: $('results-progress'), + downloadAll: $('download-all'), + startOver: $('start-over'), + retentionNote: $('retention-note'), + error: $('error'), + version: $('version'), +}; + +/** Server capabilities, filled in by loadConfig(). */ +let config = null; +/** Files staged for upload, keyed by name+size+lastModified. */ +const queue = new Map(); +/** Identifier of the batch currently being polled, if any. */ +let currentBatchId = null; +let pollTimer = null; +/** File ids whose log panel is expanded. */ +const openLogs = new Set(); + +// ---------------------------------------------------------------- helpers + +function formatBytes(bytes) { + if (bytes === null || bytes === undefined) return ''; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; +} + +function showError(message) { + el.error.textContent = message; + el.error.hidden = false; + el.error.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function clearError() { + el.error.hidden = true; + el.error.textContent = ''; +} + +/** Pull a useful message out of a FastAPI error response. */ +async function errorDetail(response, fallback) { + try { + const body = await response.json(); + if (typeof body.detail === 'string') return body.detail; + if (Array.isArray(body.detail) && body.detail.length) { + return body.detail.map((d) => d.msg || String(d)).join('; '); + } + } catch { + /* not JSON; fall through */ + } + return fallback; +} + +// ---------------------------------------------------------------- config + +async function loadConfig() { + const response = await fetch('/api/config'); + if (!response.ok) throw new Error('Could not load server configuration.'); + config = await response.json(); + + el.version.textContent = `v${config.version}`; + el.fileInput.accept = config.accepted_extensions.join(','); + el.dropzoneHint.textContent = + `PDF and image files · up to ${config.max_files} files, ` + + `${formatBytes(config.max_upload_bytes)} each`; + el.retentionNote.textContent = + `Results are deleted from the server after ` + + `${Math.round(config.batch_ttl_seconds / 60)} minutes. Download what you need.`; + + const defaults = config.defaults; + for (const lang of config.languages) { + const label = document.createElement('label'); + label.className = 'check'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.value = lang; + input.checked = defaults.languages.includes(lang); + label.append(input, document.createTextNode(` ${lang}`)); + el.languageList.append(label); + } + $('mode').value = defaults.mode; + $('output-type').value = defaults.output_type; + $('optimize').value = String(defaults.optimize); + $('image-dpi').value = defaults.image_dpi; +} + +function collectOptions() { + const languages = [...el.languageList.querySelectorAll('input:checked')].map( + (input) => input.value + ); + return { + languages: languages.length ? languages : ['eng'], + mode: $('mode').value, + output_type: $('output-type').value, + optimize: Number($('optimize').value), + image_dpi: Number($('image-dpi').value), + deskew: $('deskew').checked, + clean: $('clean').checked, + rotate_pages: $('rotate-pages').checked, + }; +} + +// ---------------------------------------------------------------- queue + +function addFiles(fileList) { + clearError(); + const rejected = []; + for (const file of fileList) { + const extension = file.name.includes('.') + ? `.${file.name.split('.').pop().toLowerCase()}` + : ''; + if (!config.accepted_extensions.includes(extension)) { + rejected.push(`${file.name} (unsupported type)`); + continue; + } + if (file.size > config.max_upload_bytes) { + rejected.push(`${file.name} (over ${formatBytes(config.max_upload_bytes)})`); + continue; + } + const key = `${file.name}:${file.size}:${file.lastModified}`; + if (queue.has(key)) continue; + if (queue.size >= config.max_files) { + rejected.push(`${file.name} (batch limit of ${config.max_files} reached)`); + continue; + } + queue.set(key, file); + } + if (rejected.length) { + showError(`Skipped ${rejected.length} file(s): ${rejected.join(', ')}`); + } + renderQueue(); +} + +function renderQueue() { + el.queue.replaceChildren(); + for (const [key, file] of queue) { + const row = document.createElement('li'); + row.className = 'file-row'; + + const main = document.createElement('div'); + main.className = 'file-main'; + const name = document.createElement('div'); + name.className = 'file-name'; + name.textContent = file.name; + const meta = document.createElement('div'); + meta.className = 'file-meta'; + meta.textContent = formatBytes(file.size); + main.append(name, meta); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'icon-button'; + remove.title = `Remove ${file.name}`; + remove.setAttribute('aria-label', `Remove ${file.name}`); + remove.textContent = '✕'; + remove.addEventListener('click', () => { + queue.delete(key); + renderQueue(); + }); + + row.append(main, remove); + el.queue.append(row); + } + el.queueCount.textContent = String(queue.size); + el.queueWrap.hidden = queue.size === 0; + el.run.disabled = queue.size === 0; + el.run.textContent = + queue.size > 1 ? `Run OCR on ${queue.size} files` : 'Run OCR'; +} + +// ---------------------------------------------------------------- submit + +function submitBatch(event) { + event.preventDefault(); + if (queue.size === 0) return; + clearError(); + + const form = new FormData(); + for (const file of queue.values()) form.append('files', file, file.name); + form.append('options', JSON.stringify(collectOptions())); + + el.run.disabled = true; + el.clearQueue.disabled = true; + el.uploadProgress.hidden = false; + el.uploadStatus.textContent = 'Uploading…'; + + // XHR rather than fetch: it reports upload progress, which matters when + // someone drops 50 scanned PDFs on a slow link. + const request = new XMLHttpRequest(); + request.open('POST', '/api/batches'); + request.responseType = 'json'; + + request.upload.addEventListener('progress', (progress) => { + if (!progress.lengthComputable) return; + const percent = Math.round((progress.loaded / progress.total) * 100); + el.uploadProgressBar.style.width = `${percent}%`; + el.uploadStatus.textContent = + percent < 100 ? `Uploading… ${percent}%` : 'Starting OCR…'; + }); + + request.addEventListener('load', () => { + resetUploadUi(); + if (request.status === 201 && request.response) { + currentBatchId = request.response.id; + showResults(request.response); + poll(); + } else { + const detail = + (request.response && request.response.detail) || + `Upload failed (HTTP ${request.status}).`; + showError(typeof detail === 'string' ? detail : 'Upload failed.'); + } + }); + + request.addEventListener('error', () => { + resetUploadUi(); + showError('Upload failed: the server could not be reached.'); + }); + + request.send(form); +} + +function resetUploadUi() { + el.uploadProgress.hidden = true; + el.uploadProgressBar.style.width = '0%'; + el.uploadStatus.textContent = ''; + el.run.disabled = queue.size === 0; + el.clearQueue.disabled = false; +} + +// ---------------------------------------------------------------- results + +function showResults(batch) { + el.uploadView.hidden = true; + el.resultsView.hidden = false; + renderResults(batch); +} + +const STATUS_TEXT = { + pending: 'Queued', + running: 'Working', + succeeded: 'Done', + failed: 'Failed', + cancelled: 'Cancelled', +}; + +function renderResults(batch) { + el.resultsProgress.textContent = `${batch.completed} / ${batch.total}`; + el.downloadAll.disabled = batch.downloadable === 0; + el.downloadAll.textContent = + batch.downloadable && batch.downloadable < batch.total + ? `Download ${batch.downloadable} finished (.zip)` + : 'Download all (.zip)'; + + el.results.replaceChildren(); + for (const file of batch.files) { + el.results.append(renderResultRow(batch, file)); + } +} + +function renderResultRow(batch, file) { + const row = document.createElement('li'); + row.className = 'file-row'; + + const main = document.createElement('div'); + main.className = 'file-main'; + + const name = document.createElement('div'); + name.className = 'file-name'; + name.textContent = file.name; + main.append(name); + + const meta = document.createElement('div'); + if (file.status === 'failed' && file.error) { + meta.className = 'file-meta err'; + meta.textContent = file.error; + } else { + meta.className = 'file-meta'; + meta.textContent = describeProgress(file); + } + main.append(meta); + + // Failed files get their ocrmypdf output on demand, so a user can see why. + if (file.status === 'failed') { + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'link-button'; + const open = openLogs.has(file.id); + toggle.textContent = open ? 'Hide details' : 'Show details'; + toggle.addEventListener('click', () => { + if (openLogs.has(file.id)) openLogs.delete(file.id); + else openLogs.add(file.id); + renderResultRow.refresh(batch); + }); + main.append(toggle); + if (open) main.append(renderLog(batch.id, file.id)); + } + + const pill = document.createElement('span'); + pill.className = `pill ${file.status}`; + if (file.status === 'running') { + const spinner = document.createElement('span'); + spinner.className = 'spinner'; + pill.append(spinner); + } + pill.append(document.createTextNode(STATUS_TEXT[file.status] || file.status)); + + row.append(main, pill); + + if (file.status === 'succeeded') { + const link = document.createElement('a'); + link.className = 'download'; + link.href = `/api/batches/${batch.id}/files/${file.id}`; + link.download = file.output_name; + link.textContent = 'Download'; + row.append(link); + } + return row; +} + +// Re-render without waiting for the next poll (used by the log toggle). +renderResultRow.refresh = (batch) => renderResults(batch); + +function describeProgress(file) { + const parts = [formatBytes(file.size_bytes)]; + if (file.status === 'succeeded') { + parts.push(`→ ${formatBytes(file.output_size_bytes)}`); + if (file.duration_seconds) parts.push(`${file.duration_seconds}s`); + } else if (file.status === 'running' && file.log_tail.length) { + parts.push(file.log_tail[file.log_tail.length - 1]); + } + return parts.filter(Boolean).join(' · '); +} + +function renderLog(batchId, fileId) { + const pre = document.createElement('pre'); + pre.className = 'log'; + pre.textContent = 'Loading…'; + fetch(`/api/batches/${batchId}/files/${fileId}/log`) + .then((response) => (response.ok ? response.text() : 'Log unavailable.')) + .then((text) => { + pre.textContent = text; + }) + .catch(() => { + pre.textContent = 'Log unavailable.'; + }); + return pre; +} + +// ---------------------------------------------------------------- polling + +async function poll() { + if (!currentBatchId) return; + try { + const response = await fetch(`/api/batches/${currentBatchId}`); + if (response.status === 404) { + showError('This batch expired and its files were deleted.'); + currentBatchId = null; + return; + } + if (!response.ok) throw new Error(await errorDetail(response, 'Status check failed.')); + const batch = await response.json(); + renderResults(batch); + if (!batch.finished) { + pollTimer = setTimeout(poll, 1500); + } + } catch (error) { + showError(`Lost contact with the server: ${error.message}`); + pollTimer = setTimeout(poll, 5000); + } +} + +function startOver() { + clearTimeout(pollTimer); + clearError(); + // Free the server's copies rather than waiting for the TTL sweep. + if (currentBatchId) { + fetch(`/api/batches/${currentBatchId}`, { method: 'DELETE' }).catch(() => {}); + } + currentBatchId = null; + openLogs.clear(); + queue.clear(); + renderQueue(); + el.results.replaceChildren(); + el.resultsView.hidden = true; + el.uploadView.hidden = false; + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +// ---------------------------------------------------------------- wiring + +el.dropzone.addEventListener('click', () => el.fileInput.click()); +el.dropzone.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + el.fileInput.click(); + } +}); +el.fileInput.addEventListener('change', () => { + addFiles(el.fileInput.files); + el.fileInput.value = ''; +}); + +for (const type of ['dragenter', 'dragover']) { + el.dropzone.addEventListener(type, (event) => { + event.preventDefault(); + el.dropzone.classList.add('dragover'); + }); +} +for (const type of ['dragleave', 'drop']) { + el.dropzone.addEventListener(type, () => el.dropzone.classList.remove('dragover')); +} +el.dropzone.addEventListener('drop', (event) => { + event.preventDefault(); + if (event.dataTransfer?.files?.length) addFiles(event.dataTransfer.files); +}); +// Dropping outside the zone would otherwise make the browser navigate away. +window.addEventListener('dragover', (event) => event.preventDefault()); +window.addEventListener('drop', (event) => event.preventDefault()); + +el.clearQueue.addEventListener('click', () => { + queue.clear(); + clearError(); + renderQueue(); +}); +el.form.addEventListener('submit', submitBatch); +el.startOver.addEventListener('click', startOver); +el.downloadAll.addEventListener('click', () => { + if (currentBatchId) window.location = `/api/batches/${currentBatchId}/download`; +}); + +loadConfig().then(renderQueue).catch((error) => showError(error.message)); diff --git a/webui/static/index.html b/webui/static/index.html new file mode 100644 index 00000000..8e8a7486 --- /dev/null +++ b/webui/static/index.html @@ -0,0 +1,131 @@ + + + + + + + +OCRmyPDF + + + + +
+

OCRmyPDF

+

Add a searchable text layer to scanned PDFs and images.

+
+ +
+ +
+
+ +

Drop files here or click to browse

+

PDF and image files

+ +
+ + + +
+

Options

+ +
+ Languages +
+

Pick every language that appears in your documents.

+
+ +
+ + + +
+ +
+ More options +
+ + +
+
+ + + +
+
+ +
+ + +
+ +
+
+ + + + + +
+ +
+ OCRmyPDF web interface +
+ + + + diff --git a/webui/static/style.css b/webui/static/style.css new file mode 100644 index 00000000..9b0ec65a --- /dev/null +++ b/webui/static/style.css @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: 2026 James R. Barlow + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-alt: #f0f2f5; + --border: #d9dee5; + --text: #1a1d21; + --muted: #666e79; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-soft: #e8efff; + --ok: #15803d; + --ok-soft: #e6f5ec; + --warn: #b45309; + --err: #b91c1c; + --err-soft: #fdecec; + --radius: 10px; + --shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .1); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14171a; + --surface: #1c2024; + --surface-alt: #23282e; + --border: #333a42; + --text: #e8eaed; + --muted: #9aa3ad; + --accent: #5b8cff; + --accent-hover: #7ba3ff; + --accent-soft: #1e2a44; + --ok: #4ade80; + --ok-soft: #16281d; + --warn: #fbbf24; + --err: #f87171; + --err-soft: #2c1a1a; + --shadow: none; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 1rem 3rem; + background: var(--bg); + color: var(--text); + font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +main, .topbar, footer { max-width: 820px; margin-inline: auto; } + +.topbar { padding: 2rem 0 1.25rem; } +.topbar h1 { margin: 0; font-size: 1.6rem; letter-spacing: -.02em; } +.tagline { margin: .3rem 0 0; color: var(--muted); } + +h2 { font-size: 1rem; margin: 0; } + +footer { + margin-top: 2.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: .82rem; +} + +/* ---------------- Dropzone ---------------- */ + +.dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + background: var(--surface); + padding: 2.5rem 1.5rem; + text-align: center; + cursor: pointer; + transition: border-color .15s, background .15s; +} +.dropzone:hover, .dropzone:focus-visible { border-color: var(--accent); outline: none; } +.dropzone.dragover { border-color: var(--accent); background: var(--accent-soft); } + +.dropzone-icon { + width: 34px; height: 34px; + stroke: var(--accent); stroke-width: 1.7; fill: none; + stroke-linecap: round; stroke-linejoin: round; +} +.dropzone-title { margin: .6rem 0 .2rem; font-weight: 600; } +.dropzone-hint { margin: 0; color: var(--muted); font-size: .85rem; } + +/* ---------------- Panels ---------------- */ + +#queue-wrap, .options, #results-view { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.1rem 1.25rem; + margin-top: 1.25rem; +} + +.queue-head { + display: flex; align-items: center; justify-content: space-between; + gap: 1rem; flex-wrap: wrap; +} + +.badge { + display: inline-block; + background: var(--surface-alt); + border-radius: 999px; + padding: .05rem .5rem; + font-size: .8rem; + color: var(--muted); + font-weight: 500; +} + +/* ---------------- File lists ---------------- */ + +.file-list { list-style: none; margin: .85rem 0 0; padding: 0; } + +.file-row { + display: flex; align-items: center; gap: .75rem; + padding: .6rem .1rem; + border-top: 1px solid var(--border); +} +.file-row:first-child { border-top: none; } + +.file-main { flex: 1; min-width: 0; } +.file-name { + font-weight: 500; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.file-meta { color: var(--muted); font-size: .8rem; } +.file-meta.err { color: var(--err); white-space: normal; } + +.pill { + font-size: .74rem; font-weight: 600; text-transform: uppercase; + letter-spacing: .03em; + padding: .16rem .5rem; border-radius: 999px; + background: var(--surface-alt); color: var(--muted); + white-space: nowrap; +} +.pill.running { background: var(--accent-soft); color: var(--accent); } +.pill.succeeded { background: var(--ok-soft); color: var(--ok); } +.pill.failed, .pill.cancelled { background: var(--err-soft); color: var(--err); } + +.spinner { + width: 13px; height: 13px; display: inline-block; vertical-align: -2px; + margin-right: .35rem; + border: 2px solid currentColor; border-right-color: transparent; + border-radius: 50%; + animation: spin .7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.5s; } } + +/* ---------------- Form ---------------- */ + +.field { display: block; margin-bottom: 1rem; } +.field-row { display: flex; gap: 1rem; flex-wrap: wrap; } +.field-row > .field { flex: 1 1 240px; } + +.label { + display: block; font-weight: 600; font-size: .85rem; margin-bottom: .3rem; +} +.help { color: var(--muted); font-size: .8rem; margin: .3rem 0 0; } + +select, input[type="number"] { + width: 100%; + padding: .45rem .55rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; +} +select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); + gap: .3rem .8rem; +} +.check { display: flex; align-items: center; gap: .4rem; font-size: .9rem; } +.check input { accent-color: var(--accent); } + +.advanced { margin: .3rem 0 1rem; } +.advanced summary { + cursor: pointer; font-weight: 600; font-size: .85rem; + padding: .35rem 0; color: var(--accent); +} +.advanced[open] summary { margin-bottom: .75rem; } + +.actions { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; } +.result-actions { display: flex; gap: .5rem; flex-wrap: wrap; } + +button { font: inherit; border-radius: 8px; cursor: pointer; } +button:disabled { opacity: .5; cursor: not-allowed; } + +.primary { + background: var(--accent); color: #fff; border: 1px solid var(--accent); + padding: .5rem 1.1rem; font-weight: 600; +} +.primary:hover:not(:disabled) { background: var(--accent-hover); } + +.secondary { + background: var(--surface); color: var(--text); border: 1px solid var(--border); + padding: .5rem 1.1rem; +} +.secondary:hover { border-color: var(--accent); } + +.link-button { + background: none; border: none; color: var(--accent); + padding: 0; font-size: .85rem; text-decoration: underline; +} + +.icon-button { + background: none; border: none; color: var(--muted); + font-size: 1.1rem; line-height: 1; padding: .2rem .4rem; +} +.icon-button:hover { color: var(--err); } + +a.download { + color: var(--accent); font-weight: 600; font-size: .85rem; + text-decoration: none; white-space: nowrap; +} +a.download:hover { text-decoration: underline; } + +.upload-status { color: var(--muted); font-size: .85rem; } + +/* ---------------- Progress + errors ---------------- */ + +.progress { + height: 5px; margin-top: .85rem; + background: var(--surface-alt); border-radius: 999px; overflow: hidden; +} +.progress-bar { + height: 100%; width: 0; + background: var(--accent); + transition: width .2s ease; +} + +.error { + margin-top: 1.25rem; padding: .75rem 1rem; + background: var(--err-soft); border: 1px solid var(--err); + color: var(--err); border-radius: var(--radius); +} + +.log { + margin: .5rem 0 0; padding: .6rem .7rem; + background: var(--surface-alt); border-radius: 8px; + font: .78rem/1.45 ui-monospace, "SF Mono", Menlo, Consolas, monospace; + white-space: pre-wrap; word-break: break-word; + max-height: 190px; overflow: auto; + color: var(--muted); +}