Compare commits

..
Author SHA1 Message Date
menekis b7377df7af fix 2026-08-03 22:12:37 -04:00
menekis 6542d80064 quick readmeinfo 2026-08-03 22:11:34 -04:00
menekis 7346e0f637 add a web interface to the service 2026-08-03 22:08:38 -04:00
James R. Barlow aa6a32e7d1 Bump version: v17.9.0 2026-07-31 01:14:13 -07:00
James R. Barlow ea99758747 bump_version.py: format after edit 2026-07-31 01:14:00 -07:00
James R. Barlow 4942751a1b Add v17.9.0 release notes for #1723, #1713, and CI release-draft fix 2026-07-31 00:45:59 -07:00
James R. Barlow be06e3184a Merge remote-tracking branch 'origin/dependabot/uv/gitpython-3.1.54' 2026-07-31 00:26:12 -07:00
James R. Barlow 39bf09f1eb docs: format 2026-07-28 11:30:52 -07:00
James R. Barlow aaffc46f73 Update uv lock 2026-07-28 01:10:04 -07:00
dependabot[bot]andGitHub 0277b3b3ba build(deps): bump gitpython from 3.1.52 to 3.1.54
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.52 to 3.1.54.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.54)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.54
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 07:58:28 +00:00
James R. Barlow 0817542883 Merge remote-tracking branches 'origin/dependabot/uv/gitpython-3.1.52', 'origin/dependabot/uv/pillow-12.3.0' and 'origin/dependabot/github_actions/actions/setup-python-7' 2026-07-27 23:21:25 -07:00
James R. Barlow 6f4744dd20 Fix --jpeg-quality/--jpg-quality being dropped by the CLI (closes #1723)
namespace_to_options() only copied argparse namespace keys that were
literal members of OcrOptions.model_fields. The CLI dest for both
--jpeg-quality and --jpg-quality was jpeg_quality, but the pydantic field
was named jpg_quality (jpeg_quality existed only as a compatibility
property, absent from model_fields). The value was silently dropped into
extra_attrs, and the optimizer always fell back to its own hardcoded
default regardless of the flag.

The same alias mismatch also affected the Python API: create_options()
uses the same model_fields-matching logic as namespace_to_options(), so
ocrmypdf.ocr(jpeg_quality=...) was silently dropped too - only the
canonical jpg_quality= kwarg worked.

Rather than patch around the mismatch, consolidate on a single canonical
name: OcrOptions.jpeg_quality (matching the primary --jpeg-quality CLI
flag and the already-consistent naming in OptimizeOptions). jpg_quality
becomes a deprecated compatibility property, and ocrmypdf.ocr(jpg_quality=)
is a deprecated alias that warns and forwards to jpeg_quality via a new
create_options() remap step. --jpg-quality remains a working (already
hidden) CLI alias with no code-path divergence, since it now shares an
argparse dest that matches the field name directly.
2026-07-27 23:14:27 -07:00
James R. Barlow 5d49f75c56 Use any installed Noto font when named families lack glyphs (closes #1722)
SystemFontProvider.NOTO_FONT_PATTERNS enumerates about two dozen Noto
families by name, and MultiFontManager only ever asked for those. Any
script outside that list fell through to the glyphless Occulta fallback
even when the correct font was installed, which is the common case on
macOS: it ships around a hundred script-specific Noto faces in
/System/Library/Fonts/Supplemental, almost none of which we knew how to
ask for. The reporter had the fonts and still got told to install them.

Add an optional GlyphSearchingFontProvider protocol (find_font_with_glyphs)
implemented by SystemFontProvider, BuiltinFontProvider and
ChainedFontProvider, and a new selection phase that uses it once the named
families fail. The system scan enumerates every Noto face, reusing the
existing variable-font/-Regular/-VF filename classification, and keeps
only the font it selects so a full scan does not retain every font file on
the system. Fonts found this way are remembered and retried by name for
later words. Capability detection is isinstance-based so third-party
providers keep working unchanged.

The warning itself was also unactionable: it named neither the characters
nor the script, which is why the reporter had to ask which package to
install. It now identifies what it could not render, e.g.
'Ꮳ' U+13E3 CHEROKEE LETTER TSA. A word mixing scripts that no single font
covers now gets a distinct message saying that installing fonts will not
help, rather than sending the user after fonts they already have.

Finally, the documented macOS install command was wrong: `brew install
font-noto` does not exist, since Homebrew has no single Noto package, only
a cask per family. The Fedora package name was also corrected, as
google-noto-fonts-common ships no actual fonts.
2026-07-26 23:25:51 -07:00
dependabot[bot]andGitHub 5a824ddd8c build(deps): bump gitpython from 3.1.50 to 3.1.52
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.50 to 3.1.52.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.52)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.52
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 14:07:17 +00:00
dependabot[bot]andGitHub 54bf03a454 build(deps): bump pillow from 12.2.0 to 12.3.0
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 10:59:50 +00:00
dependabot[bot]andGitHub 009754d137 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 10:42:33 +00:00
James R. Barlow f0a3a74374 Don't re-draft an already-published release on every main push
stage_release runs on every push to main and unconditionally deletes
and recreates the draft release for the version in _version.py. Since
_version.py isn't bumped until sometime after a release is tagged and
published by release.yml, every intervening push to main was clobbering
the just-published release back to draft status. Confirmed this hit
v17.3.0 through v17.8.0 (and briefly v17.8.1, manually fixed).

Skip the delete/recreate if a non-draft release already exists for
this tag.
2026-07-17 09:52:35 -07:00
James R. Barlow 178d339c8e Merge branch 'fix-image-xobject-nondict' 2026-07-17 00:59:30 -07:00
jbarlowandGitHub d3f8d01227 Merge pull request #1714 from mvanhorn/fix-nonembedded-cid-fonts-nondict-resource
fix: guard find_nonembedded_cid_fonts against non-dictionary resources
2026-07-17 00:39:35 -07:00
James R. Barlow b60df59c62 Tolerate non-dictionary /Resources and /XObject in image scanner
A malformed PDF may store a non-dictionary object (an array, name, or
other type) at /Resources or /Resources /XObject. The pdfinfo image
scanner iterated these with .items()/.as_dict() and probed them with the
`in` operator, which raise TypeError/ValueError on non-dictionary pikepdf
objects and crashed PdfInfo on otherwise-processable files. OCRmyPDF's
domain is messy, machine-generated PDFs, so scanning must tolerate this.

Guard _image_xobjects and _find_form_xobject_images with
isinstance(x, Dictionary) before iterating, treating a non-dictionary
/Resources or /XObject as "no image XObjects". This is the same
robustness class as the pdfa.py find_nonembedded_cid_fonts fix, applied
to the pdfinfo image scanner.
2026-07-17 00:05:50 -07:00
James R. Barlow 640b3062b2 Refine non-dict resource guard: prefer isinstance, cover FontDescriptor
Follow-on to the non-dictionary /Font and /XObject guard. Replace the
_dict_entries() helper with an isinstance(..., pikepdf.Dictionary) check
at each resource lookup, which pikepdf's metaclass supports directly and
which reads as exactly the invariant being enforced. Iterate via
as_dict().values() so the values are typed and mypy stays clean once the
Any from the untyped resources argument is narrowed away.

Also guard _cid_font_is_embedded against a non-dictionary /FontDescriptor:
`key in descriptor` raises ValueError on a non-dict, which the caller's
except (AttributeError, TypeError, KeyError) does not catch. Such a font
now counts as non-embedded and is reported, so PDF/A conversion is refused
rather than risking Ghostscript corrupting a pre-existing CID text layer.

Adds a regression test for the FontDescriptor case (issue #1713).
2026-07-16 23:42:50 -07:00
James R. Barlow ef903db360 Merge remote-tracking branch 'origin/main' into fix-nonembedded-cid-fonts-nondict-resource 2026-07-16 23:42:13 -07:00
Matt Van Horn 92a2fe880a Guard find_nonembedded_cid_fonts against non-dictionary resources
A malformed PDF can store a non-dictionary object under a page's /Font or
/XObject resource. find_nonembedded_cid_fonts() iterated .values() on that
object outside the per-entry try/except, so scanning such a page raised
(TypeError/AttributeError depending on the pikepdf version) instead of
producing output. This surfaced as a PDF/A conversion crash.

Route both resource lookups through a small helper that returns an empty
list when the resource is missing or not a dictionary, so a garbage entry
is simply treated as having no fonts. Add a regression test covering a
non-dictionary /Font and /XObject.
2026-07-16 00:56:07 -07:00
42 changed files with 4739 additions and 1086 deletions
+14 -1
View File
@@ -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 <image> -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
+14 -1
View File
@@ -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 <image> -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
+12 -4
View File
@@ -26,7 +26,7 @@ jobs:
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -60,7 +60,7 @@ jobs:
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
@@ -151,7 +151,7 @@ jobs:
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
@@ -200,7 +200,7 @@ jobs:
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
@@ -275,6 +275,14 @@ jobs:
run: |
TAG="v${{ steps.version.outputs.version }}"
# If release.yml already published this version, _version.py may
# still reflect it until the next version bump commit. Don't
# re-draft an already-published release on later pushes to main.
if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null)" == "false" ]]; then
echo "Release $TAG is already published; skipping."
exit 0
fi
# Delete existing draft release if it exists (ignore errors)
gh release delete "$TAG" --yes 2>/dev/null || true
+7
View File
@@ -79,6 +79,13 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
## Docker - WEbui
```
docker compose -f misc/docker-compose.webui.yml up --build
# http://localhost:8772/
#
```
## Languages
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users, you can often find packages that provide language packs:
+10
View File
@@ -331,6 +331,16 @@ def bump_version() -> None:
contents = contents.replace(find, replace)
path.write_text(contents, encoding="utf8")
# Format only after every file (including pyproject.toml) reflects the new
# version. Running `uv run` while pyproject.toml still had the old version
# would leave its post-bump environment/lockfile resync to happen for the
# first time during the commit's pre-commit hooks instead of here, which
# then aborts the commit with a spurious "files were modified by this
# hook" error.
for path, _find, _replace in actions:
if path.suffix == ".py":
subprocess.run(["uv", "run", "ruff", "format", str(path)], check=True)
print("Files updated.")
print()
+2
View File
@@ -95,10 +95,12 @@ from multiprocessing import Process
import ocrmypdf
from ocrmypdf import OcrOptions
def ocrmypdf_process():
options = OcrOptions(input_file='input.pdf', output_file='output.pdf')
ocrmypdf.ocr(options)
def call_ocrmypdf_from_my_app():
p = Process(target=ocrmypdf_process)
p.start()
+52
View File
@@ -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 <ocr-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 <http://localhost:8000/>.
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.
+21 -4
View File
@@ -663,9 +663,25 @@ provides text shaping for proper multilingual support. These replace the
legacy hOCR-based renderer. Install with: `pip install fpdf2 uharfbuzz`
**fonts-noto** (or an equivalent comprehensive font package) is recommended
for proper text rendering, especially for non-Latin scripts. On Debian/Ubuntu:
`apt install fonts-noto`. On Fedora: `dnf install google-noto-fonts-common`.
On macOS with Homebrew: `brew install font-noto`.
for proper text rendering, especially for non-Latin scripts. OCRmyPDF bundles
a Latin font only, and discovers the rest from the fonts installed on your
system.
- Debian/Ubuntu: `apt install fonts-noto`
- Fedora: `dnf install google-noto-fonts-all`
- macOS with Homebrew: Homebrew has no single Noto package; each family is a
separate cask. Install at least
`brew install --cask font-noto-sans font-noto-serif`, plus a cask per
additional script you OCR, for example
`brew install --cask font-noto-sans-arabic font-noto-sans-cjk`. Run
`brew search font-noto` to list them all.
If OCRmyPDF warns that no installed font has glyphs for some of the text, the
message names the characters it could not render, for example
`'Ꮳ' U+13E3 CHEROKEE LETTER TSA`. Install the Noto font for that script — here,
`fonts-noto-core` on Debian or `font-noto-sans-cherokee` on Homebrew. The text
layer remains searchable and copyable either way; only its appearance when
highlighted in a PDF viewer is affected.
**pypdfium2**, if present, provides fast PDF page rasterization using
the pdfium library (the same library used by Google Chrome). It is
@@ -772,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
```
+3 -1
View File
@@ -47,7 +47,9 @@ OCRmyPDF has the following runtime dependencies:
**For text rendering** (expressing OCR results in PDF):
- `fpdf2` (Python package) - Required for text layer rendering
- `uharfbuzz` (Python package) - Required for text layer rendering
- `font-noto` (system package) - Recommended for text layer rendering
- Noto fonts (system package) - Recommended for text layer rendering.
`fonts-noto` on Debian/Ubuntu, `google-noto-fonts-all` on Fedora; Homebrew
has no single Noto package, only per-family casks such as `font-noto-sans`.
**Other dependencies**:
- `unpaper` (system binary) - Optional, enables `--clean` and `--clean-final`
+13 -13
View File
@@ -120,6 +120,7 @@ A plugin may provide the following hooks. Hooks must be decorated with
```python
from ocrmypdf import hookimpl
@hookimpl
def add_options(parser):
pass
@@ -205,12 +206,11 @@ from ocrmypdf._options import OcrOptions
```python
# Before (v16 and earlier)
def check_options(options: argparse.Namespace) -> None:
...
def check_options(options: argparse.Namespace) -> None: ...
# After (v17+)
def check_options(options: OcrOptions) -> None:
...
def check_options(options: OcrOptions) -> None: ...
```
**Attribute access unchanged:**
@@ -229,6 +229,7 @@ options.tesseract_timeout
def check_options(options):
options.some_computed_value = compute_value(options)
# After (v17 pattern - compute at point of use)
def some_function(options):
computed = compute_value(options)
@@ -336,19 +337,17 @@ from ocrmypdf import OcrElement, OcrClass, BoundingBox
# OcrElement - represents any OCR structural unit
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(0, 0, 612, 792),
children=[...]
ocr_class=OcrClass.PAGE, bbox=BoundingBox(0, 0, 612, 792), children=[...]
)
# BoundingBox - axis-aligned bounding box (left, top, right, bottom)
bbox = BoundingBox(left=100, top=50, right=300, bottom=80)
# OcrClass - constants for element types
OcrClass.PAGE # "ocr_page"
OcrClass.LINE # "ocr_line"
OcrClass.WORD # "ocrx_word"
OcrClass.PARAGRAPH # "ocr_par"
OcrClass.PAGE # "ocr_page"
OcrClass.LINE # "ocr_line"
OcrClass.WORD # "ocrx_word"
OcrClass.PARAGRAPH # "ocr_par"
```
**Navigating the tree:**
@@ -378,6 +377,7 @@ from pathlib import Path
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf import OcrElement, OcrClass, BoundingBox
class MyOcrEngine(OcrEngine):
def generate_ocr(
self,
@@ -402,10 +402,10 @@ class MyOcrEngine(OcrEngine):
text="Hello",
),
# ... more words
]
],
),
# ... more lines
]
],
)
def supports_generate_ocr(self) -> bool:
+36
View File
@@ -3,6 +3,42 @@
# v17
## v17.9.0
- OCRmyPDF now uses any Noto font installed on the system, not just the two
dozen script families it knows by name ({issue}`1722`). Previously a document
in, say, Cherokee or Vai was rendered with the glyphless fallback font even
though the matching font was installed — a common situation on macOS, which
ships around a hundred script-specific Noto faces. When the named fonts
cannot cover a word, OCRmyPDF now searches the installed fonts for one that
can.
- The "no installed font has glyphs" warning now names the characters it could
not render, with their codepoints and Unicode names, so it is clear which
font to install. Text that mixes scripts no single font covers is now
reported as such, instead of advising the user to install fonts they may
already have.
- Fixed the macOS font installation instructions, which recommended a Homebrew
package (`font-noto`) that does not exist ({issue}`1722`). Homebrew has no
single Noto package; each family is a separate cask. The Fedora package name
was also corrected to `google-noto-fonts-all`.
- Font providers may now implement the optional `GlyphSearchingFontProvider`
protocol to participate in coverage-based font search.
- Fixed `--jpeg-quality`/`--jpg-quality` having no effect on the CLI: the
value was silently dropped before reaching the optimizer, which then
always used its own built-in default JPEG quality regardless of what was
requested ({issue}`1723`). The same bug affected the Python API's
`jpg_quality` parameter. `ocrmypdf.ocr()` now accepts `jpeg_quality`
(matching the CLI flag name) as the canonical parameter; `jpg_quality`
still works but is deprecated.
- Hardened PDF parsing against malformed (non-dictionary) `/Resources`,
`/XObject`, and `/FontDescriptor` entries, which previously crashed
`ocrmypdf.ocr()` with `AttributeError`/`TypeError`/`ValueError` on
otherwise-processable files, both during PDF/A font scanning and general
image scanning ({issue}`1713`). Thanks @mvanhorn for the initial fix.
- Release process improvements: fixed a CI bug where every push to main
after a release was tagged would incorrectly revert the just-published
GitHub release back to draft status.
## v17.8.1
- Improved the `--tesseract-pagesegmode` help text to point to
+61
View File
@@ -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
+16 -2
View File
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "ocrmypdf"
version = "17.8.1"
version = "17.9.0"
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
readme = "README.md"
license = "MPL-2.0"
@@ -55,10 +55,24 @@ Changelog = "https://github.com/ocrmypdf/OCRmyPDF/tree/main/docs/releasenotes"
# User-installable features - use `uv sync --extra <name>` 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]
+9 -15
View File
@@ -207,19 +207,19 @@ class OcrOptions(BaseModel):
# Optimization
optimize: int = 1
jpg_quality: int | None = None
jpeg_quality: int | None = None
png_quality: int | None = None
# Compatibility alias for plugins that expect jpeg_quality
# Deprecated compatibility alias for code that still uses the old field name
@property
def jpeg_quality(self):
"""Compatibility alias for jpg_quality."""
return self.jpg_quality
def jpg_quality(self):
"""Deprecated compatibility alias for jpeg_quality."""
return self.jpeg_quality
@jpeg_quality.setter
def jpeg_quality(self, value):
"""Compatibility alias for jpg_quality."""
self.jpg_quality = value
@jpg_quality.setter
def jpg_quality(self, value):
"""Deprecated compatibility alias for jpeg_quality."""
self.jpeg_quality = value
# Output behavior
no_overwrite: bool = False
@@ -642,12 +642,6 @@ class OcrOptions(BaseModel):
value = self.optimize
if value is not None:
kwargs[field_name] = _convert_value(value)
elif namespace == 'optimize' and field_name == 'jpeg_quality':
# jpg_quality maps to jpeg_quality
if 'jpg_quality' in OcrOptions.model_fields:
value = self.jpg_quality
if value is not None:
kwargs[field_name] = _convert_value(value)
# Create and cache the plugin options instance
instance = model_class(**kwargs)
+1 -1
View File
@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
__version__ = "17.8.1"
__version__ = "17.9.0"
+25 -3
View File
@@ -345,6 +345,23 @@ def _remap_language_to_languages(options_kwargs: dict) -> None:
del options_kwargs['language']
def _remap_jpg_quality_to_jpeg_quality(options_kwargs: dict) -> None:
"""Map the deprecated 'jpg_quality' parameter to 'jpeg_quality'.
'jpg_quality' was the original API parameter name. 'jpeg_quality' is the
canonical OcrOptions field, matching the primary --jpeg-quality CLI flag.
Prefer an explicitly-given 'jpeg_quality' if both are set.
"""
if 'jpg_quality' not in options_kwargs:
return
old_value = options_kwargs.pop('jpg_quality')
if old_value is None:
return
warn("ocrmypdf.ocr(jpg_quality=...) is deprecated, use jpeg_quality= instead.")
if options_kwargs.get('jpeg_quality') is None:
options_kwargs['jpeg_quality'] = old_value
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
) -> OcrOptions:
@@ -369,6 +386,9 @@ def create_options(
# Map API parameter 'language' to OcrOptions field 'languages'
_remap_language_to_languages(options_kwargs)
# Map deprecated 'jpg_quality' parameter to 'jpeg_quality'
_remap_jpg_quality_to_jpeg_quality(options_kwargs)
# Set input and output files
options_kwargs['input_file'] = input_file
options_kwargs['output_file'] = output_file
@@ -448,7 +468,8 @@ def ocr(
redo_ocr: bool | None = None,
skip_big: float | None = None,
optimize: int | None = None,
jpg_quality: int | None = None,
jpeg_quality: int | None = None,
jpg_quality: int | None = None, # Deprecated, use jpeg_quality instead
png_quality: int | None = None,
jbig2_lossy: bool | None = None,
jbig2_page_group_size: int | None = None,
@@ -511,7 +532,8 @@ def ocr( # noqa: D417
redo_ocr: bool | None = None, # Legacy, use mode='redo' instead
skip_big: float | None = None,
optimize: int | None = None,
jpg_quality: int | None = None,
jpeg_quality: int | None = None,
jpg_quality: int | None = None, # Deprecated, use jpeg_quality instead
png_quality: int | None = None,
jbig2_lossy: bool | None = None, # Deprecated, ignored
jbig2_page_group_size: int | None = None, # Deprecated, ignored
@@ -883,7 +905,7 @@ def _hocr_to_ocr_pdf( # noqa: D417
jobs: int | None = None,
use_threads: bool | None = None,
optimize: int | None = None,
jpg_quality: int | None = None,
jpeg_quality: int | None = None,
png_quality: int | None = None,
jbig2_lossy: bool | None = None, # Deprecated, ignored
jbig2_page_group_size: int | None = None, # Deprecated, ignored
+2
View File
@@ -18,6 +18,7 @@ from ocrmypdf.font.font_provider import (
BuiltinFontProvider,
ChainedFontProvider,
FontProvider,
GlyphSearchingFontProvider,
)
from ocrmypdf.font.multi_font_manager import MultiFontManager
from ocrmypdf.font.system_font_provider import SystemFontProvider
@@ -25,6 +26,7 @@ from ocrmypdf.font.system_font_provider import SystemFontProvider
__all__ = [
"FontManager",
"FontProvider",
"GlyphSearchingFontProvider",
"BuiltinFontProvider",
"ChainedFontProvider",
"MultiFontManager",
+60 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Protocol
from typing import Protocol, runtime_checkable
from ocrmypdf.font.font_manager import FontManager
@@ -52,6 +52,34 @@ class FontProvider(Protocol):
...
@runtime_checkable
class GlyphSearchingFontProvider(Protocol):
"""Optional capability: find a font by glyph coverage rather than by name.
A provider only knows a limited set of logical font names, but it may have
access to many more fonts than it can name (e.g. the ~100 script-specific
Noto faces macOS installs). Implementing this lets MultiFontManager use
them as a last resort instead of falling back to glyphless rendering.
Providers that do not implement this are used as-is; the capability is
detected at runtime with ``isinstance``.
"""
def find_font_with_glyphs(self, text: str) -> tuple[str, FontManager] | None:
"""Find a font that has glyphs for every character in text.
The returned name must subsequently resolve through ``get_font()``, so
that callers can cache the selection by name.
Args:
text: Text the font must fully cover
Returns:
(logical font name, FontManager), or None if no font covers text
"""
...
class BuiltinFontProvider:
"""Font provider using builtin fonts from ocrmypdf/data directory."""
@@ -119,6 +147,18 @@ class BuiltinFontProvider:
"""Get the glyphless fallback font."""
return self._fonts['Occulta']
def find_font_with_glyphs(self, text: str) -> tuple[str, FontManager] | None:
"""Find a bundled font that covers text, ignoring glyphless Occulta."""
if not text:
return None
codepoints = {ord(c) for c in text}
for name, font in self._fonts.items():
if name == 'Occulta':
continue
if all(font.has_glyph(cp) for cp in codepoints):
return name, font
return None
class ChainedFontProvider:
"""Font provider that tries multiple providers in order.
@@ -170,6 +210,25 @@ class ChainedFontProvider:
result.append(name)
return result
def find_font_with_glyphs(self, text: str) -> tuple[str, FontManager] | None:
"""Ask each capable provider in turn for a font that covers text.
Providers that don't implement the search are skipped.
Args:
text: Text the font must fully cover
Returns:
(logical font name, FontManager) from the first provider with a
match, or None if no provider found one
"""
for provider in self.providers:
if not isinstance(provider, GlyphSearchingFontProvider):
continue
if found := provider.find_font_with_glyphs(text):
return found
return None
def get_fallback_font(self) -> FontManager:
"""Get the glyphless fallback font.
+127 -11
View File
@@ -10,6 +10,7 @@ language hints and glyph coverage analysis.
from __future__ import annotations
import logging
import unicodedata
from pathlib import Path
from ocrmypdf.font.font_manager import FontManager
@@ -17,6 +18,7 @@ from ocrmypdf.font.font_provider import (
BuiltinFontProvider,
ChainedFontProvider,
FontProvider,
GlyphSearchingFontProvider,
)
from ocrmypdf.font.system_font_provider import SystemFontProvider
@@ -33,9 +35,17 @@ class MultiFontManager:
Font selection strategy:
1. Try language-preferred font (if language hint available)
2. Try fallback fonts in order by glyph coverage
3. Fall back to Occulta.ttf (glyphless fallback)
3. Ask the provider for any installed font that covers the text
4. Fall back to Occulta.ttf (glyphless fallback)
"""
# How many uncoverable characters to name in the missing-font warning
MAX_REPORTED_CHARS = 3
# How many characters of a word to look up individually when composing that
# warning; each lookup may scan every font installed on the system
MAX_EXAMINED_CHARS = 8
# Language to font mapping
# Keys are ISO 639-2/3 codes or Tesseract language codes
LANGUAGE_FONT_MAP = {
@@ -173,6 +183,9 @@ class MultiFontManager:
self._selection_cache: dict[tuple[str, str | None], str] = {}
# Track whether we've warned about missing fonts (warn once per script)
self._warned_scripts: set[str] = set()
# Fonts found by glyph coverage rather than by name, tried before
# repeating the (expensive) provider search
self._discovered_fonts: list[str] = []
@property
def fonts(self) -> dict[str, FontManager]:
@@ -208,7 +221,8 @@ class MultiFontManager:
Uses a hybrid approach:
1. Language-based selection (if language hint available)
2. Ordered fallback through available fonts by glyph coverage
3. Final fallback to Occulta.ttf (glyphless)
3. Provider search over every installed font, by glyph coverage
4. Final fallback to Occulta.ttf (glyphless)
Args:
word_text: The text content of the word
@@ -233,19 +247,50 @@ class MultiFontManager:
if result := self._try_font(preferred, word_text, cache_key):
return result
# Phase 2: Try fallback fonts in order
for font_name in self.FALLBACK_FONTS:
# Phase 2: Try fallback fonts in order, then anything a previous
# coverage search turned up
for font_name in [*self.FALLBACK_FONTS, *self._discovered_fonts]:
if font_name in tried_fonts:
continue
tried_fonts.add(font_name)
if result := self._try_font(font_name, word_text, cache_key):
return result
# Phase 3: Glyphless fallback (always succeeds)
# Phase 3: Ask the provider to search every installed font. The named
# families cover common scripts only, but systems ship many more (macOS
# installs ~100 Noto faces), and those should be used before giving up
# on rendering the text at all. See issue #1722.
if found := self._search_font_by_coverage(word_text):
font_name, font = found
self._selection_cache[cache_key] = font_name
return font
# Phase 4: Glyphless fallback (always succeeds)
# Warn if we're falling back for non-ASCII text (likely missing font)
self._warn_missing_font(word_text, line_language)
self._selection_cache[cache_key] = 'Occulta'
return self.font_provider.get_fallback_font()
def _search_font_by_coverage(self, text: str) -> tuple[str, FontManager] | None:
"""Search the provider for any font covering text, if it supports it.
Args:
text: Text the font must fully cover
Returns:
(font name, FontManager), or None if unsupported or nothing matched
"""
provider = self.font_provider
if not isinstance(provider, GlyphSearchingFontProvider):
return None
found = provider.find_font_with_glyphs(text)
if found is None:
return None
font_name, _font = found
if font_name not in self._discovered_fonts:
self._discovered_fonts.append(font_name)
return found
def _warn_missing_font(self, word_text: str, line_language: str | None) -> None:
"""Warn user about missing font for non-Latin text.
@@ -264,26 +309,97 @@ class MultiFontManager:
self._warned_scripts.add(warn_key)
uncoverable = self._uncoverable_characters(word_text)
if not uncoverable:
# Every character has a font, but no single font has them all.
# Telling the user to install fonts would be wrong advice here.
log.warning(
"Text mixing scripts that no single installed font covers (%r) "
"was added as an invisible text layer: it stays searchable and "
"copyable, but appears blank when highlighted in a PDF viewer. "
"Installing more fonts will not help; OCRmyPDF uses one font "
"per word.",
word_text,
)
return
missing = self._describe_characters(uncoverable)
if line_language and line_language in self.LANGUAGE_FONT_MAP:
font_family = self.LANGUAGE_FONT_MAP[line_language].removesuffix('-Regular')
log.warning(
"No installed font has glyphs for the detected '%s' text, so "
"it was added as an invisible text layer: it stays searchable "
"No installed font has glyphs for the detected '%s' text (%s), "
"so it was added as an invisible text layer: it stays searchable "
"and copyable, but appears blank when highlighted in a PDF "
"viewer. Install the %s font family (via your OS package "
"manager or https://fonts.google.com/noto) for full rendering.",
line_language,
missing,
font_family,
)
else:
log.warning(
"No installed font has glyphs for some of the detected text, "
"so it was added as an invisible text layer: it stays "
"No installed font has glyphs for some of the detected text "
"(%s), so it was added as an invisible text layer: it stays "
"searchable and copyable, but appears blank when highlighted "
"in a PDF viewer. Install the matching Noto fonts "
"(https://fonts.google.com/noto) for full rendering."
"in a PDF viewer. Install a Noto font covering that script "
"(https://fonts.google.com/noto) for full rendering.",
missing,
)
def _uncoverable_characters(self, word_text: str) -> list[str]:
"""Find the characters of word_text that no installed font can render.
Args:
word_text: The word that fell back to glyphless rendering
Returns:
The distinct uncoverable characters, in order of first appearance,
considering at most MAX_EXAMINED_CHARS of them
"""
candidates = [
char
for char in dict.fromkeys(word_text) # de-duplicate, keep order
if not char.isspace() and not self._is_char_renderable(char)
]
# The named fonts missed these, but the provider may still have a font
# for them, so confirm before telling the user to install anything. The
# search walks every installed font, hence the cap on how many
# characters we are willing to look up for one warning.
return [
char
for char in candidates[: self.MAX_EXAMINED_CHARS]
if self._search_font_by_coverage(char) is None
]
def _describe_characters(self, chars: list[str]) -> str:
"""Describe characters by codepoint and Unicode name.
Naming the codepoints tells the user which font to install even for
scripts OCRmyPDF has no language mapping for, which the generic
"install the matching Noto fonts" advice did not. See issue #1722.
Args:
chars: Characters to describe
Returns:
Human-readable description, truncated to MAX_REPORTED_CHARS
"""
described = ", ".join(
f"{char!r} U+{ord(char):04X} {unicodedata.name(char, 'unnamed character')}"
for char in chars[: self.MAX_REPORTED_CHARS]
)
if len(chars) > self.MAX_REPORTED_CHARS:
described += f", and {len(chars) - self.MAX_REPORTED_CHARS} more"
return described
def _is_char_renderable(self, char: str) -> bool:
"""Check whether any font already known to us has a glyph for char."""
for font_name in [*self.FALLBACK_FONTS, *self._discovered_fonts]:
font = self.font_provider.get_font(font_name)
if font is not None and self._has_all_glyphs(font, char):
return True
return False
def _has_all_glyphs(self, font: FontManager, text: str) -> bool:
"""Check if a font has glyphs for all characters in text.
+146
View File
@@ -209,6 +209,13 @@ class SystemFontProvider:
self._not_found: set[str] = set()
# Cached font directories (computed lazily)
self._font_dirs: list[Path] | None = None
# Cached (logical name, path) of every Noto face on the system, in the
# order the coverage search should try them (computed lazily)
self._noto_candidates: list[tuple[str, Path]] | None = None
# Memoized results of find_font_with_glyphs(), keyed by codepoint set
self._coverage_cache: dict[frozenset[int], str | None] = {}
# Font files that failed to load, so we only complain about them once
self._unloadable: set[Path] = set()
def _get_platform(self) -> str:
"""Get the current platform identifier.
@@ -352,6 +359,145 @@ class SystemFontProvider:
return best[1]
return None
@staticmethod
def _family_base(stem: str) -> str | None:
"""Get the Noto family base a filename stem is the Regular face of.
Args:
stem: Filename without extension, e.g. 'NotoSansCherokee-Regular'
Returns:
The family base ('NotoSansCherokee') or None if the stem is not a
Noto font, or is a weight/slope variant such as '-Bold' or
'-Italic' that should not stand in for the family.
"""
head = stem.split('[', 1)[0] # drop variable-font axes, e.g. '[wght]'
if head.endswith('-Regular'):
head = head[: -len('-Regular')]
elif head.endswith('-VF'):
head = head[: -len('-VF')]
elif '-' in head:
return None
return head if head.startswith('Noto') else None
@classmethod
def _candidate_sort_key(cls, base: str) -> tuple[int, int, str]:
"""Rank a family base for the coverage search.
Sans comes before serif before everything else, and plain families come
ahead of their narrower UI and Mono cousins.
"""
if base.startswith('NotoSans'):
family_rank = 0
elif base.startswith('NotoSerif'):
family_rank = 1
else:
family_rank = 2
narrow_use = base.endswith('UI') or base.startswith('NotoSansMono')
return (family_rank, int(narrow_use), base)
def _get_noto_candidates(self) -> list[tuple[str, Path]]:
"""Enumerate every Noto family installed on the system.
Scans each font directory once and keeps the best-ranked file per
family, so a family present in several directories or in several
variants contributes a single candidate.
Returns:
List of (logical font name, path) in the order to try them.
"""
if self._noto_candidates is not None:
return self._noto_candidates
best: dict[str, tuple[int, Path]] = {}
for font_dir in self._get_font_dirs():
if not font_dir.exists():
continue
try:
paths = sorted(font_dir.rglob('Noto*'))
except OSError:
# Skip directories we can't read
continue
for path in paths:
if path.suffix.lower() not in self._FONT_EXTENSIONS:
continue
base = self._family_base(path.stem)
if base is None:
continue
kind = self._classify_variant(path.stem, base)
if kind is None:
continue
rank = self._VARIANT_RANK[kind]
if base not in best or rank < best[base][0]:
best[base] = (rank, path)
self._noto_candidates = [
(f'{base}-Regular', path)
for base, (_rank, path) in sorted(
best.items(), key=lambda item: self._candidate_sort_key(item[0])
)
]
return self._noto_candidates
def find_font_with_glyphs(self, text: str) -> tuple[str, FontManager] | None:
"""Find any installed Noto font that covers every character in text.
``NOTO_FONT_PATTERNS`` enumerates the couple dozen scripts OCRmyPDF
knows by name, but systems ship far more: macOS alone installs around a
hundred script-specific Noto faces in
``/System/Library/Fonts/Supplemental``. This is the last resort that
makes those usable, so a document is only rendered glyphless when no
installed font can actually cover it. See issue #1722.
This walks every Noto face on the system and is therefore expensive;
results are memoized, and callers should only reach it after the named
fonts have failed.
Args:
text: Text that the returned font must fully cover
Returns:
(logical font name, FontManager) of the first covering font, or
None if nothing installed covers the text.
"""
if not text:
return None
needed = frozenset(ord(c) for c in text)
if needed in self._coverage_cache:
cached_name = self._coverage_cache[needed]
if cached_name is None:
return None
if cached := self._font_cache.get(cached_name):
return cached_name, cached
for font_name, path in self._get_noto_candidates():
font = self._font_cache.get(font_name)
if font is None:
if path in self._unloadable:
continue
try:
font = FontManager(path)
except Exception as e:
log.debug("Skipping unreadable font %s: %s", path, e)
self._unloadable.add(path)
continue
if all(font.has_glyph(cp) for cp in needed):
# Keep only fonts we actually use; the rest are released so a
# full scan doesn't retain every font file on the system.
self._font_cache[font_name] = font
self._not_found.discard(font_name)
self._coverage_cache[needed] = font_name
log.debug(
"Found system font %s at %s (glyph coverage match)",
font_name,
path,
)
return font_name, font
self._coverage_cache[needed] = None
return None
def get_font(self, font_name: str) -> FontManager | None:
"""Get a FontManager for the named font (lazy loading).
+7 -7
View File
@@ -449,12 +449,12 @@ def convert_to_jbig2(
def _optimize_jpeg(
xref: Xref, in_jpg: Path, opt_jpg: Path, jpg_quality: int
xref: Xref, in_jpg: Path, opt_jpg: Path, jpeg_quality: int
) -> tuple[Xref, Path | None]:
with Image.open(in_jpg) as im:
save_kwargs: dict[str, Any] = {'optimize': True}
if isinstance(jpg_quality, int) and 0 < jpg_quality <= 100:
save_kwargs['quality'] = jpg_quality
if isinstance(jpeg_quality, int) and 0 < jpeg_quality <= 100:
save_kwargs['quality'] = jpeg_quality
im.save(opt_jpg, **save_kwargs)
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
@@ -473,7 +473,7 @@ def transcode_jpegs(
for xref in jpegs:
in_jpg = jpg_name(root, xref)
opt_jpg = in_jpg.with_suffix('.opt.jpg')
yield xref, in_jpg, opt_jpg, options.jpg_quality
yield xref, in_jpg, opt_jpg, options.jpeg_quality
def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar):
xref, opt_jpg = result
@@ -703,8 +703,8 @@ def optimize(
safe_symlink(input_file, output_file)
return output_file
if not options.jpg_quality:
options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
if not options.jpeg_quality:
options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
if not options.png_quality:
options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
@@ -766,7 +766,7 @@ def main(infile, outfile, level, jobs=1):
output_file=outfile, # Required field
jobs=jobs,
optimize=int(level),
jpg_quality=0, # Use default
jpeg_quality=0, # Use default
png_quality=0,
jbig2_threshold=0.85,
quiet=True,
+13 -7
View File
@@ -12,7 +12,7 @@ from importlib.resources import files as package_files
from pathlib import Path
import pikepdf
from pikepdf import Array, Dictionary, Name, Pdf, Stream
from pikepdf import Array, Dictionary, Name, Object, Pdf, Stream
log = logging.getLogger(__name__)
@@ -137,11 +137,13 @@ def file_claims_pdfa(filename: Path):
return pdfa_dict
def _cid_font_is_embedded(type0_font: Dictionary) -> bool:
def _cid_font_is_embedded(type0_font: Object) -> bool:
"""Return True if a Type0 font's CID descendant carries embedded glyphs."""
for descendant in type0_font.get(Name.DescendantFonts, []):
descriptor = descendant.get(Name.FontDescriptor, None)
if descriptor is not None and any(
# A malformed PDF may store a non-dictionary here; `key in descriptor`
# raises on those, so require a real dictionary before probing it.
if isinstance(descriptor, Dictionary) and any(
key in descriptor for key in (Name.FontFile, Name.FontFile2, Name.FontFile3)
):
return True
@@ -174,9 +176,13 @@ def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]:
def scan_resources(resources, depth: int = 0) -> None:
if resources is None or depth > 10:
return
# A well-formed PDF stores dictionaries under /Font and /XObject, but a
# malformed one (common in OCR workloads) may store an array, a name, or
# another non-dictionary object. Only such dictionaries have .values(),
# so guard with isinstance rather than let the scan crash (issue #1713).
fonts = resources.get(Name.Font, None)
if fonts is not None:
for font in fonts.values():
if isinstance(fonts, Dictionary):
for font in fonts.as_dict().values():
try:
if font.get(Name.Subtype) != Name.Type0:
continue
@@ -186,8 +192,8 @@ def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]:
except (AttributeError, TypeError, KeyError):
continue
xobjects = resources.get(Name.XObject, None)
if xobjects is not None:
for xobj in xobjects.values():
if isinstance(xobjects, Dictionary):
for xobj in xobjects.as_dict().values():
if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj:
scan_resources(xobj[Name.Resources], depth + 1)
+15 -4
View File
@@ -287,9 +287,15 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
if Name.Resources not in container:
return
resources = container[Name.Resources]
if Name.XObject not in resources:
# A malformed PDF may store a non-dictionary at /Resources or
# /Resources /XObject; treat that as "no image XObjects" instead of
# crashing when we try to iterate it.
if not isinstance(resources, Dictionary):
return
for key, candidate in resources[Name.XObject].items():
xobjects = resources.get(Name.XObject)
if not isinstance(xobjects, Dictionary):
return
for key, candidate in xobjects.items():
if candidate is None or Name.Subtype not in candidate:
continue
if candidate[Name.Subtype] == Name.Image:
@@ -336,9 +342,14 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
if Name.Resources not in container:
return
resources = container[Name.Resources]
if Name.XObject not in resources:
# As in _image_xobjects, tolerate a non-dictionary /Resources or
# /Resources /XObject in a malformed PDF rather than crashing.
if not isinstance(resources, Dictionary):
return
xobjs = resources[Name.XObject].as_dict()
xobject = resources.get(Name.XObject)
if not isinstance(xobject, Dictionary):
return
xobjs = xobject.as_dict()
for xobj in xobjs:
candidate = xobjs[xobj]
if candidate is None or candidate.get(Name.Subtype) != Name.Form:
+39
View File
@@ -79,6 +79,45 @@ def test_language_parameter_mapped_to_languages():
assert options.languages == ['eng', 'spa']
def test_jpeg_quality_parameter_reaches_options():
"""The canonical 'jpeg_quality' API parameter must reach OcrOptions.
Regression test for GitHub issue #1723: --jpeg-quality was silently
dropped by the CLI's namespace_to_options() because the OcrOptions field
was named jpg_quality. create_options(), used by the Python API, has the
same field-name matching logic and is affected the same way when passed
the alias name.
"""
from ocrmypdf.api import create_options, setup_plugin_infrastructure
from ocrmypdf.cli import get_parser
setup_plugin_infrastructure()
parser = get_parser()
options = create_options(
input_file='test.pdf', output_file='output.pdf', parser=parser, jpeg_quality=10
)
assert options.jpeg_quality == 10
def test_jpg_quality_parameter_deprecated_alias():
"""The old 'jpg_quality' API parameter still works but warns."""
from ocrmypdf.api import create_options, setup_plugin_infrastructure
from ocrmypdf.cli import get_parser
setup_plugin_infrastructure()
parser = get_parser()
with pytest.warns(UserWarning, match='jpg_quality'):
options = create_options(
input_file='test.pdf',
output_file='output.pdf',
parser=parser,
jpg_quality=42,
)
assert options.jpeg_quality == 42
def test_stream_api(resources: Path):
in_ = (resources / 'graph.pdf').open('rb')
out = BytesIO()
+114
View File
@@ -569,3 +569,117 @@ def test_missing_font_warning_explains_consequences(font_dir, caplog):
# the text stays searchable but renders blank when highlighted.
assert 'searchable' in msg.lower()
assert 'highlight' in msg.lower() or 'select' in msg.lower()
# --- Coverage-driven last-resort font search (#1722) ---
class _FakeProviderWithSearch(_FakeFontProvider):
"""FontProvider that can also search unlisted fonts by glyph coverage."""
def __init__(self, fonts, searchable):
super().__init__(fonts)
self._searchable = searchable
self.search_calls: list[str] = []
def find_font_with_glyphs(self, text):
self.search_calls.append(text)
for name, font in self._searchable.items():
hb = font.get_hb_font()
if all(hb.get_nominal_glyph(ord(c)) for c in text):
self._fonts[name] = font # discovered fonts become resolvable
return name, font
return None
def test_unlisted_font_found_by_coverage_search():
"""A script outside FALLBACK_FONTS is rendered if the font is installed."""
searchable = {'NotoSansCherokee-Regular': _FakeFontManager('Cherokee.ttf', 'ᏣᎳᎩ')}
provider = _FakeProviderWithSearch({}, searchable)
manager = MultiFontManager(font_provider=provider)
font = manager.select_font_for_word('ᏣᎳᎩ', None)
assert font.font_path.name == 'Cherokee.ttf'
assert provider.search_calls == ['ᏣᎳᎩ']
def test_coverage_search_result_is_cached():
"""The expensive coverage search runs once per distinct word."""
searchable = {'NotoSansCherokee-Regular': _FakeFontManager('Cherokee.ttf', 'ᏣᎳᎩ')}
provider = _FakeProviderWithSearch({}, searchable)
manager = MultiFontManager(font_provider=provider)
manager.select_font_for_word('ᏣᎳᎩ', None)
manager.select_font_for_word('ᏣᎳᎩ', None)
assert len(provider.search_calls) == 1
def test_named_fonts_take_precedence_over_coverage_search():
"""The coverage search is a last resort, not a substitute for named fonts."""
fonts = {'NotoSans-Regular': _FakeFontManager('NotoSans.ttf', 'abc')}
searchable = {'NotoSansMono-Regular': _FakeFontManager('NotoSansMono.ttf', 'abc')}
provider = _FakeProviderWithSearch(fonts, searchable)
manager = MultiFontManager(font_provider=provider)
font = manager.select_font_for_word('abc', None)
assert font.font_path.name == 'NotoSans.ttf'
assert provider.search_calls == []
def test_provider_without_coverage_search_still_falls_back():
"""Providers predating find_font_with_glyphs() keep working (duck-typed)."""
manager = MultiFontManager(font_provider=_FakeFontProvider({}))
font = manager.select_font_for_word('ᏣᎳᎩ', None)
assert font.font_path.name == 'Occulta.ttf'
def test_missing_font_warning_names_the_missing_characters(font_dir, caplog):
"""The warning must identify what could not be rendered (#1722).
The user's real question is "which font package do I install?" — naming the
offending codepoints and their Unicode names answers it even for scripts
OCRmyPDF has no language mapping for.
"""
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
with caplog.at_level(logging.WARNING):
manager.select_font_for_word('ᏣᎳᎩ', None)
msg = caplog.text
assert 'U+13E3' in msg # CHEROKEE LETTER TSA
assert 'CHEROKEE' in msg.upper()
def test_missing_character_warning_ignores_covered_characters(font_dir, caplog):
"""Only the uncoverable characters are reported, not the whole word."""
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
with caplog.at_level(logging.WARNING):
manager.select_font_for_word('aᏣb', None)
msg = caplog.text
assert 'U+13E3' in msg
assert 'U+0061' not in msg # 'a' is covered by the builtin Latin font
def test_mixed_script_word_warning_does_not_advise_installing_fonts(caplog):
"""A word no single font covers is reported as such, not as a missing font.
Every character here has an installed font; telling the user to install
more would be wrong advice and is exactly the confusion behind #1722.
"""
fonts = {'NotoSans-Regular': _FakeFontManager('NotoSans.ttf', 'ab')}
searchable = {'NotoSansCherokee-Regular': _FakeFontManager('Cherokee.ttf', '')}
manager = MultiFontManager(font_provider=_FakeProviderWithSearch(fonts, searchable))
with caplog.at_level(logging.WARNING):
font = manager.select_font_for_word('aᏣb', None)
assert font.font_path.name == 'Occulta.ttf'
msg = caplog.text
assert 'mixing scripts' in msg
assert 'will not help' in msg
assert 'U+' not in msg # no codepoints to blame; nothing to install
+23
View File
@@ -17,6 +17,7 @@ from PIL import Image, ImageDraw
from ocrmypdf import optimize as opt
from ocrmypdf._exec import jbig2enc, pngquant
from ocrmypdf._exec.ghostscript import rasterize_pdf
from ocrmypdf.cli import get_options_and_plugins
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
from ocrmypdf.optimize import PdfImage, extract_image_filter
from ocrmypdf.pluginspec import GhostscriptRasterDevice
@@ -81,6 +82,28 @@ def test_jpg_png_params(resources, outpdf):
)
def test_jpeg_quality_cli_flag_reaches_options(resources, outpdf):
# Regression test for #1723: --jpeg-quality was silently dropped by
# namespace_to_options() because the argparse dest ('jpeg_quality') did
# not match the OcrOptions field it was checked against.
input_ = fspath(resources / 'c02-22.pdf')
options, _pm = get_options_and_plugins(
['--jpeg-quality', '10', input_, fspath(outpdf)]
)
assert options.jpeg_quality == 10
def test_jpg_quality_cli_alias_reaches_options(resources, outpdf):
# --jpg-quality is a hidden alias for --jpeg-quality (same argparse dest).
input_ = fspath(resources / 'c02-22.pdf')
options, _pm = get_options_and_plugins(
['--jpg-quality', '42', input_, fspath(outpdf)]
)
assert options.jpeg_quality == 42
# The old field name is still readable as a deprecated compatibility alias.
assert options.jpg_quality == 42
@needs_jbig2enc
def test_jbig2_lossless(resources, outpdf):
"""Test that JBIG2 lossless encoding works without JBIG2Globals."""
+46
View File
@@ -94,6 +94,52 @@ class TestFindNonembeddedCidFonts:
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == {'ZZZ+Hidden'}
def test_non_dictionary_font_and_xobject_resources_are_ignored(self, tmp_path):
# A malformed PDF may carry a /Font or /XObject resource that is not a
# dictionary (an array, a name, an empty value). Scanning must skip it
# rather than raise when iterating its values (regression test for the
# crash reported in issue #1713).
path = tmp_path / 'malformed_resources.pdf'
with pikepdf.new() as pdf:
page = pdf.add_blank_page()
page.Resources = pikepdf.Dictionary(
Font=pikepdf.Array([]),
XObject=pikepdf.Array([]),
)
pdf.save(path)
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == set()
def test_non_dictionary_font_descriptor_is_reported(self, tmp_path):
# A Type0 font whose descendant carries a non-dictionary /FontDescriptor
# has no embedded glyph data, so it must be reported -- not crash. This
# is the same malformed-resource bug class as #1713, one level deeper:
# `key in descriptor` raises ValueError on a non-dictionary.
path = tmp_path / 'bad_descriptor.pdf'
with pikepdf.new() as pdf:
page = pdf.add_blank_page()
cidfont = pdf.make_indirect(
pikepdf.Dictionary(
Type=Name.Font,
Subtype=Name.CIDFontType2,
BaseFont=Name('/BOGUS+CID'),
FontDescriptor=Name.NotADictionary,
)
)
type0 = pdf.make_indirect(
pikepdf.Dictionary(
Type=Name.Font,
Subtype=Name.Type0,
BaseFont=Name('/BOGUS+CID'),
Encoding=Name.Identity_H,
DescendantFonts=pikepdf.Array([cidfont]),
)
)
page.Resources = pikepdf.Dictionary(Font=pikepdf.Dictionary(F0=type0))
pdf.save(path)
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == {'BOGUS+CID'}
@pytest.fixture
def nonembedded_cid_pdf(tmp_path):
+49
View File
@@ -448,6 +448,55 @@ def test_fill_ink_cs_resets_color_to_black():
assert _ink_of_first_xobject(b"0.8 0.2 0.2 rg /DeviceGray cs /Im0 Do") is Ink.mono
def test_nondict_xobject_tolerated(outdir):
# A malformed PDF may store a non-dictionary object (here an Array) at
# /Resources /XObject. Scanning for images must tolerate this rather than
# crash on .items(); OCRmyPDF's domain is messy machine-generated PDFs.
# Same robustness class as the pdfa.py find_nonembedded_cid_fonts fix.
pdf = pikepdf.Pdf.new()
page = pdf.add_blank_page(page_size=(612, 792))
page.Resources = pikepdf.Dictionary(
Font=pikepdf.Array([]), XObject=pikepdf.Array([])
)
out = outdir / 'malformed_xobj.pdf'
pdf.save(out)
info = pdfinfo.PdfInfo(out)
assert len(info) == 1
assert len(info[0].images) == 0
@pytest.mark.parametrize(
'resources',
[
pikepdf.Array([]), # non-dict /Resources
pikepdf.Name.Foo, # non-dict /Resources (name)
pikepdf.Dictionary(XObject=pikepdf.Array([])), # non-dict /XObject
pikepdf.Dictionary(XObject=pikepdf.Name.Foo), # non-dict /XObject (name)
],
)
def test_image_scanners_tolerate_nondict_resources(resources):
# Exercise the image scanners directly on an in-memory container whose
# /Resources or /Resources /XObject is not a dictionary. (pikepdf
# normalizes a non-dict /Resources assigned to a page on save, so these
# cases must be built in memory to reach the scanner unmodified.)
from ocrmypdf.pdfinfo._contentstream import ContentsInfo
from ocrmypdf.pdfinfo._image import _find_form_xobject_images, _image_xobjects
container = pikepdf.Dictionary(Type=pikepdf.Name.Page, Resources=resources)
empty = ContentsInfo(
xobject_settings=[],
inline_images=[],
found_vector=False,
found_text=False,
name_index={},
)
pdf = pikepdf.Pdf.new()
assert list(_image_xobjects(container)) == []
assert list(_find_form_xobject_images(pdf, container, empty)) == []
def test_imageinfo_ink_inherited_in_form_xobject(outdir):
# A mask drawn inside a Form XObject inherits the fill color set before the
# Do that paints the form; the gray classification must reach the mask.
+180
View File
@@ -363,6 +363,140 @@ class TestSystemFontProviderVariableFonts:
assert provider.get_font('NotoSansSC-Regular') is None
class TestSystemFontProviderUnlistedFamilies:
"""Test the coverage-driven search over Noto families we don't enumerate.
``NOTO_FONT_PATTERNS`` names only the couple dozen most common scripts, but
macOS ships ~100 script-specific Noto fonts and Homebrew/Linux distros offer
even more. Those fonts must still be usable when the enumerated families
cannot cover the text. See issue #1722.
"""
@pytest.fixture
def real_font_bytes(self):
"""Bytes of a real, loadable font covering ASCII."""
font_path = (
Path(__file__).parent.parent
/ "src"
/ "ocrmypdf"
/ "data"
/ "NotoSans-Regular.ttf"
)
if not font_path.exists():
pytest.skip("Builtin font not available")
return font_path.read_bytes()
def _provider_for(self, tmp_path, filenames, real_font_bytes):
"""Build a provider whose only font dir is tmp_path with given files."""
for name in filenames:
(tmp_path / name).write_bytes(real_font_bytes)
provider = SystemFontProvider()
provider._font_dirs = [tmp_path]
return provider
def test_finds_unlisted_family_by_coverage(self, tmp_path, real_font_bytes):
"""A Noto family absent from NOTO_FONT_PATTERNS is still usable."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
assert 'NotoSansCherokee-Regular' not in provider.NOTO_FONT_PATTERNS
found = provider.find_font_with_glyphs('A')
assert found is not None
name, font = found
assert name == 'NotoSansCherokee-Regular'
assert font.font_path.name == 'NotoSansCherokee-Regular.ttf'
def test_finds_unlisted_variable_family(self, tmp_path, real_font_bytes):
"""Bracketed variable filenames are eligible for the coverage search."""
provider = self._provider_for(
tmp_path, ['NotoSansVithkuqi[wght].ttf'], real_font_bytes
)
found = provider.find_font_with_glyphs('A')
assert found is not None
assert found[0] == 'NotoSansVithkuqi-Regular'
def test_discovered_font_resolves_by_logical_name(self, tmp_path, real_font_bytes):
"""A font found by coverage is afterwards reachable via get_font()."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
name, font = provider.find_font_with_glyphs('A')
assert provider.get_font(name) is font
def test_negative_cache_does_not_block_discovery(self, tmp_path, real_font_bytes):
"""A prior failed get_font() must not hide a later coverage match."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
assert provider.get_font('NotoSansCherokee-Regular') is None # not listed
name, font = provider.find_font_with_glyphs('A')
assert provider.get_font(name) is font
def test_skips_bold_and_italic_styles(self, tmp_path, real_font_bytes):
"""Only Regular/variable faces are candidates, never Bold or Italic."""
provider = self._provider_for(
tmp_path,
[
'NotoSansCherokee-Bold.ttf',
'NotoSansCherokee-Italic.ttf',
'NotoSans-Italic[wdth,wght].ttf',
],
real_font_bytes,
)
assert provider.find_font_with_glyphs('A') is None
def test_ignores_non_noto_fonts(self, tmp_path, real_font_bytes):
"""Non-Noto system fonts are not enlisted by the coverage search."""
provider = self._provider_for(
tmp_path, ['DejaVuSans.ttf', 'Arial.ttf'], real_font_bytes
)
assert provider.find_font_with_glyphs('A') is None
def test_returns_none_when_no_font_covers_text(self, tmp_path, real_font_bytes):
"""Text no installed font covers yields no match rather than a wrong one."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
# U+13A3 CHEROKEE LETTER O is absent from the Latin font's cmap.
assert provider.find_font_with_glyphs('') is None
def test_empty_text_does_not_match(self, tmp_path, real_font_bytes):
"""Empty text has nothing to cover, so no font is claimed for it."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
assert provider.find_font_with_glyphs('') is None
def test_unloadable_font_file_is_skipped(self, tmp_path, real_font_bytes):
"""A corrupt font file does not abort the search for a usable one."""
(tmp_path / 'NotoSansBroken-Regular.ttf').write_bytes(b'not a font')
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
found = provider.find_font_with_glyphs('A')
assert found is not None
assert found[0] == 'NotoSansCherokee-Regular'
@pytest.mark.parametrize(
'stem,expected',
[
('NotoSansCherokee-Regular', 'NotoSansCherokee'),
('NotoSansCherokee[wght]', 'NotoSansCherokee'),
('NotoSansArabic[wdth,wght]', 'NotoSansArabic'),
('NotoSansCJKsc-VF', 'NotoSansCJKsc'),
('NotoMusic', 'NotoMusic'),
('NotoSansCJK-Regular', 'NotoSansCJK'),
('NotoSans-Bold', None),
('NotoSans-Italic[wdth,wght]', None),
('NotoSans-SemiCondensedBlackItalic', None),
('DejaVuSans-Regular', None),
],
)
def test_family_base_parsing(self, stem, expected):
"""Filename stems map to family bases, rejecting non-Regular styles."""
assert SystemFontProvider._family_base(stem) == expected
# --- ChainedFontProvider Tests ---
@@ -507,3 +641,49 @@ class TestChainedFontProviderIntegration:
# Chain should have at least as many fonts as builtin
assert chain_fonts >= builtin_fonts
class TestChainedFontProviderCoverageSearch:
"""Test that the chain delegates the coverage search to its members."""
class _Searchable:
"""Provider stub that reports one findable font."""
def __init__(self, result):
self.result = result
self.calls = 0
def get_font(self, name):
return None
def get_available_fonts(self):
return []
def get_fallback_font(self):
raise NotImplementedError
def find_font_with_glyphs(self, text):
self.calls += 1
return self.result
def test_delegates_to_first_provider_that_finds_a_font(self):
"""The first provider with a match wins; later ones are not consulted."""
first = self._Searchable(('NotoSansX-Regular', MagicMock()))
second = self._Searchable(('NotoSansY-Regular', MagicMock()))
chain = ChainedFontProvider([first, second])
assert chain.find_font_with_glyphs('x')[0] == 'NotoSansX-Regular'
assert second.calls == 0
def test_skips_providers_without_the_capability(self):
"""Providers lacking find_font_with_glyphs() are skipped, not fatal."""
legacy = MagicMock(spec=['get_font', 'get_available_fonts'])
searchable = self._Searchable(('NotoSansX-Regular', MagicMock()))
chain = ChainedFontProvider([legacy, searchable])
assert chain.find_font_with_glyphs('x')[0] == 'NotoSansX-Regular'
def test_returns_none_when_nothing_matches(self):
"""No provider matching yields None so the caller can use Occulta."""
chain = ChainedFontProvider([self._Searchable(None)])
assert chain.find_font_with_glyphs('x') is None
+9 -2
View File
@@ -89,8 +89,15 @@ def test_mutex_options():
make_ocr_opts(redo_ocr=True, force_ocr=True)
def test_optimizing(caplog):
vd.check_options(*make_opts_pm(optimize=0, png_quality=18, jpeg_quality=10))
def test_optimizing_png_quality_warns(caplog):
vd.check_options(*make_opts_pm(optimize=0, png_quality=18))
assert 'will be ignored because' in caplog.text
def test_optimizing_jpeg_quality_warns(caplog):
# Isolated from png_quality so this actually exercises the jpeg_quality
# path rather than being confounded by png_quality also being set.
vd.check_options(*make_opts_pm(optimize=0, jpeg_quality=10))
assert 'will be ignored because' in caplog.text
+401
View File
@@ -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
Generated
+1210 -1009
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
<!--
SPDX-FileCopyrightText: 2026 James R. Barlow
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# 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.
+19
View File
@@ -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"
+52
View File
@@ -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())
+250
View File
@@ -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")
+144
View File
@@ -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))
+469
View File
@@ -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)
+101
View File
@@ -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
+461
View File
@@ -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));
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<!--
SPDX-FileCopyrightText: 2026 James R. Barlow
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>OCRmyPDF</title>
<link rel="stylesheet" href="/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128441;</text></svg>">
</head>
<body>
<header class="topbar">
<h1>OCRmyPDF</h1>
<p class="tagline">Add a searchable text layer to scanned PDFs and images.</p>
</header>
<main>
<!-- ---------------- Upload view ---------------- -->
<section id="upload-view">
<div id="dropzone" class="dropzone" tabindex="0" role="button"
aria-label="Choose files or drop them here">
<svg viewBox="0 0 24 24" aria-hidden="true" class="dropzone-icon">
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
</svg>
<p class="dropzone-title">Drop files here or click to browse</p>
<p class="dropzone-hint" id="dropzone-hint">PDF and image files</p>
<input type="file" id="file-input" multiple hidden>
</div>
<div id="queue-wrap" hidden>
<div class="queue-head">
<h2>Selected files <span id="queue-count" class="badge">0</span></h2>
<button type="button" id="clear-queue" class="link-button">Clear all</button>
</div>
<ul id="queue" class="file-list"></ul>
</div>
<form id="options-form" class="options">
<h2>Options</h2>
<div class="field">
<span class="label">Languages</span>
<div id="language-list" class="checkbox-grid"></div>
<p class="help">Pick every language that appears in your documents.</p>
</div>
<div class="field-row">
<label class="field">
<span class="label">Pages that already have text</span>
<select id="mode">
<option value="skip-text">Skip them (recommended)</option>
<option value="redo-ocr">Redo the existing OCR</option>
<option value="force-ocr">Rasterize and force OCR</option>
<option value="normal">Stop with an error</option>
</select>
</label>
<label class="field">
<span class="label">Output format</span>
<select id="output-type">
<option value="pdfa">PDF/A (archival, recommended)</option>
<option value="pdf">Standard PDF</option>
<option value="pdfa-1">PDF/A-1</option>
<option value="pdfa-2">PDF/A-2</option>
<option value="pdfa-3">PDF/A-3</option>
</select>
</label>
</div>
<details class="advanced">
<summary>More options</summary>
<div class="field-row">
<label class="field">
<span class="label">Optimization</span>
<select id="optimize">
<option value="0">None</option>
<option value="1" selected>Safe (recommended)</option>
<option value="2">Lossy images</option>
<option value="3">Aggressive</option>
</select>
</label>
<label class="field">
<span class="label">Image DPI</span>
<input type="number" id="image-dpi" value="300" min="1" max="5000" step="10">
<span class="help">Used only for image inputs without DPI metadata.</span>
</label>
</div>
<div class="checkbox-grid">
<label class="check"><input type="checkbox" id="deskew"> Straighten crooked scans</label>
<label class="check"><input type="checkbox" id="clean"> Clean up before OCR</label>
<label class="check"><input type="checkbox" id="rotate-pages"> Auto-rotate pages</label>
</div>
</details>
<div class="actions">
<button type="submit" id="run" class="primary" disabled>Run OCR</button>
<span id="upload-status" class="upload-status" aria-live="polite"></span>
</div>
<div class="progress" id="upload-progress" hidden>
<div class="progress-bar" id="upload-progress-bar"></div>
</div>
</form>
</section>
<!-- ---------------- Results view ---------------- -->
<section id="results-view" hidden>
<div class="queue-head">
<h2>Results <span id="results-progress" class="badge">0 / 0</span></h2>
<div class="result-actions">
<button type="button" id="download-all" class="primary" disabled>Download all (.zip)</button>
<button type="button" id="start-over" class="secondary">Start over</button>
</div>
</div>
<p class="help" id="retention-note"></p>
<ul id="results" class="file-list"></ul>
</section>
<div id="error" class="error" hidden role="alert"></div>
</main>
<footer>
<span>OCRmyPDF web interface <span id="version"></span></span>
</footer>
<script src="/app.js"></script>
</body>
</html>
+258
View File
@@ -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);
}