Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6329489ce | ||
|
|
e6d240ee93 | ||
|
|
ff45e54c07 | ||
|
|
e0ee0882ef | ||
|
|
3d17419a6c | ||
|
|
476ec12383 | ||
|
|
e99177ada7 | ||
|
|
e95ec9c497 | ||
|
|
82f30bfbec | ||
|
|
d1437e6bbc | ||
|
|
c669d30642 | ||
|
|
3613b30ca8 | ||
|
|
0d4c3bcdcf | ||
|
|
8a8d515933 | ||
|
|
11de13ecfe | ||
|
|
58642d8411 | ||
|
|
7e42d3c771 | ||
|
|
5cb5d7a682 | ||
|
|
37e71dece6 | ||
|
|
df84945773 | ||
|
|
b5a6a9f9f1 | ||
|
|
ed36aefe48 | ||
|
|
32013f4294 | ||
|
|
8f2bcc2c64 | ||
|
|
015b53ae30 | ||
|
|
164cf2dc8a | ||
|
|
98d6d02704 | ||
|
|
5efb98931d | ||
|
|
2f4e47213f | ||
|
|
94c8123bd7 | ||
|
|
0db130e1c3 | ||
|
|
91b6a818f5 | ||
|
|
6bc9499e68 | ||
|
|
09f2d6c386 | ||
|
|
87f918f58c | ||
|
|
80e77fb021 | ||
|
|
fa9c5b3fae | ||
|
|
3d17a60a54 | ||
|
|
c33f073d4f |
+29
-7
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
FROM ubuntu:25.04 AS base
|
||||
FROM ubuntu:26.04 AS base
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV TZ=UTC
|
||||
@@ -40,7 +40,7 @@ RUN \
|
||||
WORKDIR /app
|
||||
|
||||
# Copy uv from ghcr
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.8 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /bin/
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
|
||||
@@ -60,10 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM base
|
||||
|
||||
RUN apt-get update && apt-get install -y software-properties-common
|
||||
|
||||
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
|
||||
|
||||
# Tesseract 5 ships in the Ubuntu archive as of 24.04, so no third-party PPA is
|
||||
# needed. (Previously this used ppa:alex-p/tesseract-ocr5.)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ghostscript \
|
||||
fonts-droid-fallback \
|
||||
@@ -81,6 +79,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
unpaper \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user to run the application (defense in depth). The build
|
||||
# stages above need root to install packages, but the entrypoint should not.
|
||||
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
|
||||
# and matches the --chown below. See docs/docker.md for the volume/permissions
|
||||
# implications under rootless vs rootful Docker.
|
||||
# The Ubuntu base ships a default "ubuntu" user at uid/gid 1000; remove it so
|
||||
# "app" can claim that uid for parity with the Alpine image.
|
||||
RUN userdel -r ubuntu 2>/dev/null; groupdel ubuntu 2>/dev/null; \
|
||||
groupadd -g 1000 app \
|
||||
&& useradd -u 1000 -g app -m -d /home/app app
|
||||
ENV HOME=/home/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
||||
@@ -90,9 +100,21 @@ COPY --from=builder --chown=app:app /app /app
|
||||
|
||||
RUN rm -rf /app/.git && \
|
||||
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||
ln -s /app/misc/watcher.py /app/watcher.py
|
||||
ln -s /app/misc/watcher.py /app/watcher.py && \
|
||||
chown app:app /app
|
||||
|
||||
# Default working directory for bind-mounted data, so relative input/output
|
||||
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
|
||||
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
|
||||
RUN mkdir -p /data && chown app:app /data
|
||||
WORKDIR /data
|
||||
|
||||
ENV PATH="/app/.venv/bin:${PATH}"
|
||||
|
||||
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
|
||||
# overridden) as the unprivileged app user. Override with `--user root` if you
|
||||
# need root inside a running container (e.g. to apt install extra packages).
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
FROM alpine:3.23 AS base
|
||||
FROM alpine:3.24 AS base
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV TZ=UTC
|
||||
@@ -22,7 +22,7 @@ RUN apk add --no-cache \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.8 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /bin/
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
|
||||
@@ -62,14 +62,35 @@ RUN apk add --no-cache \
|
||||
unpaper \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Create a non-root user to run the application (defense in depth). The build
|
||||
# stages above need root to install packages, but the entrypoint should not.
|
||||
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
|
||||
# and matches the --chown below. See docs/docker.md for the volume/permissions
|
||||
# implications under rootless vs rootful Docker.
|
||||
RUN addgroup -g 1000 app \
|
||||
&& adduser -u 1000 -G app -D -h /home/app app
|
||||
ENV HOME=/home/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /app /app
|
||||
|
||||
RUN rm -rf /app/.git && \
|
||||
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||
ln -s /app/misc/watcher.py /app/watcher.py
|
||||
ln -s /app/misc/watcher.py /app/watcher.py && \
|
||||
chown app:app /app
|
||||
|
||||
# Default working directory for bind-mounted data, so relative input/output
|
||||
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
|
||||
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
|
||||
RUN mkdir -p /data && chown app:app /data
|
||||
WORKDIR /data
|
||||
|
||||
ENV PATH="/app/.venv/bin:${PATH}"
|
||||
|
||||
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
|
||||
# overridden) as the unprivileged app user. Override with `--user root` if you
|
||||
# need root inside a running container (e.g. to apk add extra packages).
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
# PyPI doesn't support sigstore publishing, so generate after publishing to PyPI
|
||||
- name: Sign the dists with Sigstore
|
||||
uses: sigstore/gh-action-sigstore-python@v3.3.0
|
||||
uses: sigstore/gh-action-sigstore-python@v3.4.0
|
||||
with:
|
||||
inputs: |
|
||||
./dist/*.tar.gz
|
||||
|
||||
@@ -121,6 +121,20 @@ representation. This is useful for redoing OCR, for fixing OCR text
|
||||
with a damaged character map (text is selectable but not searchable),
|
||||
and destroying redacted information.
|
||||
|
||||
### Tagged PDFs and structural markup
|
||||
|
||||
Some PDFs carry a logical structure tree (`/StructTreeRoot`), the markup that
|
||||
makes a "Tagged PDF" — typically the result of layout analysis or a born-digital
|
||||
export. By default OCRmyPDF treats this as a signal that the document may not need
|
||||
OCR and exits, in the same way it stops on PDFs that already contain text. Use
|
||||
`--tagged-pdf-mode ignore`, or one of `--mode skip`/`redo`/`force`, to process
|
||||
such a file anyway.
|
||||
|
||||
OCRmyPDF cannot rebuild a structure tree to match newly recognized text. When
|
||||
`--force-ocr` rasterizes pages, or `--redo-ocr` strips and rewrites the text layer,
|
||||
the structure tree no longer corresponds to the page content, so it is discarded.
|
||||
`--mode skip` leaves text pages untouched, so their structural markup is preserved.
|
||||
|
||||
### Time and image size limits
|
||||
|
||||
By default, OCRmyPDF permits tesseract to run for three minutes (180
|
||||
|
||||
+9
-1
@@ -174,7 +174,15 @@ docker run \
|
||||
--env PYTHONUNBUFFERED=1 \
|
||||
--interactive --tty --entrypoint python3 \
|
||||
jbarlow83/ocrmypdf \
|
||||
watcher.py
|
||||
/app/watcher.py
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
The image runs as the non-root `app` user (uid 1000) by default, so it
|
||||
may not be able to write to the `/output` and `/processed` volumes unless
|
||||
you add a `--user` argument. The correct value depends on whether you use
|
||||
rootful Docker, rootless Docker, or Podman -- see
|
||||
{ref}`Bind-mounted volumes <docker-volumes>` for details.
|
||||
:::
|
||||
|
||||
This service will watch for a file that matches `/input/\*.pdf`, convert
|
||||
|
||||
+22
-7
@@ -249,19 +249,34 @@ case. Use `--tesseract-non-ocr-timeout` to control the timeout for
|
||||
non-OCR operations, if needed.
|
||||
:::
|
||||
|
||||
### Remove all text or OCR from my PDF
|
||||
### Remove the OCR text layer from my PDF
|
||||
|
||||
This is getting ridiculous, but OCRmyPDF can complete strip all textual
|
||||
information from a PDF and reconstruct it as a \"bag of images\" PDF.
|
||||
To remove the invisible OCR text layer while keeping the original pages
|
||||
exactly as they are -- no rasterizing, no change to images or visible
|
||||
content, and a smaller output file -- use `--mode strip`:
|
||||
|
||||
```bash
|
||||
ocrmypdf --mode strip input.pdf output.pdf
|
||||
```
|
||||
|
||||
Why would you want to do this? Perhaps you have a PDF where OCR failed to
|
||||
produce useful results and you simply want to get rid of it.
|
||||
|
||||
`--mode strip` removes only text drawn as *invisible* (PDF text render
|
||||
mode 3), which is how OCRmyPDF and most OCR tools add a searchable layer
|
||||
over a scanned page. Some OCR products -- and OCRmyPDF v2.2 and earlier --
|
||||
instead draw *visible* text and paint an opaque image on top of it. That
|
||||
text is part of the visible page, so `--mode strip` cannot remove it
|
||||
without altering the page's appearance.
|
||||
|
||||
To strip *all* text, including such visible text, rasterize the whole page
|
||||
into a \"bag of images\" PDF instead (this rebuilds every page as an image,
|
||||
so the file usually grows and vector content is lost):
|
||||
|
||||
```bash
|
||||
ocrmypdf --ocr-engine none --force-ocr input.pdf output.pdf
|
||||
```
|
||||
|
||||
Why would you want to do this? Perhaps you have a PDF where OCR fails to
|
||||
produce useful results, and just want to get rid of all OCR information.
|
||||
This command also removes OCR generated by third party tools.
|
||||
|
||||
### Optimize images without performing OCR
|
||||
|
||||
You can also optimize all images without performing any OCR:
|
||||
|
||||
+84
-23
@@ -71,15 +71,29 @@ application (as opposed to the more conventional case, where a Docker
|
||||
container runs as a server). For that reason we usually use the `--rm`
|
||||
argument to delete the container when it exits.
|
||||
|
||||
:::{note}
|
||||
The image runs as a non-root user (`app`, uid/gid 1000) by default,
|
||||
rather than as root. This is a defense-in-depth measure: a flaw in
|
||||
OCRmyPDF or one of its dependencies cannot trivially act as root inside
|
||||
the container. The examples below assume **rootless Docker** or
|
||||
**Podman**; the differences for traditional *rootful* Docker are
|
||||
described separately under *Special case: rootful Docker* below.
|
||||
:::
|
||||
|
||||
To start a Docker container (instance of the image):
|
||||
|
||||
:::{code} bash
|
||||
docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - -
|
||||
:::
|
||||
|
||||
For convenience, create a shell alias to hide the Docker command. It is
|
||||
easier to send the input file as stdin and read the output from stdout
|
||||
-- **this avoids the messy permission issues with Docker entirely**.
|
||||
### Recommended: pipe through stdin and stdout
|
||||
|
||||
The easiest and most portable way to use the image is to send the input
|
||||
file on stdin and read the output from stdout. This **avoids file
|
||||
permission issues entirely** -- nothing is written to a mounted
|
||||
directory, so it does not matter which user the container runs as, nor
|
||||
whether you use rootless or rootful Docker. For convenience, create a
|
||||
shell alias to hide the Docker command:
|
||||
|
||||
:::{code} bash
|
||||
alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine'
|
||||
@@ -90,28 +104,42 @@ docker_ocrmypdf - - <input.pdf >output.pdf
|
||||
Or in the wonderful [fish shell](https://fishshell.com/):
|
||||
|
||||
:::{code} fish
|
||||
alias docker_ocrmypdf 'docker run --rm jbarlow83/ocrmypdf-alpine'
|
||||
alias docker_ocrmypdf 'docker run --rm -i jbarlow83/ocrmypdf-alpine'
|
||||
funcsave docker_ocrmypdf
|
||||
:::
|
||||
|
||||
Alternately, you could mount the local current working directory as a
|
||||
Docker volume:
|
||||
{#docker-volumes}
|
||||
### Bind-mounted volumes
|
||||
|
||||
If you would rather mount a directory and pass file paths, you need to
|
||||
consider which user owns the files OCRmyPDF writes back into that
|
||||
directory. The image's default working directory is `/data`, so mounting
|
||||
your files there lets you pass plain relative paths without an explicit
|
||||
`--workdir`. Because the container runs as the non-root `app` user, the
|
||||
right invocation otherwise depends on your container runtime.
|
||||
|
||||
**Rootless Docker (the assumed default).** Your own account runs the
|
||||
daemon, so the container's `root` maps back to *your* unprivileged host
|
||||
user, while every other container uid -- including the image's default
|
||||
`app`/1000 -- maps to a *subordinate* uid. A directory you own on the
|
||||
host therefore appears owned by `root` inside the container, so the
|
||||
default `app` user usually **cannot write to it at all**. Run the job as
|
||||
container-`root`, which under rootless Docker is still your ordinary host
|
||||
user, so the write succeeds and the output is owned by you:
|
||||
|
||||
:::{code} bash
|
||||
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||
docker_ocrmypdf /data/input.pdf /data/output.pdf
|
||||
alias docker_ocrmypdf='docker run --rm -i --user 0:0 -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||
docker_ocrmypdf input.pdf output.pdf
|
||||
:::
|
||||
|
||||
## Podman
|
||||
|
||||
Especially if you use [Podman](https://podman.io/) (or use Docker in
|
||||
rootless mode), you may need to add `--userns keep-id` there,
|
||||
otherwise you may get access errors, because the user ID is otherwise not
|
||||
mapped to the same UID as on the host:
|
||||
**Podman.** Podman provides `--userns keep-id`, which maps your host uid
|
||||
straight through into the container. Combined with `--user`, you run as
|
||||
your own uid and own the output directly, otherwise you may get access
|
||||
errors because the user ID is not mapped to the same UID as on the host:
|
||||
|
||||
:::{code} bash
|
||||
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||
podman_ocrmypdf /data/input.pdf /data/output.pdf
|
||||
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||
podman_ocrmypdf input.pdf output.pdf
|
||||
:::
|
||||
|
||||
If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to
|
||||
@@ -124,10 +152,27 @@ the end of the linked podman documentation for details. This results in
|
||||
the following full command:
|
||||
|
||||
:::{code} bash
|
||||
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine'
|
||||
podman_ocrmypdf /data/input.pdf /data/output.pdf
|
||||
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine'
|
||||
podman_ocrmypdf input.pdf output.pdf
|
||||
:::
|
||||
|
||||
{#docker-rootful}
|
||||
### Special case: rootful Docker
|
||||
|
||||
With a traditional root daemon, container uid *N* is the *same* uid *N*
|
||||
on the host. Running the container as root would therefore fill your
|
||||
mounted directory with root-owned files and -- more importantly -- a
|
||||
container escape would run as real host root. Drop to your own uid so the
|
||||
output is owned by you and the process stays unprivileged:
|
||||
|
||||
:::{code} bash
|
||||
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||
docker_ocrmypdf input.pdf output.pdf
|
||||
:::
|
||||
|
||||
The non-root default and the `--user` override both reduce the risk here,
|
||||
but rootless Docker or Podman remain the safer choice when available.
|
||||
|
||||
{#docker-lang-packs}
|
||||
## Adding languages to the Docker image
|
||||
|
||||
@@ -139,8 +184,12 @@ creating a new Dockerfile based on the public one.
|
||||
:::{code} dockerfile
|
||||
FROM jbarlow83/ocrmypdf
|
||||
|
||||
# The image runs as the non-root "app" user, so switch back to root for
|
||||
# build steps that install packages, then drop back to "app".
|
||||
USER root
|
||||
# Example: add Italian
|
||||
RUN apt install tesseract-ocr-ita
|
||||
RUN apt-get update && apt-get install -y tesseract-ocr-ita
|
||||
USER app
|
||||
:::
|
||||
|
||||
To install language packs (training data) such as the
|
||||
@@ -179,7 +228,11 @@ Extending the Docker image
|
||||
--------------------------
|
||||
|
||||
You can extend the Docker image with your own customizations, similar to
|
||||
the way it is extended to add language packs.
|
||||
the way it is extended to add language packs. Because the image runs as
|
||||
the non-root `app` user, switch to `USER root` for any build steps that
|
||||
require root (installing packages, writing to system directories) and
|
||||
back to `USER app` afterwards, as shown in the language pack example
|
||||
above.
|
||||
|
||||
Note that the Docker image is subject to change at any time. For
|
||||
example, the base image may be updated to a newer version of Ubuntu or
|
||||
@@ -196,7 +249,7 @@ Executing the test suite
|
||||
The OCRmyPDF test suite is installed with image. To run it:
|
||||
|
||||
:::{code} bash
|
||||
docker run --rm --entrypoint python jbarlow83/ocrmypdf -m pytest
|
||||
docker run --rm --workdir /app --entrypoint python jbarlow83/ocrmypdf -m pytest
|
||||
:::
|
||||
|
||||
Accessing the shell
|
||||
@@ -205,7 +258,15 @@ Accessing the shell
|
||||
To use the shell in the Docker image:
|
||||
|
||||
:::{code} bash
|
||||
docker run -it --entrypoint sh jbarlow83/ocrmypdf
|
||||
docker run -it --entrypoint sh jbarlow83/ocrmypdf-alpine
|
||||
:::
|
||||
|
||||
This shell runs as the non-root `app` user. If you need root inside the
|
||||
container -- for example to install extra packages with `apk` or `apt` --
|
||||
add `--user root`:
|
||||
|
||||
:::{code} bash
|
||||
docker run -it --user root --entrypoint sh jbarlow83/ocrmypdf-alpine
|
||||
:::
|
||||
|
||||
Using the OCRmyPDF web service wrapper
|
||||
@@ -215,7 +276,7 @@ The OCRmyPDF Docker image includes an example, barebones HTTP web
|
||||
service. The webservice may be launched as follows:
|
||||
|
||||
:::{code} bash
|
||||
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf webservice.py
|
||||
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf /app/webservice.py
|
||||
:::
|
||||
|
||||
We omit the `--rm` parameter so that the container will not be
|
||||
|
||||
+11
-4
@@ -178,10 +178,17 @@ v17 addresses through alternative codepaths. When Ghostscript is used:
|
||||
encoding, which may introduce compression artifacts, if Ghostscript
|
||||
PDF/A is enabled.
|
||||
- Ghostscript may transcode grayscale and color images, potentially
|
||||
lossily, based on an internal algorithm. This
|
||||
behavior can be suppressed by setting `--pdfa-image-compression` to
|
||||
`jpeg` or `lossless` to set all images to one type or the other.
|
||||
Ghostscript lacks an option to maintain the input image's format.
|
||||
lossily, based on an internal algorithm. By default
|
||||
(`--pdfa-image-compression=auto`) OCRmyPDF selects lossless image
|
||||
compression at `-O0` so Ghostscript will not transcode lossless images
|
||||
to JPEG. At `-O1` (the default optimization level) and above, `auto`
|
||||
defers to Ghostscript's heuristic instead; `-O1` is a historical
|
||||
exception, kept for backwards compatibility because coercing it to
|
||||
lossless can substantially bloat output. You can override this by
|
||||
setting `--pdfa-image-compression` to `jpeg` or `lossless` to force all
|
||||
images to one type or the other. `lossless` passes existing JPEGs
|
||||
through untouched (re-encoding them losslessly would only inflate them)
|
||||
while encoding non-JPEG images losslessly.
|
||||
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
||||
Advanced users can also tune Ghostscript's image recompression with
|
||||
`--ghostscript-jpeg-quality` and `--ghostscript-jpeg-maxdpi`; see
|
||||
|
||||
@@ -3,6 +3,111 @@
|
||||
|
||||
# v17
|
||||
|
||||
## v17.7.0
|
||||
|
||||
- The Docker images now run as a non-root user (`app`, uid/gid 1000) by default
|
||||
rather than as root, as a defense-in-depth measure. If you bind-mount a
|
||||
directory for input and output, you may now need to add a `--user` argument so
|
||||
the container can write to it; the correct value differs for rootless Docker,
|
||||
Podman, and rootful Docker, and is described in the Docker documentation.
|
||||
Piping the input and output through stdin/stdout still works with no
|
||||
permission setup.
|
||||
- The Docker images now default their working directory to `/data`, so files in
|
||||
a directory mounted there can be given as relative paths without an explicit
|
||||
`--workdir`.
|
||||
- The Ubuntu Docker image now installs Tesseract 5 from the Ubuntu archive
|
||||
instead of the third-party `alex-p/tesseract-ocr5` PPA, and the base images
|
||||
were updated to Ubuntu 26.04 and Alpine 3.24.
|
||||
- Fixed a missing space in the error message shown when OCRmyPDF cannot access
|
||||
its working directory inside a Docker container.
|
||||
- Updated packaged dependencies, including the optional web service stack
|
||||
(starlette, tornado, python-multipart) and cryptography.
|
||||
|
||||
## v17.6.0
|
||||
|
||||
- When the optimizer encounters an image it cannot process (for example, an
|
||||
exotic colorspace that cannot be transcoded), it now logs a concise warning
|
||||
that the image was left unchanged rather than printing an alarming
|
||||
traceback. The output file was already valid in these cases; only the
|
||||
reporting was misleading. The full traceback is still available at debug
|
||||
verbosity (`-v 1`) ({issue}`846`).
|
||||
- `--pdfa-image-compression=auto` (the default) now selects lossless image
|
||||
compression at `-O0` so Ghostscript no longer transcodes lossless images to
|
||||
JPEG during PDF/A generation. At `-O1` and above, `auto` continues to defer
|
||||
to Ghostscript's heuristic, which may recompress images lossily. `-O1` (the
|
||||
default level) is kept as a historical exception because coercing it to
|
||||
lossless can substantially bloat output; users who want guaranteed lossless
|
||||
image handling should pass `--pdfa-image-compression=lossless` or use `-O0`
|
||||
({issue}`1124`).
|
||||
- `--pdfa-image-compression=lossless` now passes existing JPEG images through
|
||||
unchanged rather than re-encoding them with a lossless codec. Re-encoding an
|
||||
already-lossy JPEG losslessly cannot recover quality and only inflates the
|
||||
file, so JPEGs are preserved while non-JPEG images are encoded losslessly.
|
||||
- OCRmyPDF now validates and repairs malformed page-boundary boxes
|
||||
(``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``, ``/BleedBox``) in its
|
||||
input, following the PDF 2.0 specification. Coordinates written in invalid
|
||||
exponential notation are reinterpreted ({issue}`1398`); rectangles whose
|
||||
corners are given in reversed order are normalized, which previously crashed
|
||||
with ``NegativeDimensionError`` ({issue}`1526`); and a crop/trim/art/bleed box
|
||||
that falls outside the MediaBox is clamped to their intersection, or discarded
|
||||
when that intersection is empty, which previously produced an output with a
|
||||
zero-height effective page that some viewers refused to open ({issue}`1400`).
|
||||
When a box is discarded, clamped, or reinterpreted, OCRmyPDF logs a warning
|
||||
recommending visual inspection of the output. Thanks @ajdlinux for the initial
|
||||
fix in PR #1691.
|
||||
- OCRmyPDF now discards an embedded Adobe full-text search index
|
||||
(``/Root/PieceInfo/SearchIndex``) from its output. This proprietary index,
|
||||
produced by Acrobat's "Embed Index" feature, is read only by Adobe Acrobat;
|
||||
other viewers ignore it and search the text on the fly. Because any change to
|
||||
a PDF invalidates the index, retaining it after OCRmyPDF rewrites the document
|
||||
would leave a stale index that returns incorrect search results in Acrobat.
|
||||
Modern viewers rebuild a search index on demand, so there is no loss of
|
||||
search capability.
|
||||
- OCRmyPDF now discards embedded per-page thumbnail images (the optional
|
||||
``/Thumb`` image XObject on a page) from its output. OCRmyPDF alters page
|
||||
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
|
||||
arbitrarily, so a retained thumbnail would be stale and no longer match its
|
||||
page. Embedded thumbnails are a navigation aid that modern viewers generate
|
||||
on demand, so there is no loss of functionality.
|
||||
- Fixed a regression in OCR quality for PDFs that paint a 1-bit image mask
|
||||
(stencil) with a gray or colored fill color. Previously such pages were
|
||||
rasterized as 1-bit black-and-white before OCR, so Ghostscript dithered
|
||||
mid-tone text into an unreadable stipple and Tesseract failed to recognize
|
||||
it. The rasterizer now inspects the fill color used to paint a mask and
|
||||
promotes the page to grayscale or full color as needed, so the distinction
|
||||
is preserved for the OCR engine. This applies to both the Ghostscript and
|
||||
pypdfium rasterizers. {issue}`1688`
|
||||
- The default 1-bit raster device for Ghostscript is now ``pngmonod``
|
||||
(error-diffusion) instead of ``pngmono`` (ordered dithering). It produces
|
||||
better input for OCR on faint or anti-aliased scans at negligible cost and
|
||||
no change to output file size, since the rasterized image is an
|
||||
intermediate that is discarded after OCR.
|
||||
- When rasterizing pages with Ghostscript, OCRmyPDF now enables text and
|
||||
graphics anti-aliasing (``-dTextAlphaBits=4 -dGraphicsAlphaBits=4``) for the
|
||||
grayscale and color raster devices. Ghostscript 10.x renders aliased glyphs
|
||||
that OCR frequently misreads as extra word breaks or substituted characters;
|
||||
anti-aliasing materially improves OCR accuracy on the Ghostscript
|
||||
rasterization path, especially for small fonts at moderate resolution. The
|
||||
1-bit monochrome devices are unaffected, since they perform their own
|
||||
anti-aliased downscaling and older Ghostscript versions reject alpha-bit
|
||||
options on them. Note that the default rasterizer (``--rasterizer auto``)
|
||||
prefers pypdfium2, which already anti-aliases; this change benefits users who
|
||||
select ``--rasterizer ghostscript`` or do not have pypdfium2 installed.
|
||||
OCRmyPDF now also logs which rasterizer rendered each page at debug verbosity
|
||||
(``-v 1``), and the ``--rasterizer`` help text explains the OCR-quality
|
||||
trade-off, to make such reports easier to diagnose. {issue}`1439`
|
||||
- When Tesseract reports a page with many diacritics, OCRmyPDF still logs its
|
||||
interpreted "lots of diacritics - possibly poor OCR" hint, but now also emits
|
||||
Tesseract's raw message at debug verbosity (``-v 1``) so the original wording
|
||||
is available for diagnosis. {issue}`1566`
|
||||
- Added ``--mode strip``, which removes the invisible OCR text layer from a PDF
|
||||
in place. Unlike ``--ocr-engine none --force-ocr``, it does not rasterize the
|
||||
page, so images and visible content are preserved unchanged and the output is
|
||||
smaller rather than larger. Only text drawn as invisible (PDF text render mode
|
||||
3) is removed; some OCR engines -- and OCRmyPDF v2.2 and earlier -- express
|
||||
text as visible glyphs covered by an opaque image, and that text cannot be
|
||||
removed this way. {issue}`1435`
|
||||
|
||||
## v17.5.0
|
||||
|
||||
- Added support for the ``end`` alias in ``--pages``, denoting the last page
|
||||
|
||||
@@ -6,12 +6,19 @@ services:
|
||||
ocrmypdf:
|
||||
restart: always
|
||||
container_name: ocrmypdf
|
||||
image: jbarlow83/ocrmypdf
|
||||
image: jbarlow83/ocrmypdf-alpine
|
||||
volumes:
|
||||
- "/media/scan:/input"
|
||||
- "/mnt/scan:/output"
|
||||
environment:
|
||||
- OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0
|
||||
# The image runs as the non-root "app" user (uid 1000) by default. The
|
||||
# correct value here depends on your runtime, so that the watcher can write
|
||||
# to the /output bind mount and the files end up owned by you:
|
||||
# rootful Docker -> your host uid:gid
|
||||
# rootless Docker -> "0:0" (container root maps to your host user)
|
||||
# Podman -> your host uid:gid, plus `userns_mode: "keep-id"`
|
||||
# See docs/docker.md ("Bind-mounted volumes") for the reasoning.
|
||||
user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>"
|
||||
entrypoint: python3
|
||||
command: watcher.py
|
||||
command: /app/watcher.py
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ocrmypdf"
|
||||
version = "17.5.0"
|
||||
version = "17.7.0"
|
||||
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
||||
readme = "README.md"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -150,6 +150,19 @@ def rasterize_pdf(
|
||||
else:
|
||||
effective_dpi = raster_dpi
|
||||
|
||||
# Anti-alias text and vector graphics when rendering to a contone device.
|
||||
# Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
|
||||
# extra word breaks; anti-aliasing empirically improves OCR accuracy on the
|
||||
# Ghostscript path, especially for small fonts at moderate DPI (#1439).
|
||||
# The 1-bit mono devices do not accept alpha bits (older Ghostscript
|
||||
# rejects them) and pngmonod performs its own anti-aliased downscaling.
|
||||
mono_devices = (GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD)
|
||||
antialias_args = (
|
||||
[]
|
||||
if raster_device in mono_devices
|
||||
else ['-dTextAlphaBits=4', '-dGraphicsAlphaBits=4']
|
||||
)
|
||||
|
||||
args_gs = (
|
||||
[
|
||||
GS,
|
||||
@@ -162,6 +175,7 @@ def rasterize_pdf(
|
||||
f'-dLastPage={pageno}',
|
||||
f'-r{effective_dpi.x:f}x{effective_dpi.y:f}',
|
||||
]
|
||||
+ antialias_args
|
||||
+ (['-dUseCropBox'] if use_cropbox else [])
|
||||
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||
@@ -299,6 +313,11 @@ def generate_pdfa(
|
||||
]
|
||||
elif compression == 'lossless':
|
||||
compression_args = [
|
||||
# Re-encoding an existing JPEG with a lossless codec only inflates
|
||||
# its size: the lossy data is already baked in, so there is nothing
|
||||
# to gain. Pass JPEGs through untouched and apply lossless (Flate)
|
||||
# encoding only to images that are not already JPEG.
|
||||
"-dPassThroughJPEGImages=true",
|
||||
"-dAutoFilterColorImages=false",
|
||||
"-dColorImageFilter=/FlateEncode",
|
||||
"-dAutoFilterGrayImages=false",
|
||||
@@ -405,4 +424,8 @@ def generate_pdfa(
|
||||
for part in stderr.split('****'):
|
||||
log.error(part)
|
||||
if _gs_devicen_reported(stderr):
|
||||
raise ColorConversionNeededError()
|
||||
# Ghostscript could not normalize the DeviceN colorspace for PDF/A,
|
||||
# even if the user requested a conversion strategy. The output is
|
||||
# liable to render blank in some viewers, so raise regardless of the
|
||||
# strategy and tailor the guidance to what was attempted.
|
||||
raise ColorConversionNeededError(color_conversion_strategy)
|
||||
|
||||
@@ -297,6 +297,10 @@ def tesseract_log_output(stream: bytes) -> None:
|
||||
continue
|
||||
elif 'diacritics' in line:
|
||||
tlog.warning("lots of diacritics - possibly poor OCR")
|
||||
# Surface the raw Tesseract message at debug level so users can see
|
||||
# exactly what Tesseract reported (e.g. the affected count) without
|
||||
# losing the interpreted hint above (#1566).
|
||||
tlog.debug(line.strip())
|
||||
elif line.startswith('OSD: Weak margin'):
|
||||
tlog.warning("unsure about page orientation")
|
||||
elif 'Error in pixScanForForeground' in line:
|
||||
|
||||
+104
-3
@@ -211,6 +211,95 @@ def strip_invisible_text(pdf: Pdf, page: Page):
|
||||
page.Contents = Stream(pdf, content_stream)
|
||||
|
||||
|
||||
def discard_text_search_index(pdf: Pdf) -> bool:
|
||||
"""Discard an embedded Adobe full-text search index from the catalog.
|
||||
|
||||
Adobe Acrobat can embed a full-text search index in the document catalog at
|
||||
``/Root/PieceInfo/SearchIndex``. It is built from the page text, and only
|
||||
Acrobat reads it; other viewers ignore it and search the text on the fly.
|
||||
Any change to the PDF invalidates the index, so once OCRmyPDF rewrites the
|
||||
document (editing the text layer, rasterizing, optimizing) a retained index
|
||||
would be stale and return incorrect search results in Acrobat. We cannot
|
||||
update this vendor-private data, so we discard it; modern viewers rebuild a
|
||||
search index on demand. Returns True if the catalog was modified.
|
||||
"""
|
||||
try:
|
||||
pieceinfo = pdf.Root.get(Name.PieceInfo)
|
||||
if not isinstance(pieceinfo, Dictionary) or Name.SearchIndex not in pieceinfo:
|
||||
return False
|
||||
del pieceinfo[Name.SearchIndex]
|
||||
log.debug(
|
||||
"Discarded embedded text search index "
|
||||
"(/Root/PieceInfo/SearchIndex) because the PDF was rewritten; "
|
||||
"it would otherwise be stale."
|
||||
)
|
||||
# Drop an empty PieceInfo rather than leave a husk behind.
|
||||
if len(pieceinfo) == 0:
|
||||
del pdf.Root.PieceInfo
|
||||
return True
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def discard_page_thumbnails(pdf: Pdf) -> int:
|
||||
"""Discard embedded per-page thumbnail images.
|
||||
|
||||
A page object may carry an optional ``/Thumb`` image XObject — a miniature
|
||||
rendering of the page (ISO 32000-2, 12.3.4). It is only a navigation aid and
|
||||
modern viewers generate page thumbnails on demand. OCRmyPDF alters page
|
||||
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
|
||||
arbitrarily, so any retained thumbnail would be stale and misrepresent its
|
||||
page. We discard them; viewers rebuild thumbnails as needed. Returns the
|
||||
number of thumbnails removed.
|
||||
"""
|
||||
removed = 0
|
||||
for page in pdf.pages:
|
||||
pageobj = page.obj
|
||||
if Name.Thumb in pageobj:
|
||||
del pageobj[Name.Thumb]
|
||||
removed += 1
|
||||
if removed:
|
||||
log.debug(
|
||||
"Discarded %d embedded page thumbnail(s) (/Thumb) because the PDF "
|
||||
"was rewritten; they would otherwise be stale.",
|
||||
removed,
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
def discard_structure_tree(pdf: Pdf) -> bool:
|
||||
"""Discard the logical structure (tagged-PDF) tree from the document.
|
||||
|
||||
The structure tree (``/Root/StructTreeRoot``, ``/Root/MarkInfo``) maps
|
||||
marked content in the page content streams to semantic elements via MCIDs.
|
||||
When OCRmyPDF rasterizes pages (force) or strips and rewrites the text layer
|
||||
(redo), those MCIDs are destroyed or renumbered, leaving the tree dangling
|
||||
and inconsistent with the new content. We cannot rebuild it to match, so we
|
||||
discard it; the page-level ``/StructParents`` keys go too. Returns True if
|
||||
the catalog was modified.
|
||||
"""
|
||||
modified = False
|
||||
try:
|
||||
if Name.StructTreeRoot in pdf.Root:
|
||||
del pdf.Root.StructTreeRoot
|
||||
modified = True
|
||||
if Name.MarkInfo in pdf.Root:
|
||||
del pdf.Root.MarkInfo
|
||||
modified = True
|
||||
for page in pdf.pages:
|
||||
if Name.StructParents in page.obj:
|
||||
del page.obj[Name.StructParents]
|
||||
modified = True
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
return modified
|
||||
if modified:
|
||||
log.debug(
|
||||
"Discarded the logical structure tree (/Root/StructTreeRoot) "
|
||||
"because the PDF was re-OCR'd; it would otherwise be stale."
|
||||
)
|
||||
return modified
|
||||
|
||||
|
||||
class OcrGrafter:
|
||||
"""Manages grafting text-only PDFs onto regular PDFs."""
|
||||
|
||||
@@ -253,6 +342,14 @@ class OcrGrafter:
|
||||
ocr_tree: OCR tree for fpdf2 renderer.
|
||||
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
|
||||
"""
|
||||
if self.context.options.mode == ProcessingMode.strip_text:
|
||||
# Strip mode: remove the invisible OCR text layer in place without
|
||||
# rasterizing or grafting anything. Honor --pages if specified.
|
||||
options = self.context.options
|
||||
if not options.pages or pageno in options.pages:
|
||||
strip_invisible_text(self.pdf_base, self.pdf_base.pages[pageno])
|
||||
return
|
||||
|
||||
if ocr_output and ocr_tree:
|
||||
raise ValueError(
|
||||
'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer'
|
||||
@@ -319,9 +416,9 @@ class OcrGrafter:
|
||||
|
||||
def finalize(self):
|
||||
# Can have hocr OR parsed pages OR neither (no OCR), but not both
|
||||
assert not (
|
||||
self.fpdf2_hocr_pages and self.fpdf2_parsed_pages
|
||||
), "Can't have both hocr and ocrtree pages"
|
||||
assert not (self.fpdf2_hocr_pages and self.fpdf2_parsed_pages), (
|
||||
"Can't have both hocr and ocrtree pages"
|
||||
)
|
||||
|
||||
if self.fpdf2_hocr_pages:
|
||||
# Render all pages with fpdf2, then graft
|
||||
@@ -331,6 +428,10 @@ class OcrGrafter:
|
||||
if self.fpdf2_parsed_pages:
|
||||
self._render_and_graft_fpdf2_pages()
|
||||
|
||||
discard_text_search_index(self.pdf_base)
|
||||
discard_page_thumbnails(self.pdf_base)
|
||||
if self.context.options.mode in (ProcessingMode.force, ProcessingMode.redo):
|
||||
discard_structure_tree(self.pdf_base)
|
||||
self.pdf_base.save(self.output_file)
|
||||
self.pdf_base.close()
|
||||
return self.output_file
|
||||
|
||||
@@ -43,12 +43,16 @@ class ProcessingMode(StrEnum):
|
||||
- ``force``: Rasterize all content and run OCR regardless of existing text
|
||||
- ``skip``: Skip OCR on pages that already have text
|
||||
- ``redo``: Re-OCR pages, stripping old invisible text layer
|
||||
- ``strip``: Remove the invisible OCR text layer in place; do not OCR
|
||||
"""
|
||||
|
||||
default = 'default'
|
||||
force = 'force'
|
||||
skip = 'skip'
|
||||
redo = 'redo'
|
||||
# User-facing value is '--mode strip'; the member is named strip_text to
|
||||
# avoid shadowing str.strip on this str-based enum.
|
||||
strip_text = 'strip'
|
||||
|
||||
|
||||
class TaggedPdfMode(StrEnum):
|
||||
@@ -79,8 +83,7 @@ def _resolve_page_token(token: str, total_pages: int | None) -> int:
|
||||
if token.lower() == 'end':
|
||||
if total_pages is None:
|
||||
raise BadArgsError(
|
||||
"'end' was used in --pages but the total page count is not yet "
|
||||
"known"
|
||||
"'end' was used in --pages but the total page count is not yet known"
|
||||
)
|
||||
return total_pages
|
||||
return int(token)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# SPDX-FileCopyrightText: 2026 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2025 ajdlinux
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Validate and repair malformed page-boundary boxes.
|
||||
|
||||
A page's boundary boxes (``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``,
|
||||
``/BleedBox``) are sometimes malformed in ways that PDF readers tolerate but
|
||||
that crash or corrupt downstream processing. This module normalizes them in
|
||||
place following the PDF 2.0 specification (ISO 32000-2:2020):
|
||||
|
||||
- **Non-decimal coordinates** (§7.3.3): a coordinate written in exponential
|
||||
notation is invalid PDF number syntax and is stored by qpdf/pikepdf as a
|
||||
string. We coerce it back to a number (issue #1398).
|
||||
- **Reversed corners** (§7.9.5): a rectangle is "a pair of diagonally opposite
|
||||
corners"; ``[llx lly urx ury]`` is only the typical order. We normalize to
|
||||
``[min_x, min_y, max_x, max_y]`` (issue #1526).
|
||||
- **Sub-box outside the MediaBox** (§14.11.2): "If the bounds of the crop,
|
||||
trim, bleed or art box extends outside of the bounds of the media box, a
|
||||
processor shall treat the box as its intersection with the media box." We
|
||||
clamp to that intersection, or discard the sub-box (so it inherits the
|
||||
MediaBox) when the intersection is empty (issue #1400).
|
||||
|
||||
A rectangle is treated as empty when its width or height is ``<= 0``; PDF 2.0
|
||||
permits zero-dimension rectangles and defines no minimum page size, so no other
|
||||
size floor is imposed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pikepdf
|
||||
from pikepdf import Name
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SUBBOXES = ('CropBox', 'TrimBox', 'ArtBox', 'BleedBox')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoxRepair:
|
||||
"""A single change made to a page box.
|
||||
|
||||
Attributes:
|
||||
box: The box name, e.g. ``"CropBox"``.
|
||||
kind: One of ``"reordered"`` (reversed corners normalized; lossless),
|
||||
``"recoded"`` (non-numeric/exponential coordinate coerced),
|
||||
``"clamped"`` (sub-box clamped to the MediaBox), ``"discarded"``
|
||||
(sub-box removed because its MediaBox intersection was empty), or
|
||||
``"degenerate_mediabox"`` (MediaBox has zero width or height).
|
||||
"""
|
||||
|
||||
box: str
|
||||
kind: str
|
||||
|
||||
|
||||
def _read_box(values: Sequence) -> tuple[list[float], bool, bool] | None:
|
||||
"""Coerce a box array to floats and normalize corner order.
|
||||
|
||||
Returns ``(normalized_values, recoded, reordered)`` where ``recoded`` is
|
||||
True if any element needed string/exponential coercion and ``reordered`` is
|
||||
True if the corners were given in non-standard order. Returns None if the
|
||||
array is not four finite numbers.
|
||||
"""
|
||||
if len(values) != 4:
|
||||
return None
|
||||
nums: list[float] = []
|
||||
recoded = False
|
||||
for v in values:
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
n = float(str(v))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
recoded = True
|
||||
if not math.isfinite(n):
|
||||
return None
|
||||
nums.append(n)
|
||||
x0, y0, x1, y1 = nums
|
||||
normalized = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)]
|
||||
reordered = normalized != nums
|
||||
return normalized, recoded, reordered
|
||||
|
||||
|
||||
def coerce_box(values: Iterable) -> list[float]:
|
||||
"""Return box values coerced to floats with corner order normalized.
|
||||
|
||||
Robust against exponential/string coordinates and reversed corners, so
|
||||
callers that only need to read a box (e.g. dimension calculations) do not
|
||||
crash on malformed input. Falls back to best-effort per-element coercion if
|
||||
the array is not four numbers.
|
||||
"""
|
||||
values = list(values)
|
||||
result = _read_box(values)
|
||||
if result is not None:
|
||||
return result[0]
|
||||
coerced = []
|
||||
for v in values:
|
||||
try:
|
||||
coerced.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
coerced.append(float(str(v)))
|
||||
return coerced
|
||||
|
||||
|
||||
def _is_empty(box: Sequence[float]) -> bool:
|
||||
"""A rectangle is empty when its width or height is non-positive."""
|
||||
return (box[2] - box[0]) <= 0 or (box[3] - box[1]) <= 0
|
||||
|
||||
|
||||
def repair_page_boxes(page: pikepdf.Page) -> list[BoxRepair]:
|
||||
"""Validate and repair the boundary boxes of a single page, in place.
|
||||
|
||||
Returns the list of changes made (empty if the page was already valid).
|
||||
Only boxes that actually change are written back, so valid pages are left
|
||||
untouched. Performs no logging or I/O.
|
||||
"""
|
||||
repairs: list[BoxRepair] = []
|
||||
|
||||
# MediaBox is the reference rectangle; read it inheritance-aware.
|
||||
mediabox: list[float] | None = None
|
||||
try:
|
||||
mb_result = _read_box(list(page.mediabox.as_list()))
|
||||
except (AttributeError, KeyError, RuntimeError):
|
||||
mb_result = None
|
||||
if mb_result is not None:
|
||||
mediabox, recoded, reordered = mb_result
|
||||
if reordered:
|
||||
repairs.append(BoxRepair('MediaBox', 'reordered'))
|
||||
if recoded:
|
||||
repairs.append(BoxRepair('MediaBox', 'recoded'))
|
||||
if recoded or reordered:
|
||||
page.obj.MediaBox = pikepdf.Array(mediabox)
|
||||
if _is_empty(mediabox):
|
||||
repairs.append(BoxRepair('MediaBox', 'degenerate_mediabox'))
|
||||
mediabox = None # don't clamp against a degenerate reference
|
||||
|
||||
for box in _SUBBOXES:
|
||||
name = Name('/' + box)
|
||||
if name not in page.obj:
|
||||
continue
|
||||
try:
|
||||
sub_result = _read_box(list(page.obj[name]))
|
||||
except (TypeError, RuntimeError):
|
||||
continue
|
||||
if sub_result is None:
|
||||
continue
|
||||
values, recoded, reordered = sub_result
|
||||
if reordered:
|
||||
repairs.append(BoxRepair(box, 'reordered'))
|
||||
if recoded:
|
||||
repairs.append(BoxRepair(box, 'recoded'))
|
||||
if recoded or reordered:
|
||||
page.obj[name] = pikepdf.Array(values)
|
||||
|
||||
if mediabox is None:
|
||||
continue
|
||||
intersection = [
|
||||
max(values[0], mediabox[0]),
|
||||
max(values[1], mediabox[1]),
|
||||
min(values[2], mediabox[2]),
|
||||
min(values[3], mediabox[3]),
|
||||
]
|
||||
if _is_empty(intersection):
|
||||
del page.obj[name]
|
||||
repairs.append(BoxRepair(box, 'discarded'))
|
||||
elif intersection != values:
|
||||
page.obj[name] = pikepdf.Array(intersection)
|
||||
repairs.append(BoxRepair(box, 'clamped'))
|
||||
|
||||
return repairs
|
||||
|
||||
|
||||
# Per-kind log severity and message template ({box} is substituted).
|
||||
_KIND_MESSAGES: dict[str, tuple[int, str]] = {
|
||||
'discarded': (
|
||||
logging.WARNING,
|
||||
'{box} lies outside the MediaBox and was discarded; '
|
||||
'the full page will be shown',
|
||||
),
|
||||
'clamped': (
|
||||
logging.WARNING,
|
||||
'{box} extended beyond the MediaBox and was clamped to it',
|
||||
),
|
||||
'recoded': (
|
||||
logging.WARNING,
|
||||
'{box} used invalid (e.g. exponential) coordinates, which were reinterpreted',
|
||||
),
|
||||
'degenerate_mediabox': (
|
||||
logging.WARNING,
|
||||
'MediaBox has zero width or height and could not be repaired; '
|
||||
'output may be invalid',
|
||||
),
|
||||
'reordered': (
|
||||
logging.DEBUG,
|
||||
'{box} corners were reversed and have been normalized',
|
||||
),
|
||||
}
|
||||
|
||||
# Kinds that change page appearance and warrant manual review of the output.
|
||||
_INSPECT_KINDS = frozenset({'discarded', 'clamped', 'recoded'})
|
||||
_INSPECT = ' Please visually inspect the output PDF.'
|
||||
|
||||
|
||||
def _format_pages(pagenos: Iterable[int]) -> str:
|
||||
"""Format 0-based page numbers as a compact 1-based range string."""
|
||||
nums = sorted(p + 1 for p in pagenos)
|
||||
ranges: list[tuple[int, int]] = []
|
||||
start = prev = nums[0]
|
||||
for n in nums[1:]:
|
||||
if n == prev + 1:
|
||||
prev = n
|
||||
continue
|
||||
ranges.append((start, prev))
|
||||
start = prev = n
|
||||
ranges.append((start, prev))
|
||||
return ', '.join(f'{a}' if a == b else f'{a}-{b}' for a, b in ranges)
|
||||
|
||||
|
||||
def summarize_box_repairs(
|
||||
repairs_by_page: Mapping[int, Sequence[BoxRepair]],
|
||||
) -> list[tuple[int, str]]:
|
||||
"""Aggregate per-page repairs into ``(log_level, message)`` pairs.
|
||||
|
||||
Repairs are grouped by ``(kind, box)`` so a defect shared across many pages
|
||||
yields a single message listing the affected pages, rather than one message
|
||||
per page.
|
||||
"""
|
||||
groups: dict[tuple[str, str], set[int]] = {}
|
||||
for pageno, repairs in repairs_by_page.items():
|
||||
for repair in repairs:
|
||||
groups.setdefault((repair.kind, repair.box), set()).add(pageno)
|
||||
|
||||
messages: list[tuple[int, str]] = []
|
||||
for (kind, box), pages in sorted(groups.items()):
|
||||
level, template = _KIND_MESSAGES[kind]
|
||||
text = f'Page(s) {_format_pages(pages)}: {template.format(box=box)}.'
|
||||
if kind in _INSPECT_KINDS:
|
||||
text += _INSPECT
|
||||
messages.append((level, text))
|
||||
return messages
|
||||
|
||||
|
||||
def log_box_repairs(repairs_by_page: Mapping[int, Sequence[BoxRepair]]) -> None:
|
||||
"""Emit aggregated log messages for the repairs made across all pages."""
|
||||
for level, message in summarize_box_repairs(repairs_by_page):
|
||||
log.log(level, message)
|
||||
+66
-35
@@ -29,6 +29,7 @@ from ocrmypdf._exec import unpaper
|
||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||
from ocrmypdf._metadata import repair_docinfo_nuls
|
||||
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
|
||||
from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes
|
||||
from ocrmypdf.exceptions import (
|
||||
DigitalSignatureError,
|
||||
DpiError,
|
||||
@@ -44,7 +45,7 @@ from ocrmypdf.pdfa import (
|
||||
generate_pdfa_ps,
|
||||
speculative_pdfa_conversion,
|
||||
)
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, Ink, PageInfo, PdfInfo
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
|
||||
|
||||
try:
|
||||
@@ -116,8 +117,7 @@ def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions)
|
||||
|
||||
if im.mode in ('RGBA', 'LA'):
|
||||
raise UnsupportedImageFormatError(
|
||||
"The input image has an alpha channel. Remove the alpha "
|
||||
"channel first."
|
||||
"The input image has an alpha channel. Remove the alpha channel first."
|
||||
)
|
||||
|
||||
if 'iccprofile' not in im.info:
|
||||
@@ -175,6 +175,12 @@ def triage(
|
||||
)
|
||||
try:
|
||||
with pikepdf.open(input_file) as pdf:
|
||||
repairs_by_page = {
|
||||
n: repairs
|
||||
for n, page in enumerate(pdf.pages)
|
||||
if (repairs := repair_page_boxes(page))
|
||||
}
|
||||
log_box_repairs(repairs_by_page)
|
||||
pdf.save(output_file)
|
||||
except pikepdf.PdfError as e:
|
||||
raise InputFileError() from e
|
||||
@@ -250,12 +256,15 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
|
||||
"image of the form and all filled form fields. The output PDF "
|
||||
"will be 'flattened' and will no longer be fillable."
|
||||
)
|
||||
if pdfinfo.is_tagged:
|
||||
if pdfinfo.is_tagged or pdfinfo.has_structure_tree:
|
||||
log.warning(
|
||||
"This PDF is marked as a Tagged PDF. This often indicates "
|
||||
"that the PDF was generated from an office document and does "
|
||||
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
||||
"tagged correctly."
|
||||
"This PDF contains structural markup (it is a Tagged PDF or "
|
||||
"carries a logical structure tree). This often indicates that the "
|
||||
"PDF was generated from an office document or is otherwise born "
|
||||
"digital, and does not need OCR. OCRmyPDF cannot rebuild this "
|
||||
"structure to match new text, so any page it re-OCRs with "
|
||||
"--force-ocr or --redo-ocr will have its structural markup "
|
||||
"discarded."
|
||||
)
|
||||
if (
|
||||
options.tagged_pdf_mode == TaggedPdfMode.default
|
||||
@@ -325,6 +334,11 @@ def is_ocr_required(page_context: PageContext) -> bool:
|
||||
pageinfo = page_context.pageinfo
|
||||
options = page_context.options
|
||||
|
||||
if options.mode == ProcessingMode.strip_text:
|
||||
# Strip mode removes the OCR text layer in place; it never rasterizes
|
||||
# or runs OCR. The stripping happens in OcrGrafter.graft_page.
|
||||
return False
|
||||
|
||||
ocr_required = True
|
||||
|
||||
if options.pages and pageinfo.pageno not in options.pages:
|
||||
@@ -508,6 +522,49 @@ def calculate_raster_dpi(page_context: PageContext):
|
||||
return canvas_dpi, page_dpi
|
||||
|
||||
|
||||
def _select_raster_device(pageinfo: PageInfo) -> GhostscriptRasterDevice:
|
||||
"""Choose the minimum raster device that preserves the page's color depth.
|
||||
|
||||
The device escalates from 1-bit mono through grayscale, indexed, and full
|
||||
color as required by the page's images, image masks, and vector content.
|
||||
Image masks are painted with the current fill color, so a mask painted in
|
||||
gray or color escalates the device even though the mask itself is 1-bit.
|
||||
"""
|
||||
colorspaces = [
|
||||
GhostscriptRasterDevice.PNGMONOD,
|
||||
GhostscriptRasterDevice.PNGGRAY,
|
||||
GhostscriptRasterDevice.PNG256,
|
||||
GhostscriptRasterDevice.PNG16M,
|
||||
]
|
||||
device_idx = 0
|
||||
|
||||
def at_least(colorspace):
|
||||
return max(device_idx, colorspaces.index(colorspace))
|
||||
|
||||
for image in pageinfo.images:
|
||||
if image.type_ == 'stencil':
|
||||
# The fill color used to paint the mask, not the 1-bit mask data,
|
||||
# determines the color depth OCR needs.
|
||||
if image.ink == Ink.color:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
elif image.ink == Ink.gray:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||
continue
|
||||
if image.bpc > 1:
|
||||
if image.color == Colorspace.index:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG256)
|
||||
elif image.color == Colorspace.gray:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||
else:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
if pageinfo.has_vector:
|
||||
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
return colorspaces[device_idx]
|
||||
|
||||
|
||||
def rasterize(
|
||||
input_file: Path,
|
||||
page_context: PageContext,
|
||||
@@ -529,39 +586,13 @@ def rasterize(
|
||||
Returns:
|
||||
Path: The output PNG file path.
|
||||
"""
|
||||
colorspaces = [
|
||||
GhostscriptRasterDevice.PNGMONO,
|
||||
GhostscriptRasterDevice.PNGGRAY,
|
||||
GhostscriptRasterDevice.PNG256,
|
||||
GhostscriptRasterDevice.PNG16M,
|
||||
]
|
||||
device_idx = 0
|
||||
|
||||
if remove_vectors is None:
|
||||
remove_vectors = page_context.options.remove_vectors
|
||||
|
||||
output_file = page_context.get_path(f'rasterize{output_tag}.png')
|
||||
pageinfo = page_context.pageinfo
|
||||
|
||||
def at_least(colorspace):
|
||||
return max(device_idx, colorspaces.index(colorspace))
|
||||
|
||||
for image in pageinfo.images:
|
||||
if image.type_ != 'image':
|
||||
continue # ignore masks
|
||||
if image.bpc > 1:
|
||||
if image.color == Colorspace.index:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG256)
|
||||
elif image.color == Colorspace.gray:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||
else:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
if pageinfo.has_vector:
|
||||
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
device = colorspaces[device_idx]
|
||||
device = _select_raster_device(pageinfo)
|
||||
|
||||
log.debug(
|
||||
f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}"
|
||||
|
||||
@@ -17,7 +17,7 @@ import pikepdf
|
||||
|
||||
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||
from ocrmypdf._exec import unpaper
|
||||
from ocrmypdf._options import OcrOptions
|
||||
from ocrmypdf._options import OcrOptions, ProcessingMode
|
||||
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||
from ocrmypdf.exceptions import (
|
||||
BadArgsError,
|
||||
@@ -118,8 +118,36 @@ def check_options_preprocessing(options: OcrOptions) -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_options_strip(options: OcrOptions) -> None:
|
||||
"""Reject options that cannot apply in strip mode.
|
||||
|
||||
``--mode strip`` removes the OCR text layer in place without rasterizing or
|
||||
running OCR, so image-processing and OCR-output options have no effect.
|
||||
"""
|
||||
if options.mode != ProcessingMode.strip_text:
|
||||
return
|
||||
incompatible = {
|
||||
'--deskew': options.deskew,
|
||||
'--clean': options.clean,
|
||||
'--clean-final': options.clean_final,
|
||||
'--remove-background': options.remove_background,
|
||||
'--rotate-pages': options.rotate_pages,
|
||||
'--oversample': options.oversample,
|
||||
'--remove-vectors': options.remove_vectors,
|
||||
'--sidecar': options.sidecar,
|
||||
}
|
||||
used = sorted(name for name, value in incompatible.items() if value)
|
||||
if used:
|
||||
raise BadArgsError(
|
||||
"--mode strip removes the OCR text layer without rasterizing or "
|
||||
"running OCR, so these options have no effect and are not allowed: "
|
||||
f"{', '.join(used)}"
|
||||
)
|
||||
|
||||
|
||||
def _check_plugin_invariant_options(options: OcrOptions) -> None:
|
||||
check_platform()
|
||||
check_options_strip(options)
|
||||
check_options_sidecar(options)
|
||||
check_options_preprocessing(options)
|
||||
|
||||
@@ -182,7 +210,7 @@ def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str
|
||||
if running_in_docker(): # pragma: no cover
|
||||
msg += (
|
||||
"\nDocker cannot access your working directory unless you "
|
||||
"explicitly share it with the Docker container and set up"
|
||||
"explicitly share it with the Docker container and set up "
|
||||
"permissions correctly.\n"
|
||||
"You may find it easier to use stdin/stdout:"
|
||||
"\n"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
__version__ = "17.5.0"
|
||||
__version__ = "17.7.0"
|
||||
|
||||
@@ -44,6 +44,30 @@ class PdfaImageCompression(StrEnum):
|
||||
LOSSLESS = 'lossless'
|
||||
|
||||
|
||||
def _resolve_auto_compression(
|
||||
compression: PdfaImageCompression, optimize_level: int
|
||||
) -> PdfaImageCompression:
|
||||
"""Resolve 'auto' image compression based on the optimization level.
|
||||
|
||||
At ``-O0`` (no optimization) ``auto`` maps to ``lossless`` so Ghostscript
|
||||
will not transcode lossless images to JPEG during PDF/A generation. At all
|
||||
other levels ``auto`` defers to Ghostscript's heuristic, which may
|
||||
recompress images lossily.
|
||||
|
||||
``-O1`` is a historical exception: although it is otherwise a
|
||||
lossless-only optimization level, coercing ``auto`` to ``lossless`` there
|
||||
can bloat output substantially (Ghostscript's heuristic often picks JPEG
|
||||
for photographic content), so the default is left alone for backwards
|
||||
compatibility. Users who want guaranteed lossless image handling at any
|
||||
level can pass ``--pdfa-image-compression=lossless`` explicitly.
|
||||
|
||||
Explicit ``jpeg`` and ``lossless`` choices are always respected.
|
||||
"""
|
||||
if compression == PdfaImageCompression.AUTO and optimize_level == 0:
|
||||
return PdfaImageCompression.LOSSLESS
|
||||
return compression
|
||||
|
||||
|
||||
class GhostscriptOptions(BaseModel):
|
||||
"""Options specific to Ghostscript operations."""
|
||||
|
||||
@@ -99,9 +123,14 @@ class GhostscriptOptions(BaseModel):
|
||||
choices=[pc.value for pc in PdfaImageCompression],
|
||||
default=PdfaImageCompression.AUTO.value,
|
||||
help="Specify how to compress images in the output PDF/A. 'auto' lets "
|
||||
"OCRmyPDF decide. 'jpeg' changes all grayscale and color images to "
|
||||
"OCRmyPDF decide: at -O0 it uses lossless image compression so "
|
||||
"Ghostscript does not transcode lossless images to JPEG; at -O1 and "
|
||||
"above it defers to Ghostscript's heuristic, which may recompress "
|
||||
"images lossily. 'jpeg' changes all grayscale and color images to "
|
||||
"JPEG compression. 'lossless' uses PNG-style lossless compression "
|
||||
"for all images. Monochrome images are always compressed using a "
|
||||
"for non-JPEG images and passes existing JPEGs through unchanged "
|
||||
"(re-encoding them losslessly would only inflate them). Monochrome "
|
||||
"images are always compressed using a "
|
||||
"lossless codec. Compression settings "
|
||||
"are applied to all pages, including those for which OCR was "
|
||||
"skipped. Not supported for --output-type=pdf ; that setting "
|
||||
@@ -227,6 +256,8 @@ def rasterize_pdf_page(
|
||||
# Let pypdfium handle it (it will error in check_options if unavailable)
|
||||
return None
|
||||
|
||||
log.debug("Rasterizing page %d with the Ghostscript rasterizer", pageno)
|
||||
|
||||
ghostscript.rasterize_pdf(
|
||||
input_file,
|
||||
output_file,
|
||||
@@ -397,10 +428,15 @@ def generate_pdfa(
|
||||
if output_type == 'pdfa':
|
||||
output_type = 'pdfa-2'
|
||||
|
||||
compression = _resolve_auto_compression(
|
||||
context.options.ghostscript.pdfa_image_compression,
|
||||
context.options.optimize,
|
||||
)
|
||||
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_pages=[pdfmark, *pdf_pages],
|
||||
output_file=output_file,
|
||||
compression=context.options.ghostscript.pdfa_image_compression,
|
||||
compression=compression,
|
||||
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
|
||||
jpeg_quality=context.options.ghostscript.jpeg_quality,
|
||||
jpeg_maxdpi=context.options.ghostscript.jpeg_maxdpi,
|
||||
|
||||
@@ -96,7 +96,12 @@ def _render_page_to_bitmap(
|
||||
# Render the page to a bitmap
|
||||
# The scale parameter controls the resolution
|
||||
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
|
||||
grayscale = raster_device.lower() in ('pngmono', 'pnggray', 'jpeggray')
|
||||
grayscale = raster_device.lower() in (
|
||||
'pngmono',
|
||||
'pngmonod',
|
||||
'pnggray',
|
||||
'jpeggray',
|
||||
)
|
||||
|
||||
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
|
||||
if not use_cropbox:
|
||||
@@ -157,8 +162,8 @@ def _process_image_for_output(
|
||||
# This ensures pypdfium output matches Ghostscript's native device output
|
||||
raster_device_lower = raster_device.lower()
|
||||
|
||||
if raster_device_lower == 'pngmono':
|
||||
# Convert to 1-bit black and white (matches Ghostscript pngmono device)
|
||||
if raster_device_lower in ('pngmono', 'pngmonod'):
|
||||
# Convert to 1-bit black and white (matches Ghostscript pngmono/pngmonod)
|
||||
if pil_image.mode != '1':
|
||||
if pil_image.mode not in ('L', '1'):
|
||||
pil_image = pil_image.convert('L')
|
||||
@@ -184,7 +189,15 @@ def _process_image_for_output(
|
||||
# pngalpha: keep RGBA as-is
|
||||
|
||||
# Determine output format based on raster_device
|
||||
png_devices = ('png', 'pngmono', 'pnggray', 'png256', 'png16m', 'pngalpha')
|
||||
png_devices = (
|
||||
'png',
|
||||
'pngmono',
|
||||
'pngmonod',
|
||||
'pnggray',
|
||||
'png256',
|
||||
'png16m',
|
||||
'pngalpha',
|
||||
)
|
||||
if raster_device_lower in png_devices:
|
||||
format_name = 'PNG'
|
||||
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
|
||||
@@ -242,6 +255,8 @@ def rasterize_pdf_page(
|
||||
if pdfium is None:
|
||||
return None # Fall back to Ghostscript
|
||||
|
||||
log.debug("Rasterizing page %d with the pypdfium2 rasterizer", pageno)
|
||||
|
||||
# Acquire lock to ensure thread-safe access to pypdfium2
|
||||
with (
|
||||
_pdfium_lock,
|
||||
|
||||
+12
-4
@@ -327,7 +327,11 @@ Online documentation is located at:
|
||||
"'default' errors if text is found. "
|
||||
"'force' rasterizes all content and runs OCR (same as --force-ocr). "
|
||||
"'skip' skips pages with existing text (same as --skip-text). "
|
||||
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr).",
|
||||
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr). "
|
||||
"'strip' removes the invisible OCR text layer without rasterizing or "
|
||||
"running OCR, producing a smaller file; only text drawn as invisible "
|
||||
"(render mode 3) is removed, so text from some OCR engines cannot be "
|
||||
"removed this way.",
|
||||
)
|
||||
# Legacy flags for backward compatibility - these set the mode internally
|
||||
ocrsettings.add_argument(
|
||||
@@ -423,9 +427,13 @@ Online documentation is located at:
|
||||
'--rasterizer',
|
||||
choices=['auto', 'ghostscript', 'pypdfium'],
|
||||
default='auto',
|
||||
help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, "
|
||||
"falling back to Ghostscript. 'pypdfium' is faster but requires the "
|
||||
"pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.",
|
||||
help="Choose PDF page rasterizer. 'auto' (the default) prefers pypdfium2 "
|
||||
"when the pypdfium2 package is installed, falling back to Ghostscript "
|
||||
"otherwise. pypdfium2 anti-aliases page content and generally produces "
|
||||
"better input for OCR than Ghostscript 10.x, which can render aliased "
|
||||
"glyphs that OCR misreads as extra word breaks. 'pypdfium' forces the "
|
||||
"pypdfium2 rasterizer (requires the pypdfium2 package); 'ghostscript' "
|
||||
"forces the traditional Ghostscript rasterizer.",
|
||||
)
|
||||
advanced.add_argument(
|
||||
'--rotate-pages-threshold',
|
||||
|
||||
@@ -140,13 +140,42 @@ class TaggedPDFError(InputFileError):
|
||||
|
||||
|
||||
class ColorConversionNeededError(BadArgsError):
|
||||
"""PDF needs color conversion."""
|
||||
"""PDF needs color conversion to a standard color space.
|
||||
|
||||
message = dedent(
|
||||
"""\
|
||||
The input PDF has an unusual color space. Use
|
||||
--color-conversion-strategy to convert to a common color space
|
||||
such as RGB, or use --output-type pdf to skip PDF/A conversion
|
||||
and retain the original color space.
|
||||
"""
|
||||
)
|
||||
Ghostscript reported a DeviceN colorspace with an inappropriate alternate.
|
||||
The resulting PDF/A is liable to render incorrectly (often blank) in some
|
||||
viewers such as Adobe Reader, so the colorspace must be normalized to a
|
||||
common one. RGB, CMYK, and Gray are known to work; LeaveColorUnchanged
|
||||
performs no conversion and UseDeviceIndependentColor does not resolve the
|
||||
problem (see https://github.com/ocrmypdf/OCRmyPDF/issues/1187).
|
||||
"""
|
||||
|
||||
# Strategies that can normalize an unusual DeviceN colorspace into one that
|
||||
# PDF/A viewers render correctly.
|
||||
_effective_strategies = "RGB, CMYK, or Gray"
|
||||
|
||||
def __init__(self, color_conversion_strategy: str = "LeaveColorUnchanged"):
|
||||
"""Build guidance tailored to the conversion strategy that was used."""
|
||||
super().__init__()
|
||||
if color_conversion_strategy == "LeaveColorUnchanged":
|
||||
self.message = dedent(
|
||||
f"""\
|
||||
The input PDF has an unusual DeviceN color space that cannot be
|
||||
represented in PDF/A; the output may appear blank in some viewers
|
||||
such as Adobe Reader. Convert it to a common color space with
|
||||
--color-conversion-strategy ({self._effective_strategies}), or use
|
||||
--output-type pdf to skip PDF/A conversion and retain the original
|
||||
color space.
|
||||
"""
|
||||
)
|
||||
else:
|
||||
self.message = dedent(
|
||||
f"""\
|
||||
Color conversion with --color-conversion-strategy
|
||||
{color_conversion_strategy} did not resolve the input PDF's unusual
|
||||
DeviceN color space; the output may appear blank in some viewers
|
||||
such as Adobe Reader. Try a different --color-conversion-strategy
|
||||
({self._effective_strategies}), or use --output-type pdf to skip
|
||||
PDF/A conversion and retain the original color space.
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -354,9 +354,16 @@ def extract_images(
|
||||
pdf=pdf, root=root, image=image, xref=xref, options=options
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(
|
||||
f"xref {xref}: While extracting this image, an error occurred"
|
||||
# Optimization is best-effort: an image we cannot process is simply
|
||||
# left unchanged in the output, which remains valid. Report this as
|
||||
# a concise warning rather than an alarming traceback (issue #846);
|
||||
# the full detail is still available at debug verbosity.
|
||||
log.warning(
|
||||
f"xref {xref}: this image could not be processed by the "
|
||||
"optimizer and was left unchanged. The output file is still "
|
||||
"valid."
|
||||
)
|
||||
log.debug(f"xref {xref}: image optimization error detail", exc_info=True)
|
||||
errors += 1
|
||||
else:
|
||||
if result:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect
|
||||
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect, Ink
|
||||
from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo
|
||||
|
||||
__all__ = ["Colorspace", "Encoding", "FloatRect", "PageInfo", "PdfInfo"]
|
||||
__all__ = ["Colorspace", "Encoding", "FloatRect", "Ink", "PageInfo", "PdfInfo"]
|
||||
|
||||
@@ -11,11 +11,11 @@ from math import hypot, inf, isclose
|
||||
from typing import NamedTuple
|
||||
from warnings import warn
|
||||
|
||||
from pikepdf import Matrix, Object, PdfInlineImage, parse_content_stream
|
||||
from pikepdf import Matrix, Name, Object, PdfInlineImage, parse_content_stream
|
||||
|
||||
from ocrmypdf.exceptions import InputFileError
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pdfinfo._types import UNIT_SQUARE
|
||||
from ocrmypdf.pdfinfo._types import UNIT_SQUARE, Ink
|
||||
|
||||
|
||||
class XobjectSettings(NamedTuple):
|
||||
@@ -24,6 +24,7 @@ class XobjectSettings(NamedTuple):
|
||||
name: str
|
||||
shorthand: tuple[float, float, float, float, float, float]
|
||||
stack_depth: int
|
||||
fill_ink: Ink
|
||||
|
||||
|
||||
class InlineSettings(NamedTuple):
|
||||
@@ -32,6 +33,7 @@ class InlineSettings(NamedTuple):
|
||||
iimage: PdfInlineImage
|
||||
shorthand: tuple[float, float, float, float, float, float]
|
||||
stack_depth: int
|
||||
fill_ink: Ink
|
||||
|
||||
|
||||
class ContentsInfo(NamedTuple):
|
||||
@@ -67,6 +69,60 @@ def _is_unit_square(shorthand):
|
||||
return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise)
|
||||
|
||||
|
||||
_INK_EPSILON = 1e-3
|
||||
|
||||
# Maps a fill-colorspace name (set by the `cs` operator) to a device color
|
||||
# family we can classify. Names not present here (Separation, ICCBased,
|
||||
# Indexed, DeviceN, Pattern, resource names like /CS0) are treated as color.
|
||||
_DEVICE_FILL_SPACE = {
|
||||
'/DeviceGray': 'gray',
|
||||
'/CalGray': 'gray',
|
||||
'/G': 'gray',
|
||||
'/DeviceRGB': 'rgb',
|
||||
'/CalRGB': 'rgb',
|
||||
'/RGB': 'rgb',
|
||||
'/DeviceCMYK': 'cmyk',
|
||||
'/CMYK': 'cmyk',
|
||||
}
|
||||
|
||||
|
||||
def _ink_from_components(space: str, comps: list[float]) -> Ink:
|
||||
"""Classify a device-color fill into mono/gray/color.
|
||||
|
||||
``space`` is one of 'gray', 'rgb', 'cmyk'. Any other value is treated
|
||||
conservatively as color, since we cannot prove it is achromatic.
|
||||
"""
|
||||
eps = _INK_EPSILON
|
||||
if space == 'gray' and len(comps) == 1:
|
||||
return Ink.mono if comps[0] <= eps else Ink.gray
|
||||
if space == 'rgb' and len(comps) == 3:
|
||||
r, g, b = comps
|
||||
if max(r, g, b) <= eps:
|
||||
return Ink.mono
|
||||
if abs(r - g) <= eps and abs(g - b) <= eps:
|
||||
return Ink.gray
|
||||
return Ink.color
|
||||
if space == 'cmyk' and len(comps) == 4:
|
||||
c, m, y, k = comps
|
||||
if c <= eps and m <= eps and y <= eps:
|
||||
return Ink.mono if k <= eps else Ink.gray
|
||||
return Ink.color
|
||||
return Ink.color # conservative-to-color
|
||||
|
||||
|
||||
def _operand_floats(operands) -> list[float] | None:
|
||||
"""Convert color operands to floats, or None if any is non-numeric.
|
||||
|
||||
Color operators in a malformed content stream may carry the wrong number
|
||||
of operands or a non-numeric operand (e.g. a Name). Returning None lets
|
||||
the caller keep the prior fill state instead of raising.
|
||||
"""
|
||||
try:
|
||||
return [float(o) for o in operands]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_stack(graphobjs):
|
||||
"""Convert runs of qQ's in the stack into single graphobjs."""
|
||||
for operands, operator in graphobjs:
|
||||
@@ -78,12 +134,15 @@ def _normalize_stack(graphobjs):
|
||||
yield (operands, operator)
|
||||
|
||||
|
||||
def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
def _interpret_contents(
|
||||
contentstream: Object, initial_shorthand=UNIT_SQUARE, initial_fill_ink=Ink.mono
|
||||
):
|
||||
"""Interpret the PDF content stream.
|
||||
|
||||
The stack represents the state of the PDF graphics stack. We are only
|
||||
interested in the current transformation matrix (CTM) so we only track
|
||||
this object; a full implementation would need to track many other items.
|
||||
The stack represents the state of the PDF graphics stack. We track the
|
||||
current transformation matrix (CTM) and the current fill color (so that
|
||||
image masks, which are painted with the fill color, can be classified);
|
||||
a full implementation would need to track many other items.
|
||||
|
||||
The CTM is initialized to the mapping from user space to device space.
|
||||
PDF units are 1/72". In a PDF viewer or printer this matrix is initialized
|
||||
@@ -102,10 +161,12 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
stack depth exceeds the spec limit and set a hard limit beyond this to
|
||||
bound our memory requirements. If the stack underflows behavior is
|
||||
undefined in the spec, but we just pretend nothing happened and leave the
|
||||
CTM unchanged.
|
||||
graphics state unchanged.
|
||||
"""
|
||||
stack = []
|
||||
ctm = Matrix(initial_shorthand)
|
||||
fill_ink = initial_fill_ink # PDF default fill color is black
|
||||
fill_space = '/DeviceGray' # current fill colorspace name (for sc/scn)
|
||||
xobject_settings: list[XobjectSettings] = []
|
||||
inline_images: list[InlineSettings] = []
|
||||
name_index = defaultdict(lambda: [])
|
||||
@@ -114,14 +175,15 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
vector_ops = set('S s f F f* B B* b b*'.split())
|
||||
text_showing_ops = set("""TJ Tj " '""".split())
|
||||
image_ops = set('BI ID EI q Q Do cm'.split())
|
||||
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops)
|
||||
color_ops = set('g rg k cs sc scn'.split())
|
||||
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops | color_ops)
|
||||
|
||||
for n, graphobj in enumerate(
|
||||
_normalize_stack(parse_content_stream(contentstream, operator_whitelist))
|
||||
):
|
||||
operands, operator = graphobj
|
||||
if operator == 'q':
|
||||
stack.append(ctm)
|
||||
stack.append((ctm, fill_ink, fill_space))
|
||||
if len(stack) > 32: # See docstring
|
||||
if len(stack) > 128:
|
||||
raise RuntimeError(
|
||||
@@ -130,9 +192,9 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
warn("PDF graphics stack overflowed spec limit")
|
||||
elif operator == 'Q':
|
||||
try:
|
||||
ctm = stack.pop()
|
||||
ctm, fill_ink, fill_space = stack.pop()
|
||||
except IndexError:
|
||||
# Keeping the ctm the same seems to be the only sensible thing
|
||||
# Keeping the state the same seems to be the only sensible thing
|
||||
# to do. Just pretend nothing happened, keep calm and carry on.
|
||||
warn("PDF graphics stack underflowed - PDF may be malformed")
|
||||
elif operator == 'cm':
|
||||
@@ -143,17 +205,51 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
"PDF content stream is corrupt - this PDF is malformed. "
|
||||
"Use a PDF editor that is capable of visually inspecting the PDF."
|
||||
) from e
|
||||
elif operator == 'g':
|
||||
if vals := _operand_floats(operands):
|
||||
fill_ink = _ink_from_components('gray', vals)
|
||||
fill_space = '/DeviceGray'
|
||||
elif operator == 'rg':
|
||||
if vals := _operand_floats(operands):
|
||||
fill_ink = _ink_from_components('rgb', vals)
|
||||
fill_space = '/DeviceRGB'
|
||||
elif operator == 'k':
|
||||
if vals := _operand_floats(operands):
|
||||
fill_ink = _ink_from_components('cmyk', vals)
|
||||
fill_space = '/DeviceCMYK'
|
||||
elif operator == 'cs':
|
||||
# Selecting a colorspace resets the fill color to that space's
|
||||
# initial value, which is black for all device colorspaces.
|
||||
fill_ink = Ink.mono
|
||||
if operands:
|
||||
fill_space = str(operands[0])
|
||||
elif operator in ('sc', 'scn'):
|
||||
if any(isinstance(o, Name) for o in operands):
|
||||
fill_ink = Ink.color # pattern fill
|
||||
else:
|
||||
space = _DEVICE_FILL_SPACE.get(fill_space)
|
||||
vals = _operand_floats(operands)
|
||||
if space is None or vals is None:
|
||||
fill_ink = Ink.color # conservative for non-device space
|
||||
else:
|
||||
fill_ink = _ink_from_components(space, vals)
|
||||
elif operator == 'Do':
|
||||
image_name = operands[0]
|
||||
settings = XobjectSettings(
|
||||
name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack)
|
||||
name=image_name,
|
||||
shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack),
|
||||
fill_ink=fill_ink,
|
||||
)
|
||||
xobject_settings.append(settings)
|
||||
name_index[str(image_name)].append(settings)
|
||||
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
|
||||
iimage = operands[0]
|
||||
inline = InlineSettings(
|
||||
iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack)
|
||||
iimage=iimage,
|
||||
shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack),
|
||||
fill_ink=fill_ink,
|
||||
)
|
||||
inline_images.append(inline)
|
||||
elif operator in vector_ops:
|
||||
|
||||
@@ -36,6 +36,7 @@ from ocrmypdf.pdfinfo._types import (
|
||||
UNIT_SQUARE,
|
||||
Colorspace,
|
||||
Encoding,
|
||||
Ink,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
@@ -61,10 +62,12 @@ class ImageInfo:
|
||||
pdfimage: Object | None = None,
|
||||
inline: PdfInlineImage | None = None,
|
||||
shorthand=None,
|
||||
fill_ink: Ink | None = None,
|
||||
):
|
||||
"""Initialize an ImageInfo."""
|
||||
self._name = str(name)
|
||||
self._shorthand = shorthand
|
||||
self._fill_ink = fill_ink
|
||||
|
||||
pim: PdfInlineImage | PdfImage
|
||||
|
||||
@@ -175,6 +178,17 @@ class ImageInfo:
|
||||
"""Type of image, either 'image' or 'stencil'."""
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def ink(self) -> Ink | None:
|
||||
"""Fill-color classification for stencil masks, else None.
|
||||
|
||||
A stencil (image mask) is painted with the current fill color; this
|
||||
reports whether that color is mono/gray/color so the rasterizer can
|
||||
choose a device that does not discard the distinction. Non-stencil
|
||||
images return None.
|
||||
"""
|
||||
return self._fill_ink if self._type == 'stencil' else None
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Width of the image in pixels."""
|
||||
@@ -249,7 +263,10 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
||||
"""Find inline images in the contentstream."""
|
||||
for n, inline in enumerate(contentsinfo.inline_images):
|
||||
yield ImageInfo(
|
||||
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
||||
name=f'inline-{n:02d}',
|
||||
shorthand=inline.shorthand,
|
||||
inline=inline.iimage,
|
||||
fill_ink=inline.fill_ink,
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +317,12 @@ def _find_regular_images(
|
||||
# these from our DPI calculation for the page.
|
||||
continue
|
||||
|
||||
yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand)
|
||||
yield ImageInfo(
|
||||
name=draw.name,
|
||||
pdfimage=pdfimage,
|
||||
shorthand=draw.shorthand,
|
||||
fill_ink=draw.fill_ink,
|
||||
)
|
||||
|
||||
|
||||
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
||||
@@ -330,13 +352,19 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
||||
# but in practice both Form XObjects and multiple drawing of the
|
||||
# same object are both very rare.
|
||||
ctm_shorthand = settings.shorthand
|
||||
# A Form XObject inherits the graphics state (including fill color)
|
||||
# in effect at the Do that draws it, so a mask painted with an
|
||||
# inherited gray/color fill must carry that classification inward.
|
||||
yield from _process_content_streams(
|
||||
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand
|
||||
pdf=pdf,
|
||||
container=form_xobject,
|
||||
shorthand=ctm_shorthand,
|
||||
initial_fill_ink=settings.fill_ink,
|
||||
)
|
||||
|
||||
|
||||
def _process_content_streams(
|
||||
*, pdf: Pdf, container: Object, shorthand=None
|
||||
*, pdf: Pdf, container: Object, shorthand=None, initial_fill_ink=Ink.mono
|
||||
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
||||
"""Find all individual instances of images drawn in the container.
|
||||
|
||||
@@ -377,7 +405,7 @@ def _process_content_streams(
|
||||
else:
|
||||
return
|
||||
|
||||
contentsinfo = _interpret_contents(container, initial_shorthand)
|
||||
contentsinfo = _interpret_contents(container, initial_shorthand, initial_fill_ink)
|
||||
|
||||
if contentsinfo.found_vector:
|
||||
yield VectorMarker()
|
||||
|
||||
@@ -39,6 +39,20 @@ class Encoding(Enum):
|
||||
flate_jpeg = auto()
|
||||
|
||||
|
||||
class Ink(Enum):
|
||||
"""Classification of the fill color used to paint a stencil image mask.
|
||||
|
||||
A stencil (image mask) is painted with the current fill color, so the
|
||||
color depth needed to rasterize it for OCR depends on that fill color,
|
||||
not on the mask's 1-bit data.
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
mono = auto() # black (or no color information to preserve)
|
||||
gray = auto() # achromatic but not pure black
|
||||
color = auto() # chromatic, or a fill we cannot prove is achromatic
|
||||
|
||||
|
||||
FloatRect = tuple[float, float, float, float]
|
||||
|
||||
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
|
||||
|
||||
@@ -19,6 +19,7 @@ from pdfminer.layout import LTPage, LTTextBox
|
||||
from pikepdf import Name, Page, Pdf
|
||||
|
||||
from ocrmypdf._concurrent import Executor, SerialExecutor
|
||||
from ocrmypdf._pageboxes import coerce_box
|
||||
from ocrmypdf.exceptions import EncryptedPdfError
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker
|
||||
@@ -34,6 +35,12 @@ from ocrmypdf.pdfinfo.layout import (
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def _box_rect(values: Iterable) -> FloatRect:
|
||||
"""Coerce a page box to a normalized ``FloatRect`` (4-tuple)."""
|
||||
b = coerce_box(values)
|
||||
return (b[0], b[1], b[2], b[3])
|
||||
|
||||
|
||||
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
|
||||
"""Smarter text detection that ignores text in margins."""
|
||||
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
||||
@@ -140,15 +147,15 @@ class PageInfo:
|
||||
miner_state: PdfMinerState | None,
|
||||
):
|
||||
page: Page = pdf.pages[pageno]
|
||||
mediabox = [Decimal(d) for d in page.mediabox.as_list()]
|
||||
mediabox = [Decimal(str(d)) for d in coerce_box(page.mediabox.as_list())]
|
||||
width_pt = mediabox[2] - mediabox[0]
|
||||
height_pt = mediabox[3] - mediabox[1]
|
||||
|
||||
self._artbox = [float(d) for d in page.artbox.as_list()]
|
||||
self._bleedbox = [float(d) for d in page.bleedbox.as_list()]
|
||||
self._cropbox = [float(d) for d in page.cropbox.as_list()]
|
||||
self._mediabox = [float(d) for d in page.mediabox.as_list()]
|
||||
self._trimbox = [float(d) for d in page.trimbox.as_list()]
|
||||
self._artbox = _box_rect(page.artbox.as_list())
|
||||
self._bleedbox = _box_rect(page.bleedbox.as_list())
|
||||
self._cropbox = _box_rect(page.cropbox.as_list())
|
||||
self._mediabox = _box_rect(page.mediabox.as_list())
|
||||
self._trimbox = _box_rect(page.trimbox.as_list())
|
||||
|
||||
check_this_page = pageno in check_pages
|
||||
|
||||
@@ -398,6 +405,7 @@ class PdfInfo:
|
||||
_has_acroform: bool = False
|
||||
_has_signature: bool = False
|
||||
_needs_rendering: bool = False
|
||||
_has_structure_tree: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -449,6 +457,7 @@ class PdfInfo:
|
||||
self._is_tagged = bool(
|
||||
pdf.Root.get(Name.MarkInfo, {}).get(Name.Marked, False)
|
||||
)
|
||||
self._has_structure_tree = Name.StructTreeRoot in pdf.Root
|
||||
|
||||
@property
|
||||
def pages(self) -> list[PageInfo | None]:
|
||||
@@ -481,6 +490,11 @@ class PdfInfo:
|
||||
"""Return True if the document catalog indicates this is a Tagged PDF."""
|
||||
return self._is_tagged
|
||||
|
||||
@property
|
||||
def has_structure_tree(self) -> bool:
|
||||
"""Return True if the document catalog has a logical structure tree."""
|
||||
return self._has_structure_tree
|
||||
|
||||
@property
|
||||
def filename(self) -> str | Path:
|
||||
"""Return filename of PDF."""
|
||||
|
||||
@@ -38,6 +38,7 @@ class GhostscriptRasterDevice(StrEnum):
|
||||
JPEGGRAY = 'jpeggray'
|
||||
JPEGCOLOR = 'jpeg'
|
||||
PNGMONO = 'pngmono'
|
||||
PNGMONOD = 'pngmonod'
|
||||
PNGGRAY = 'pnggray'
|
||||
PNG256 = 'png256'
|
||||
PNG16M = 'png16m'
|
||||
|
||||
+198
-1
@@ -17,7 +17,11 @@ from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from ocrmypdf._exec import ghostscript
|
||||
from ocrmypdf._exec.ghostscript import DuplicateFilter, rasterize_pdf
|
||||
from ocrmypdf.builtin_plugins.ghostscript import _repair_gs106_jpeg_corruption
|
||||
from ocrmypdf.builtin_plugins.ghostscript import (
|
||||
PdfaImageCompression,
|
||||
_repair_gs106_jpeg_corruption,
|
||||
_resolve_auto_compression,
|
||||
)
|
||||
from ocrmypdf.exceptions import ColorConversionNeededError, ExitCode, InputFileError
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||
@@ -137,6 +141,62 @@ def test_rasterize_low_dpi_one_axis(francais, outdir):
|
||||
assert im.info['dpi'] == forced_dpi
|
||||
|
||||
|
||||
def _capture_rasterize_args(resources, outdir, raster_device):
|
||||
"""Run rasterize_pdf with the gs subprocess mocked; return the gs argv."""
|
||||
out = outdir / 'out.png'
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
captured['args'] = list(args)
|
||||
# Produce a valid PNG so rasterize_pdf's post-processing succeeds.
|
||||
Image.new('RGB', (2, 2)).save(out)
|
||||
return subprocess.CompletedProcess(args, returncode=0, stdout=b'', stderr=b'')
|
||||
|
||||
with patch('ocrmypdf._exec.ghostscript.run', side_effect=fake_run):
|
||||
rasterize_pdf(
|
||||
resources / 'francais.pdf',
|
||||
out,
|
||||
raster_device=raster_device,
|
||||
raster_dpi=Resolution(150.0, 150.0),
|
||||
)
|
||||
return captured['args']
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'raster_device',
|
||||
[
|
||||
GhostscriptRasterDevice.PNGGRAY,
|
||||
GhostscriptRasterDevice.PNG256,
|
||||
GhostscriptRasterDevice.PNG16M,
|
||||
],
|
||||
)
|
||||
def test_rasterize_antialiases_contone_devices(resources, outdir, raster_device):
|
||||
"""Contone raster devices receive anti-aliasing flags to aid OCR.
|
||||
|
||||
Ghostscript 10.x renders aliased glyphs that OCR misreads as extra word
|
||||
breaks; -dTextAlphaBits/-dGraphicsAlphaBits markedly improve accuracy,
|
||||
especially for small fonts at moderate DPI (see issue #1439).
|
||||
"""
|
||||
args = _capture_rasterize_args(resources, outdir, raster_device)
|
||||
assert '-dTextAlphaBits=4' in args
|
||||
assert '-dGraphicsAlphaBits=4' in args
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'raster_device',
|
||||
[GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD],
|
||||
)
|
||||
def test_rasterize_no_antialias_on_mono_devices(resources, outdir, raster_device):
|
||||
"""1-bit mono devices must not receive alpha-bit flags.
|
||||
|
||||
Older Ghostscript versions reject -dTextAlphaBits on 1-bit devices, and
|
||||
pngmonod performs its own anti-aliased downscaling.
|
||||
"""
|
||||
args = _capture_rasterize_args(resources, outdir, raster_device)
|
||||
assert not any(a.startswith('-dTextAlphaBits') for a in args)
|
||||
assert not any(a.startswith('-dGraphicsAlphaBits') for a in args)
|
||||
|
||||
|
||||
def test_generate_pdfa_default_jpeg_quality(outdir):
|
||||
"""When jpeg_quality is None, Ghostscript receives -dJPEGQ=95 (default)."""
|
||||
with (
|
||||
@@ -327,6 +387,88 @@ def test_ghostscript_mandatory_color_conversion(resources, outpdf):
|
||||
)
|
||||
|
||||
|
||||
def _run_generate_pdfa_with_devicen_warning(outdir, color_conversion_strategy):
|
||||
"""Invoke generate_pdfa with Ghostscript mocked to emit the DeviceN warning.
|
||||
|
||||
Ghostscript emits this warning when it writes a DeviceN colorspace with an
|
||||
inappropriate alternate, i.e. when it could not normalize the colorspace for
|
||||
PDF/A. The output is then liable to render blank in viewers such as Adobe
|
||||
Reader (see issue #1187), regardless of which conversion strategy was
|
||||
requested.
|
||||
"""
|
||||
(outdir / 'input.pdf').write_bytes(b'%PDF-1.5\n%fake\n')
|
||||
with (
|
||||
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||
):
|
||||
run_mock.return_value = subprocess.CompletedProcess(
|
||||
['gs'],
|
||||
returncode=0,
|
||||
stdout='',
|
||||
stderr='Attempting to write a DeviceN space with an inappropriate '
|
||||
'alternate, reverting to the alternate color space.',
|
||||
)
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_pages=[outdir / 'input.pdf'],
|
||||
output_file=outdir / 'out.pdf',
|
||||
compression='auto',
|
||||
color_conversion_strategy=color_conversion_strategy,
|
||||
)
|
||||
|
||||
|
||||
def test_devicen_warning_default_strategy_raises_with_guidance(outdir):
|
||||
"""Default (no conversion): raise and tell the user to pick a strategy."""
|
||||
with pytest.raises(ColorConversionNeededError) as exc_info:
|
||||
_run_generate_pdfa_with_devicen_warning(outdir, 'LeaveColorUnchanged')
|
||||
message = str(exc_info.value)
|
||||
assert '--color-conversion-strategy' in message
|
||||
assert 'RGB' in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'strategy',
|
||||
[
|
||||
# A strategy that genuinely cannot fix the colorspace; confirmed in #1187.
|
||||
'UseDeviceIndependentColor',
|
||||
# A normally-effective strategy that nonetheless failed on this input:
|
||||
# if Ghostscript still warns, the output is still broken and we must not
|
||||
# silently pass it through (the behaviour PR #1692 would have introduced).
|
||||
'RGB',
|
||||
],
|
||||
)
|
||||
def test_devicen_warning_persists_despite_strategy_still_raises(outdir, strategy):
|
||||
"""If the warning survives the requested conversion, the output is broken.
|
||||
|
||||
We must still raise rather than silently emit a PDF/A that may render blank.
|
||||
The guidance should acknowledge that the chosen strategy did not work and
|
||||
point at strategies that do (or --output-type pdf).
|
||||
"""
|
||||
with pytest.raises(ColorConversionNeededError) as exc_info:
|
||||
_run_generate_pdfa_with_devicen_warning(outdir, strategy)
|
||||
message = str(exc_info.value)
|
||||
assert strategy in message
|
||||
assert '--output-type pdf' in message
|
||||
|
||||
|
||||
def test_no_devicen_warning_does_not_raise(outdir):
|
||||
"""When Ghostscript does not warn, conversion succeeded; never raise."""
|
||||
(outdir / 'input.pdf').write_bytes(b'%PDF-1.5\n%fake\n')
|
||||
with (
|
||||
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||
):
|
||||
run_mock.return_value = subprocess.CompletedProcess(
|
||||
['gs'], returncode=0, stdout='', stderr=''
|
||||
)
|
||||
# Must not raise for any strategy when there is no DeviceN warning.
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_pages=[outdir / 'input.pdf'],
|
||||
output_file=outdir / 'out.pdf',
|
||||
compression='auto',
|
||||
color_conversion_strategy='RGB',
|
||||
)
|
||||
|
||||
|
||||
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
||||
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
||||
# ghostscript can produce empty files with return code 0
|
||||
@@ -593,3 +735,58 @@ class TestGs106JpegCorruptionRepair:
|
||||
repaired = _repair_gs106_jpeg_corruption(source_path, damaged_path)
|
||||
assert repaired is False, "Should not repair truncation > 15 bytes"
|
||||
assert "JPEG corruption detected" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('compression', 'optimize', 'expected'),
|
||||
[
|
||||
# auto coerces to lossless only at -O0; -O1 is a historical exception
|
||||
# that keeps Ghostscript's (possibly lossy) heuristic, as do -O2/-O3
|
||||
(PdfaImageCompression.AUTO, 0, PdfaImageCompression.LOSSLESS),
|
||||
(PdfaImageCompression.AUTO, 1, PdfaImageCompression.AUTO),
|
||||
(PdfaImageCompression.AUTO, 2, PdfaImageCompression.AUTO),
|
||||
(PdfaImageCompression.AUTO, 3, PdfaImageCompression.AUTO),
|
||||
# explicit choices are always respected, regardless of optimize level
|
||||
(PdfaImageCompression.JPEG, 0, PdfaImageCompression.JPEG),
|
||||
(PdfaImageCompression.JPEG, 1, PdfaImageCompression.JPEG),
|
||||
(PdfaImageCompression.LOSSLESS, 1, PdfaImageCompression.LOSSLESS),
|
||||
(PdfaImageCompression.LOSSLESS, 3, PdfaImageCompression.LOSSLESS),
|
||||
],
|
||||
)
|
||||
def test_resolve_auto_compression(compression, optimize, expected):
|
||||
assert _resolve_auto_compression(compression, optimize) == expected
|
||||
|
||||
|
||||
def _capture_generate_pdfa_args(tmp_path, compression):
|
||||
"""Run generate_pdfa with a mocked Ghostscript and return the argv it built."""
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
captured['args'] = list(args)
|
||||
return CompletedProcess(args, 0, None, stderr='')
|
||||
|
||||
out = tmp_path / 'out.pdf'
|
||||
with patch('ocrmypdf._exec.ghostscript.run_polling_stderr', side_effect=fake_run):
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_pages=['dummy.pdf'],
|
||||
output_file=out,
|
||||
compression=compression,
|
||||
color_conversion_strategy='RGB',
|
||||
)
|
||||
return captured['args']
|
||||
|
||||
|
||||
def test_lossless_compression_passes_through_jpegs(tmp_path):
|
||||
# Re-encoding an existing JPEG losslessly only bloats it (the lossy data is
|
||||
# already baked in), so lossless mode must let Ghostscript pass JPEGs through
|
||||
# untouched while still keeping lossless images lossless.
|
||||
args = _capture_generate_pdfa_args(tmp_path, 'lossless')
|
||||
assert '-dPassThroughJPEGImages=true' in args
|
||||
assert '-dColorImageFilter=/FlateEncode' in args
|
||||
|
||||
|
||||
def test_jpeg_compression_does_not_force_passthrough(tmp_path):
|
||||
args = _capture_generate_pdfa_args(tmp_path, 'jpeg')
|
||||
assert '-dPassThroughJPEGImages=true' not in args
|
||||
|
||||
+38
-12
@@ -197,14 +197,14 @@ def test_optimize_off(resources, outpdf):
|
||||
def test_group3(resources):
|
||||
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
||||
im = pdf.pages[0].Resources.XObject['/Im1']
|
||||
assert (
|
||||
opt.extract_image_filter(im, im.objgen[0]) is not None
|
||||
), "Group 4 should be allowed"
|
||||
assert opt.extract_image_filter(im, im.objgen[0]) is not None, (
|
||||
"Group 4 should be allowed"
|
||||
)
|
||||
|
||||
im.DecodeParms['/K'] = 0
|
||||
assert (
|
||||
opt.extract_image_filter(im, im.objgen[0]) is None
|
||||
), "Group 3 should be disallowed"
|
||||
assert opt.extract_image_filter(im, im.objgen[0]) is None, (
|
||||
"Group 3 should be disallowed"
|
||||
)
|
||||
|
||||
|
||||
def test_find_formx(resources):
|
||||
@@ -234,9 +234,7 @@ def test_find_formx_circular_reference(resources, tmp_path, caplog):
|
||||
# entries that all point back to /Form1 itself, creating a fan-out
|
||||
# cycle of branching factor 3.
|
||||
form = pdf.pages[0].obj.Resources.XObject.Form1
|
||||
form.Resources.XObject = Dictionary(
|
||||
{'/Fm0': form, '/Fm1': form, '/Fm2': form}
|
||||
)
|
||||
form.Resources.XObject = Dictionary({'/Fm0': form, '/Fm1': form, '/Fm2': form})
|
||||
pdf.save(out)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger='ocrmypdf.optimize')
|
||||
@@ -244,9 +242,7 @@ def test_find_formx_circular_reference(resources, tmp_path, caplog):
|
||||
opt._find_image_xrefs(pdf)
|
||||
|
||||
n_warnings = sum(
|
||||
1
|
||||
for r in caplog.records
|
||||
if 'Recursion depth exceeded' in r.getMessage()
|
||||
1 for r in caplog.records if 'Recursion depth exceeded' in r.getMessage()
|
||||
)
|
||||
# Without the fix this is in the tens of thousands.
|
||||
assert n_warnings == 0, (
|
||||
@@ -255,6 +251,36 @@ def test_find_formx_circular_reference(resources, tmp_path, caplog):
|
||||
)
|
||||
|
||||
|
||||
def test_extract_images_traps_errors_as_warning(resources, tmp_path, caplog):
|
||||
"""Regression for issue #846.
|
||||
|
||||
The optimizer is best-effort: any image it cannot process can simply be
|
||||
passed through unchanged. When extraction of an image raises (e.g. an
|
||||
exotic colorspace pikepdf cannot transcode), the user should see a concise
|
||||
warning that the image was left unchanged, not an alarming traceback
|
||||
logged at ERROR level.
|
||||
"""
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
def boom(*, pdf, root, image, xref, options):
|
||||
raise NotImplementedError("synthetic extraction failure")
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger='ocrmypdf.optimize')
|
||||
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
||||
results = list(opt.extract_images(pdf, tmp_path, Mock(), boom))
|
||||
|
||||
# The error is trapped, not propagated, and nothing is extracted.
|
||||
assert results == []
|
||||
# A friendly warning is emitted...
|
||||
assert any(
|
||||
r.levelno == logging.WARNING and 'left unchanged' in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
# ...and no traceback is logged at ERROR level or above.
|
||||
assert not any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
|
||||
def test_extract_image_filter_with_pdf_image():
|
||||
image = Dictionary()
|
||||
image.Subtype = Name.Image
|
||||
|
||||
@@ -7,6 +7,7 @@ import pikepdf
|
||||
import pytest
|
||||
|
||||
from ocrmypdf._exec import verapdf
|
||||
from ocrmypdf._pageboxes import repair_page_boxes
|
||||
|
||||
from .conftest import check_ocrmypdf
|
||||
|
||||
@@ -127,3 +128,147 @@ def test_crop_box(
|
||||
with pikepdf.open(outdir / 'processed.pdf') as pdf:
|
||||
page = pdf.pages[0]
|
||||
assert [float(x) for x in page.cropbox] == crop_expected
|
||||
|
||||
|
||||
# --- Unit tests for repair_page_boxes (issues #1398, #1526, #1400) ---
|
||||
|
||||
|
||||
def _is_numeric(obj) -> bool:
|
||||
try:
|
||||
float(obj)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _one_page_pdf(**boxes):
|
||||
"""Build a one-page PDF, setting the named boxes to the given arrays."""
|
||||
pdf = pikepdf.new()
|
||||
page = pdf.add_blank_page(page_size=(612, 792))
|
||||
for name, rect in boxes.items():
|
||||
setattr(page.obj, name, pikepdf.Array(rect))
|
||||
return pdf, page
|
||||
|
||||
|
||||
def test_repair_reversed_mediabox_is_normalized():
|
||||
# #1526: diagonally-opposite corners given in reversed order
|
||||
_pdf, page = _one_page_pdf(MediaBox=[0, 792, 612, 0])
|
||||
repairs = repair_page_boxes(page)
|
||||
assert [float(x) for x in page.obj.MediaBox] == [0, 0, 612, 792]
|
||||
assert any(r.box == 'MediaBox' and r.kind == 'reordered' for r in repairs)
|
||||
|
||||
|
||||
def test_repair_cropbox_entirely_outside_mediabox_is_discarded():
|
||||
# #1400: CropBox lies entirely outside the MediaBox -> empty intersection
|
||||
_pdf, page = _one_page_pdf(
|
||||
MediaBox=[0, 0, 612, 792], CropBox=[1000, 1000, 1500, 1500]
|
||||
)
|
||||
repairs = repair_page_boxes(page)
|
||||
assert '/CropBox' not in page.obj
|
||||
assert any(r.box == 'CropBox' and r.kind == 'discarded' for r in repairs)
|
||||
|
||||
|
||||
def test_repair_cropbox_partially_outside_mediabox_is_clamped():
|
||||
_pdf, page = _one_page_pdf(MediaBox=[0, 0, 612, 792], CropBox=[200, 200, 800, 900])
|
||||
repairs = repair_page_boxes(page)
|
||||
assert [float(x) for x in page.obj.CropBox] == [200, 200, 612, 792]
|
||||
assert any(r.box == 'CropBox' and r.kind == 'clamped' for r in repairs)
|
||||
|
||||
|
||||
def test_repair_exponential_coordinate_is_coerced():
|
||||
# #1398: a coordinate stored as a string in exponential notation
|
||||
_pdf, page = _one_page_pdf(
|
||||
MediaBox=[0, 0, 612, 792],
|
||||
TrimBox=[pikepdf.String('3.05175781e-005'), 0, 612, 792],
|
||||
)
|
||||
repairs = repair_page_boxes(page)
|
||||
trim = page.obj.TrimBox
|
||||
assert all(_is_numeric(x) for x in trim)
|
||||
assert float(trim[0]) == pytest.approx(3.05175781e-5, abs=1e-4)
|
||||
assert any(r.box == 'TrimBox' and r.kind == 'recoded' for r in repairs)
|
||||
|
||||
|
||||
def test_repair_degenerate_mediabox_is_reported():
|
||||
_pdf, page = _one_page_pdf(MediaBox=[0, 0, 0, 792]) # zero width
|
||||
repairs = repair_page_boxes(page)
|
||||
assert any(r.box == 'MediaBox' and r.kind == 'degenerate_mediabox' for r in repairs)
|
||||
|
||||
|
||||
def test_repair_valid_page_makes_no_changes():
|
||||
_pdf, page = _one_page_pdf(MediaBox=[0, 0, 612, 792], CropBox=[10, 10, 600, 780])
|
||||
repairs = repair_page_boxes(page)
|
||||
assert repairs == []
|
||||
assert [float(x) for x in page.obj.MediaBox] == [0, 0, 612, 792]
|
||||
assert [float(x) for x in page.obj.CropBox] == [10, 10, 600, 780]
|
||||
|
||||
|
||||
def test_summarize_box_repairs_aggregates_and_sets_severity():
|
||||
import logging
|
||||
|
||||
from ocrmypdf._pageboxes import BoxRepair, summarize_box_repairs
|
||||
|
||||
repairs_by_page = {
|
||||
0: [BoxRepair('CropBox', 'discarded')],
|
||||
2: [BoxRepair('CropBox', 'discarded')],
|
||||
3: [BoxRepair('CropBox', 'discarded')],
|
||||
1: [BoxRepair('MediaBox', 'reordered')],
|
||||
}
|
||||
messages = summarize_box_repairs(repairs_by_page)
|
||||
|
||||
discard = [(lvl, m) for lvl, m in messages if 'discarded' in m]
|
||||
assert len(discard) == 1
|
||||
level, text = discard[0]
|
||||
assert level == logging.WARNING
|
||||
assert 'Page(s) 1, 3-4' in text # 0-based keys shown 1-based, ranges compacted
|
||||
assert 'visually inspect' in text
|
||||
|
||||
reordered = [(lvl, m) for lvl, m in messages if 'reversed' in m]
|
||||
assert len(reordered) == 1
|
||||
assert reordered[0][0] == logging.DEBUG
|
||||
assert 'visually inspect' not in reordered[0][1]
|
||||
|
||||
|
||||
def test_cropbox_outside_mediabox_yields_valid_output(resources, outdir):
|
||||
# #1400: a CropBox entirely outside the MediaBox produces an effective
|
||||
# page of N x 0 pt; the pipeline must repair it to valid output.
|
||||
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
||||
page = pdf.pages[0]
|
||||
mb = [float(x) for x in page.mediabox]
|
||||
page.CropBox = [mb[2] + 100, mb[3] + 100, mb[2] + 200, mb[3] + 200]
|
||||
pdf.save(outdir / 'badcrop.pdf')
|
||||
|
||||
check_ocrmypdf(
|
||||
outdir / 'badcrop.pdf',
|
||||
outdir / 'out.pdf',
|
||||
'--output-type',
|
||||
'pdf',
|
||||
'--optimize',
|
||||
'0',
|
||||
)
|
||||
|
||||
with pikepdf.open(outdir / 'out.pdf') as pdf:
|
||||
cb = [float(x) for x in pdf.pages[0].cropbox] # resolves to MediaBox
|
||||
assert (cb[2] - cb[0]) > 0 and (cb[3] - cb[1]) > 0
|
||||
|
||||
|
||||
def test_reversed_mediabox_does_not_crash(resources, outdir):
|
||||
# #1526: reversed MediaBox corners previously raised NegativeDimensionError.
|
||||
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
||||
page = pdf.pages[0]
|
||||
mb = [float(x) for x in page.mediabox]
|
||||
page.MediaBox = [mb[0], mb[3], mb[2], mb[1]] # swap y corners
|
||||
pdf.save(outdir / 'reversed.pdf')
|
||||
|
||||
check_ocrmypdf(
|
||||
outdir / 'reversed.pdf',
|
||||
outdir / 'out.pdf',
|
||||
'--force-ocr',
|
||||
'--output-type',
|
||||
'pdf',
|
||||
'--optimize',
|
||||
'0',
|
||||
)
|
||||
|
||||
with pikepdf.open(outdir / 'out.pdf') as pdf:
|
||||
mb = [float(x) for x in pdf.pages[0].mediabox]
|
||||
assert (mb[2] - mb[0]) > 0 and (mb[3] - mb[1]) > 0
|
||||
|
||||
+191
-2
@@ -18,8 +18,8 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from ocrmypdf import pdfinfo
|
||||
from ocrmypdf.exceptions import InputFileError
|
||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding
|
||||
from ocrmypdf.pdfinfo._contentstream import _interpret_contents
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, Ink
|
||||
from ocrmypdf.pdfinfo._contentstream import _ink_from_components, _interpret_contents
|
||||
from ocrmypdf.pdfinfo.layout import PDFPage
|
||||
|
||||
warnings.filterwarnings(
|
||||
@@ -290,3 +290,192 @@ def test_image_scale0(image_scale0):
|
||||
)
|
||||
assert not pi.pages[0]._images[0].dpi.is_finite
|
||||
assert pi.pages[0].dpi == Resolution(0, 0)
|
||||
|
||||
|
||||
def test_ink_enum_is_picklable():
|
||||
# ImageInfo crosses the worker-process boundary, so Ink must pickle.
|
||||
for member in (Ink.mono, Ink.gray, Ink.color):
|
||||
assert pickle.loads(pickle.dumps(member)) is member
|
||||
|
||||
|
||||
def test_pngmonod_device_exists():
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||
|
||||
assert GhostscriptRasterDevice.PNGMONOD == 'pngmonod'
|
||||
# PNGMONO retained for compatibility / explicit use
|
||||
assert GhostscriptRasterDevice.PNGMONO == 'pngmono'
|
||||
|
||||
|
||||
def _ink_of_first_xobject(body: bytes):
|
||||
from ocrmypdf.pdfinfo._contentstream import _interpret_contents
|
||||
|
||||
p = pikepdf.Pdf.new()
|
||||
stream = pikepdf.Stream(p, body)
|
||||
info = _interpret_contents(stream)
|
||||
return info.xobject_settings[0].fill_ink
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body, expected",
|
||||
[
|
||||
(b"/Im0 Do", 'mono'), # default fill is black
|
||||
(b"0.263 0.263 0.263 rg /Im0 Do", 'gray'),
|
||||
(b"0.5 g /Im0 Do", 'gray'),
|
||||
(b"0 g /Im0 Do", 'mono'),
|
||||
(b"0.8 0.2 0.2 rg /Im0 Do", 'color'),
|
||||
(b"0 0 0 0.5 k /Im0 Do", 'gray'),
|
||||
(b"0.5 0.1 0 0 k /Im0 Do", 'color'),
|
||||
],
|
||||
)
|
||||
def test_fill_ink_tracked_per_draw(body, expected):
|
||||
assert _ink_of_first_xobject(body) is Ink[expected]
|
||||
|
||||
|
||||
def test_fill_ink_non_device_colorspace_is_color():
|
||||
# cs to a non-device colorspace then scn -> conservative color
|
||||
assert _ink_of_first_xobject(b"/CS0 cs 0.4 scn /Im0 Do") is Ink.color
|
||||
|
||||
|
||||
def test_fill_ink_pattern_scn_is_color():
|
||||
assert _ink_of_first_xobject(b"/Pattern cs /P0 scn /Im0 Do") is Ink.color
|
||||
|
||||
|
||||
def test_fill_ink_respects_graphics_stack():
|
||||
# Set red, save, set gray, restore -> red again at the Do
|
||||
assert _ink_of_first_xobject(b"0.8 0.1 0.1 rg q 0.5 g Q /Im0 Do") is Ink.color
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
b"g /Im0 Do", # g with no operand
|
||||
b"/Foo g /Im0 Do", # g with a non-numeric operand
|
||||
b"cs /Im0 Do", # cs with no operand
|
||||
b"0.5 /Foo k /Im0 Do", # k with a non-numeric operand
|
||||
b"/DeviceRGB cs /Foo 0.5 scn /Im0 Do", # scn with mixed bad operands
|
||||
],
|
||||
)
|
||||
def test_fill_ink_tolerates_malformed_color_operands(body):
|
||||
# Malformed color operators must not crash the interpreter; they leave the
|
||||
# fill state at its prior value (default mono) or fall back conservatively.
|
||||
assert _ink_of_first_xobject(body) in (Ink.mono, Ink.color)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"space, comps, expected",
|
||||
[
|
||||
('gray', [0.0], 'mono'),
|
||||
('gray', [0.263], 'gray'),
|
||||
('gray', [1.0], 'gray'), # white -> gray (harmless)
|
||||
('rgb', [0.0, 0.0, 0.0], 'mono'),
|
||||
('rgb', [0.263, 0.263, 0.263], 'gray'),
|
||||
('rgb', [0.8, 0.2, 0.2], 'color'),
|
||||
('rgb', [1.0, 1.0, 1.0], 'gray'),
|
||||
('cmyk', [0.0, 0.0, 0.0, 0.0], 'mono'), # white
|
||||
('cmyk', [0.0, 0.0, 0.0, 0.5], 'gray'),
|
||||
('cmyk', [0.5, 0.1, 0.0, 0.0], 'color'),
|
||||
('unknown', [0.5], 'color'), # conservative fallback
|
||||
],
|
||||
)
|
||||
def test_ink_from_components(space, comps, expected):
|
||||
assert _ink_from_components(space, comps) is Ink[expected]
|
||||
|
||||
|
||||
def _make_image_mask_pdf(path, content_fill: bytes):
|
||||
"""Build a 1-page PDF with one 8x8 image mask painted with content_fill.
|
||||
|
||||
content_fill is the color operator sequence emitted before drawing the
|
||||
mask, e.g. b"0.263 0.263 0.263 rg".
|
||||
"""
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(72, 72))
|
||||
# 8x8 1-bpc mask, each row padded to a byte (1 byte per row).
|
||||
mask_bytes = bytes([0x7E] * 8)
|
||||
mask = pikepdf.Stream(pdf, mask_bytes)
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = 8
|
||||
mask.Height = 8
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"q 72 0 0 72 0 0 cm %s %s Do Q" % (content_fill, bytes(name))
|
||||
)
|
||||
pdf.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mask_gray_pdf(outdir):
|
||||
return _make_image_mask_pdf(outdir / 'mask_gray.pdf', b"0.263 0.263 0.263 rg")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mask_rgb_pdf(outdir):
|
||||
return _make_image_mask_pdf(outdir / 'mask_rgb.pdf', b"0.8 0.2 0.2 rg")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mask_black_pdf(outdir):
|
||||
return _make_image_mask_pdf(outdir / 'mask_black.pdf', b"0 g")
|
||||
|
||||
|
||||
def test_imageinfo_ink_gray(mask_gray_pdf):
|
||||
image = pdfinfo.PdfInfo(mask_gray_pdf)[0].images[0]
|
||||
assert image.type_ == 'stencil'
|
||||
assert image.ink is Ink.gray
|
||||
|
||||
|
||||
def test_imageinfo_ink_color(mask_rgb_pdf):
|
||||
image = pdfinfo.PdfInfo(mask_rgb_pdf)[0].images[0]
|
||||
assert image.ink is Ink.color
|
||||
|
||||
|
||||
def test_imageinfo_ink_black(mask_black_pdf):
|
||||
image = pdfinfo.PdfInfo(mask_black_pdf)[0].images[0]
|
||||
assert image.ink is Ink.mono
|
||||
|
||||
|
||||
def test_imageinfo_ink_none_for_regular_image(eight_by_eight_regular_image):
|
||||
image = pdfinfo.PdfInfo(eight_by_eight_regular_image)[0].images[0]
|
||||
assert image.ink is None
|
||||
|
||||
|
||||
def test_fill_ink_cs_resets_color_to_black():
|
||||
# `cs` resets the fill color to the colorspace's initial value (black),
|
||||
# so a stale color set before `cs` must not leak to the drawn mask.
|
||||
assert _ink_of_first_xobject(b"0.8 0.2 0.2 rg /DeviceGray cs /Im0 Do") is Ink.mono
|
||||
|
||||
|
||||
def test_imageinfo_ink_inherited_in_form_xobject(outdir):
|
||||
# A mask drawn inside a Form XObject inherits the fill color set before the
|
||||
# Do that paints the form; the gray classification must reach the mask.
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(72, 72))
|
||||
|
||||
mask = pikepdf.Stream(pdf, bytes([0x7E] * 8))
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = 8
|
||||
mask.Height = 8
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
|
||||
# Form draws the mask with no color of its own, inheriting the caller's.
|
||||
form = pikepdf.Stream(pdf, b"q 72 0 0 72 0 0 cm /Im0 Do Q")
|
||||
form.Type = pikepdf.Name.XObject
|
||||
form.Subtype = pikepdf.Name.Form
|
||||
form.BBox = [0, 0, 72, 72]
|
||||
form.Resources = pikepdf.Dictionary(XObject=pikepdf.Dictionary(Im0=mask))
|
||||
|
||||
fname = pdf.pages[0].add_resource(form, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"0.263 0.263 0.263 rg %s Do" % bytes(fname)
|
||||
)
|
||||
out = outdir / 'form_mask.pdf'
|
||||
pdf.save(out)
|
||||
|
||||
image = pdfinfo.PdfInfo(out)[0].images[0]
|
||||
assert image.type_ == 'stencil'
|
||||
assert image.ink is Ink.gray
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import warnings
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from reportlab.lib.units import inch
|
||||
@@ -13,8 +14,10 @@ from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
|
||||
from ocrmypdf import _pipeline, pdfinfo
|
||||
from ocrmypdf._pipeline import _select_raster_device
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pdfinfo import Encoding
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval"
|
||||
@@ -176,3 +179,39 @@ def test_should_visible_page_image_use_jpg(encodings, expected):
|
||||
pageinfo = Mock()
|
||||
pageinfo.images = [Mock(enc=enc) for enc in encodings]
|
||||
assert _pipeline.should_visible_page_image_use_jpg(pageinfo) == expected
|
||||
|
||||
|
||||
def _make_image_mask_pdf(path, content_fill: bytes):
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(72, 72))
|
||||
mask = pikepdf.Stream(pdf, bytes([0x7E] * 8))
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = 8
|
||||
mask.Height = 8
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"q 72 0 0 72 0 0 cm %s %s Do Q" % (content_fill, bytes(name))
|
||||
)
|
||||
pdf.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_select_device_gray_mask(tmp_path):
|
||||
p = _make_image_mask_pdf(tmp_path / 'g.pdf', b"0.263 0.263 0.263 rg")
|
||||
pageinfo = pdfinfo.PdfInfo(p)[0]
|
||||
assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNGGRAY
|
||||
|
||||
|
||||
def test_select_device_color_mask(tmp_path):
|
||||
p = _make_image_mask_pdf(tmp_path / 'c.pdf', b"0.8 0.2 0.2 rg")
|
||||
pageinfo = pdfinfo.PdfInfo(p)[0]
|
||||
assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNG16M
|
||||
|
||||
|
||||
def test_select_device_black_mask_stays_mono(tmp_path):
|
||||
p = _make_image_mask_pdf(tmp_path / 'b.pdf', b"0 g")
|
||||
pageinfo = pdfinfo.PdfInfo(p)[0]
|
||||
assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNGMONOD
|
||||
|
||||
@@ -213,6 +213,95 @@ class TestRasterizerHookDirect:
|
||||
assert result == img
|
||||
assert img.exists()
|
||||
|
||||
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
||||
def test_pypdfium_pngmonod_produces_1bit(self, resources, tmp_path):
|
||||
"""Pngmonod is treated like pngmono by pypdfium: it yields a 1-bit PNG."""
|
||||
pm = get_plugin_manager([])
|
||||
options = OcrOptions(
|
||||
input_file=resources / 'graph.pdf',
|
||||
output_file=tmp_path / 'out.pdf',
|
||||
rasterizer='pypdfium',
|
||||
)
|
||||
|
||||
img = tmp_path / 'pngmonod_test.png'
|
||||
result = pm.rasterize_pdf_page(
|
||||
input_file=resources / 'graph.pdf',
|
||||
output_file=img,
|
||||
raster_device='pngmonod',
|
||||
raster_dpi=Resolution(50, 50),
|
||||
page_dpi=Resolution(50, 50),
|
||||
pageno=1,
|
||||
rotation=0,
|
||||
filter_vector=False,
|
||||
stop_on_soft_error=True,
|
||||
options=options,
|
||||
use_cropbox=False,
|
||||
)
|
||||
assert result == img
|
||||
with Image.open(img) as im:
|
||||
assert im.mode == '1'
|
||||
|
||||
|
||||
def _make_text_mask_pdf(path, fill: bytes):
|
||||
"""Build a letter page with a large text image mask painted with ``fill``.
|
||||
|
||||
The mask is a 1-bit stencil; ``fill`` is the color operator sequence that
|
||||
sets the paint color (e.g. ``b"0.263 0.263 0.263 rg"``). With a gray fill
|
||||
this reproduces issue #1688: the text is mid-gray, which is dithered into
|
||||
noise if rasterized to 1-bit but reads correctly once promoted to gray.
|
||||
"""
|
||||
from importlib.resources import as_file, files
|
||||
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
w, h = 1700, 600
|
||||
im = Image.new('1', (w, h), 1) # 1 = white = "do not paint" under Decode [0 1]
|
||||
draw = ImageDraw.Draw(im)
|
||||
# Use a font bundled with ocrmypdf so this test is portable across platforms;
|
||||
# system fonts like DejaVu are not present on macOS/Windows CI runners.
|
||||
with as_file(files('ocrmypdf.data') / 'NotoSans-Regular.ttf') as font_path:
|
||||
font = ImageFont.truetype(str(font_path), 220)
|
||||
draw.text((40, 120), "TESTING", fill=0, font=font)
|
||||
|
||||
packed = im.tobytes() # 1-bpc, rows byte-padded, MSB first
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(612, 792))
|
||||
mask = pikepdf.Stream(pdf, packed)
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = w
|
||||
mask.Height = h
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"q 560 0 0 200 26 500 cm %s %s Do Q" % (fill, bytes(name))
|
||||
)
|
||||
pdf.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rasterizer", ['ghostscript', 'pypdfium'])
|
||||
def test_gray_mask_ocrs_to_text(tmp_path, rasterizer):
|
||||
"""A gray-painted text mask OCRs to real text on both rasterizers (#1688)."""
|
||||
if rasterizer == 'pypdfium' and not PYPDFIUM_AVAILABLE:
|
||||
pytest.skip("pypdfium2 not installed")
|
||||
|
||||
src = _make_text_mask_pdf(tmp_path / 'mask.pdf', b"0.263 0.263 0.263 rg")
|
||||
out = tmp_path / 'out.pdf'
|
||||
sidecar = tmp_path / 'out.txt'
|
||||
check_ocrmypdf(
|
||||
src,
|
||||
out,
|
||||
'--rasterizer',
|
||||
rasterizer,
|
||||
'--sidecar',
|
||||
str(sidecar),
|
||||
'--oversample',
|
||||
'300',
|
||||
)
|
||||
assert 'TESTING' in sidecar.read_text().upper()
|
||||
|
||||
|
||||
def _create_gradient_image(width: int, height: int) -> Image.Image:
|
||||
"""Create an image with multiple gradients to detect rasterization errors.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
from pikepdf import Dictionary, Name, String
|
||||
|
||||
from ocrmypdf._graft import discard_text_search_index
|
||||
|
||||
from .conftest import check_ocrmypdf
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
def _add_search_index(pdf: pikepdf.Pdf, *, other_owner: bool = False) -> None:
|
||||
"""Attach an Adobe-style embedded search index to the document catalog."""
|
||||
pieceinfo = Dictionary(
|
||||
SearchIndex=Dictionary(
|
||||
LastModified=String("D:20240101000000Z"),
|
||||
Private=Dictionary(IndexFile=String("dummy.pdx")),
|
||||
)
|
||||
)
|
||||
if other_owner:
|
||||
pieceinfo[Name.SomeOtherApp] = Dictionary(
|
||||
LastModified=String("D:20240101000000Z")
|
||||
)
|
||||
pdf.Root.PieceInfo = pdf.make_indirect(pieceinfo)
|
||||
|
||||
|
||||
def test_discard_text_search_index_removes_only_search_index(resources):
|
||||
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
||||
# No PieceInfo at all -> nothing to do
|
||||
assert not discard_text_search_index(pdf)
|
||||
|
||||
_add_search_index(pdf, other_owner=True)
|
||||
assert discard_text_search_index(pdf), "Expected file to be modified"
|
||||
|
||||
# SearchIndex gone, but the other application's private data is preserved
|
||||
assert Name.SearchIndex not in pdf.Root.PieceInfo
|
||||
assert Name.SomeOtherApp in pdf.Root.PieceInfo
|
||||
|
||||
# Idempotent: a second call finds nothing to remove
|
||||
assert not discard_text_search_index(pdf)
|
||||
|
||||
|
||||
def test_discard_text_search_index_drops_empty_pieceinfo(resources):
|
||||
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
||||
_add_search_index(pdf, other_owner=False)
|
||||
assert discard_text_search_index(pdf)
|
||||
# PieceInfo held only the SearchIndex, so the whole husk is removed
|
||||
assert Name.PieceInfo not in pdf.Root
|
||||
|
||||
|
||||
def test_discard_text_search_index_tolerates_malformed_pieceinfo(resources):
|
||||
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
||||
pdf.Root.PieceInfo = String("not a dictionary")
|
||||
assert not discard_text_search_index(pdf)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_with_search_index(resources, outdir):
|
||||
out = outdir / 'with_search_index.pdf'
|
||||
with pikepdf.open(resources / 'graph.pdf') as pdf:
|
||||
_add_search_index(pdf, other_owner=False)
|
||||
assert Name.SearchIndex in pdf.Root.PieceInfo
|
||||
pdf.save(out)
|
||||
return out
|
||||
|
||||
|
||||
def test_search_index_discarded_end_to_end(pdf_with_search_index, outpdf, caplog):
|
||||
caplog.set_level(logging.DEBUG)
|
||||
check_ocrmypdf(
|
||||
pdf_with_search_index,
|
||||
outpdf,
|
||||
'--output-type',
|
||||
'pdf',
|
||||
'--plugin',
|
||||
'tests/plugins/tesseract_noop.py',
|
||||
)
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.PieceInfo not in pdf.Root
|
||||
assert 'search index' in caplog.text.lower()
|
||||
|
||||
|
||||
def test_search_index_discarded_with_ocr_engine_none(pdf_with_search_index, outpdf):
|
||||
# Even in pure image-processing mode, OCRmyPDF rewrites the PDF, which
|
||||
# invalidates the embedded index, so it must still be discarded.
|
||||
check_ocrmypdf(
|
||||
pdf_with_search_index,
|
||||
outpdf,
|
||||
'--ocr-engine',
|
||||
'none',
|
||||
'--output-type',
|
||||
'pdf',
|
||||
)
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.PieceInfo not in pdf.Root
|
||||
@@ -0,0 +1,71 @@
|
||||
# SPDX-FileCopyrightText: 2026 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Tests for --mode strip (remove the OCR text layer in place)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
|
||||
from ocrmypdf.exceptions import BadArgsError
|
||||
from ocrmypdf.pdfinfo import PdfInfo
|
||||
|
||||
from .conftest import check_ocrmypdf, run_ocrmypdf_api
|
||||
|
||||
|
||||
def _image_raw_bytes(pdf_path):
|
||||
"""Return raw (still-compressed) stream bytes of each image on page 1."""
|
||||
out = []
|
||||
with pikepdf.open(pdf_path) as pdf:
|
||||
resources = pdf.pages[0].get('/Resources', {})
|
||||
for _name, xobj in resources.get('/XObject', {}).items():
|
||||
if xobj.get('/Subtype') == pikepdf.Name.Image:
|
||||
out.append(bytes(xobj.read_raw_bytes()))
|
||||
return out
|
||||
|
||||
|
||||
def test_mode_strip_removes_ocr_layer(resources, outpdf):
|
||||
"""--mode strip removes the invisible OCR layer without rasterizing.
|
||||
|
||||
The page image is preserved byte-for-byte and the output is no larger than
|
||||
the input.
|
||||
"""
|
||||
input_pdf = resources / 'graph_ocred.pdf'
|
||||
assert PdfInfo(input_pdf, detailed_analysis=True)[0].has_text
|
||||
|
||||
out = check_ocrmypdf(
|
||||
input_pdf, outpdf, '--mode', 'strip', '--output-type', 'pdf', '--optimize', '0'
|
||||
)
|
||||
|
||||
info = PdfInfo(out, detailed_analysis=True)
|
||||
assert len(info) == 1, "page count must be unchanged"
|
||||
assert not info[0].has_text, "OCR text layer should be removed"
|
||||
assert _image_raw_bytes(out) == _image_raw_bytes(input_pdf), (
|
||||
"page image must be preserved byte-for-byte (no rasterization)"
|
||||
)
|
||||
assert out.stat().st_size <= input_pdf.stat().st_size, (
|
||||
"removing the text layer must not grow the file"
|
||||
)
|
||||
|
||||
|
||||
def test_mode_strip_preserves_visible_text(resources, outpdf):
|
||||
"""--mode strip leaves visible/born-digital text untouched (render mode != 3).
|
||||
|
||||
type3_font_nomapping.pdf is born-digital text with no images (the #1608
|
||||
case): its visible text must survive strip, which only removes invisible
|
||||
OCR text.
|
||||
"""
|
||||
input_pdf = resources / 'type3_font_nomapping.pdf'
|
||||
out = check_ocrmypdf(
|
||||
input_pdf, outpdf, '--mode', 'strip', '--output-type', 'pdf', '--optimize', '0'
|
||||
)
|
||||
assert PdfInfo(out, detailed_analysis=True)[0].has_text
|
||||
|
||||
|
||||
def test_mode_strip_rejects_image_processing_options(resources, no_outpdf):
|
||||
"""Options requiring rasterization/OCR are rejected in strip mode."""
|
||||
with pytest.raises(BadArgsError, match=r'--deskew'):
|
||||
run_ocrmypdf_api(
|
||||
resources / 'graph_ocred.pdf', no_outpdf, '--mode', 'strip', '--deskew'
|
||||
)
|
||||
+48
-5
@@ -3,9 +3,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
from pikepdf import Name
|
||||
|
||||
import ocrmypdf
|
||||
from ocrmypdf.pdfinfo import PdfInfo
|
||||
|
||||
|
||||
def test_block_tagged(resources):
|
||||
@@ -13,6 +16,25 @@ def test_block_tagged(resources):
|
||||
ocrmypdf.ocr(resources / 'tagged.pdf', '_.pdf')
|
||||
|
||||
|
||||
def test_detect_structure_tree(resources):
|
||||
assert PdfInfo(resources / 'tagged.pdf').has_structure_tree is True
|
||||
|
||||
|
||||
def test_structure_tree_without_markinfo_blocks(resources, tmp_path):
|
||||
"""A PDF with a structure tree but no /MarkInfo flag is still blocked."""
|
||||
untagged = tmp_path / 'struct_only.pdf'
|
||||
with pikepdf.open(resources / 'tagged.pdf') as pdf:
|
||||
del pdf.Root.MarkInfo
|
||||
pdf.save(untagged)
|
||||
|
||||
info = PdfInfo(untagged)
|
||||
assert info.is_tagged is False
|
||||
assert info.has_structure_tree is True
|
||||
|
||||
with pytest.raises(ocrmypdf.exceptions.TaggedPDFError):
|
||||
ocrmypdf.ocr(untagged, '_.pdf')
|
||||
|
||||
|
||||
def test_force_tagged_warns(resources, outpdf, caplog):
|
||||
caplog.set_level('WARNING')
|
||||
ocrmypdf.ocr(
|
||||
@@ -21,11 +43,11 @@ def test_force_tagged_warns(resources, outpdf, caplog):
|
||||
force_ocr=True,
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
assert 'marked as a Tagged PDF' in caplog.text
|
||||
assert 'structural markup' in caplog.text
|
||||
|
||||
|
||||
def test_tagged_pdf_mode_ignore_with_skip_text(resources, outpdf, caplog):
|
||||
"""Ignore tagged_pdf_mode should warn but not error."""
|
||||
"""Ignore tagged_pdf_mode should warn but not error, and keep structure."""
|
||||
caplog.set_level('WARNING')
|
||||
ocrmypdf.ocr(
|
||||
resources / 'tagged.pdf',
|
||||
@@ -34,11 +56,14 @@ def test_tagged_pdf_mode_ignore_with_skip_text(resources, outpdf, caplog):
|
||||
skip_text=True, # Tagged PDF has text, so skip pages with text
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
assert 'marked as a Tagged PDF' in caplog.text
|
||||
assert 'structural markup' in caplog.text
|
||||
# skip-text leaves the text pages untouched, so the structure tree remains valid
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.StructTreeRoot in pdf.Root
|
||||
|
||||
|
||||
def test_tagged_pdf_mode_ignore_with_force(resources, outpdf, caplog):
|
||||
"""Ignore tagged_pdf_mode with force mode should warn."""
|
||||
"""Ignore tagged_pdf_mode with force mode should warn and discard structure."""
|
||||
caplog.set_level('WARNING')
|
||||
ocrmypdf.ocr(
|
||||
resources / 'tagged.pdf',
|
||||
@@ -47,4 +72,22 @@ def test_tagged_pdf_mode_ignore_with_force(resources, outpdf, caplog):
|
||||
force_ocr=True,
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
assert 'marked as a Tagged PDF' in caplog.text
|
||||
assert 'structural markup' in caplog.text
|
||||
# force-ocr rasterizes every page, destroying the MCIDs the tree relies on
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.StructTreeRoot not in pdf.Root
|
||||
assert Name.MarkInfo not in pdf.Root
|
||||
|
||||
|
||||
def test_tagged_pdf_mode_ignore_with_redo(resources, outpdf):
|
||||
"""Redo mode rewrites the text layer, so structure is discarded."""
|
||||
ocrmypdf.ocr(
|
||||
resources / 'tagged.pdf',
|
||||
outpdf,
|
||||
tagged_pdf_mode='ignore',
|
||||
redo_ocr=True,
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.StructTreeRoot not in pdf.Root
|
||||
assert Name.MarkInfo not in pdf.Root
|
||||
|
||||
@@ -141,6 +141,14 @@ def test_tesseract_log_output(caplog, in_, logged):
|
||||
assert logged in caplog.text
|
||||
|
||||
|
||||
def test_tesseract_log_output_diacritics_raw(caplog):
|
||||
"""Diacritics branch keeps the interpreted hint and surfaces raw (#1566)."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
tesseract.tesseract_log_output(b'lots of diacritics blah blah')
|
||||
assert 'possibly poor OCR' in caplog.text # interpreted hint retained
|
||||
assert 'lots of diacritics blah blah' in caplog.text # raw message surfaced
|
||||
|
||||
|
||||
def test_tesseract_log_output_raises(caplog):
|
||||
with pytest.raises(tesseract.TesseractConfigError):
|
||||
tesseract.tesseract_log_output(b'parameter not found: moo')
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
from pikepdf import Name
|
||||
|
||||
from ocrmypdf._graft import discard_page_thumbnails
|
||||
|
||||
from .conftest import check_ocrmypdf
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
def _add_thumbnail(pdf: pikepdf.Pdf, pageindex: int = 0) -> None:
|
||||
"""Attach a minimal /Thumb image XObject to a page."""
|
||||
width, height = 4, 4
|
||||
thumb = pikepdf.Stream(pdf, b'\x00' * (width * height))
|
||||
thumb.Type = Name.XObject
|
||||
thumb.Subtype = Name.Image
|
||||
thumb.Width = width
|
||||
thumb.Height = height
|
||||
thumb.ColorSpace = Name.DeviceGray
|
||||
thumb.BitsPerComponent = 8
|
||||
pdf.pages[pageindex].obj.Thumb = pdf.make_indirect(thumb)
|
||||
|
||||
|
||||
def test_discard_page_thumbnails_removes_thumbnails(resources):
|
||||
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
||||
# No thumbnails -> nothing to do
|
||||
assert discard_page_thumbnails(pdf) == 0
|
||||
|
||||
_add_thumbnail(pdf, 0)
|
||||
assert Name.Thumb in pdf.pages[0].obj
|
||||
|
||||
assert discard_page_thumbnails(pdf) == 1
|
||||
assert Name.Thumb not in pdf.pages[0].obj
|
||||
|
||||
# Idempotent: a second call finds nothing to remove
|
||||
assert discard_page_thumbnails(pdf) == 0
|
||||
|
||||
|
||||
def test_discard_page_thumbnails_counts_each_page(resources):
|
||||
with pikepdf.open(resources / 'multipage.pdf') as pdf:
|
||||
assert len(pdf.pages) >= 2
|
||||
_add_thumbnail(pdf, 0)
|
||||
_add_thumbnail(pdf, 1)
|
||||
assert discard_page_thumbnails(pdf) == 2
|
||||
assert all(Name.Thumb not in page.obj for page in pdf.pages)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_with_thumbnail(resources, outdir):
|
||||
out = outdir / 'with_thumbnail.pdf'
|
||||
with pikepdf.open(resources / 'graph.pdf') as pdf:
|
||||
_add_thumbnail(pdf, 0)
|
||||
assert Name.Thumb in pdf.pages[0].obj
|
||||
pdf.save(out)
|
||||
return out
|
||||
|
||||
|
||||
def test_thumbnail_discarded_end_to_end(pdf_with_thumbnail, outpdf, caplog):
|
||||
caplog.set_level(logging.DEBUG)
|
||||
check_ocrmypdf(
|
||||
pdf_with_thumbnail,
|
||||
outpdf,
|
||||
'--output-type',
|
||||
'pdf',
|
||||
'--plugin',
|
||||
'tests/plugins/tesseract_noop.py',
|
||||
)
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert all(Name.Thumb not in page.obj for page in pdf.pages)
|
||||
assert 'thumbnail' in caplog.text.lower()
|
||||
|
||||
|
||||
def test_thumbnail_discarded_with_ocr_engine_none(pdf_with_thumbnail, outpdf):
|
||||
# Even in pure image-processing mode, OCRmyPDF rewrites the PDF, which can
|
||||
# alter page appearance, so the stale thumbnail must still be discarded.
|
||||
check_ocrmypdf(
|
||||
pdf_with_thumbnail,
|
||||
outpdf,
|
||||
'--ocr-engine',
|
||||
'none',
|
||||
'--output-type',
|
||||
'pdf',
|
||||
)
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert all(Name.Thumb not in page.obj for page in pdf.pages)
|
||||
@@ -461,61 +461,61 @@ toml = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1434,7 +1434,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ocrmypdf"
|
||||
version = "17.4.2"
|
||||
version = "17.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "deprecation" },
|
||||
@@ -2307,11 +2307,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.29"
|
||||
version = "0.0.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2880,15 +2880,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.1.0"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3012,19 +3012,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.5"
|
||||
version = "6.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user