Compare commits

...
132 Commits
Author SHA1 Message Date
James R. Barlow 177349cc84 v14.2.1 release notes 2023-05-23 13:21:46 -07:00
James R. Barlow 070c9772ce Add test to confirm we can find formx optimizable images 2023-05-23 00:33:54 -07:00
James R. Barlow 1bc09045a5 optimize: Add recursion into Form XObjects 2023-05-23 00:07:15 -07:00
James R. Barlow e46a18dd2f Refactor searching for optimizable images 2023-05-22 23:42:10 -07:00
James R. Barlow c64871c2ed pyproject: Make classifiers more generic 2023-05-22 23:37:42 -07:00
James R. Barlow de909fb99a Docker arm64 needs to build pycairo; add its dependencies 2023-05-10 01:31:31 -07:00
James R. Barlow 731b2fc477 Fix interaction with old Python and singledispatch and typing 2023-05-10 00:15:34 -07:00
James R. Barlow 214f6ec759 v14.2.0 release notes 2023-05-09 23:32:57 -07:00
James R. Barlow 080aa4dbd1 imageops: fix rare assert failure 2023-05-09 23:27:08 -07:00
James R. Barlow 7af5dcd4a4 Improve directions about install other languages 2023-05-05 13:35:27 -07:00
James R. Barlow fe9f52fbe7 Remove Ghostscript UserUnit warning
All supported Ghostscript versions have proper UserUnit support.
2023-04-29 00:42:38 -07:00
James R. Barlow fcbdeb8dbe Fix resampling attributeerror when Pillow < 9.1.0 2023-04-25 15:17:45 -07:00
James R. Barlow cb251a8d03 Merge branches 'feature/better-imageops' and 'feature/cleanup-fitz' 2023-04-16 01:41:46 -07:00
James R. Barlow 3731fdfd72 Add --tesseract-downsample-above X 2023-04-16 01:41:19 -07:00
James R. Barlow b2e6a6431e Tidy use of pikepdf.open without with 2023-04-15 20:17:44 -07:00
James R. Barlow 9ff1e56bf6 Tidy test_metadata - importorskip idiom 2023-04-15 17:56:13 -07:00
James R. Barlow 2b30f74fce Improve imageops
Fix issues and add better tests. Require hypothesis.
2023-04-15 17:48:33 -07:00
James R. Barlow 10f4c48e0b Fix mypy errors 2023-04-14 13:49:14 -07:00
James R. Barlow 37d5c086bb Fix things not caught by ruff 2023-04-14 13:44:47 -07:00
James R. Barlow 91830627e5 v14.1.0 2023-04-14 13:11:10 -07:00
James R. Barlow f2fc37b257 Merge feature to downsample very large images 2023-04-14 13:09:15 -07:00
James R. Barlow a99e40fa84 Update notes about ocrmypdf.ocr usage 2023-04-14 11:54:58 -07:00
James R. Barlow 9ce692a6f1 ruff: further fixes 2023-04-14 02:39:36 -07:00
James R. Barlow a3c49b8f31 ruff: more lint 2023-04-14 02:19:19 -07:00
James R. Barlow 33b70be7d5 ruff: more fixes, mainly missing docstrings 2023-04-14 02:16:38 -07:00
James R. Barlow 4924b11b6b Additional ruff fixes 2023-04-14 01:25:16 -07:00
James R. Barlow 1d0e4e7c9f ruff: fix from __future__ and drop isort 2023-04-14 00:49:02 -07:00
James R. Barlow 9b8d14d16e Accept most of ruff's delinting 2023-04-14 00:45:34 -07:00
James R. Barlow b7eb93eb79 Adopt ruff and fix prelim lints 2023-04-14 00:19:17 -07:00
James R. Barlow 42c0d0f48f Update pre-commit 2023-04-14 00:06:13 -07:00
James R. Barlow 5fce50ff7c Change linter to ruff 2023-04-14 00:05:43 -07:00
James R. Barlow 765ed4c386 Remove older linters 2023-04-14 00:03:12 -07:00
James R. Barlow 1b2849ec0a Add different timeout control for non-OCR
This is mainly to permit the use of --tesseract-timeout 0 as a way of
disabling OCR but still allowing deskew and orientation detection to work.

The better solution will be introducing an explicit argument to
"don't ocr my pdf", but that will take more rework.
2023-04-13 23:59:58 -07:00
James R. Barlow 4f604591b4 Convert to image size based 2023-04-13 23:49:12 -07:00
James R. Barlow 01dc8e23ff As you were 2023-04-13 23:27:58 -07:00
James R. Barlow b432770cfc Add different timeout control for non-OCR
This is mainly to permit the use of --tesseract-timeout 0 as a way of
disabling OCR but still allowing deskew and orientation detection to work.

The better solution will be introducing an explicit argument to
"don't ocr my pdf", but that will take more rework.
2023-04-13 22:58:12 -07:00
James R. Barlow 5502fb8d9f jobcontext: typo 2023-04-13 16:04:46 -07:00
林博仁(Buo-ren, Lin)andGitHub e66922b030 Fix snap package building (#1082) (#1089)
* Fix snap package building (#1082)

Currently the snap package no longer contains the main Python package
of OCRmyPDF due to the fact that [Snapcraft doesn't run the `pip install .`
command when the setup.py isn't detected in the source tree][1].  This
patch incorporates a workaround from [@cjp256 at the Snapcraft Forum][2]
to generate a setup.py file from the pyproject.toml to make the package
works again.

[1]: [Line 143 of snapcraft/python.py at 0e2cf91 · snapcore/snapcraft · GitHub](https://github.com/snapcore/snapcraft/blob/0e2cf91/snapcraft_legacy/plugins/v2/python.py#L143)
[2]: [Ian Johnson's reply in "Building a core20 python snap using pyproject.toml - snapcraft - snapcraft.io"](https://forum.snapcraft.io/t/building-a-core20-python-snap-using-pyproject-toml/22028/2)

Fixes #1082.

Signed-off-by: 林博仁(Buo-ren Lin) <Buo.Ren.Lin@gmail.com>

* Remove unnecessary cloning of own source tree to build the snap package

Signed-off-by: 林博仁(Buo-ren Lin) <Buo.Ren.Lin@gmail.com>

---------

Signed-off-by: 林博仁(Buo-ren Lin) <Buo.Ren.Lin@gmail.com>
2023-04-13 00:06:19 -07:00
James R. Barlow 00e9759b16 readme: improve 2023-04-12 23:56:50 -07:00
Kian-Meng AngandGitHub ba10c5345b Fix typos (#1087)
Found via `codespell -S tests,LICENSES -L flate`
2023-03-29 21:57:32 -07:00
James R. Barlow 8a5f94988a v14.0.4 release notes 2023-03-17 14:32:04 -07:00
Frederick RobinsonandGitHub aa73e3c69f handle case when candidate is None (#1066) 2023-03-17 14:27:27 -07:00
James R. Barlow 9d5fa05a00 v14.0.3 2023-02-15 12:00:26 -08:00
997380e567 Avoid deleting /dev/null when run as root (#1068)
Co-authored-by: Andrew Wood <andrew@fluidgravity.co.uk>
2023-01-31 16:20:24 -08:00
comzineandGitHub 2685f910b1 watcher: added setting RETRIES_LOADING_FILE to avoid giving up to early (#1063) 2023-01-25 17:36:54 -08:00
Natanael ArndtandGitHub bfcc586032 tesseract-osd is also required on fedora (#1064) 2023-01-25 17:36:23 -08:00
Lucas LarsonandGitHub 2d77b95fd9 substitute broken link (#1057) (#1058)
use `formulae.brew.sh` as seen in other repositories¹ instead of
`brewformulas.org`, which has been offline since 2020,² to fix #1057.

1. https://github.com/search?type=code&q=/shields.io\/homebrew/+language:reStructuredText
2. https://gitlab.com/zedtux/brewformulas.org/issues/223#note_322019672

Signed-off-by: Lucas Larson <LucasLarson@riseup.net>

Signed-off-by: Lucas Larson <LucasLarson@riseup.net>
2023-01-15 13:13:41 -08:00
James R. Barlow f072e91120 v14.0.2 2023-01-03 17:07:53 -08:00
James R. Barlow efa2bca8a3 Github Actions ignores ||, force it to do the right thing 2023-01-03 14:51:09 -08:00
James R. Barlow b039010d3e Ignore brew update errors 2023-01-03 14:36:21 -08:00
James R. Barlow 3f7cefcf5d macos - drop Python 3.10 since homebrew is having trouble 2023-01-02 13:08:42 -08:00
James R. Barlow 45f97d1565 macos build: brew first? 2023-01-02 12:36:28 -08:00
James R. Barlow 1281f8eb68 Workaround 2to3 linking error in macos build 2023-01-02 01:10:20 -08:00
James R. Barlow 9ef61864fb Update Python versions 2023-01-01 19:13:50 -08:00
James R. Barlow 90b2119ad3 pdfinfo: skip null FormX entries 2023-01-01 16:58:14 -08:00
James R. Barlow f0cc7f2230 docs: explicitly end win32 support 2023-01-01 16:58:14 -08:00
M.Yasoob Ullah Khalid ☺andGitHub d60a384aab Fixed the source installation instructions (#1048) 2022-12-22 02:54:53 -08:00
James R. Barlow 14a6093636 Remove old issue templates 2022-12-14 17:33:00 -08:00
James R. Barlow 54b42d73ab Merge remote-tracking branch 'origin/master' 2022-12-14 17:31:56 -08:00
M.Yasoob Ullah Khalid ☺andGitHub 9abed14f1f fixed interchanged words (#1039) 2022-12-14 17:24:55 -08:00
Doug RinckesandGitHub d09f61d4fe log completion message (#1044)
This logs the "done" message if neither delete nor archive options are set.
2022-12-14 17:24:41 -08:00
M.Yasoob Ullah Khalid ☺andGitHub 4a0130649e Fixed some wording (#1045) 2022-12-14 17:24:22 -08:00
jbarlow83andGitHub a0224d94ca Update issue templates 2022-12-14 17:23:57 -08:00
Carlos GarcésandGitHub 9e7b9de830 Fix tesseract documentation url (#1047) 2022-12-14 11:58:21 -08:00
James R. Barlow 08fc5fc01d Fix issue templates - Github doesn't support comments above 2022-10-24 14:55:08 -07:00
James R. Barlow 110c75cba2 Use actual TesseractVersion for comparison 2022-10-03 23:28:36 -07:00
James R. Barlow 5afca3f342 Use relaxed version parsing 2022-10-03 22:44:35 -07:00
James R. Barlow d9eb0ba7ef v14.0.1 release notes 2022-10-03 22:02:50 -07:00
James R. Barlow 46d0978a09 Update version scripts to support Ghostscript 10.0 2022-10-03 21:59:31 -07:00
James R. Barlow 4e35100978 Docker image: add jbig2dec 2022-10-03 20:08:28 -07:00
James R. Barlow 7bd0e43243 Merge branch 'feature/py311' 2022-09-21 02:42:57 -07:00
James R. Barlow 9cd97da5f2 dockerfile: toml 2022-09-21 02:20:28 -07:00
James R. Barlow d002703c41 docker: install gpg-agent 2022-09-21 01:04:34 -07:00
James R. Barlow b1fecf3b05 Temporarily disable PyPy 3.8 and 3.9 testing
Minor pikepdf issue?
2022-09-21 01:00:08 -07:00
James R. Barlow c2ccc7f29d Fix test failure due to new logging from pikepdf 2022-09-21 01:00:08 -07:00
James R. Barlow 36dfd12e2c Enable Python 3.11 testing on Linux 2022-09-21 00:59:44 -07:00
James R. Barlow 7da4e6ca7f Address some linter warnings 2022-09-21 00:05:12 -07:00
James R. Barlow 16fc52079d Drop setup.py - use pyproject.toml exclusively 2022-09-20 23:47:40 -07:00
James R. Barlow f37decf3b3 Remove old pdfminer.six shim 2022-09-20 23:02:17 -07:00
James R. Barlow 4ad4a13ef0 Simplify dependencies 2022-09-20 22:50:44 -07:00
James R. Barlow 6f61f69a8d v14.0.0 release notes 2022-09-20 22:47:07 -07:00
James R. Barlow 88831e8ab1 Merge branch 'feature/tess5' 2022-09-20 22:33:07 -07:00
James R. Barlow 2ebc36fcec Merge branch 'feature/drop-3.7' 2022-09-20 22:32:59 -07:00
James R. Barlow 1709e23701 pipeline: add further typing details 2022-09-19 20:19:15 -07:00
James R. Barlow 2e55cb5980 helpers: improve typing of Resolution 2022-09-19 20:03:54 -07:00
James R. Barlow 6dbaebdc0c Merge branch 'master' into feature/drop-3.7 2022-09-15 23:00:27 -07:00
James R. Barlow 5156fe7662 Test PyPy 3.8 and 3.9 2022-09-15 22:56:44 -07:00
James R. Barlow 2c99c89e45 ghostscript: document why we treat gs 9.56.0 differently 2022-09-15 22:51:04 -07:00
James R. Barlow 74286e7e1e Use tesseract 5 again in Docker image 2022-08-23 13:39:45 -07:00
James R. Barlow 2e937dee9f Refactor cache manifest creation 2022-08-19 00:19:38 -07:00
James R. Barlow 23f3830533 Merge remote-tracking branch 'origin/master' 2022-08-14 17:01:10 -07:00
belamuandGitHub 305e6594be add Fedora packages in jbig2 doc (#1006)
Packages needed to install jbig2 encoding are mentioned for Ubuntu systems but are missing for other systems. This commit adds the infomation in the documentation what the packages are called on Fedora systems.
2022-08-11 10:31:09 -07:00
James R. Barlow f4155dca77 tests: convert all uses of multipage.pdf to fixture 2022-08-11 01:13:10 -07:00
James R. Barlow 545cd031b0 Replace public domain graph.pdf and derivates with licensed version 2022-08-11 01:09:00 -07:00
James R. Barlow a1c7826336 dep5: mention mawi 2022-08-06 15:47:45 -07:00
James R. Barlow c5359bd990 jbig2 is from linn now 2022-08-06 15:36:20 -07:00
James R. Barlow 7f77308846 Remake palette.pdf using baiona-colormapped file 2022-08-06 15:35:47 -07:00
James R. Barlow 8e7c5f3001 dep5: correction, graph.pdf is actually public domain 2022-08-06 15:34:38 -07:00
James R. Barlow 79db985181 Improve encryption tests; drop some public domain resources
Generate the encrypted files we need and remove special test files we retained for this.

Replace jbig2.pdf based on congress.jpg with version based on ccitt.pdf.
2022-08-06 14:37:45 -07:00
James R. Barlow 7d23a661fc Track .reuse/dep5 2022-08-06 14:32:09 -07:00
James R. Barlow 99e94807c5 .gitignore: updates 2022-08-06 14:30:05 -07:00
James R. Barlow 8412de9344 Merge branch 'master' into feature/drop-3.7 2022-08-06 14:04:23 -07:00
James R. Barlow b458b1422b Merge remote-tracking branch 'origin/master' 2022-08-06 03:06:07 -07:00
James R. Barlow 76bd8cab13 Drop Ubuntu 18.04 content 2022-08-06 02:58:26 -07:00
James R. Barlow ef70c9499e Don't deprecate falsy filter_page_image 2022-08-05 01:07:46 -07:00
James R. Barlow 47dcb6fcd0 Revert "Remove deprecated falsy handling of filter_page_image"
This reverts commit c9389c7713.
2022-08-05 01:01:09 -07:00
James R. Barlow 88d2949e6b Abolish setup.cfg and migrate to pyproject.toml 2022-08-04 05:00:46 -07:00
James R. Barlow c9389c7713 Remove deprecated falsy handling of filter_page_image 2022-08-04 04:21:15 -07:00
James R. Barlow 4d2f499f97 Remove optional status of coloredlogs
Everything optional is a possible complication.
Better to remove the option.
2022-08-04 04:15:56 -07:00
James R. Barlow 4104904a1e Document reason for suppress some third party deprecation warnings 2022-08-04 04:13:02 -07:00
James R. Barlow 1a0a797ca6 Remove our @deprecated decorator and use standard package 2022-08-04 04:00:25 -07:00
James R. Barlow 670ce2b969 Remove support for non-callable version checker 2022-08-04 03:58:10 -07:00
James R. Barlow 7e97981114 hocrtx: unused imports 2022-08-04 03:56:44 -07:00
James R. Barlow 53db866ef9 Remove deprecated exception PdfMergeFailedError 2022-08-04 03:54:55 -07:00
James R. Barlow d591a3e059 resources readme: remove license and copyright info
Better to not repeat ourselves and present this info in example one place.
2022-08-04 03:42:22 -07:00
James R. Barlow 37c050aa4f readme: fix copyright 2022-08-04 03:41:45 -07:00
James R. Barlow 4b9ea40a0c spdx: move identifiers to files that support them
If the apparent license changed, take this commit as correct.
2022-08-04 03:26:54 -07:00
林博仁(Buo-ren, Lin)andGitHub 165432486b snap: add JBIG2 support (#1001)
This patch includes a jbig2enc build into the snap package, allow the
user to run OcrMyPDF with the --jbig2-lossy option or other options that trigger JBIG2 encoding.

Signed-off-by: 林博仁(Buo-ren Lin) <Buo.Ren.Lin@gmail.com>
2022-08-02 16:35:01 -07:00
James R. Barlow d619fac0bd unpaper: tidy file 2022-08-02 15:30:20 -07:00
James R. Barlow acc70036cc Set minimum Tesseract to 4.1.1 2022-08-02 15:20:29 -07:00
James R. Barlow 80b7cf6330 Update black target versions 2022-08-02 15:07:43 -07:00
James R. Barlow 8a8c06c79c Update pre-commit for py3.8+ 2022-08-02 15:05:43 -07:00
James R. Barlow 67773da309 Drop support for Ghostscript <9.50 2022-08-02 15:01:10 -07:00
James R. Barlow d5a9861d5c readme: freebsd calls it py-ocrmypdf now 2022-08-02 14:49:09 -07:00
James R. Barlow 9ffe829a10 Remove external importlib_metadata since Python 3.8 provides it directly 2022-08-02 14:48:11 -07:00
James R. Barlow 8a3b82e364 Make Python 3.8 minimum requirement 2022-08-02 14:46:01 -07:00
James R. Barlow 580822a6a2 Fix Windows ghostscript path scanning 2022-08-02 14:39:23 -07:00
James R. Barlow 9f3a52fd12 windows: compare Ghostscript versions properly 2022-08-01 13:58:24 -07:00
James R. Barlow 52e829d845 windows: tidy path shimming 2022-08-01 13:23:57 -07:00
James R. Barlow 2b2e5c271a Don't lru_cache versions
lru_cache could interfere with testing and we don't have evidence this
is slow enough to justify optimizing.
2022-08-01 13:23:31 -07:00
James R. Barlow 5fe3102e4e tests: new test to confirm correct printing of tesseract install advice 2022-08-01 12:31:37 -07:00
James R. Barlow 5b57520c98 tests: simplify some validation tests 2022-08-01 12:31:05 -07:00
106 changed files with 1720 additions and 1179 deletions
+12 -4
View File
@@ -1,5 +1,5 @@
# OCRmyPDF # SPDX-FileCopyrightText: 2022 James R. Barlow
# # SPDX-License-Identifier: MPL-2.0
FROM ubuntu:22.04 as base FROM ubuntu:22.04 as base
@@ -25,7 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libffi-dev \ libffi-dev \
ca-certificates \ ca-certificates \
curl \ curl \
git git \
libcairo2-dev \
pkg-config
# Get the latest pip (Ubuntu version doesn't support manylinux2010) # Get the latest pip (Ubuntu version doesn't support manylinux2010)
RUN \ RUN \
@@ -50,8 +52,14 @@ RUN pip3 install --no-cache-dir .[test,webservice,watcher]
FROM base FROM base
# For Tesseract 5
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common gpg-agent
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ghostscript \ ghostscript \
jbig2dec \
img2pdf \ img2pdf \
libsm6 libxext6 libxrender-dev \ libsm6 libxext6 libxrender-dev \
pngquant \ pngquant \
@@ -74,7 +82,7 @@ COPY --from=builder /app/misc/webservice.py /app/
COPY --from=builder /app/misc/watcher.py /app/ COPY --from=builder /app/misc/watcher.py /app/
# Copy minimal project files to get the test suite. # Copy minimal project files to get the test suite.
COPY --from=builder /app/setup.cfg /app/setup.py /app/README.md /app/ COPY --from=builder /app/pyproject.toml /app/README.md /app/
COPY --from=builder /app/tests /app/tests COPY --from=builder /app/tests /app/tests
ENTRYPOINT ["/usr/local/bin/ocrmypdf"] ENTRYPOINT ["/usr/local/bin/ocrmypdf"]
+3
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
# dotfiles # dotfiles
.* .*
!.coveragerc !.coveragerc
+3
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
# Always use Unix convention for new lines # Always use Unix convention for new lines
* text eol=lf * text eol=lf
+3
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
# These are supported funding model platforms # These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
@@ -17,11 +17,7 @@ A clear and concise description of what you want to happen.
A clear and concise description of any alternative solutions or features you've considered. Please include the versions of OCRmyPDF and other supporting programs (Tesseract OCR, Ghostscript) - maybe an alternative already exists in a newer version. A clear and concise description of any alternative solutions or features you've considered. Please include the versions of OCRmyPDF and other supporting programs (Tesseract OCR, Ghostscript) - maybe an alternative already exists in a newer version.
**Example file** **Example file**
If your issue concerns how OCRmyPDF processes certain files, and please provide an example file that helps illustrate how OCRmyPDF's output could be improve. If your issue concerns how OCRmyPDF processes certain files, and please provide an example file that helps illustrate how OCRmyPDF's output could be improve. You could also look in ``tests/resources`` and see if any of those files demonstrates your issue.
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/ocrmypdf/OCRmyPDF/wiki) for OCRmyPDF's author only.
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
**Additional context** **Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.
@@ -1,7 +1,7 @@
--- ---
name: General issues name: General issues
about: Installation, packages, dependencies, "nothing works", test suite failures... about: Installation, packages, dependencies, "nothing works", test suite failures...
title: '' title: "[BUG]"
labels: '' labels: ''
assignees: '' assignees: ''
@@ -23,6 +23,7 @@ If applicable, add screenshots to help explain your problem.
- OS: - OS:
- Python version: - Python version:
- OCRmyPDF version: - OCRmyPDF version:
- Platform: x64 or ARM
**Installation** **Installation**
How did you install OCRmyPDF? Did you install it from your operating system's How did you install OCRmyPDF? Did you install it from your operating system's
@@ -1,7 +1,7 @@
--- ---
name: Problem with a specific input file name: Problem with specific file
about: Something went wrong while trying to OCR a specific file about: Something went wrong while trying to OCR a specific file
title: '' title: "[BUG]"
labels: '' labels: ''
assignees: '' assignees: ''
+3
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
# To get started with Dependabot version updates, you'll need to specify which # To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located. # package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options: # Please see the documentation for all configuration options:
+22 -22
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
name: Test and deploy name: Test and deploy
on: on:
@@ -20,8 +22,6 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- os: ubuntu-18.04
python: "3.7"
- os: ubuntu-20.04 - os: ubuntu-20.04
python: "3.8" python: "3.8"
- os: ubuntu-20.04 - os: ubuntu-20.04
@@ -31,7 +31,13 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
python: "3.9" python: "3.9"
- os: ubuntu-latest - os: ubuntu-latest
python: "pypy-3.8" python: "3.10"
- os: ubuntu-latest
python: "3.11"
# - os: ubuntu-latest
# python: "pypy3.8"
#- os: ubuntu-latest
# python: "pypy3.9"
- os: ubuntu-latest - os: ubuntu-latest
python: "3.9" python: "3.9"
tesseract5: true tesseract5: true
@@ -73,12 +79,6 @@ jobs:
unpaper \ unpaper \
zlib1g zlib1g
- name: Install Ubuntu 18.04 packages
if: matrix.os == 'ubuntu-18.04'
run: |
sudo apt-get install -y --no-install-recommends \
libexempi3
- name: Install Ubuntu 20.04 packages - name: Install Ubuntu 20.04 packages
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-latest'
run: | run: |
@@ -96,7 +96,7 @@ jobs:
- name: Install Python packages - name: Install Python packages
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip wheel
python -m pip install .[test] python -m pip install --prefer-binary .[test]
- name: Report versions - name: Report versions
run: | run: |
@@ -122,7 +122,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest] os: [macos-latest]
python: ["3.9", "3.10"] python: ["3.10", "3.11"]
env: env:
OS: ${{ matrix.os }} OS: ${{ matrix.os }}
@@ -133,12 +133,8 @@ jobs:
with: with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/setup-python@v4
name: Install Python
with:
python-version: ${{ matrix.python }}
- name: Install Homebrew deps - name: Install Homebrew deps
continue-on-error: true
run: | run: |
brew update brew update
brew install \ brew install \
@@ -149,10 +145,15 @@ jobs:
pngquant \ pngquant \
tesseract tesseract
- uses: actions/setup-python@v4
name: Install Python
with:
python-version: ${{ matrix.python }}
- name: Install Python packages - name: Install Python packages
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip wheel
python -m pip install .[test] python -m pip install --prefer-binary .[test]
- name: Report versions - name: Report versions
run: | run: |
@@ -177,7 +178,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [windows-latest] os: [windows-latest]
python: ["3.9", "3.10"] python: ["3.10", "3.11"]
env: env:
OS: ${{ matrix.os }} OS: ${{ matrix.os }}
@@ -201,7 +202,7 @@ jobs:
- name: Install Python packages - name: Install Python packages
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip wheel
python -m pip install .[test] python -m pip install --prefer-binary .[test]
- name: Test - name: Test
run: | run: |
@@ -228,9 +229,8 @@ jobs:
- name: Make wheels and sdist - name: Make wheels and sdist
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip wheel build
python setup.py sdist python -m build --sdist --wheel
python setup.py bdist_wheel
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
+11 -11
View File
@@ -1,14 +1,15 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: CC-BY-SA-4.0
# dotfiles # dotfiles
.* .coverage
!.coveragerc .venv*/
!.dockerignore .tox/
!.git_archival.txt .vscode/
!.gitattributes .hypothesis/
!.gitignore .ipynb_checkpoints/
!.pre-commit-config.yaml .mypy_cache/
!.readthedocs.yaml .pytest_cache/
!.github/
!.docker/
# Dev scratch # Dev scratch
*.ipynb *.ipynb
@@ -42,4 +43,3 @@ docs/_build/
docs/_static/ docs/_static/
docs/_templates/ docs/_templates/
docs/Makefile docs/Makefile
ocrmypdf/lib/_*.py
+11 -16
View File
@@ -1,33 +1,28 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v4.4.0
hooks: hooks:
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
- id: check-toml - id: check-toml
- id: check-yaml - id: check-yaml
- id: debug-statements - id: debug-statements
- repo: https://github.com/pycqa/isort - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 5.10.1 rev: "v0.0.261"
hooks: hooks:
- id: isort - id: ruff
args: ["--profile", "black", "-a", "from __future__ import annotations"] files: "src/.*\\.pyi?$"
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.6.0 rev: 23.3.0
hooks: hooks:
- id: black - id: black
language_version: python language_version: python
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.2
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/asottile/pyupgrade
rev: v2.37.2
hooks:
- id: pyupgrade
args: ["--py37-plus"]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.971 rev: v1.2.0
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: additional_dependencies:
+4 -1
View File
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
# Read the Docs configuration file # Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
@@ -14,7 +17,7 @@ formats:
# Optionally set the version of Python and requirements required to build your docs # Optionally set the version of Python and requirements required to build your docs
python: python:
version: "3.7" version: "3.8"
install: install:
- method: pip - method: pip
path: . path: .
+132
View File
@@ -0,0 +1,132 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: OCRmyPDF
Upstream-Contact: James R. Barlow <james@purplerock.ca>
Source: https://github.com/ocrmypdf/OCRmyPDF
Files:
.git_archival.txt
docs/images/logo-social.png
docs/images/logo-square-256.svg
docs/images/logo-square.png
docs/images/logo-square.svg
docs/images/logo.svg
setup.cfg
Copyright: (C) 2022 James R. Barlow
License: MPL-2.0
Files:
.github/ISSUE_TEMPLATE/*.md
docs/images/macos-workflow.png
Copyright: (C) 2022 James R. Barlow
License: CC-BY-SA-4.0
Files:
tests/resources/acroform.pdf
tests/resources/aspect.pdf
tests/resources/blank.pdf
tests/resources/cmyk.pdf
tests/resources/crom.png
tests/resources/enormous.pdf
tests/resources/formxobject.pdf
tests/resources/francais.pdf
tests/resources/hugemono.pdf
tests/resources/invalid.pdf
tests/resources/kcs.pdf
tests/resources/livecycle.pdf
tests/resources/missing_docinfo.pdf
tests/resources/negzero.pdf
tests/resources/no_contents.pdf
tests/resources/toc.pdf
tests/resources/trivial.pdf
tests/resources/truetype_font_nomapping.pdf
tests/resources/type3_font_nomapping.pdf
Copyright: (C) 2022 James R. Barlow
License: CC-BY-SA-4.0
Files:
tests/resources/graph.pdf
tests/resources/graph_ocred.pdf
Copyright: (C) 2012 SmokeyJoe
License: GFDL-1.2-or-later or CC-BY-SA-3.0
Files: tests/resources/c02-22.pdf
tests/resources/congress.jpg
tests/resources/multipage.pdf
Copyright: Public domain
License: public-domain
Copyright on these files has expired.
Files: docs/images/bitmap_vs_svg.svg
Copyright: (C) 2006 Yug
License: CC-BY-SA-2.5
Files: tests/cache/*
Copyright: (C) 2022 James R. Barlow
License: CC-BY-SA-4.0
Files: tests/resources/linn.png
tests/resources/linn.pdf
tests/resources/linn.txt
tests/resources/ccitt.pdf
tests/resources/cardinal.pdf
tests/resources/jbig2.pdf
tests/resources/skew.pdf
tests/resources/rotated_skew.pdf
tests/resources/poster.pdf
Copyright: (C) 1985 Forat Electronics
License: GFDL-1.2-or-later or CC-BY-SA-3.0
Files: tests/resources/lichtenstein.pdf
Copyright: (C) 2001 Andreas Tille
(C) 2007 Alessio Damato
License: GFDL-1.2-or-later or CC-BY-SA-3.0
Files: tests/resources/masks.pdf
Copyright: held by the contributors to the German Wikipedia article "Linux"
see: https://de.wikipedia.org/w/index.php?title=Linux&action=history
(masks.pdf generated from Wikipedia article as of 2016-08-24)
License: CC-BY-SA-3.0
Files: tests/resources/epson.pdf
Copyright: held by the contributors to the Wikipedia article "Optical character recognition"
see: https://en.wikipedia.org/w/index.php?title=Optical_character_recognition&action=history
(epson.pdf generated from Wikipedia article as of 2016-09-14)
License: CC-BY-SA-3.0
Files: tests/resources/typewriter.png tests/resources/2400dpi.pdf
Copyright: (C) 2005 Ellywa
License: GFDL-1.2-or-later or CC-BY-SA-1.0 or CC-BY-SA-2.0 or CC-BY-SA-2.5 or CC-BY-SA-3.0
Comment:
Obtained from: https://commons.wikimedia.org/wiki/File:Triumph.typewriter_text_Linzensoep.gif
Files: tests/resources/overlay.pdf
Copyright: (C) 2017 Max Anderson
License: MIT
Files:
tests/resources/baiona*.png
tests/resources/baiona*.jpg
tests/resources/link.pdf
tests/resources/palette.pdf
Copyright: (C) 2014 Euskaldunaa
License: CC-BY-SA-4.0
Files: tests/resources/vector.pdf
Copyright: (C) 2018 Catscratch
License: MIT
Files: src/ocrmypdf/data/sRGB.icc
Copyright: Kai-Uwe Behrmann <www.behrmann.name>
Marti Maria <www.littlecms.com>
Photogamut <www.photogamut.org>
Graeme Gill <www.argyllcms.com>
ColorSolutions <www.basICColor.com>
License: Zlib
Files: tests/resources/3small.pdf
Copyright: (C) 2014 Euskaldunaa
(C) 2017 James R. Barlow
(C) 2005 Ellywa
License: CC-BY-SA-4.0 and (GFDL-1.2-or-later or CC-BY-SA-1.0 or CC-BY-SA-2.0 or CC-BY-SA-2.5 or CC-BY-SA-3.0)
Comment: concatenation of baiona_gray.png, crom.png and typewriter.png/2400dpi.pdf
+8 -6
View File
@@ -1,3 +1,7 @@
<!-- SPDX-FileCopyrightText: 2014 Julien Pfefferkorn -->
<!-- SPDX-FileCopyrightText: 2015 James R. Barlow -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
<img src="docs/images/logo.svg" width="240" alt="OCRmyPDF"> <img src="docs/images/logo.svg" width="240" alt="OCRmyPDF">
[![Build Status](https://github.com/ocrmypdf/OCRmyPDF/actions/workflows/build.yml/badge.svg)](https://github.com/ocrmypdf/OCRmyPDF/actions/workflows/build.yml) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs] ![Python versions][pyversions] [![Build Status](https://github.com/ocrmypdf/OCRmyPDF/actions/workflows/build.yml/badge.svg)](https://github.com/ocrmypdf/OCRmyPDF/actions/workflows/build.yml) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs] ![Python versions][pyversions]
@@ -66,7 +70,7 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
| macOS (Homebrew) | ``brew install ocrmypdf`` | | macOS (Homebrew) | ``brew install ocrmypdf`` |
| macOS (nix) | ``nix-env -i ocrmypdf`` | | macOS (nix) | ``nix-env -i ocrmypdf`` |
| LinuxBrew | ``brew install ocrmypdf`` | | LinuxBrew | ``brew install ocrmypdf`` |
| FreeBSD | ``pkg install py37-ocrmypdf`` | | FreeBSD | ``pkg install py-ocrmypdf`` |
| Conda | ``conda install ocrmypdf`` | | Conda | ``conda install ocrmypdf`` |
| Ubuntu Snap | ``snap install ocrmypdf`` | | Ubuntu Snap | ``snap install ocrmypdf`` |
@@ -92,10 +96,7 @@ brew install tesseract-lang
You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple languages can be requested. You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple languages can be requested.
OCRmyPDF supports Tesseract 4.0 and the beta versions of Tesseract 5.0. It will OCRmyPDF supports Tesseract 4.1.1+. It will automatically use whichever version it finds first on the `PATH` environment variable. On Windows, if `PATH` does not provide a Tesseract binary, we use the highest version number that is installed according to the Windows Registry.
automatically use whichever version it finds first on the `PATH` environment
variable. On Windows, if `PATH` does not provide a Tesseract binary, we use
the highest version number that is installed according to the Windows Registry.
## Documentation and support ## Documentation and support
@@ -111,7 +112,7 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF
## Requirements ## Requirements
In addition to the required Python version (3.7+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD. In addition to the required Python version (3.8+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
## Press & Media ## Press & Media
@@ -122,6 +123,7 @@ In addition to the required Python version (3.7+), OCRmyPDF requires external pr
- [heise Durchsuchbare PDF-Dokumente mit OCRmyPDF erstellen](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html) - [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/) - [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/) - [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)
## Business enquiries ## Business enquiries
+1 -1
View File
@@ -160,7 +160,7 @@ Changing tesseract configuration variables
------------------------------------------ ------------------------------------------
You can override tesseract's default `control You can override tesseract's default `control
parameters <https://github.com/tesseract-ocr/tesseract/wiki/ControlParams>`__ parameters <https://tesseract-ocr.github.io/tessdoc/tess3/ControlParams.html>`__
with a configuration file. with a configuration file.
As an example, this configuration will disable Tesseract's dictionary As an example, this configuration will disable Tesseract's dictionary
-8
View File
@@ -72,14 +72,6 @@ OCRmyPDF, use processes.
not take at least one of these steps, process semantics will prevent not take at least one of these steps, process semantics will prevent
OCRmyPDF from working correctly. OCRmyPDF from working correctly.
.. warning::
On macOS with Python 3.7, you must call
:func:`multiprocessing.set_start_method("spawn")`. Without this, multiprocessing
will be unstable. From the command line, OCRmyPDF does this automatically,
but as an API user you must do this. See Python bpo-33725 for details.
Python 3.8+ also resolve this automatically.
Logging Logging
------- -------
+3 -3
View File
@@ -21,8 +21,8 @@ processors. To maximize parallelism without overloading your system with
processes, consider using ``parallel -j 2`` to limit parallel to running processes, consider using ``parallel -j 2`` to limit parallel to running
two jobs at once. two jobs at once.
This command will run all ocrmypdf all files named ``*.pdf`` in the This command will run ``ocrmypdf`` on all files named ``*.pdf`` in the
current directory and write them to the previous created ``output/`` current directory and write them to the previously created ``output/``
folder. It will not search subdirectories. folder. It will not search subdirectories.
The ``--tag`` argument tells parallel to print the filename as a prefix The ``--tag`` argument tells parallel to print the filename as a prefix
@@ -133,7 +133,7 @@ Users may need to customize the script to meet their requirements.
"OCR_OUTPUT_DIRECTORY", "Set output directory (should not be under input)" "OCR_OUTPUT_DIRECTORY", "Set output directory (should not be under input)"
"OCR_ARCHIVE_DIRECTORY", "Set archive directory for processed originals (should not be under input, requires ``OCR_ON_SUCCESS_ARCHIVE`` to be set)" "OCR_ARCHIVE_DIRECTORY", "Set archive directory for processed originals (should not be under input, requires ``OCR_ON_SUCCESS_ARCHIVE`` to be set)"
"OCR_ON_SUCCESS_DELETE", "This will delete the input file if the exit code is 0 (OK)" "OCR_ON_SUCCESS_DELETE", "This will delete the input file if the exit code is 0 (OK)"
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed orignal file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted." "OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``" "OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
"OCR_DESKEW", "Apply deskew to crooked input PDFs" "OCR_DESKEW", "Apply deskew to crooked input PDFs"
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true}'``." "OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true}'``."
+3 -6
View File
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: CC-BY-SA-4.0 # SPDX-License-Identifier: CC-BY-SA-4.0
# ruff: noqa: E402
# ocrmypdf documentation build configuration file, created by # ocrmypdf documentation build configuration file, created by
# sphinx-quickstart on Sun Sep 4 14:29:43 2016. # sphinx-quickstart on Sun Sep 4 14:29:43 2016.
# #
@@ -22,8 +24,6 @@
# import sys # import sys
# sys.path.insert(0, os.path.abspath('.')) # sys.path.insert(0, os.path.abspath('.'))
"""isort:skip_file"""
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # If your documentation needs a minimal Sphinx version, state it here.
@@ -76,6 +76,7 @@ author = 'James R. Barlow'
# The short X.Y version. # The short X.Y version.
import os import os
from importlib.metadata import version as package_version
on_rtd = os.environ.get('READTHEDOCS') == 'True' on_rtd = os.environ.get('READTHEDOCS') == 'True'
@@ -96,10 +97,6 @@ if on_rtd:
] ]
sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
try:
from importlib_metadata import version as package_version
except ModuleNotFoundError:
from importlib.metadata import version as package_version
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = package_version('ocrmypdf') release = package_version('ocrmypdf')
+2 -2
View File
@@ -283,7 +283,7 @@ argument. (Normally, OCRmyPDF will exit with an error if asked to modify
a file with OCR.) a file with OCR.)
This may be helpful for users who want to take advantage of accuracy This may be helpful for users who want to take advantage of accuracy
improvements in Tesseract 4.0 for files they previously OCRed with an improvements in Tesseract for files they previously OCRed with an
earlier version of Tesseract and OCRmyPDF. earlier version of Tesseract and OCRmyPDF.
.. code-block:: bash .. code-block:: bash
@@ -360,7 +360,7 @@ The types of optimization available may expand over time. By default,
OCRmyPDF compresses data streams inside PDFs, and will change OCRmyPDF compresses data streams inside PDFs, and will change
inefficient compression modes to more modern versions. A program like inefficient compression modes to more modern versions. A program like
``qpdf`` can be used to change encodings, e.g. to inspect the internals ``qpdf`` can be used to change encodings, e.g. to inspect the internals
fo a PDF. for a PDF.
.. code-block:: bash .. code-block:: bash
+22 -63
View File
@@ -21,7 +21,7 @@ These platforms have one-liner installs:
+-------------------------------+-----------------------------------------+ +-------------------------------+-----------------------------------------+
| Windows Subsystem for Linux | ``apt install ocrmypdf`` | | Windows Subsystem for Linux | ``apt install ocrmypdf`` |
+-------------------------------+-----------------------------------------+ +-------------------------------+-----------------------------------------+
| Fedora | ``dnf install ocrmypdf`` | | Fedora | ``dnf install ocrmypdf tesseract-osd`` |
+-------------------------------+-----------------------------------------+ +-------------------------------+-----------------------------------------+
| macOS | ``brew install ocrmypdf`` | | macOS | ``brew install ocrmypdf`` |
+-------------------------------+-----------------------------------------+ +-------------------------------+-----------------------------------------+
@@ -44,7 +44,7 @@ install, or install a more recent version than your platform provides, read on.
Installing on Linux Installing on Linux
=================== ===================
Debian and Ubuntu 18.04 or newer Debian and Ubuntu 20.04 or newer
-------------------------------- --------------------------------
.. |deb-11| image:: https://repology.org/badge/version-for-repo/debian_11/ocrmypdf.svg .. |deb-11| image:: https://repology.org/badge/version-for-repo/debian_11/ocrmypdf.svg
@@ -56,9 +56,6 @@ Debian and Ubuntu 18.04 or newer
.. |deb-unstable| image:: https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg .. |deb-unstable| image:: https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
:alt: Debian unstable :alt: Debian unstable
.. |ubu-1804| image:: https://repology.org/badge/version-for-repo/ubuntu_18_04/ocrmypdf.svg
:alt: Ubuntu 18.04 LTS
.. |ubu-2004| image:: https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg .. |ubu-2004| image:: https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg
:alt: Ubuntu 20.04 LTS :alt: Ubuntu 20.04 LTS
@@ -72,7 +69,7 @@ Debian and Ubuntu 18.04 or newer
+-----------------------------------------------+ +-----------------------------------------------+
| |deb-11| |deb-12| |deb-unstable| | | |deb-11| |deb-12| |deb-unstable| |
+-----------------------------------------------+ +-----------------------------------------------+
| |ubu-1804| |ubu-2004| |ubu-2204| | | |ubu-2004| |ubu-2204| |
+-----------------------------------------------+ +-----------------------------------------------+
Users of Debian 9 ("stretch") or later, or Ubuntu 18.04 or later, including users Users of Debian 9 ("stretch") or later, or Ubuntu 18.04 or later, including users
@@ -80,7 +77,7 @@ of Windows Subsystem for Linux, may simply
.. code-block:: bash .. code-block:: bash
apt-get install ocrmypdf apt install ocrmypdf
As indicated in the table above, Debian and Ubuntu releases may lag As indicated in the table above, Debian and Ubuntu releases may lag
behind the latest version. If the version available for your platform is behind the latest version. If the version available for your platform is
@@ -124,7 +121,7 @@ Users of Fedora 29 or later may simply
.. code-block:: bash .. code-block:: bash
dnf install ocrmypdf dnf install ocrmypdf tesseract-osd
For full details on version availability, check the `Fedora Package For full details on version availability, check the `Fedora Package
Tracker <https://apps.fedoraproject.org/packages/ocrmypdf>`__. Tracker <https://apps.fedoraproject.org/packages/ocrmypdf>`__.
@@ -198,46 +195,6 @@ To install for the current user only:
To add JBIG2 encoding, see :ref:`jbig2`. To add JBIG2 encoding, see :ref:`jbig2`.
Ubuntu 18.04 LTS
----------------
Ubuntu 18.04 includes ocrmypdf 6.1.2 - you can install that with ``apt``, but
it is quite old now. To install a more recent version, uninstall the old version
of ocrmypdf, and install the following dependencies:
.. code-block:: bash
sudo apt-get -y remove ocrmypdf
sudo apt-get -y update
sudo apt-get -y install \
ghostscript \
icc-profiles-free \
libxml2 \
pngquant \
python3-distutils \
python3-pkg-resources \
python3-reportlab \
qpdf \
tesseract-ocr \
zlib1g \
unpaper
We will need a newer version of ``pip`` then was available for Ubuntu 18.04:
.. code-block:: bash
wget https://bootstrap.pypa.io/get-pip.py && python3 get-pip.py
Then install the most recent ocrmypdf for the local user and set the
user's ``PATH`` to check for the user's Python packages.
.. code-block:: bash
export PATH=$HOME/.local/bin:$PATH
python3 -m pip install --user ocrmypdf
To add JBIG2 encoding, see :ref:`jbig2`.
Arch Linux (AUR) Arch Linux (AUR)
---------------- ----------------
@@ -336,7 +293,7 @@ Homebrew
.. image:: https://img.shields.io/homebrew/v/ocrmypdf.svg .. image:: https://img.shields.io/homebrew/v/ocrmypdf.svg
:alt: homebrew :alt: homebrew
:target: http://brewformulas.org/Ocrmypdf :target: https://formulae.brew.sh/formula/ocrmypdf
OCRmyPDF is now a standard `Homebrew <https://brew.sh>`__ formula. To OCRmyPDF is now a standard `Homebrew <https://brew.sh>`__ formula. To
install on macOS: install on macOS:
@@ -417,9 +374,9 @@ Native Windows
You must install the following for Windows: You must install the following for Windows:
* Python 3.7 (64-bit) or later * Python 3.8 (64-bit) or later
* Tesseract 4.0 or later * Tesseract 4.1.1 (64-bit) or later
* Ghostscript 9.50 or later * Ghostscript 9.50 (64-bit) or later
Using the `Chocolatey <https://chocolatey.org/>`_ package manager, install the Using the `Chocolatey <https://chocolatey.org/>`_ package manager, install the
following when running in an Administrator command prompt: following when running in an Administrator command prompt:
@@ -438,10 +395,8 @@ Administrator.):
* ``pip install ocrmypdf`` * ``pip install ocrmypdf``
Chocolatey automatically selects appropriate versions of these applications. If you Chocolatey automatically selects appropriate versions of these applications. Please make sure
are installing them manually, please install 64-bit versions of all applications for you are installing the 64-bit versions.
64-bit Windows, or 32-bit versions of all applications for 32-bit Windows. Mixing
the "bitness" of these programs will lead to errors.
OCRmyPDF will check the Windows Registry and standard locations in your Program Files OCRmyPDF will check the Windows Registry and standard locations in your Program Files
for third party software it needs (specifically, Tesseract and Ghostscript). To for third party software it needs (specifically, Tesseract and Ghostscript). To
@@ -456,6 +411,10 @@ to change the PATH.
Please download Python from Python.org or Chocolatey instead, and do not use the Please download Python from Python.org or Chocolatey instead, and do not use the
Microsoft Store version. Microsoft Store version.
.. warning::
32-bit Windows might work, but is not supported.
Windows Subsystem for Linux Windows Subsystem for Linux
--------------------------- ---------------------------
@@ -481,7 +440,7 @@ Cygwin64
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``:: First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
python37 (or later) python38 (or later)
python3?-devel python3?-devel
python3?-pip python3?-pip
python3?-lxml python3?-lxml
@@ -571,7 +530,7 @@ programs being installed.
``apt install python3-pip python3-venv``, create a virtual environment, ``apt install python3-pip python3-venv``, create a virtual environment,
and install OCRmyPDF in that environment. and install OCRmyPDF in that environment.
`See here for more inforation on Debian-Python issues `See here for more information on Debian-Python issues
<https://gist.github.com/tiran/2dec9e03c6f901814f6d1e8dad09528e>`__. <https://gist.github.com/tiran/2dec9e03c6f901814f6d1e8dad09528e>`__.
For best results, first install `your platform's For best results, first install `your platform's
@@ -616,9 +575,9 @@ manager. ``pip`` cannot provide them.
The following versions are required: The following versions are required:
- Python 3.7 or newer - Python 3.8 or newer
- Ghostscript 9.23 or newer - Ghostscript 9.50 or newer
- Tesseract 4.0.0 or newer - Tesseract 4.1.1 or newer
- jbig2enc 0.29 or newer - jbig2enc 0.29 or newer
- pngquant 2.5 or newer - pngquant 2.5 or newer
- unpaper 6.1 - unpaper 6.1
@@ -649,7 +608,7 @@ unfortunately, the ``pip install`` command cannot satisfy all of them.
Installing HEAD revision from sources Installing HEAD revision from sources
===================================== =====================================
If you have ``git`` and Python 3.7 or newer installed, you can install If you have ``git`` and Python 3.8 or newer installed, you can install
from source. When the ``pip`` installer runs, it will alert you if from source. When the ``pip`` installer runs, it will alert you if
dependencies are missing. dependencies are missing.
@@ -679,7 +638,7 @@ system-wide:
.. code-block:: bash .. code-block:: bash
git clone -b master https://github.com/ocrmypdf/OCRmyPDF.git git clone -b master https://github.com/ocrmypdf/OCRmyPDF.git
python3 -m venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
cd OCRmyPDF cd OCRmyPDF
pip install . pip install .
+3 -4
View File
@@ -85,7 +85,7 @@ OCRmyPDF analyzes each page of a PDF to determine the colorspace and
resolution (DPI) needed to capture all of the information on that page resolution (DPI) needed to capture all of the information on that page
without losing content. It uses without losing content. It uses
`Ghostscript <http://ghostscript.com/>`__ to rasterize the page, and `Ghostscript <http://ghostscript.com/>`__ to rasterize the page, and
then performs on OCR the rasterized image to create an OCR "layer". then performs OCR on the rasterized image to create an OCR "layer".
The layer is then grafted back onto the original PDF. The layer is then grafted back onto the original PDF.
While one can use a program like Ghostscript or ImageMagick to get an While one can use a program like Ghostscript or ImageMagick to get an
@@ -190,11 +190,10 @@ Ghostscript also imposes some limitations:
behavior can be suppressed by setting ``--pdfa-image-compression`` to behavior can be suppressed by setting ``--pdfa-image-compression`` to
``jpeg`` or ``lossless`` to set all images to one type or the other. ``jpeg`` or ``lossless`` to set all images to one type or the other.
Ghostscript has no option to maintain the input image's format. Ghostscript has no option to maintain the input image's format.
(Ghostscript 9.25+ can copy JPEG images without transcoding them; (Modern Ghostscript can copy JPEG images without transcoding them.)
earlier versions will transcode.)
- Ghostscript's PDF/A conversion removes any XMP metadata that is not - Ghostscript's PDF/A conversion removes any XMP metadata that is not
one of the standard XMP metadata namespaces for PDFs. In particular, one of the standard XMP metadata namespaces for PDFs. In particular,
PRISM Metdata is removed. PRISM Metadata is removed.
- Ghostscript's PDF/A conversion seems to remove or deactivate - Ghostscript's PDF/A conversion seems to remove or deactivate
hyperlinks and other active content. hyperlinks and other active content.
+2 -1
View File
@@ -37,7 +37,8 @@ For all other Linux, you must build a JBIG2 encoder from source:
.. _jbig2-lossy: .. _jbig2-lossy:
Dependencies include libtoolize and libleptonica, which on Ubuntu systems Dependencies include libtoolize and libleptonica, which on Ubuntu systems
are packaged as libtool and libleptonica-dev. are packaged as libtool and libleptonica-dev. On Fedora (35) they are packaged
as libtool and leptonica-devel.
Lossy mode JBIG2 Lossy mode JBIG2
================ ================
+1 -1
View File
@@ -74,7 +74,7 @@ This enables these languages for all packages (e.g. including aspell).
# Add English and German language support for Tesseract only # Add English and German language support for Tesseract only
echo 'app-text/tessdata_fast l10n_de l10n_en' >> /etc/portage/package.use echo 'app-text/tessdata_fast l10n_de l10n_en' >> /etc/portage/package.use
# Add global English and German language support (the `l10n_` from equery has to be omited) # Add global English and German language support (the `l10n_` from equery has to be omitted)
echo L10N="de en" >> /etc/portage/make.conf echo L10N="de en" >> /etc/portage/make.conf
# update system to reflect changed USE flags # update system to reflect changed USE flags
+6 -10
View File
@@ -76,20 +76,16 @@ Setuptools plugins
You can also create a plugin that OCRmyPDF will always automatically load if both are You can also create a plugin that OCRmyPDF will always automatically load if both are
installed in the same virtual environment, using a setuptools entrypoint. installed in the same virtual environment, using a setuptools entrypoint.
Your package's ``setup.py`` would need to contain the following, for a plugin Your package's ``pyproject.toml`` would need to contain the following, for a plugin
named ``ocrmypdf-exampleplugin``: named ``ocrmypdf-exampleplugin``:
.. code-block:: python .. code-block:: toml
# sample ./setup.py file [project]
from setuptools import setup name = "ocrmypdf-exampleplugin"
setup( [project.entry-points."ocrmypdf"]
name="ocrmypdf-exampleplugin", exampleplugin = "exampleplugin.pluginmodule"
packages=["exampleplugin"],
# the following makes a plugin available to pytest
entry_points={"ocrmypdf": ["exampleplugin = exampleplugin.pluginmodule"]},
)
.. code-block:: ini .. code-block:: ini
+73 -1
View File
@@ -28,6 +28,78 @@ tagged yet.
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg .. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
v14.2.1
=======
- Fixed :issue:`977`, where images inside Form XObjects were always excluded
from image optimization.
v14.2.0
=======
- Added `--tesseract-downsample-above` to downsample larger images even when
they do not exceed Tesseract's internal limits. This can be used to speed
up OCR, possibly sacrificing accuracy.
- Fixed resampling AttributeError on older Pillow. :issue:`1096`
- Removed an error about using Ghostscript on PDFs with that have the /UserUnit
feature in use. Previously, Ghostscript would fail to process these PDFs,
but in all supported versions it is now supported, so the error is no longer
needed.
- Improved documentation around installing other language packs for Tesseract.
v14.1.0
=======
- Added ``--tesseract-non-ocr-timeout``. This allows using Tesseract's deskew
and other non-OCR features while disabling OCR using ``--tesseract-timeout 0``.
- Added ``--tesseract-downsample-large-images``. This downsamples larges images
that exceed the maximum image size Tesseract can handle. Large images may still
take a long time to process, but this allows them to be processed if that
is desired.
- Fixed :issue:`1082`, an issue with snap packaged building.
- Change linter to ruff, fix lint errors, update documentation.
v14.0.4
=======
- Fixed :issue:`1066, 1075`, an exception when processing certain malformed PDFs.
v14.0.3
=======
- Fixed :issue:`1068`, avoid deleting /dev/null when running as root.
- Other documentation fixes.
v14.0.2
=======
- Fixed :issue:`1052`, an exception on attempting to process certain nonconforming PDFs.
- Explicitly documented that Windows 32-bit is no longer supported.
- Fixed source installation instructions.
- Other documentation fixes.
v14.0.1
=======
- Fixed some version checks done with smart version comparison.
- Added missing jbig2dec to Docker image.
v14.0.0
=======
- Dropped support for Python 3.7.
- Dropped support generally speaking, all dependencies older than what Ubuntu 20.04
provides.
- Ghostscript 9.50 or newer is now required. Shims to support old versions were
removed.
- Tesseract 4.1.1 or newer is now required. Shims to support old versions were
removed.
- Docker image now uses Tesseract 5.
- Dropped setup.cfg configuration for pyproject.toml.
- Removed deprecation exception PdfMergeFailedError.
- A few more public domain test files were removed or replaced. We are aiming for
100% compliance with SPDX and generally towards simplifying copyright.
v13.7.0 v13.7.0
======= =======
@@ -681,7 +753,7 @@ v10.3.2
v10.3.1 v10.3.1
======= =======
- Fixed a number of test suite failures with pdfminer.six older than veresion 20200402. - Fixed a number of test suite failures with pdfminer.six older than version 20200402.
- Enabled support for pdfminer.six 20200720. - Enabled support for pdfminer.six 20200720.
v10.3.0 v10.3.0
+9 -2
View File
@@ -2,11 +2,18 @@
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder> # SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Example of using ocrmypdf as a library in a script.
This script will recursively search a directory for PDF files and run OCR on
them. It will log the results. It runs OCR on every file, even if it already
has text. OCRmyPDF will detect files that already have text.
You should edit this script to meet your needs.
"""
from __future__ import annotations from __future__ import annotations
# This script must be edited to meet your needs.
import logging import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT
--- ---
version: "3.3" version: "3.3"
services: services:
+1 -2
View File
@@ -1,8 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R Barlow: https://github.com/jbarlow83 # SPDX-FileCopyrightText: 2022 James R Barlow: https://github.com/jbarlow83
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
""" """An example of an OCRmyPDF plugin.
An example of an OCRmyPDF plugin.
This plugin adds two new command line arguments This plugin adds two new command line arguments
--grayscale-ocr: converts the image to grayscale before performing OCR on it --grayscale-ocr: converts the image to grayscale before performing OCR on it
+3 -4
View File
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: 2017 Enantiomerie # SPDX-FileCopyrightText: 2017 Enantiomerie
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Example OCRmyPDF for Synology NAS."""
from __future__ import annotations from __future__ import annotations
# This script must be edited to meet your needs. # This script must be edited to meet your needs.
@@ -25,10 +27,7 @@ logging.basicConfig(
filemode='w', filemode='w',
) )
if len(sys.argv) > 1: start_dir = sys.argv[1] if len(sys.argv) > 1 else '.'
start_dir = sys.argv[1]
else:
start_dir = '.'
for dir_name, _subdirs, file_list in os.walk(start_dir): for dir_name, _subdirs, file_list in os.walk(start_dir):
logging.info(dir_name) logging.info(dir_name)
+7 -1
View File
@@ -3,6 +3,8 @@
# SPDX-FileCopyrightText: 2020 James R Barlow <https://github.com/jbarlow83> # SPDX-FileCopyrightText: 2020 James R Barlow <https://github.com/jbarlow83>
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Watch a directory for new PDFs and OCR them."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -38,6 +40,7 @@ DESKEW = getenv_bool('OCR_DESKEW')
OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}')) OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}'))
POLL_NEW_FILE_SECONDS = int(os.getenv('OCR_POLL_NEW_FILE_SECONDS', '1')) POLL_NEW_FILE_SECONDS = int(os.getenv('OCR_POLL_NEW_FILE_SECONDS', '1'))
USE_POLLING = getenv_bool('OCR_USE_POLLING') USE_POLLING = getenv_bool('OCR_USE_POLLING')
RETRIES_LOADING_FILE = int(os.getenv('OCR_RETRIES_LOADING_FILE', '5'))
LOGLEVEL = os.getenv('OCR_LOGLEVEL', 'INFO') LOGLEVEL = os.getenv('OCR_LOGLEVEL', 'INFO')
PATTERNS = ['*.pdf', '*.PDF'] PATTERNS = ['*.pdf', '*.PDF']
@@ -64,7 +67,7 @@ def wait_for_file_ready(file_path):
# watchdog event before the file is actually fully on disk, causing # watchdog event before the file is actually fully on disk, causing
# pikepdf to fail. # pikepdf to fail.
retries = 5 retries = RETRIES_LOADING_FILE
while retries: while retries:
try: try:
pdf = pikepdf.open(file_path) pdf = pikepdf.open(file_path)
@@ -103,6 +106,8 @@ def execute_ocrmypdf(file_path):
elif ON_SUCCESS_ARCHIVE: elif ON_SUCCESS_ARCHIVE:
log.info(f'OCR is done. Archiving {file_path.name} to {ARCHIVE_DIRECTORY}') log.info(f'OCR is done. Archiving {file_path.name} to {ARCHIVE_DIRECTORY}')
shutil.move(file_path, f'{ARCHIVE_DIRECTORY}/{file_path.name}') shutil.move(file_path, f'{ARCHIVE_DIRECTORY}/{file_path.name}')
else:
log.info('OCR is done')
else: else:
log.info('OCR is done') log.info('OCR is done')
@@ -140,6 +145,7 @@ def main():
f"DESKEW: {DESKEW}\n" f"DESKEW: {DESKEW}\n"
f"ARGS: {OCR_JSON_SETTINGS}\n" f"ARGS: {OCR_JSON_SETTINGS}\n"
f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n" f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n"
f"RETRIES_LOADING_FILE: {RETRIES_LOADING_FILE}\n"
f"USE_POLLING: {USE_POLLING}\n" f"USE_POLLING: {USE_POLLING}\n"
f"LOGLEVEL: {LOGLEVEL}" f"LOGLEVEL: {LOGLEVEL}"
) )
+3 -3
View File
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: 2019 James R. Barlow # SPDX-FileCopyrightText: 2019 James R. Barlow
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""This is a simple web service/HTTP wrapper for OCRmyPDF """This is a simple web service/HTTP wrapper for OCRmyPDF.
This may be more convenient than the command line tool for some Docker users. This may be more convenient than the command line tool for some Docker users.
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
@@ -15,7 +15,7 @@ from __future__ import annotations
import os import os
import shlex import shlex
from subprocess import PIPE, run from subprocess import run
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from flask import Flask, Response, request, send_from_directory from flask import Flask, Response, request, send_from_directory
@@ -48,7 +48,7 @@ def do_ocrmypdf(file):
return Response("--sidecar not supported", 501, mimetype='text/plain') return Response("--sidecar not supported", 501, mimetype='text/plain')
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file] ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8") proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8", check=False)
if proc.returncode != 0: if proc.returncode != 0:
stderr = proc.stderr stderr = proc.stderr
return Response(stderr, 400, mimetype='text/plain') return Response(stderr, 400, mimetype='text/plain')
+109 -8
View File
@@ -1,16 +1,99 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
[build-system] [build-system]
requires = [ requires = [
"setuptools >= 52", "setuptools >= 61",
"setuptools_scm[toml] >= 7.0.5", "setuptools_scm[toml] >= 7.0.5",
"wheel" "wheel"
] ]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project]
name = "ocrmypdf"
dynamic = ["version"]
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
readme = "README.md"
license = {text = "MPL-2.0"}
requires-python = ">=3.8"
dependencies = [
"Pillow>=8.2.0",
"coloredlogs>=14.0",
"deprecation>=2.1.0",
"img2pdf>=0.3.0", # pure Python
"packaging>=20",
"pdfminer.six>=20201018",
"pikepdf>=5.0.1",
"pluggy>=0.13.0",
"reportlab>=3.5.66",
"tqdm>=4",
"importlib-resources>=5;python_version<'3.9'", # until Python 3.9
"typing-extensions>=4;python_version<'3.10'",
]
authors = [{name = "James R. Barlow", email="james@purplerock.ca"}]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"Intended Audience :: Science/Research",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Operating System :: POSIX :: BSD",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: Scientific/Engineering :: Image Recognition",
"Topic :: Text Processing :: Indexing",
"Topic :: Text Processing :: Linguistic",
]
keywords = [
"PDF",
"OCR",
"optical character recognition",
"PDF/A",
"scanning",
]
[project.urls]
Documentation = "https://ocrmypdf.readthedocs.io/"
Source = "https://github.com/ocrmypdf/OCRmyPDF"
Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
[project.optional-dependencies]
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
extended_test = ["PyMuPDF==1.19.1"]
test = [
"coverage[toml]>=5",
"hypothesis>=6.0.0",
"pytest>=6.0.0",
"pytest-cov>=2.11.1",
"pytest-xdist>=2.2.0",
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
"types-Pillow",
"types-humanfriendly",
]
watcher = ["watchdog>=1.0.2"]
webservice = ["Flask>=1"]
[project.scripts]
ocrmypdf = "ocrmypdf.__main__:run"
[tool.setuptools.package-data]
ocrmypdf = ["data/sRGB.icc", "py.typed"]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = false
[tool.setuptools_scm] [tool.setuptools_scm]
[tool.distutils.bdist_wheel]
python-tag = "py38"
[tool.black] [tool.black]
line-length = 88 line-length = 88
target-version = ["py37", "py38"] target-version = ["py38", "py39", "py310", "py311"]
skip-string-normalization = true skip-string-normalization = true
include = '\.pyi?$' include = '\.pyi?$'
exclude = ''' exclude = '''
@@ -94,12 +177,30 @@ module = [
'pdfminer.*', 'pdfminer.*',
'reportlab.*', 'reportlab.*',
'fitz', 'fitz',
'libxmp.utils', 'libxmp.utils'
'importlib_metadata'
] ]
ignore_missing_imports = true ignore_missing_imports = true
[tool.pylint.basic] [tool.ruff]
good-names = ["i", "j", "k", "ex", "Run", "_", "e", "p", "im", "w", "h", "m", "x", "y", "a", "b", "fp", "n", "f", "s", "v", "q", "dx", "dy"] select = [
logging-format-style = "old" "D", # pydocstyle
disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "logging-fstring-interpolation", "missing-function-docstring", "too-few-public-methods"] "E", # pycodestyle
"W", # pycodestyle
"F", # pyflakes
"I001", # isort
"UP", # pyupgrade
]
target-version = "py38"
[tool.ruff.isort]
known-first-party = ["ocrmypdf"]
required-imports = ["from __future__ import annotations"]
[tool.ruff.pydocstyle]
convention = "google"
[tool.ruff.per-file-ignores]
"docs/conf.py" = ["D100", "D101", "D105"]
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
"misc/*.py" = ["D103", "D101", "D102"]
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
-116
View File
@@ -1,116 +0,0 @@
[metadata]
name = ocrmypdf
description = OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/ocrmypdf/OCRmyPDF
author = James R. Barlow
author_email = james@purplerock.ca
license = MPL-2.0
license_file = LICENSE
license_files =
LICENSE
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Console
Intended Audience :: End Users/Desktop
Intended Audience :: Science/Research
Intended Audience :: System Administrators
License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
Operating System :: MacOS :: MacOS X
Operating System :: Microsoft :: Windows :: Windows 10
Operating System :: POSIX
Operating System :: POSIX :: BSD
Operating System :: POSIX :: Linux
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Topic :: Scientific/Engineering :: Image Recognition
Topic :: Text Processing :: Indexing
Topic :: Text Processing :: Linguistic
keywords =
PDF
OCR
optical character recognition
PDF/A
scanning
project_urls =
Documentation = https://ocrmypdf.readthedocs.io/
Source = https://github.com/ocrmypdf/OCRmyPDF
Tracker = https://github.com/ocrmypdf/OCRmyPDF/issues
[options]
packages = find:
install_requires =
Pillow>=8.2.0
coloredlogs>=14.0 # strictly optional
img2pdf>=0.3.0 # pure Python
packaging>=20
pdfminer.six!=20200720,>=20191110
pikepdf!=5.0.0,>=4.0.0
pluggy>=0.13.0
reportlab>=3.5.66
tqdm>=4
importlib-metadata>=4;python_version<'3.8' # until Python 3.8
importlib-resources>=5;python_version<'3.9' # until Python 3.9
typing-extensions>=4;python_version<'3.10'
python_requires = >=3.7
include_package_data = True
package_dir =
=src
platforms = any
setup_requires =
setuptools-scm
setuptools-scm-git-archive
zip_safe = False
[options.packages.find]
where = src
[options.entry_points]
console_scripts =
ocrmypdf = ocrmypdf.__main__:run
[options.extras_require]
docs =
sphinx
sphinx-issues
sphinx-rtd-theme
extended_test =
PyMuPDF==1.19.1
test =
coverage[toml]>=5
pytest>=6.0.0
pytest-cov>=2.11.1
pytest-xdist>=2.2.0
python-xmp-toolkit==2.0.1 # also requires apt-get install libexempi3
types-Pillow
types-humanfriendly
watcher =
watchdog>=1.0.2
webservice =
Flask>=1
[options.package_data]
ocrmypdf =
data/sRGB.icc
py.typed
[bdist_wheel]
python-tag = py37
[aliases]
test = pytest
[check-manifest]
ignore =
.github
[flake8]
ignore = D203,F401,W503,E501,E203,F841
exclude = .git,__pycache__,docs/conf.py,build,dist,.venv,.venvpp,.eggs,tmp,src/ocrmypdf/lib/
max-complexity = 10
max-line-length = 100
-10
View File
@@ -1,10 +0,0 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""setup.py to support older setuptools and pip."""
from __future__ import annotations
from setuptools import setup
setup()
+23 -1
View File
@@ -1,3 +1,8 @@
# SPDX-FileCopyrightText: 2022 Alexander Langanke
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-FileCopyrightText: 2023 林博仁(Buo-ren, Lin) <Buo.Ren.Lin@gmail.com>
# SPDX-License-Identifier: MPL-2.0
name: ocrmypdf name: ocrmypdf
title: OCRmyPDF title: OCRmyPDF
base: core20 base: core20
@@ -41,9 +46,19 @@ parts:
stage-packages: stage-packages:
- lib32stdc++6 - lib32stdc++6
jbig2enc:
plugin: autotools
source: https://github.com/agl/jbig2enc.git
source-tag: '0.29'
build-packages:
- libleptonica-dev
ocrmypdf: ocrmypdf:
plugin: python plugin: python
source: https://github.com/ocrmypdf/OCRmyPDF.git source: .
build-packages:
- python3-pip
stage-packages: stage-packages:
- ghostscript - ghostscript
@@ -66,7 +81,14 @@ parts:
- setuptools - setuptools
- tqdm - tqdm
- pipe - pipe
- wheel
override-build: | override-build: |
pip3 install --user dephell[full]
$HOME/.local/bin/dephell deps convert \
--from-path pyproject.toml \
--from-format pyproject \
--to-path setup.py \
--to-format setuppy
snapcraftctl build snapcraftctl build
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
+5 -2
View File
@@ -1,3 +1,6 @@
<!-- SPDX-FileCopyrightText: 2022 James R. Barlow -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
# Release checklist # Release checklist
## Patch release ## Patch release
@@ -14,11 +17,11 @@
- Check README.md - Check README.md
- Check setup.py - Check pyproject.toml
- Are classifiers up to date? - Are classifiers up to date?
- Is `python_requires` correct? - Is `python_requires` correct?
- Python 3.6 is EOL on December 2021-12. Could drop support then. - Is it to drop support for older Pythons?
- Can we tighten any `install_requires` dependencies? - Can we tighten any `install_requires` dependencies?
- Search for old version shims we can remove - Search for old version shims we can remove
+30 -1
View File
@@ -21,7 +21,6 @@ from ocrmypdf.exceptions import (
InputFileError, InputFileError,
MissingDependencyError, MissingDependencyError,
OutputFileAccessError, OutputFileAccessError,
PdfMergeFailedError,
PriorOcrFoundError, PriorOcrFoundError,
SubprocessOutputError, SubprocessOutputError,
TesseractConfigError, TesseractConfigError,
@@ -30,3 +29,33 @@ from ocrmypdf.exceptions import (
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
hookimpl = _HookimplMarker('ocrmypdf') hookimpl = _HookimplMarker('ocrmypdf')
__all__ = [
'__version__',
'BadArgsError',
'configure_logging',
'DpiError',
'EncryptedPdfError',
'Executor',
'ExitCode',
'ExitCodeException',
'helpers',
'hocrtransform',
'hookimpl',
'InputFileError',
'MissingDependencyError',
'ocr',
'OcrEngine',
'OrientationConfidence',
'OutputFileAccessError',
'PageContext',
'pdfa',
'PdfContext',
'pdfinfo',
'PriorOcrFoundError',
'PROGRAM_NAME',
'SubprocessOutputError',
'TesseractConfigError',
'UnsupportedImageFormatError',
'Verbosity',
]
+6 -3
View File
@@ -11,7 +11,6 @@ import os
import signal import signal
import sys import sys
from contextlib import suppress from contextlib import suppress
from multiprocessing import set_start_method
from ocrmypdf import __version__ from ocrmypdf import __version__
from ocrmypdf._plugin_manager import get_parser_options_plugins from ocrmypdf._plugin_manager import get_parser_options_plugins
@@ -29,10 +28,16 @@ log = logging.getLogger('ocrmypdf')
def sigbus(*args): def sigbus(*args):
"""Handle SIGBUS signals.
pikepdf, depending on configuration, may use mmap so SIGBUS is a
possibility.
"""
raise InputFileError("Lost access to the input file") raise InputFileError("Lost access to the input file")
def run(args=None): def run(args=None):
"""Run the ocrmypdf command line interface."""
_parser, options, plugin_manager = get_parser_options_plugins(args=args) _parser, options, plugin_manager = get_parser_options_plugins(args=args)
with suppress(AttributeError, PermissionError): with suppress(AttributeError, PermissionError):
@@ -71,6 +76,4 @@ def run(args=None):
if __name__ == '__main__': if __name__ == '__main__':
if sys.platform == 'darwin' and sys.version_info < (3, 8):
set_start_method('spawn') # see python bpo-33725
sys.exit(run()) sys.exit(run())
+2 -4
View File
@@ -51,8 +51,7 @@ class Executor(ABC):
task_arguments: Iterable | None = None, task_arguments: Iterable | None = None,
task_finished: Callable | None = None, task_finished: Callable | None = None,
) -> None: ) -> None:
""" """Set up parallel execution and progress reporting.
Set up parallel execution and progress reporting.
Args: Args:
use_threads: If ``False``, the workload is the sort that will benefit from use_threads: If ``False``, the workload is the sort that will benefit from
@@ -60,7 +59,7 @@ class Executor(ABC):
heavily, and parallelizing it with threads is not expected to be heavily, and parallelizing it with threads is not expected to be
performant). performant).
max_workers: The maximum number of workers that should be run. max_workers: The maximum number of workers that should be run.
tdqm_kwargs: Arguments to set up the progress bar. tqdm_kwargs: Arguments to set up the progress bar.
worker_initializer: Called when a worker is initialized, in the worker's worker_initializer: Called when a worker is initialized, in the worker's
execution context. If the child workers are processes, it must be execution context. If the child workers are processes, it must be
possible to marshall/pickle the worker initializer. possible to marshall/pickle the worker initializer.
@@ -73,7 +72,6 @@ class Executor(ABC):
task. This runs in the parent's context, but the parameters must be task. This runs in the parent's context, but the parameters must be
marshallable to the worker. marshallable to the worker.
""" """
if not task_arguments: if not task_arguments:
return # Nothing to do! return # Nothing to do!
if not worker_initializer: if not worker_initializer:
+1 -1
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Manage third party executables""" """Manage third party executables."""
from __future__ import annotations from __future__ import annotations
+6 -30
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Interface to Ghostscript executable""" """Interface to Ghostscript executable."""
from __future__ import annotations from __future__ import annotations
@@ -14,6 +14,7 @@ from os import fspath
from pathlib import Path from pathlib import Path
from subprocess import PIPE, CalledProcessError from subprocess import PIPE, CalledProcessError
from packaging.version import Version
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from ocrmypdf.exceptions import SubprocessOutputError from ocrmypdf.exceptions import SubprocessOutputError
@@ -47,21 +48,6 @@ def version():
return get_version(GS) return get_version(GS)
def jpeg_passthrough_available() -> bool:
"""Returns True if the installed version of Ghostscript supports JPEG passthru
Prior to 9.23, Ghostscript decoded and re-encoded JPEGs internally. In 9.23
it gained the ability to keep JPEGs unmodified. However, the 9.23
implementation was buggy and would deletes the last two bytes of images in
some cases, as reported here.
https://bugs.ghostscript.com/show_bug.cgi?id=699216
The issue was fixed for 9.24, hence that is the first version we consider
the feature available. (Ghostscript 9.24 has its own problems is blacklisted.)
"""
return version() >= '9.24'
def _gs_error_reported(stream) -> bool: def _gs_error_reported(stream) -> bool:
match = re.search(r'error', stream, flags=re.IGNORECASE) match = re.search(r'error', stream, flags=re.IGNORECASE)
return bool(match) return bool(match)
@@ -201,20 +187,10 @@ def generate_pdfa(
] ]
strategy = 'LeaveColorUnchanged' strategy = 'LeaveColorUnchanged'
# Older versions of Ghostscript expect a leading slash in gs_version = Version(version())
# sColorConversionStrategy, newer ones should not have it. See Ghostscript if gs_version == Version('9.56.0'):
# git commit fe1c025d. # 9.56.0 breaks our OCR, should be fixed in 9.56.1
gs_version = version() # https://bugs.ghostscript.com/show_bug.cgi?id=705187
strategy = ('/' + strategy) if gs_version < '9.19' else strategy
if gs_version == '9.23':
# 9.23: added JPEG passthrough as a new feature, but with a bug that
# incorrectly formats some images. Fixed as of 9.24. So we disable this
# feature for 9.23.
# https://bugs.ghostscript.com/show_bug.cgi?id=699216
compression_args.append('-dPassThroughJPEGImages=false')
elif gs_version == '9.56.0':
# 9.56.0 breaks our OCR...?
compression_args.append('-dNEWPDF=false') compression_args.append('-dNEWPDF=false')
# nb no need to specify ProcessColorModel when ColorConversionStrategy # nb no need to specify ProcessColorModel when ColorConversionStrategy
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Interface to jbig2 executable""" """Interface to jbig2 executable."""
from __future__ import annotations from __future__ import annotations
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Interface to pngquant executable""" """Interface to pngquant executable."""
from __future__ import annotations from __future__ import annotations
+14 -18
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Interface to Tesseract executable""" """Interface to Tesseract executable."""
from __future__ import annotations from __future__ import annotations
@@ -33,11 +33,13 @@ HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<head> <head>
<title></title> <title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<meta name='ocr-system' content='tesseract 4.0.0' /> <meta name='ocr-system' content='tesseract 4.1.1' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/> <meta name='ocr-capabilities'
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head> </head>
<body> <body>
<div class='ocr_page' id='page_1' title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'> <div class='ocr_page' id='page_1'
title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'>
</div> </div>
</body> </body>
</html> </html>
@@ -52,7 +54,7 @@ TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
class TesseractLoggerAdapter(logging.LoggerAdapter): class TesseractLoggerAdapter(logging.LoggerAdapter):
"Prepend [tesseract] to messages emitted from tesseract" """Prepend [tesseract] to messages emitted from tesseract."""
def process(self, msg, kwargs): def process(self, msg, kwargs):
kwargs['extra'] = self.extra kwargs['extra'] = self.extra
@@ -104,7 +106,8 @@ TESSERACT_VERSION_PATTERN = r"""
class TesseractVersion(Version): class TesseractVersion(Version):
"Modify standard packaging.Version regex to support Tesseract idiosyncracies." """Modify standard packaging.Version regex to support Tesseract idiosyncrasies."""
_regex = re.compile( _regex = re.compile(
r"^\s*" + TESSERACT_VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE r"^\s*" + TESSERACT_VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
) )
@@ -114,15 +117,6 @@ def version() -> str:
return get_version('tesseract', regex=r'tesseract\s(.+)') return get_version('tesseract', regex=r'tesseract\s(.+)')
def has_user_words() -> bool:
"""Does Tesseract have --user-words capability?
Not available in 4.0, but available in 4.1. Also available in 3.x, but
we no longer support 3.x.
"""
return version() >= '4.1'
def has_thresholding() -> bool: def has_thresholding() -> bool:
"""Does Tesseract have -c thresholding method capability?""" """Does Tesseract have -c thresholding method capability?"""
return version() >= '5.0' return version() >= '5.0'
@@ -244,7 +238,7 @@ def get_deskew(
def tesseract_log_output(stream: bytes) -> None: def tesseract_log_output(stream: bytes) -> None:
tlog = TesseractLoggerAdapter( tlog = TesseractLoggerAdapter(
log, extra=log.extra if hasattr(log, 'extra') else None log, extra=log.extra if hasattr(log, 'extra') else None # type: ignore
) )
if not stream: if not stream:
@@ -289,8 +283,10 @@ def page_timedout(timeout: float) -> None:
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None: def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
"""Produce a .hocr file that reports no text detected on a page that is """Produce a .hocr file that reports no text detected.
the same size as the input image."""
Ensures page is the same size as the input image.
"""
with Image.open(image) as im: with Image.open(image) as im:
w, h = im.size w, h = im.size
+6 -4
View File
@@ -1,11 +1,9 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
from __future__ import annotations """Interface to unpaper executable."""
# unpaper documentation: from __future__ import annotations
# https://github.com/Flameeyes/unpaper/blob/master/doc/basic-concepts.md
"""Interface to unpaper executable"""
import logging import logging
import os import os
@@ -22,6 +20,10 @@ from PIL import Image
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
from ocrmypdf.subprocess import get_version, run from ocrmypdf.subprocess import get_version, run
# unpaper documentation:
# https://github.com/Flameeyes/unpaper/blob/master/doc/basic-concepts.md
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
else: else:
+4 -7
View File
@@ -37,7 +37,6 @@ def _update_resources(*, obj, font, font_key, procset):
obj can be a page or Form XObject. obj can be a page or Form XObject.
""" """
resources = _ensure_dictionary(obj, Name.Resources) resources = _ensure_dictionary(obj, Name.Resources)
fonts = _ensure_dictionary(resources, Name.Font) fonts = _ensure_dictionary(resources, Name.Font)
if font_key is not None and font_key not in fonts: if font_key is not None and font_key not in fonts:
@@ -131,7 +130,8 @@ class OcrGrafter:
text_misaligned = (text_rotation - content_rotation) % 360 text_misaligned = (text_rotation - content_rotation) % 360
log.debug( log.debug(
f"Text rotation: (text, autorotate, content) -> text misalignment = " f"Text rotation: (text, autorotate, content) -> text misalignment = "
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> {text_misaligned}" f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> "
f"{text_misaligned}"
) )
if textpdf and self.font: if textpdf and self.font:
@@ -166,7 +166,6 @@ class OcrGrafter:
the font to page 1 even if page 1 doesn't use it, so we have a way to get it the font to page 1 even if page 1 doesn't use it, so we have a way to get it
back. back.
""" """
page0 = self.pdf_base.pages[0] page0 = self.pdf_base.pages[0]
_update_resources( _update_resources(
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
@@ -199,8 +198,7 @@ class OcrGrafter:
return self.output_file return self.output_file
def _find_font(self, text): def _find_font(self, text):
"""Copy a font from the filename text into pdf_base""" """Copy a font from the filename text into pdf_base."""
font, font_key = None, None font, font_key = None, None
possible_font_names = ('/f-0-0', '/F1') possible_font_names = ('/f-0-0', '/F1')
try: try:
@@ -233,8 +231,7 @@ class OcrGrafter:
text_rotation: int, text_rotation: int,
strip_old_text: bool, strip_old_text: bool,
): ):
"""Insert the text layer from text page 0 on to pdf_base at page_num""" """Insert the text layer from text page 0 on to pdf_base at page_num."""
# pylint: disable=invalid-name # pylint: disable=invalid-name
log.debug("Grafting") log.debug("Grafting")
+1 -1
View File
@@ -59,7 +59,7 @@ class PdfContext:
class PageContext: class PageContext:
"""Holds our context for a page. """Holds our context for a page.
Must be pickable, so stores only intrinsic/simple data elements or those Must be pickle-able, so stores only intrinsic/simple data elements or those
capable of their serializing themselves via ``__getstate__``. capable of their serializing themselves via ``__getstate__``.
""" """
+1 -1
View File
@@ -24,7 +24,7 @@ class PageNumberFilter(logging.Filter):
class TqdmConsole: class TqdmConsole:
"""Wrapper to log messages in a way that is compatible with tqdm progress bar """Wrapper to log messages in a way that is compatible with tqdm progress bar.
This routes log messages through tqdm so that it can print them above the This routes log messages through tqdm so that it can print them above the
progress bar, and then refresh the progress bar, rather than overwriting progress bar, and then refresh the progress bar, rather than overwriting
+66 -69
View File
@@ -1,4 +1,5 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2018-2022 James R. Barlow
# SPDX-FileCopyrightText: 2019 Martin Wind
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""OCRmyPDF page processing pipeline functions.""" """OCRmyPDF page processing pipeline functions."""
@@ -13,7 +14,7 @@ from contextlib import suppress
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from shutil import copyfileobj from shutil import copyfileobj
from typing import Iterable from typing import Any, BinaryIO, Iterable, Sequence, cast
import img2pdf import img2pdf
import pikepdf import pikepdf
@@ -35,7 +36,7 @@ from ocrmypdf.exceptions import (
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
from ocrmypdf.hocrtransform import HocrTransform from ocrmypdf.hocrtransform import HocrTransform
from ocrmypdf.pdfa import generate_pdfa_ps from ocrmypdf.pdfa import generate_pdfa_ps
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
# Remove this workaround when we require Pillow >= 10 # Remove this workaround when we require Pillow >= 10
try: try:
@@ -49,7 +50,7 @@ log = logging.getLogger(__name__)
VECTOR_PAGE_DPI = 400 VECTOR_PAGE_DPI = 400
def triage_image_file(input_file, output_file, options): def triage_image_file(input_file: Path, output_file: Path, options) -> None:
log.info("Input file is not a PDF, checking if it is an image...") log.info("Input file is not a PDF, checking if it is an image...")
try: try:
im = Image.open(input_file) im = Image.open(input_file)
@@ -113,23 +114,24 @@ def triage_image_file(input_file, output_file, options):
raise UnsupportedImageFormatError() from e raise UnsupportedImageFormatError() from e
def _pdf_guess_version(input_file, search_window=1024): def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
"""Try to find version signature at start of file. """Try to find version signature at start of file.
Not robust enough to deal with appended files. Not robust enough to deal with appended files.
Returns empty string if not found, indicating file is probably not PDF. Returns empty string if not found, indicating file is probably not PDF.
""" """
with open(input_file, 'rb') as f: with open(input_file, 'rb') as f:
signature = f.read(search_window) signature = f.read(search_window)
m = re.search(br'%PDF-(\d\.\d)', signature) m = re.search(br'%PDF-(\d\.\d)', signature)
if m: if m:
return m.group(1) return m.group(1).decode('ascii')
return '' return ''
def triage(original_filename, input_file, output_file, options): def triage(
original_filename: str, input_file: Path, output_file: Path, options
) -> Path:
try: try:
if _pdf_guess_version(input_file): if _pdf_guess_version(input_file):
if options.image_dpi: if options.image_dpi:
@@ -153,9 +155,9 @@ def get_pdfinfo(
input_file, input_file,
*, *,
executor: Executor, executor: Executor,
detailed_analysis=False, detailed_analysis: bool = False,
progbar=False, progbar: bool = False,
max_workers=None, max_workers: int | None = None,
check_pages=None, check_pages=None,
) -> PdfInfo: ) -> PdfInfo:
try: try:
@@ -173,7 +175,7 @@ def get_pdfinfo(
raise InputFileError() from e raise InputFileError() from e
def validate_pdfinfo_options(context: PdfContext): def validate_pdfinfo_options(context: PdfContext) -> None:
pdfinfo = context.pdfinfo pdfinfo = context.pdfinfo
options = context.options options = context.options
@@ -183,15 +185,6 @@ def validate_pdfinfo_options(context: PdfContext):
"Designer and can only be read by Adobe Acrobat or Adobe Reader." "Designer and can only be read by Adobe Acrobat or Adobe Reader."
) )
raise InputFileError() raise InputFileError()
if pdfinfo.has_userunit and options.output_type.startswith('pdfa'):
log.error(
"This input file uses a PDF feature that is not supported "
"by Ghostscript, so you cannot use --output-type=pdfa for this "
"file. (Specifically, it uses the PDF-1.6 /UserUnit feature to "
"support very large or small page sizes, and Ghostscript cannot "
"output these files.) Use --output-type=pdf instead."
)
raise InputFileError()
if pdfinfo.has_acroform: if pdfinfo.has_acroform:
if options.redo_ocr: if options.redo_ocr:
log.error( log.error(
@@ -214,12 +207,12 @@ def validate_pdfinfo_options(context: PdfContext):
context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options) context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options)
def _vector_page_dpi(pageinfo): def _vector_page_dpi(pageinfo: PageInfo) -> int:
return VECTOR_PAGE_DPI if pageinfo.has_vector or pageinfo.has_text else 0.0 return VECTOR_PAGE_DPI if pageinfo.has_vector or pageinfo.has_text else 0
def get_page_dpi(pageinfo, options): def get_page_dpi(pageinfo: PageInfo, options) -> Resolution:
"Get the DPI when nonsquare DPI is tolerable" """Get the DPI when nonsquare DPI is tolerable."""
xres = max( xres = max(
pageinfo.dpi.x or VECTOR_PAGE_DPI, pageinfo.dpi.x or VECTOR_PAGE_DPI,
options.oversample or 0.0, options.oversample or 0.0,
@@ -233,8 +226,8 @@ def get_page_dpi(pageinfo, options):
return Resolution(float(xres), float(yres)) return Resolution(float(xres), float(yres))
def get_page_square_dpi(pageinfo, options) -> Resolution: def get_page_square_dpi(pageinfo: PageInfo, options) -> Resolution:
"Get the DPI when we require xres == yres, scaled to physical units" """Get the DPI when we require xres == yres, scaled to physical units."""
xres = pageinfo.dpi.x or 0.0 xres = pageinfo.dpi.x or 0.0
yres = pageinfo.dpi.y or 0.0 yres = pageinfo.dpi.y or 0.0
userunit = float(pageinfo.userunit) or 1.0 userunit = float(pageinfo.userunit) or 1.0
@@ -249,8 +242,8 @@ def get_page_square_dpi(pageinfo, options) -> Resolution:
return Resolution(units, units) return Resolution(units, units)
def get_canvas_square_dpi(pageinfo, options) -> Resolution: def get_canvas_square_dpi(pageinfo: PageInfo, options) -> Resolution:
"""Get the DPI when we require xres == yres, in Postscript units""" """Get the DPI when we require xres == yres, in Postscript units."""
units = float( units = float(
max( max(
(pageinfo.dpi.x) or VECTOR_PAGE_DPI, (pageinfo.dpi.x) or VECTOR_PAGE_DPI,
@@ -262,7 +255,7 @@ def get_canvas_square_dpi(pageinfo, options) -> Resolution:
return Resolution(units, units) return Resolution(units, units)
def is_ocr_required(page_context: PageContext): def is_ocr_required(page_context: PageContext) -> bool:
pageinfo = page_context.pageinfo pageinfo = page_context.pageinfo
options = page_context.options options = page_context.options
@@ -312,8 +305,8 @@ def is_ocr_required(page_context: PageContext):
log.warning( log.warning(
"page has no images - " "page has no images - "
"all vector content will be " "all vector content will be "
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and likely " f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and "
"increasing file size. Use --oversample to adjust the " "likely increasing file size. Use --oversample to adjust the "
"DPI." "DPI."
) )
else: else:
@@ -337,7 +330,7 @@ def is_ocr_required(page_context: PageContext):
return ocr_required return ocr_required
def rasterize_preview(input_file: Path, page_context: PageContext): def rasterize_preview(input_file: Path, page_context: PageContext) -> Path:
output_file = page_context.get_path('rasterize_preview.jpg') output_file = page_context.get_path('rasterize_preview.jpg')
canvas_dpi = get_canvas_square_dpi(page_context.pageinfo, page_context.options) canvas_dpi = get_canvas_square_dpi(page_context.pageinfo, page_context.options)
page_dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) page_dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
@@ -354,10 +347,8 @@ def rasterize_preview(input_file: Path, page_context: PageContext):
return output_file return output_file
def describe_rotation(page_context: PageContext, orient_conf, correction: int): def describe_rotation(page_context: PageContext, orient_conf, correction: int) -> str:
""" """Describe the page rotation we are going to perform."""
Describe the page rotation we are going to perform.
"""
direction = {0: '', 90: '', 180: '', 270: ''} direction = {0: '', 90: '', 180: '', 270: ''}
turns = {0: ' ', 90: '', 180: '', 270: ''} turns = {0: ' ', 90: '', 180: '', 270: ''}
@@ -383,7 +374,7 @@ def describe_rotation(page_context: PageContext, orient_conf, correction: int):
return f"{facing}, confidence {orient_conf.confidence:.2f} - {action}" return f"{facing}, confidence {orient_conf.confidence:.2f} - {action}"
def get_orientation_correction(preview: Path, page_context: PageContext): def get_orientation_correction(preview: Path, page_context: PageContext) -> int:
"""Work out orientation correct for each page. """Work out orientation correct for each page.
We ask Ghostscript to draw a preview page, which will rasterize with the We ask Ghostscript to draw a preview page, which will rasterize with the
@@ -398,7 +389,6 @@ def get_orientation_correction(preview: Path, page_context: PageContext):
which points it (hopefully) upright. _graft.py takes care of the orienting which points it (hopefully) upright. _graft.py takes care of the orienting
the image and text layers. the image and text layers.
""" """
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation( orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
preview, page_context.options preview, page_context.options
) )
@@ -469,18 +459,17 @@ def rasterize(
return output_file return output_file
def preprocess_remove_background(input_file: Path, page_context: PageContext): def preprocess_remove_background(input_file: Path, page_context: PageContext) -> Path:
if any(image.bpc > 1 for image in page_context.pageinfo.images): if any(image.bpc > 1 for image in page_context.pageinfo.images):
raise NotImplementedError("--remove-background is temporarily not implemented") raise NotImplementedError("--remove-background is temporarily not implemented")
# output_file = page_context.get_path('pp_rm_bg.png') # output_file = page_context.get_path('pp_rm_bg.png')
# leptonica.remove_background(input_file, output_file) # leptonica.remove_background(input_file, output_file)
# return output_file # return output_file
else: log.info("background removal skipped on mono page")
log.info("background removal skipped on mono page") return input_file
return input_file
def preprocess_deskew(input_file: Path, page_context: PageContext): def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path:
output_file = page_context.get_path('pp_deskew.png') output_file = page_context.get_path('pp_deskew.png')
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
@@ -500,7 +489,7 @@ def preprocess_deskew(input_file: Path, page_context: PageContext):
return output_file return output_file
def preprocess_clean(input_file: Path, page_context: PageContext): def preprocess_clean(input_file: Path, page_context: PageContext) -> Path:
output_file = page_context.get_path('pp_clean.png') output_file = page_context.get_path('pp_clean.png')
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
return unpaper.clean( return unpaper.clean(
@@ -511,11 +500,12 @@ def preprocess_clean(input_file: Path, page_context: PageContext):
) )
def create_ocr_image(image: Path, page_context: PageContext): def create_ocr_image(image: Path, page_context: PageContext) -> Path:
"""Create the image we send for OCR. May not be the same as the display """Create the image we send for OCR.
image depending on preprocessing. This image will never be shown to the
user."""
Might not be the same as the display image depending on preprocessing.
This image will never be shown to the user.
"""
output_file = page_context.get_path('ocr.png') output_file = page_context.get_path('ocr.png')
options = page_context.options options = page_context.options
with Image.open(image) as im: with Image.open(image) as im:
@@ -559,7 +549,7 @@ def create_ocr_image(image: Path, page_context: PageContext):
return output_file return output_file
def ocr_engine_hocr(input_file: Path, page_context: PageContext): def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, Path]:
hocr_out = page_context.get_path('ocr_hocr.hocr') hocr_out = page_context.get_path('ocr_hocr.hocr')
hocr_text_out = page_context.get_path('ocr_hocr.txt') hocr_text_out = page_context.get_path('ocr_hocr.txt')
options = page_context.options options = page_context.options
@@ -574,9 +564,11 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext):
return (hocr_out, hocr_text_out) return (hocr_out, hocr_text_out)
def should_visible_page_image_use_jpg(pageinfo): def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
# If all images were JPEGs originally, produce a JPEG as output # If all images were JPEGs originally, produce a JPEG as output
return pageinfo.images and all(im.enc == Encoding.jpeg for im in pageinfo.images) return bool(pageinfo.images) and all(
im.enc == Encoding.jpeg for im in pageinfo.images
)
def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path: def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
@@ -599,8 +591,8 @@ def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
def create_pdf_page_from_image( def create_pdf_page_from_image(
image: Path, page_context: PageContext, orientation_correction image: Path, page_context: PageContext, orientation_correction: int
): ) -> Path:
# We rasterize a square DPI version of each page because most image # We rasterize a square DPI version of each page because most image
# processing tools don't support rectangular DPI. Use the square DPI as it # processing tools don't support rectangular DPI. Use the square DPI as it
# accurately describes the image. It would be possible to resample the image # accurately describes the image. It would be possible to resample the image
@@ -628,11 +620,10 @@ def create_pdf_page_from_image(
output_file = page_context.plugin_manager.hook.filter_pdf_page( output_file = page_context.plugin_manager.hook.filter_pdf_page(
page=page_context, image_filename=image, output_pdf=output_file page=page_context, image_filename=image, output_pdf=output_file
) )
return output_file return output_file
def render_hocr_page(hocr: Path, page_context: PageContext): def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
options = page_context.options options = page_context.options
output_file = page_context.get_path('ocr_hocr.pdf') output_file = page_context.get_path('ocr_hocr.pdf')
dpi = get_page_square_dpi(page_context.pageinfo, options) dpi = get_page_square_dpi(page_context.pageinfo, options)
@@ -649,7 +640,9 @@ def render_hocr_page(hocr: Path, page_context: PageContext):
return output_file return output_file
def ocr_engine_textonly_pdf(input_image: Path, page_context: PageContext): def ocr_engine_textonly_pdf(
input_image: Path, page_context: PageContext
) -> tuple[Path, Path]:
output_pdf = page_context.get_path('ocr_tess.pdf') output_pdf = page_context.get_path('ocr_tess.pdf')
output_text = page_context.get_path('ocr_tess.txt') output_text = page_context.get_path('ocr_tess.txt')
options = page_context.options options = page_context.options
@@ -695,13 +688,13 @@ def get_docinfo(base_pdf: pikepdf.Pdf, context: PdfContext) -> dict[str, str]:
return pdfmark return pdfmark
def generate_postscript_stub(context: PdfContext): def generate_postscript_stub(context: PdfContext) -> Path:
output_file = context.get_path('pdfa.ps') output_file = context.get_path('pdfa.ps')
generate_pdfa_ps(output_file) generate_pdfa_ps(output_file)
return output_file return output_file
def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext): def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -> Path:
options = context.options options = context.options
input_pdfinfo = context.pdfinfo input_pdfinfo = context.pdfinfo
fix_docinfo_file = context.get_path('fix_docinfo.pdf') fix_docinfo_file = context.get_path('fix_docinfo.pdf')
@@ -748,14 +741,14 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext):
return output_file return output_file
def should_linearize(working_file: Path, context: PdfContext): def should_linearize(working_file: Path, context: PdfContext) -> bool:
filesize = os.stat(working_file).st_size filesize = os.stat(working_file).st_size
if filesize > (context.options.fast_web_view * 1_000_000): if filesize > (context.options.fast_web_view * 1_000_000):
return True return True
return False return False
def get_pdf_save_settings(output_type: str): def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
if output_type == 'pdfa-1': if output_type == 'pdfa-1':
# Trigger recompression to ensure object streams are removed, because # Trigger recompression to ensure object streams are removed, because
# Acrobat complains about them in PDF/A-1b validation. # Acrobat complains about them in PDF/A-1b validation.
@@ -773,7 +766,7 @@ def get_pdf_save_settings(output_type: str):
) )
def metadata_fixup(working_file: Path, context: PdfContext): def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
output_file = context.get_path('metafix.pdf') output_file = context.get_path('metafix.pdf')
options = context.options options = context.options
@@ -829,7 +822,9 @@ def metadata_fixup(working_file: Path, context: PdfContext):
return output_file return output_file
def optimize_pdf(input_file: Path, context: PdfContext, executor: Executor): def optimize_pdf(
input_file: Path, context: PdfContext, executor: Executor
) -> tuple[Path, Sequence[str]]:
output_file = context.get_path('optimize.pdf') output_file = context.get_path('optimize.pdf')
output_pdf, messages = context.plugin_manager.hook.optimize_pdf( output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
input_pdf=input_file, input_pdf=input_file,
@@ -865,7 +860,7 @@ def enumerate_compress_ranges(iterable):
yield (skipped_from, index), None yield (skipped_from, index), None
def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext): def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Path:
output_file = context.get_path('sidecar.txt') output_file = context.get_path('sidecar.txt')
with open(output_file, 'w', encoding="utf-8") as stream: with open(output_file, 'w', encoding="utf-8") as stream:
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files): for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
@@ -890,15 +885,17 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext):
return output_file return output_file
def copy_final(input_file, output_file, _context: PdfContext): def copy_final(
input_file: Path, output_file: str | Path | BinaryIO, _context: PdfContext
) -> None:
log.debug('%s -> %s', input_file, output_file) log.debug('%s -> %s', input_file, output_file)
with open(input_file, 'rb') as input_stream: with input_file.open('rb') as input_stream:
if output_file == '-': if output_file == '-':
copyfileobj(input_stream, sys.stdout.buffer) copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush() sys.stdout.flush()
elif hasattr(output_file, 'writable'): elif hasattr(output_file, 'writable'):
output_stream = output_file output_stream = cast(BinaryIO, output_file)
copyfileobj(input_stream, output_stream) copyfileobj(input_stream, output_stream) # type: ignore[misc]
with suppress(AttributeError): with suppress(AttributeError):
output_stream.flush() output_stream.flush()
else: else:
+9 -10
View File
@@ -1,4 +1,5 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2019-2022 James R. Barlow
# SPDX-FileCopyrightText: 2019 Martin Wind
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Implements the concurrent and page synchronous parts of the pipeline.""" """Implements the concurrent and page synchronous parts of the pipeline."""
@@ -206,7 +207,7 @@ def exec_page_sync(page_context: PageContext) -> PageResult:
filtered_image = page_context.plugin_manager.hook.filter_page_image( filtered_image = page_context.plugin_manager.hook.filter_page_image(
page=page_context, image_filename=visible_image_out page=page_context, image_filename=visible_image_out
) )
if filtered_image: if filtered_image is not None: # None if no hook is present
visible_image_out = filtered_image visible_image_out = filtered_image
pdf_page_from_image_out = create_pdf_page_from_image( pdf_page_from_image_out = create_pdf_page_from_image(
visible_image_out, page_context, orientation_correction visible_image_out, page_context, orientation_correction
@@ -250,8 +251,7 @@ def worker_init(max_pixels: int) -> None:
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
"""Execute the pipeline concurrently""" """Execute the pipeline concurrently."""
# Run exec_page_sync on every page context # Run exec_page_sync on every page context
options = context.options options = context.options
max_workers = min(len(context.pdfinfo), options.jobs) max_workers = min(len(context.pdfinfo), options.jobs)
@@ -315,8 +315,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
def configure_debug_logging( def configure_debug_logging(
log_filename: Path, prefix: str = '' log_filename: Path, prefix: str = ''
) -> logging.FileHandler: ) -> logging.FileHandler:
""" """Create a debug log file at a specified location.
Create a debug log file at a specified location.
Arguments: Arguments:
log_filename: Where to the put the log file. log_filename: Where to the put the log file.
@@ -419,13 +418,13 @@ def run_pipeline(
options, start_input_file, options.output_file, optimize_messages options, start_input_file, options.output_file, optimize_messages
) )
except (KeyboardInterrupt if not api else NeverRaise): except KeyboardInterrupt if not api else NeverRaise:
if options.verbose >= 1: if options.verbose >= 1:
log.exception("KeyboardInterrupt") log.exception("KeyboardInterrupt")
else: else:
log.error("KeyboardInterrupt") log.error("KeyboardInterrupt")
return ExitCode.ctrl_c return ExitCode.ctrl_c
except (ExitCodeException if not api else NeverRaise) as e: except ExitCodeException if not api else NeverRaise as e:
e = cast(ExitCodeException, e) e = cast(ExitCodeException, e)
if options.verbose >= 1: if options.verbose >= 1:
log.exception("ExitCodeException") log.exception("ExitCodeException")
@@ -434,7 +433,7 @@ def run_pipeline(
else: else:
log.error(type(e).__name__) log.error(type(e).__name__)
return e.exit_code return e.exit_code
except (PIL.Image.DecompressionBombError if not api else NeverRaise): except PIL.Image.DecompressionBombError if not api else NeverRaise:
log.exception( log.exception(
"A decompression bomb error was encountered while executing the " "A decompression bomb error was encountered while executing the "
"pipeline. Use the argument --max-image-mpixels to raise the maximum " "pipeline. Use the argument --max-image-mpixels to raise the maximum "
@@ -452,7 +451,7 @@ def run_pipeline(
"argument." "argument."
) )
return ExitCode.child_process_error return ExitCode.child_process_error
except (Exception if not api else NeverRaise): # pylint: disable=broad-except except Exception if not api else NeverRaise: # pylint: disable=broad-except
log.exception("An exception occurred while executing the pipeline") log.exception("An exception occurred while executing the pipeline")
return ExitCode.other_error return ExitCode.other_error
finally: finally:
+17 -10
View File
@@ -61,12 +61,20 @@ def check_options_languages(options: Namespace, ocr_engine_languages: set[str])
return return
missing_languages = options.languages - ocr_engine_languages missing_languages = options.languages - ocr_engine_languages
if missing_languages: if missing_languages:
lang_text = '\n'.join(lang for lang in missing_languages)
msg = ( msg = (
"OCR engine does not have language data for the following " "OCR engine does not have language data for the following "
"requested languages: \n" "requested languages: \n"
f"{lang_text}\n"
"Please install the appropriate language data for your OCR engine.\n"
"\n"
"See the online documentation for instructions:\n"
" https://ocrmypdf.readthedocs.io/en/latest/languages.html\n"
"\n"
"Note: most languages are identified by a 3-digit ISO 639-2 Code.\n"
"For example, English is 'eng', German is 'deu', and Spanish is 'spa'."
"\n"
) )
msg += '\n'.join(lang for lang in missing_languages)
msg += '\nNote: most languages are identified by a 3-digit ISO 639-2 Code'
raise MissingDependencyError(msg) raise MissingDependencyError(msg)
@@ -109,12 +117,10 @@ def check_options_output(options: Namespace) -> None:
def check_options_sidecar(options: Namespace) -> None: def check_options_sidecar(options: Namespace) -> None:
if options.sidecar == '\0': if options.sidecar == '\0':
if options.output_file == '-': if options.output_file == '-':
raise BadArgsError( raise BadArgsError("--sidecar filename needed when output file is stdout.")
"--sidecar filename must be specified when output file is stdout."
)
elif options.output_file == os.devnull: elif options.output_file == os.devnull:
raise BadArgsError( raise BadArgsError(
"--sidecar filename must be specified when output file is /dev/null or NUL." "--sidecar filename needed when output file is /dev/null or NUL."
) )
options.sidecar = options.output_file + '.txt' options.sidecar = options.output_file + '.txt'
if options.sidecar == options.input_file or options.sidecar == options.output_file: if options.sidecar == options.input_file or options.sidecar == options.output_file:
@@ -134,7 +140,7 @@ def check_options_preprocessing(options: Namespace) -> None:
package='unpaper', package='unpaper',
version_checker=unpaper.version, version_checker=unpaper.version,
need_version='6.1', need_version='6.1',
required_for=['--clean, --clean-final'], required_for="--clean, --clean-final", # Problem arguments
) )
try: try:
if options.unpaper_args: if options.unpaper_args:
@@ -221,7 +227,7 @@ def check_options_metadata(options: Namespace) -> None:
def check_options_pillow(options: Namespace) -> None: def check_options_pillow(options: Namespace) -> None:
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000) PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
if PIL.Image.MAX_IMAGE_PIXELS == 0: if PIL.Image.MAX_IMAGE_PIXELS == 0:
PIL.Image.MAX_IMAGE_PIXELS = None PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
def _check_plugin_invariant_options(options: Namespace) -> None: def _check_plugin_invariant_options(options: Namespace) -> None:
@@ -276,7 +282,8 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
"permissions correctly.\n" "permissions correctly.\n"
"You may find it easier to use stdin/stdout:" "You may find it easier to use stdin/stdout:"
"\n" "\n"
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf\n" "\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf"
"\n"
) )
raise InputFileError(msg) from e raise InputFileError(msg) from e
@@ -333,7 +340,7 @@ def report_output_file_size(
for arg in image_preproc: for arg in image_preproc:
if getattr(options, arg, False): if getattr(options, arg, False):
reasons.append( reasons.append(
f"The argument --{arg.replace('_', '-')} was issued, causing transcoding." f"--{arg.replace('_', '-')} was issued, causing transcoding."
) )
reasons.extend(optimize_messages) reasons.extend(optimize_messages)
+1 -4
View File
@@ -8,10 +8,7 @@ OCRmyPDF uses setuptools_scm to derive version from git tags.
from __future__ import annotations from __future__ import annotations
try: from importlib.metadata import version as _package_version
from importlib.metadata import version as _package_version
except ImportError:
from importlib_metadata import version as _package_version # type: ignore
PROGRAM_NAME = 'ocrmypdf' PROGRAM_NAME = 'ocrmypdf'
+62 -56
View File
@@ -15,6 +15,9 @@ from pathlib import Path
from typing import AnyStr, BinaryIO, Iterable, Union from typing import AnyStr, BinaryIO, Iterable, Union
from warnings import warn from warnings import warn
import coloredlogs
from humanfriendly.terminal import enable_ansi_support
from ocrmypdf._logging import PageNumberFilter, TqdmConsole from ocrmypdf._logging import PageNumberFilter, TqdmConsole
from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf._sync import run_pipeline from ocrmypdf._sync import run_pipeline
@@ -22,15 +25,6 @@ from ocrmypdf._validation import check_options
from ocrmypdf.cli import ArgumentParser, get_parser from ocrmypdf.cli import ArgumentParser, get_parser
from ocrmypdf.helpers import is_iterable_notstr from ocrmypdf.helpers import is_iterable_notstr
try:
import coloredlogs
except ModuleNotFoundError:
coloredlogs = None # pylint: disable=invalid-name
if coloredlogs:
from humanfriendly.terminal import enable_ansi_support
StrPath = Union[Path, AnyStr] StrPath = Union[Path, AnyStr]
PathOrIO = Union[BinaryIO, StrPath] PathOrIO = Union[BinaryIO, StrPath]
@@ -92,7 +86,6 @@ def configure_logging(
Returns: Returns:
The toplevel logger for ocrmypdf (or the root logger, if we are managing it). The toplevel logger for ocrmypdf (or the root logger, if we are managing it).
""" """
prefix = '' if manage_root_logger else 'ocrmypdf' prefix = '' if manage_root_logger else 'ocrmypdf'
log = logging.getLogger(prefix) log = logging.getLogger(prefix)
@@ -121,7 +114,7 @@ def configure_logging(
use_colors = progress_bar_friendly use_colors = progress_bar_friendly
formatter = None formatter = None
if coloredlogs and use_colors: if use_colors:
use_colors = enable_ansi_support() use_colors = enable_ansi_support()
if use_colors: if use_colors:
use_colors = coloredlogs.terminal_supports_colors() use_colors = coloredlogs.terminal_supports_colors()
@@ -149,6 +142,7 @@ def configure_logging(
def create_options( def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs *, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
): ):
"""Construct an options object from the input/output files and keyword arguments."""
cmdline = [] cmdline = []
deferred = [] deferred = []
@@ -209,59 +203,72 @@ def create_options(
return options return options
def ocr( # pylint: disable=unused-argument def ocr( # noqa: ruff: disable=D417
input_file: PathOrIO, input_file: PathOrIO,
output_file: PathOrIO, output_file: PathOrIO,
*, *,
language: Iterable[str] = None, language: Iterable[str] | None = None,
image_dpi: int = None, image_dpi: int | None = None,
output_type=None, output_type: str | None = None,
sidecar: StrPath | None = None, sidecar: StrPath | None = None,
jobs: int = None, jobs: int | None = None,
use_threads: bool = None, use_threads: bool | None = None,
title: str = None, title: str | None = None,
author: str = None, author: str | None = None,
subject: str = None, subject: str | None = None,
keywords: str = None, keywords: str | None = None,
rotate_pages: bool = None, rotate_pages: bool | None = None,
remove_background: bool = None, remove_background: bool | None = None,
deskew: bool = None, deskew: bool | None = None,
clean: bool = None, clean: bool | None = None,
clean_final: bool = None, clean_final: bool | None = None,
unpaper_args: str = None, unpaper_args: str | None = None,
oversample: int = None, oversample: int | None = None,
remove_vectors: bool = None, remove_vectors: bool | None = None,
force_ocr: bool = None, force_ocr: bool | None = None,
skip_text: bool = None, skip_text: bool | None = None,
redo_ocr: bool = None, redo_ocr: bool | None = None,
skip_big: float = None, skip_big: float | None = None,
optimize: int = None, optimize: int | None = None,
jpg_quality: int = None, jpg_quality: int | None = None,
png_quality: int = None, png_quality: int | None = None,
jbig2_lossy: bool = None, jbig2_lossy: bool | None = None,
jbig2_page_group_size: int = None, jbig2_page_group_size: int | None = None,
pages: str = None, pages: str | None = None,
max_image_mpixels: float = None, max_image_mpixels: float | None = None,
tesseract_config: Iterable[str] = None, tesseract_config: Iterable[str] | None = None,
tesseract_pagesegmode: int = None, tesseract_pagesegmode: int | None = None,
tesseract_oem: int = None, tesseract_oem: int | None = None,
tesseract_thresholding: int = None, tesseract_thresholding: int | None = None,
pdf_renderer=None, pdf_renderer: str | None = None,
tesseract_timeout: float = None, tesseract_timeout: float | None = None,
rotate_pages_threshold: float = None, tesseract_non_ocr_timeout: float | None = None,
pdfa_image_compression=None, rotate_pages_threshold: float | None = None,
user_words: os.PathLike = None, pdfa_image_compression: str | None = None,
user_patterns: os.PathLike = None, user_words: os.PathLike | None = None,
fast_web_view: float = None, user_patterns: os.PathLike | None = None,
plugins: Iterable[StrPath] = None, fast_web_view: float | None = None,
plugins: Iterable[StrPath] | None = None,
plugin_manager=None, plugin_manager=None,
keep_temporary_files: bool = None, keep_temporary_files: bool | None = None,
progress_bar: bool = None, progress_bar: bool | None = None,
**kwargs, **kwargs,
): ):
"""Run OCRmyPDF on one PDF or image. """Run OCRmyPDF on one PDF or image.
For most arguments, see documentation for the equivalent command line parameter. For most arguments, see documentation for the equivalent command line parameter.
This API takes a threading lock, because OCRmyPDF uses global state in particular
for the plugin system. The jobs parameter will be used to create a pool of
worker threads or processes at different times, subject to change. A Python
process can only run one OCRmyPDF task at a time.
To run parallelize instances OCRmyPDF, use separate Python processes to scale
horizontally. Generally speaking you should set jobs=sqrt(cpu_count) and run
sqrt(cpu_count) processes as a starting point. If you have files with a high page
count, run fewer processes and more jobs per process. If you have a lot of short
files, run more processes and fewer jobs per process.
A few specific arguments are discussed here: A few specific arguments are discussed here:
Args: Args:
@@ -283,9 +290,8 @@ def ocr( # pylint: disable=unused-argument
When a stream is used as output, whether via a writable object or When a stream is used as output, whether via a writable object or
``"-"``, some final validation steps are not performed (we do not read ``"-"``, some final validation steps are not performed (we do not read
back the stream after it is written). back the stream after it is written).
Raises: Raises:
ocrmypdf.PdfMergeFailedError: If the input PDF is malformed, preventing merging
with the OCR layer.
ocrmypdf.MissingDependencyError: If a required dependency program is missing or ocrmypdf.MissingDependencyError: If a required dependency program is missing or
was not found on PATH. was not found on PATH.
ocrmypdf.UnsupportedImageFormatError: If the input file type was an image that ocrmypdf.UnsupportedImageFormatError: If the input file type was an image that
+2 -4
View File
@@ -1,8 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
from __future__ import annotations """Plugins in this package are automatically loaded by ocrmypdf."""
# This file exists only mark builtin_plugins as a package. from __future__ import annotations
# The plugin manager will not load it, so anything defined here may not be
# processed as a module.
+9 -6
View File
@@ -1,9 +1,9 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
"""OCRmyPDF's multiprocessing/multithreading abstraction layer.""" """OCRmyPDF's multiprocessing/multithreading abstraction layer."""
from __future__ import annotations
import logging import logging
import logging.handlers import logging.handlers
import multiprocessing import multiprocessing
@@ -30,7 +30,7 @@ WorkerInit = Callable[[Queue, UserInit, int], None]
def log_listener(q: Queue): def log_listener(q: Queue):
"""Listen to the worker processes and forward the messages to logging """Listen to the worker processes and forward the messages to logging.
For simplicity this is a thread rather than a process. Only one process For simplicity this is a thread rather than a process. Only one process
should actually write to sys.stderr or whatever we're using, so if this is should actually write to sys.stderr or whatever we're using, so if this is
@@ -39,7 +39,6 @@ def log_listener(q: Queue):
See: See:
https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
""" """
while True: while True:
try: try:
record = q.get() record = q.get()
@@ -55,12 +54,12 @@ def log_listener(q: Queue):
def process_sigbus(*args): def process_sigbus(*args):
"""Handle SIGBUS signal at the worker level."""
raise InputFileError("A worker process lost access to an input file") raise InputFileError("A worker process lost access to an input file")
def process_init(q: Queue, user_init: UserInit, loglevel) -> None: def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
"""Initialize a process pool worker""" """Initialize a process pool worker."""
# Ignore SIGINT (our parent process will kill us gracefully) # Ignore SIGINT (our parent process will kill us gracefully)
signal.signal(signal.SIGINT, signal.SIG_IGN) signal.signal(signal.SIGINT, signal.SIG_IGN)
@@ -82,6 +81,7 @@ def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
def thread_init(q: Queue, user_init: UserInit, loglevel) -> None: def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
"""Begin a thread pool worker."""
del q # unused but required argument del q # unused but required argument
del loglevel # unused but required argument del loglevel # unused but required argument
# As a thread, block SIGBUS so the main thread deals with it... # As a thread, block SIGBUS so the main thread deals with it...
@@ -164,14 +164,17 @@ class StandardExecutor(Executor):
@hookimpl @hookimpl
def get_executor(progressbar_class): def get_executor(progressbar_class):
"""Return the default executor."""
return StandardExecutor(pbar_class=progressbar_class) return StandardExecutor(pbar_class=progressbar_class)
@hookimpl @hookimpl
def get_progressbar_class(): def get_progressbar_class():
"""Return the default progress bar class."""
return tqdm return tqdm
@hookimpl @hookimpl
def get_logging_console(): def get_logging_console():
"""Return the default logging console handler."""
return logging.StreamHandler(stream=TqdmConsole(sys.stderr)) return logging.StreamHandler(stream=TqdmConsole(sys.stderr))
+5 -21
View File
@@ -8,7 +8,6 @@ import logging
from ocrmypdf import hookimpl from ocrmypdf import hookimpl
from ocrmypdf._exec import ghostscript from ocrmypdf._exec import ghostscript
from ocrmypdf._validation import HOCR_OK_LANGS
from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import check_external_program from ocrmypdf.subprocess import check_external_program
@@ -17,41 +16,24 @@ log = logging.getLogger(__name__)
@hookimpl @hookimpl
def check_options(options): def check_options(options):
"""Check that the options are valid for this plugin."""
check_external_program( check_external_program(
program='gs', program='gs',
package='ghostscript', package='ghostscript',
version_checker=ghostscript.version, version_checker=ghostscript.version,
need_version='9.15', # limited by Travis CI / Ubuntu 14.04 backports need_version='9.50', # Ubuntu 20.04's version
) )
gs_version = ghostscript.version() gs_version = ghostscript.version()
if gs_version in ('9.24', '9.51'): if gs_version in ('9.51',):
raise MissingDependencyError( raise MissingDependencyError(
f"Ghostscript {gs_version} contains serious regressions and is not " f"Ghostscript {gs_version} contains serious regressions and is not "
"supported. Please upgrade to a newer version, or downgrade to the " "supported. Please upgrade to a newer version, or downgrade to the "
"previous version." "previous version."
) )
# We have these constraints to check for.
# 1. Ghostscript < 9.20 mangles multibyte Unicode
# 2. hocr doesn't work on non-Latin languages (so don't select it)
is_latin = options.languages.issubset(HOCR_OK_LANGS)
if gs_version < '9.20' and options.output_type != 'pdf' and not is_latin:
# https://bugs.ghostscript.com/show_bug.cgi?id=696874
# Ghostscript < 9.20 fails to encode multibyte characters properly
log.warning(
f"The installed version of Ghostscript ({gs_version}) does not work "
"correctly with the OCR languages you specified. Use --output-type pdf or "
"upgrade to Ghostscript 9.20 or later to avoid this issue."
)
if options.output_type == 'pdfa': if options.output_type == 'pdfa':
options.output_type = 'pdfa-2' options.output_type = 'pdfa-2'
if options.output_type == 'pdfa-3' and ghostscript.version() < '9.19':
raise MissingDependencyError(
"--output-type pdfa-3 requires Ghostscript 9.19 or later"
)
@hookimpl @hookimpl
def rasterize_pdf_page( def rasterize_pdf_page(
@@ -64,6 +46,7 @@ def rasterize_pdf_page(
rotation, rotation,
filter_vector, filter_vector,
): ):
"""Rasterize a single page of a PDF file using Ghostscript."""
ghostscript.rasterize_pdf( ghostscript.rasterize_pdf(
input_file, input_file,
output_file, output_file,
@@ -87,6 +70,7 @@ def generate_pdfa(
pdfa_part, pdfa_part,
progressbar_class, progressbar_class,
): ):
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
ghostscript.generate_pdfa( ghostscript.generate_pdfa(
pdf_pages=[*pdf_pages, pdfmark], pdf_pages=[*pdf_pages, pdfmark],
output_file=output_file, output_file=output_file,
+70 -12
View File
@@ -8,10 +8,14 @@ from __future__ import annotations
import logging import logging
import os import os
from PIL import Image
from ocrmypdf import hookimpl from ocrmypdf import hookimpl
from ocrmypdf._exec import tesseract from ocrmypdf._exec import tesseract
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.cli import numeric, str_to_int from ocrmypdf.cli import numeric, str_to_int
from ocrmypdf.helpers import clamp from ocrmypdf.helpers import clamp
from ocrmypdf.imageops import calculate_downsample, downsample_image
from ocrmypdf.pluginspec import OcrEngine from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf.subprocess import check_external_program from ocrmypdf.subprocess import check_external_program
@@ -43,7 +47,7 @@ def add_options(parser):
metavar='MODE', metavar='MODE',
choices=range(0, 4), choices=range(0, 4),
help=( help=(
"Set Tesseract 4.0+ OCR engine mode: " "Set Tesseract 4+ OCR engine mode: "
"0 - original Tesseract only; " "0 - original Tesseract only; "
"1 - neural nets LSTM only; " "1 - neural nets LSTM only; "
"2 - Tesseract + LSTM; " "2 - Tesseract + LSTM; "
@@ -58,7 +62,7 @@ def add_options(parser):
metavar='METHOD', metavar='METHOD',
help=( help=(
"Set Tesseract 5.0+ input image thresholding mode. This may improve OCR " "Set Tesseract 5.0+ input image thresholding mode. This may improve OCR "
"results on low quality images or those that contain high constrast color. " "results on low quality images or those that contain high contrast color. "
"legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu " "legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu "
"algorithm with improved sort for background color changes; sauvola is " "algorithm with improved sort for background color changes; sauvola is "
"based on local standard deviation." "based on local standard deviation."
@@ -69,8 +73,48 @@ def add_options(parser):
default=180.0, default=180.0,
type=numeric(float, 0), type=numeric(float, 0),
metavar='SECONDS', metavar='SECONDS',
help='Give up on OCR after the timeout, but copy the preprocessed page ' help=(
'into the final output', "Give up on OCR after the timeout, but copy the preprocessed page "
"into the final output."
),
)
tess.add_argument(
'--tesseract-non-ocr-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
help=(
"Give up on non-OCR operations such as deskewing and orientation "
"after timeout. This is a separate timeout from --tesseract-timeout "
"because these operations are not as expensive as OCR."
),
)
tess.add_argument(
'--tesseract-downsample-large-images',
action='store_true',
help=(
"Downsample large images before OCR. Tesseract has an upper limit on the "
"size images it will support. If this argument is given, OCRmyPDF will "
"downsample large images to fit Tesseract. This may reduce OCR quality, "
"on large images the most desirable text is usually larger. If this "
"parameter is not supplied, Tesseract will error out and produce no OCR "
"on the page in question. This argument should be used with a high value "
"of --tesseract-timeout to ensure Tesseract has enough to time."
),
)
tess.add_argument(
'--tesseract-downsample-above',
action='store',
type=numeric(int, 100, 32767),
default=32767,
help=(
"Downsample images larger than this size pixel size in either dimension "
"before OCR. --tesseract-downsample-large-images downsamples only when "
"an image exceeds Tesseract's internal limits. This argument causes "
"downsampling to occur when an image exceeds the given size. This may "
"reduce OCR quality, but on large images the most desirable text is "
"usually larger."
),
) )
tess.add_argument( tess.add_argument(
'--user-words', '--user-words',
@@ -93,7 +137,7 @@ def check_options(options):
program='tesseract', program='tesseract',
package={'linux': 'tesseract-ocr'}, package={'linux': 'tesseract-ocr'},
version_checker=tesseract.version, version_checker=tesseract.version,
need_version='4.0.0-beta.1', # using backport for Travis CI need_version='4.1.1', # Ubuntu 20.04 version
version_parser=tesseract.TesseractVersion, version_parser=tesseract.TesseractVersion,
) )
@@ -101,11 +145,6 @@ def check_options(options):
if options.pdf_renderer == 'auto': if options.pdf_renderer == 'auto':
options.pdf_renderer = 'sandwich' options.pdf_renderer = 'sandwich'
if not tesseract.has_user_words() and (options.user_words or options.user_patterns):
log.warning(
"Tesseract 4.0 (which you have installed) ignores --user-words and "
"--user-patterns, so these arguments have no effect."
)
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0: if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
log.warning( log.warning(
"The installed version of Tesseract does not support changes to its " "The installed version of Tesseract does not support changes to its "
@@ -137,6 +176,25 @@ def validate(pdfinfo, options):
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads) log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
@hookimpl
def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
"""Filter the image before OCR.
Tesseract cannot handle images with more than 32767 pixels in either axis,
or more than 2**31 bytes. This function resizes the image to fit within
those limits.
"""
threshold = min(page.options.tesseract_downsample_above, 32767)
options = page.options
if options.tesseract_downsample_large_images:
size = calculate_downsample(
image, max_size=(threshold, threshold), max_bytes=(2**31) - 1
)
image = downsample_image(image, size)
return image
class TesseractOcrEngine(OcrEngine): class TesseractOcrEngine(OcrEngine):
"""Implements OCR with Tesseract.""" """Implements OCR with Tesseract."""
@@ -161,7 +219,7 @@ class TesseractOcrEngine(OcrEngine):
return tesseract.get_orientation( return tesseract.get_orientation(
input_file, input_file,
engine_mode=options.tesseract_oem, engine_mode=options.tesseract_oem,
timeout=options.tesseract_timeout, timeout=options.tesseract_non_ocr_timeout,
) )
@staticmethod @staticmethod
@@ -170,7 +228,7 @@ class TesseractOcrEngine(OcrEngine):
input_file, input_file,
languages=options.languages, languages=options.languages,
engine_mode=options.tesseract_oem, engine_mode=options.tesseract_oem,
timeout=options.tesseract_timeout, timeout=options.tesseract_non_ocr_timeout,
) )
@staticmethod @staticmethod
+13 -2
View File
@@ -15,7 +15,7 @@ T = TypeVar('T', int, float)
def numeric(basetype: Callable[[Any], T], min_: T | None = None, max_: T | None = None): def numeric(basetype: Callable[[Any], T], min_: T | None = None, max_: T | None = None):
"""Validator for numeric params""" """Validator for numeric params."""
min_ = basetype(min_) if min_ is not None else None min_ = basetype(min_) if min_ is not None else None
max_ = basetype(max_) if max_ is not None else None max_ = basetype(max_) if max_ is not None else None
@@ -46,7 +46,7 @@ def str_to_int(mapping: Mapping[str, int]):
class ArgumentParser(argparse.ArgumentParser): class ArgumentParser(argparse.ArgumentParser):
"""Override parser's default behavior of calling sys.exit() """Override parser's default behavior of calling sys.exit().
https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code
@@ -57,13 +57,21 @@ class ArgumentParser(argparse.ArgumentParser):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize the parser."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._api_mode = False self._api_mode = False
def enable_api_mode(self): def enable_api_mode(self):
"""Enable API mode.
When set, the parser will not call sys.exit() on error. OCRmyPDF was originally
a command line program, but now it has an API. The API works by synthesizing
command line arguments.
"""
self._api_mode = True self._api_mode = True
def error(self, message): def error(self, message):
"""Override the default argparse error behavior."""
if not self._api_mode: if not self._api_mode:
super().error(message) super().error(message)
return return
@@ -74,11 +82,13 @@ class LanguageSetAction(argparse.Action):
"""Manages a list of languages.""" """Manages a list of languages."""
def __init__(self, option_strings, dest, default=None, **kwargs): def __init__(self, option_strings, dest, default=None, **kwargs):
"""Initialize the action."""
if default is None: if default is None:
default = set() default = set()
super().__init__(option_strings, dest, default=default, **kwargs) super().__init__(option_strings, dest, default=default, **kwargs)
def __call__(self, parser, namespace, values, option_string=None): def __call__(self, parser, namespace, values, option_string=None):
"""Add a language to the set."""
dest = getattr(namespace, self.dest) dest = getattr(namespace, self.dest)
if '+' in values: if '+' in values:
dest.update(lang for lang in values.split('+')) dest.update(lang for lang in values.split('+'))
@@ -87,6 +97,7 @@ class LanguageSetAction(argparse.Action):
def get_parser(): def get_parser():
"""Get the main CLI parser."""
parser = ArgumentParser( parser = ArgumentParser(
prog=_PROGRAM_NAME, prog=_PROGRAM_NAME,
allow_abbrev=True, allow_abbrev=True,
+3 -22
View File
@@ -35,6 +35,7 @@ class ExitCodeException(Exception):
message = "" message = ""
def __str__(self): def __str__(self):
"""Return a string representation of the exception."""
super_msg = super().__str__() # Don't do str(super()) super_msg = super().__str__() # Don't do str(super())
if self.message: if self.message:
return self.message.format(super_msg) return self.message.format(super_msg)
@@ -47,26 +48,6 @@ class BadArgsError(ExitCodeException):
exit_code = ExitCode.bad_args exit_code = ExitCode.bad_args
class PdfMergeFailedError(ExitCodeException): # deprecated
"""An intermediate PDF can't be merged.
No longer in use.
"""
exit_code = ExitCode.input_file
message = dedent(
'''\
Failed to merge PDF image layer with OCR layer
Usually this happens because the input PDF file is malformed and
ocrmypdf cannot correct the problem on its own.
Try using
ocrmypdf --pdf-renderer sandwich [..other args..]
'''
)
class MissingDependencyError(ExitCodeException): class MissingDependencyError(ExitCodeException):
"""A third-party dependency is missing.""" """A third-party dependency is missing."""
@@ -114,7 +95,7 @@ class EncryptedPdfError(ExitCodeException):
exit_code = ExitCode.encrypted_pdf exit_code = ExitCode.encrypted_pdf
message = dedent( message = dedent(
'''\ """\
Input PDF is encrypted. The encryption must be removed to Input PDF is encrypted. The encryption must be removed to
perform OCR. perform OCR.
@@ -123,7 +104,7 @@ class EncryptedPdfError(ExitCodeException):
You can remove the encryption using You can remove the encryption using
qpdf --decrypt [--password=[password]] infilename qpdf --decrypt [--password=[password]] infilename
''' """
) )
+6
View File
@@ -1,3 +1,9 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# #
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Extra plugins. These are not automatically inserted when ocrmypdf is run.
You can use these plugins by specifying them on the command line, e.g.:
ocrmypdf --plugin ocrmypdf.extra_plugins.semfree ...
"""
+11 -4
View File
@@ -54,6 +54,7 @@ def split_every(n: int, iterable: Iterable) -> Iterator:
def process_sigbus(*args): def process_sigbus(*args):
"""Handle SIGBUS signal at the worker level."""
raise InputFileError("A worker process lost access to an input file") raise InputFileError("A worker process lost access to an input file")
@@ -61,20 +62,21 @@ class ConnectionLogHandler(logging.handlers.QueueHandler):
"""Handler used by child processes to forward log messages to parent.""" """Handler used by child processes to forward log messages to parent."""
def __init__(self, conn: Connection) -> None: def __init__(self, conn: Connection) -> None:
"""Initialize the handler."""
# sets the parent's queue to None - parent only touches queue # sets the parent's queue to None - parent only touches queue
# in enqueue() which we override # in enqueue() which we override
super().__init__(None) # type: ignore super().__init__(None) # type: ignore
self.conn = conn self.conn = conn
def enqueue(self, record): def enqueue(self, record):
"""Enqueue a log message."""
self.conn.send(('log', record)) self.conn.send(('log', record))
def process_loop( def process_loop(
conn: Connection, user_init: Callable[[], None], loglevel, task, task_args conn: Connection, user_init: Callable[[], None], loglevel, task, task_args
): ):
"""Initialize a process pool worker""" """Initialize a process pool worker."""
# Install SIGBUS handler (so our parent process can abort somewhat gracefully) # Install SIGBUS handler (so our parent process can abort somewhat gracefully)
with suppress(AttributeError): # Windows and Cygwin do not have SIGBUS with suppress(AttributeError): # Windows and Cygwin do not have SIGBUS
# Windows and Cygwin do not have pthread_sigmask or SIGBUS # Windows and Cygwin do not have pthread_sigmask or SIGBUS
@@ -166,8 +168,7 @@ class LambdaExecutor(Executor):
continue continue
if msg_type == MessageType.result: if msg_type == MessageType.result:
if task_finished: task_finished(msg, pbar)
task_finished(msg, pbar)
elif msg_type == 'log': elif msg_type == 'log':
record = msg record = msg
logger = logging.getLogger(record.name) logger = logging.getLogger(record.name)
@@ -185,14 +186,20 @@ class LambdaExecutor(Executor):
@hookimpl @hookimpl
def get_executor(progressbar_class): def get_executor(progressbar_class):
"""Return a LambdaExecutor instance."""
return LambdaExecutor(pbar_class=progressbar_class) return LambdaExecutor(pbar_class=progressbar_class)
@hookimpl @hookimpl
def get_logging_console(): def get_logging_console():
"""Return a logging.StreamHandler instance."""
return logging.StreamHandler() return logging.StreamHandler()
@hookimpl @hookimpl
def get_progressbar_class(): def get_progressbar_class():
"""Return a NullProgressBar instance.
This executor cannot use a progress bar.
"""
return NullProgressBar return NullProgressBar
+51 -31
View File
@@ -10,14 +10,12 @@ import multiprocessing
import os import os
import shutil import shutil
import warnings import warnings
from collections import namedtuple
from collections.abc import Iterable from collections.abc import Iterable
from contextlib import suppress from contextlib import suppress
from functools import wraps
from io import StringIO from io import StringIO
from math import isclose, isfinite from math import isclose, isfinite
from pathlib import Path from pathlib import Path
from typing import Any, Sequence from typing import Any, Generic, Sequence, SupportsFloat, SupportsRound, TypeVar
import img2pdf import img2pdf
import pikepdf import pikepdf
@@ -35,23 +33,36 @@ else:
) )
class Resolution(namedtuple('Resolution', ('x', 'y'))): T = TypeVar('T', bound=SupportsRound[Any])
class Resolution(Generic[T]):
"""The number of pixels per inch in each 2D direction. """The number of pixels per inch in each 2D direction.
Resolution objects are considered "equal" for == purposes if they are Resolution objects are considered "equal" for == purposes if they are
equal to a reasonable tolerance. equal to a reasonable tolerance.
""" """
__slots__ = () x: T
y: T
__slots__ = ('x', 'y')
def __init__(self, x: T, y: T):
"""Construct a Resolution object."""
self.x = x
self.y = y
# rel_tol after converting from dpi to pixels per meter and saving # rel_tol after converting from dpi to pixels per meter and saving
# as integer with rounding, as many file formats # as integer with rounding, as many file formats
CONVERSION_ERROR = 0.002 CONVERSION_ERROR = 0.002
def round(self, ndigits: int): def round(self, ndigits: int) -> Resolution:
"""Round to ndigits after the decimal point."""
return Resolution(round(self.x, ndigits), round(self.y, ndigits)) return Resolution(round(self.x, ndigits), round(self.y, ndigits))
def to_int(self): def to_int(self) -> Resolution[int]:
"""Round to nearest integer."""
return Resolution(int(round(self.x)), int(round(self.y))) return Resolution(int(round(self.x)), int(round(self.y)))
@classmethod @classmethod
@@ -60,13 +71,20 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))):
@property @property
def is_square(self) -> bool: def is_square(self) -> bool:
"""True if the resolution is square (x == y)."""
return self._isclose(self.x, self.y) return self._isclose(self.x, self.y)
@property @property
def is_finite(self) -> bool: def is_finite(self) -> bool:
return isfinite(self.x) and isfinite(self.y) """True if both x and y are finite numbers."""
if isinstance(self.x, SupportsFloat) and isinstance(self.y, SupportsFloat):
return isfinite(self.x) and isfinite(self.y)
return True
def take_max(self, vals, yvals=None): def take_max(
self, vals: Iterable[Any], yvals: Iterable[Any] | None = None
) -> Resolution:
"""Return a new Resolution object with the maximum resolution of inputs."""
if yvals is not None: if yvals is not None:
return Resolution(max(self.x, *vals), max(self.y, *yvals)) return Resolution(max(self.x, *vals), max(self.y, *yvals))
max_x, max_y = self.x, self.y max_x, max_y = self.x, self.y
@@ -75,16 +93,24 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))):
max_y = max(y, max_y) max_y = max(y, max_y)
return Resolution(max_x, max_y) return Resolution(max_x, max_y)
def flip_axis(self): def flip_axis(self) -> Resolution[T]:
"""Return a new Resolution object with x and y swapped."""
return Resolution(self.y, self.x) return Resolution(self.y, self.x)
def __getitem__(self, idx: int | slice) -> T:
"""Support [0] and [1] indexing."""
return (self.x, self.y)[idx]
def __str__(self): def __str__(self):
"""Return a string representation of the resolution."""
return f"{self.x:f}x{self.y:f}" return f"{self.x:f}x{self.y:f}"
def __repr__(self): # pragma: no cover def __repr__(self): # pragma: no cover
"""Return a repr() of the resolution."""
return f"Resolution({self.x}x{self.y} dpi)" return f"Resolution({self.x}x{self.y} dpi)"
def __eq__(self, other): def __eq__(self, other):
"""Return True if the resolution is equal to another resolution."""
if isinstance(other, tuple) and len(other) == 2: if isinstance(other, tuple) and len(other) == 2:
other = Resolution(*other) other = Resolution(*other)
if not isinstance(other, Resolution): if not isinstance(other, Resolution):
@@ -93,7 +119,7 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))):
class NeverRaise(Exception): class NeverRaise(Exception):
"""An exception that is never raised""" """An exception that is never raised."""
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike): def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
@@ -138,6 +164,10 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
def samefile(file1: os.PathLike, file2: os.PathLike): def samefile(file1: os.PathLike, file2: os.PathLike):
"""Return True if two files are the same file.
Attempts to account for different relative paths to the same file.
"""
if os.name == 'nt': if os.name == 'nt':
return file1 == file2 return file1 == file2
else: else:
@@ -155,7 +185,7 @@ def monotonic(seq: Sequence) -> bool:
def page_number(input_file: os.PathLike) -> int: def page_number(input_file: os.PathLike) -> int:
"""Get one-based page number implied by filename (000002.pdf -> 2)""" """Get one-based page number implied by filename (000002.pdf -> 2)."""
return int(os.path.basename(os.fspath(input_file))[0:6]) return int(os.path.basename(os.fspath(input_file))[0:6])
@@ -184,7 +214,7 @@ def is_file_writable(test_file: os.PathLike) -> bool:
p = p.resolve(strict=False) p = p.resolve(strict=False)
# p.is_file() throws an exception in some cases # p.is_file() throws an exception in some cases
if p.exists() and p.is_file(): if p.exists() and (p.is_file() or p.samefile(os.devnull)):
return os.access( return os.access(
os.fspath(p), os.fspath(p),
os.W_OK, os.W_OK,
@@ -258,13 +288,20 @@ def clamp(n, smallest, largest): # mypy doesn't understand types for this
def remove_all_log_handlers(logger): def remove_all_log_handlers(logger):
"Remove all log handlers, usually used in a child process." """Remove all log handlers, usually used in a child process.
The child process inherits the log handlers from the parent process when
a fork occurs. Typically we want to remove all log handlers in the child
process so that the child process can set up a single queue handler to
forward log messages to the parent process.
"""
for handler in logger.handlers[:]: for handler in logger.handlers[:]:
logger.removeHandler(handler) logger.removeHandler(handler)
handler.close() # To ensure handlers with opened resources are released handler.close() # To ensure handlers with opened resources are released
def pikepdf_enable_mmap(): def pikepdf_enable_mmap():
"""Enable pikepdf mmap."""
# try: # try:
# if pikepdf._qpdf.set_access_default_mmap(True): # if pikepdf._qpdf.set_access_default_mmap(True):
# log.debug("pikepdf mmap enabled") # log.debug("pikepdf mmap enabled")
@@ -275,20 +312,3 @@ def pikepdf_enable_mmap():
# Fix is not in pybind11 2.6.0 # Fix is not in pybind11 2.6.0
# log.debug("pikepdf mmap disabled") # log.debug("pikepdf mmap disabled")
return return
def deprecated(func):
"""Warn that function is deprecated."""
@wraps(func)
def new_func(*args, **kwargs):
warnings.simplefilter('always', DeprecationWarning) # turn off filter
warnings.warn(
f"Call to deprecated function {func.__name__}.",
category=DeprecationWarning,
stacklevel=2,
)
warnings.simplefilter('default', DeprecationWarning) # reset filter
return func(*args, **kwargs)
return new_func
+14 -35
View File
@@ -14,10 +14,11 @@ import re
import warnings import warnings
from math import atan, cos, sin from math import atan, cos, sin
from pathlib import Path from pathlib import Path
from typing import Any, NamedTuple, Optional, Tuple, Union from typing import Any, NamedTuple
from xml.etree import ElementTree from xml.etree import ElementTree
with warnings.catch_warnings(): with warnings.catch_warnings():
# reportlab uses deprecated load_module
warnings.filterwarnings( warnings.filterwarnings(
'ignore', category=DeprecationWarning, message=r".*load_module.*" 'ignore', category=DeprecationWarning, message=r".*load_module.*"
) )
@@ -98,11 +99,10 @@ class HocrTransformError(Exception):
class HocrTransform: class HocrTransform:
"""A class for converting documents from the hOCR format.
"""
A class for converting documents from the hOCR format.
For details of the hOCR format, see: For details of the hOCR format, see:
http://kba.cloud/hocr-spec/ http://kba.cloud/hocr-spec/.
""" """
box_pattern = re.compile(r'bbox((\s+\d+){4})') box_pattern = re.compile(r'bbox((\s+\d+){4})')
@@ -118,6 +118,7 @@ class HocrTransform:
) )
def __init__(self, *, hocr_filename: str | Path, dpi: float): def __init__(self, *, hocr_filename: str | Path, dpi: float):
"""Initialize the HocrTransform object."""
self.dpi = dpi self.dpi = dpi
self.hocr = ElementTree.parse(os.fspath(hocr_filename)) self.hocr = ElementTree.parse(os.fspath(hocr_filename))
@@ -142,9 +143,7 @@ class HocrTransform:
raise HocrTransformError("hocr file is missing page dimensions") raise HocrTransformError("hocr file is missing page dimensions")
def __str__(self): # pragma: no cover def __str__(self): # pragma: no cover
""" """Return the textual content of the HTML body."""
Return the textual content of the HTML body
"""
if self.hocr is None: if self.hocr is None:
return '' return ''
body = self.hocr.find(self._child_xpath('body')) body = self.hocr.find(self._child_xpath('body'))
@@ -154,9 +153,7 @@ class HocrTransform:
return '' return ''
def _get_element_text(self, element: Element): def _get_element_text(self, element: Element):
""" """Return the textual content of the element and its children."""
Return the textual content of the element and its children
"""
text = '' text = ''
if element.text is not None: if element.text is not None:
text += element.text text += element.text
@@ -168,10 +165,7 @@ class HocrTransform:
@classmethod @classmethod
def element_coordinates(cls, element: Element) -> Rect: def element_coordinates(cls, element: Element) -> Rect:
""" """Get coordinates of the bounding box around an element."""
Returns a tuple containing the coordinates of the bounding box around
an element
"""
out = Rect._make(0 for _ in range(4)) out = Rect._make(0 for _ in range(4))
if 'title' in element.attrib: if 'title' in element.attrib:
matches = cls.box_pattern.search(element.attrib['title']) matches = cls.box_pattern.search(element.attrib['title'])
@@ -182,9 +176,7 @@ class HocrTransform:
@classmethod @classmethod
def baseline(cls, element: Element) -> tuple[float, float]: def baseline(cls, element: Element) -> tuple[float, float]:
""" """Get baseline's slope and intercept."""
Returns a tuple containing the baseline slope and intercept.
"""
if 'title' in element.attrib: if 'title' in element.attrib:
matches = cls.baseline_pattern.search(element.attrib['title']) matches = cls.baseline_pattern.search(element.attrib['title'])
if matches: if matches:
@@ -192,9 +184,7 @@ class HocrTransform:
return (0.0, 0.0) return (0.0, 0.0)
def pt_from_pixel(self, pxl) -> Rect: def pt_from_pixel(self, pxl) -> Rect:
""" """Returns the quantity in PDF units (pt) given quantity in pixels."""
Returns the quantity in PDF units (pt) given quantity in pixels
"""
return Rect._make((c / self.dpi * inch) for c in pxl) return Rect._make((c / self.dpi * inch) for c in pxl)
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str: def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
@@ -205,21 +195,9 @@ class HocrTransform:
@classmethod @classmethod
def replace_unsupported_chars(cls, s: str) -> str: def replace_unsupported_chars(cls, s: str) -> str:
""" """Replaces characters with those available in the Helvetica typeface."""
Given an input string, returns the corresponding string that:
* is available in the Helvetica facetype
* does not contain any ligature (to allow easy search in the PDF file)
"""
return s.translate(cls.ligatures) return s.translate(cls.ligatures)
def topdown_position(self, element):
pxl_line_coords = self.element_coordinates(element)
line_box = self.pt_from_pixel(pxl_line_coords)
# Coordinates here are still in the hocr coordinate system, so 0 on the y axis
# is the top of the page and increasing values of y will move towards the
# bottom of the page.
return line_box.y2
def to_pdf( def to_pdf(
self, self,
*, *,
@@ -230,8 +208,8 @@ class HocrTransform:
invisible_text: bool = False, invisible_text: bool = False,
interword_spaces: bool = False, interword_spaces: bool = False,
) -> None: ) -> None:
""" """Creates a PDF file with an image superimposed on top of the text.
Creates a PDF file with an image superimposed on top of the text.
Text is positioned according to the bounding box of the lines in Text is positioned according to the bounding box of the lines in
the hOCR file. the hOCR file.
The image need not be identical to the image used to create the hOCR The image need not be identical to the image used to create the hOCR
@@ -322,6 +300,7 @@ class HocrTransform:
@classmethod @classmethod
def polyval(cls, poly, x): # pragma: no cover def polyval(cls, poly, x): # pragma: no cover
"""Calculate the value of a polynomial at a point."""
return x * poly[0] + poly[1] return x * poly[0] + poly[1]
def _do_line( def _do_line(
+172
View File
@@ -0,0 +1,172 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""OCR-related image manipulation."""
from __future__ import annotations
import logging
from functools import singledispatch
from math import floor, sqrt
from typing import Optional, Tuple
from PIL import Image
# Remove this workaround when we require Pillow >= 9.1.0
try:
Resampling = Image.Resampling # type: ignore
except AttributeError:
# Pillow 9 shim
Resampling = Image # type: ignore
# While from __future__ import annotations, we use singledispatch here, which
# does not support annotations. Disable check about using old-style typing
# until Python 3.10, OR when drop singledispatch in ocrmypdf 15.
# ruff: noqa: UP006
# ruff: noqa: UP007
log = logging.getLogger(__name__)
def bytes_per_pixel(mode: str) -> int:
"""Return the number of padded bytes per pixel for a given PIL image mode.
In RGB mode we assume 4 bytes per pixel, which is the case for most
consumers.
"""
if mode in ('1', 'L', 'P'):
return 1
if mode in ('LA', 'PA', 'La') or mode.startswith('I;16'):
return 2
return 4
@singledispatch
def calculate_downsample(
image_size: Tuple[int, int],
bytes_per_pixel: int,
*,
max_size: Optional[Tuple[int, int]] = None,
max_pixels: Optional[int] = None,
max_bytes: Optional[int] = None,
) -> Tuple[int, int]:
"""Calculate image size required to downsample an image to fit limits.
If no limit is exceeded, the input image's size is returned.
Args:
image_size: Dimensions of image.
bytes_per_pixel: Number of bytes per pixel.
max_size: The maximum width and height of the image.
max_pixels: The maximum number of pixels in the image. Some image consumers
limit the total number of pixels as some value other than width*height.
max_bytes: The maximum number of bytes in the image. RGB is counted as 4
bytes; all other modes are counted as 1 byte.
"""
size = image_size
if max_size is not None:
overage = max_size[0] / size[0], max_size[1] / size[1]
size_factor = min(overage)
if size_factor < 1.0:
log.debug("Resizing image to fit image dimensions limit")
size = floor(size[0] * size_factor), floor(size[1] * size_factor)
if size[0] == 0:
size = 1, min(size[1], max_size[1])
elif size[1] == 0:
size = min(size[0], max_size[0]), 1
if max_pixels is not None:
if size[0] * size[1] > max_pixels:
log.debug("Resizing image to fit image pixel limit")
pixels_factor = sqrt(max_pixels / (size[0] * size[1]))
size = floor(size[0] * pixels_factor), floor(size[1] * pixels_factor)
if max_bytes is not None:
bpp = bytes_per_pixel
# stride = bytes per line
stride = size[0] * bpp
height = size[1]
if stride * height > max_bytes:
log.debug("Resizing image to fit image byte size limit")
bytes_factor = sqrt(max_bytes / (stride * height))
scaled_stride = floor(stride * bytes_factor)
scaled_height = floor(height * bytes_factor)
if scaled_stride == 0:
scaled_stride = bpp
scaled_height = min(max_bytes // bpp, scaled_height)
if scaled_height == 0:
scaled_height = 1
scaled_stride = min(max_bytes // scaled_height, scaled_stride)
size = floor(scaled_stride / bpp), scaled_height
return size
@calculate_downsample.register
def _(
image: Image.Image,
arg: None = None,
*,
max_size: Optional[Tuple[int, int]] = None,
max_pixels: Optional[int] = None,
max_bytes: Optional[int] = None,
) -> Tuple[int, int]:
"""Calculate image size required to downsample an image to fit limits.
If no limit is exceeded, the input image's size is returned.
Args:
image: The image to downsample.
max_size: The maximum width and height of the image.
max_pixels: The maximum number of pixels in the image. Some image consumers
limit the total number of pixels as some value other than width*height.
max_bytes: The maximum number of bytes in the image. RGB is counted as 4
bytes; all other modes are counted as 1 byte.
"""
return calculate_downsample(
image.size,
bytes_per_pixel(image.mode),
max_size=max_size,
max_pixels=max_pixels,
max_bytes=max_bytes,
)
def downsample_image(
image: Image.Image,
new_size: tuple[int, int],
*,
resample_mode: Image.Resampling = Resampling.BICUBIC,
reducing_gap: int = 3,
) -> Image.Image:
"""Downsample an image to fit within the given limits.
The DPI is adjusted to match the new size, which is how we can ensure the
OCR is positioned correctly.
Args:
image: The image to downsample
new_size: The new size of the image.
resample_mode: The resampling mode to use when downsampling.
reducing_gap: The reducing gap to use when downsampling (for larger
reductions).
"""
if new_size == image.size:
return image
original_size = image.size
original_dpi = image.info['dpi']
image = image.resize(
new_size,
resample=resample_mode,
reducing_gap=reducing_gap,
)
image.info['dpi'] = (
round(original_dpi[0] * new_size[0] / original_size[0]),
round(original_dpi[1] * new_size[1] / original_size[1]),
)
log.debug(f"Rescaled image to {image.size} pixels and {image.info['dpi']} dpi")
return image
+82 -31
View File
@@ -53,20 +53,24 @@ class XrefExt(NamedTuple):
def img_name(root: Path, xref: Xref, ext: str) -> Path: def img_name(root: Path, xref: Xref, ext: str) -> Path:
"""Return the name of an image file for a given xref and extension."""
return root / f'{xref:08d}{ext}' return root / f'{xref:08d}{ext}'
def png_name(root: Path, xref: Xref) -> Path: def png_name(root: Path, xref: Xref) -> Path:
"""Return the name of a PNG file for a given xref."""
return img_name(root, xref, '.png') return img_name(root, xref, '.png')
def jpg_name(root: Path, xref: Xref) -> Path: def jpg_name(root: Path, xref: Xref) -> Path:
"""Return the name of a JPEG file for a given xref."""
return img_name(root, xref, '.jpg') return img_name(root, xref, '.jpg')
def extract_image_filter( def extract_image_filter(
pike: Pdf, root: Path, image: Stream, xref: Xref pike: Pdf, root: Path, image: Stream, xref: Xref
) -> tuple[PdfImage, tuple[Name, Object]] | None: ) -> tuple[PdfImage, tuple[Name, Object]] | None:
"""Determine if an image is extractable."""
del pike # unused args del pike # unused args
del root del root
@@ -124,6 +128,7 @@ def extract_image_filter(
def extract_image_jbig2( def extract_image_jbig2(
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options *, pike: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None: ) -> XrefExt | None:
"""Extract an image, saving it as a JBIG2 file."""
del options # unused arg del options # unused arg
result = extract_image_filter(pike, root, image, xref) result = extract_image_filter(pike, root, image, xref)
@@ -165,6 +170,7 @@ def extract_image_jbig2(
def extract_image_generic( def extract_image_generic(
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options *, pike: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None: ) -> XrefExt | None:
"""Generic image extraction."""
result = extract_image_filter(pike, root, image, xref) result = extract_image_filter(pike, root, image, xref)
if result is None: if result is None:
return None return None
@@ -224,13 +230,72 @@ def extract_image_generic(
return None return None
def _find_image_xrefs_container(
pdf: Pdf,
container: Object,
pageno: int,
include_xrefs: MutableSet[Xref],
exclude_xrefs: MutableSet[Xref],
pageno_for_xref: dict[Xref, int],
depth: int = 0,
):
"""Find all image XRefs in a page or Form XObject and add to the include/exclude sets."""
if depth > 10:
log.warning("Recursion depth exceeded in _find_image_xrefs_page")
return
try:
xobjs = container.Resources.XObject
except AttributeError:
return
for _imname, image in dict(xobjs).items():
if image.objgen[1] != 0:
continue # Ignore images in an incremental PDF
if Name.Subtype in image and image.Subtype == Name.Form:
# Recurse into Form XObjects
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
_find_image_xrefs_container(
pdf,
image,
pageno,
include_xrefs,
exclude_xrefs,
pageno_for_xref,
depth + 1,
)
continue
xref = Xref(image.objgen[0])
if Name.SMask in image:
# Ignore soft masks
smask_xref = Xref(image.SMask.objgen[0])
exclude_xrefs.add(smask_xref)
log.debug(f"xref {smask_xref}: skipping image because it is an SMask")
include_xrefs.add(xref)
log.debug(f"xref {xref}: treating as an optimization candidate")
if xref not in pageno_for_xref:
pageno_for_xref[xref] = pageno
def _find_image_xrefs(pdf: Pdf):
include_xrefs: MutableSet[Xref] = set()
exclude_xrefs: MutableSet[Xref] = set()
pageno_for_xref: dict[Xref, int] = {}
for pageno, page in enumerate(pdf.pages):
_find_image_xrefs_container(
pdf, page, pageno, include_xrefs, exclude_xrefs, pageno_for_xref
)
working_xrefs = include_xrefs - exclude_xrefs
return working_xrefs, pageno_for_xref
def extract_images( def extract_images(
pike: Pdf, pike: Pdf,
root: Path, root: Path,
options, options,
extract_fn: Callable[..., XrefExt | None], extract_fn: Callable[..., XrefExt | None],
) -> Iterator[tuple[int, XrefExt]]: ) -> Iterator[tuple[int, XrefExt]]:
"""Extract image using extract_fn """Extract image using extract_fn.
Enumerate images on each page, lookup their xref/ID number in the PDF. Enumerate images on each page, lookup their xref/ID number in the PDF.
Exclude images that are soft masks (i.e. alpha transparency related). Exclude images that are soft masks (i.e. alpha transparency related).
@@ -244,31 +309,8 @@ def extract_images(
it does a tuple should be returned: (xref, ext) where .ext is the file it does a tuple should be returned: (xref, ext) where .ext is the file
extension. extract_fn must also extract the file it finds interesting. extension. extract_fn must also extract the file it finds interesting.
""" """
include_xrefs: MutableSet[Xref] = set()
exclude_xrefs: MutableSet[Xref] = set()
pageno_for_xref = {}
errors = 0 errors = 0
for pageno, page in enumerate(pike.pages): working_xrefs, pageno_for_xref = _find_image_xrefs(pike)
try:
xobjs = page.Resources.XObject
except AttributeError:
continue
for _imname, image in dict(xobjs).items():
if image.objgen[1] != 0:
continue # Ignore images in an incremental PDF
xref = Xref(image.objgen[0])
if Name.SMask in image:
# Ignore soft masks
smask_xref = Xref(image.SMask.objgen[0])
exclude_xrefs.add(smask_xref)
log.debug(f"xref {smask_xref}: skipping image because it is an SMask")
include_xrefs.add(xref)
log.debug(f"xref {xref}: treating as an optimization candidate")
if xref not in pageno_for_xref:
pageno_for_xref[xref] = pageno
working_xrefs = include_xrefs - exclude_xrefs
for xref in working_xrefs: for xref in working_xrefs:
image = pike.get_object((xref, 0)) image = pike.get_object((xref, 0))
try: try:
@@ -289,8 +331,7 @@ def extract_images(
def extract_images_generic( def extract_images_generic(
pike: Pdf, root: Path, options pike: Pdf, root: Path, options
) -> tuple[list[Xref], list[Xref]]: ) -> tuple[list[Xref], list[Xref]]:
"""Extract any >=2bpp image we think we can improve""" """Extract any >=2bpp image we think we can improve."""
jpegs = [] jpegs = []
pngs = [] pngs = []
for _, xref_ext in extract_images(pike, root, options, extract_image_generic): for _, xref_ext in extract_images(pike, root, options, extract_image_generic):
@@ -304,8 +345,7 @@ def extract_images_generic(
def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefExt]]: def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefExt]]:
"""Extract any bitonal image that we think we can improve as JBIG2""" """Extract any bitonal image that we think we can improve as JBIG2."""
jbig2_groups = defaultdict(list) jbig2_groups = defaultdict(list)
for pageno, xref_ext in extract_images(pike, root, options, extract_image_jbig2): for pageno, xref_ext in extract_images(pike, root, options, extract_image_jbig2):
group = pageno // options.jbig2_page_group_size group = pageno // options.jbig2_page_group_size
@@ -318,7 +358,7 @@ def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefE
def _produce_jbig2_images( def _produce_jbig2_images(
jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor
) -> None: ) -> None:
"""Produce JBIG2 images from their groups""" """Produce JBIG2 images from their groups."""
def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]): def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]):
for group, xref_exts in groups.items(): for group, xref_exts in groups.items():
@@ -423,6 +463,8 @@ def _optimize_jpeg(args: tuple[Xref, Path, Path, int]) -> tuple[Xref, Path | Non
def transcode_jpegs( def transcode_jpegs(
pike: Pdf, jpegs: Sequence[Xref], root: Path, options, executor: Executor pike: Pdf, jpegs: Sequence[Xref], root: Path, options, executor: Executor
) -> None: ) -> None:
"""Optimize JPEGs according to optimization settings."""
def jpeg_args() -> Iterator[tuple[Xref, Path, Path, int]]: def jpeg_args() -> Iterator[tuple[Xref, Path, Path, int]]:
for xref in jpegs: for xref in jpegs:
in_jpg = jpg_name(root, xref) in_jpg = jpg_name(root, xref)
@@ -481,6 +523,12 @@ def _deflate_jpeg(args: tuple[Pdf, threading.Lock, Xref, int]) -> tuple[Xref, by
def deflate_jpegs(pike: Pdf, root: Path, options, executor: Executor) -> None: def deflate_jpegs(pike: Pdf, root: Path, options, executor: Executor) -> None:
"""Apply FlateDecode to JPEGs.
This is a lossless compression method that is supported by all PDF viewers,
and generally results in a smaller file size compared to straight DCTDecode
images.
"""
jpegs = [] jpegs = []
for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg): for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg):
xref = xref_ext.xref xref = xref_ext.xref
@@ -568,6 +616,7 @@ def transcode_pngs(
options, options,
executor, executor,
) -> None: ) -> None:
"""Apply lossy transcoding to PNGs."""
modified: MutableSet[Xref] = set() modified: MutableSet[Xref] = set()
if options.optimize >= 2: if options.optimize >= 2:
png_quality = ( png_quality = (
@@ -614,6 +663,7 @@ def optimize(
save_settings, save_settings,
executor: Executor = DEFAULT_EXECUTOR, executor: Executor = DEFAULT_EXECUTOR,
) -> Path: ) -> Path:
"""Optimize images in a PDF file."""
options = context.options options = context.options
if options.optimize == 0: if options.optimize == 0:
safe_symlink(input_file, output_file) safe_symlink(input_file, output_file)
@@ -670,11 +720,12 @@ def optimize(
def main(infile, outfile, level, jobs=1): def main(infile, outfile, level, jobs=1):
"""Entry point for direct optimization of a file."""
from shutil import copy # pylint: disable=import-outside-toplevel from shutil import copy # pylint: disable=import-outside-toplevel
from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
class OptimizeOptions: class OptimizeOptions:
"""Emulate ocrmypdf's options""" """Emulate ocrmypdf's options."""
def __init__( def __init__(
self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy
+5 -8
View File
@@ -1,9 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
""" """Utilities for PDF/A production and confirmation with Ghostspcript."""
Utilities for PDF/A production and confirmation with Ghostspcript.
"""
from __future__ import annotations from __future__ import annotations
@@ -25,8 +23,8 @@ def _postscript_objdef(
alias: str, alias: str,
dictionary: dict[str, str], dictionary: dict[str, str],
*, *,
stream_name: str = None, stream_name: str | None = None,
stream_data: bytes = None, stream_data: bytes | None = None,
) -> Iterator[str]: ) -> Iterator[str]:
assert (stream_name is None) == (stream_data is None) assert (stream_name is None) == (stream_data is None)
@@ -75,7 +73,7 @@ def _make_postscript(icc_name: str, icc_data: bytes, colors: int) -> Iterator[st
def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'): def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
"""Create a Postscript PDFMARK file for Ghostscript PDF/A conversion """Create a Postscript PDFMARK file for Ghostscript PDF/A conversion.
pdfmark is an extension to the Postscript language that describes some PDF pdfmark is an extension to the Postscript language that describes some PDF
features like bookmarks and annotations. It was originally specified Adobe features like bookmarks and annotations. It was originally specified Adobe
@@ -84,7 +82,7 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript
to create a PDF/A, we need to create a pdfmark file with the necessary metadata. to create a PDF/A, we need to create a pdfmark file with the necessary metadata.
This function takes care of the many version-specific bugs and pecularities in This function takes care of the many version-specific bugs and peculiarities in
Ghostscript's handling of pdfmark. Ghostscript's handling of pdfmark.
The only information we put in specifies that we want the file to be a The only information we put in specifies that we want the file to be a
@@ -118,7 +116,6 @@ def file_claims_pdfa(filename: Path):
This only checks if the XMP metadata contains a PDF/A marker. It does not This only checks if the XMP metadata contains a PDF/A marker. It does not
do full PDF/A validation. do full PDF/A validation.
""" """
with pikepdf.open(filename) as pdf: with pikepdf.open(filename) as pdf:
pdfmeta = pdf.open_metadata() pdfmeta = pdf.open_metadata()
if not pdfmeta.pdfa_status: if not pdfmeta.pdfa_status:
+3 -1
View File
@@ -6,4 +6,6 @@
from __future__ import annotations from __future__ import annotations
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PdfInfo from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo
__all__ = ["Colorspace", "Encoding", "PageInfo", "PdfInfo"]
+83 -38
View File
@@ -17,16 +17,7 @@ from functools import partial
from math import hypot, inf, isclose from math import hypot, inf, isclose
from os import PathLike from os import PathLike
from pathlib import Path from pathlib import Path
from typing import ( from typing import Container, Iterable, Iterator, Mapping, NamedTuple, Sequence, Tuple
Container,
Iterable,
Iterator,
Mapping,
NamedTuple,
Optional,
Sequence,
Tuple,
)
from warnings import warn from warnings import warn
from pikepdf import ( from pikepdf import (
@@ -173,7 +164,7 @@ class TextMarker:
def _normalize_stack(graphobjs): def _normalize_stack(graphobjs):
"""Convert runs of qQ's in the stack into single graphobjs""" """Convert runs of qQ's in the stack into single graphobjs."""
for operands, operator in graphobjs: for operands, operator in graphobjs:
operator = str(operator) operator = str(operator)
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
@@ -209,7 +200,6 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
undefined in the spec, but we just pretend nothing happened and leave the undefined in the spec, but we just pretend nothing happened and leave the
CTM unchanged. CTM unchanged.
""" """
stack = [] stack = []
ctm = PdfMatrix(initial_shorthand) ctm = PdfMatrix(initial_shorthand)
xobject_settings: list[XobjectSettings] = [] xobject_settings: list[XobjectSettings] = []
@@ -316,7 +306,6 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
/MediaBox. /MediaBox.
""" """
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
# Calculate the width and height of the image in PDF units # Calculate the width and height of the image in PDF units
@@ -333,7 +322,12 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
class ImageInfo: class ImageInfo:
"""Information about an image found in a PDF.""" """Information about an image found in a PDF.
This gathers information from pikepdf and pdfminer.six, and is pickle-able
so that it can be passed to a worker process, unlike objects from those
libraries.
"""
DPI_PREC = Decimal('1.000') DPI_PREC = Decimal('1.000')
@@ -348,6 +342,7 @@ class ImageInfo:
inline: PdfInlineImage | None = None, inline: PdfInlineImage | None = None,
shorthand=None, shorthand=None,
): ):
"""Initialize an ImageInfo."""
self._name = str(name) self._name = str(name)
self._shorthand = shorthand self._shorthand = shorthand
@@ -414,45 +409,62 @@ class ImageInfo:
@property @property
def name(self): def name(self):
"""Name of the image as it appears in the PDF."""
return self._name return self._name
@property @property
def type_(self): def type_(self):
"""Type of image, either 'image' or 'stencil'."""
return self._type return self._type
@property @property
def width(self): def width(self):
"""Width of the image in pixels."""
return self._width return self._width
@property @property
def height(self): def height(self):
"""Height of the image in pixels."""
return self._height return self._height
@property @property
def bpc(self): def bpc(self):
"""Bits per component."""
return self._bpc return self._bpc
@property @property
def color(self): def color(self):
"""Colorspace of the image."""
return self._color if self._color is not None else '?' return self._color if self._color is not None else '?'
@property @property
def comp(self): def comp(self):
"""Number of components/channels in the image."""
return self._comp if self._comp is not None else '?' return self._comp if self._comp is not None else '?'
@property @property
def enc(self): def enc(self):
"""Encoding of the image."""
return self._enc if self._enc is not None else 'image' return self._enc if self._enc is not None else 'image'
@property @property
def renderable(self): def renderable(self) -> bool:
"""Whether the image is renderable.
Some PDFs in the wild have invalid images that are not renderable.
"""
return self.dpi.is_finite and self.width >= 0 and self.height >= 0 return self.dpi.is_finite and self.width >= 0 and self.height >= 0
@property @property
def dpi(self): def dpi(self):
"""Dots per inch of the image.
Calculated based on where and how the image is drawn in the PDF.
"""
return _get_dpi(self._shorthand, (self._width, self._height)) return _get_dpi(self._shorthand, (self._width, self._height))
def __repr__(self): def __repr__(self):
"""Return a string representation of the image."""
return ( return (
f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} " f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} "
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>" f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
@@ -460,8 +472,7 @@ class ImageInfo:
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]: def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
"Find inline images in the contentstream" """Find inline images in the contentstream."""
for n, inline in enumerate(contentsinfo.inline_images): for n, inline in enumerate(contentsinfo.inline_images):
yield ImageInfo( yield ImageInfo(
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
@@ -469,7 +480,7 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
def _image_xobjects(container) -> Iterator[tuple[Object, str]]: def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
"""Search for all XObject-based images in the container """Search for all XObject-based images in the container.
Usually the container is a page, but it could also be a Form XObject Usually the container is a page, but it could also be a Form XObject
that contains images. Filter out the Form XObjects which are dealt with that contains images. Filter out the Form XObjects which are dealt with
@@ -480,33 +491,29 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
since the object does not know its own name. since the object does not know its own name.
""" """
if '/Resources' not in container: if '/Resources' not in container:
return return
resources = container['/Resources'] resources = container['/Resources']
if '/XObject' not in resources: if '/XObject' not in resources:
return return
xobjs = resources['/XObject'].as_dict() for key, candidate in resources['/XObject'].items():
for xobj in xobjs: if candidate is None or '/Subtype' not in candidate:
candidate: Object = xobjs[xobj]
if '/Subtype' not in candidate:
continue continue
if candidate['/Subtype'] == '/Image': if candidate['/Subtype'] == '/Image':
pdfimage = candidate pdfimage = candidate
yield (pdfimage, xobj) yield (pdfimage, key)
def _find_regular_images( def _find_regular_images(
container: Object, contentsinfo: ContentsInfo container: Object, contentsinfo: ContentsInfo
) -> Iterator[ImageInfo]: ) -> Iterator[ImageInfo]:
"""Find images stored in the container's /Resources /XObject """Find images stored in the container's /Resources /XObject.
Usually the container is a page, but it could also be a Form XObject Usually the container is a page, but it could also be a Form XObject
that contains images. that contains images.
Generates images with their DPI at time of drawing. Generates images with their DPI at time of drawing.
""" """
for pdfimage, xobj in _image_xobjects(container): for pdfimage, xobj in _image_xobjects(container):
if xobj not in contentsinfo.name_index: if xobj not in contentsinfo.name_index:
continue continue
@@ -523,7 +530,7 @@ def _find_regular_images(
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo): def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
"""Find any images that are in Form XObjects in the container """Find any images that are in Form XObjects in the container.
The container may be a page, or a parent Form XObject. The container may be a page, or a parent Form XObject.
@@ -536,7 +543,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
xobjs = resources['/XObject'].as_dict() xobjs = resources['/XObject'].as_dict()
for xobj in xobjs: for xobj in xobjs:
candidate = xobjs[xobj] candidate = xobjs[xobj]
if candidate['/Subtype'] != '/Form': if candidate is None or candidate['/Subtype'] != '/Form':
continue continue
form_xobject = candidate form_xobject = candidate
@@ -557,7 +564,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
def _process_content_streams( def _process_content_streams(
*, pdf: Pdf, container: Object, shorthand=None *, pdf: Pdf, container: Object, shorthand=None
) -> Iterator[VectorMarker | TextMarker | ImageInfo]: ) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
"""Find all individual instances of images drawn in the container """Find all individual instances of images drawn in the container.
Usually the container is a page, but it may also be a Form XObject. Usually the container is a page, but it may also be a Form XObject.
@@ -574,7 +581,6 @@ def _process_content_streams(
downsampling. downsampling.
""" """
if container.get('/Type') == '/Page' and '/Contents' in container: if container.get('/Type') == '/Page' and '/Contents' in container:
initial_shorthand = shorthand or UNIT_SQUARE initial_shorthand = shorthand or UNIT_SQUARE
elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form': elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form':
@@ -606,8 +612,7 @@ def _process_content_streams(
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool: def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
"""Smarter text detection that ignores text in margins""" """Smarter text detection that ignores text in margins."""
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
margin_ratio = 0.125 margin_ratio = 0.125
@@ -619,10 +624,11 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
) )
def rects_intersect(a: FloatRect, b: FloatRect) -> bool: def rects_intersect(a: FloatRect, b: FloatRect) -> bool:
""" """Check if two 4-tuple rects intersect.
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3) Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
Formula assumes all boxes are in first quadrant Formula assumes all boxes are in first quadrant.
""" """
return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1] return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1]
@@ -635,7 +641,7 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]: def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]:
"""Extract only limited content from text boxes """Extract only limited content from text boxes.
We do this to save memory and ensure that our objects are pickleable. We do this to save memory and ensure that our objects are pickleable.
""" """
@@ -751,6 +757,7 @@ class PageInfo:
check_pages: Container[int], check_pages: Container[int],
detailed_analysis: bool = False, detailed_analysis: bool = False,
): ):
"""Initialize a PageInfo object."""
self._pageno = pageno self._pageno = pageno
self._infile = infile self._infile = infile
self._detailed_analysis = detailed_analysis self._detailed_analysis = detailed_analysis
@@ -827,40 +834,56 @@ class PageInfo:
@property @property
def pageno(self) -> int: def pageno(self) -> int:
"""Return page number (0-based)."""
return self._pageno return self._pageno
@property @property
def has_text(self) -> bool: def has_text(self) -> bool:
"""Return True if page has text, False if not or unknown."""
return bool(self._has_text) return bool(self._has_text)
@property @property
def has_corrupt_text(self) -> bool: def has_corrupt_text(self) -> bool:
"""Return True if page has corrupt text, False if not or unknown."""
if not self._detailed_analysis: if not self._detailed_analysis:
raise NotImplementedError('Did not do detailed analysis') raise NotImplementedError('Did not do detailed analysis')
return any(tbox.is_corrupt for tbox in self._textboxes) return any(tbox.is_corrupt for tbox in self._textboxes)
@property @property
def has_vector(self) -> bool: def has_vector(self) -> bool:
"""Return True if page has vector graphics, False if not or unknown.
Vector graphics are sometimes used to draw fonts, so it may not be
obvious on visual inspection whether a page has text or not.
"""
return bool(self._has_vector) return bool(self._has_vector)
@property @property
def width_inches(self) -> Decimal: def width_inches(self) -> Decimal:
"""Return width of page in inches."""
return self._width_inches return self._width_inches
@property @property
def height_inches(self) -> Decimal: def height_inches(self) -> Decimal:
"""Return height of page in inches."""
return self._height_inches return self._height_inches
@property @property
def width_pixels(self) -> int: def width_pixels(self) -> int:
"""Return width of page in pixels."""
return int(round(float(self.width_inches) * self.dpi.x)) return int(round(float(self.width_inches) * self.dpi.x))
@property @property
def height_pixels(self) -> int: def height_pixels(self) -> int:
"""Return height of page in pixels."""
return int(round(float(self.height_inches) * self.dpi.y)) return int(round(float(self.height_inches) * self.dpi.y))
@property @property
def rotation(self) -> int: def rotation(self) -> int:
"""Return rotation of page in degrees.
Will only be a multiple of 90.
"""
return self._rotate return self._rotate
@rotation.setter @rotation.setter
@@ -871,10 +894,13 @@ class PageInfo:
raise ValueError("rotation must be a cardinal angle") raise ValueError("rotation must be a cardinal angle")
@property @property
def images(self): def images(self) -> list[ImageInfo]:
"""Return images."""
return self._images return self._images
def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None): def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None):
"""Return textareas bounding boxes in PDF coordinates on the page."""
def predicate(obj, want_visible, want_corrupt): def predicate(obj, want_visible, want_corrupt):
result = True result = True
if want_visible is not None: if want_visible is not None:
@@ -894,22 +920,26 @@ class PageInfo:
@property @property
def dpi(self) -> Resolution: def dpi(self) -> Resolution:
"""Return DPI needed to render all images on the page."""
if self._dpi is None: if self._dpi is None:
return Resolution(0.0, 0.0) return Resolution(0.0, 0.0)
return self._dpi return self._dpi
@property @property
def userunit(self) -> Decimal: def userunit(self) -> Decimal:
"""Return user unit of page."""
return self._userunit return self._userunit
@property @property
def min_version(self) -> str: def min_version(self) -> str:
"""Return minimum PDF version needed to render this page."""
if self.userunit is not None: if self.userunit is not None:
return '1.6' return '1.6'
else: else:
return '1.5' return '1.5'
def __repr__(self): def __repr__(self):
"""Return string representation."""
return ( return (
f'<PageInfo ' f'<PageInfo '
f'pageno={self.pageno} {self.width_inches}"x{self.height_inches}" ' f'pageno={self.pageno} {self.width_inches}"x{self.height_inches}" '
@@ -921,7 +951,7 @@ DEFAULT_EXECUTOR = SerialExecutor()
class PdfInfo: class PdfInfo:
"""Get summary information about a PDF""" """Get summary information about a PDF."""
def __init__( def __init__(
self, self,
@@ -929,10 +959,11 @@ class PdfInfo:
*, *,
detailed_analysis: bool = False, detailed_analysis: bool = False,
progbar: bool = False, progbar: bool = False,
max_workers: int = None, max_workers: int | None = None,
check_pages=None, check_pages=None,
executor: Executor = DEFAULT_EXECUTOR, executor: Executor = DEFAULT_EXECUTOR,
): ):
"""Initialize."""
self._infile = infile self._infile = infile
if check_pages is None: if check_pages is None:
check_pages = range(0, 1_000_000_000) check_pages = range(0, 1_000_000_000)
@@ -959,42 +990,56 @@ class PdfInfo:
@property @property
def pages(self) -> Sequence[PageInfo | None]: def pages(self) -> Sequence[PageInfo | None]:
"""Return list of PageInfo objects, one per page in the PDF."""
return self._pages return self._pages
@property @property
def min_version(self) -> str: def min_version(self) -> str:
"""Return minimum PDF version needed to render this PDF."""
# The minimum PDF is the maximum version that any particular page needs # The minimum PDF is the maximum version that any particular page needs
return max(page.min_version for page in self.pages if page) return max(page.min_version for page in self.pages if page)
@property @property
def has_userunit(self) -> bool: def has_userunit(self) -> bool:
"""Return True if any page has a user unit."""
return any(page.userunit != 1.0 for page in self.pages if page) return any(page.userunit != 1.0 for page in self.pages if page)
@property @property
def has_acroform(self) -> bool: def has_acroform(self) -> bool:
"""Return True if any page has an AcroForm."""
return self._has_acroform return self._has_acroform
@property @property
def filename(self) -> str | Path: def filename(self) -> str | Path:
"""Return filename of PDF."""
if not isinstance(self._infile, (str, Path)): if not isinstance(self._infile, (str, Path)):
raise NotImplementedError("can't get filename from stream") raise NotImplementedError("can't get filename from stream")
return self._infile return self._infile
@property @property
def needs_rendering(self) -> bool: def needs_rendering(self) -> bool:
"""Return True if PDF contains XFA forms.
XFA forms are not supported by most standard PDF renderers, so we
need to detect and suppress them.
"""
return self._needs_rendering return self._needs_rendering
def __getitem__(self, item) -> PageInfo: def __getitem__(self, item) -> PageInfo:
"""Return PageInfo object for page number `item`."""
return self._pages[item] return self._pages[item]
def __len__(self): def __len__(self):
"""Return number of pages in PDF."""
return len(self._pages) return len(self._pages)
def __repr__(self): def __repr__(self):
"""Return string representation."""
return f"<PdfInfo('...'), page count={len(self)}>" return f"<PdfInfo('...'), page count={len(self)}>"
def main(): def main():
"""Run as a script."""
import argparse # pylint: disable=import-outside-toplevel import argparse # pylint: disable=import-outside-toplevel
from pprint import pprint # pylint: disable=import-outside-toplevel from pprint import pprint # pylint: disable=import-outside-toplevel
+62 -28
View File
@@ -1,5 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Detailed text position and layout analysis, building on pdfminer.six."""
from __future__ import annotations from __future__ import annotations
import re import re
@@ -23,45 +25,68 @@ from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
STRIP_NAME = re.compile(r'[0-9]+') STRIP_NAME = re.compile(r'[0-9]+')
original_PDFSimpleFont_init = PDFSimpleFont.__init__ original_pdfsimplefont_init = PDFSimpleFont.__init__
def PDFSimpleFont__init__(self, descriptor, widths, spec): def pdfsimplefont__init__(self, descriptor, widths, spec):
"""Monkeypatch pdfminer.six PDFSimpleFont.__init__.
If there is no ToUnicode and no Encoding, pdfminer.six assumes that Unicode
conversion is possible. This is incorrect, according to PDF Reference Manual
9.10.2. This patch fixes that.
"""
# Font encoding is specified either by a name of # Font encoding is specified either by a name of
# built-in encoding or a dictionary that describes # built-in encoding or a dictionary that describes
# the differences. # the differences.
original_PDFSimpleFont_init(self, descriptor, widths, spec) original_pdfsimplefont_init(self, descriptor, widths, spec)
# pdfminer is incorrect. If there is no ToUnicode and no Encoding, do not
# assume Unicode conversion is possible. RM 9.10.2
if not self.unicode_map and 'Encoding' not in spec: if not self.unicode_map and 'Encoding' not in spec:
self.cid2unicode = {} self.cid2unicode = {}
return return
PDFSimpleFont.__init__ = PDFSimpleFont__init__ PDFSimpleFont.__init__ = pdfsimplefont__init__
# #
# pdfminer patches when creator is PScript5.dll # pdfminer patches when creator is PScript5.dll
# #
def PDFType3Font__PScript5_get_height(self): def pdftype3font__pscript5_get_height(self):
"""Monkeypatch for PScript5.dll PDFs.
The height of Type3 fonts is known to be incorrect in PScript5.dll
generated PDFs. This patch attempts to correct the height by
using the bbox height if it is available, otherwise using the
ascent and descent.
"""
h = self.bbox[3] - self.bbox[1] h = self.bbox[3] - self.bbox[1]
if h == 0: if h == 0:
h = self.ascent - self.descent h = self.ascent - self.descent
return h * copysign(1.0, self.vscale) return h * copysign(1.0, self.vscale)
def PDFType3Font__PScript5_get_descent(self): def pdftype3font__pscript5_get_descent(self):
"""Monkeypatch for PScript5.dll PDFs.
The descent of Type3 fonts is known to be incorrect in PScript5.dll
generated PDFs. This patch attempts to correct the descent by
using the vscale.
"""
return self.descent * copysign(1.0, self.vscale) return self.descent * copysign(1.0, self.vscale)
def PDFType3Font__PScript5_get_ascent(self): def pdftype3font__pscript5_get_ascent(self):
"""Monkeypatch for PScript5.dll PDFs.
The ascent of Type3 fonts is known to be incorrect in PScript5.dll
generated PDFs. This patch attempts to correct the ascent by
using the vscale.
"""
return self.ascent * copysign(1.0, self.vscale) return self.ascent * copysign(1.0, self.vscale)
class LTStateAwareChar(LTChar): class LTStateAwareChar(LTChar):
"""A subclass of LTChar that tracks text render mode at time of drawing""" """A subclass of LTChar that tracks text render mode at time of drawing."""
__slots__ = ( __slots__ = (
'rendermode', 'rendermode',
@@ -94,6 +119,7 @@ class LTStateAwareChar(LTChar):
graphicstate, graphicstate,
textstate, textstate,
): ):
"""Initialize."""
super().__init__( super().__init__(
matrix, matrix,
font, font,
@@ -109,7 +135,7 @@ class LTStateAwareChar(LTChar):
self.rendermode = textstate.render self.rendermode = textstate.render
def is_compatible(self, obj): def is_compatible(self, obj):
"""Check if characters can be combined into a textline """Check if characters can be combined into a textline.
We consider characters compatible if: We consider characters compatible if:
- the Unicode mapping is known, and both have the same render mode - the Unicode mapping is known, and both have the same render mode
@@ -127,36 +153,41 @@ class LTStateAwareChar(LTChar):
return False return False
def get_text(self): def get_text(self):
"""Get text from this character."""
if isinstance(self._text, tuple): if isinstance(self._text, tuple):
return '\ufffd' # standard 'Unknown symbol' return '\ufffd' # standard 'Unknown symbol'
return self._text return self._text
def __repr__(self): def __repr__(self):
return '<{} {} matrix={} rendermode={!r} font={!r} adv={} text={!r}>'.format( """Return a string representation of this object."""
self.__class__.__name__, return (
bbox2str(self.bbox), f"<{self.__class__.__name__} "
matrix2str(self.matrix), f"{bbox2str(self.bbox)} "
self.rendermode, f"matrix={matrix2str(self.matrix)} "
self.fontname, f"rendermode={self.rendermode!r} "
self.adv, f"font={self.fontname!r} "
self.get_text(), f"adv={self.adv} "
f"text={self.get_text()!r}>"
) )
class TextPositionTracker(PDFLayoutAnalyzer): class TextPositionTracker(PDFLayoutAnalyzer):
"""A page layout analyzer that pays attention to text visibility""" """A page layout analyzer that pays attention to text visibility."""
def __init__(self, rsrcmgr, pageno=1, laparams=None): def __init__(self, rsrcmgr, pageno=1, laparams=None):
"""Initialize the layout analyzer."""
super().__init__(rsrcmgr, pageno, laparams) super().__init__(rsrcmgr, pageno, laparams)
self.textstate = None self.textstate = None
self.result = None self.result = None
self.cur_item = None # not defined in pdfminer code as it should be self.cur_item = None # not defined in pdfminer code as it should be
def begin_page(self, page, ctm): def begin_page(self, page, ctm):
"""Begin processing of a page."""
super().begin_page(page, ctm) super().begin_page(page, ctm)
self.cur_item = LTPage(self.pageno, page.mediabox) self.cur_item = LTPage(self.pageno, page.mediabox)
def end_page(self, page): def end_page(self, page):
"""End processing of a page."""
assert not self._stack, str(len(self._stack)) assert not self._stack, str(len(self._stack))
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item)) assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
if self.laparams is not None: if self.laparams is not None:
@@ -165,12 +196,14 @@ class TextPositionTracker(PDFLayoutAnalyzer):
self.receive_layout(self.cur_item) self.receive_layout(self.cur_item)
def render_string(self, textstate, seq, ncs, graphicstate): def render_string(self, textstate, seq, ncs, graphicstate):
"""Respond to render string event by updating text state."""
self.textstate = textstate.copy() self.textstate = textstate.copy()
super().render_string(self.textstate, seq, ncs, graphicstate) super().render_string(self.textstate, seq, ncs, graphicstate)
def render_char( def render_char(
self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate
): ):
"""Respond to render char event by updating text state."""
try: try:
text = font.to_unichr(cid) text = font.to_unichr(cid)
assert isinstance(text, str), str(type(text)) assert isinstance(text, str), str(type(text))
@@ -195,23 +228,23 @@ class TextPositionTracker(PDFLayoutAnalyzer):
return item.adv return item.adv
def handle_undefined_char(self, font, cid): def handle_undefined_char(self, font, cid):
"""Handle undefined character."""
# log.info('undefined: %r, %r', font, cid) # log.info('undefined: %r, %r', font, cid)
return (font.fontname, cid) return (font.fontname, cid)
def receive_layout(self, ltpage): def receive_layout(self, ltpage):
"""Receive layout handler."""
self.result = ltpage self.result = ltpage
def get_result(self): def get_result(self):
"""Get the result of the analysis."""
return self.result return self.result
def get_page_analysis(infile, pageno, pscript5_mode): def get_page_analysis(infile, pageno, pscript5_mode):
"""Get the page analysis for a given page."""
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True) rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
if pdfminer.__version__ < '20200402': disable_boxes_flow = None
# Workaround for https://github.com/pdfminer/pdfminer.six/issues/395
disable_boxes_flow = 2
else:
disable_boxes_flow = None
dev = TextPositionTracker( dev = TextPositionTracker(
rman, rman,
laparams=LAParams( laparams=LAParams(
@@ -225,9 +258,9 @@ def get_page_analysis(infile, pageno, pscript5_mode):
patcher = patch.multiple( patcher = patch.multiple(
'pdfminer.pdffont.PDFType3Font', 'pdfminer.pdffont.PDFType3Font',
spec=True, spec=True,
get_ascent=PDFType3Font__PScript5_get_ascent, get_ascent=pdftype3font__pscript5_get_ascent,
get_descent=PDFType3Font__PScript5_get_descent, get_descent=pdftype3font__pscript5_get_descent,
get_height=PDFType3Font__PScript5_get_height, get_height=pdftype3font__pscript5_get_height,
) )
patcher.start() patcher.start()
@@ -250,6 +283,7 @@ def get_page_analysis(infile, pageno, pscript5_mode):
def get_text_boxes(obj): def get_text_boxes(obj):
"""Get the text boxes attached to the current node."""
for child in obj: for child in obj:
if isinstance(child, (LTTextBox)): if isinstance(child, (LTTextBox)):
yield child yield child
+40 -19
View File
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
hookspec = pluggy.HookspecMarker('ocrmypdf') hookspec = pluggy.HookspecMarker('ocrmypdf')
# pylint: disable=unused-argument # pylint: disable=unused-argument
# mypy: disable-error-code=empty-body
@hookspec(firstresult=True) @hookspec(firstresult=True)
@@ -43,7 +44,7 @@ def get_logging_console() -> Handler:
@hookspec @hookspec
def initialize(plugin_manager: pluggy.PluginManager): def initialize(plugin_manager: pluggy.PluginManager) -> None:
"""Called when this plugin is first loaded into OCRmyPDF. """Called when this plugin is first loaded into OCRmyPDF.
The primary intended use of this is for plugins to check compatibility with other The primary intended use of this is for plugins to check compatibility with other
@@ -99,6 +100,8 @@ def check_options(options: Namespace) -> None:
ocrmypdf.exceptions.ExitCodeException: If options are not acceptable ocrmypdf.exceptions.ExitCodeException: If options are not acceptable
and the application should terminate gracefully with an informative and the application should terminate gracefully with an informative
message and error code. message and error code.
Note: Note:
This hook will be called from the main process, and may modify global state This hook will be called from the main process, and may modify global state
before child worker processes are forked. before child worker processes are forked.
@@ -127,6 +130,8 @@ def get_executor(progressbar_class) -> Executor:
Note: Note:
This hook will be called from the main process, and may modify global state This hook will be called from the main process, and may modify global state
before child worker processes are forked. before child worker processes are forked.
Note: Note:
This is a :ref:`firstresult hook<firstresult>`. This is a :ref:`firstresult hook<firstresult>`.
""" """
@@ -159,7 +164,6 @@ def get_progressbar_class():
Here is how OCRmyPDF will use the progress bar: Here is how OCRmyPDF will use the progress bar:
Example: Example:
pbar_class = pm.hook.get_progressbar_class() pbar_class = pm.hook.get_progressbar_class()
with pbar_class(**tqdm_kwargs) as pbar: with pbar_class(**tqdm_kwargs) as pbar:
... ...
@@ -181,6 +185,8 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
ocrmypdf.exceptions.ExitCodeException: If options or pdfinfo are not acceptable ocrmypdf.exceptions.ExitCodeException: If options or pdfinfo are not acceptable
and the application should terminate gracefully with an informative and the application should terminate gracefully with an informative
message and error code. message and error code.
Note: Note:
This hook will be called from the main process, and may modify global state This hook will be called from the main process, and may modify global state
before child worker processes are forked. before child worker processes are forked.
@@ -218,6 +224,8 @@ def rasterize_pdf_page(
Note: Note:
This hook will be called from child processes. Modifying global state This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes. will not affect the main process or other child processes.
Note: Note:
This is a :ref:`firstresult hook<firstresult>`. This is a :ref:`firstresult hook<firstresult>`.
""" """
@@ -228,23 +236,32 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
"""Called to filter the image before it is sent to OCR. """Called to filter the image before it is sent to OCR.
This is the image that OCR sees, not what the user sees when they view the This is the image that OCR sees, not what the user sees when they view the
PDF. If ``redo_ocr`` is enabled, portions of the image will be masked so PDF. In certain modes such as ``--redo-ocr``, portions of the image may be
they are not shown to OCR. The main use of this hook is expected to be hiding masked out to hide them from OCR.
content from OCR.
The main uses of this hook are expected to be hiding content from OCR,
conditioning images to OCR better with filters, and adjusting images to
match any constraints imposed by the OCR engine.
The input image may be color, grayscale, or monochrome, and the The input image may be color, grayscale, or monochrome, and the
output image may differ. The pixel width and height of the output image may differ. For example, if you know that a custom OCR engine
output image must be identical to the input image, or misalignment between does not care about the color of the text, you could convert the image to
the OCR text layer and visual position of the text will occur. Likewise, it to grayscale or monochrome.
the output must be a faithful representation of the input, or alignment
errors may occurs.
Tesseract OCR only deals with monochrome images, and internally converts Generally speaking, the output image should be a faithful representation of
non-monochrome images to OCR. of the input image. You *may* change the pixel width and height of the
the input image, but you must not change the aspect ratio, and you must
calculate the DPI of the output image based on the new pixel width and
height or the OCR text layer will be misaligned with the visual position.
The built-in Tesseract OCR engine uses this hook itself to downsample
very large images to fit its constraints.
Note: Note:
This hook will be called from child processes. Modifying global state This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes. will not affect the main process or other child processes.
Note: Note:
This is a :ref:`firstresult hook<firstresult>`. This is a :ref:`firstresult hook<firstresult>`.
""" """
@@ -269,7 +286,7 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
to enforce these constraints; it is up to the plugin to do sensible things. to enforce these constraints; it is up to the plugin to do sensible things.
OCRmyPDF will create the PDF page based on the image format used (unless the OCRmyPDF will create the PDF page based on the image format used (unless the
hook is overriden). If you convert the image to a JPEG, the output page will hook is overridden). If you convert the image to a JPEG, the output page will
be created as a JPEG, etc. If you change the colorspace, that change will be be created as a JPEG, etc. If you change the colorspace, that change will be
kept. Note that the OCRmyPDF image optimization stage, if enabled, may kept. Note that the OCRmyPDF image optimization stage, if enabled, may
ultimately chose a different format. ultimately chose a different format.
@@ -278,13 +295,11 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
will occur. The return value should be a path to a file in the same folder will occur. The return value should be a path to a file in the same folder
as ``image_filename``. as ``image_filename``.
Implementation detail: If the value returned is falsy, OCRmyPDF will ignore
the return value and assume the input file was unmodified. This is deprecated.
To leave the image unmodified, ``image_filename`` should be returned.
Note: Note:
This hook will be called from child processes. Modifying global state This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes. will not affect the main process or other child processes.
Note: Note:
This is a :ref:`firstresult hook<firstresult>`. This is a :ref:`firstresult hook<firstresult>`.
""" """
@@ -327,6 +342,8 @@ def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -
Note: Note:
This hook will be called from child processes. Modifying global state This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes. will not affect the main process or other child processes.
Note: Note:
This is a :ref:`firstresult hook<firstresult>`. This is a :ref:`firstresult hook<firstresult>`.
""" """
@@ -385,7 +402,8 @@ class OcrEngine(ABC):
"""Returns the set of all languages that are supported by the engine. """Returns the set of all languages that are supported by the engine.
Languages are typically given in 3-letter ISO 3166-1 codes, but actually Languages are typically given in 3-letter ISO 3166-1 codes, but actually
can be any value understood by the OCR engine.""" can be any value understood by the OCR engine.
"""
@staticmethod @staticmethod
@abstractmethod @abstractmethod
@@ -417,6 +435,9 @@ class OcrEngine(ABC):
a single page PDF with no visible content of any kind, sized a single page PDF with no visible content of any kind, sized
to the dimensions implied by the input_file's width, height to the dimensions implied by the input_file's width, height
and DPI. The image will be grafted onto the input PDF page. and DPI. The image will be grafted onto the input PDF page.
output_text: The expected name of a text file containing the
recognized text.
options: The command line options.
""" """
@@ -478,7 +499,7 @@ def generate_pdfa(
Note: Note:
This is a :ref:`firstresult hook<firstresult>`. This is a :ref:`firstresult hook<firstresult>`.
See also: See Also:
https://github.com/tqdm/tqdm https://github.com/tqdm/tqdm
""" """
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Utilities to measure OCR quality""" """Utilities to measure OCR quality."""
from __future__ import annotations from __future__ import annotations
+12 -13
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Wrappers to manage subprocess calls""" """Wrappers to manage subprocess calls."""
from __future__ import annotations from __future__ import annotations
@@ -9,7 +9,6 @@ import os
import re import re
import sys import sys
from contextlib import suppress from contextlib import suppress
from functools import lru_cache
from pathlib import Path from pathlib import Path
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
from subprocess import run as subprocess_run from subprocess import run as subprocess_run
@@ -35,20 +34,25 @@ def run(
check: bool = False, check: bool = False,
**kwargs, **kwargs,
) -> CompletedProcess: ) -> CompletedProcess:
"""Wrapper around :py:func:`subprocess.run` """Wrapper around :py:func:`subprocess.run`.
The main purpose of this wrapper is to log subprocess output in an orderly The main purpose of this wrapper is to log subprocess output in an orderly
fashion that indentifies the responsible subprocess. An additional fashion that identifies the responsible subprocess. An additional
task is that this function goes to greater lengths to find possible Windows 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. locations of our dependencies when they are not on the system PATH.
Arguments should be identical to ``subprocess.run``, except for following: Arguments should be identical to ``subprocess.run``, except for following:
Arguments: 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 logs_errors_to_stdout: If True, indicates that the process writes its error
messages to stdout rather than stderr, so stdout should be logged 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 if there is an error. If False, stderr is logged. Could be used with
stderr=STDOUT, stdout=PIPE for example. 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) args, env, process_log, _text = _fix_process_args(args, env, kwargs)
@@ -135,7 +139,6 @@ def _fix_process_args(
return args, env, process_log, text return args, env, process_log, text
@lru_cache(maxsize=None)
def get_version( def get_version(
program: str, program: str,
*, *,
@@ -143,7 +146,7 @@ def get_version(
regex=r'(\d+(\.\d+)*)', regex=r'(\d+(\.\d+)*)',
env: OsEnviron | None = None, env: OsEnviron | None = None,
) -> str: ) -> str:
"""Get the version of the specified program """Get the version of the specified program.
Arguments: Arguments:
program: The program to version check. program: The program to version check.
@@ -316,7 +319,7 @@ def check_external_program(
program: The name of the program to test. program: The name of the program to test.
package: The name of a software package that typically supplies this program. package: The name of a software package that typically supplies this program.
Usually the same as program. Usually the same as program.
version_check: A callable without arguments that retrieves the installed version_checker: A callable without arguments that retrieves the installed
version of program. version of program.
need_version: The minimum required version. need_version: The minimum required version.
required_for: The name of an argument of feature that requires this program. required_for: The name of an argument of feature that requires this program.
@@ -325,12 +328,8 @@ def check_external_program(
version_parser: A class that should be used to parse and compare version version_parser: A class that should be used to parse and compare version
numbers. Used when version numbers do not follow standard conventions. numbers. Used when version numbers do not follow standard conventions.
""" """
try: try:
if callable(version_checker): found_version = version_checker()
found_version = version_checker()
else: # deprecated
found_version = version_checker
except (CalledProcessError, FileNotFoundError) as e: except (CalledProcessError, FileNotFoundError) as e:
_error_missing_program(program, package, required_for, recommended) _error_missing_program(program, package, required_for, recommended)
if not recommended: if not recommended:
+39 -13
View File
@@ -6,12 +6,15 @@ from __future__ import annotations
import logging import logging
import os import os
import re
import shutil import shutil
import sys import sys
from itertools import chain from itertools import chain
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Iterable, Iterator, TypeVar from typing import Any, Callable, Iterable, Iterator, TypeVar
from packaging.version import InvalidVersion, Version
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
from typing import TypeAlias from typing import TypeAlias
else: else:
@@ -29,12 +32,13 @@ else:
spec=['HKEYType', 'EnumKey', 'EnumValue', 'HKEY_LOCAL_MACHINE', 'OpenKey'] spec=['HKEYType', 'EnumKey', 'EnumValue', 'HKEY_LOCAL_MACHINE', 'OpenKey']
) )
# mypy does not understand winreg.HKeyType where winreg is a Mock (fair enough!) # mypy does not understand winreg.HKeyType where winreg is a Mock (fair enough!)
HKEYType: TypeAlias = Any HKEYType: TypeAlias = Any # type: ignore
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
T = TypeVar('T') T = TypeVar('T')
Tkey = TypeVar('Tkey')
def ghostscript_version_key(s: str) -> tuple[int, int, int]: def ghostscript_version_key(s: str) -> tuple[int, int, int]:
@@ -99,6 +103,30 @@ def registry_path_tesseract(env=None) -> Iterator[Path]:
log.warning(e) log.warning(e)
def _gs_version_in_path_key(path: Path) -> tuple[str, Version | None]:
"""Key function for comparing Ghostscript and Tesseract paths.
Ghostscript installs on Windows:
%PROGRAMFILES%/gs/gs9.56.1/bin -> ('gs', Version('9.56.1'))
%PROGRAMFILES%/gs/9.24/bin -> ('gs', Version('9.24'))
Tesseract looks like:
%PROGRAMFILES%/Tesseract-OCR -> ('Tesseract-OCR', None)
Thus ensuring the resulting tuple will order the alternatives correctly,
e.g. gs10.0 > gs9.99.
"""
match = re.search(r'gs[/\\]?([0-9.]+)[/\\]bin', str(path))
if match:
try:
version_str = match.group(1)
version = Version(version_str)
return 'gs', version
except InvalidVersion:
pass
return path.name, None
def program_files_paths(env=None) -> Iterator[Path]: def program_files_paths(env=None) -> Iterator[Path]:
if not env: if not env:
env = os.environ env = os.environ
@@ -116,7 +144,7 @@ def program_files_paths(env=None) -> Iterator[Path]:
return iter( return iter(
sorted( sorted(
(p for p in path_walker()), (p for p in path_walker()),
key=lambda p: (p.name, p.parent.name), key=_gs_version_in_path_key,
reverse=True, reverse=True,
) )
) )
@@ -141,13 +169,7 @@ SHIMS = [
def fix_windows_args(program: str, args, env): def fix_windows_args(program: str, args, env):
"""Adjust our desired program and command line arguments for use on Windows""" """Adjust our desired program and command line arguments for use on Windows."""
if sys.version_info < (3, 8):
# bpo-33617 - Windows needs manual Path -> str conversion
args = [os.fspath(arg) for arg in args]
program = os.fspath(program)
# If we are running a .py on Windows, ensure we call it with this Python # If we are running a .py on Windows, ensure we call it with this Python
# (to support test suite shims) # (to support test suite shims)
if program.lower().endswith('.py'): if program.lower().endswith('.py'):
@@ -164,11 +186,11 @@ def fix_windows_args(program: str, args, env):
return args return args
def unique_everseen(iterable: Iterable[T], key: Callable[[T], T]) -> Iterator[T]: def unique_everseen(iterable: Iterable[T], key: Callable[[T], Tkey]) -> Iterator[T]:
"List unique elements, preserving order." """List unique elements, preserving order."""
# unique_everseen('AAAABBBCCDAABBB') --> A B C D # unique_everseen('AAAABBBCCDAABBB') --> A B C D
# unique_everseen('ABBCcAD', str.lower) --> A B C D # unique_everseen('ABBCcAD', str.lower) --> A B C D
seen: set[T] = set() seen: set[Tkey] = set()
seen_add = seen.add seen_add = seen.add
for element in iterable: for element in iterable:
k = key(element) k = key(element)
@@ -177,11 +199,15 @@ def unique_everseen(iterable: Iterable[T], key: Callable[[T], T]) -> Iterator[T]
yield element yield element
def _casefold_path(path: Path) -> str:
return str.casefold(str(path))
def shim_env_path(env=None): def shim_env_path(env=None):
if env is None: if env is None:
env = os.environ env = os.environ
shim_paths = chain.from_iterable(shim(env) for shim in SHIMS) shim_paths = chain.from_iterable(shim(env) for shim in SHIMS)
return os.pathsep.join( return os.pathsep.join(
str(p) for p in unique_everseen(shim_paths, key=lambda p: str.casefold(str(p))) str(p) for p in unique_everseen(shim_paths, key=_casefold_path)
) )
+2
View File
@@ -1,4 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
"""Tests."""
from __future__ import annotations from __future__ import annotations
+12 -8
View File
@@ -3,12 +3,10 @@
from __future__ import annotations from __future__ import annotations
import os
import platform import platform
import sys import sys
from pathlib import Path from pathlib import Path
from subprocess import PIPE, CompletedProcess, run from subprocess import CompletedProcess, run
from typing import List
import pytest import pytest
@@ -44,7 +42,7 @@ TESTS_ROOT = Path(__file__).parent.resolve()
PROJECT_ROOT = TESTS_ROOT PROJECT_ROOT = TESTS_ROOT
@pytest.fixture @pytest.fixture(scope="session")
def resources() -> Path: def resources() -> Path:
return Path(TESTS_ROOT) / 'resources' return Path(TESTS_ROOT) / 'resources'
@@ -71,13 +69,21 @@ def outtxt(tmp_path) -> Path:
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def no_outpdf(tmp_path) -> Path: def no_outpdf(tmp_path) -> Path:
"""This just documents the fact that a test is not expected to produce """Document fact that a test is not expected to produce output.
This just documents the fact that a test is not expected to produce
output. Unfortunately an assertion failure inside a test fixture produces output. Unfortunately an assertion failure inside a test fixture produces
an error rather than a test failure, so no testing is done. It's up to an error rather than a test failure, so no testing is done. It's up to
the test to confirm that no output file was created.""" the test to confirm that no output file was created.
"""
return tmp_path / 'no_output.pdf' return tmp_path / 'no_output.pdf'
@pytest.fixture(scope="session")
def multipage(resources):
return resources / 'multipage.pdf'
def check_ocrmypdf(input_file: Path, output_file: Path, *args) -> Path: def check_ocrmypdf(input_file: Path, output_file: Path, *args) -> Path:
"""Run ocrmypdf and confirm that a valid plausible PDF was created.""" """Run ocrmypdf and confirm that a valid plausible PDF was created."""
api_args = [str(input_file), str(output_file)] + [ api_args = [str(input_file), str(output_file)] + [
@@ -105,7 +111,6 @@ def run_ocrmypdf_api(input_file: Path, output_file: Path, *args) -> ExitCode:
The return code must always be checked or the test may declare a failure The return code must always be checked or the test may declare a failure
to be pass. to be pass.
""" """
api_args = [str(input_file), str(output_file)] + [ api_args = [str(input_file), str(output_file)] + [
str(arg) for arg in args if arg is not None str(arg) for arg in args if arg is not None
] ]
@@ -123,7 +128,6 @@ def run_ocrmypdf(
If an exception is thrown this fact will be returned as part of the result If an exception is thrown this fact will be returned as part of the result
text and return code rather than exception objects. text and return code rather than exception objects.
""" """
p_args = ( p_args = (
[sys.executable, '-m', 'ocrmypdf'] [sys.executable, '-m', 'ocrmypdf']
+ [str(arg) for arg in args if arg is not None] + [str(arg) for arg in args if arg is not None]
+2 -2
View File
@@ -9,13 +9,13 @@ from ocrmypdf import hookimpl
from ocrmypdf.builtin_plugins import ghostscript from ocrmypdf.builtin_plugins import ghostscript
from ocrmypdf.subprocess import run_polling_stderr from ocrmypdf.subprocess import run_polling_stderr
elision_warning = """GPL Ghostscript 9.20: Setting Overprint Mode to 1 ELISION_WARNING = """GPL Ghostscript 9.50: Setting Overprint Mode to 1
not permitted in PDF/A-2, overprint mode not set""" not permitted in PDF/A-2, overprint mode not set"""
def run_append_stderr(*args, **kwargs): def run_append_stderr(*args, **kwargs):
proc = run_polling_stderr(*args, **kwargs) proc = run_polling_stderr(*args, **kwargs)
proc.stderr += '\n' + elision_warning + '\n' proc.stderr += '\n' + ELISION_WARNING + '\n'
return proc return proc
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Tesseract bad utf8 """Tesseract bad utf8.
In some cases, some versions of Tesseract can output binary gibberish or data In some cases, some versions of Tesseract can output binary gibberish or data
that is not UTF-8 compatible, so we are forced to check that we can convert it that is not UTF-8 compatible, so we are forced to check that we can convert it
+11 -10
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Cache output of tesseract to speed up test suite """Cache output of tesseract to speed up test suite.
The cache is keyed by by the input test file The input arguments are slugged The cache is keyed by by the input test file The input arguments are slugged
into a hideous filename that more or less represents them literally. Joined into a hideous filename that more or less represents them literally. Joined
@@ -21,7 +21,7 @@ were produced.
Certain operations are not cached and routed to Tesseract OCR directly. Certain operations are not cached and routed to Tesseract OCR directly.
Assumes Tesseract 4.0.0-alpha or higher. Assumes Tesseract 4+.
""" """
@@ -140,18 +140,19 @@ def cached_run(options, run_args, **run_kwargs):
tessfile = args.outputbase + '.' + configfile tessfile = args.outputbase + '.' + configfile
shutil.copy(tessfile, str(cache_folder / configfile) + '.bin') shutil.copy(tessfile, str(cache_folder / configfile) + '.bin')
manifest = {}
manifest['tesseract_version'] = TesseractOcrEngine.version().replace('\n', ' ')
manifest['system'] = platform.system()
manifest['python'] = platform.python_version()
manifest['argv_slug'] = cache_folder.name
manifest['sourcefile'] = str(Path(source_file).relative_to(TESTS_ROOT))
def clean_sys_argv(): def clean_sys_argv():
for arg in run_args[1:]: for arg in run_args[1:]:
yield re.sub(r'.*/ocrmypdf[.]io[.][^/]+[/](.*)', r'$TMPDIR/\1', arg) yield re.sub(r'.*/ocrmypdf[.]io[.][^/]+[/](.*)', r'$TMPDIR/\1', arg)
manifest['args'] = list(clean_sys_argv()) manifest = {
'tesseract_version': TesseractOcrEngine.version().replace('\n', ' '),
'system': platform.system(),
'python': platform.python_version(),
'argv_slug': cache_folder.name,
'sourcefile': str(Path(source_file).relative_to(TESTS_ROOT)),
'args': list(clean_sys_argv()),
}
with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f: with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f:
json.dump(manifest, f) json.dump(manifest, f)
f.write('\n') f.write('\n')
+7 -5
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Tesseract no-op/fixed rotate plugin """Tesseract no-op/fixed rotate plugin.
To quickly run tests where getting OCR output is not necessary and we want to test To quickly run tests where getting OCR output is not necessary and we want to test
the rotation pipeline. the rotation pipeline.
@@ -27,14 +27,16 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<head> <head>
<title></title> <title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name='ocr-system' content='tesseract 4.0.0' /> <meta name='ocr-system' content='tesseract 4.1.1' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/> <meta name='ocr-capabilities'
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
</head> </head>
<body> <body>
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'> <div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}"> <div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}"> <p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span> <span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}">
<span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
</span> </span>
</p> </p>
</div> </div>
@@ -46,7 +48,7 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
class FixedRotateNoopOcrEngine(OcrEngine): class FixedRotateNoopOcrEngine(OcrEngine):
@staticmethod @staticmethod
def version(): def version():
return '4.0.0' return '4.1.1'
@staticmethod @staticmethod
def creator_tag(options): def creator_tag(options):
+7 -5
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow # SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Tesseract no-op plugin """Tesseract no-op plugin.
To quickly run tests where getting OCR output is not necessary. To quickly run tests where getting OCR output is not necessary.
@@ -25,14 +25,16 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<head> <head>
<title></title> <title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name='ocr-system' content='tesseract 4.0.0' /> <meta name='ocr-system' content='tesseract 4.1.1' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/> <meta name='ocr-capabilities'
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
</head> </head>
<body> <body>
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'> <div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}"> <div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}"> <p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span> <span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}">
<span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
</span> </span>
</p> </p>
</div> </div>
@@ -44,7 +46,7 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
class NoopOcrEngine(OcrEngine): class NoopOcrEngine(OcrEngine):
@staticmethod @staticmethod
def version(): def version():
return '4.0.0' return '4.1.1'
@staticmethod @staticmethod
def creator_tag(options): def creator_tag(options):
+41 -106
View File
@@ -1,168 +1,105 @@
.. SPDX-FileCopyrightText: 2022 James R. Barlow
.. SPDX-License-Identifier: CC-BY-SA-4.0
These test files are used in OCRmyPDF's test suite. They do not necessarily produce OCR results These test files are used in OCRmyPDF's test suite. They do not necessarily produce OCR results
at all and are not meant as examples of OCR output. Some are even invalid PDFs that might at all and are not necessarily meant as examples of OCR output. Some are even invalid PDFs that might
crash certain PDF viewers. crash certain PDF viewers.
Some of these images were obtained from the public domain. Others are copyrighted and may have
Files derived from free sources licenses associated. Refer to ``.reuse/dep5`` file in OCRmyPDF's Git repository for information on
=============================== the copyright holder(s) and license(s) applicable to these resources.
These test resources come from free sources, under either public domain or Creative Commons licenses.
In some cases they were converted from one image format to another without other changes.
.. list-table:: .. list-table::
:widths: 20 50 30 :widths: 15 35 50
:header-rows: 1 :header-rows: 1
* - File * - File
- Source - Source
- License - Purpose
* - c02-22.pdf * - c02-22.pdf
- `Project Gutenberg`_, Adventures of Huckleberry Finn, page 22 - `Project Gutenberg`_, Adventures of Huckleberry Finn, page 22
- Public Domain - difficult OCR image (obscure fonts and illustrations)
* - congress.jpg * - congress.jpg
- `US Congressional Records`_ - `US Congressional Records`_
- Public Domain - difficult OCR image (color background)
* - graph.pdf * - graph.pdf
- `Wikimedia: Pandas text analysis.png`_ - `Wikimedia:Simple_line_graph_of_ACE_2012_results_by_candidate_sj01.png`_
- Public Domain - image with slanted text
* - lichtenstein.pdf * - lichtenstein.pdf
- `Wikimedia: JPEG2000 Lichtenstein`_ - `Wikimedia: JPEG2000 Lichtenstein`_
- Creative Commons BY-SA 3.0 - JPEG2000 image
* - linn.png, linn.pdf, linn.txt * - linn.png, linn.pdf, linn.txt
- `Wikimedia: LinnSequencer`_ - `Wikimedia: LinnSequencer`_
- Creative Commons BY-SA 3.0 - image with two columns
* - typewriter.png, 2400dpi.pdf * - typewriter.png, 2400dpi.pdf
- `Wikimedia: Triumph typewrtier text Linzensoep`_ - `Wikimedia: Triumph typewrtier text Linzensoep`_
- Creative Commons BY-SA 2.5 - simple text
* - baiona.png * - baiona.png
- `Wikimedia: Baionako udalerri mugakideak`_ - `Wikimedia: Baionako udalerri mugakideak`_
- Creative Commons BY-SA 4.0 - multilingual text and images
* - enron1.pdf
- EnronData.org
- Creative Commons BY 3.0
Files generated for this project
================================
The following test resources were crafted specifically for this project, and are
licensed under the specified license.
.. list-table::
:widths: 20 40 15 15 10
:header-rows: 1
* - File
- Purpose
- Contributor
- Copyright Holder
- License
* - aspect.pdf * - aspect.pdf
- synthetic
- test image with 200 x 100 DPI resolution - test image with 200 x 100 DPI resolution
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - blank.pdf * - blank.pdf
- synthetic
- blank PDF generated by Adobe Illustrator CC 17, containing a lot of application-specific metadata/bloat - blank PDF generated by Adobe Illustrator CC 17, containing a lot of application-specific metadata/bloat
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - cmyk.pdf * - cmyk.pdf
- synthetic
- a CMYK image created in Photoshop - a CMYK image created in Photoshop
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - crom.png * - crom.png
- synthetic
- test for non-dictionary words - test for non-dictionary words
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - enormous.pdf * - enormous.pdf
- synthetic
- very large PDF page - very large PDF page
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - epson.pdf * - epson.pdf
- synthetic
- a linearized PDF containing some unusual indirect objects, created by an Epson printer; printout of a Wikipedia article (CC-BY-SA) - a linearized PDF containing some unusual indirect objects, created by an Epson printer; printout of a Wikipedia article (CC-BY-SA)
- @lowesjam
- Wikipedia authors
- CC-BY-SA 3.0
* - formxobject.pdf * - formxobject.pdf
- synthetic
- hand-crafted PDF containing an image inside a Form XObject - hand-crafted PDF containing an image inside a Form XObject
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - francais.pdf * - francais.pdf
- synthetic
- a page containing French accents (diacritics) - a page containing French accents (diacritics)
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - hugemono.pdf * - hugemono.pdf
- synthetic
- large monochrome 35000x35000 image in JBIG2 encoding - large monochrome 35000x35000 image in JBIG2 encoding
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - invalid.pdf * - invalid.pdf
- synthetic
- a PDF file header followed by EOF marker - a PDF file header followed by EOF marker
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - kcs.pdf * - kcs.pdf
- synthetic
- PDF file generated by Kodak Capture Desktop Software 1.2; has invalid table of contents - PDF file generated by Kodak Capture Desktop Software 1.2; has invalid table of contents
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - livecycle.pdf * - livecycle.pdf
- synthetic
- a minimal PDF that claims to use dynamic XFA forms - a minimal PDF that claims to use dynamic XFA forms
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - masks.pdf * - masks.pdf
- synthetic
- file containing explicit masks and a stencil mask drawn without a proper transformation matrix; printout of a German Wikipedia article (CC-BY-SA) - file containing explicit masks and a stencil mask drawn without a proper transformation matrix; printout of a German Wikipedia article (CC-BY-SA)
- @supergrobi
- Wikipedia authors
- CC-BY-SA 3.0
* - missing_docinfo.pdf * - missing_docinfo.pdf
- synthetic
- PDF file with no /DocumentInfo section - PDF file with no /DocumentInfo section
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - overlay.pdf * - overlay.pdf
- synthetic
- PDF file generated by PDFPen pro that triggered content stream parse errors - PDF file generated by PDFPen pro that triggered content stream parse errors
- @maxandersen
- @maxandersen
- CC-BY-SA 4.0
* - negzero.pdf * - negzero.pdf
- synthetic
- copy of formxobject.pdf with token that qpdf doesn't like - copy of formxobject.pdf with token that qpdf doesn't like
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - no_contents.pdf * - no_contents.pdf
- synthetic
- synthetic PDF with a blank page that has no /Contents entry - synthetic PDF with a blank page that has no /Contents entry
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - truetype_font_nomapping.pdf * - truetype_font_nomapping.pdf
- synthetic
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping - example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - trivial.pdf * - trivial.pdf
- synthetic
- smallest possible valid PDF-1.3 with all required fields - smallest possible valid PDF-1.3 with all required fields
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - type3_font_nomapping.pdf * - type3_font_nomapping.pdf
- synthetic
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping - example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
- @jbarlow83
- @jbarlow83
- CC-BY-SA 4.0
* - vector.pdf * - vector.pdf
- synthetic
- a PDF with vector art and text rendered as curves with no fonts - a PDF with vector art and text rendered as curves with no fonts
- @Catscratch
- @Catscratch
- CC-BY-SA 4.0
Assemblies Assemblies
========== ==========
@@ -174,14 +111,12 @@ These test resources are assemblies or derivatives from other previously mention
- baiona_alpha.png (from baiona.png, RGB+A version) - baiona_alpha.png (from baiona.png, RGB+A version)
- cardinal.pdf (four cardinal directions, baked-in rotated copies of linn.png) - cardinal.pdf (four cardinal directions, baked-in rotated copies of linn.png)
- ccitt.pdf (linn.png, converted to CCITT encoding) - ccitt.pdf (linn.png, converted to CCITT encoding)
- encrypted_algo4.pdf (congress.jpg, encrypted with algorithm 4 - not supported by PyPDF2)
- graph_ocred.pdf (from graph.pdf) - graph_ocred.pdf (from graph.pdf)
- jbig2.pdf (congress.jpg, converted to JBIG2 encoding) - jbig2.pdf (from linn.png)
- multipage.pdf (from several other files) - multipage.pdf (from several other files)
- palette.pdf (congress.jpg, converted to a 256-color palette) - palette.pdf (from baiona_colormapped.png)
- poster.pdf (from linn.png) - poster.pdf (from linn.png)
- rotated_skew.pdf (a /Rotate'd and skewed document from linn.png) - rotated_skew.pdf (a /Rotate'd and skewed document from linn.png)
- skew-encrypted.pdf (skew.pdf with encryption - access supported by PyPDF2, password is "password")
- skew.pdf (from linn.png, skew simulated by adjusting the transformation matrix) - skew.pdf (from linn.png, skew simulated by adjusting the transformation matrix)
- toc.pdf (from formxobject.pdf, trivial.pdf) - toc.pdf (from formxobject.pdf, trivial.pdf)
@@ -192,7 +127,7 @@ These test resources are assemblies or derivatives from other previously mention
.. _`US Congressional Records`: http://www.baxleystamps.com/litho/meiji/courts_1871.jpg .. _`US Congressional Records`: http://www.baxleystamps.com/litho/meiji/courts_1871.jpg
.. _`Wikimedia: Pandas text analysis.png`: https://en.wikipedia.org/wiki/File:Pandas_text_analysis.png .. _`Wikimedia: Simple_line_graph_of_ACE_2012_results_by_candidate_sj01.png`: https://en.wikipedia.org/wiki/File:Simple_line_graph_of_ACE_2012_results_by_candidate_sj01.png
.. _`Wikimedia: JPEG2000 Lichtenstein`: https://en.wikipedia.org/wiki/JPEG_2000#/media/File:Jpeg2000_2-level_wavelet_transform-lichtenstein.png .. _`Wikimedia: JPEG2000 Lichtenstein`: https://en.wikipedia.org/wiki/JPEG_2000#/media/File:Jpeg2000_2-level_wavelet_transform-lichtenstein.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Regular → Executable
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
-2
View File
@@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
import pytest
from ocrmypdf.helpers import check_pdf from ocrmypdf.helpers import check_pdf
+1 -1
View File
@@ -4,7 +4,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from subprocess import PIPE, run from subprocess import run
import pytest import pytest
+2 -2
View File
@@ -14,9 +14,9 @@ from .conftest import run_ocrmypdf_api
@pytest.mark.skipif(True, reason="--use-threads is currently default") @pytest.mark.skipif(True, reason="--use-threads is currently default")
@pytest.mark.skipif(os.name == 'nt', reason="Windows doesn't have SIGKILL") @pytest.mark.skipif(os.name == 'nt', reason="Windows doesn't have SIGKILL")
def test_simulate_oom_killer(resources, no_outpdf): def test_simulate_oom_killer(multipage, no_outpdf):
exitcode = run_ocrmypdf_api( exitcode = run_ocrmypdf_api(
resources / 'multipage.pdf', multipage,
no_outpdf, no_outpdf,
'--force-ocr', '--force-ocr',
'--plugin', '--plugin',
-1
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
import pikepdf import pikepdf
import pytest
import ocrmypdf import ocrmypdf
+15 -11
View File
@@ -6,9 +6,11 @@ from __future__ import annotations
import logging import logging
import multiprocessing import multiprocessing
import os import os
from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from packaging.version import Version
from ocrmypdf import helpers from ocrmypdf import helpers
@@ -52,15 +54,6 @@ def test_no_cpu_count(monkeypatch):
assert invoked, "Patched function called during test" assert invoked, "Patched function called during test"
def test_deprecated():
@helpers.deprecated
def old_function():
return 42
with pytest.deprecated_call():
assert old_function() == 42
skipif_docker = pytest.mark.skipif(running_in_docker(), reason="fails on Docker") skipif_docker = pytest.mark.skipif(running_in_docker(), reason="fails on Docker")
@@ -100,6 +93,17 @@ class TestFileIsWritable:
assert not helpers.is_file_writable(pathmock) assert not helpers.is_file_writable(pathmock)
@pytest.mark.skipif(os.name != 'nt', reason="Windows test")
def test_gs_install_locations():
# pylint: disable=import-outside-toplevel
from ocrmypdf.subprocess._windows import _gs_version_in_path_key
assert _gs_version_in_path_key(Path("C:\\Program Files\\gs\\gs9.52\\bin")) == (
'gs',
Version('9.52'),
)
@pytest.mark.skipif(os.name != 'nt', reason="Windows test") @pytest.mark.skipif(os.name != 'nt', reason="Windows test")
def test_shim_paths(tmp_path): def test_shim_paths(tmp_path):
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
@@ -109,7 +113,7 @@ def test_shim_paths(tmp_path):
progfiles.mkdir() progfiles.mkdir()
(progfiles / 'tesseract-ocr').mkdir() (progfiles / 'tesseract-ocr').mkdir()
(progfiles / 'gs' / '9.51' / 'bin').mkdir(parents=True) (progfiles / 'gs' / '9.51' / 'bin').mkdir(parents=True)
(progfiles / 'gs' / '9.52' / 'bin').mkdir(parents=True) (progfiles / 'gs' / 'gs9.52.3' / 'bin').mkdir(parents=True)
syspath = tmp_path / 'bin' syspath = tmp_path / 'bin'
env = {'PROGRAMFILES': str(progfiles), 'PATH': str(syspath)} env = {'PROGRAMFILES': str(progfiles), 'PATH': str(syspath)}
@@ -117,7 +121,7 @@ def test_shim_paths(tmp_path):
results = result_str.split(os.pathsep) results = result_str.split(os.pathsep)
assert results[0] == str(syspath), results assert results[0] == str(syspath), results
assert results[-3].endswith('tesseract-ocr'), results assert results[-3].endswith('tesseract-ocr'), results
assert results[-2].endswith(os.path.join('gs', '9.52', 'bin')), results assert results[-2].endswith(os.path.join('gs9.52.3', 'bin')), results
assert results[-1].endswith(os.path.join('gs', '9.51', 'bin')), results assert results[-1].endswith(os.path.join('gs', '9.51', 'bin')), results
+53
View File
@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
import hypothesis.strategies as st
from hypothesis import given
from PIL import Image
from ocrmypdf.imageops import bytes_per_pixel, calculate_downsample, downsample_image
def test_bytes_per_pixel():
assert bytes_per_pixel('RGB') == 4
assert bytes_per_pixel('RGBA') == 4
assert bytes_per_pixel('LA') == 2
assert bytes_per_pixel('L') == 1
def test_calculate_downsample():
im = Image.new('RGB', (100, 100))
assert calculate_downsample(im, max_size=(50, 50)) == (50, 50)
assert calculate_downsample(im, max_pixels=2500) == (50, 50)
assert calculate_downsample(im, max_bytes=10000) == (50, 50)
assert calculate_downsample(im, max_bytes=100000) == (100, 100)
@given(
st.one_of(st.just("RGB"), st.just('L')),
st.integers(min_value=1, max_value=100000),
st.integers(min_value=1, max_value=100000),
st.integers(min_value=64, max_value=100000),
st.integers(min_value=64, max_value=100000),
st.integers(min_value=64 * 64, max_value=1000000),
)
def test_calculate_downsample_hypothesis(mode, im_w, im_h, max_x, max_y, max_bytes):
result = calculate_downsample(
(im_w, im_h),
bytes_per_pixel(mode),
max_size=(max_x, max_y),
max_bytes=max_bytes,
)
assert result[0] <= max_x
assert result[1] <= max_y
assert result[0] * result[1] * bytes_per_pixel(mode) <= max_bytes
def test_downsample_image():
im = Image.new('RGB', (100, 100))
im.info['dpi'] = (300, 300)
ds = downsample_image(im, (50, 50))
assert ds.size == (50, 50)
assert ds.info['dpi'] == (150, 150)
-2
View File
@@ -5,8 +5,6 @@ from __future__ import annotations
import logging import logging
import pytest
from ocrmypdf._sync import configure_debug_logging from ocrmypdf._sync import configure_debug_logging
+27 -26
View File
@@ -7,16 +7,15 @@ import os
import shutil import shutil
from math import isclose from math import isclose
from pathlib import Path from pathlib import Path
from subprocess import PIPE, run from subprocess import run
from unittest.mock import patch from unittest.mock import patch
import pikepdf import pikepdf
import PIL
import pytest import pytest
from PIL import Image from PIL import Image
import ocrmypdf import ocrmypdf
from ocrmypdf._exec import ghostscript, tesseract from ocrmypdf._exec import tesseract
from ocrmypdf.exceptions import ExitCode, MissingDependencyError from ocrmypdf.exceptions import ExitCode, MissingDependencyError
from ocrmypdf.pdfa import file_claims_pdfa from ocrmypdf.pdfa import file_claims_pdfa
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo
@@ -153,9 +152,9 @@ def test_skip_big(resources, outpdf):
@pytest.mark.parametrize('renderer', RENDERERS) @pytest.mark.parametrize('renderer', RENDERERS)
@pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) @pytest.mark.parametrize('output_type', ['pdf', 'pdfa'])
def test_maximum_options(renderer, output_type, resources, outpdf): def test_maximum_options(renderer, output_type, multipage, outpdf):
check_ocrmypdf( check_ocrmypdf(
resources / 'multipage.pdf', multipage,
outpdf, outpdf,
'-d', '-d',
'-ci' if have_unpaper() else None, '-ci' if have_unpaper() else None,
@@ -179,7 +178,8 @@ def test_maximum_options(renderer, output_type, resources, outpdf):
@pytest.mark.skipif( @pytest.mark.skipif(
tesseract.version() >= '5', reason="tess 5 tries harder to find its files" tesseract.TesseractVersion(tesseract.version()) >= tesseract.TesseractVersion('5'),
reason="tess 5 tries harder to find its files",
) )
def test_tesseract_missing_tessdata(monkeypatch, resources, no_outpdf, tmpdir): def test_tesseract_missing_tessdata(monkeypatch, resources, no_outpdf, tmpdir):
monkeypatch.setenv("TESSDATA_PREFIX", os.fspath(tmpdir)) monkeypatch.setenv("TESSDATA_PREFIX", os.fspath(tmpdir))
@@ -292,12 +292,6 @@ def test_input_file_not_a_pdf(caplog, no_outpdf):
assert input_file in caplog.text assert input_file in caplog.text
def test_encrypted(resources, caplog, no_outpdf):
result = run_ocrmypdf_api(resources / 'skew-encrypted.pdf', no_outpdf)
assert result == ExitCode.encrypted_pdf
assert 'encryption must be removed' in caplog.text
@pytest.mark.parametrize('renderer', RENDERERS) @pytest.mark.parametrize('renderer', RENDERERS)
def test_pagesegmode(renderer, resources, outpdf): def test_pagesegmode(renderer, resources, outpdf):
check_ocrmypdf( check_ocrmypdf(
@@ -398,14 +392,27 @@ def test_tesseract_image_too_big(renderer, resources, outpdf):
) )
def test_algo4(resources, outpdf): @pytest.mark.parametrize('encryption_level', [2, 3, 4, 6])
p = run_ocrmypdf( def test_encrypted(resources, outpdf, encryption_level, caplog):
resources / 'encrypted_algo4.pdf', encryption = pikepdf.models.encryption.Encryption(
owner='ocrmypdf',
user='ocrmypdf',
R=encryption_level,
aes=(encryption_level >= 4),
metadata=(encryption_level == 6),
)
with pikepdf.open(resources / 'jbig2.pdf') as pdf:
pdf.save(outpdf, encryption=encryption)
exitcode = run_ocrmypdf_api(
outpdf,
outpdf, outpdf,
'--plugin', '--plugin',
'tests/plugins/tesseract_noop.py', 'tests/plugins/tesseract_noop.py',
) )
assert p.returncode == ExitCode.encrypted_pdf assert exitcode == ExitCode.encrypted_pdf
assert 'encryption must be removed' in caplog.text
def test_jbig2_passthrough(resources, outpdf): def test_jbig2_passthrough(resources, outpdf):
@@ -439,7 +446,7 @@ def test_linearized_pdf_and_indirect_object(resources, outpdf):
def test_very_high_dpi(resources, outpdf): def test_very_high_dpi(resources, outpdf):
"Checks for a Decimal quantize error with high DPI, etc" """Checks for a Decimal quantize error with high DPI, etc."""
check_ocrmypdf( check_ocrmypdf(
resources / '2400dpi.pdf', resources / '2400dpi.pdf',
outpdf, outpdf,
@@ -539,7 +546,6 @@ def test_tesseract_config_invalid(renderer, resources, invalid_tess_config, outp
assert p.returncode == ExitCode.invalid_config assert p.returncode == ExitCode.invalid_config
@pytest.mark.skipif(not tesseract.has_user_words(), reason='not functional until 4.1.0')
def test_user_words_ocr(resources, outdir): def test_user_words_ocr(resources, outdir):
# Does not actually test if --user-words causes output to differ # Does not actually test if --user-words causes output to differ
word_list = outdir / 'wordlist.txt' word_list = outdir / 'wordlist.txt'
@@ -716,11 +722,9 @@ def test_compression_changed(ocrmypdf_exec, resources, image, compression, outpd
if compression == "jpeg": if compression == "jpeg":
assert pdfimage.enc == Encoding.jpeg assert pdfimage.enc == Encoding.jpeg
else: else:
if ghostscript.jpeg_passthrough_available(): if image.endswith('jpg'):
# Ghostscript 9.23 adds JPEG passthrough, which allows a JPEG to be # Ghostscript JPEG passthrough - no issue
# copied without transcoding - so report assert pdfimage.enc == Encoding.jpeg
if image.endswith('jpg'):
assert pdfimage.enc == Encoding.jpeg
else: else:
assert pdfimage.enc not in (Encoding.jpeg, Encoding.jpeg2000) assert pdfimage.enc not in (Encoding.jpeg, Encoding.jpeg2000)
@@ -774,9 +778,6 @@ def test_sidecar_nonempty(resources, outpdf):
@pytest.mark.parametrize('pdfa_level', ['1', '2', '3']) @pytest.mark.parametrize('pdfa_level', ['1', '2', '3'])
def test_pdfa_n(pdfa_level, resources, outpdf): def test_pdfa_n(pdfa_level, resources, outpdf):
if pdfa_level == '3' and ghostscript.version() < '9.19':
pytest.xfail(reason='Ghostscript >= 9.19 required')
check_ocrmypdf( check_ocrmypdf(
resources / 'ccitt.pdf', resources / 'ccitt.pdf',
outpdf, outpdf,
+46 -66
View File
@@ -22,19 +22,9 @@ from ocrmypdf.pdfinfo import PdfInfo
from .conftest import check_ocrmypdf, run_ocrmypdf from .conftest import check_ocrmypdf, run_ocrmypdf
try:
import fitz
except ImportError:
fitz = None
pytestmark = pytest.mark.filterwarnings('ignore:.*XMLParser.*:DeprecationWarning')
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf']) @pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
def test_preserve_docinfo(output_type, resources, outpdf): def test_preserve_docinfo(output_type, resources, outpdf):
pdf_before = pikepdf.open(resources / 'graph.pdf')
output = check_ocrmypdf( output = check_ocrmypdf(
resources / 'graph.pdf', resources / 'graph.pdf',
outpdf, outpdf,
@@ -43,14 +33,13 @@ def test_preserve_docinfo(output_type, resources, outpdf):
'--plugin', '--plugin',
'tests/plugins/tesseract_noop.py', 'tests/plugins/tesseract_noop.py',
) )
with pikepdf.open(resources / 'graph.pdf') as pdf_before, pikepdf.open(
pdf_after = pikepdf.open(output) output
) as pdf_after:
for key in ('/Title', '/Author'): for key in ('/Title', '/Author'):
assert pdf_before.docinfo[key] == pdf_after.docinfo[key] assert pdf_before.docinfo[key] == pdf_after.docinfo[key]
pdfa_info = file_claims_pdfa(str(output))
pdfa_info = file_claims_pdfa(str(output)) assert pdfa_info['output'] == output_type
assert pdfa_info['output'] == output_type
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf']) @pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
@@ -74,23 +63,20 @@ def test_override_metadata(output_type, resources, outpdf):
assert p.returncode == ExitCode.ok, p.stderr assert p.returncode == ExitCode.ok, p.stderr
before = pikepdf.open(input_file) with pikepdf.open(input_file) as before, pikepdf.open(outpdf) as after:
after = pikepdf.open(outpdf) assert after.docinfo.Title == german, after.docinfo
assert after.docinfo.Author == chinese, after.docinfo
assert after.docinfo.get('/Keywords', '') == ''
assert after.docinfo.Title == german, after.docinfo before_date = decode_pdf_date(str(before.docinfo.CreationDate))
assert after.docinfo.Author == chinese, after.docinfo after_date = decode_pdf_date(str(after.docinfo.CreationDate))
assert after.docinfo.get('/Keywords', '') == '' assert before_date == after_date
before_date = decode_pdf_date(str(before.docinfo.CreationDate)) pdfa_info = file_claims_pdfa(outpdf)
after_date = decode_pdf_date(str(after.docinfo.CreationDate)) assert pdfa_info['output'] == output_type
assert before_date == after_date
pdfa_info = file_claims_pdfa(outpdf)
assert pdfa_info['output'] == output_type
def test_high_unicode(resources, no_outpdf): def test_high_unicode(resources, no_outpdf):
# Ghostscript doesn't support high Unicode, so neither do we, to be # Ghostscript doesn't support high Unicode, so neither do we, to be
# safe # safe
input_file = resources / 'c02-22.pdf' input_file = resources / 'c02-22.pdf'
@@ -110,10 +96,10 @@ def test_high_unicode(resources, no_outpdf):
assert p.returncode == ExitCode.bad_args, p.stderr assert p.returncode == ExitCode.bad_args, p.stderr
@pytest.mark.skipif(not fitz, reason="test uses fitz")
@pytest.mark.parametrize('ocr_option', ['--skip-text', '--force-ocr']) @pytest.mark.parametrize('ocr_option', ['--skip-text', '--force-ocr'])
@pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) @pytest.mark.parametrize('output_type', ['pdf', 'pdfa'])
def test_bookmarks_preserved(output_type, ocr_option, resources, outpdf): def test_bookmarks_preserved(output_type, ocr_option, resources, outpdf):
fitz = pytest.importorskip('fitz')
input_file = resources / 'toc.pdf' input_file = resources / 'toc.pdf'
before_toc = fitz.Document(str(input_file)).get_toc() before_toc = fitz.Document(str(input_file)).get_toc()
@@ -151,30 +137,36 @@ def test_creation_date_preserved(output_type, resources, infile, outpdf):
'tests/plugins/tesseract_noop.py', 'tests/plugins/tesseract_noop.py',
) )
pdf_before = pikepdf.open(input_file) with pikepdf.open(input_file) as pdf_before, pikepdf.open(outpdf) as pdf_after:
pdf_after = pikepdf.open(outpdf) before = pdf_before.trailer.get('/Info', {})
after = pdf_after.trailer.get('/Info', {})
before = pdf_before.trailer.get('/Info', {}) if not before:
after = pdf_after.trailer.get('/Info', {}) assert after.get('/CreationDate', '') != ''
else:
# We expect that the creation date stayed the same
date_before = decode_pdf_date(str(before['/CreationDate']))
date_after = decode_pdf_date(str(after['/CreationDate']))
assert seconds_between_dates(date_before, date_after) < 1000
if not before: # We expect that the modified date is quite recent
assert after.get('/CreationDate', '') != '' date_after = decode_pdf_date(str(after['/ModDate']))
else: assert (
# We expect that the creation date stayed the same seconds_between_dates(date_after, datetime.datetime.now(timezone.utc))
date_before = decode_pdf_date(str(before['/CreationDate'])) < 1000
date_after = decode_pdf_date(str(after['/CreationDate'])) )
assert seconds_between_dates(date_before, date_after) < 1000
# We expect that the modified date is quite recent
date_after = decode_pdf_date(str(after['/ModDate']))
assert seconds_between_dates(date_after, datetime.datetime.now(timezone.utc)) < 1000
@pytest.fixture @pytest.fixture
def libxmp_file_to_dict(): def libxmp_file_to_dict():
try: try:
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning) # libxmp imports distutils.Version, which is deprecated
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=r".*distutils Version classes are deprecated.*",
)
from libxmp.utils import ( from libxmp.utils import (
file_to_dict, # pylint: disable=import-outside-toplevel file_to_dict, # pylint: disable=import-outside-toplevel
) )
@@ -229,17 +221,6 @@ def test_xml_metadata_preserved(
'pdf:keywords', 'pdf:keywords',
] ]
acquired_properties = ['dc:format'] acquired_properties = ['dc:format']
might_change_properties = [
'dc:date',
'pdf:pdfversion',
'pdf:Producer',
'xmp:CreateDate',
'xmp:ModifyDate',
'xmp:MetadataDate',
'xmp:CreatorTool',
'xmpMM:DocumentId',
'xmpMM:DnstanceId',
]
# Cleanup messy data structure # Cleanup messy data structure
# Top level is key-value mapping of namespaces to keys under namespace, # Top level is key-value mapping of namespaces to keys under namespace,
@@ -290,10 +271,9 @@ def test_kodak_toc(resources, outpdf):
'tests/plugins/tesseract_noop.py', 'tests/plugins/tesseract_noop.py',
) )
p = pikepdf.open(outpdf) with pikepdf.open(outpdf) as p:
if pikepdf.Name.First in p.Root.Outlines:
if pikepdf.Name.First in p.Root.Outlines: assert isinstance(p.Root.Outlines.First, pikepdf.Dictionary)
assert isinstance(p.Root.Outlines.First, pikepdf.Dictionary)
def test_metadata_fixup_warning(resources, outdir, caplog): def test_metadata_fixup_warning(resources, outdir, caplog):
@@ -311,10 +291,10 @@ def test_metadata_fixup_warning(resources, outdir, caplog):
assert record.levelname != 'WARNING', "Unexpected warning" assert record.levelname != 'WARNING', "Unexpected warning"
# Now add some metadata that will not be copyable # Now add some metadata that will not be copyable
graph = pikepdf.open(outdir / 'graph.pdf') with pikepdf.open(outdir / 'graph.pdf') as graph:
with graph.open_metadata() as meta: with graph.open_metadata() as meta:
meta['prism2:publicationName'] = 'OCRmyPDF Test' meta['prism2:publicationName'] = 'OCRmyPDF Test'
graph.save(outdir / 'graph_mod.pdf') graph.save(outdir / 'graph_mod.pdf')
context = PdfContext( context = PdfContext(
options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([]) options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([])
+29 -13
View File
@@ -27,10 +27,18 @@ needs_jbig2enc = pytest.mark.skipif(
) )
# pylint:disable=redefined-outer-name
@pytest.fixture(scope="session")
def palette(resources):
return resources / 'palette.pdf'
@needs_pngquant @needs_pngquant
@pytest.mark.parametrize('pdf', ['multipage.pdf', 'palette.pdf']) @pytest.mark.parametrize('pdf', ['multipage', 'palette'])
def test_basic(resources, pdf, outpdf): def test_basic(multipage, palette, pdf, outpdf):
infile = resources / pdf infile = multipage if pdf == 'multipage' else palette
opt.main(infile, outpdf, level=3) opt.main(infile, outpdf, level=3)
assert 0.98 * Path(outpdf).stat().st_size <= Path(infile).stat().st_size assert 0.98 * Path(outpdf).stat().st_size <= Path(infile).stat().st_size
@@ -92,14 +100,14 @@ def test_jbig2_lossy(lossy, resources, outpdf):
check_ocrmypdf(*args) check_ocrmypdf(*args)
pdf = pikepdf.open(outpdf) with pikepdf.open(outpdf) as pdf:
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values()))) pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
assert pim.filters[0] == '/JBIG2Decode' assert pim.filters[0] == '/JBIG2Decode'
if lossy: if lossy:
assert '/JBIG2Globals' in pim.decode_parms[0] assert '/JBIG2Globals' in pim.decode_parms[0]
else: else:
assert len(pim.decode_parms) == 0 assert len(pim.decode_parms) == 0
@needs_pngquant @needs_pngquant
@@ -126,9 +134,9 @@ def test_flate_to_jbig2(resources, outdir):
'tests/plugins/tesseract_noop.py', 'tests/plugins/tesseract_noop.py',
) )
pdf = pikepdf.open(outdir / 'out.pdf') with pikepdf.open(outdir / 'out.pdf') as pdf:
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values()))) pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
assert pim.filters[0] == '/JBIG2Decode' assert pim.filters[0] == '/JBIG2Decode'
@needs_pngquant @needs_pngquant
@@ -196,3 +204,11 @@ def test_group3(resources, outdir):
assert ( assert (
opt.extract_image_filter(pdf, outdir, im, im.objgen[0]) is None opt.extract_image_filter(pdf, outdir, im, im.objgen[0]) is None
), "Group 3 should be disallowed" ), "Group 3 should be disallowed"
def test_find_formx(resources, outdir):
with pikepdf.open(resources / 'formxobject.pdf') as pdf:
working, pagenos = opt._find_image_xrefs(pdf)
assert len(working) == 1
xref = next(iter(working))
assert pagenos[xref] == 0
+2 -3
View File
@@ -48,10 +48,9 @@ def test_nonmonotonic_warning(caplog):
assert 'out of order' in caplog.text assert 'out of order' in caplog.text
def test_limited_pages(resources, outpdf): def test_limited_pages(multipage, outpdf):
multi = resources / 'multipage.pdf'
ocrmypdf.ocr( ocrmypdf.ocr(
multi, multipage,
outpdf, outpdf,
pages='5-6', pages='5-6',
optimize=0, optimize=0,
-1
View File
@@ -42,7 +42,6 @@ VECTOR_RESOLUTION = Resolution(_pipeline.VECTOR_PAGE_DPI, _pipeline.VECTOR_PAGE_
], ],
) )
def test_dpi_needed(image, text, vector, result, rgb_image, outdir): def test_dpi_needed(image, text, vector, result, rgb_image, outdir):
c = Canvas(str(outdir / 'dpi.pdf'), pagesize=(5 * inch, 5 * inch)) c = Canvas(str(outdir / 'dpi.pdf'), pagesize=(5 * inch, 5 * inch))
if image: if image:
c.drawImage(rgb_image, 1 * inch, 1 * inch, width=1 * inch, height=1 * inch) c.drawImage(rgb_image, 1 * inch, 1 * inch, width=1 * inch, height=1 * inch)

Some files were not shown because too many files have changed in this diff Show More