Compare commits

...
39 Commits
Author SHA1 Message Date
James R. Barlow d6329489ce Bump version: v17.7.0 2026-06-17 15:37:30 -07:00
James R. Barlow e6d240ee93 Update release notes 2026-06-17 15:37:19 -07:00
James R. Barlow ff45e54c07 Run Docker images as non-root user and default to /data workdir
Harden the Docker images by dropping root privileges, and make the
bind-mount workflow less fiddly.

- Create a non-root `app` user (uid/gid 1000) in both images and add
  `USER app` before the entrypoint, so ocrmypdf (and the
  webservice/watcher) no longer run as root. This also fixes the
  previously dangling `--chown=app:app`, which referenced a user that
  was never created. The Ubuntu base ships a default `ubuntu`/1000 user,
  so remove it first so `app` can take uid 1000 (parity with Alpine).

- Add `WORKDIR /data` (created and app-owned) so bind-mounted input and
  output can be passed as relative paths without `--workdir`. The
  webservice/watcher are now invoked by absolute path (`/app/*.py`)
  since the working directory is no longer `/app`.

- Drop the redundant `ppa:alex-p/tesseract-ocr5` from the Ubuntu image:
  Tesseract 5 ships in the Ubuntu archive as of 24.04, and the PPA had
  no build for the 26.04 base, which broke the build outright.

- Rewrite docs/docker.md rootless-first: stdin/stdout piping as the
  recommended permission-free path, then per-runtime volume guidance
  (rootless Docker `--user 0:0`, Podman `--userns keep-id`, rootful
  Docker as the special case). Update batch.md and the compose example
  to match (absolute script paths, per-runtime `user:` guidance).
2026-06-17 15:13:19 -07:00
James R. Barlow e0ee0882ef Fix typo in error message 2026-06-17 14:49:31 -07:00
James R. Barlow 3d17419a6c Update dockerfiles to latest uv and system images 2026-06-17 11:33:31 -07:00
James R. Barlow 476ec12383 Merge remote-tracking branches 'origin/dependabot/uv/starlette-1.3.1', 'origin/dependabot/uv/tornado-6.5.7', 'origin/dependabot/uv/cryptography-48.0.1' and 'origin/dependabot/uv/python-multipart-0.0.31' 2026-06-17 11:30:35 -07:00
dependabot[bot]andGitHub e99177ada7 build(deps): bump starlette from 1.1.0 to 1.3.1
Bumps [starlette](https://github.com/Kludex/starlette) from 1.1.0 to 1.3.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/1.1.0...1.3.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 09:45:21 +00:00
dependabot[bot]andGitHub e95ec9c497 build(deps): bump tornado from 6.5.5 to 6.5.7
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.5 to 6.5.7.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.7)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 09:45:20 +00:00
dependabot[bot]andGitHub 82f30bfbec build(deps): bump cryptography from 48.0.0 to 48.0.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.0 to 48.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.0...48.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 09:45:06 +00:00
dependabot[bot]andGitHub d1437e6bbc build(deps): bump python-multipart from 0.0.29 to 0.0.31
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.29 to 0.0.31.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.29...0.0.31)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 00:16:38 +00:00
dependabot[bot]andGitHub c669d30642 build(deps): bump sigstore/gh-action-sigstore-python from 3.3.0 to 3.4.0 (#1701) 2026-06-15 07:17:34 -07:00
James R. Barlow 3613b30ca8 Bump version: v17.6.0 2026-06-11 22:37:37 -07:00
James R. Barlow 0d4c3bcdcf feat: add --mode strip to remove the OCR text layer without rasterizing
Adds a processing mode that removes the invisible (render mode 3) OCR text
layer 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. Options that require
rasterization or OCR (--deskew, --clean, --sidecar, etc.) are rejected.

Only invisible text is removed; text drawn as visible glyphs under an
opaque image (some OCR engines, and OCRmyPDF v2.2 and earlier) cannot be
removed this way, as documented.

Closes #1435.
2026-06-11 12:34:03 -07:00
James R. Barlow 8a8d515933 feat: surface raw Tesseract diacritics message at debug level
When Tesseract reports a page with many diacritics, OCRmyPDF rewrites the
message to "lots of diacritics - possibly poor OCR", which hid the
original wording. Keep the interpreted hint but also emit Tesseract's raw
line at debug verbosity (-v 1) so users can see exactly what Tesseract
reported.

Closes #1566.
2026-06-11 00:21:39 -07:00
James R. Barlow 11de13ecfe feat: anti-alias Ghostscript rasterization to improve OCR quality
Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
extra word breaks or substituted characters. Enable text and graphics
anti-aliasing (-dTextAlphaBits=4 -dGraphicsAlphaBits=4) for the contone
raster devices, which empirically improves OCR accuracy on the
Ghostscript path, especially for small fonts at moderate DPI. The 1-bit
mono devices are excluded, since older Ghostscript rejects alpha bits on
them and pngmonod performs its own anti-aliased downscaling.

Also log which rasterizer rendered each page at debug verbosity and
clarify the --rasterizer help text, so quality reports are easier to
diagnose. The default rasterizer (auto) already prefers pypdfium2, which
anti-aliases; this primarily benefits --rasterizer ghostscript and
installs without pypdfium2.

Closes #1439.
2026-06-10 23:39:03 -07:00
James R. Barlow 58642d8411 fix: report un-optimizable images as a warning, not a traceback
The optimizer is best-effort: any image it cannot process is left
unchanged in the output, which remains valid. Previously, an extraction
failure (e.g. an exotic colorspace pikepdf cannot transcode) was logged
with log.exception, printing a full traceback at ERROR level that alarmed
users even though nothing was wrong with the output (issue #846).

Trap such failures with a concise warning that the image was left
unchanged and the output is still valid, and demote the traceback to
debug verbosity for diagnosis.
2026-06-10 13:38:05 -07:00
James R. Barlow 7e42d3c771 feat: make pdfa-image-compression=auto lossless at -O0
Ghostscript's `auto` image compression heuristic can transcode lossless
images to JPEG during PDF/A generation, which is surprising at
optimization levels that otherwise promise lossless-only operations
(issue #1124).

`--pdfa-image-compression=auto` (the default) now coerces to lossless at
-O0 so Ghostscript will not transcode lossless images to JPEG. -O1 and
above continue to defer to Ghostscript's heuristic; -O1 (the default
level) is kept as a historical exception because coercing it to lossless
substantially bloats output. Users wanting guaranteed lossless image
handling can pass --pdfa-image-compression=lossless or use -O0.

Also make `lossless` pass existing JPEGs through unchanged
(-dPassThroughJPEGImages=true) instead of re-encoding them with a
lossless codec, which only inflates already-lossy data.
2026-06-10 13:10:43 -07:00
James R. Barlow 5cb5d7a682 Merge remote-tracking branch 'origin/dependabot/github_actions/codecov/codecov-action-7' 2026-06-09 01:12:35 -07:00
James R. Barlow 37e71dece6 Merge branch 'feature/page-box-repair' 2026-06-09 01:10:58 -07:00
James R. Barlow df84945773 feat: validate and repair malformed page boxes
Validate and repair the page-boundary boxes (MediaBox, CropBox, TrimBox,
ArtBox, BleedBox) of input PDFs in triage(), following PDF 2.0:

- Coerce coordinates written in invalid exponential notation, stored by
  qpdf/pikepdf as strings (#1398).
- Normalize rectangles whose corners are given in reversed order, which
  previously crashed with NegativeDimensionError (#1526).
- Clamp a crop/trim/art/bleed box that extends outside the MediaBox to
  their intersection, or discard it when the intersection is empty, which
  previously produced a zero-height effective page some viewers rejected
  (#1400).

When a box is discarded, clamped, or reinterpreted, a warning recommends
visual inspection of the output. pdfinfo box reading shares the same
coercion helper so PdfInfo no longer crashes on malformed boxes.

Supersedes PR #1691. Thanks @ajdlinux.

Closes #1398, #1526, #1400
2026-06-09 01:10:26 -07:00
James R. Barlow b5a6a9f9f1 feat: discard stale structure tree when re-OCRing tagged PDFs
A tagged/structured PDF carries a logical structure tree
(/Root/StructTreeRoot, /MarkInfo) that maps marked page content to
semantic elements via MCIDs. When --force-ocr rasterizes pages or
--redo-ocr rewrites the text layer, those MCIDs are destroyed or
renumbered and the tree is left dangling. We cannot rebuild it to match
the new text, so discard it for force/redo modes, following the same
pattern as the thumbnail and search-index discards. --skip-text leaves
text pages untouched, so their structure is preserved.

Also broaden the default-mode "looks born-digital" stop signal to fire
on /StructTreeRoot, not just /MarkInfo/Marked, so structure-tree-only
PDFs are no longer silently OCR'd. The existing --tagged-pdf-mode ignore
escape hatch is unchanged.
2026-06-08 15:47:53 -07:00
dependabot[bot]andGitHub ed36aefe48 Bump codecov/codecov-action from 6 to 7
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 10:42:38 +00:00
James R. Barlow 32013f4294 Merge branch 'feature/discard-obsolete-pdf-features' 2026-06-07 02:09:00 -07:00
James R. Barlow 8f2bcc2c64 feat: discard stale embedded page thumbnails when rewriting PDF
A page may carry an optional /Thumb image XObject (ISO 32000-2 12.3.4),
a miniature rendering used only as a navigation aid. 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. Modern viewers generate thumbnails on demand, so
there is no loss of functionality.

OcrGrafter.finalize() now strips /Thumb from every page before saving,
alongside the existing search-index discard, covering both the OCR and
hOCR pipelines. Orphaned thumbnail XObjects are garbage-collected on save.
2026-06-07 00:30:34 -07:00
James R. Barlow 015b53ae30 feat: discard stale embedded text search index when rewriting PDF
Adobe Acrobat can embed a proprietary full-text search index in the
document catalog at /Root/PieceInfo/SearchIndex. Only Acrobat reads it;
other viewers ignore it and search the text on the fly. Any change to a
PDF invalidates the index, so once OCRmyPDF rewrites the document the
retained index is stale and returns incorrect search results in Acrobat.

OcrGrafter.finalize() now discards it before saving (covering both the
OCR and hOCR pipelines), preserving any other PieceInfo owner data and
dropping an empty PieceInfo. Modern viewers rebuild a search index on
demand, so there is no loss of search capability.
2026-06-07 00:02:28 -07:00
James R. Barlow 164cf2dc8a test: use bundled font in gray-mask test for macOS/Windows portability
test_gray_mask_ocrs_to_text loaded DejaVu, which only exists on the Linux
CI runners; the OSError fallback hardcoded a Linux-only path, so the test
failed with "cannot open resource" on macOS and Windows. Load the bundled
ocrmypdf.data/NotoSans-Regular.ttf via importlib.resources instead, which
is guaranteed present on every platform.
2026-06-05 12:48:33 -07:00
James R. Barlow 98d6d02704 Merge branch 'fix/1688-mask-fill-color-device'
Promote rasterization device based on image-mask fill color so gray/colored
stencil text is not destroyed by 1-bit dithering before OCR; default the
1-bit Ghostscript device to pngmonod. Fixes #1688.
2026-06-05 11:41:18 -07:00
James R. Barlow 5efb98931d fix: inherit fill color into Form XObjects; reset fill color on cs (#1688)
Address final review findings: a mask painted inside a Form XObject now
inherits the fill color in effect at the form's Do operator (previously it
reset to black, missing gray/color promotion one indirection deep). The cs
operator now resets the fill color to black per PDF spec, so a stale color
set before cs cannot leak to a subsequently drawn mask.
2026-06-05 11:40:50 -07:00
James R. Barlow 2f4e47213f style: ruff format operator whitelist line (#1688) 2026-06-05 11:40:50 -07:00
James R. Barlow 94c8123bd7 docs: release note for image mask fill-color device promotion (#1688) 2026-06-05 11:40:50 -07:00
James R. Barlow 0db130e1c3 test: end-to-end gray image-mask OCR across rasterizers (#1688) 2026-06-05 11:40:50 -07:00
James R. Barlow 91b6a818f5 feat: promote raster device for color/gray image masks; default pngmonod (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 6bc9499e68 feat: recognize pngmonod device in pypdfium rasterizer (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 09f2d6c386 feat: add pngmonod raster device to enum (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 87f918f58c feat: expose fill-color ink classification on ImageInfo (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 80e77fb021 feat: track image mask fill color during content stream interpretation (#1688)
Track the current PDF fill color on the graphics stack alongside the CTM and
record an Ink classification (mono/gray/color) per image-draw event. Image
masks are painted with the current fill color, so this enables later device
promotion. Color operators are tolerant of malformed operands to preserve
robustness on untrusted input.
2026-06-05 11:40:49 -07:00
James R. Barlow fa9c5b3fae feat: add fill color -> Ink classification helper (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 3d17a60a54 feat: add Ink classification type for image mask fill colors (#1688) 2026-06-05 11:40:49 -07:00
jbarlowandGitHub c33f073d4f Improve DeviceN color conversion guidance (#1623) (#1694)
When Ghostscript reports a DeviceN colorspace with an inappropriate
alternate, the resulting PDF/A may render blank in viewers such as Adobe
Reader (#1187). The error is gated on that Ghostscript warning, which is
the authoritative signal that the *output* is broken.

Previously the error message always told the user to "use
--color-conversion-strategy", which is confusing when they already set
one and it didn't help. Crucially, the warning persists for strategies
that don't actually normalize the colorspace -- notably
UseDeviceIndependentColor (confirmed in #1187) -- so silencing the error
for any non-default strategy would emit a silently-broken PDF/A.

Keep raising whenever Ghostscript still reports the warning, regardless
of strategy, but tailor the guidance: if no conversion was requested,
suggest RGB/CMYK/Gray; if a conversion was requested but the warning
persisted, say so and point at strategies that work or --output-type pdf.

Add unit tests (mocked Ghostscript) covering the default case, the
warning-persists-despite-strategy case for both an ineffective strategy
and a normally-effective one, and the no-warning happy path.
2026-06-04 14:48:24 -07:00
43 changed files with 2184 additions and 232 deletions
+29 -7
View File
@@ -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"]
+24 -3
View File
@@ -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"]
+3 -3
View File
@@ -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:
+1 -1
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+105
View File
@@ -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
+9 -2
View File
@@ -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
View File
@@ -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"
+24 -1
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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
+5 -2
View 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)
+253
View File
@@ -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
View File
@@ -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}"
+30 -2
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
__version__ = "17.5.0"
__version__ = "17.7.0"
+39 -3
View File
@@ -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,
+19 -4
View File
@@ -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
View File
@@ -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',
+38 -9
View File
@@ -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.
"""
)
+9 -2
View File
@@ -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:
+2 -2
View File
@@ -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"]
+109 -13
View File
@@ -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:
+33 -5
View File
@@ -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()
+14
View File
@@ -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] = {
+20 -6
View File
@@ -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."""
+1
View File
@@ -38,6 +38,7 @@ class GhostscriptRasterDevice(StrEnum):
JPEGGRAY = 'jpeggray'
JPEGCOLOR = 'jpeg'
PNGMONO = 'pngmono'
PNGMONOD = 'pngmonod'
PNGGRAY = 'pnggray'
PNG256 = 'png256'
PNG16M = 'png16m'
+198 -1
View File
@@ -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
View File
@@ -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
+145
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
+89
View File
@@ -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.
+101
View File
@@ -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
+71
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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')
+93
View File
@@ -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)
Generated
+68 -68
View File
@@ -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]]