Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7377df7af | ||
|
|
6542d80064 | ||
|
|
7346e0f637 | ||
|
|
aa6a32e7d1 | ||
|
|
ea99758747 | ||
|
|
4942751a1b | ||
|
|
be06e3184a | ||
|
|
39bf09f1eb | ||
|
|
aaffc46f73 | ||
|
|
0277b3b3ba | ||
|
|
0817542883 | ||
|
|
6f4744dd20 | ||
|
|
5d49f75c56 | ||
|
|
5a824ddd8c | ||
|
|
54bf03a454 | ||
|
|
009754d137 | ||
|
|
f0a3a74374 | ||
|
|
178d339c8e | ||
|
|
d3f8d01227 | ||
|
|
b60df59c62 | ||
|
|
640b3062b2 | ||
|
|
ef903db360 | ||
|
|
92a2fe880a |
+14
-1
@@ -55,7 +55,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
COPY . /app
|
COPY . /app
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen \
|
uv sync --frozen \
|
||||||
--extra webservice --extra watcher --no-dev \
|
--extra webservice --extra watcher --extra webui --no-dev \
|
||||||
--no-install-package pyarrow
|
--no-install-package pyarrow
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
@@ -107,9 +107,22 @@ chown app:app /app
|
|||||||
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
|
# 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.
|
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
|
||||||
RUN mkdir -p /data && chown app:app /data
|
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
|
WORKDIR /data
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
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
|
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
|
||||||
# overridden) as the unprivileged app user. Override with `--user root` if you
|
# overridden) as the unprivileged app user. Override with `--user root` if you
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
COPY . /app
|
COPY . /app
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen \
|
uv sync --frozen \
|
||||||
--extra webservice --extra watcher --no-dev \
|
--extra webservice --extra watcher --extra webui --no-dev \
|
||||||
--no-install-package pyarrow
|
--no-install-package pyarrow
|
||||||
|
|
||||||
FROM base
|
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`).
|
# 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.
|
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
|
||||||
RUN mkdir -p /data && chown app:app /data
|
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
|
WORKDIR /data
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
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
|
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
|
||||||
# overridden) as the unprivileged app user. Override with `--user root` if you
|
# overridden) as the unprivileged app user. Override with `--user root` if you
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
version: "0.9.x"
|
version: "0.9.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
version: "0.9.x"
|
version: "0.9.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
version: "0.9.x"
|
version: "0.9.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ jobs:
|
|||||||
version: "0.9.x"
|
version: "0.9.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
@@ -275,6 +275,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
TAG="v${{ steps.version.outputs.version }}"
|
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)
|
# Delete existing draft release if it exists (ignore errors)
|
||||||
gh release delete "$TAG" --yes 2>/dev/null || true
|
gh release delete "$TAG" --yes 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Languages
|
||||||
|
|
||||||
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users, you can often find packages that provide language packs:
|
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users, you can often find packages that provide language packs:
|
||||||
|
|||||||
@@ -331,6 +331,16 @@ def bump_version() -> None:
|
|||||||
contents = contents.replace(find, replace)
|
contents = contents.replace(find, replace)
|
||||||
path.write_text(contents, encoding="utf8")
|
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("Files updated.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
@@ -95,10 +95,12 @@ from multiprocessing import Process
|
|||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
from ocrmypdf import OcrOptions
|
from ocrmypdf import OcrOptions
|
||||||
|
|
||||||
|
|
||||||
def ocrmypdf_process():
|
def ocrmypdf_process():
|
||||||
options = OcrOptions(input_file='input.pdf', output_file='output.pdf')
|
options = OcrOptions(input_file='input.pdf', output_file='output.pdf')
|
||||||
ocrmypdf.ocr(options)
|
ocrmypdf.ocr(options)
|
||||||
|
|
||||||
|
|
||||||
def call_ocrmypdf_from_my_app():
|
def call_ocrmypdf_from_my_app():
|
||||||
p = Process(target=ocrmypdf_process)
|
p = Process(target=ocrmypdf_process)
|
||||||
p.start()
|
p.start()
|
||||||
|
|||||||
@@ -310,3 +310,55 @@ Affero GPLv3 (AGPLv3) since Ghostscript is also licensed in this way.
|
|||||||
In addition to the above, please read our
|
In addition to the above, please read our
|
||||||
`general remarks on using OCRmyPDF as a service <ocr-service>`{.interpreted-text
|
`general remarks on using OCRmyPDF as a service <ocr-service>`{.interpreted-text
|
||||||
role="ref"}.
|
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
@@ -663,9 +663,25 @@ provides text shaping for proper multilingual support. These replace the
|
|||||||
legacy hOCR-based renderer. Install with: `pip install fpdf2 uharfbuzz`
|
legacy hOCR-based renderer. Install with: `pip install fpdf2 uharfbuzz`
|
||||||
|
|
||||||
**fonts-noto** (or an equivalent comprehensive font package) is recommended
|
**fonts-noto** (or an equivalent comprehensive font package) is recommended
|
||||||
for proper text rendering, especially for non-Latin scripts. On Debian/Ubuntu:
|
for proper text rendering, especially for non-Latin scripts. OCRmyPDF bundles
|
||||||
`apt install fonts-noto`. On Fedora: `dnf install google-noto-fonts-common`.
|
a Latin font only, and discovers the rest from the fonts installed on your
|
||||||
On macOS with Homebrew: `brew install font-noto`.
|
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
|
**pypdfium2**, if present, provides fast PDF page rasterization using
|
||||||
the pdfium library (the same library used by Google Chrome). It is
|
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
|
```bash
|
||||||
# Using uv (recommended)
|
# Using uv (recommended)
|
||||||
uv sync --extra watcher # File watching service
|
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
|
uv sync --extra watcher --extra webservice # Multiple features
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -47,7 +47,9 @@ OCRmyPDF has the following runtime dependencies:
|
|||||||
**For text rendering** (expressing OCR results in PDF):
|
**For text rendering** (expressing OCR results in PDF):
|
||||||
- `fpdf2` (Python package) - Required for text layer rendering
|
- `fpdf2` (Python package) - Required for text layer rendering
|
||||||
- `uharfbuzz` (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**:
|
**Other dependencies**:
|
||||||
- `unpaper` (system binary) - Optional, enables `--clean` and `--clean-final`
|
- `unpaper` (system binary) - Optional, enables `--clean` and `--clean-final`
|
||||||
|
|||||||
+13
-13
@@ -120,6 +120,7 @@ A plugin may provide the following hooks. Hooks must be decorated with
|
|||||||
```python
|
```python
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def add_options(parser):
|
def add_options(parser):
|
||||||
pass
|
pass
|
||||||
@@ -205,12 +206,11 @@ from ocrmypdf._options import OcrOptions
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Before (v16 and earlier)
|
# Before (v16 and earlier)
|
||||||
def check_options(options: argparse.Namespace) -> None:
|
def check_options(options: argparse.Namespace) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
# After (v17+)
|
# After (v17+)
|
||||||
def check_options(options: OcrOptions) -> None:
|
def check_options(options: OcrOptions) -> None: ...
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Attribute access unchanged:**
|
**Attribute access unchanged:**
|
||||||
@@ -229,6 +229,7 @@ options.tesseract_timeout
|
|||||||
def check_options(options):
|
def check_options(options):
|
||||||
options.some_computed_value = compute_value(options)
|
options.some_computed_value = compute_value(options)
|
||||||
|
|
||||||
|
|
||||||
# After (v17 pattern - compute at point of use)
|
# After (v17 pattern - compute at point of use)
|
||||||
def some_function(options):
|
def some_function(options):
|
||||||
computed = compute_value(options)
|
computed = compute_value(options)
|
||||||
@@ -336,19 +337,17 @@ from ocrmypdf import OcrElement, OcrClass, BoundingBox
|
|||||||
|
|
||||||
# OcrElement - represents any OCR structural unit
|
# OcrElement - represents any OCR structural unit
|
||||||
page = OcrElement(
|
page = OcrElement(
|
||||||
ocr_class=OcrClass.PAGE,
|
ocr_class=OcrClass.PAGE, bbox=BoundingBox(0, 0, 612, 792), children=[...]
|
||||||
bbox=BoundingBox(0, 0, 612, 792),
|
|
||||||
children=[...]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# BoundingBox - axis-aligned bounding box (left, top, right, bottom)
|
# BoundingBox - axis-aligned bounding box (left, top, right, bottom)
|
||||||
bbox = BoundingBox(left=100, top=50, right=300, bottom=80)
|
bbox = BoundingBox(left=100, top=50, right=300, bottom=80)
|
||||||
|
|
||||||
# OcrClass - constants for element types
|
# OcrClass - constants for element types
|
||||||
OcrClass.PAGE # "ocr_page"
|
OcrClass.PAGE # "ocr_page"
|
||||||
OcrClass.LINE # "ocr_line"
|
OcrClass.LINE # "ocr_line"
|
||||||
OcrClass.WORD # "ocrx_word"
|
OcrClass.WORD # "ocrx_word"
|
||||||
OcrClass.PARAGRAPH # "ocr_par"
|
OcrClass.PARAGRAPH # "ocr_par"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Navigating the tree:**
|
**Navigating the tree:**
|
||||||
@@ -378,6 +377,7 @@ from pathlib import Path
|
|||||||
from ocrmypdf.pluginspec import OcrEngine
|
from ocrmypdf.pluginspec import OcrEngine
|
||||||
from ocrmypdf import OcrElement, OcrClass, BoundingBox
|
from ocrmypdf import OcrElement, OcrClass, BoundingBox
|
||||||
|
|
||||||
|
|
||||||
class MyOcrEngine(OcrEngine):
|
class MyOcrEngine(OcrEngine):
|
||||||
def generate_ocr(
|
def generate_ocr(
|
||||||
self,
|
self,
|
||||||
@@ -402,10 +402,10 @@ class MyOcrEngine(OcrEngine):
|
|||||||
text="Hello",
|
text="Hello",
|
||||||
),
|
),
|
||||||
# ... more words
|
# ... more words
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
# ... more lines
|
# ... more lines
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def supports_generate_ocr(self) -> bool:
|
def supports_generate_ocr(self) -> bool:
|
||||||
|
|||||||
@@ -3,6 +3,42 @@
|
|||||||
|
|
||||||
# v17
|
# 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
|
## v17.8.1
|
||||||
|
|
||||||
- Improved the `--tesseract-pagesegmode` help text to point to
|
- Improved the `--tesseract-pagesegmode` help text to point to
|
||||||
|
|||||||
@@ -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
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ocrmypdf"
|
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"
|
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MPL-2.0"
|
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]`
|
# User-installable features - use `uv sync --extra <name>` or `pip install ocrmypdf[name]`
|
||||||
watcher = ["watchdog>=1.0.2", "cyclopts>=3", "python-dotenv"]
|
watcher = ["watchdog>=1.0.2", "cyclopts>=3", "python-dotenv"]
|
||||||
webservice = ["streamlit>=1.41.0"]
|
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]
|
[project.scripts]
|
||||||
ocrmypdf = "ocrmypdf.__main__:run"
|
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]
|
[tool.distutils.bdist_wheel]
|
||||||
python-tag = "py311"
|
python-tag = "py311"
|
||||||
|
|
||||||
@@ -144,7 +158,7 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["ocrmypdf"]
|
known-first-party = ["ocrmypdf", "webui"]
|
||||||
required-imports = ["from __future__ import annotations"]
|
required-imports = ["from __future__ import annotations"]
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-import-conventions]
|
[tool.ruff.lint.flake8-import-conventions]
|
||||||
|
|||||||
@@ -207,19 +207,19 @@ class OcrOptions(BaseModel):
|
|||||||
|
|
||||||
# Optimization
|
# Optimization
|
||||||
optimize: int = 1
|
optimize: int = 1
|
||||||
jpg_quality: int | None = None
|
jpeg_quality: int | None = None
|
||||||
png_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
|
@property
|
||||||
def jpeg_quality(self):
|
def jpg_quality(self):
|
||||||
"""Compatibility alias for jpg_quality."""
|
"""Deprecated compatibility alias for jpeg_quality."""
|
||||||
return self.jpg_quality
|
return self.jpeg_quality
|
||||||
|
|
||||||
@jpeg_quality.setter
|
@jpg_quality.setter
|
||||||
def jpeg_quality(self, value):
|
def jpg_quality(self, value):
|
||||||
"""Compatibility alias for jpg_quality."""
|
"""Deprecated compatibility alias for jpeg_quality."""
|
||||||
self.jpg_quality = value
|
self.jpeg_quality = value
|
||||||
|
|
||||||
# Output behavior
|
# Output behavior
|
||||||
no_overwrite: bool = False
|
no_overwrite: bool = False
|
||||||
@@ -642,12 +642,6 @@ class OcrOptions(BaseModel):
|
|||||||
value = self.optimize
|
value = self.optimize
|
||||||
if value is not None:
|
if value is not None:
|
||||||
kwargs[field_name] = _convert_value(value)
|
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
|
# Create and cache the plugin options instance
|
||||||
instance = model_class(**kwargs)
|
instance = model_class(**kwargs)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
__version__ = "17.8.1"
|
__version__ = "17.9.0"
|
||||||
|
|||||||
+25
-3
@@ -345,6 +345,23 @@ def _remap_language_to_languages(options_kwargs: dict) -> None:
|
|||||||
del options_kwargs['language']
|
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(
|
def create_options(
|
||||||
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
||||||
) -> OcrOptions:
|
) -> OcrOptions:
|
||||||
@@ -369,6 +386,9 @@ def create_options(
|
|||||||
# Map API parameter 'language' to OcrOptions field 'languages'
|
# Map API parameter 'language' to OcrOptions field 'languages'
|
||||||
_remap_language_to_languages(options_kwargs)
|
_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
|
# Set input and output files
|
||||||
options_kwargs['input_file'] = input_file
|
options_kwargs['input_file'] = input_file
|
||||||
options_kwargs['output_file'] = output_file
|
options_kwargs['output_file'] = output_file
|
||||||
@@ -448,7 +468,8 @@ def ocr(
|
|||||||
redo_ocr: bool | None = None,
|
redo_ocr: bool | None = None,
|
||||||
skip_big: float | None = None,
|
skip_big: float | None = None,
|
||||||
optimize: int | 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,
|
png_quality: int | None = None,
|
||||||
jbig2_lossy: bool | None = None,
|
jbig2_lossy: bool | None = None,
|
||||||
jbig2_page_group_size: int | 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
|
redo_ocr: bool | None = None, # Legacy, use mode='redo' instead
|
||||||
skip_big: float | None = None,
|
skip_big: float | None = None,
|
||||||
optimize: int | 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,
|
png_quality: int | None = None,
|
||||||
jbig2_lossy: bool | None = None, # Deprecated, ignored
|
jbig2_lossy: bool | None = None, # Deprecated, ignored
|
||||||
jbig2_page_group_size: int | 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,
|
jobs: int | None = None,
|
||||||
use_threads: bool | None = None,
|
use_threads: bool | None = None,
|
||||||
optimize: int | None = None,
|
optimize: int | None = None,
|
||||||
jpg_quality: int | None = None,
|
jpeg_quality: int | None = None,
|
||||||
png_quality: int | None = None,
|
png_quality: int | None = None,
|
||||||
jbig2_lossy: bool | None = None, # Deprecated, ignored
|
jbig2_lossy: bool | None = None, # Deprecated, ignored
|
||||||
jbig2_page_group_size: int | None = None, # Deprecated, ignored
|
jbig2_page_group_size: int | None = None, # Deprecated, ignored
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ocrmypdf.font.font_provider import (
|
|||||||
BuiltinFontProvider,
|
BuiltinFontProvider,
|
||||||
ChainedFontProvider,
|
ChainedFontProvider,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
|
GlyphSearchingFontProvider,
|
||||||
)
|
)
|
||||||
from ocrmypdf.font.multi_font_manager import MultiFontManager
|
from ocrmypdf.font.multi_font_manager import MultiFontManager
|
||||||
from ocrmypdf.font.system_font_provider import SystemFontProvider
|
from ocrmypdf.font.system_font_provider import SystemFontProvider
|
||||||
@@ -25,6 +26,7 @@ from ocrmypdf.font.system_font_provider import SystemFontProvider
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"FontManager",
|
"FontManager",
|
||||||
"FontProvider",
|
"FontProvider",
|
||||||
|
"GlyphSearchingFontProvider",
|
||||||
"BuiltinFontProvider",
|
"BuiltinFontProvider",
|
||||||
"ChainedFontProvider",
|
"ChainedFontProvider",
|
||||||
"MultiFontManager",
|
"MultiFontManager",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
from ocrmypdf.font.font_manager import FontManager
|
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:
|
class BuiltinFontProvider:
|
||||||
"""Font provider using builtin fonts from ocrmypdf/data directory."""
|
"""Font provider using builtin fonts from ocrmypdf/data directory."""
|
||||||
|
|
||||||
@@ -119,6 +147,18 @@ class BuiltinFontProvider:
|
|||||||
"""Get the glyphless fallback font."""
|
"""Get the glyphless fallback font."""
|
||||||
return self._fonts['Occulta']
|
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:
|
class ChainedFontProvider:
|
||||||
"""Font provider that tries multiple providers in order.
|
"""Font provider that tries multiple providers in order.
|
||||||
@@ -170,6 +210,25 @@ class ChainedFontProvider:
|
|||||||
result.append(name)
|
result.append(name)
|
||||||
return result
|
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:
|
def get_fallback_font(self) -> FontManager:
|
||||||
"""Get the glyphless fallback font.
|
"""Get the glyphless fallback font.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ language hints and glyph coverage analysis.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ocrmypdf.font.font_manager import FontManager
|
from ocrmypdf.font.font_manager import FontManager
|
||||||
@@ -17,6 +18,7 @@ from ocrmypdf.font.font_provider import (
|
|||||||
BuiltinFontProvider,
|
BuiltinFontProvider,
|
||||||
ChainedFontProvider,
|
ChainedFontProvider,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
|
GlyphSearchingFontProvider,
|
||||||
)
|
)
|
||||||
from ocrmypdf.font.system_font_provider import SystemFontProvider
|
from ocrmypdf.font.system_font_provider import SystemFontProvider
|
||||||
|
|
||||||
@@ -33,9 +35,17 @@ class MultiFontManager:
|
|||||||
Font selection strategy:
|
Font selection strategy:
|
||||||
1. Try language-preferred font (if language hint available)
|
1. Try language-preferred font (if language hint available)
|
||||||
2. Try fallback fonts in order by glyph coverage
|
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
|
# Language to font mapping
|
||||||
# Keys are ISO 639-2/3 codes or Tesseract language codes
|
# Keys are ISO 639-2/3 codes or Tesseract language codes
|
||||||
LANGUAGE_FONT_MAP = {
|
LANGUAGE_FONT_MAP = {
|
||||||
@@ -173,6 +183,9 @@ class MultiFontManager:
|
|||||||
self._selection_cache: dict[tuple[str, str | None], str] = {}
|
self._selection_cache: dict[tuple[str, str | None], str] = {}
|
||||||
# Track whether we've warned about missing fonts (warn once per script)
|
# Track whether we've warned about missing fonts (warn once per script)
|
||||||
self._warned_scripts: set[str] = set()
|
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
|
@property
|
||||||
def fonts(self) -> dict[str, FontManager]:
|
def fonts(self) -> dict[str, FontManager]:
|
||||||
@@ -208,7 +221,8 @@ class MultiFontManager:
|
|||||||
Uses a hybrid approach:
|
Uses a hybrid approach:
|
||||||
1. Language-based selection (if language hint available)
|
1. Language-based selection (if language hint available)
|
||||||
2. Ordered fallback through available fonts by glyph coverage
|
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:
|
Args:
|
||||||
word_text: The text content of the word
|
word_text: The text content of the word
|
||||||
@@ -233,19 +247,50 @@ class MultiFontManager:
|
|||||||
if result := self._try_font(preferred, word_text, cache_key):
|
if result := self._try_font(preferred, word_text, cache_key):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Phase 2: Try fallback fonts in order
|
# Phase 2: Try fallback fonts in order, then anything a previous
|
||||||
for font_name in self.FALLBACK_FONTS:
|
# coverage search turned up
|
||||||
|
for font_name in [*self.FALLBACK_FONTS, *self._discovered_fonts]:
|
||||||
if font_name in tried_fonts:
|
if font_name in tried_fonts:
|
||||||
continue
|
continue
|
||||||
|
tried_fonts.add(font_name)
|
||||||
if result := self._try_font(font_name, word_text, cache_key):
|
if result := self._try_font(font_name, word_text, cache_key):
|
||||||
return result
|
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)
|
# Warn if we're falling back for non-ASCII text (likely missing font)
|
||||||
self._warn_missing_font(word_text, line_language)
|
self._warn_missing_font(word_text, line_language)
|
||||||
self._selection_cache[cache_key] = 'Occulta'
|
self._selection_cache[cache_key] = 'Occulta'
|
||||||
return self.font_provider.get_fallback_font()
|
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:
|
def _warn_missing_font(self, word_text: str, line_language: str | None) -> None:
|
||||||
"""Warn user about missing font for non-Latin text.
|
"""Warn user about missing font for non-Latin text.
|
||||||
|
|
||||||
@@ -264,26 +309,97 @@ class MultiFontManager:
|
|||||||
|
|
||||||
self._warned_scripts.add(warn_key)
|
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:
|
if line_language and line_language in self.LANGUAGE_FONT_MAP:
|
||||||
font_family = self.LANGUAGE_FONT_MAP[line_language].removesuffix('-Regular')
|
font_family = self.LANGUAGE_FONT_MAP[line_language].removesuffix('-Regular')
|
||||||
log.warning(
|
log.warning(
|
||||||
"No installed font has glyphs for the detected '%s' text, so "
|
"No installed font has glyphs for the detected '%s' text (%s), "
|
||||||
"it was added as an invisible text layer: it stays searchable "
|
"so it was added as an invisible text layer: it stays searchable "
|
||||||
"and copyable, but appears blank when highlighted in a PDF "
|
"and copyable, but appears blank when highlighted in a PDF "
|
||||||
"viewer. Install the %s font family (via your OS package "
|
"viewer. Install the %s font family (via your OS package "
|
||||||
"manager or https://fonts.google.com/noto) for full rendering.",
|
"manager or https://fonts.google.com/noto) for full rendering.",
|
||||||
line_language,
|
line_language,
|
||||||
|
missing,
|
||||||
font_family,
|
font_family,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.warning(
|
log.warning(
|
||||||
"No installed font has glyphs for some of the detected text, "
|
"No installed font has glyphs for some of the detected text "
|
||||||
"so it was added as an invisible text layer: it stays "
|
"(%s), so it was added as an invisible text layer: it stays "
|
||||||
"searchable and copyable, but appears blank when highlighted "
|
"searchable and copyable, but appears blank when highlighted "
|
||||||
"in a PDF viewer. Install the matching Noto fonts "
|
"in a PDF viewer. Install a Noto font covering that script "
|
||||||
"(https://fonts.google.com/noto) for full rendering."
|
"(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:
|
def _has_all_glyphs(self, font: FontManager, text: str) -> bool:
|
||||||
"""Check if a font has glyphs for all characters in text.
|
"""Check if a font has glyphs for all characters in text.
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ class SystemFontProvider:
|
|||||||
self._not_found: set[str] = set()
|
self._not_found: set[str] = set()
|
||||||
# Cached font directories (computed lazily)
|
# Cached font directories (computed lazily)
|
||||||
self._font_dirs: list[Path] | None = None
|
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:
|
def _get_platform(self) -> str:
|
||||||
"""Get the current platform identifier.
|
"""Get the current platform identifier.
|
||||||
@@ -352,6 +359,145 @@ class SystemFontProvider:
|
|||||||
return best[1]
|
return best[1]
|
||||||
return None
|
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:
|
def get_font(self, font_name: str) -> FontManager | None:
|
||||||
"""Get a FontManager for the named font (lazy loading).
|
"""Get a FontManager for the named font (lazy loading).
|
||||||
|
|
||||||
|
|||||||
@@ -449,12 +449,12 @@ def convert_to_jbig2(
|
|||||||
|
|
||||||
|
|
||||||
def _optimize_jpeg(
|
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]:
|
) -> tuple[Xref, Path | None]:
|
||||||
with Image.open(in_jpg) as im:
|
with Image.open(in_jpg) as im:
|
||||||
save_kwargs: dict[str, Any] = {'optimize': True}
|
save_kwargs: dict[str, Any] = {'optimize': True}
|
||||||
if isinstance(jpg_quality, int) and 0 < jpg_quality <= 100:
|
if isinstance(jpeg_quality, int) and 0 < jpeg_quality <= 100:
|
||||||
save_kwargs['quality'] = jpg_quality
|
save_kwargs['quality'] = jpeg_quality
|
||||||
im.save(opt_jpg, **save_kwargs)
|
im.save(opt_jpg, **save_kwargs)
|
||||||
|
|
||||||
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
||||||
@@ -473,7 +473,7 @@ def transcode_jpegs(
|
|||||||
for xref in jpegs:
|
for xref in jpegs:
|
||||||
in_jpg = jpg_name(root, xref)
|
in_jpg = jpg_name(root, xref)
|
||||||
opt_jpg = in_jpg.with_suffix('.opt.jpg')
|
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):
|
def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar):
|
||||||
xref, opt_jpg = result
|
xref, opt_jpg = result
|
||||||
@@ -703,8 +703,8 @@ def optimize(
|
|||||||
safe_symlink(input_file, output_file)
|
safe_symlink(input_file, output_file)
|
||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
if not options.jpg_quality:
|
if not options.jpeg_quality:
|
||||||
options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
||||||
if not options.png_quality:
|
if not options.png_quality:
|
||||||
options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
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
|
output_file=outfile, # Required field
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
optimize=int(level),
|
optimize=int(level),
|
||||||
jpg_quality=0, # Use default
|
jpeg_quality=0, # Use default
|
||||||
png_quality=0,
|
png_quality=0,
|
||||||
jbig2_threshold=0.85,
|
jbig2_threshold=0.85,
|
||||||
quiet=True,
|
quiet=True,
|
||||||
|
|||||||
+13
-7
@@ -12,7 +12,7 @@ from importlib.resources import files as package_files
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
from pikepdf import Array, Dictionary, Name, Pdf, Stream
|
from pikepdf import Array, Dictionary, Name, Object, Pdf, Stream
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,11 +137,13 @@ def file_claims_pdfa(filename: Path):
|
|||||||
return pdfa_dict
|
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."""
|
"""Return True if a Type0 font's CID descendant carries embedded glyphs."""
|
||||||
for descendant in type0_font.get(Name.DescendantFonts, []):
|
for descendant in type0_font.get(Name.DescendantFonts, []):
|
||||||
descriptor = descendant.get(Name.FontDescriptor, None)
|
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)
|
key in descriptor for key in (Name.FontFile, Name.FontFile2, Name.FontFile3)
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
@@ -174,9 +176,13 @@ def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]:
|
|||||||
def scan_resources(resources, depth: int = 0) -> None:
|
def scan_resources(resources, depth: int = 0) -> None:
|
||||||
if resources is None or depth > 10:
|
if resources is None or depth > 10:
|
||||||
return
|
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)
|
fonts = resources.get(Name.Font, None)
|
||||||
if fonts is not None:
|
if isinstance(fonts, Dictionary):
|
||||||
for font in fonts.values():
|
for font in fonts.as_dict().values():
|
||||||
try:
|
try:
|
||||||
if font.get(Name.Subtype) != Name.Type0:
|
if font.get(Name.Subtype) != Name.Type0:
|
||||||
continue
|
continue
|
||||||
@@ -186,8 +192,8 @@ def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]:
|
|||||||
except (AttributeError, TypeError, KeyError):
|
except (AttributeError, TypeError, KeyError):
|
||||||
continue
|
continue
|
||||||
xobjects = resources.get(Name.XObject, None)
|
xobjects = resources.get(Name.XObject, None)
|
||||||
if xobjects is not None:
|
if isinstance(xobjects, Dictionary):
|
||||||
for xobj in xobjects.values():
|
for xobj in xobjects.as_dict().values():
|
||||||
if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj:
|
if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj:
|
||||||
scan_resources(xobj[Name.Resources], depth + 1)
|
scan_resources(xobj[Name.Resources], depth + 1)
|
||||||
|
|
||||||
|
|||||||
@@ -287,9 +287,15 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
|||||||
if Name.Resources not in container:
|
if Name.Resources not in container:
|
||||||
return
|
return
|
||||||
resources = container[Name.Resources]
|
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
|
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:
|
if candidate is None or Name.Subtype not in candidate:
|
||||||
continue
|
continue
|
||||||
if candidate[Name.Subtype] == Name.Image:
|
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:
|
if Name.Resources not in container:
|
||||||
return
|
return
|
||||||
resources = container[Name.Resources]
|
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
|
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:
|
for xobj in xobjs:
|
||||||
candidate = xobjs[xobj]
|
candidate = xobjs[xobj]
|
||||||
if candidate is None or candidate.get(Name.Subtype) != Name.Form:
|
if candidate is None or candidate.get(Name.Subtype) != Name.Form:
|
||||||
|
|||||||
@@ -79,6 +79,45 @@ def test_language_parameter_mapped_to_languages():
|
|||||||
assert options.languages == ['eng', 'spa']
|
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):
|
def test_stream_api(resources: Path):
|
||||||
in_ = (resources / 'graph.pdf').open('rb')
|
in_ = (resources / 'graph.pdf').open('rb')
|
||||||
out = BytesIO()
|
out = BytesIO()
|
||||||
|
|||||||
@@ -569,3 +569,117 @@ def test_missing_font_warning_explains_consequences(font_dir, caplog):
|
|||||||
# the text stays searchable but renders blank when highlighted.
|
# the text stays searchable but renders blank when highlighted.
|
||||||
assert 'searchable' in msg.lower()
|
assert 'searchable' in msg.lower()
|
||||||
assert 'highlight' in msg.lower() or 'select' 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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from PIL import Image, ImageDraw
|
|||||||
from ocrmypdf import optimize as opt
|
from ocrmypdf import optimize as opt
|
||||||
from ocrmypdf._exec import jbig2enc, pngquant
|
from ocrmypdf._exec import jbig2enc, pngquant
|
||||||
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
||||||
|
from ocrmypdf.cli import get_options_and_plugins
|
||||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
||||||
from ocrmypdf.optimize import PdfImage, extract_image_filter
|
from ocrmypdf.optimize import PdfImage, extract_image_filter
|
||||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
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
|
@needs_jbig2enc
|
||||||
def test_jbig2_lossless(resources, outpdf):
|
def test_jbig2_lossless(resources, outpdf):
|
||||||
"""Test that JBIG2 lossless encoding works without JBIG2Globals."""
|
"""Test that JBIG2 lossless encoding works without JBIG2Globals."""
|
||||||
|
|||||||
@@ -94,6 +94,52 @@ class TestFindNonembeddedCidFonts:
|
|||||||
with pikepdf.open(path) as pdf:
|
with pikepdf.open(path) as pdf:
|
||||||
assert find_nonembedded_cid_fonts(pdf) == {'ZZZ+Hidden'}
|
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
|
@pytest.fixture
|
||||||
def nonembedded_cid_pdf(tmp_path):
|
def nonembedded_cid_pdf(tmp_path):
|
||||||
|
|||||||
@@ -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
|
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):
|
def test_imageinfo_ink_inherited_in_form_xobject(outdir):
|
||||||
# A mask drawn inside a Form XObject inherits the fill color set before the
|
# 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.
|
# Do that paints the form; the gray classification must reach the mask.
|
||||||
|
|||||||
@@ -363,6 +363,140 @@ class TestSystemFontProviderVariableFonts:
|
|||||||
assert provider.get_font('NotoSansSC-Regular') is None
|
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 ---
|
# --- ChainedFontProvider Tests ---
|
||||||
|
|
||||||
|
|
||||||
@@ -507,3 +641,49 @@ class TestChainedFontProviderIntegration:
|
|||||||
|
|
||||||
# Chain should have at least as many fonts as builtin
|
# Chain should have at least as many fonts as builtin
|
||||||
assert chain_fonts >= builtin_fonts
|
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
|
||||||
|
|||||||
@@ -89,8 +89,15 @@ def test_mutex_options():
|
|||||||
make_ocr_opts(redo_ocr=True, force_ocr=True)
|
make_ocr_opts(redo_ocr=True, force_ocr=True)
|
||||||
|
|
||||||
|
|
||||||
def test_optimizing(caplog):
|
def test_optimizing_png_quality_warns(caplog):
|
||||||
vd.check_options(*make_opts_pm(optimize=0, png_quality=18, jpeg_quality=10))
|
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
|
assert 'will be ignored because' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+117
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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));
|
||||||
@@ -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'>🖹</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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user