Compare commits

...
60 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
James R. Barlow 5d7b5742e4 Bump version: v17.5.0 2026-05-27 13:36:30 -07:00
James R. Barlow c391b2b7d0 Draft release notes for v17.5.0 2026-05-27 13:35:45 -07:00
James R. Barlow 0250929150 Update uv.lock 2026-05-26 13:11:12 -07:00
James R. Barlow 9748208e68 Support 'end' alias for last page in --pages
Closes #1615. The token 'end' (case-insensitive) is now accepted as an
alias for the document's last page, e.g. --pages 3-end. Resolution is
deferred until the page count is known from the input PDF.
2026-05-26 12:18:09 -07:00
James R. Barlow e4b0c04be4 Fix pypdfium2 MediaBox rendering when CropBox is smaller
PDFium does not support negative crop values to expand the render
area beyond the CropBox: such values only pad the output canvas with
white, leaving content outside the CropBox clipped. Set the in-memory
CropBox to the MediaBox before rendering instead. Reported in #1685.
2026-05-25 23:17:17 -07:00
James R. Barlow efb83ad64f Add --ghostscript-jpeg-quality and --ghostscript-jpeg-maxdpi
Expose Ghostscript's -dJPEGQ and image downsampling switches as
advanced, plugin-scoped options for tuning PDF/A output, without
polluting the central OcrOptions registry. The optimizer's existing
--jpeg-quality remains the recommended JPEG quality control.

- GhostscriptOptions gains jpeg_quality and jpeg_maxdpi fields and CLI
  args (advanced help text). jpeg_quality=0 is honored as Ghostscript's
  maximum compression rather than being silently coerced to the default.
- _exec.ghostscript.generate_pdfa() forwards both values; when
  jpeg_maxdpi is set, downsample threshold is pinned at 1.0.
- _get_plugin_options falls back to extra_attrs for namespaced fields
  so plugins can own their options without registering them centrally.
- Documentation explains the rationale: Ghostscript is the legacy path
  (pypdfium + verapdf is preferred in v17+), the optimizer is the
  supported file-size lever, and lowering quality is almost always a
  better trade than downsampling.
2026-05-25 10:20:54 -07:00
James R. Barlow 08e40f96e8 Surface Tesseract config errors instead of FileNotFoundError
When Tesseract cannot find its 'hocr' or 'txt' config files in the
tessdata configs/ directory, it prints "read_params_file: Can't open"
warnings, exits 0, and produces no output. OCRmyPDF then crashed with a
confusing FileNotFoundError on the missing hOCR file (issue #1687).

Promote read_params_file warnings to TesseractConfigError with guidance
on the likely cause, and verify the expected output file exists after
Tesseract claims success as defense-in-depth for other silent-failure
modes.
2026-05-25 01:45:45 -07:00
James R. Barlow 3f6feb1dcc Merge branch 'main' of github.com:ocrmypdf/OCRmyPDF 2026-05-25 01:38:46 -07:00
jbarlowandGitHub ab6553f4ff Merge pull request #1677 from ocrmypdf/dependabot/uv/gitpython-3.1.50
Bump gitpython from 3.1.47 to 3.1.50
2026-05-25 01:36:02 -07:00
jbarlowandGitHub cedca9fa1f Merge pull request #1679 from ocrmypdf/dependabot/uv/urllib3-2.7.0
Bump urllib3 from 2.6.3 to 2.7.0
2026-05-25 01:35:45 -07:00
jbarlowandGitHub 3f40118022 Merge pull request #1686 from ocrmypdf/dependabot/uv/idna-3.15
Bump idna from 3.11 to 3.15
2026-05-25 01:35:31 -07:00
dependabot[bot]andGitHub b18b1da6d0 Bump idna from 3.11 to 3.15
Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 21:26:42 +00:00
James R. Barlow 14fb9f56e8 Add explanatory note about Ghostscript -dJPEG=95 2026-05-16 12:18:34 -07:00
James R. Barlow 8709cf506b Update uv.lock 2026-05-12 10:14:47 -07:00
jbarlowandGitHub 9a92eb40df Merge pull request #1680 from cislunarspace/docs/refresh-chinese-readme 2026-05-12 15:52:47 +02:00
ouyangjiahong 0a59c210f9 docs: refresh Chinese README translation
Align the Chinese README with the current English version and remove stale generated wrapper text.
2026-05-12 09:17:44 +08:00
dependabot[bot]andGitHub 0b370fdd15 Bump urllib3 from 2.6.3 to 2.7.0
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 17:48:53 +00:00
dependabot[bot]andGitHub 1c16dd26f7 Bump gitpython from 3.1.47 to 3.1.50
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.47 to 3.1.50.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.47...3.1.50)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-09 04:47:56 +00:00
dependabot[bot]andGitHub c355d927ba Bump gitpython from 3.1.46 to 3.1.47
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.46 to 3.1.47.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 01:22:53 +00:00
James R. Barlow c993857752 Fix Form XObject cycle detection in image xref scan (#1321)
The 2024 guard against runaway recursion in _find_image_xrefs_container
only deduplicated image xrefs, but Form XObject xrefs are never added to
include_xrefs/exclude_xrefs, so a self-referential or DAG-shaped Form
graph re-entered every branch until the depth limit fired -- producing
the reported flood of warnings (and minutes-long hangs) on PowerPoint
exports.

Thread a visited_forms set through the recursion so each Form XObject is
descended into at most once per document. With memoization in place the
depth limit is no longer a cycle defense, so demote its log to debug.

Add a regression test that synthesises a circular-Form PDF from the
existing formxobject.pdf fixture (no new binary fixture, no license
issues) and asserts zero "Recursion depth exceeded" warnings.
2026-04-25 00:48:25 -07:00
James R. Barlow 84f5fe9ee0 Separate probing from execution in _exec and subprocess modules
Split ocrmypdf.subprocess/__init__.py into three private submodules by
concern (_run, _version, _check) and reduce __init__ to re-exports.
Introduce ocrmypdf._exec._probe.ToolProbe to centralize the version()/
available() pattern each tool module was reimplementing, so the "is this
tool installed and suitable?" question is cleanly distinct from the
pure, picklable functions that do the work.

Also replace the ghostscript module-import log.addFilter() side effect
with an idempotent _ensure_log_filter_installed() called at the top of
each work function, so the DuplicateFilter is present in subprocess
workers without relying on import-time ordering.

Public API of ocrmypdf.subprocess is unchanged.
2026-04-24 13:33:34 -07:00
58 changed files with 4370 additions and 1642 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
+90 -76
View File
@@ -1,8 +1,3 @@
# OCRmyPDF 中文说明
我将为您创建一个中文版的 README.md 文件。以下是基于原始英文版本的中文翻译:
```markdown:/Users/huapai/PycharmProjects/OCRmyPDF/README.md
<!-- SPDX-FileCopyrightText: 2014 Julien Pfefferkorn -->
<!-- SPDX-FileCopyrightText: 2015 James R. Barlow -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
@@ -16,98 +11,109 @@
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
[pyversions]: https://img.shields.io/pypi/pyversions/ocrmypdf "支持的 Python 版本"
OCRmyPDF 为扫描 PDF 文件添加 OCR 文本层,使其可以搜索或复制粘贴。
OCRmyPDF 为扫描 PDF 文件添加 OCR 文本层,使其可以搜索或复制粘贴。
```bash
ocrmypdf # 是一个可脚本化的命令行程序
-l eng+fra # 支持多种语言
--rotate-pages # 可以修正旋转错误的页面
--deskew # 可以校正斜的 PDF
--title "My PDF" # 可以更改输出元数据
--jobs 4 # 默认使用多核心处理
--output-type pdfa # 默认生成 PDF/A 格式
ocrmypdf # 是一个可脚本化的命令行程序
-l eng+fra # 支持多种语言
--rotate-pages # 可以修正旋转方向错误的页面
--deskew # 可以校正斜的 PDF
--title "My PDF" # 可以更改输出元数据
--jobs 4 # 默认使用多个 CPU 核心
--output-type pdfa # 默认生成 PDF/A
input_scanned.pdf # 接受 PDF 输入(或图像)
output_searchable.pdf # 生成经过验证的 PDF 输出
```
[查看发布说明了解最新变更详情](https://ocrmypdf.readthedocs.io/en/latest/release_notes.html)。
[查看发布说明了解最新变更详情](https://ocrmypdf.readthedocs.io/en/latest/release_notes.html)。
## 主要特点
## 主要功能
- 从普通 PDF 生成可搜索的 [PDF/A](https://en.wikipedia.org/?title=PDF/A) 文件
- 准确地将 OCR 文本放置在图像下方,便于复制/粘贴
- 将 OCR 文本准确放置在图像下方,便于复制/粘贴
- 保持原始嵌入图像的精确分辨率
- 在可能的情况下,以"无损"操作方式插入 OCR 信息,不破坏任何其他内容
- 在可能,以无损操作插入 OCR 信息,不干扰任何其他内容
- 优化 PDF 图像,通常生成比输入文件更小的文件
- 如果需要,在执行 OCR 前对图像进行校正和/或清理
- 按需在执行 OCR 前校正和/或清理图像
- 验证输入和输出文件
- 在所有可用 CPU 核心分配工作
- 在所有可用 CPU 核心分配工作
- 使用 [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) 引擎识别超过 [100 种语言](https://github.com/tesseract-ocr/tessdata)
- 保护的私数据安全
- 适当扩展处理包含数千页的文件
- 在数百万 PDF 上经过实战测试
- 保护的私数据
- 可以妥善扩展处理包含数千页的文件
-在数百万 PDF 上经过实战检验。
<img src="misc/screencast/demo.svg" alt="终端会话中的 OCRmyPDF 演示">
<img src="misc/screencast/demo.svg" alt="OCRmyPDF 在终端会话中的演示">
详情请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/)。
## 开发动机
## 动机
我在网上搜索免费的命令行工具来对 PDF 文件行 OCR:我找到了很多,但没有一个真正令人满意:
在网上寻找一款免费的命令行工具来对 PDF 文件行 OCR:我找到了很多,但没有一个真正令人满意:
- 要么它们生成的 PDF 文件中文本位置错误(使复制/粘贴变得不可能
- 要么它们不处理重音和多语言字符
- 要么它们改变嵌入图像的分辨率
- 要么它们生成了体积巨大的 PDF 文件
- 要么它们在尝试 OCR 时崩溃
- 要么它们不生成有效的 PDF 文件
- 最重要的是,它们都不生成 PDF/A 文件(专为长期存储设计的格式)
- 要么生成的 PDF 文件中文本位于图像下方的错误位置(导致无法复制/粘贴
- 要么无法处理重音字符和多语言字符
- 要么改变嵌入图像的分辨率
- 要么生成的 PDF 文件大得离谱
- 要么在尝试 OCR 时崩溃
- 要么无法生成有效的 PDF 文件
- 除此之外,它们都不生成 PDF/A 文件(专为长期存储设计的格式)
...所以我决定开发自己的工具。
……所以我决定开发自己的工具。
## 安装
支持 Linux、Windows、macOS 和 FreeBSD。Docker 镜像也可用,同时支持 x64 和 ARM。
支持 Linux、Windows、macOS 和 FreeBSD。也提供 Docker 镜像,同时支持 x64 和 ARM。
| 操作系统 | 安装命令 |
| --------------------------- | ----------------------------- |
| Debian, Ubuntu | ``apt install ocrmypdf`` |
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
| Fedora | ``dnf install ocrmypdf`` |
| macOS (Homebrew) | ``brew install ocrmypdf`` |
| macOS (MacPorts) | ``port install ocrmypdf`` |
| macOS (nix) | ``nix-env -i ocrmypdf`` |
| LinuxBrew | ``brew install ocrmypdf`` |
| FreeBSD | ``pkg install py-ocrmypdf`` |
| Ubuntu Snap | ``snap install ocrmypdf`` |
| 操作系统 | 安装命令 |
| ----------------------------- | ------------------------------ |
| Debian, Ubuntu | ``apt install ocrmypdf`` |
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
| Fedora | ``dnf install ocrmypdf`` |
| macOS (Homebrew) | ``brew install ocrmypdf`` |
| macOS (MacPorts) | ``port install ocrmypdf`` |
| macOS (nix) | ``nix-env -i ocrmypdf`` |
| LinuxBrew | ``brew install ocrmypdf`` |
| FreeBSD | ``pkg install py-ocrmypdf`` |
| OpenBSD | ``pkg_add ocrmypdf`` |
| Ubuntu Snap | ``snap install ocrmypdf`` |
对于其他用户[请参阅我们的文档](https://ocrmypdf.readthedocs.io/en/latest/installation.html)了解安装步骤。
其他用户请[参阅我们的文档](https://ocrmypdf.readthedocs.io/en/latest/installation.html)了解安装步骤。
## 语言
OCRmyPDF 使用 Tesseract 行 OCR,并依赖其语言包。对于 Linux 用户,通常可以找到提供语言包的软件包:
OCRmyPDF 使用 Tesseract 行 OCR,并依赖其语言包。对于 Linux 用户,通常可以找到提供语言包的软件包:
```bash
# 显示所有 Tesseract 语言包的列表
apt-cache search tesseract-ocr
# Debian/Ubuntu 用户
apt-get install tesseract-ocr-chi-sim # 示例:安装中文简体语言包
apt-cache search tesseract-ocr # 显示所有 Tesseract 语言包列表
apt-get install tesseract-ocr-chi-sim # 示例:安装简体中文语言包
# Arch Linux 用户
pacman -S tesseract-data-eng tesseract-data-deu # 示例:安装英语和德语语言包
# OpenBSD 用户
pkg_info -aQ tesseract # 显示所有 Tesseract 语言包列表
pkg_add tesseract-cym # 示例:安装威尔士语语言包
# brew macOS 用户
brew install tesseract-lang
# Fedora 用户
dnf search tesseract-langpack # 显示所有 Tesseract 语言包列表
dnf install tesseract-langpack-ita # 示例:安装意大利语语言包
```
然后,您可以向 OCRmyPDF 传递 `-l LANG` 参数,提示它应搜索哪些语言。可以请求多种语言。
随后可以向 OCRmyPDF 传递 `-l LANG` 参数,提示它应搜索哪些语言。可以同时请求多种语言。
OCRmyPDF 支持 Tesseract 4.1.1+。它会自动使用 `PATH` 环境变量中首先找到的版本。在 Windows 上,如果 `PATH` 不提供 Tesseract 二进制文件,我们会根据 Windows 注册表使用已安装的最高版本号。
OCRmyPDF 支持 Tesseract 4.1.1+。它会自动使用 `PATH` 环境变量中首先找到的版本。在 Windows 上,如果 `PATH` 中没有 Tesseract 二进制文件,我们会根据 Windows 注册表使用已安装的最高版本号。
## 文档和支持
安装 OCRmyPDF 后,可以通过以下方式访问内置帮助,解命令语法和选项:
安装 OCRmyPDF 后,可以通过以下命令访问内置帮助,解命令语法和选项:
```bash
ocrmypdf --help
@@ -115,13 +121,13 @@ ocrmypdf --help
我们的[文档托管在 Read the Docs 上](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
请在我们的 [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) 页面报告问题,并遵循问题模板以获得快速响应。
请在我们的 [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) 页面报告问题,并遵循 issue 模板以便快速获得响应。
## 功能演示
```bash
# 添加 OCR 层并转换为 PDF/A
ocrmypdf input.pdf output.pdf
# 添加 OCR 层并要求输出 PDF/A
ocrmypdf --output-type pdfa input.pdf output.pdf
# 将图像转换为单页 PDF
ocrmypdf input.jpg output.pdf
@@ -129,45 +135,53 @@ ocrmypdf input.jpg output.pdf
# 就地为文件添加 OCR(仅在成功时修改文件)
ocrmypdf myfile.pdf myfile.pdf
# 使用非英语语言行 OCR(查找语言的 ISO 639-3 代码)
# 使用非英语语言行 OCR查找对应语言的 ISO 639-3 代码)
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
# OCR 多语言文档
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
# 校正(矫正倾斜的页面
# 校正歪斜页面
ocrmypdf --deskew input.pdf output.pdf
```
更多功能请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
更多功能请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
## 要求
所需的 Python 版本外,OCRmyPDF 还需要外部程序安装 Ghostscript 和 Tesseract OCR。OCRmyPDF 是纯 Python 编写的,几乎可以在所有平台上运行:Linux、macOS、Windows 和 FreeBSD。
除所需的 Python 版本外,OCRmyPDF 还需要安装 Ghostscript 和 Tesseract OCR 这两个外部程序。OCRmyPDF 是纯 Python 项目,几乎可以在所有平台上运行:Linux、macOS、Windows 和 FreeBSD。
## 媒体报道
## 插件
- [使用 OCRmyPDF 实现无纸化](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
- [将扫描文档转换为带有编辑的压缩可搜索 PDF](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
- [c't 1-2014, 第 59 页](https://heise.de/-2279695):在德国领先的 IT 杂志 c't 中详细介绍 OCRmyPDF v1.0
- [heise Open Source, 09/2014: 使用 OCRmyPDF 进行文本识别](https://heise.de/-2356670)
- [heise 使用 OCRmyPDF 创建可搜索的 PDF 文档](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
- [优秀实用工具:OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
- [LinuxUser 使用 OCRmyPDF 和 Scanbd 自动化文本识别](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
- [Y Combinator 讨论](https://news.ycombinator.com/item?id=32028752)
OCRmyPDF 提供插件接口,允许扩展或替换其能力。以下是我们知道的一些插件:
## 商业咨询
- [OCRmyPDF-AppleOCR](https://github.com/mkyt/ocrmypdf-AppleOCR):用 Apple Vision Framework 替换标准 Tesseract OCR 引擎。需要 macOS。
- [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR):用 EasyOCR 替换标准 Tesseract OCR 引擎;EasyOCR 是基于 PyTorch 的较新 OCR 引擎。强烈建议使用 GPU。
- [OCRmyPDF-PaddleOCR](https://github.com/clefru/ocrmypdf-paddleocr):用 PaddleOCR 替换标准 Tesseract OCR 引擎;PaddleOCR 是功能强大的 GPU 加速 OCR 引擎。
如果没有公司和用户选择为功能开发和咨询提供支持,OCRmyPDF 就不会成为今天的软件。我们很乐意讨论所有咨询,无论是扩展现有功能集,还是将 OCRmyPDF 集成到更大的系统中。
[paperless-ngx](https://docs.paperless-ngx.com/) 将 OCRmyPDF 集成到可搜索的文档管理系统中。
## 新闻与媒体
- [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
- [Converting a scanned document into a compressed searchable PDF with redactions](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
- [c't 1-2014,第 59 页](https://heise.de/-2279695):德国领先 IT 杂志 c't 对 OCRmyPDF v1.0 的详细介绍
- [heise Open Source, 09/2014: Texterkennung mit OCRmyPDF](https://heise.de/-2356670)
- [heise Durchsuchbare PDF-Dokumente mit OCRmyPDF erstellen](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
- [Y Combinator discussion](https://news.ycombinator.com/item?id=32028752)
## 商务咨询
如果没有公司和用户选择支持功能开发与咨询服务,OCRmyPDF 不会成为今天的软件。无论是扩展现有功能集,还是将 OCRmyPDF 集成到更大的系统中,我们都很乐意讨论各类咨询需求。
## 许可证
OCRmyPDF 软件根据 Mozilla 公共许可证 2.0 (MPL-2.0) 授权。许可证允许将 OCRmyPDF 与其他代码集成,包括商业和闭源代码,但要求发布对 OCRmyPDF 所做的源代码级修改。
OCRmyPDF 软件采用 Mozilla Public License 2.0 (MPL-2.0) 授权。许可证允许将 OCRmyPDF 与其他代码集成,包括商业代码和闭源代码,但要求发布对 OCRmyPDF 所做的源代码级修改。
OCRmyPDF 的某些组件其他许可证,标准 SPDX 许可证标识符或 DEP5 版权许可信息文件所示。一般来说,非核心代码根据 MIT 许可,文档和测试文件根据 Creative Commons ShareAlike 4.0 (CC-BY-SA 4.0) 许可。
OCRmyPDF 的某些组件采用其他许可证,具体由标准 SPDX 许可证标识符或 DEP5 版权许可信息文件标明。一般来说,非核心代码采用 MIT 许可,文档和测试文件采用 Creative Commons ShareAlike 4.0 (CC-BY-SA 4.0) 许可
## 免责声明
本软件按"原样"分发,不提供任何明示或暗示的保证或条件。
这份中文版 README.md 保留了原始文档的所有重要信息,包括功能介绍、安装说明、语言支持、使用示例等内容,同时保持了原始格式和结构。
本软件按原样分发,不提供任何明示或暗示的保证或条件。
+78
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
@@ -419,6 +433,70 @@ curves. In this case, you may want to use a different color conversion
strategy. The `--color-conversion-strategy` option allows you to select a
different strategy, such as `RGB`.
## Advanced Ghostscript tuning
:::{versionadded} 17.5.0
:::
OCRmyPDF intentionally hides most Ghostscript controls because Ghostscript
is a legacy code path. The preferred PDF/A pipeline in v17+ uses pypdfium2
as the rasterizer and verapdf to validate speculative PDF/A output, with
Ghostscript reserved as a fallback for PDFs that cannot be made compliant
without it. OCRmyPDF's separate optimizer (controlled by `--optimize`,
`--jpeg-quality`, `--png-quality`, etc.) is the supported way to shrink
output PDFs: it gives consistent results across input files, and isolates
Ghostscript so it can focus on producing a PDF/A with as few image
transformations as possible.
The two options below are exposed for advanced users who want to tune
Ghostscript's intermediate PDF/A output directly. Most users will get
more predictable results from the optimizer.
### `--ghostscript-jpeg-quality Q`
Sets Ghostscript's `-dJPEGQ` switch for images that Ghostscript chooses
to recompress to JPEG while building a PDF/A. `Q=0` requests maximum
compression and `Q=100` requests best quality; if the flag is omitted,
OCRmyPDF passes `95` (the historical default). This only affects images
Ghostscript transcodes — existing JPEGs pass through unchanged on modern
Ghostscript releases. For end-to-end JPEG quality tuning, prefer
`--jpeg-quality`, which is implemented by the OCRmyPDF optimizer and is
applied independently of whatever Ghostscript decides to do.
Note: setting both `--ghostscript-jpeg-quality` and `--jpeg-quality` can
result in double JPEG recompression, since the optimizer may re-encode
images that Ghostscript already recompressed. This can degrade quality
in subtle ways.
### `--ghostscript-jpeg-maxdpi DPI`
Enables Ghostscript's image downsampling and caps color, grayscale, and
monochrome image resolution to `DPI`. The downsample threshold is set to
`1.0`, so any image whose effective DPI exceeds the cap will be
downsampled.
Reducing JPEG quality is almost always a better trade than downsampling
at the same compression budget: a 400 DPI JPEG at modest quality usually
looks much better than a 200 DPI JPEG, because the JPEG codec can spend
bits where they count. Downsampling is also dangerous for PDFs that
combine a low-resolution color image with a high-resolution monochrome
mask — capping the mask resolution can produce visible quality loss.
For these reasons, prefer `--jpeg-quality` over `--ghostscript-jpeg-maxdpi`
unless you specifically want to force a hard DPI cap.
Example:
```bash
ocrmypdf --output-type pdfa \
--ghostscript-jpeg-quality 80 \
--ghostscript-jpeg-maxdpi 150 \
in.pdf out.pdf
```
These options only take effect when Ghostscript is invoked for PDF/A
conversion (`--output-type pdfa`, `pdfa-1`, `pdfa-2`, or `pdfa-3`, or
when `--output-type auto` falls back to Ghostscript).
## PDF/A output modes
:::{versionchanged} 17.0.0
+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
+47 -12
View File
@@ -31,6 +31,16 @@ ocrmypdf --output-type pdf input.pdf output.pdf
ocrmypdf --output-type pdfa --pdfa-image-compression jpeg input.pdf output.pdf
```
### Reduce JPEG quality with the optimizer
This is the recommended way to shrink JPEG content in the output. The
optimizer applies regardless of `--output-type`, so it works on both
plain PDFs and Ghostscript-produced PDF/A files.
```bash
ocrmypdf --optimize 2 --jpeg-quality 60 input.pdf output.pdf
```
### Modify a file in place
The file will only be overwritten if OCRmyPDF is successful.
@@ -239,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:
@@ -333,12 +358,22 @@ Hyphens denote a range of pages and commas separate page numbers. If you
prefer to use spaces, quote all of the page numbers:
`--pages '2, 3, 5, 7'`.
The token `end` (case-insensitive) is an alias for the last page in the
document. For example, `--pages 3-end` OCRs from page 3 through the
final page, and `--pages end` OCRs only the last page:
```bash
ocrmypdf --pages 3-end input.pdf output.pdf
ocrmypdf --pages end input.pdf output.pdf
```
OCRmyPDF will warn if your list of page numbers contains duplicates or
overlapping pages. OCRmyPDF does not currently account for document page
numbers, such as an introduction section of a book that uses Roman
numerals. It simply counts the number of virtual pieces of paper since
the start. If your list of pages is out of numerical order, OCRmyPDF
will sort it for you.
overlapping pages. (Repeated page numbers are de-duplicated automatically,
since the underlying set of pages is what matters.) OCRmyPDF does not
currently account for document page numbers, such as an introduction
section of a book that uses Roman numerals. It simply counts the number
of virtual pieces of paper since the start. If your list of pages is out
of numerical order, OCRmyPDF will sort it for you.
Regardless of the argument to `--pages`, OCRmyPDF will optimize all
pages/images in the file and convert it to PDF/A, unless you disable
+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
+16 -4
View File
@@ -178,11 +178,23 @@ 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
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning).
Most users should prefer `--jpeg-quality` (applied by the OCRmyPDF
optimizer) over those Ghostscript-scoped controls.
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
one of the standard XMP metadata namespaces for PDFs. In particular,
PRISM Metadata is removed.
+10 -1
View File
@@ -98,7 +98,16 @@ If `pngquant` is installed, OCRmyPDF will use it to perform quantize
paletted images to reduce their size.
The quality of JPEGs may be lowered, on the assumption that a lower
quality image may be suitable for storage after OCR.
quality image may be suitable for storage after OCR. Use `--jpeg-quality`
to control the optimizer's JPEG quality target. The optimizer is the
recommended way to reduce JPEG image sizes: it applies consistently
regardless of whether Ghostscript was used to produce a PDF/A.
If you specifically need to tune Ghostscript's own PDF/A image handling
(for example, to force a hard DPI cap), see
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning)
for the separate `--ghostscript-jpeg-quality` and
`--ghostscript-jpeg-maxdpi` options.
It is not possible to optimize all image types. Uncommon image types may
be skipped by the optimizer.
+127
View File
@@ -3,6 +3,133 @@
# 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
of the document. For example, ``--pages 3-end`` OCRs from page 3 through
the final page. {issue}`1615`
- Added ``--ghostscript-jpeg-quality`` and ``--ghostscript-jpeg-maxdpi``
advanced options for tuning Ghostscript's PDF/A output. The optimizer's
``--jpeg-quality`` remains the recommended file-size control.
- Fixed pypdfium2 rasterizer clipping content when the CropBox was smaller
than the MediaBox (e.g. JSTOR or cropped PDFs). {issue}`1685`
- Fixed Form XObject cycle detection in the optimizer's image xref scan.
Self-referential or DAG-shaped Form graphs (notably from PowerPoint
exports) previously produced floods of recursion warnings and could hang
for minutes. {issue}`1321`
- Tesseract config errors are now surfaced as ``TesseractConfigError`` with
actionable guidance, instead of crashing later with a confusing
``FileNotFoundError`` on the missing hOCR output. {issue}`1687`
- Refreshed the Chinese README translation. Thanks @cislunarspace.
- Internal refactoring of the ``_exec`` and ``subprocess`` modules to
separate probing from execution.
- CI dependency updates.
## v17.4.2
- Fixed Python API unconditionally overriding ``PIL.Image.MAX_IMAGE_PIXELS``
+3
View File
@@ -46,6 +46,8 @@ __ocrmypdf_arguments()
--rasterizer (PDF page rasterizer)
--rotate-pages-threshold (page rotation confidence)
--pdfa-image-compression (set PDF/A image compression options)
--ghostscript-jpeg-quality (Ghostscript JPEG quality during PDF/A [0..100])
--ghostscript-jpeg-maxdpi (cap Ghostscript image DPI during PDF/A)
--fast-web-view (if file size if above this amount in MB linearize PDF)
--continue-on-soft-render-error (continue after recoverable render errors)
--plugin (name of plugin to import)
@@ -337,6 +339,7 @@ __ocrmypdf_check_previous()
--title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\
--jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\
--ghostscript-jpeg-quality|--ghostscript-jpeg-maxdpi|\
--tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\
--rotate-pages-threshold|--fast-web-view)
# argument required but no completions available
+2
View File
@@ -102,6 +102,8 @@ function __fish_ocrmypdf_pdfa_compression
echo -e "lossless\t"(_ "convert color and grayscale images to lossless (PNG)")
end
complete -c ocrmypdf -x -l pdfa-image-compression -a '(__fish_ocrmypdf_pdfa_compression)' -d "set PDF/A image compression options"
complete -c ocrmypdf -x -l ghostscript-jpeg-quality -d "Ghostscript JPEG quality during PDF/A [0..100]"
complete -c ocrmypdf -x -l ghostscript-jpeg-maxdpi -d "cap Ghostscript image DPI during PDF/A"
complete -c ocrmypdf -x -s j -l jobs -d "how many worker processes to use"
complete -c ocrmypdf -x -l title -d "set metadata"
+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.4.2"
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"
+72
View File
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2026 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Probe helper for external executables.
Each ``ocrmypdf._exec.<tool>`` module describes its external program with a
module-level :class:`ToolProbe` and delegates ``version()`` / ``available()``
to it. This separates the "is the tool installed and suitable?" question
(probing) from the "run the tool" question (execution). Work functions stay
as pure module-level functions so they are trivially picklable for use in
subprocess workers.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from packaging.version import Version
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version
@dataclass(frozen=True)
class ToolProbe:
"""Describes how to detect an external executable and its version.
Attributes:
program: The program name as it appears on PATH (or a full path).
version_arg: The argument that elicits a version string.
version_regex: A regex with a capturing group that extracts the
version from the program's output.
version_cls: A :class:`packaging.version.Version` subclass, used for
tools with non-standard version strings (e.g. Tesseract).
env: Optional environment overrides applied when probing the version.
also_catch: Additional exception types that should be treated as
"not available" by :meth:`available`. :class:`OSError` is useful
for tools like verapdf whose launcher may fail with non-standard
errors when the JVM is missing.
"""
program: str
version_arg: str = '--version'
version_regex: str = r'(\d+(\.\d+)*)'
version_cls: type[Version] = Version
env: Mapping[str, str] | None = None
also_catch: tuple[type[BaseException], ...] = ()
def version(self) -> Version:
"""Return the installed version of the program.
Raises:
MissingDependencyError: if the program cannot be found or its
version string cannot be parsed.
"""
raw = get_version(
self.program,
version_arg=self.version_arg,
regex=self.version_regex,
env=self.env,
)
return self.version_cls(raw)
def available(self) -> bool:
"""Return whether a usable version of the program is installed."""
try:
self.version()
except MissingDependencyError:
return False
except self.also_catch:
return False
return True
+72 -6
View File
@@ -16,6 +16,7 @@ from subprocess import PIPE, CalledProcessError
from packaging.version import Version
from PIL import Image, UnidentifiedImageError
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import (
ColorConversionNeededError,
InputFileError,
@@ -23,7 +24,7 @@ from ocrmypdf.exceptions import (
)
from ocrmypdf.helpers import Resolution
from ocrmypdf.pluginspec import GhostscriptRasterDevice
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
from ocrmypdf.subprocess import run, run_polling_stderr
COLOR_CONVERSION_STRATEGIES = frozenset(
[
@@ -69,11 +70,19 @@ class DuplicateFilter(logging.Filter):
return True
log.addFilter(DuplicateFilter(log))
PROBE = ToolProbe(program=GS)
version = PROBE.version
available = PROBE.available
def version() -> Version:
return Version(get_version(GS))
def _ensure_log_filter_installed() -> None:
"""Idempotently attach the duplicate-suppressing filter to the GS logger.
Called at the top of each work function so the filter is present in the
main process *and* in any subprocess worker that calls Ghostscript.
"""
if not any(isinstance(f, DuplicateFilter) for f in log.filters):
log.addFilter(DuplicateFilter(log))
def _gs_error_reported(stream) -> bool:
@@ -123,6 +132,7 @@ def rasterize_pdf(
use_cropbox: If True, rasterize the CropBox instead of MediaBox.
Default is False (use MediaBox).
"""
_ensure_log_filter_installed()
raster_dpi = raster_dpi.round(6)
if not page_dpi:
page_dpi = raster_dpi
@@ -140,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,
@@ -152,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 [])
@@ -268,11 +292,14 @@ def generate_pdfa(
*,
compression: str,
color_conversion_strategy: str,
jpeg_quality: int | None = None,
jpeg_maxdpi: int | None = None,
pdf_version: str = '1.5',
pdfa_part: str = '2',
progressbar_class=None,
stop_on_error: bool = False,
):
_ensure_log_filter_installed()
# Ghostscript's compression is all or nothing. We can either force all images
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
# In most case it's best to let it decide.
@@ -286,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",
@@ -307,6 +339,35 @@ def generate_pdfa(
# Windows has lots of fatal "permission denied" errors
stop_on_error = False
# `-dJPEGQ=N` tells Ghostscript to use a JPEG quality of N, IF it decides
# to transcode an image to JPEG. When there are existing JPEG images,
# Ghostscript uses passthrough mode, so the quality level is not changed.
# OCRmyPDF's optimizer separately uses the `--jpeg-quality` command line
# option to potentially re-encode JPEG images, regardless of whether
# Ghostscript decided to transcode them to JPEG or not.
# `jpeg_quality=0` is meaningful to Ghostscript (maximum compression), so
# only fall back to the default when the value is None.
effective_jpeg_quality = jpeg_quality if jpeg_quality is not None else 95
# Downsampling images is a blunt-force way to reduce file size and almost
# always degrades quality more than lowering JPEG quality at the original
# resolution. We expose this for users with very specific needs (e.g.
# producing very small files for screen-only viewing); the optimizer is
# usually a better choice.
downsample_args: list[str] = []
if jpeg_maxdpi is not None:
downsample_args = [
"-dDownsampleColorImages=true",
"-dColorImageDownsampleThreshold=1.0",
"-dDownsampleGrayImages=true",
"-dGrayImageDownsampleThreshold=1.0",
"-dDownsampleMonoImages=true",
"-dMonoImageDownsampleThreshold=1.0",
f"-dColorImageResolution={jpeg_maxdpi}",
f"-dGrayImageResolution={jpeg_maxdpi}",
f"-dMonoImageResolution={jpeg_maxdpi}",
]
# nb no need to specify ProcessColorModel when ColorConversionStrategy
# is set; see:
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
@@ -323,8 +384,9 @@ def generate_pdfa(
]
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
+ compression_args
+ downsample_args
+ [
"-dJPEGQ=95",
f"-dJPEGQ={effective_jpeg_quality}", # See note above on JPEG quality
"-dSubsetFonts=false", # Prevents GS from messing up some encodings
f"-dPDFA={pdfa_part}",
"-dPDFACompatibilityPolicy=1",
@@ -362,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)
+6 -4
View File
@@ -9,21 +9,23 @@ from subprocess import PIPE, CalledProcessError
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
_PROBE = ToolProbe(program='jbig2', version_regex=r'jbig2enc (\d+(\.\d+)*).*')
def version() -> Version:
try:
version = get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
return _PROBE.version()
except CalledProcessError as e:
# TeX Live for Windows provides an incompatible jbig2.EXE which may
# be on the PATH.
raise MissingDependencyError('jbig2enc') from e
return Version(version)
def available():
def available() -> bool:
try:
version()
except MissingDependencyError:
+5 -15
View File
@@ -8,22 +8,12 @@ from __future__ import annotations
from pathlib import Path
from subprocess import PIPE
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.subprocess import run
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
def version() -> Version:
return Version(get_version('pngquant', regex=r'(\d+(\.\d+)*).*'))
def available():
try:
version()
except MissingDependencyError:
return False
return True
PROBE = ToolProbe(program='pngquant', version_regex=r'(\d+(\.\d+)*).*')
version = PROBE.version
available = PROBE.available
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
+43 -6
View File
@@ -17,13 +17,14 @@ from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import (
MissingDependencyError,
SubprocessOutputError,
TesseractConfigError,
)
from ocrmypdf.pluginspec import OrientationConfidence
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
log = logging.getLogger(__name__)
@@ -115,8 +116,13 @@ class TesseractVersion(Version):
)
def version() -> Version:
return TesseractVersion(get_version('tesseract', regex=r'tesseract\s(.+)'))
PROBE = ToolProbe(
program='tesseract',
version_regex=r'tesseract\s(.+)',
version_cls=TesseractVersion,
)
version = PROBE.version
available = PROBE.available
def has_thresholding() -> bool:
@@ -287,12 +293,14 @@ def tesseract_log_output(stream: bytes) -> None:
lines = text.splitlines()
for line in lines:
if line.startswith(
("Tesseract Open Source", "Warning in pixReadMem")
):
if line.startswith(("Tesseract Open Source", "Warning in pixReadMem")):
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:
@@ -309,6 +317,23 @@ def tesseract_log_output(stream: bytes) -> None:
tlog.warning(line.strip())
elif 'read_params_file' in line.lower():
tlog.error(line.strip())
# Tesseract emits "read_params_file: Can't open <name>" when it
# cannot locate a config file (e.g. 'hocr', 'txt') in its
# tessdata configs/ directory, then exits 0 without producing
# the requested output. Promote to a hard error so the user
# sees the root cause instead of a downstream FileNotFoundError.
if "Can't open" in line:
missing = line.split("Can't open", 1)[1].strip()
else:
missing = line.strip()
raise TesseractConfigError(
f"Tesseract cannot open its config file '{missing}'. "
"This usually means Tesseract is installed but its config "
"files are missing from the tessdata configs/ directory. "
"On Debian/Ubuntu, ensure the 'tesseract-ocr' package is "
"fully installed. If you set TESSDATA_PREFIX, verify its "
"configs/ subdirectory contains the required files."
)
else:
tlog.info(line.strip())
@@ -389,6 +414,12 @@ def generate_hocr(
raise SubprocessOutputError() from e
else:
tesseract_log_output(stdout)
if not output_hocr.exists():
raise SubprocessOutputError(
"Tesseract exited successfully but did not produce the "
f"expected hOCR output at {output_hocr}. Tesseract output:\n"
+ (stdout.decode(errors='replace') if stdout else '(empty)')
)
# The sidecar text file will get the suffix .txt; rename it to
# whatever caller wants it named
with suppress(FileNotFoundError):
@@ -457,6 +488,12 @@ def generate_pdf(
stdout = p.stdout
with suppress(FileNotFoundError):
prefix.with_suffix('.txt').replace(output_text)
if not output_pdf.exists():
raise SubprocessOutputError(
"Tesseract exited successfully but did not produce the "
f"expected PDF output at {output_pdf}. Tesseract output:\n"
+ (stdout.decode(errors='replace') if stdout else '(empty)')
)
except TimeoutExpired:
page_timedout(timeout)
use_skip_page(output_pdf, output_text)
+5 -4
View File
@@ -14,11 +14,11 @@ from pathlib import Path
from subprocess import PIPE, STDOUT
from tempfile import TemporaryDirectory
from packaging.version import Version
from PIL import Image
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import SubprocessOutputError
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
# unpaper documentation:
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
@@ -46,8 +46,9 @@ class UnpaperImageTooLargeError(Exception):
super().__init__(self.message)
def version() -> Version:
return Version(get_version('unpaper', regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)'))
PROBE = ToolProbe(program='unpaper', version_regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)')
version = PROBE.version
available = PROBE.available
@contextmanager
+9 -15
View File
@@ -11,10 +11,9 @@ from pathlib import Path
from subprocess import PIPE
from typing import NamedTuple
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
log = logging.getLogger(__name__)
@@ -27,18 +26,13 @@ class ValidationResult(NamedTuple):
message: str
def version() -> Version:
"""Get verapdf version."""
return Version(get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)'))
def available() -> bool:
"""Check if verapdf is available."""
try:
version()
except (MissingDependencyError, OSError):
return False
return True
PROBE = ToolProbe(
program='verapdf',
version_regex=r'veraPDF (\d+(\.\d+)*)',
also_catch=(OSError,),
)
version = PROBE.version
available = PROBE.available
def output_type_to_flavour(output_type: str) -> str:
+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
+54 -5
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):
@@ -65,8 +69,33 @@ class TaggedPdfMode(StrEnum):
ignore = 'ignore'
def _pages_from_ranges(ranges: str) -> set[int]:
"""Convert page range string to set of page numbers."""
def _has_end_alias(ranges: str) -> bool:
"""Return True if the page range string uses the ``end`` alias."""
return 'end' in ranges.lower()
def _resolve_page_token(token: str, total_pages: int | None) -> int:
"""Convert a single page-number token to a 1-based integer.
The literal ``end`` (case-insensitive) is resolved to ``total_pages``. If
``total_pages`` is None, an error is raised.
"""
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"
)
return total_pages
return int(token)
def _pages_from_ranges(ranges: str, total_pages: int | None = None) -> set[int]:
"""Convert page range string to set of 0-based page numbers.
The token ``end`` (case-insensitive) is an alias for the last page of the
document. It is resolved using ``total_pages``; if ``end`` appears in the
string and ``total_pages`` is None, a :class:`BadArgsError` is raised.
"""
pages: list[int] = []
page_groups = ranges.replace(' ', '').split(',')
for group in page_groups:
@@ -75,10 +104,15 @@ def _pages_from_ranges(ranges: str) -> set[int]:
try:
start, end = group.split('-')
except ValueError:
pages.append(int(group) - 1)
try:
pages.append(_resolve_page_token(group, total_pages) - 1)
except ValueError:
raise BadArgsError(f"invalid page number '{group}'") from None
else:
try:
new_pages = list(range(int(start) - 1, int(end)))
start_n = _resolve_page_token(start, total_pages)
end_n = _resolve_page_token(end, total_pages)
new_pages = list(range(start_n - 1, end_n))
if not new_pages:
raise BadArgsError(
f"invalid page subrange '{start}-{end}'"
@@ -332,11 +366,19 @@ class OcrOptions(BaseModel):
@field_validator('pages')
@classmethod
def validate_pages_format(cls, v):
"""Convert page ranges string to set of page numbers."""
"""Convert page ranges string to set of page numbers.
If the string uses the ``end`` alias, the original string is preserved
so that resolution can happen later, once the document's page count is
known.
"""
if v is None:
return v
if isinstance(v, set):
return v # Already processed
if _has_end_alias(v):
# Defer resolution until total page count is known
return v
# Convert string ranges to set of page numbers
return _pages_from_ranges(v)
@@ -582,6 +624,13 @@ class OcrOptions(BaseModel):
value = getattr(self, flat_name)
if value is not None:
kwargs[field_name] = _convert_value(value)
# Plugin-scoped fields that aren't in the central OcrOptions
# registry: argparse stores them in extra_attrs under the
# namespace_field name.
elif flat_name in self.extra_attrs:
value = self.extra_attrs[flat_name]
if value is not None:
kwargs[field_name] = _convert_value(value)
# Also check direct field name (for fields like jbig2_lossy)
elif field_name in OcrOptions.model_fields:
value = getattr(self, field_name)
+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}"
+7 -2
View File
@@ -343,12 +343,17 @@ def setup_pipeline(
def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo:
# Handle pages field - it might be a string that needs conversion
# Handle pages field - it might be a string that needs conversion.
# A string indicates the ``end`` alias was used and resolution was
# deferred; we resolve it now using the document's actual page count.
check_pages = options.pages
if isinstance(check_pages, str):
from ocrmypdf._options import _pages_from_ranges
check_pages = _pages_from_ranges(check_pages)
with Pdf.open(pdf_path) as pdf:
total_pages = len(pdf.pages)
check_pages = _pages_from_ranges(check_pages, total_pages=total_pages)
options.pages = check_pages
return get_pdfinfo(
pdf_path,
+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.4.2"
__version__ = "17.7.0"
+91 -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."""
@@ -54,6 +78,27 @@ class GhostscriptOptions(BaseModel):
pdfa_image_compression: Annotated[
PdfaImageCompression, Field(description="PDF/A image compression method")
] = PdfaImageCompression.AUTO
jpeg_quality: Annotated[
int | None,
Field(
ge=0,
le=100,
description=(
"JPEG quality (0-100) for Ghostscript image recompression during "
"PDF/A generation; None uses Ghostscript's default."
),
),
] = None
jpeg_maxdpi: Annotated[
int | None,
Field(
ge=1,
description=(
"Maximum DPI for Ghostscript image downsampling during PDF/A "
"generation."
),
),
] = None
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
@@ -78,14 +123,48 @@ 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 "
"preserves the original compression of all images.",
)
gs.add_argument(
'--ghostscript-jpeg-quality',
type=int,
metavar='Q',
default=None,
dest=f'{namespace}_jpeg_quality',
help=(
"Advanced: Set Ghostscript's -dJPEGQ for images that Ghostscript "
"transcodes to JPEG during PDF/A generation. 0 is maximum "
"compression; 100 is best quality. If omitted, Ghostscript's "
"default is used. This only affects images Ghostscript chooses "
"to recompress; for general JPEG quality tuning prefer "
"--jpeg-quality, which is applied by the OCRmyPDF optimizer."
),
)
gs.add_argument(
'--ghostscript-jpeg-maxdpi',
type=int,
metavar='DPI',
default=None,
dest=f'{namespace}_jpeg_maxdpi',
help=(
"Advanced: Force Ghostscript to downsample color, grayscale, "
"and monochrome images in PDF/A output to the given maximum DPI. "
"Reducing JPEG quality usually gives better results than "
"downsampling at the same file size, and can degrade quality "
"of high-resolution monochrome masks."
),
)
@hookimpl
@@ -177,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,
@@ -347,11 +428,18 @@ 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,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
+30 -25
View File
@@ -48,27 +48,18 @@ def _open_pdf_document(input_file: Path):
return pdfium.PdfDocument(input_file)
def _calculate_mediabox_crop(page) -> tuple[float, float, float, float]:
"""Calculate crop values to expand rendering from CropBox to MediaBox.
def _expand_cropbox_to_mediabox(page) -> None:
"""Set the page's CropBox to its MediaBox so PDFium renders the full page.
By default pypdfium2 renders to the CropBox. To render the full MediaBox,
we need negative crop values to expand the rendering area.
Returns:
Tuple of (left, bottom, right, top) crop values. Negative values
expand the rendering area beyond the CropBox to the MediaBox.
PDFium renders to the CropBox by default. Negative ``crop`` values to
``render()`` are not supported and only pad the output canvas without
expanding the rendered area content outside the CropBox is clipped.
The supported approach is to widen the CropBox in memory before rendering.
The document is never saved back to disk, so this mutation is local.
See https://github.com/ocrmypdf/OCRmyPDF/issues/1685.
"""
mediabox = page.get_mediabox() # (left, bottom, right, top)
cropbox = page.get_cropbox() # (left, bottom, right, top), defaults to mediabox
# Calculate how much to expand from cropbox to mediabox
# Negative values = expand, positive = shrink
return (
mediabox[0] - cropbox[0], # Expand left
mediabox[1] - cropbox[1], # Expand bottom
cropbox[2] - mediabox[2], # Expand right
cropbox[3] - mediabox[3], # Expand top
)
page.set_cropbox(*mediabox)
def _render_page_to_bitmap(
@@ -105,16 +96,20 @@ 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',
)
# Calculate crop to render the appropriate box
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
crop = (0, 0, 0, 0) if use_cropbox else _calculate_mediabox_crop(page)
if not use_cropbox:
_expand_cropbox_to_mediabox(page)
bitmap = page.render(
scale=scale,
rotation=0, # We already set rotation on the page
crop=crop,
may_draw_forms=True,
draw_annots=True,
grayscale=grayscale,
@@ -167,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')
@@ -194,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'):
@@ -252,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,
+14 -5
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(
@@ -387,7 +391,8 @@ Online documentation is located at:
type=str,
help=(
"Limit OCR to the specified pages (ranges or comma separated), "
"skipping others"
"skipping others. The token 'end' is an alias for the last page, "
"so e.g. '3-end' OCRs from page 3 to the last page."
),
)
advanced.add_argument(
@@ -422,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.
"""
)
+23 -4
View File
@@ -260,10 +260,19 @@ def _find_image_xrefs_container(
exclude_xrefs: MutableSet[Xref],
pageno_for_xref: dict[Xref, int],
depth: int = 0,
visited_forms: MutableSet[Xref] | None = None,
):
"""Find all image XRefs or Form XObject and add to the include/exclude sets."""
# Form XObjects are not added to include/exclude_xrefs, so the dedup
# check below doesn't catch Form-XObject cycles or DAGs. Track them in
# a shared set so each Form is only descended into once per document
# (issue #1321).
if visited_forms is None:
visited_forms = set()
if depth > 10:
log.warning("Recursion depth exceeded in _find_image_xrefs_page")
# With visited_forms memoization, this is a soft DAG-height guard
# rather than a cycle defense, so a debug log is sufficient.
log.debug("Recursion depth exceeded in _find_image_xrefs_page")
return
try:
xobjs = container.Resources.XObject
@@ -276,7 +285,9 @@ def _find_image_xrefs_container(
if xref in include_xrefs or xref in exclude_xrefs:
continue # Already processed
if Name.Subtype in image and image.Subtype == Name.Form:
# Recurse into Form XObjects
if xref in visited_forms:
continue
visited_forms.add(xref)
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
_find_image_xrefs_container(
pdf,
@@ -286,6 +297,7 @@ def _find_image_xrefs_container(
exclude_xrefs,
pageno_for_xref,
depth + 1,
visited_forms,
)
continue
if Name.SMask in image:
@@ -342,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'
+25 -339
View File
@@ -1,345 +1,31 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Wrappers to manage subprocess calls."""
"""Wrappers to manage subprocess calls.
This package is split into three private submodules by concern:
- :mod:`ocrmypdf.subprocess._run` - low-level execution wrappers (``run``,
``run_polling_stderr``) that add OCRmyPDF-aware logging and Windows PATH
resolution. Useful as drop-in replacements for :func:`subprocess.run`.
- :mod:`ocrmypdf.subprocess._version` - version probing (``get_version``).
- :mod:`ocrmypdf.subprocess._check` - startup validation
(``check_external_program``) with platform-aware error messages.
The names below are the stable public API. Importing from the private
submodules directly is not supported for external code.
"""
from __future__ import annotations
import logging
import os
import re
import sys
from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
from subprocess import run as subprocess_run
from ocrmypdf.subprocess._check import check_external_program
from ocrmypdf.subprocess._run import Args, Environ, run, run_polling_stderr
from ocrmypdf.subprocess._version import get_version
from packaging.version import Version
from ocrmypdf.exceptions import MissingDependencyError
# pylint: disable=logging-format-interpolation
log = logging.getLogger(__name__)
Args = Sequence[Path | str]
Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access
def run(
args: Args,
*,
env: Environ | None = None,
logs_errors_to_stdout: bool = False,
check: bool = False,
**kwargs,
) -> CompletedProcess:
"""Wrapper around :py:func:`subprocess.run`.
The main purpose of this wrapper is to log subprocess output in an orderly
fashion that identifies the responsible subprocess. An additional
task is that this function goes to greater lengths to find possible Windows
locations of our dependencies when they are not on the system PATH.
Arguments should be identical to ``subprocess.run``, except for following:
Args:
args: Positional arguments to pass to ``subprocess.run``.
env: A set of environment variables. If None, the OS environment is used.
logs_errors_to_stdout: If True, indicates that the process writes its error
messages to stdout rather than stderr, so stdout should be logged
if there is an error. If False, stderr is logged. Could be used with
stderr=STDOUT, stdout=PIPE for example.
check: If True, raise an exception if the process exits with a non-zero
status code. If False, the return value will indicate success or failure.
kwargs: Additional arguments to pass to ``subprocess.run``.
"""
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
stderr = None
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
try:
proc = subprocess_run(args, env=env, check=check, **kwargs)
except CalledProcessError as e:
stderr = getattr(e, stderr_name, None)
raise
else:
stderr = getattr(proc, stderr_name, None)
finally:
if process_log.isEnabledFor(logging.DEBUG) and stderr:
with suppress(AttributeError, UnicodeDecodeError):
stderr = stderr.decode('utf-8', 'replace')
if logs_errors_to_stdout:
process_log.debug("stdout/stderr = %s", stderr)
else:
process_log.debug("stderr = %s", stderr)
return proc
def run_polling_stderr(
args: Args,
*,
callback: Callable[[str], None],
check: bool = False,
env: Environ | None = None,
**kwargs,
) -> CompletedProcess:
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
Every line of produced by stderr will be forwarded to the callback function.
The intended use is monitoring progress of subprocesses that output their
own progress indicators. In addition, each line will be logged if debug
logging is enabled.
Requires stderr to be opened in text mode for ease of handling errors. In
addition the expected encoding= and errors= arguments should be set. Note
that if stdout is already set up, it need not be binary.
"""
args, env, process_log, text = _fix_process_args(args, env, kwargs)
assert text, "Must use text=True"
with Popen(args, env=env, **kwargs) as proc:
lines = []
while proc.poll() is None:
if proc.stderr is None:
continue
for msg in iter(proc.stderr.readline, ''):
if process_log.isEnabledFor(logging.DEBUG):
process_log.debug(msg.strip())
callback(msg)
lines.append(msg)
stderr = ''.join(lines)
if check and proc.returncode != 0:
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
def _fix_process_args(
args: Args, env: Environ | None, kwargs
) -> tuple[Args, Environ, logging.Logger, bool]:
if not env:
env = os.environ
# Search in spoof path if necessary
program = str(args[0])
if sys.platform == 'win32':
# pylint: disable=import-outside-toplevel
from ocrmypdf.subprocess._windows import fix_windows_args
args = fix_windows_args(program, args, env)
log.debug("Running: %s", args)
process_log = log.getChild(os.path.basename(program))
text = bool(kwargs.get('text', False))
return args, env, process_log, text
def get_version(
program: str,
*,
version_arg: str = '--version',
regex=r'(\d+(\.\d+)*)',
env: Environ | None = None,
) -> str:
"""Get the version of the specified program.
Arguments:
program: The program to version check.
version_arg: The argument needed to ask for its version, e.g. ``--version``.
regex: A regular expression to parse the program's output and obtain the
version.
env: Custom ``os.environ`` in which to run program.
"""
args_prog = [program, version_arg]
try:
proc = run(
args_prog,
close_fds=True,
text=True,
stdout=PIPE,
stderr=STDOUT,
check=True,
env=env,
)
output: str = proc.stdout
except FileNotFoundError as e:
raise MissingDependencyError(
f"Could not find program '{program}' on the PATH"
) from e
except CalledProcessError as e:
if e.returncode != 0:
log.exception(e)
raise MissingDependencyError(
f"Ran program '{program}' but it exited with an error:\n{e.output}"
) from e
raise MissingDependencyError(
f"Could not find program '{program}' on the PATH"
) from e
match = re.match(regex, output.strip())
if not match:
raise MissingDependencyError(
f"The program '{program}' did not report its version. "
f"Message was:\n{output}"
)
version = match.group(1)
return version
MISSING_PROGRAM = '''
The program '{program}' could not be executed or was not found on your
system PATH.
'''
MISSING_OPTIONAL_PROGRAM = '''
The program '{program}' could not be executed or was not found on your
system PATH. This program is required when you use the
{required_for} arguments. You could try omitting these arguments, or install
the package.
'''
MISSING_RECOMMEND_PROGRAM = '''
The program '{program}' could not be executed or was not found on your
system PATH. This program is recommended when using the {required_for} arguments,
but not required, so we will proceed. For best results, install the program.
'''
OLD_VERSION = '''
OCRmyPDF requires '{program}' {need_version} or higher. Your system appears
to have {found_version}. Please update this program.
'''
OLD_VERSION_REQUIRED_FOR = '''
OCRmyPDF requires '{program}' {need_version} or higher when run with the
{required_for} arguments. {program} {found_version} is installed.
If you omit these arguments, OCRmyPDF may be able to
proceed. For best results, update the program.
'''
OSX_INSTALL_ADVICE = '''
If you have homebrew installed, try these command to install the missing
package:
brew install {package}
'''
LINUX_INSTALL_ADVICE = '''
On systems with the aptitude package manager (Debian, Ubuntu), try these
commands:
sudo apt update
sudo apt install {package}
On RPM-based systems (Red Hat, Fedora), try this command:
sudo dnf install {package}
'''
WINDOWS_INSTALL_ADVICE = '''
If not already installed, install the Chocolatey package manager. Then use
a command prompt to install the missing package:
choco install {package}
'''
def _get_platform() -> str:
if sys.platform.startswith('freebsd'):
return 'freebsd'
elif sys.platform.startswith('linux'):
return 'linux'
elif sys.platform.startswith('win'):
return 'windows'
return sys.platform
def _error_trailer(program: str, package: str | Mapping[str, str], **kwargs) -> None:
del kwargs
if isinstance(package, Mapping):
package = package.get(_get_platform(), program)
if _get_platform() == 'darwin':
log.info(OSX_INSTALL_ADVICE.format(**locals()))
elif _get_platform() == 'linux':
log.info(LINUX_INSTALL_ADVICE.format(**locals()))
elif _get_platform() == 'windows':
log.info(WINDOWS_INSTALL_ADVICE.format(**locals()))
def _error_missing_program(
program: str, package: str, required_for: str | None, recommended: bool
) -> None:
# pylint: disable=unused-argument
if recommended:
log.warning(MISSING_RECOMMEND_PROGRAM.format(**locals()))
elif required_for:
log.error(MISSING_OPTIONAL_PROGRAM.format(**locals()))
else:
log.error(MISSING_PROGRAM.format(**locals()))
_error_trailer(**locals())
def _error_old_version(
program: str,
package: str,
need_version: str,
found_version: str,
required_for: str | None,
) -> None:
# pylint: disable=unused-argument
if required_for:
log.error(OLD_VERSION_REQUIRED_FOR.format(**locals()))
else:
log.error(OLD_VERSION.format(**locals()))
_error_trailer(**locals())
def check_external_program(
*,
program: str,
package: str,
version_checker: Callable[[], Version],
need_version: str | Version,
required_for: str | None = None,
recommended: bool = False,
version_parser: type[Version] = Version,
) -> None:
"""Check for required version of external program and raise exception if not.
Args:
program: The name of the program to test.
package: The name of a software package that typically supplies this program.
Usually the same as program.
version_checker: A callable without arguments that retrieves the installed
version of program.
need_version: The minimum required version.
required_for: The name of an argument of feature that requires this program.
recommended: If this external program is recommended, instead of raising
an exception, log a warning and allow execution to continue.
version_parser: A class that should be used to parse and compare version
numbers. Used when version numbers do not follow standard conventions.
"""
if not isinstance(need_version, Version):
need_version = version_parser(need_version)
try:
found_version = version_checker()
except (CalledProcessError, FileNotFoundError) as e:
_error_missing_program(program, package, required_for, recommended)
if not recommended:
raise MissingDependencyError(program) from e
return
except MissingDependencyError:
_error_missing_program(program, package, required_for, recommended)
if not recommended:
raise
return
if found_version and found_version < need_version:
_error_old_version(
program, package, str(need_version), str(found_version), required_for
)
if not recommended:
raise MissingDependencyError(program)
log.debug('Found %s %s', program, found_version)
__all__ = [
'Args',
'Environ',
'check_external_program',
'get_version',
'run',
'run_polling_stderr',
]
+171
View File
@@ -0,0 +1,171 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Validate that required external programs are installed and new enough."""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable, Mapping
from subprocess import CalledProcessError
from packaging.version import Version
from ocrmypdf.exceptions import MissingDependencyError
log = logging.getLogger('ocrmypdf.subprocess')
MISSING_PROGRAM = '''
The program '{program}' could not be executed or was not found on your
system PATH.
'''
MISSING_OPTIONAL_PROGRAM = '''
The program '{program}' could not be executed or was not found on your
system PATH. This program is required when you use the
{required_for} arguments. You could try omitting these arguments, or install
the package.
'''
MISSING_RECOMMEND_PROGRAM = '''
The program '{program}' could not be executed or was not found on your
system PATH. This program is recommended when using the {required_for} arguments,
but not required, so we will proceed. For best results, install the program.
'''
OLD_VERSION = '''
OCRmyPDF requires '{program}' {need_version} or higher. Your system appears
to have {found_version}. Please update this program.
'''
OLD_VERSION_REQUIRED_FOR = '''
OCRmyPDF requires '{program}' {need_version} or higher when run with the
{required_for} arguments. {program} {found_version} is installed.
If you omit these arguments, OCRmyPDF may be able to
proceed. For best results, update the program.
'''
OSX_INSTALL_ADVICE = '''
If you have homebrew installed, try these command to install the missing
package:
brew install {package}
'''
LINUX_INSTALL_ADVICE = '''
On systems with the aptitude package manager (Debian, Ubuntu), try these
commands:
sudo apt update
sudo apt install {package}
On RPM-based systems (Red Hat, Fedora), try this command:
sudo dnf install {package}
'''
WINDOWS_INSTALL_ADVICE = '''
If not already installed, install the Chocolatey package manager. Then use
a command prompt to install the missing package:
choco install {package}
'''
def _get_platform() -> str:
if sys.platform.startswith('freebsd'):
return 'freebsd'
elif sys.platform.startswith('linux'):
return 'linux'
elif sys.platform.startswith('win'):
return 'windows'
return sys.platform
def _error_trailer(program: str, package: str | Mapping[str, str], **kwargs) -> None:
del kwargs
if isinstance(package, Mapping):
package = package.get(_get_platform(), program)
if _get_platform() == 'darwin':
log.info(OSX_INSTALL_ADVICE.format(**locals()))
elif _get_platform() == 'linux':
log.info(LINUX_INSTALL_ADVICE.format(**locals()))
elif _get_platform() == 'windows':
log.info(WINDOWS_INSTALL_ADVICE.format(**locals()))
def _error_missing_program(
program: str, package: str, required_for: str | None, recommended: bool
) -> None:
# pylint: disable=unused-argument
if recommended:
log.warning(MISSING_RECOMMEND_PROGRAM.format(**locals()))
elif required_for:
log.error(MISSING_OPTIONAL_PROGRAM.format(**locals()))
else:
log.error(MISSING_PROGRAM.format(**locals()))
_error_trailer(**locals())
def _error_old_version(
program: str,
package: str,
need_version: str,
found_version: str,
required_for: str | None,
) -> None:
# pylint: disable=unused-argument
if required_for:
log.error(OLD_VERSION_REQUIRED_FOR.format(**locals()))
else:
log.error(OLD_VERSION.format(**locals()))
_error_trailer(**locals())
def check_external_program(
*,
program: str,
package: str,
version_checker: Callable[[], Version],
need_version: str | Version,
required_for: str | None = None,
recommended: bool = False,
version_parser: type[Version] = Version,
) -> None:
"""Check for required version of external program and raise exception if not.
Args:
program: The name of the program to test.
package: The name of a software package that typically supplies this program.
Usually the same as program.
version_checker: A callable without arguments that retrieves the installed
version of program.
need_version: The minimum required version.
required_for: The name of an argument of feature that requires this program.
recommended: If this external program is recommended, instead of raising
an exception, log a warning and allow execution to continue.
version_parser: A class that should be used to parse and compare version
numbers. Used when version numbers do not follow standard conventions.
"""
if not isinstance(need_version, Version):
need_version = version_parser(need_version)
try:
found_version = version_checker()
except (CalledProcessError, FileNotFoundError) as e:
_error_missing_program(program, package, required_for, recommended)
if not recommended:
raise MissingDependencyError(program) from e
return
except MissingDependencyError:
_error_missing_program(program, package, required_for, recommended)
if not recommended:
raise
return
if found_version and found_version < need_version:
_error_old_version(
program, package, str(need_version), str(found_version), required_for
)
if not recommended:
raise MissingDependencyError(program)
log.debug('Found %s %s', program, found_version)
+137
View File
@@ -0,0 +1,137 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Low-level wrappers around :py:mod:`subprocess`.
These functions exist to give OCRmyPDF child processes uniform logging
behavior and to route through any platform-specific PATH fix-ups before
invocation. They are intended as drop-in replacements for
:py:func:`subprocess.run` in contexts where that routing is desirable
(for example, plugin-provided tools).
"""
from __future__ import annotations
import logging
import os
import sys
from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from subprocess import CalledProcessError, CompletedProcess, Popen
from subprocess import run as subprocess_run
log = logging.getLogger('ocrmypdf.subprocess')
Args = Sequence[Path | str]
Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access
def run(
args: Args,
*,
env: Environ | None = None,
logs_errors_to_stdout: bool = False,
check: bool = False,
**kwargs,
) -> CompletedProcess:
"""Wrapper around :py:func:`subprocess.run`.
The main purpose of this wrapper is to log subprocess output in an orderly
fashion that identifies the responsible subprocess. An additional
task is that this function goes to greater lengths to find possible Windows
locations of our dependencies when they are not on the system PATH.
Arguments should be identical to ``subprocess.run``, except for following:
Args:
args: Positional arguments to pass to ``subprocess.run``.
env: A set of environment variables. If None, the OS environment is used.
logs_errors_to_stdout: If True, indicates that the process writes its error
messages to stdout rather than stderr, so stdout should be logged
if there is an error. If False, stderr is logged. Could be used with
stderr=STDOUT, stdout=PIPE for example.
check: If True, raise an exception if the process exits with a non-zero
status code. If False, the return value will indicate success or failure.
kwargs: Additional arguments to pass to ``subprocess.run``.
"""
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
stderr = None
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
try:
proc = subprocess_run(args, env=env, check=check, **kwargs)
except CalledProcessError as e:
stderr = getattr(e, stderr_name, None)
raise
else:
stderr = getattr(proc, stderr_name, None)
finally:
if process_log.isEnabledFor(logging.DEBUG) and stderr:
with suppress(AttributeError, UnicodeDecodeError):
stderr = stderr.decode('utf-8', 'replace')
if logs_errors_to_stdout:
process_log.debug("stdout/stderr = %s", stderr)
else:
process_log.debug("stderr = %s", stderr)
return proc
def run_polling_stderr(
args: Args,
*,
callback: Callable[[str], None],
check: bool = False,
env: Environ | None = None,
**kwargs,
) -> CompletedProcess:
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
Every line of produced by stderr will be forwarded to the callback function.
The intended use is monitoring progress of subprocesses that output their
own progress indicators. In addition, each line will be logged if debug
logging is enabled.
Requires stderr to be opened in text mode for ease of handling errors. In
addition the expected encoding= and errors= arguments should be set. Note
that if stdout is already set up, it need not be binary.
"""
args, env, process_log, text = _fix_process_args(args, env, kwargs)
assert text, "Must use text=True"
with Popen(args, env=env, **kwargs) as proc:
lines = []
while proc.poll() is None:
if proc.stderr is None:
continue
for msg in iter(proc.stderr.readline, ''):
if process_log.isEnabledFor(logging.DEBUG):
process_log.debug(msg.strip())
callback(msg)
lines.append(msg)
stderr = ''.join(lines)
if check and proc.returncode != 0:
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
def _fix_process_args(
args: Args, env: Environ | None, kwargs
) -> tuple[Args, Environ, logging.Logger, bool]:
if not env:
env = os.environ
# Search in spoof path if necessary
program = str(args[0])
if sys.platform == 'win32':
# pylint: disable=import-outside-toplevel
from ocrmypdf.subprocess._windows import fix_windows_args
args = fix_windows_args(program, args, env)
log.debug("Running: %s", args)
process_log = log.getChild(os.path.basename(program))
text = bool(kwargs.get('text', False))
return args, env, process_log, text
+73
View File
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Extract version strings from external programs."""
from __future__ import annotations
import logging
import re
from subprocess import PIPE, STDOUT, CalledProcessError
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess._run import Environ
log = logging.getLogger('ocrmypdf.subprocess')
def get_version(
program: str,
*,
version_arg: str = '--version',
regex=r'(\d+(\.\d+)*)',
env: Environ | None = None,
) -> str:
"""Get the version of the specified program.
Arguments:
program: The program to version check.
version_arg: The argument needed to ask for its version, e.g. ``--version``.
regex: A regular expression to parse the program's output and obtain the
version.
env: Custom ``os.environ`` in which to run program.
"""
# Late import of the public ``run`` so that tests patching
# ``ocrmypdf.subprocess.run`` affect this function. Binding ``run`` at
# module load time would capture the real implementation and bypass the
# patch.
from ocrmypdf import subprocess as _sp
args_prog = [program, version_arg]
try:
proc = _sp.run(
args_prog,
close_fds=True,
text=True,
stdout=PIPE,
stderr=STDOUT,
check=True,
env=env,
)
output: str = proc.stdout
except FileNotFoundError as e:
raise MissingDependencyError(
f"Could not find program '{program}' on the PATH"
) from e
except CalledProcessError as e:
if e.returncode != 0:
log.exception(e)
raise MissingDependencyError(
f"Ran program '{program}' but it exited with an error:\n{e.output}"
) from e
raise MissingDependencyError(
f"Could not find program '{program}' on the PATH"
) from e
match = re.match(regex, output.strip())
if not match:
raise MissingDependencyError(
f"The program '{program}' did not report its version. "
f"Message was:\n{output}"
)
version = match.group(1)
return version
+327 -5
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,185 @@ 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 (
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=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='auto',
color_conversion_strategy='LeaveColorUnchanged',
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=95' in args
# No downsample switches when jpeg_maxdpi is not set
assert not any(a.startswith('-dDownsampleColorImages') for a in args)
assert not any(a.startswith('-dColorImageResolution') for a in args)
def test_generate_pdfa_uses_user_jpeg_quality(outdir):
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=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='jpeg',
color_conversion_strategy='RGB',
jpeg_quality=72,
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=72' in args
assert '-dJPEGQ=95' not in args
def test_generate_pdfa_jpeg_quality_zero_is_max_compression(outdir):
"""Explicit jpeg_quality=0 must reach Ghostscript as -dJPEGQ=0.
Ghostscript accepts 0 as a valid quality value (maximum compression);
it must not be silently replaced by the default 95.
"""
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=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='jpeg',
color_conversion_strategy='RGB',
jpeg_quality=0,
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=0' in args
assert '-dJPEGQ=95' not in args
def test_generate_pdfa_honors_jpeg_maxdpi(outdir):
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=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='auto',
color_conversion_strategy='LeaveColorUnchanged',
jpeg_maxdpi=300,
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=95' in args
assert '-dDownsampleColorImages=true' in args
assert '-dColorImageDownsampleThreshold=1.0' in args
assert '-dDownsampleGrayImages=true' in args
assert '-dGrayImageDownsampleThreshold=1.0' in args
assert '-dDownsampleMonoImages=true' in args
assert '-dMonoImageDownsampleThreshold=1.0' in args
assert '-dColorImageResolution=300' in args
assert '-dGrayImageResolution=300' in args
assert '-dMonoImageResolution=300' in args
def test_ghostscript_jpeg_options_via_cli(resources, outpdf):
"""End-to-end: CLI flags reach the ghostscript plugin namespace."""
with patch(
'ocrmypdf._exec.ghostscript.generate_pdfa',
wraps=ghostscript.generate_pdfa,
) as gen_mock:
run_ocrmypdf_api(
resources / 'francais.pdf',
outpdf,
'--output-type',
'pdfa',
'--ghostscript-jpeg-quality',
'60',
'--ghostscript-jpeg-maxdpi',
'150',
'--plugin',
'tests/plugins/tesseract_noop.py',
)
assert gen_mock.called
call_kwargs = gen_mock.call_args.kwargs
assert call_kwargs['jpeg_quality'] == 60
assert call_kwargs['jpeg_maxdpi'] == 150
def test_gs_render_failure(resources, outpdf, caplog):
exitcode = run_ocrmypdf_api(
resources / 'blank.pdf',
@@ -176,9 +359,9 @@ def test_ghostscript_pdfa_failure(resources, outpdf, caplog):
'--plugin',
'tests/plugins/gs_pdfa_failure.py',
)
assert (
exitcode == ExitCode.pdfa_conversion_failed
), "Unexpected return when PDF/A fails"
assert exitcode == ExitCode.pdfa_conversion_failed, (
"Unexpected return when PDF/A fails"
)
def test_ghostscript_feature_elision(resources, outpdf):
@@ -204,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
@@ -439,7 +704,9 @@ class TestGs106JpegCorruptionRepair:
repaired_bytes_list.append(obj.read_raw_bytes())
assert len(repaired_bytes_list) == len(original_bytes_list)
for orig, repaired_bytes in zip(original_bytes_list, repaired_bytes_list, strict=False):
for orig, repaired_bytes in zip(
original_bytes_list, repaired_bytes_list, strict=False
):
assert orig == repaired_bytes, "Repaired bytes should match original"
# Check that error/warning was logged
@@ -468,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
+72 -6
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):
@@ -215,6 +215,72 @@ def test_find_formx(resources):
assert pagenos[xref] == 0
def test_find_formx_circular_reference(resources, tmp_path, caplog):
"""Regression for issue #1321.
Some PDFs (notably PowerPoint exports) contain Form XObjects that
reference themselves or each other in a cycle. The recursion guard in
_find_image_xrefs_container only deduplicates *image* xrefs, so a Form
XObject cycle would re-enter every branch until the depth limit fired,
producing thousands of "Recursion depth exceeded" warnings (and minutes
of wall-clock time on real-world inputs).
"""
import logging
src = resources / 'formxobject.pdf'
out = tmp_path / 'circular_form.pdf'
with pikepdf.open(src) as pdf:
# /Form1 lives at xref 10. Replace its Resources.XObject with three
# 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})
pdf.save(out)
caplog.set_level(logging.WARNING, logger='ocrmypdf.optimize')
with pikepdf.open(out) as pdf:
opt._find_image_xrefs(pdf)
n_warnings = sum(
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, (
f"Form XObject cycle should be detected without depth-limit warnings; "
f"got {n_warnings}"
)
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
+59
View File
@@ -42,6 +42,35 @@ def test_pages(pages, result):
assert _pages_from_ranges(pages) == result
@pytest.mark.parametrize(
'pages, total_pages, result',
[
['end', 10, {9}],
['END', 10, {9}],
['1-end', 3, {0, 1, 2}],
['3-end', 5, {2, 3, 4}],
['end-end', 7, {6}],
['1,end', 4, {0, 3}],
['2-4,end', 10, {1, 2, 3, 9}],
['end,end,end', 5, {4}],
['end-1', 5, BadArgsError], # empty range when end > 1
],
)
def test_pages_end_alias(pages, total_pages, result):
if isinstance(result, type):
with pytest.raises(result):
_pages_from_ranges(pages, total_pages=total_pages)
else:
assert _pages_from_ranges(pages, total_pages=total_pages) == result
def test_end_alias_requires_total_pages():
with pytest.raises(BadArgsError, match="total page count"):
_pages_from_ranges('1-end')
with pytest.raises(BadArgsError, match="total page count"):
_pages_from_ranges('end')
def test_nonmonotonic_warning(caplog):
pages = _pages_from_ranges('1, 3, 2')
assert pages == {0, 1, 2}
@@ -61,3 +90,33 @@ def test_limited_pages(multipage, outpdf):
assert not pi.pages[0].has_text
assert pi.pages[4].has_text
assert pi.pages[5].has_text
def test_limited_pages_end_alias(multipage, outpdf):
# multipage has 6 pages; 5-end == pages 5..6
ocrmypdf.ocr(
multipage,
outpdf,
pages='5-end',
optimize=0,
output_type='pdf',
plugins=['tests/plugins/tesseract_cache.py'],
)
pi = PdfInfo(outpdf)
assert not pi.pages[0].has_text
assert pi.pages[4].has_text
assert pi.pages[5].has_text
def test_pages_end_alone(multipage, outpdf):
ocrmypdf.ocr(
multipage,
outpdf,
pages='end',
optimize=0,
output_type='pdf',
plugins=['tests/plugins/tesseract_cache.py'],
)
pi = PdfInfo(outpdf)
assert not pi.pages[0].has_text
assert pi.pages[5].has_text
+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
+15 -1
View File
@@ -128,7 +128,6 @@ def test_timeout(caplog):
(b'Error in boxClipToRectangle', ''),
(b'an unexpected error', 'an unexpected error'),
(b'a dire warning', 'a dire warning'),
(b'read_params_file something', 'read_params_file'),
(b'an innocent message', 'innocent'),
(b'\x7f\x7f\x80innocent unicode failure', 'innocent'),
],
@@ -142,12 +141,27 @@ 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')
assert 'not found' in caplog.text
def test_tesseract_log_output_raises_on_missing_config(caplog):
with pytest.raises(tesseract.TesseractConfigError) as excinfo:
tesseract.tesseract_log_output(b"read_params_file: Can't open hocr")
assert 'hocr' in excinfo.value.args[0]
assert 'read_params_file' in caplog.text
def test_blocked_language(resources, no_outpdf):
infile = resources / 'masks.pdf'
for bad_lang in ['osd', 'equ']:
+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
+1079 -980
View File
File diff suppressed because it is too large Load Diff