Compare commits

...
42 Commits
Author SHA1 Message Date
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
75 changed files with 944 additions and 411 deletions
+3 -1
View File
@@ -25,7 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libffi-dev \
ca-certificates \
curl \
git
git \
libcairo2-dev \
pkg-config
# Get the latest pip (Ubuntu version doesn't support manylinux2010)
RUN \
+1
View File
@@ -6,6 +6,7 @@
.venv*/
.tox/
.vscode/
.hypothesis/
.ipynb_checkpoints/
.mypy_cache/
.pytest_cache/
+8 -16
View File
@@ -3,34 +3,26 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.4.0
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/pycqa/isort
rev: 5.10.1
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.0.261"
hooks:
- id: isort
args: ["--profile", "black", "-a", "from __future__ import annotations"]
- id: ruff
files: "src/.*\\.pyi?$"
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black
rev: 22.6.0
rev: 23.3.0
hooks:
- id: black
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: ["--py38-plus"]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.971
rev: v1.2.0
hooks:
- id: mypy
additional_dependencies:
+3 -2
View File
@@ -70,7 +70,7 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
| macOS (Homebrew) | ``brew install ocrmypdf`` |
| macOS (nix) | ``nix-env -i ocrmypdf`` |
| LinuxBrew | ``brew install ocrmypdf`` |
| FreeBSD | ``pkg install py-ocrmypdf`` |
| FreeBSD | ``pkg install py-ocrmypdf`` |
| Conda | ``conda install ocrmypdf`` |
| Ubuntu Snap | ``snap install ocrmypdf`` |
@@ -112,7 +112,7 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF
## 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
@@ -123,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)
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
- [Y Combinator discussion](https://news.ycombinator.com/item?id=32028752)
## Business enquiries
+1 -1
View File
@@ -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_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_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_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}'``."
+2 -3
View File
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: CC-BY-SA-4.0
# ruff: noqa: E402
# ocrmypdf documentation build configuration file, created by
# sphinx-quickstart on Sun Sep 4 14:29:43 2016.
#
@@ -22,8 +24,6 @@
# import sys
# sys.path.insert(0, os.path.abspath('.'))
"""isort:skip_file"""
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
@@ -78,7 +78,6 @@ author = 'James R. Barlow'
import os
from importlib.metadata import version as package_version
on_rtd = os.environ.get('READTHEDOCS') == 'True'
if on_rtd:
+1 -1
View File
@@ -360,7 +360,7 @@ The types of optimization available may expand over time. By default,
OCRmyPDF compresses data streams inside PDFs, and will change
inefficient compression modes to more modern versions. A program like
``qpdf`` can be used to change encodings, e.g. to inspect the internals
fo a PDF.
for a PDF.
.. code-block:: bash
+4 -4
View File
@@ -21,7 +21,7 @@ These platforms have one-liner installs:
+-------------------------------+-----------------------------------------+
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
+-------------------------------+-----------------------------------------+
| Fedora | ``dnf install ocrmypdf`` |
| Fedora | ``dnf install ocrmypdf tesseract-osd`` |
+-------------------------------+-----------------------------------------+
| macOS | ``brew install ocrmypdf`` |
+-------------------------------+-----------------------------------------+
@@ -121,7 +121,7 @@ Users of Fedora 29 or later may simply
.. code-block:: bash
dnf install ocrmypdf
dnf install ocrmypdf tesseract-osd
For full details on version availability, check the `Fedora Package
Tracker <https://apps.fedoraproject.org/packages/ocrmypdf>`__.
@@ -293,7 +293,7 @@ Homebrew
.. image:: https://img.shields.io/homebrew/v/ocrmypdf.svg
: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
install on macOS:
@@ -530,7 +530,7 @@ programs being installed.
``apt install python3-pip python3-venv``, create a virtual 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>`__.
For best results, first install `your platform's
+1 -1
View File
@@ -193,7 +193,7 @@ Ghostscript also imposes some limitations:
(Modern Ghostscript can copy JPEG images without transcoding them.)
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
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
hyperlinks and other active content.
+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
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
# update system to reflect changed USE flags
+37 -1
View File
@@ -28,6 +28,42 @@ tagged yet.
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
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
=======
@@ -711,7 +747,7 @@ v10.3.2
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.
v10.3.0
+9 -2
View File
@@ -2,11 +2,18 @@
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
# 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
# This script must be edited to meet your needs.
import logging
import os
import sys
from pathlib import Path
+1 -2
View File
@@ -1,8 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R Barlow: https://github.com/jbarlow83
# SPDX-License-Identifier: MIT
"""
An example of an OCRmyPDF plugin.
"""An example of an OCRmyPDF plugin.
This plugin adds two new command line arguments
--grayscale-ocr: converts the image to grayscale before performing OCR on it
+1 -1
View File
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: 2017 Enantiomerie
# SPDX-License-Identifier: MIT
"""Example OCRmyPDF for Synology NAS"""
"""Example OCRmyPDF for Synology NAS."""
from __future__ import annotations
+5 -1
View File
@@ -3,6 +3,8 @@
# SPDX-FileCopyrightText: 2020 James R Barlow <https://github.com/jbarlow83>
# SPDX-License-Identifier: MIT
"""Watch a directory for new PDFs and OCR them."""
from __future__ import annotations
import json
@@ -38,6 +40,7 @@ DESKEW = getenv_bool('OCR_DESKEW')
OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}'))
POLL_NEW_FILE_SECONDS = int(os.getenv('OCR_POLL_NEW_FILE_SECONDS', '1'))
USE_POLLING = getenv_bool('OCR_USE_POLLING')
RETRIES_LOADING_FILE = int(os.getenv('OCR_RETRIES_LOADING_FILE', '5'))
LOGLEVEL = os.getenv('OCR_LOGLEVEL', 'INFO')
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
# pikepdf to fail.
retries = 5
retries = RETRIES_LOADING_FILE
while retries:
try:
pdf = pikepdf.open(file_path)
@@ -142,6 +145,7 @@ def main():
f"DESKEW: {DESKEW}\n"
f"ARGS: {OCR_JSON_SETTINGS}\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"LOGLEVEL: {LOGLEVEL}"
)
+3 -3
View File
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: 2019 James R. Barlow
# 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.
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
@@ -15,7 +15,7 @@ from __future__ import annotations
import os
import shlex
from subprocess import PIPE, run
from subprocess import run
from tempfile import TemporaryDirectory
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')
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:
stderr = proc.stderr
return Response(stderr, 400, mimetype='text/plain')
+24 -4
View File
@@ -69,6 +69,7 @@ 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",
@@ -184,7 +185,26 @@ module = [
]
ignore_missing_imports = true
[tool.pylint.basic]
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"]
logging-format-style = "old"
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"]
[tool.ruff]
select = [
"D", # pydocstyle
"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"]
+12 -1
View File
@@ -1,5 +1,6 @@
# 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
@@ -54,7 +55,10 @@ parts:
ocrmypdf:
plugin: python
source: https://github.com/ocrmypdf/OCRmyPDF.git
source: .
build-packages:
- python3-pip
stage-packages:
- ghostscript
@@ -77,7 +81,14 @@ parts:
- setuptools
- tqdm
- pipe
- wheel
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
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
+30
View File
@@ -29,3 +29,33 @@ from ocrmypdf.exceptions import (
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
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 -1
View File
@@ -11,7 +11,6 @@ import os
import signal
import sys
from contextlib import suppress
from multiprocessing import set_start_method
from ocrmypdf import __version__
from ocrmypdf._plugin_manager import get_parser_options_plugins
@@ -29,10 +28,16 @@ log = logging.getLogger('ocrmypdf')
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")
def run(args=None):
"""Run the ocrmypdf command line interface."""
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
with suppress(AttributeError, PermissionError):
+2 -4
View File
@@ -51,8 +51,7 @@ class Executor(ABC):
task_arguments: Iterable | None = None,
task_finished: Callable | None = None,
) -> None:
"""
Set up parallel execution and progress reporting.
"""Set up parallel execution and progress reporting.
Args:
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
performant).
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
execution context. If the child workers are processes, it must be
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
marshallable to the worker.
"""
if not task_arguments:
return # Nothing to do!
if not worker_initializer:
+1 -1
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Manage third party executables"""
"""Manage third party executables."""
from __future__ import annotations
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to Ghostscript executable"""
"""Interface to Ghostscript executable."""
from __future__ import annotations
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to jbig2 executable"""
"""Interface to jbig2 executable."""
from __future__ import annotations
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to pngquant executable"""
"""Interface to pngquant executable."""
from __future__ import annotations
+12 -7
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to Tesseract executable"""
"""Interface to Tesseract executable."""
from __future__ import annotations
@@ -34,10 +34,12 @@ HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<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>
<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>
</body>
</html>
@@ -52,7 +54,7 @@ TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
class TesseractLoggerAdapter(logging.LoggerAdapter):
"Prepend [tesseract] to messages emitted from tesseract"
"""Prepend [tesseract] to messages emitted from tesseract."""
def process(self, msg, kwargs):
kwargs['extra'] = self.extra
@@ -104,7 +106,8 @@ TESSERACT_VERSION_PATTERN = r"""
class TesseractVersion(Version):
"Modify standard packaging.Version regex to support Tesseract idiosyncracies."
"""Modify standard packaging.Version regex to support Tesseract idiosyncrasies."""
_regex = re.compile(
r"^\s*" + TESSERACT_VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
)
@@ -280,8 +283,10 @@ def page_timedout(timeout: float) -> 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
the same size as the input image."""
"""Produce a .hocr file that reports no text detected.
Ensures page is the same size as the input image.
"""
with Image.open(image) as im:
w, h = im.size
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to unpaper executable"""
"""Interface to unpaper executable."""
from __future__ import annotations
+4 -7
View File
@@ -37,7 +37,6 @@ def _update_resources(*, obj, font, font_key, procset):
obj can be a page or Form XObject.
"""
resources = _ensure_dictionary(obj, Name.Resources)
fonts = _ensure_dictionary(resources, Name.Font)
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
log.debug(
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:
@@ -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
back.
"""
page0 = self.pdf_base.pages[0]
_update_resources(
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
@@ -199,8 +198,7 @@ class OcrGrafter:
return self.output_file
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
possible_font_names = ('/f-0-0', '/F1')
try:
@@ -233,8 +231,7 @@ class OcrGrafter:
text_rotation: int,
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
log.debug("Grafting")
+1 -1
View File
@@ -59,7 +59,7 @@ class PdfContext:
class PageContext:
"""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__``.
"""
+1 -1
View File
@@ -24,7 +24,7 @@ class PageNumberFilter(logging.Filter):
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
progress bar, and then refresh the progress bar, rather than overwriting
+21 -29
View File
@@ -14,7 +14,7 @@ from contextlib import suppress
from datetime import datetime, timezone
from pathlib import Path
from shutil import copyfileobj
from typing import Any, Iterable, Sequence
from typing import Any, BinaryIO, Iterable, Sequence, cast
import img2pdf
import pikepdf
@@ -121,7 +121,6 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
Returns empty string if not found, indicating file is probably not PDF.
"""
with open(input_file, 'rb') as f:
signature = f.read(search_window)
m = re.search(br'%PDF-(\d\.\d)', signature)
@@ -186,15 +185,6 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
"Designer and can only be read by Adobe Acrobat or Adobe Reader."
)
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 options.redo_ocr:
log.error(
@@ -222,7 +212,7 @@ def _vector_page_dpi(pageinfo: PageInfo) -> int:
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(
pageinfo.dpi.x or VECTOR_PAGE_DPI,
options.oversample or 0.0,
@@ -237,7 +227,7 @@ def get_page_dpi(pageinfo: 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
yres = pageinfo.dpi.y or 0.0
userunit = float(pageinfo.userunit) or 1.0
@@ -253,7 +243,7 @@ def get_page_square_dpi(pageinfo: 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(
max(
(pageinfo.dpi.x) or VECTOR_PAGE_DPI,
@@ -315,8 +305,8 @@ def is_ocr_required(page_context: PageContext) -> bool:
log.warning(
"page has no images - "
"all vector content will be "
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and likely "
"increasing file size. Use --oversample to adjust the "
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and "
"likely increasing file size. Use --oversample to adjust the "
"DPI."
)
else:
@@ -358,9 +348,7 @@ def rasterize_preview(input_file: Path, page_context: PageContext) -> Path:
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: ''}
turns = {0: ' ', 90: '', 180: '', 270: ''}
@@ -401,7 +389,6 @@ def get_orientation_correction(preview: Path, page_context: PageContext) -> int:
which points it (hopefully) upright. _graft.py takes care of the orienting
the image and text layers.
"""
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
preview, page_context.options
)
@@ -514,10 +501,11 @@ def preprocess_clean(input_file: Path, page_context: PageContext) -> Path:
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
image depending on preprocessing. This image will never be shown to the
user."""
"""Create the image we send for OCR.
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')
options = page_context.options
with Image.open(image) as im:
@@ -578,7 +566,9 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
# 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:
@@ -895,15 +885,17 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
return output_file
def copy_final(input_file, output_file, _context: PdfContext) -> None:
def copy_final(
input_file: Path, output_file: str | Path | BinaryIO, _context: PdfContext
) -> None:
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 == '-':
copyfileobj(input_stream, sys.stdout.buffer)
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
elif hasattr(output_file, 'writable'):
output_stream = output_file
copyfileobj(input_stream, output_stream)
output_stream = cast(BinaryIO, output_file)
copyfileobj(input_stream, output_stream) # type: ignore[misc]
with suppress(AttributeError):
output_stream.flush()
else:
+6 -8
View File
@@ -251,8 +251,7 @@ def worker_init(max_pixels: int) -> None:
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
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
@@ -316,8 +315,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
def configure_debug_logging(
log_filename: Path, prefix: str = ''
) -> logging.FileHandler:
"""
Create a debug log file at a specified location.
"""Create a debug log file at a specified location.
Arguments:
log_filename: Where to the put the log file.
@@ -420,13 +418,13 @@ def run_pipeline(
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:
log.exception("KeyboardInterrupt")
else:
log.error("KeyboardInterrupt")
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)
if options.verbose >= 1:
log.exception("ExitCodeException")
@@ -435,7 +433,7 @@ def run_pipeline(
else:
log.error(type(e).__name__)
return e.exit_code
except (PIL.Image.DecompressionBombError if not api else NeverRaise):
except PIL.Image.DecompressionBombError if not api else NeverRaise:
log.exception(
"A decompression bomb error was encountered while executing the "
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
@@ -453,7 +451,7 @@ def run_pipeline(
"argument."
)
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")
return ExitCode.other_error
finally:
+15 -8
View File
@@ -61,12 +61,20 @@ def check_options_languages(options: Namespace, ocr_engine_languages: set[str])
return
missing_languages = options.languages - ocr_engine_languages
if missing_languages:
lang_text = '\n'.join(lang for lang in missing_languages)
msg = (
"OCR engine does not have language data for the following "
"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)
@@ -109,12 +117,10 @@ def check_options_output(options: Namespace) -> None:
def check_options_sidecar(options: Namespace) -> None:
if options.sidecar == '\0':
if options.output_file == '-':
raise BadArgsError(
"--sidecar filename must be specified when output file is stdout."
)
raise BadArgsError("--sidecar filename needed when output file is stdout.")
elif options.output_file == os.devnull:
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'
if options.sidecar == options.input_file or options.sidecar == options.output_file:
@@ -276,7 +282,8 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
"permissions correctly.\n"
"You may find it easier to use stdin/stdout:"
"\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
@@ -333,7 +340,7 @@ def report_output_file_size(
for arg in image_preproc:
if getattr(options, arg, False):
reasons.append(
f"The argument --{arg.replace('_', '-')} was issued, causing transcoding."
f"--{arg.replace('_', '-')} was issued, causing transcoding."
)
reasons.extend(optimize_messages)
+58 -44
View File
@@ -86,7 +86,6 @@ def configure_logging(
Returns:
The toplevel logger for ocrmypdf (or the root logger, if we are managing it).
"""
prefix = '' if manage_root_logger else 'ocrmypdf'
log = logging.getLogger(prefix)
@@ -143,6 +142,7 @@ def configure_logging(
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
):
"""Construct an options object from the input/output files and keyword arguments."""
cmdline = []
deferred = []
@@ -203,59 +203,72 @@ def create_options(
return options
def ocr( # pylint: disable=unused-argument
def ocr( # noqa: ruff: disable=D417
input_file: PathOrIO,
output_file: PathOrIO,
*,
language: Iterable[str] = None,
image_dpi: int = None,
output_type=None,
language: Iterable[str] | None = None,
image_dpi: int | None = None,
output_type: str | None = None,
sidecar: StrPath | None = None,
jobs: int = None,
use_threads: bool = None,
title: str = None,
author: str = None,
subject: str = None,
keywords: str = None,
rotate_pages: bool = None,
remove_background: bool = None,
deskew: bool = None,
clean: bool = None,
clean_final: bool = None,
unpaper_args: str = None,
oversample: int = None,
remove_vectors: bool = None,
force_ocr: bool = None,
skip_text: bool = None,
redo_ocr: bool = None,
skip_big: float = None,
optimize: int = None,
jpg_quality: int = None,
png_quality: int = None,
jbig2_lossy: bool = None,
jbig2_page_group_size: int = None,
pages: str = None,
max_image_mpixels: float = None,
tesseract_config: Iterable[str] = None,
tesseract_pagesegmode: int = None,
tesseract_oem: int = None,
tesseract_thresholding: int = None,
pdf_renderer=None,
tesseract_timeout: float = None,
rotate_pages_threshold: float = None,
pdfa_image_compression=None,
user_words: os.PathLike = None,
user_patterns: os.PathLike = None,
fast_web_view: float = None,
plugins: Iterable[StrPath] = None,
jobs: int | None = None,
use_threads: bool | None = None,
title: str | None = None,
author: str | None = None,
subject: str | None = None,
keywords: str | None = None,
rotate_pages: bool | None = None,
remove_background: bool | None = None,
deskew: bool | None = None,
clean: bool | None = None,
clean_final: bool | None = None,
unpaper_args: str | None = None,
oversample: int | None = None,
remove_vectors: bool | None = None,
force_ocr: bool | None = None,
skip_text: bool | None = None,
redo_ocr: bool | None = None,
skip_big: float | None = None,
optimize: int | None = None,
jpg_quality: int | None = None,
png_quality: int | None = None,
jbig2_lossy: bool | None = None,
jbig2_page_group_size: int | None = None,
pages: str | None = None,
max_image_mpixels: float | None = None,
tesseract_config: Iterable[str] | None = None,
tesseract_pagesegmode: int | None = None,
tesseract_oem: int | None = None,
tesseract_thresholding: int | None = None,
pdf_renderer: str | None = None,
tesseract_timeout: float | None = None,
tesseract_non_ocr_timeout: float | None = None,
rotate_pages_threshold: float | None = None,
pdfa_image_compression: str | None = None,
user_words: os.PathLike | None = None,
user_patterns: os.PathLike | None = None,
fast_web_view: float | None = None,
plugins: Iterable[StrPath] | None = None,
plugin_manager=None,
keep_temporary_files: bool = None,
progress_bar: bool = None,
keep_temporary_files: bool | None = None,
progress_bar: bool | None = None,
**kwargs,
):
"""Run OCRmyPDF on one PDF or image.
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:
Args:
@@ -277,6 +290,7 @@ def ocr( # pylint: disable=unused-argument
When a stream is used as output, whether via a writable object or
``"-"``, some final validation steps are not performed (we do not read
back the stream after it is written).
Raises:
ocrmypdf.MissingDependencyError: If a required dependency program is missing or
was not found on PATH.
+2 -4
View File
@@ -1,8 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# 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.
# The plugin manager will not load it, so anything defined here may not be
# processed as a module.
from __future__ import annotations
+7 -4
View File
@@ -30,7 +30,7 @@ WorkerInit = Callable[[Queue, UserInit, int], None]
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
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:
https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
"""
while True:
try:
record = q.get()
@@ -55,12 +54,12 @@ def log_listener(q: Queue):
def process_sigbus(*args):
"""Handle SIGBUS signal at the worker level."""
raise InputFileError("A worker process lost access to an input file")
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)
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:
"""Begin a thread pool worker."""
del q # unused but required argument
del loglevel # unused but required argument
# As a thread, block SIGBUS so the main thread deals with it...
@@ -164,14 +164,17 @@ class StandardExecutor(Executor):
@hookimpl
def get_executor(progressbar_class):
"""Return the default executor."""
return StandardExecutor(pbar_class=progressbar_class)
@hookimpl
def get_progressbar_class():
"""Return the default progress bar class."""
return tqdm
@hookimpl
def get_logging_console():
"""Return the default logging console handler."""
return logging.StreamHandler(stream=TqdmConsole(sys.stderr))
+3 -1
View File
@@ -8,7 +8,6 @@ import logging
from ocrmypdf import hookimpl
from ocrmypdf._exec import ghostscript
from ocrmypdf._validation import HOCR_OK_LANGS
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import check_external_program
@@ -17,6 +16,7 @@ log = logging.getLogger(__name__)
@hookimpl
def check_options(options):
"""Check that the options are valid for this plugin."""
check_external_program(
program='gs',
package='ghostscript',
@@ -46,6 +46,7 @@ def rasterize_pdf_page(
rotation,
filter_vector,
):
"""Rasterize a single page of a PDF file using Ghostscript."""
ghostscript.rasterize_pdf(
input_file,
output_file,
@@ -69,6 +70,7 @@ def generate_pdfa(
pdfa_part,
progressbar_class,
):
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
ghostscript.generate_pdfa(
pdf_pages=[*pdf_pages, pdfmark],
output_file=output_file,
+68 -5
View File
@@ -8,10 +8,14 @@ from __future__ import annotations
import logging
import os
from PIL import Image
from ocrmypdf import hookimpl
from ocrmypdf._exec import tesseract
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.cli import numeric, str_to_int
from ocrmypdf.helpers import clamp
from ocrmypdf.imageops import calculate_downsample, downsample_image
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf.subprocess import check_external_program
@@ -58,7 +62,7 @@ def add_options(parser):
metavar='METHOD',
help=(
"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 "
"algorithm with improved sort for background color changes; sauvola is "
"based on local standard deviation."
@@ -69,8 +73,48 @@ def add_options(parser):
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
help='Give up on OCR after the timeout, but copy the preprocessed page '
'into the final output',
help=(
"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(
'--user-words',
@@ -132,6 +176,25 @@ def validate(pdfinfo, options):
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):
"""Implements OCR with Tesseract."""
@@ -156,7 +219,7 @@ class TesseractOcrEngine(OcrEngine):
return tesseract.get_orientation(
input_file,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_timeout,
timeout=options.tesseract_non_ocr_timeout,
)
@staticmethod
@@ -165,7 +228,7 @@ class TesseractOcrEngine(OcrEngine):
input_file,
languages=options.languages,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_timeout,
timeout=options.tesseract_non_ocr_timeout,
)
@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):
"""Validator for numeric params"""
"""Validator for numeric params."""
min_ = basetype(min_) if min_ 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):
"""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
@@ -57,13 +57,21 @@ class ArgumentParser(argparse.ArgumentParser):
"""
def __init__(self, *args, **kwargs):
"""Initialize the parser."""
super().__init__(*args, **kwargs)
self._api_mode = False
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
def error(self, message):
"""Override the default argparse error behavior."""
if not self._api_mode:
super().error(message)
return
@@ -74,11 +82,13 @@ class LanguageSetAction(argparse.Action):
"""Manages a list of languages."""
def __init__(self, option_strings, dest, default=None, **kwargs):
"""Initialize the action."""
if default is None:
default = set()
super().__init__(option_strings, dest, default=default, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
"""Add a language to the set."""
dest = getattr(namespace, self.dest)
if '+' in values:
dest.update(lang for lang in values.split('+'))
@@ -87,6 +97,7 @@ class LanguageSetAction(argparse.Action):
def get_parser():
"""Get the main CLI parser."""
parser = ArgumentParser(
prog=_PROGRAM_NAME,
allow_abbrev=True,
+3 -2
View File
@@ -35,6 +35,7 @@ class ExitCodeException(Exception):
message = ""
def __str__(self):
"""Return a string representation of the exception."""
super_msg = super().__str__() # Don't do str(super())
if self.message:
return self.message.format(super_msg)
@@ -94,7 +95,7 @@ class EncryptedPdfError(ExitCodeException):
exit_code = ExitCode.encrypted_pdf
message = dedent(
'''\
"""\
Input PDF is encrypted. The encryption must be removed to
perform OCR.
@@ -103,7 +104,7 @@ class EncryptedPdfError(ExitCodeException):
You can remove the encryption using
qpdf --decrypt [--password=[password]] infilename
'''
"""
)
+6
View File
@@ -1,3 +1,9 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
#
# 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):
"""Handle SIGBUS signal at the worker level."""
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."""
def __init__(self, conn: Connection) -> None:
"""Initialize the handler."""
# sets the parent's queue to None - parent only touches queue
# in enqueue() which we override
super().__init__(None) # type: ignore
self.conn = conn
def enqueue(self, record):
"""Enqueue a log message."""
self.conn.send(('log', record))
def process_loop(
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)
with suppress(AttributeError): # Windows and Cygwin do not have SIGBUS
# Windows and Cygwin do not have pthread_sigmask or SIGBUS
@@ -166,8 +168,7 @@ class LambdaExecutor(Executor):
continue
if msg_type == MessageType.result:
if task_finished:
task_finished(msg, pbar)
task_finished(msg, pbar)
elif msg_type == 'log':
record = msg
logger = logging.getLogger(record.name)
@@ -185,14 +186,20 @@ class LambdaExecutor(Executor):
@hookimpl
def get_executor(progressbar_class):
"""Return a LambdaExecutor instance."""
return LambdaExecutor(pbar_class=progressbar_class)
@hookimpl
def get_logging_console():
"""Return a logging.StreamHandler instance."""
return logging.StreamHandler()
@hookimpl
def get_progressbar_class():
"""Return a NullProgressBar instance.
This executor cannot use a progress bar.
"""
return NullProgressBar
+26 -4
View File
@@ -49,6 +49,7 @@ class Resolution(Generic[T]):
__slots__ = ('x', 'y')
def __init__(self, x: T, y: T):
"""Construct a Resolution object."""
self.x = x
self.y = y
@@ -57,9 +58,11 @@ class Resolution(Generic[T]):
CONVERSION_ERROR = 0.002
def round(self, ndigits: int) -> Resolution:
"""Round to ndigits after the decimal point."""
return Resolution(round(self.x, ndigits), round(self.y, ndigits))
def to_int(self) -> Resolution[int]:
"""Round to nearest integer."""
return Resolution(int(round(self.x)), int(round(self.y)))
@classmethod
@@ -68,10 +71,12 @@ class Resolution(Generic[T]):
@property
def is_square(self) -> bool:
"""True if the resolution is square (x == y)."""
return self._isclose(self.x, self.y)
@property
def is_finite(self) -> bool:
"""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
@@ -79,6 +84,7 @@ class Resolution(Generic[T]):
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:
return Resolution(max(self.x, *vals), max(self.y, *yvals))
max_x, max_y = self.x, self.y
@@ -88,18 +94,23 @@ class Resolution(Generic[T]):
return Resolution(max_x, max_y)
def flip_axis(self) -> Resolution[T]:
"""Return a new Resolution object with x and y swapped."""
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):
"""Return a string representation of the resolution."""
return f"{self.x:f}x{self.y:f}"
def __repr__(self): # pragma: no cover
"""Return a repr() of the resolution."""
return f"Resolution({self.x}x{self.y} dpi)"
def __eq__(self, other):
"""Return True if the resolution is equal to another resolution."""
if isinstance(other, tuple) and len(other) == 2:
other = Resolution(*other)
if not isinstance(other, Resolution):
@@ -108,7 +119,7 @@ class Resolution(Generic[T]):
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):
@@ -153,6 +164,10 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: 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':
return file1 == file2
else:
@@ -170,7 +185,7 @@ def monotonic(seq: Sequence) -> bool:
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])
@@ -199,7 +214,7 @@ def is_file_writable(test_file: os.PathLike) -> bool:
p = p.resolve(strict=False)
# 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(
os.fspath(p),
os.W_OK,
@@ -273,13 +288,20 @@ def clamp(n, smallest, largest): # mypy doesn't understand types for this
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[:]:
logger.removeHandler(handler)
handler.close() # To ensure handlers with opened resources are released
def pikepdf_enable_mmap():
"""Enable pikepdf mmap."""
# try:
# if pikepdf._qpdf.set_access_default_mmap(True):
# log.debug("pikepdf mmap enabled")
+12 -34
View File
@@ -99,11 +99,10 @@ class HocrTransformError(Exception):
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:
http://kba.cloud/hocr-spec/
http://kba.cloud/hocr-spec/.
"""
box_pattern = re.compile(r'bbox((\s+\d+){4})')
@@ -119,6 +118,7 @@ class HocrTransform:
)
def __init__(self, *, hocr_filename: str | Path, dpi: float):
"""Initialize the HocrTransform object."""
self.dpi = dpi
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
@@ -143,9 +143,7 @@ class HocrTransform:
raise HocrTransformError("hocr file is missing page dimensions")
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:
return ''
body = self.hocr.find(self._child_xpath('body'))
@@ -155,9 +153,7 @@ class HocrTransform:
return ''
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 = ''
if element.text is not None:
text += element.text
@@ -169,10 +165,7 @@ class HocrTransform:
@classmethod
def element_coordinates(cls, element: Element) -> Rect:
"""
Returns a tuple containing the coordinates of the bounding box around
an element
"""
"""Get coordinates of the bounding box around an element."""
out = Rect._make(0 for _ in range(4))
if 'title' in element.attrib:
matches = cls.box_pattern.search(element.attrib['title'])
@@ -183,9 +176,7 @@ class HocrTransform:
@classmethod
def baseline(cls, element: Element) -> tuple[float, float]:
"""
Returns a tuple containing the baseline slope and intercept.
"""
"""Get baseline's slope and intercept."""
if 'title' in element.attrib:
matches = cls.baseline_pattern.search(element.attrib['title'])
if matches:
@@ -193,9 +184,7 @@ class HocrTransform:
return (0.0, 0.0)
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)
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
@@ -206,21 +195,9 @@ class HocrTransform:
@classmethod
def replace_unsupported_chars(cls, s: str) -> str:
"""
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)
"""
"""Replaces characters with those available in the Helvetica typeface."""
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(
self,
*,
@@ -231,8 +208,8 @@ class HocrTransform:
invisible_text: bool = False,
interword_spaces: bool = False,
) -> 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
the hOCR file.
The image need not be identical to the image used to create the hOCR
@@ -323,6 +300,7 @@ class HocrTransform:
@classmethod
def polyval(cls, poly, x): # pragma: no cover
"""Calculate the value of a polynomial at a point."""
return x * poly[0] + poly[1]
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
+22 -8
View File
@@ -53,20 +53,24 @@ class XrefExt(NamedTuple):
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}'
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')
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')
def extract_image_filter(
pike: Pdf, root: Path, image: Stream, xref: Xref
) -> tuple[PdfImage, tuple[Name, Object]] | None:
"""Determine if an image is extractable."""
del pike # unused args
del root
@@ -124,6 +128,7 @@ def extract_image_filter(
def extract_image_jbig2(
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None:
"""Extract an image, saving it as a JBIG2 file."""
del options # unused arg
result = extract_image_filter(pike, root, image, xref)
@@ -165,6 +170,7 @@ def extract_image_jbig2(
def extract_image_generic(
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None:
"""Generic image extraction."""
result = extract_image_filter(pike, root, image, xref)
if result is None:
return None
@@ -230,7 +236,7 @@ def extract_images(
options,
extract_fn: Callable[..., XrefExt | None],
) -> 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.
Exclude images that are soft masks (i.e. alpha transparency related).
@@ -244,7 +250,6 @@ def extract_images(
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.
"""
include_xrefs: MutableSet[Xref] = set()
exclude_xrefs: MutableSet[Xref] = set()
pageno_for_xref = {}
@@ -289,8 +294,7 @@ def extract_images(
def extract_images_generic(
pike: Pdf, root: Path, options
) -> tuple[list[Xref], list[Xref]]:
"""Extract any >=2bpp image we think we can improve"""
"""Extract any >=2bpp image we think we can improve."""
jpegs = []
pngs = []
for _, xref_ext in extract_images(pike, root, options, extract_image_generic):
@@ -304,8 +308,7 @@ def extract_images_generic(
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)
for pageno, xref_ext in extract_images(pike, root, options, extract_image_jbig2):
group = pageno // options.jbig2_page_group_size
@@ -318,7 +321,7 @@ def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefE
def _produce_jbig2_images(
jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor
) -> None:
"""Produce JBIG2 images from their groups"""
"""Produce JBIG2 images from their groups."""
def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]):
for group, xref_exts in groups.items():
@@ -423,6 +426,8 @@ def _optimize_jpeg(args: tuple[Xref, Path, Path, int]) -> tuple[Xref, Path | Non
def transcode_jpegs(
pike: Pdf, jpegs: Sequence[Xref], root: Path, options, executor: Executor
) -> None:
"""Optimize JPEGs according to optimization settings."""
def jpeg_args() -> Iterator[tuple[Xref, Path, Path, int]]:
for xref in jpegs:
in_jpg = jpg_name(root, xref)
@@ -481,6 +486,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:
"""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 = []
for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg):
xref = xref_ext.xref
@@ -568,6 +579,7 @@ def transcode_pngs(
options,
executor,
) -> None:
"""Apply lossy transcoding to PNGs."""
modified: MutableSet[Xref] = set()
if options.optimize >= 2:
png_quality = (
@@ -614,6 +626,7 @@ def optimize(
save_settings,
executor: Executor = DEFAULT_EXECUTOR,
) -> Path:
"""Optimize images in a PDF file."""
options = context.options
if options.optimize == 0:
safe_symlink(input_file, output_file)
@@ -670,11 +683,12 @@ def optimize(
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 tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
class OptimizeOptions:
"""Emulate ocrmypdf's options"""
"""Emulate ocrmypdf's options."""
def __init__(
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-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
@@ -25,8 +23,8 @@ def _postscript_objdef(
alias: str,
dictionary: dict[str, str],
*,
stream_name: str = None,
stream_data: bytes = None,
stream_name: str | None = None,
stream_data: bytes | None = None,
) -> Iterator[str]:
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'):
"""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
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
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.
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
do full PDF/A validation.
"""
with pikepdf.open(filename) as pdf:
pdfmeta = pdf.open_metadata()
if not pdfmeta.pdfa_status:
+2
View File
@@ -7,3 +7,5 @@
from __future__ import annotations
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo
__all__ = ["Colorspace", "Encoding", "PageInfo", "PdfInfo"]
+79 -23
View File
@@ -164,7 +164,7 @@ class TextMarker:
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:
operator = str(operator)
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
@@ -200,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
CTM unchanged.
"""
stack = []
ctm = PdfMatrix(initial_shorthand)
xobject_settings: list[XobjectSettings] = []
@@ -307,7 +306,6 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
/MediaBox.
"""
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
# Calculate the width and height of the image in PDF units
@@ -324,7 +322,12 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
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')
@@ -339,6 +342,7 @@ class ImageInfo:
inline: PdfInlineImage | None = None,
shorthand=None,
):
"""Initialize an ImageInfo."""
self._name = str(name)
self._shorthand = shorthand
@@ -405,45 +409,62 @@ class ImageInfo:
@property
def name(self):
"""Name of the image as it appears in the PDF."""
return self._name
@property
def type_(self):
"""Type of image, either 'image' or 'stencil'."""
return self._type
@property
def width(self):
"""Width of the image in pixels."""
return self._width
@property
def height(self):
"""Height of the image in pixels."""
return self._height
@property
def bpc(self):
"""Bits per component."""
return self._bpc
@property
def color(self):
"""Colorspace of the image."""
return self._color if self._color is not None else '?'
@property
def comp(self):
"""Number of components/channels in the image."""
return self._comp if self._comp is not None else '?'
@property
def enc(self):
"""Encoding of the image."""
return self._enc if self._enc is not None else 'image'
@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
@property
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))
def __repr__(self):
"""Return a string representation of the image."""
return (
f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} "
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
@@ -451,8 +472,7 @@ class 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):
yield ImageInfo(
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
@@ -460,7 +480,7 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
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
that contains images. Filter out the Form XObjects which are dealt with
@@ -471,7 +491,6 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
since the object does not know its own name.
"""
if '/Resources' not in container:
return
resources = container['/Resources']
@@ -488,14 +507,13 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
def _find_regular_images(
container: Object, contentsinfo: ContentsInfo
) -> 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
that contains images.
Generates images with their DPI at time of drawing.
"""
for pdfimage, xobj in _image_xobjects(container):
if xobj not in contentsinfo.name_index:
continue
@@ -512,7 +530,7 @@ def _find_regular_images(
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.
@@ -525,7 +543,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
xobjs = resources['/XObject'].as_dict()
for xobj in xobjs:
candidate = xobjs[xobj]
if candidate['/Subtype'] != '/Form':
if candidate is None or candidate['/Subtype'] != '/Form':
continue
form_xobject = candidate
@@ -546,7 +564,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
def _process_content_streams(
*, pdf: Pdf, container: Object, shorthand=None
) -> 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.
@@ -563,7 +581,6 @@ def _process_content_streams(
downsampling.
"""
if container.get('/Type') == '/Page' and '/Contents' in container:
initial_shorthand = shorthand or UNIT_SQUARE
elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form':
@@ -595,8 +612,7 @@ def _process_content_streams(
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
margin_ratio = 0.125
@@ -608,10 +624,11 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
)
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)
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]
@@ -624,7 +641,7 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
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.
"""
@@ -740,6 +757,7 @@ class PageInfo:
check_pages: Container[int],
detailed_analysis: bool = False,
):
"""Initialize a PageInfo object."""
self._pageno = pageno
self._infile = infile
self._detailed_analysis = detailed_analysis
@@ -816,40 +834,56 @@ class PageInfo:
@property
def pageno(self) -> int:
"""Return page number (0-based)."""
return self._pageno
@property
def has_text(self) -> bool:
"""Return True if page has text, False if not or unknown."""
return bool(self._has_text)
@property
def has_corrupt_text(self) -> bool:
"""Return True if page has corrupt text, False if not or unknown."""
if not self._detailed_analysis:
raise NotImplementedError('Did not do detailed analysis')
return any(tbox.is_corrupt for tbox in self._textboxes)
@property
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)
@property
def width_inches(self) -> Decimal:
"""Return width of page in inches."""
return self._width_inches
@property
def height_inches(self) -> Decimal:
"""Return height of page in inches."""
return self._height_inches
@property
def width_pixels(self) -> int:
"""Return width of page in pixels."""
return int(round(float(self.width_inches) * self.dpi.x))
@property
def height_pixels(self) -> int:
"""Return height of page in pixels."""
return int(round(float(self.height_inches) * self.dpi.y))
@property
def rotation(self) -> int:
"""Return rotation of page in degrees.
Will only be a multiple of 90.
"""
return self._rotate
@rotation.setter
@@ -860,10 +894,13 @@ class PageInfo:
raise ValueError("rotation must be a cardinal angle")
@property
def images(self):
def images(self) -> list[ImageInfo]:
"""Return images."""
return self._images
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):
result = True
if want_visible is not None:
@@ -883,22 +920,26 @@ class PageInfo:
@property
def dpi(self) -> Resolution:
"""Return DPI needed to render all images on the page."""
if self._dpi is None:
return Resolution(0.0, 0.0)
return self._dpi
@property
def userunit(self) -> Decimal:
"""Return user unit of page."""
return self._userunit
@property
def min_version(self) -> str:
"""Return minimum PDF version needed to render this page."""
if self.userunit is not None:
return '1.6'
else:
return '1.5'
def __repr__(self):
"""Return string representation."""
return (
f'<PageInfo '
f'pageno={self.pageno} {self.width_inches}"x{self.height_inches}" '
@@ -910,7 +951,7 @@ DEFAULT_EXECUTOR = SerialExecutor()
class PdfInfo:
"""Get summary information about a PDF"""
"""Get summary information about a PDF."""
def __init__(
self,
@@ -918,10 +959,11 @@ class PdfInfo:
*,
detailed_analysis: bool = False,
progbar: bool = False,
max_workers: int = None,
max_workers: int | None = None,
check_pages=None,
executor: Executor = DEFAULT_EXECUTOR,
):
"""Initialize."""
self._infile = infile
if check_pages is None:
check_pages = range(0, 1_000_000_000)
@@ -948,42 +990,56 @@ class PdfInfo:
@property
def pages(self) -> Sequence[PageInfo | None]:
"""Return list of PageInfo objects, one per page in the PDF."""
return self._pages
@property
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
return max(page.min_version for page in self.pages if page)
@property
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)
@property
def has_acroform(self) -> bool:
"""Return True if any page has an AcroForm."""
return self._has_acroform
@property
def filename(self) -> str | Path:
"""Return filename of PDF."""
if not isinstance(self._infile, (str, Path)):
raise NotImplementedError("can't get filename from stream")
return self._infile
@property
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
def __getitem__(self, item) -> PageInfo:
"""Return PageInfo object for page number `item`."""
return self._pages[item]
def __len__(self):
"""Return number of pages in PDF."""
return len(self._pages)
def __repr__(self):
"""Return string representation."""
return f"<PdfInfo('...'), page count={len(self)}>"
def main():
"""Run as a script."""
import argparse # pylint: disable=import-outside-toplevel
from pprint import pprint # pylint: disable=import-outside-toplevel
+41 -5
View File
@@ -29,12 +29,16 @@ original_pdfsimplefont_init = PDFSimpleFont.__init__
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
# built-in encoding or a dictionary that describes
# the differences.
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:
self.cid2unicode = {}
return
@@ -48,6 +52,13 @@ PDFSimpleFont.__init__ = pdfsimplefont__init__
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]
if h == 0:
h = self.ascent - self.descent
@@ -55,15 +66,27 @@ def pdftype3font__pscript5_get_height(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)
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)
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__ = (
'rendermode',
@@ -96,6 +119,7 @@ class LTStateAwareChar(LTChar):
graphicstate,
textstate,
):
"""Initialize."""
super().__init__(
matrix,
font,
@@ -111,7 +135,7 @@ class LTStateAwareChar(LTChar):
self.rendermode = textstate.render
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:
- the Unicode mapping is known, and both have the same render mode
@@ -129,11 +153,13 @@ class LTStateAwareChar(LTChar):
return False
def get_text(self):
"""Get text from this character."""
if isinstance(self._text, tuple):
return '\ufffd' # standard 'Unknown symbol'
return self._text
def __repr__(self):
"""Return a string representation of this object."""
return (
f"<{self.__class__.__name__} "
f"{bbox2str(self.bbox)} "
@@ -146,19 +172,22 @@ class LTStateAwareChar(LTChar):
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):
"""Initialize the layout analyzer."""
super().__init__(rsrcmgr, pageno, laparams)
self.textstate = None
self.result = None
self.cur_item = None # not defined in pdfminer code as it should be
def begin_page(self, page, ctm):
"""Begin processing of a page."""
super().begin_page(page, ctm)
self.cur_item = LTPage(self.pageno, page.mediabox)
def end_page(self, page):
"""End processing of a page."""
assert not self._stack, str(len(self._stack))
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
if self.laparams is not None:
@@ -167,12 +196,14 @@ class TextPositionTracker(PDFLayoutAnalyzer):
self.receive_layout(self.cur_item)
def render_string(self, textstate, seq, ncs, graphicstate):
"""Respond to render string event by updating text state."""
self.textstate = textstate.copy()
super().render_string(self.textstate, seq, ncs, graphicstate)
def render_char(
self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate
):
"""Respond to render char event by updating text state."""
try:
text = font.to_unichr(cid)
assert isinstance(text, str), str(type(text))
@@ -197,17 +228,21 @@ class TextPositionTracker(PDFLayoutAnalyzer):
return item.adv
def handle_undefined_char(self, font, cid):
"""Handle undefined character."""
# log.info('undefined: %r, %r', font, cid)
return (font.fontname, cid)
def receive_layout(self, ltpage):
"""Receive layout handler."""
self.result = ltpage
def get_result(self):
"""Get the result of the analysis."""
return self.result
def get_page_analysis(infile, pageno, pscript5_mode):
"""Get the page analysis for a given page."""
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
disable_boxes_flow = None
dev = TextPositionTracker(
@@ -248,6 +283,7 @@ def get_page_analysis(infile, pageno, pscript5_mode):
def get_text_boxes(obj):
"""Get the text boxes attached to the current node."""
for child in obj:
if isinstance(child, (LTTextBox)):
yield child
+40 -15
View File
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
hookspec = pluggy.HookspecMarker('ocrmypdf')
# pylint: disable=unused-argument
# mypy: disable-error-code=empty-body
@hookspec(firstresult=True)
@@ -43,7 +44,7 @@ def get_logging_console() -> Handler:
@hookspec
def initialize(plugin_manager: pluggy.PluginManager):
def initialize(plugin_manager: pluggy.PluginManager) -> None:
"""Called when this plugin is first loaded into OCRmyPDF.
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
and the application should terminate gracefully with an informative
message and error code.
Note:
This hook will be called from the main process, and may modify global state
before child worker processes are forked.
@@ -127,6 +130,8 @@ def get_executor(progressbar_class) -> Executor:
Note:
This hook will be called from the main process, and may modify global state
before child worker processes are forked.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@@ -159,7 +164,6 @@ def get_progressbar_class():
Here is how OCRmyPDF will use the progress bar:
Example:
pbar_class = pm.hook.get_progressbar_class()
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
and the application should terminate gracefully with an informative
message and error code.
Note:
This hook will be called from the main process, and may modify global state
before child worker processes are forked.
@@ -218,6 +224,8 @@ def rasterize_pdf_page(
Note:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
Note:
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.
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
they are not shown to OCR. The main use of this hook is expected to be hiding
content from OCR.
PDF. In certain modes such as ``--redo-ocr``, portions of the image may be
masked out to hide them 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
output image may differ. The pixel width and height of the
output image must be identical to the input image, or misalignment between
the OCR text layer and visual position of the text will occur. Likewise,
the output must be a faithful representation of the input, or alignment
errors may occurs.
output image may differ. For example, if you know that a custom OCR engine
does not care about the color of the text, you could convert the image to
it to grayscale or monochrome.
Tesseract OCR only deals with monochrome images, and internally converts
non-monochrome images to OCR.
Generally speaking, the output image should be a faithful representation of
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:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
Note:
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.
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
kept. Note that the OCRmyPDF image optimization stage, if enabled, may
ultimately chose a different format.
@@ -281,6 +298,8 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
Note:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@@ -323,6 +342,8 @@ def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -
Note:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@@ -381,7 +402,8 @@ class OcrEngine(ABC):
"""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
can be any value understood by the OCR engine."""
can be any value understood by the OCR engine.
"""
@staticmethod
@abstractmethod
@@ -413,6 +435,9 @@ class OcrEngine(ABC):
a single page PDF with no visible content of any kind, sized
to the dimensions implied by the input_file's width, height
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.
"""
@@ -474,7 +499,7 @@ def generate_pdfa(
Note:
This is a :ref:`firstresult hook<firstresult>`.
See also:
See Also:
https://github.com/tqdm/tqdm
"""
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Utilities to measure OCR quality"""
"""Utilities to measure OCR quality."""
from __future__ import annotations
+10 -6
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Wrappers to manage subprocess calls"""
"""Wrappers to manage subprocess calls."""
from __future__ import annotations
@@ -34,20 +34,25 @@ def run(
check: bool = False,
**kwargs,
) -> 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
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
locations of our dependencies when they are not on the system PATH.
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
messages to stdout rather than stderr, so stdout should be logged
if there is an error. If False, stderr is logged. Could be used with
stderr=STDOUT, stdout=PIPE for example.
check: If True, raise an exception if the process exits with a non-zero
status code. If False, the return value will indicate success or failure.
kwargs: Additional arguments to pass to ``subprocess.run``.
"""
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
@@ -141,7 +146,7 @@ def get_version(
regex=r'(\d+(\.\d+)*)',
env: OsEnviron | None = None,
) -> str:
"""Get the version of the specified program
"""Get the version of the specified program.
Arguments:
program: The program to version check.
@@ -323,7 +328,6 @@ def check_external_program(
version_parser: A class that should be used to parse and compare version
numbers. Used when version numbers do not follow standard conventions.
"""
try:
found_version = version_checker()
except (CalledProcessError, FileNotFoundError) as e:
+3 -4
View File
@@ -32,7 +32,7 @@ else:
spec=['HKEYType', 'EnumKey', 'EnumValue', 'HKEY_LOCAL_MACHINE', 'OpenKey']
)
# 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__)
@@ -169,8 +169,7 @@ SHIMS = [
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 we are running a .py on Windows, ensure we call it with this Python
# (to support test suite shims)
if program.lower().endswith('.py'):
@@ -188,7 +187,7 @@ def fix_windows_args(program: str, args, env):
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('ABBCcAD', str.lower) --> A B C D
seen: set[Tkey] = set()
+2
View File
@@ -1,4 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Tests."""
from __future__ import annotations
+6 -7
View File
@@ -3,12 +3,10 @@
from __future__ import annotations
import os
import platform
import sys
from pathlib import Path
from subprocess import PIPE, CompletedProcess, run
from typing import List
from subprocess import CompletedProcess, run
import pytest
@@ -71,10 +69,13 @@ def outtxt(tmp_path) -> Path:
@pytest.fixture(scope="function")
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
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'
@@ -110,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
to be pass.
"""
api_args = [str(input_file), str(output_file)] + [
str(arg) for arg in args if arg is not None
]
@@ -128,7 +128,6 @@ def run_ocrmypdf(
If an exception is thrown this fact will be returned as part of the result
text and return code rather than exception objects.
"""
p_args = (
[sys.executable, '-m', 'ocrmypdf']
+ [str(arg) for arg in args if arg is not None]
+1 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT
"""Tesseract bad utf8
"""Tesseract bad utf8.
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
+1 -1
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# 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
into a hideous filename that more or less represents them literally. Joined
+5 -3
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# 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
the rotation pipeline.
@@ -28,13 +28,15 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<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>
<body>
<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}">
<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>
</p>
</div>
+5 -3
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT
"""Tesseract no-op plugin
"""Tesseract no-op plugin.
To quickly run tests where getting OCR output is not necessary.
@@ -26,13 +26,15 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<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>
<body>
<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}">
<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>
</p>
</div>
-2
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import pytest
from ocrmypdf.helpers import check_pdf
+1 -1
View File
@@ -4,7 +4,7 @@
from __future__ import annotations
import os
from subprocess import PIPE, run
from subprocess import run
import pytest
-1
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
from unittest.mock import patch
import pikepdf
import pytest
import ocrmypdf
+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 pytest
from ocrmypdf._sync import configure_debug_logging
+1 -1
View File
@@ -446,7 +446,7 @@ def test_linearized_pdf_and_indirect_object(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(
resources / '2400dpi.pdf',
outpdf,
+40 -62
View File
@@ -22,16 +22,9 @@ from ocrmypdf.pdfinfo import PdfInfo
from .conftest import check_ocrmypdf, run_ocrmypdf
try:
import fitz
except ImportError:
fitz = None
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
def test_preserve_docinfo(output_type, resources, outpdf):
pdf_before = pikepdf.open(resources / 'graph.pdf')
output = check_ocrmypdf(
resources / 'graph.pdf',
outpdf,
@@ -40,14 +33,13 @@ def test_preserve_docinfo(output_type, resources, outpdf):
'--plugin',
'tests/plugins/tesseract_noop.py',
)
pdf_after = pikepdf.open(output)
for key in ('/Title', '/Author'):
assert pdf_before.docinfo[key] == pdf_after.docinfo[key]
pdfa_info = file_claims_pdfa(str(output))
assert pdfa_info['output'] == output_type
with pikepdf.open(resources / 'graph.pdf') as pdf_before, pikepdf.open(
output
) as pdf_after:
for key in ('/Title', '/Author'):
assert pdf_before.docinfo[key] == pdf_after.docinfo[key]
pdfa_info = file_claims_pdfa(str(output))
assert pdfa_info['output'] == output_type
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
@@ -71,23 +63,20 @@ def test_override_metadata(output_type, resources, outpdf):
assert p.returncode == ExitCode.ok, p.stderr
before = pikepdf.open(input_file)
after = pikepdf.open(outpdf)
with pikepdf.open(input_file) as before, pikepdf.open(outpdf) as after:
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
assert after.docinfo.Author == chinese, after.docinfo
assert after.docinfo.get('/Keywords', '') == ''
before_date = decode_pdf_date(str(before.docinfo.CreationDate))
after_date = decode_pdf_date(str(after.docinfo.CreationDate))
assert before_date == after_date
before_date = decode_pdf_date(str(before.docinfo.CreationDate))
after_date = decode_pdf_date(str(after.docinfo.CreationDate))
assert before_date == after_date
pdfa_info = file_claims_pdfa(outpdf)
assert pdfa_info['output'] == output_type
pdfa_info = file_claims_pdfa(outpdf)
assert pdfa_info['output'] == output_type
def test_high_unicode(resources, no_outpdf):
# Ghostscript doesn't support high Unicode, so neither do we, to be
# safe
input_file = resources / 'c02-22.pdf'
@@ -107,10 +96,10 @@ def test_high_unicode(resources, no_outpdf):
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('output_type', ['pdf', 'pdfa'])
def test_bookmarks_preserved(output_type, ocr_option, resources, outpdf):
fitz = pytest.importorskip('fitz')
input_file = resources / 'toc.pdf'
before_toc = fitz.Document(str(input_file)).get_toc()
@@ -148,23 +137,24 @@ def test_creation_date_preserved(output_type, resources, infile, outpdf):
'tests/plugins/tesseract_noop.py',
)
pdf_before = pikepdf.open(input_file)
pdf_after = pikepdf.open(outpdf)
with pikepdf.open(input_file) as pdf_before, pikepdf.open(outpdf) as pdf_after:
before = pdf_before.trailer.get('/Info', {})
after = pdf_after.trailer.get('/Info', {})
before = pdf_before.trailer.get('/Info', {})
after = pdf_after.trailer.get('/Info', {})
if not before:
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:
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
# 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
# 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
@@ -231,17 +221,6 @@ def test_xml_metadata_preserved(
'pdf:keywords',
]
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
# Top level is key-value mapping of namespaces to keys under namespace,
@@ -292,10 +271,9 @@ def test_kodak_toc(resources, outpdf):
'tests/plugins/tesseract_noop.py',
)
p = pikepdf.open(outpdf)
if pikepdf.Name.First in p.Root.Outlines:
assert isinstance(p.Root.Outlines.First, pikepdf.Dictionary)
with pikepdf.open(outpdf) as p:
if pikepdf.Name.First in p.Root.Outlines:
assert isinstance(p.Root.Outlines.First, pikepdf.Dictionary)
def test_metadata_fixup_warning(resources, outdir, caplog):
@@ -313,10 +291,10 @@ def test_metadata_fixup_warning(resources, outdir, caplog):
assert record.levelname != 'WARNING', "Unexpected warning"
# Now add some metadata that will not be copyable
graph = pikepdf.open(outdir / 'graph.pdf')
with graph.open_metadata() as meta:
meta['prism2:publicationName'] = 'OCRmyPDF Test'
graph.save(outdir / 'graph_mod.pdf')
with pikepdf.open(outdir / 'graph.pdf') as graph:
with graph.open_metadata() as meta:
meta['prism2:publicationName'] = 'OCRmyPDF Test'
graph.save(outdir / 'graph_mod.pdf')
context = PdfContext(
options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([])
+10 -10
View File
@@ -100,14 +100,14 @@ def test_jbig2_lossy(lossy, resources, outpdf):
check_ocrmypdf(*args)
pdf = pikepdf.open(outpdf)
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
assert pim.filters[0] == '/JBIG2Decode'
with pikepdf.open(outpdf) as pdf:
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
assert pim.filters[0] == '/JBIG2Decode'
if lossy:
assert '/JBIG2Globals' in pim.decode_parms[0]
else:
assert len(pim.decode_parms) == 0
if lossy:
assert '/JBIG2Globals' in pim.decode_parms[0]
else:
assert len(pim.decode_parms) == 0
@needs_pngquant
@@ -134,9 +134,9 @@ def test_flate_to_jbig2(resources, outdir):
'tests/plugins/tesseract_noop.py',
)
pdf = pikepdf.open(outdir / 'out.pdf')
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
assert pim.filters[0] == '/JBIG2Decode'
with pikepdf.open(outdir / 'out.pdf') as pdf:
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
assert pim.filters[0] == '/JBIG2Decode'
@needs_pngquant
-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):
c = Canvas(str(outdir / 'dpi.pdf'), pagesize=(5 * inch, 5 * inch))
if image:
c.drawImage(rgb_image, 1 * inch, 1 * inch, width=1 * inch, height=1 * inch)
-2
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import pytest
from ocrmypdf import quality as qual
+9 -9
View File
@@ -153,13 +153,14 @@ def test_autorotate_threshold(threshold, op, comparison_threshold, resources, ou
def test_rotated_skew_timeout(resources, outpdf):
"""This document contains an image that is rotated 90 into place with a
"""Check rotated skew timeout.
This document contains an image that is rotated 90 into place with a
/Rotate tag and intentionally skewed by altering the transformation matrix.
This tests for a bug where the combination of preprocessing and a tesseract
timeout produced a page whose dimensions did not match the original's.
"""
input_file = resources / 'rotated_skew.pdf'
in_pageinfo = PdfInfo(input_file)[0]
@@ -190,8 +191,7 @@ def test_rotated_skew_timeout(resources, outpdf):
), "Expected page rotation to be baked in"
@pytest.mark.xfail(reason="tesseract timeout blocks tesseract based deskew")
def test_rotate_deskew_timeout(resources, outdir):
def test_rotate_deskew_ocr_timeout(resources, outdir):
check_ocrmypdf(
resources / 'rotated_skew.pdf',
outdir / 'deskewed.pdf',
@@ -237,11 +237,11 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
**IMG2PDF_KWARGS,
)
mempdf.seek(0)
pike = pikepdf.open(mempdf)
pike.pages[0].Rotate = page_angle
target = outdir / f'{prefix}_{image_angle}_{page_angle}.pdf'
pike.save(target)
return target
with pikepdf.open(mempdf) as pdf:
pdf.pages[0].Rotate = page_angle
target = outdir / f'{prefix}_{image_angle}_{page_angle}.pdf'
pdf.save(target)
return target
reference = make_rotate_test('ref', 0, 0)
test = make_rotate_test('test', image_angle, page_angle)
+1 -4
View File
@@ -4,13 +4,10 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from subprocess import DEVNULL, PIPE, Popen, run
from subprocess import DEVNULL, PIPE, run
import pytest
from ocrmypdf.exceptions import ExitCode
from ocrmypdf.helpers import check_pdf
from .conftest import run_ocrmypdf
+1 -2
View File
@@ -8,14 +8,13 @@ from os import fspath
from unittest.mock import patch
import pytest
from PIL import Image
from ocrmypdf._exec import unpaper
from ocrmypdf._plugin_manager import get_parser_options_plugins
from ocrmypdf._validation import check_options
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
from .conftest import check_ocrmypdf, have_unpaper, ocrmypdf_exec, run_ocrmypdf
from .conftest import check_ocrmypdf, have_unpaper, run_ocrmypdf
# pylint: disable=redefined-outer-name
+3 -8
View File
@@ -20,18 +20,13 @@ def poster(resources):
return resources / 'poster.pdf'
def test_userunit_ghostscript_fails(poster, no_outpdf, caplog):
result = run_ocrmypdf_api(poster, no_outpdf, '--output-type=pdfa')
assert result == ExitCode.input_file
assert 'not supported by Ghostscript' in caplog.text
def test_userunit_pdf_passes(poster, outpdf):
@pytest.mark.parametrize("mode", ['pdf', 'pdfa'])
def test_userunit_pdf_passes(mode, poster, outpdf):
before = PdfInfo(poster)
check_ocrmypdf(
poster,
outpdf,
'--output-type=pdf',
f'--output-type={mode}',
'--plugin',
'tests/plugins/tesseract_cache.py',
)
+1 -1
View File
@@ -219,7 +219,7 @@ def test_version_comparison():
vd.check_external_program(
program="tesseract",
package="tesseract",
version_checker=lambda: 'v4.1.1.20181030', # Some Windows builds use this format
version_checker=lambda: 'v4.1.1.20181030', # Used in some Windows builds
need_version='4.1.1',
version_parser=TesseractVersion,
)