Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de909fb99a | ||
|
|
731b2fc477 | ||
|
|
214f6ec759 | ||
|
|
080aa4dbd1 | ||
|
|
7af5dcd4a4 | ||
|
|
fe9f52fbe7 | ||
|
|
fcbdeb8dbe | ||
|
|
cb251a8d03 | ||
|
|
3731fdfd72 | ||
|
|
b2e6a6431e | ||
|
|
9ff1e56bf6 | ||
|
|
2b30f74fce | ||
|
|
10f4c48e0b | ||
|
|
37d5c086bb | ||
|
|
91830627e5 | ||
|
|
f2fc37b257 | ||
|
|
a99e40fa84 | ||
|
|
9ce692a6f1 | ||
|
|
a3c49b8f31 | ||
|
|
33b70be7d5 | ||
|
|
4924b11b6b | ||
|
|
1d0e4e7c9f | ||
|
|
9b8d14d16e | ||
|
|
b7eb93eb79 | ||
|
|
42c0d0f48f | ||
|
|
5fce50ff7c | ||
|
|
765ed4c386 | ||
|
|
1b2849ec0a | ||
|
|
4f604591b4 | ||
|
|
01dc8e23ff | ||
|
|
b432770cfc | ||
|
|
5502fb8d9f | ||
|
|
e66922b030 | ||
|
|
00e9759b16 | ||
|
|
ba10c5345b | ||
|
|
8a5f94988a | ||
|
|
aa73e3c69f |
+3
-1
@@ -25,7 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libffi-dev \
|
libffi-dev \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
git
|
git \
|
||||||
|
libcairo2-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
# Get the latest pip (Ubuntu version doesn't support manylinux2010)
|
# Get the latest pip (Ubuntu version doesn't support manylinux2010)
|
||||||
RUN \
|
RUN \
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
.venv*/
|
.venv*/
|
||||||
.tox/
|
.tox/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.hypothesis/
|
||||||
.ipynb_checkpoints/
|
.ipynb_checkpoints/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
+8
-16
@@ -3,34 +3,26 @@
|
|||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.3.0
|
rev: v4.4.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: debug-statements
|
- id: debug-statements
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
rev: 5.10.1
|
rev: "v0.0.261"
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: ruff
|
||||||
args: ["--profile", "black", "-a", "from __future__ import annotations"]
|
files: "src/.*\\.pyi?$"
|
||||||
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.6.0
|
rev: 23.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python
|
language_version: python
|
||||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
|
||||||
rev: v1.20.2
|
|
||||||
hooks:
|
|
||||||
- id: setup-cfg-fmt
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
|
||||||
rev: v2.37.2
|
|
||||||
hooks:
|
|
||||||
- id: pyupgrade
|
|
||||||
args: ["--py38-plus"]
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v0.971
|
rev: v1.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
|
|||||||
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
||||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||||
| Conda | ``conda install ocrmypdf`` |
|
| Conda | ``conda install ocrmypdf`` |
|
||||||
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
In addition to the required Python version (3.7+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
In addition to the required Python version (3.8+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
||||||
|
|
||||||
## Press & Media
|
## Press & Media
|
||||||
|
|
||||||
@@ -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)
|
- [heise Durchsuchbare PDF-Dokumente mit OCRmyPDF erstellen](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
|
||||||
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
|
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
|
||||||
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
|
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
|
||||||
|
- [Y Combinator discussion](https://news.ycombinator.com/item?id=32028752)
|
||||||
|
|
||||||
## Business enquiries
|
## Business enquiries
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ Users may need to customize the script to meet their requirements.
|
|||||||
"OCR_OUTPUT_DIRECTORY", "Set output directory (should not be under input)"
|
"OCR_OUTPUT_DIRECTORY", "Set output directory (should not be under input)"
|
||||||
"OCR_ARCHIVE_DIRECTORY", "Set archive directory for processed originals (should not be under input, requires ``OCR_ON_SUCCESS_ARCHIVE`` to be set)"
|
"OCR_ARCHIVE_DIRECTORY", "Set archive directory for processed originals (should not be under input, requires ``OCR_ON_SUCCESS_ARCHIVE`` to be set)"
|
||||||
"OCR_ON_SUCCESS_DELETE", "This will delete the input file if the exit code is 0 (OK)"
|
"OCR_ON_SUCCESS_DELETE", "This will delete the input file if the exit code is 0 (OK)"
|
||||||
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed orignal file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
||||||
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
||||||
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
||||||
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true}'``."
|
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true}'``."
|
||||||
|
|||||||
+2
-3
@@ -2,6 +2,8 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: CC-BY-SA-4.0
|
# SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
|
|
||||||
# ocrmypdf documentation build configuration file, created by
|
# ocrmypdf documentation build configuration file, created by
|
||||||
# sphinx-quickstart on Sun Sep 4 14:29:43 2016.
|
# sphinx-quickstart on Sun Sep 4 14:29:43 2016.
|
||||||
#
|
#
|
||||||
@@ -22,8 +24,6 @@
|
|||||||
# import sys
|
# import sys
|
||||||
# sys.path.insert(0, os.path.abspath('.'))
|
# sys.path.insert(0, os.path.abspath('.'))
|
||||||
|
|
||||||
"""isort:skip_file"""
|
|
||||||
|
|
||||||
# -- General configuration ------------------------------------------------
|
# -- General configuration ------------------------------------------------
|
||||||
|
|
||||||
# If your documentation needs a minimal Sphinx version, state it here.
|
# If your documentation needs a minimal Sphinx version, state it here.
|
||||||
@@ -78,7 +78,6 @@ author = 'James R. Barlow'
|
|||||||
import os
|
import os
|
||||||
from importlib.metadata import version as package_version
|
from importlib.metadata import version as package_version
|
||||||
|
|
||||||
|
|
||||||
on_rtd = os.environ.get('READTHEDOCS') == 'True'
|
on_rtd = os.environ.get('READTHEDOCS') == 'True'
|
||||||
|
|
||||||
if on_rtd:
|
if on_rtd:
|
||||||
|
|||||||
+1
-1
@@ -360,7 +360,7 @@ The types of optimization available may expand over time. By default,
|
|||||||
OCRmyPDF compresses data streams inside PDFs, and will change
|
OCRmyPDF compresses data streams inside PDFs, and will change
|
||||||
inefficient compression modes to more modern versions. A program like
|
inefficient compression modes to more modern versions. A program like
|
||||||
``qpdf`` can be used to change encodings, e.g. to inspect the internals
|
``qpdf`` can be used to change encodings, e.g. to inspect the internals
|
||||||
fo a PDF.
|
for a PDF.
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ programs being installed.
|
|||||||
``apt install python3-pip python3-venv``, create a virtual environment,
|
``apt install python3-pip python3-venv``, create a virtual environment,
|
||||||
and install OCRmyPDF in that environment.
|
and install OCRmyPDF in that environment.
|
||||||
|
|
||||||
`See here for more inforation on Debian-Python issues
|
`See here for more information on Debian-Python issues
|
||||||
<https://gist.github.com/tiran/2dec9e03c6f901814f6d1e8dad09528e>`__.
|
<https://gist.github.com/tiran/2dec9e03c6f901814f6d1e8dad09528e>`__.
|
||||||
|
|
||||||
For best results, first install `your platform's
|
For best results, first install `your platform's
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ Ghostscript also imposes some limitations:
|
|||||||
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
||||||
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
||||||
one of the standard XMP metadata namespaces for PDFs. In particular,
|
one of the standard XMP metadata namespaces for PDFs. In particular,
|
||||||
PRISM Metdata is removed.
|
PRISM Metadata is removed.
|
||||||
- Ghostscript's PDF/A conversion seems to remove or deactivate
|
- Ghostscript's PDF/A conversion seems to remove or deactivate
|
||||||
hyperlinks and other active content.
|
hyperlinks and other active content.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ This enables these languages for all packages (e.g. including aspell).
|
|||||||
# Add English and German language support for Tesseract only
|
# Add English and German language support for Tesseract only
|
||||||
echo 'app-text/tessdata_fast l10n_de l10n_en' >> /etc/portage/package.use
|
echo 'app-text/tessdata_fast l10n_de l10n_en' >> /etc/portage/package.use
|
||||||
|
|
||||||
# Add global English and German language support (the `l10n_` from equery has to be omited)
|
# Add global English and German language support (the `l10n_` from equery has to be omitted)
|
||||||
echo L10N="de en" >> /etc/portage/make.conf
|
echo L10N="de en" >> /etc/portage/make.conf
|
||||||
|
|
||||||
# update system to reflect changed USE flags
|
# update system to reflect changed USE flags
|
||||||
|
|||||||
+31
-1
@@ -28,6 +28,36 @@ tagged yet.
|
|||||||
|
|
||||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
v14.2.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
|
v14.0.3
|
||||||
=======
|
=======
|
||||||
|
|
||||||
@@ -717,7 +747,7 @@ v10.3.2
|
|||||||
v10.3.1
|
v10.3.1
|
||||||
=======
|
=======
|
||||||
|
|
||||||
- Fixed a number of test suite failures with pdfminer.six older than veresion 20200402.
|
- Fixed a number of test suite failures with pdfminer.six older than version 20200402.
|
||||||
- Enabled support for pdfminer.six 20200720.
|
- Enabled support for pdfminer.six 20200720.
|
||||||
|
|
||||||
v10.3.0
|
v10.3.0
|
||||||
|
|||||||
+9
-2
@@ -2,11 +2,18 @@
|
|||||||
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
|
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Example of using ocrmypdf as a library in a script.
|
||||||
|
|
||||||
|
This script will recursively search a directory for PDF files and run OCR on
|
||||||
|
them. It will log the results. It runs OCR on every file, even if it already
|
||||||
|
has text. OCRmyPDF will detect files that already have text.
|
||||||
|
|
||||||
|
You should edit this script to meet your needs.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# This script must be edited to meet your needs.
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R Barlow: https://github.com/jbarlow83
|
# SPDX-FileCopyrightText: 2022 James R Barlow: https://github.com/jbarlow83
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""
|
"""An example of an OCRmyPDF plugin.
|
||||||
An example of an OCRmyPDF plugin.
|
|
||||||
|
|
||||||
This plugin adds two new command line arguments
|
This plugin adds two new command line arguments
|
||||||
--grayscale-ocr: converts the image to grayscale before performing OCR on it
|
--grayscale-ocr: converts the image to grayscale before performing OCR on it
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2017 Enantiomerie
|
# SPDX-FileCopyrightText: 2017 Enantiomerie
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Example OCRmyPDF for Synology NAS"""
|
"""Example OCRmyPDF for Synology NAS."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# SPDX-FileCopyrightText: 2020 James R Barlow <https://github.com/jbarlow83>
|
# SPDX-FileCopyrightText: 2020 James R Barlow <https://github.com/jbarlow83>
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Watch a directory for new PDFs and OCR them."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2019 James R. Barlow
|
# SPDX-FileCopyrightText: 2019 James R. Barlow
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
"""This is a simple web service/HTTP wrapper for OCRmyPDF
|
"""This is a simple web service/HTTP wrapper for OCRmyPDF.
|
||||||
|
|
||||||
This may be more convenient than the command line tool for some Docker users.
|
This may be more convenient than the command line tool for some Docker users.
|
||||||
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from subprocess import PIPE, run
|
from subprocess import run
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from flask import Flask, Response, request, send_from_directory
|
from flask import Flask, Response, request, send_from_directory
|
||||||
@@ -48,7 +48,7 @@ def do_ocrmypdf(file):
|
|||||||
return Response("--sidecar not supported", 501, mimetype='text/plain')
|
return Response("--sidecar not supported", 501, mimetype='text/plain')
|
||||||
|
|
||||||
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
|
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
|
||||||
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8")
|
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8", check=False)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr = proc.stderr
|
stderr = proc.stderr
|
||||||
return Response(stderr, 400, mimetype='text/plain')
|
return Response(stderr, 400, mimetype='text/plain')
|
||||||
|
|||||||
+24
-4
@@ -69,6 +69,7 @@ docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
|
|||||||
extended_test = ["PyMuPDF==1.19.1"]
|
extended_test = ["PyMuPDF==1.19.1"]
|
||||||
test = [
|
test = [
|
||||||
"coverage[toml]>=5",
|
"coverage[toml]>=5",
|
||||||
|
"hypothesis>=6.0.0",
|
||||||
"pytest>=6.0.0",
|
"pytest>=6.0.0",
|
||||||
"pytest-cov>=2.11.1",
|
"pytest-cov>=2.11.1",
|
||||||
"pytest-xdist>=2.2.0",
|
"pytest-xdist>=2.2.0",
|
||||||
@@ -184,7 +185,26 @@ module = [
|
|||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pylint.basic]
|
[tool.ruff]
|
||||||
good-names = ["i", "j", "k", "ex", "Run", "_", "e", "p", "im", "w", "h", "m", "x", "y", "a", "b", "fp", "n", "f", "s", "v", "q", "dx", "dy"]
|
select = [
|
||||||
logging-format-style = "old"
|
"D", # pydocstyle
|
||||||
disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "logging-fstring-interpolation", "missing-function-docstring", "too-few-public-methods"]
|
"E", # pycodestyle
|
||||||
|
"W", # pycodestyle
|
||||||
|
"F", # pyflakes
|
||||||
|
"I001", # isort
|
||||||
|
"UP", # pyupgrade
|
||||||
|
]
|
||||||
|
target-version = "py38"
|
||||||
|
|
||||||
|
[tool.ruff.isort]
|
||||||
|
known-first-party = ["ocrmypdf"]
|
||||||
|
required-imports = ["from __future__ import annotations"]
|
||||||
|
|
||||||
|
[tool.ruff.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[tool.ruff.per-file-ignores]
|
||||||
|
"docs/conf.py" = ["D100", "D101", "D105"]
|
||||||
|
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
|
||||||
|
"misc/*.py" = ["D103", "D101", "D102"]
|
||||||
|
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
|
||||||
+12
-1
@@ -1,5 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 Alexander Langanke
|
# SPDX-FileCopyrightText: 2022 Alexander Langanke
|
||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
|
# SPDX-FileCopyrightText: 2023 林博仁(Buo-ren, Lin) <Buo.Ren.Lin@gmail.com>
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
name: ocrmypdf
|
name: ocrmypdf
|
||||||
@@ -54,7 +55,10 @@ parts:
|
|||||||
|
|
||||||
ocrmypdf:
|
ocrmypdf:
|
||||||
plugin: python
|
plugin: python
|
||||||
source: https://github.com/ocrmypdf/OCRmyPDF.git
|
source: .
|
||||||
|
|
||||||
|
build-packages:
|
||||||
|
- python3-pip
|
||||||
|
|
||||||
stage-packages:
|
stage-packages:
|
||||||
- ghostscript
|
- ghostscript
|
||||||
@@ -77,7 +81,14 @@ parts:
|
|||||||
- setuptools
|
- setuptools
|
||||||
- tqdm
|
- tqdm
|
||||||
- pipe
|
- pipe
|
||||||
|
- wheel
|
||||||
|
|
||||||
override-build: |
|
override-build: |
|
||||||
|
pip3 install --user dephell[full]
|
||||||
|
$HOME/.local/bin/dephell deps convert \
|
||||||
|
--from-path pyproject.toml \
|
||||||
|
--from-format pyproject \
|
||||||
|
--to-path setup.py \
|
||||||
|
--to-format setuppy
|
||||||
snapcraftctl build
|
snapcraftctl build
|
||||||
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
|
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
|
||||||
|
|||||||
@@ -29,3 +29,33 @@ from ocrmypdf.exceptions import (
|
|||||||
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
|
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
|
||||||
|
|
||||||
hookimpl = _HookimplMarker('ocrmypdf')
|
hookimpl = _HookimplMarker('ocrmypdf')
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'__version__',
|
||||||
|
'BadArgsError',
|
||||||
|
'configure_logging',
|
||||||
|
'DpiError',
|
||||||
|
'EncryptedPdfError',
|
||||||
|
'Executor',
|
||||||
|
'ExitCode',
|
||||||
|
'ExitCodeException',
|
||||||
|
'helpers',
|
||||||
|
'hocrtransform',
|
||||||
|
'hookimpl',
|
||||||
|
'InputFileError',
|
||||||
|
'MissingDependencyError',
|
||||||
|
'ocr',
|
||||||
|
'OcrEngine',
|
||||||
|
'OrientationConfidence',
|
||||||
|
'OutputFileAccessError',
|
||||||
|
'PageContext',
|
||||||
|
'pdfa',
|
||||||
|
'PdfContext',
|
||||||
|
'pdfinfo',
|
||||||
|
'PriorOcrFoundError',
|
||||||
|
'PROGRAM_NAME',
|
||||||
|
'SubprocessOutputError',
|
||||||
|
'TesseractConfigError',
|
||||||
|
'UnsupportedImageFormatError',
|
||||||
|
'Verbosity',
|
||||||
|
]
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from multiprocessing import set_start_method
|
|
||||||
|
|
||||||
from ocrmypdf import __version__
|
from ocrmypdf import __version__
|
||||||
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
||||||
@@ -29,10 +28,16 @@ log = logging.getLogger('ocrmypdf')
|
|||||||
|
|
||||||
|
|
||||||
def sigbus(*args):
|
def sigbus(*args):
|
||||||
|
"""Handle SIGBUS signals.
|
||||||
|
|
||||||
|
pikepdf, depending on configuration, may use mmap so SIGBUS is a
|
||||||
|
possibility.
|
||||||
|
"""
|
||||||
raise InputFileError("Lost access to the input file")
|
raise InputFileError("Lost access to the input file")
|
||||||
|
|
||||||
|
|
||||||
def run(args=None):
|
def run(args=None):
|
||||||
|
"""Run the ocrmypdf command line interface."""
|
||||||
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
|
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
|
||||||
|
|
||||||
with suppress(AttributeError, PermissionError):
|
with suppress(AttributeError, PermissionError):
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ class Executor(ABC):
|
|||||||
task_arguments: Iterable | None = None,
|
task_arguments: Iterable | None = None,
|
||||||
task_finished: Callable | None = None,
|
task_finished: Callable | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Set up parallel execution and progress reporting.
|
||||||
Set up parallel execution and progress reporting.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
use_threads: If ``False``, the workload is the sort that will benefit from
|
use_threads: If ``False``, the workload is the sort that will benefit from
|
||||||
@@ -60,7 +59,7 @@ class Executor(ABC):
|
|||||||
heavily, and parallelizing it with threads is not expected to be
|
heavily, and parallelizing it with threads is not expected to be
|
||||||
performant).
|
performant).
|
||||||
max_workers: The maximum number of workers that should be run.
|
max_workers: The maximum number of workers that should be run.
|
||||||
tdqm_kwargs: Arguments to set up the progress bar.
|
tqdm_kwargs: Arguments to set up the progress bar.
|
||||||
worker_initializer: Called when a worker is initialized, in the worker's
|
worker_initializer: Called when a worker is initialized, in the worker's
|
||||||
execution context. If the child workers are processes, it must be
|
execution context. If the child workers are processes, it must be
|
||||||
possible to marshall/pickle the worker initializer.
|
possible to marshall/pickle the worker initializer.
|
||||||
@@ -73,7 +72,6 @@ class Executor(ABC):
|
|||||||
task. This runs in the parent's context, but the parameters must be
|
task. This runs in the parent's context, but the parameters must be
|
||||||
marshallable to the worker.
|
marshallable to the worker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not task_arguments:
|
if not task_arguments:
|
||||||
return # Nothing to do!
|
return # Nothing to do!
|
||||||
if not worker_initializer:
|
if not worker_initializer:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Manage third party executables"""
|
"""Manage third party executables."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Interface to Ghostscript executable"""
|
"""Interface to Ghostscript executable."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Interface to jbig2 executable"""
|
"""Interface to jbig2 executable."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Interface to pngquant executable"""
|
"""Interface to pngquant executable."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Interface to Tesseract executable"""
|
"""Interface to Tesseract executable."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -34,10 +34,12 @@ HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<title></title>
|
<title></title>
|
||||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
|
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
|
||||||
<meta name='ocr-system' content='tesseract 4.1.1' />
|
<meta name='ocr-system' content='tesseract 4.1.1' />
|
||||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
<meta name='ocr-capabilities'
|
||||||
|
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class='ocr_page' id='page_1' title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'>
|
<div class='ocr_page' id='page_1'
|
||||||
|
title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -52,7 +54,7 @@ TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
|
|||||||
|
|
||||||
|
|
||||||
class TesseractLoggerAdapter(logging.LoggerAdapter):
|
class TesseractLoggerAdapter(logging.LoggerAdapter):
|
||||||
"Prepend [tesseract] to messages emitted from tesseract"
|
"""Prepend [tesseract] to messages emitted from tesseract."""
|
||||||
|
|
||||||
def process(self, msg, kwargs):
|
def process(self, msg, kwargs):
|
||||||
kwargs['extra'] = self.extra
|
kwargs['extra'] = self.extra
|
||||||
@@ -104,7 +106,8 @@ TESSERACT_VERSION_PATTERN = r"""
|
|||||||
|
|
||||||
|
|
||||||
class TesseractVersion(Version):
|
class TesseractVersion(Version):
|
||||||
"Modify standard packaging.Version regex to support Tesseract idiosyncracies."
|
"""Modify standard packaging.Version regex to support Tesseract idiosyncrasies."""
|
||||||
|
|
||||||
_regex = re.compile(
|
_regex = re.compile(
|
||||||
r"^\s*" + TESSERACT_VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
|
r"^\s*" + TESSERACT_VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
|
||||||
)
|
)
|
||||||
@@ -280,8 +283,10 @@ def page_timedout(timeout: float) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
|
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
|
||||||
"""Produce a .hocr file that reports no text detected on a page that is
|
"""Produce a .hocr file that reports no text detected.
|
||||||
the same size as the input image."""
|
|
||||||
|
Ensures page is the same size as the input image.
|
||||||
|
"""
|
||||||
with Image.open(image) as im:
|
with Image.open(image) as im:
|
||||||
w, h = im.size
|
w, h = im.size
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Interface to unpaper executable"""
|
"""Interface to unpaper executable."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ def _update_resources(*, obj, font, font_key, procset):
|
|||||||
|
|
||||||
obj can be a page or Form XObject.
|
obj can be a page or Form XObject.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
resources = _ensure_dictionary(obj, Name.Resources)
|
resources = _ensure_dictionary(obj, Name.Resources)
|
||||||
fonts = _ensure_dictionary(resources, Name.Font)
|
fonts = _ensure_dictionary(resources, Name.Font)
|
||||||
if font_key is not None and font_key not in fonts:
|
if font_key is not None and font_key not in fonts:
|
||||||
@@ -131,7 +130,8 @@ class OcrGrafter:
|
|||||||
text_misaligned = (text_rotation - content_rotation) % 360
|
text_misaligned = (text_rotation - content_rotation) % 360
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Text rotation: (text, autorotate, content) -> text misalignment = "
|
f"Text rotation: (text, autorotate, content) -> text misalignment = "
|
||||||
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> {text_misaligned}"
|
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> "
|
||||||
|
f"{text_misaligned}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if textpdf and self.font:
|
if textpdf and self.font:
|
||||||
@@ -166,7 +166,6 @@ class OcrGrafter:
|
|||||||
the font to page 1 even if page 1 doesn't use it, so we have a way to get it
|
the font to page 1 even if page 1 doesn't use it, so we have a way to get it
|
||||||
back.
|
back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
page0 = self.pdf_base.pages[0]
|
page0 = self.pdf_base.pages[0]
|
||||||
_update_resources(
|
_update_resources(
|
||||||
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
|
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
|
||||||
@@ -199,8 +198,7 @@ class OcrGrafter:
|
|||||||
return self.output_file
|
return self.output_file
|
||||||
|
|
||||||
def _find_font(self, text):
|
def _find_font(self, text):
|
||||||
"""Copy a font from the filename text into pdf_base"""
|
"""Copy a font from the filename text into pdf_base."""
|
||||||
|
|
||||||
font, font_key = None, None
|
font, font_key = None, None
|
||||||
possible_font_names = ('/f-0-0', '/F1')
|
possible_font_names = ('/f-0-0', '/F1')
|
||||||
try:
|
try:
|
||||||
@@ -233,8 +231,7 @@ class OcrGrafter:
|
|||||||
text_rotation: int,
|
text_rotation: int,
|
||||||
strip_old_text: bool,
|
strip_old_text: bool,
|
||||||
):
|
):
|
||||||
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
|
"""Insert the text layer from text page 0 on to pdf_base at page_num."""
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
log.debug("Grafting")
|
log.debug("Grafting")
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class PdfContext:
|
|||||||
class PageContext:
|
class PageContext:
|
||||||
"""Holds our context for a page.
|
"""Holds our context for a page.
|
||||||
|
|
||||||
Must be pickable, so stores only intrinsic/simple data elements or those
|
Must be pickle-able, so stores only intrinsic/simple data elements or those
|
||||||
capable of their serializing themselves via ``__getstate__``.
|
capable of their serializing themselves via ``__getstate__``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class PageNumberFilter(logging.Filter):
|
|||||||
|
|
||||||
|
|
||||||
class TqdmConsole:
|
class TqdmConsole:
|
||||||
"""Wrapper to log messages in a way that is compatible with tqdm progress bar
|
"""Wrapper to log messages in a way that is compatible with tqdm progress bar.
|
||||||
|
|
||||||
This routes log messages through tqdm so that it can print them above the
|
This routes log messages through tqdm so that it can print them above the
|
||||||
progress bar, and then refresh the progress bar, rather than overwriting
|
progress bar, and then refresh the progress bar, rather than overwriting
|
||||||
|
|||||||
+21
-29
@@ -14,7 +14,7 @@ from contextlib import suppress
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
from typing import Any, Iterable, Sequence
|
from typing import Any, BinaryIO, Iterable, Sequence, cast
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
import pikepdf
|
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.
|
Returns empty string if not found, indicating file is probably not PDF.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with open(input_file, 'rb') as f:
|
with open(input_file, 'rb') as f:
|
||||||
signature = f.read(search_window)
|
signature = f.read(search_window)
|
||||||
m = re.search(br'%PDF-(\d\.\d)', signature)
|
m = re.search(br'%PDF-(\d\.\d)', signature)
|
||||||
@@ -186,15 +185,6 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
|
|||||||
"Designer and can only be read by Adobe Acrobat or Adobe Reader."
|
"Designer and can only be read by Adobe Acrobat or Adobe Reader."
|
||||||
)
|
)
|
||||||
raise InputFileError()
|
raise InputFileError()
|
||||||
if pdfinfo.has_userunit and options.output_type.startswith('pdfa'):
|
|
||||||
log.error(
|
|
||||||
"This input file uses a PDF feature that is not supported "
|
|
||||||
"by Ghostscript, so you cannot use --output-type=pdfa for this "
|
|
||||||
"file. (Specifically, it uses the PDF-1.6 /UserUnit feature to "
|
|
||||||
"support very large or small page sizes, and Ghostscript cannot "
|
|
||||||
"output these files.) Use --output-type=pdf instead."
|
|
||||||
)
|
|
||||||
raise InputFileError()
|
|
||||||
if pdfinfo.has_acroform:
|
if pdfinfo.has_acroform:
|
||||||
if options.redo_ocr:
|
if options.redo_ocr:
|
||||||
log.error(
|
log.error(
|
||||||
@@ -222,7 +212,7 @@ def _vector_page_dpi(pageinfo: PageInfo) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def get_page_dpi(pageinfo: PageInfo, options) -> Resolution:
|
def get_page_dpi(pageinfo: PageInfo, options) -> Resolution:
|
||||||
"Get the DPI when nonsquare DPI is tolerable"
|
"""Get the DPI when nonsquare DPI is tolerable."""
|
||||||
xres = max(
|
xres = max(
|
||||||
pageinfo.dpi.x or VECTOR_PAGE_DPI,
|
pageinfo.dpi.x or VECTOR_PAGE_DPI,
|
||||||
options.oversample or 0.0,
|
options.oversample or 0.0,
|
||||||
@@ -237,7 +227,7 @@ def get_page_dpi(pageinfo: PageInfo, options) -> Resolution:
|
|||||||
|
|
||||||
|
|
||||||
def get_page_square_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
|
xres = pageinfo.dpi.x or 0.0
|
||||||
yres = pageinfo.dpi.y or 0.0
|
yres = pageinfo.dpi.y or 0.0
|
||||||
userunit = float(pageinfo.userunit) or 1.0
|
userunit = float(pageinfo.userunit) or 1.0
|
||||||
@@ -253,7 +243,7 @@ def get_page_square_dpi(pageinfo: PageInfo, options) -> Resolution:
|
|||||||
|
|
||||||
|
|
||||||
def get_canvas_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(
|
units = float(
|
||||||
max(
|
max(
|
||||||
(pageinfo.dpi.x) or VECTOR_PAGE_DPI,
|
(pageinfo.dpi.x) or VECTOR_PAGE_DPI,
|
||||||
@@ -315,8 +305,8 @@ def is_ocr_required(page_context: PageContext) -> bool:
|
|||||||
log.warning(
|
log.warning(
|
||||||
"page has no images - "
|
"page has no images - "
|
||||||
"all vector content will be "
|
"all vector content will be "
|
||||||
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and likely "
|
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and "
|
||||||
"increasing file size. Use --oversample to adjust the "
|
"likely increasing file size. Use --oversample to adjust the "
|
||||||
"DPI."
|
"DPI."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -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:
|
def describe_rotation(page_context: PageContext, orient_conf, correction: int) -> str:
|
||||||
"""
|
"""Describe the page rotation we are going to perform."""
|
||||||
Describe the page rotation we are going to perform.
|
|
||||||
"""
|
|
||||||
direction = {0: '⇧', 90: '⇨', 180: '⇩', 270: '⇦'}
|
direction = {0: '⇧', 90: '⇨', 180: '⇩', 270: '⇦'}
|
||||||
turns = {0: ' ', 90: '⬏', 180: '↻', 270: '⬑'}
|
turns = {0: ' ', 90: '⬏', 180: '↻', 270: '⬑'}
|
||||||
|
|
||||||
@@ -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
|
which points it (hopefully) upright. _graft.py takes care of the orienting
|
||||||
the image and text layers.
|
the image and text layers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
|
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
|
||||||
preview, page_context.options
|
preview, page_context.options
|
||||||
)
|
)
|
||||||
@@ -514,10 +501,11 @@ def preprocess_clean(input_file: Path, page_context: PageContext) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def create_ocr_image(image: 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
|
"""Create the image we send for OCR.
|
||||||
image depending on preprocessing. This image will never be shown to the
|
|
||||||
user."""
|
|
||||||
|
|
||||||
|
Might not be the same as the display image depending on preprocessing.
|
||||||
|
This image will never be shown to the user.
|
||||||
|
"""
|
||||||
output_file = page_context.get_path('ocr.png')
|
output_file = page_context.get_path('ocr.png')
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
with Image.open(image) as im:
|
with Image.open(image) as im:
|
||||||
@@ -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:
|
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
||||||
# If all images were JPEGs originally, produce a JPEG as output
|
# If all images were JPEGs originally, produce a JPEG as output
|
||||||
return pageinfo.images and all(im.enc == Encoding.jpeg for im in pageinfo.images)
|
return bool(pageinfo.images) and all(
|
||||||
|
im.enc == Encoding.jpeg for im in pageinfo.images
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
||||||
@@ -895,15 +885,17 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
|
|||||||
return output_file
|
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)
|
log.debug('%s -> %s', input_file, output_file)
|
||||||
with open(input_file, 'rb') as input_stream:
|
with input_file.open('rb') as input_stream:
|
||||||
if output_file == '-':
|
if output_file == '-':
|
||||||
copyfileobj(input_stream, sys.stdout.buffer)
|
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
elif hasattr(output_file, 'writable'):
|
elif hasattr(output_file, 'writable'):
|
||||||
output_stream = output_file
|
output_stream = cast(BinaryIO, output_file)
|
||||||
copyfileobj(input_stream, output_stream)
|
copyfileobj(input_stream, output_stream) # type: ignore[misc]
|
||||||
with suppress(AttributeError):
|
with suppress(AttributeError):
|
||||||
output_stream.flush()
|
output_stream.flush()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -251,8 +251,7 @@ def worker_init(max_pixels: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||||
"""Execute the pipeline concurrently"""
|
"""Execute the pipeline concurrently."""
|
||||||
|
|
||||||
# Run exec_page_sync on every page context
|
# Run exec_page_sync on every page context
|
||||||
options = context.options
|
options = context.options
|
||||||
max_workers = min(len(context.pdfinfo), options.jobs)
|
max_workers = min(len(context.pdfinfo), options.jobs)
|
||||||
@@ -316,8 +315,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
|||||||
def configure_debug_logging(
|
def configure_debug_logging(
|
||||||
log_filename: Path, prefix: str = ''
|
log_filename: Path, prefix: str = ''
|
||||||
) -> logging.FileHandler:
|
) -> logging.FileHandler:
|
||||||
"""
|
"""Create a debug log file at a specified location.
|
||||||
Create a debug log file at a specified location.
|
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
log_filename: Where to the put the log file.
|
log_filename: Where to the put the log file.
|
||||||
@@ -420,13 +418,13 @@ def run_pipeline(
|
|||||||
options, start_input_file, options.output_file, optimize_messages
|
options, start_input_file, options.output_file, optimize_messages
|
||||||
)
|
)
|
||||||
|
|
||||||
except (KeyboardInterrupt if not api else NeverRaise):
|
except KeyboardInterrupt if not api else NeverRaise:
|
||||||
if options.verbose >= 1:
|
if options.verbose >= 1:
|
||||||
log.exception("KeyboardInterrupt")
|
log.exception("KeyboardInterrupt")
|
||||||
else:
|
else:
|
||||||
log.error("KeyboardInterrupt")
|
log.error("KeyboardInterrupt")
|
||||||
return ExitCode.ctrl_c
|
return ExitCode.ctrl_c
|
||||||
except (ExitCodeException if not api else NeverRaise) as e:
|
except ExitCodeException if not api else NeverRaise as e:
|
||||||
e = cast(ExitCodeException, e)
|
e = cast(ExitCodeException, e)
|
||||||
if options.verbose >= 1:
|
if options.verbose >= 1:
|
||||||
log.exception("ExitCodeException")
|
log.exception("ExitCodeException")
|
||||||
@@ -435,7 +433,7 @@ def run_pipeline(
|
|||||||
else:
|
else:
|
||||||
log.error(type(e).__name__)
|
log.error(type(e).__name__)
|
||||||
return e.exit_code
|
return e.exit_code
|
||||||
except (PIL.Image.DecompressionBombError if not api else NeverRaise):
|
except PIL.Image.DecompressionBombError if not api else NeverRaise:
|
||||||
log.exception(
|
log.exception(
|
||||||
"A decompression bomb error was encountered while executing the "
|
"A decompression bomb error was encountered while executing the "
|
||||||
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
|
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
|
||||||
@@ -453,7 +451,7 @@ def run_pipeline(
|
|||||||
"argument."
|
"argument."
|
||||||
)
|
)
|
||||||
return ExitCode.child_process_error
|
return ExitCode.child_process_error
|
||||||
except (Exception if not api else NeverRaise): # pylint: disable=broad-except
|
except Exception if not api else NeverRaise: # pylint: disable=broad-except
|
||||||
log.exception("An exception occurred while executing the pipeline")
|
log.exception("An exception occurred while executing the pipeline")
|
||||||
return ExitCode.other_error
|
return ExitCode.other_error
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -61,12 +61,20 @@ def check_options_languages(options: Namespace, ocr_engine_languages: set[str])
|
|||||||
return
|
return
|
||||||
missing_languages = options.languages - ocr_engine_languages
|
missing_languages = options.languages - ocr_engine_languages
|
||||||
if missing_languages:
|
if missing_languages:
|
||||||
|
lang_text = '\n'.join(lang for lang in missing_languages)
|
||||||
msg = (
|
msg = (
|
||||||
"OCR engine does not have language data for the following "
|
"OCR engine does not have language data for the following "
|
||||||
"requested languages: \n"
|
"requested languages: \n"
|
||||||
|
f"{lang_text}\n"
|
||||||
|
"Please install the appropriate language data for your OCR engine.\n"
|
||||||
|
"\n"
|
||||||
|
"See the online documentation for instructions:\n"
|
||||||
|
" https://ocrmypdf.readthedocs.io/en/latest/languages.html\n"
|
||||||
|
"\n"
|
||||||
|
"Note: most languages are identified by a 3-digit ISO 639-2 Code.\n"
|
||||||
|
"For example, English is 'eng', German is 'deu', and Spanish is 'spa'."
|
||||||
|
"\n"
|
||||||
)
|
)
|
||||||
msg += '\n'.join(lang for lang in missing_languages)
|
|
||||||
msg += '\nNote: most languages are identified by a 3-digit ISO 639-2 Code'
|
|
||||||
raise MissingDependencyError(msg)
|
raise MissingDependencyError(msg)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,12 +117,10 @@ def check_options_output(options: Namespace) -> None:
|
|||||||
def check_options_sidecar(options: Namespace) -> None:
|
def check_options_sidecar(options: Namespace) -> None:
|
||||||
if options.sidecar == '\0':
|
if options.sidecar == '\0':
|
||||||
if options.output_file == '-':
|
if options.output_file == '-':
|
||||||
raise BadArgsError(
|
raise BadArgsError("--sidecar filename needed when output file is stdout.")
|
||||||
"--sidecar filename must be specified when output file is stdout."
|
|
||||||
)
|
|
||||||
elif options.output_file == os.devnull:
|
elif options.output_file == os.devnull:
|
||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
"--sidecar filename must be specified when output file is /dev/null or NUL."
|
"--sidecar filename needed when output file is /dev/null or NUL."
|
||||||
)
|
)
|
||||||
options.sidecar = options.output_file + '.txt'
|
options.sidecar = options.output_file + '.txt'
|
||||||
if options.sidecar == options.input_file or options.sidecar == options.output_file:
|
if options.sidecar == options.input_file or options.sidecar == options.output_file:
|
||||||
@@ -276,7 +282,8 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
|
|||||||
"permissions correctly.\n"
|
"permissions correctly.\n"
|
||||||
"You may find it easier to use stdin/stdout:"
|
"You may find it easier to use stdin/stdout:"
|
||||||
"\n"
|
"\n"
|
||||||
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf\n"
|
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf"
|
||||||
|
"\n"
|
||||||
)
|
)
|
||||||
raise InputFileError(msg) from e
|
raise InputFileError(msg) from e
|
||||||
|
|
||||||
@@ -333,7 +340,7 @@ def report_output_file_size(
|
|||||||
for arg in image_preproc:
|
for arg in image_preproc:
|
||||||
if getattr(options, arg, False):
|
if getattr(options, arg, False):
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"The argument --{arg.replace('_', '-')} was issued, causing transcoding."
|
f"--{arg.replace('_', '-')} was issued, causing transcoding."
|
||||||
)
|
)
|
||||||
|
|
||||||
reasons.extend(optimize_messages)
|
reasons.extend(optimize_messages)
|
||||||
|
|||||||
+58
-44
@@ -86,7 +86,6 @@ def configure_logging(
|
|||||||
Returns:
|
Returns:
|
||||||
The toplevel logger for ocrmypdf (or the root logger, if we are managing it).
|
The toplevel logger for ocrmypdf (or the root logger, if we are managing it).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
prefix = '' if manage_root_logger else 'ocrmypdf'
|
prefix = '' if manage_root_logger else 'ocrmypdf'
|
||||||
|
|
||||||
log = logging.getLogger(prefix)
|
log = logging.getLogger(prefix)
|
||||||
@@ -143,6 +142,7 @@ def configure_logging(
|
|||||||
def create_options(
|
def create_options(
|
||||||
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
||||||
):
|
):
|
||||||
|
"""Construct an options object from the input/output files and keyword arguments."""
|
||||||
cmdline = []
|
cmdline = []
|
||||||
deferred = []
|
deferred = []
|
||||||
|
|
||||||
@@ -203,59 +203,72 @@ def create_options(
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def ocr( # pylint: disable=unused-argument
|
def ocr( # noqa: ruff: disable=D417
|
||||||
input_file: PathOrIO,
|
input_file: PathOrIO,
|
||||||
output_file: PathOrIO,
|
output_file: PathOrIO,
|
||||||
*,
|
*,
|
||||||
language: Iterable[str] = None,
|
language: Iterable[str] | None = None,
|
||||||
image_dpi: int = None,
|
image_dpi: int | None = None,
|
||||||
output_type=None,
|
output_type: str | None = None,
|
||||||
sidecar: StrPath | None = None,
|
sidecar: StrPath | None = None,
|
||||||
jobs: int = None,
|
jobs: int | None = None,
|
||||||
use_threads: bool = None,
|
use_threads: bool | None = None,
|
||||||
title: str = None,
|
title: str | None = None,
|
||||||
author: str = None,
|
author: str | None = None,
|
||||||
subject: str = None,
|
subject: str | None = None,
|
||||||
keywords: str = None,
|
keywords: str | None = None,
|
||||||
rotate_pages: bool = None,
|
rotate_pages: bool | None = None,
|
||||||
remove_background: bool = None,
|
remove_background: bool | None = None,
|
||||||
deskew: bool = None,
|
deskew: bool | None = None,
|
||||||
clean: bool = None,
|
clean: bool | None = None,
|
||||||
clean_final: bool = None,
|
clean_final: bool | None = None,
|
||||||
unpaper_args: str = None,
|
unpaper_args: str | None = None,
|
||||||
oversample: int = None,
|
oversample: int | None = None,
|
||||||
remove_vectors: bool = None,
|
remove_vectors: bool | None = None,
|
||||||
force_ocr: bool = None,
|
force_ocr: bool | None = None,
|
||||||
skip_text: bool = None,
|
skip_text: bool | None = None,
|
||||||
redo_ocr: bool = None,
|
redo_ocr: bool | None = None,
|
||||||
skip_big: float = None,
|
skip_big: float | None = None,
|
||||||
optimize: int = None,
|
optimize: int | None = None,
|
||||||
jpg_quality: int = None,
|
jpg_quality: int | None = None,
|
||||||
png_quality: int = None,
|
png_quality: int | None = None,
|
||||||
jbig2_lossy: bool = None,
|
jbig2_lossy: bool | None = None,
|
||||||
jbig2_page_group_size: int = None,
|
jbig2_page_group_size: int | None = None,
|
||||||
pages: str = None,
|
pages: str | None = None,
|
||||||
max_image_mpixels: float = None,
|
max_image_mpixels: float | None = None,
|
||||||
tesseract_config: Iterable[str] = None,
|
tesseract_config: Iterable[str] | None = None,
|
||||||
tesseract_pagesegmode: int = None,
|
tesseract_pagesegmode: int | None = None,
|
||||||
tesseract_oem: int = None,
|
tesseract_oem: int | None = None,
|
||||||
tesseract_thresholding: int = None,
|
tesseract_thresholding: int | None = None,
|
||||||
pdf_renderer=None,
|
pdf_renderer: str | None = None,
|
||||||
tesseract_timeout: float = None,
|
tesseract_timeout: float | None = None,
|
||||||
rotate_pages_threshold: float = None,
|
tesseract_non_ocr_timeout: float | None = None,
|
||||||
pdfa_image_compression=None,
|
rotate_pages_threshold: float | None = None,
|
||||||
user_words: os.PathLike = None,
|
pdfa_image_compression: str | None = None,
|
||||||
user_patterns: os.PathLike = None,
|
user_words: os.PathLike | None = None,
|
||||||
fast_web_view: float = None,
|
user_patterns: os.PathLike | None = None,
|
||||||
plugins: Iterable[StrPath] = None,
|
fast_web_view: float | None = None,
|
||||||
|
plugins: Iterable[StrPath] | None = None,
|
||||||
plugin_manager=None,
|
plugin_manager=None,
|
||||||
keep_temporary_files: bool = None,
|
keep_temporary_files: bool | None = None,
|
||||||
progress_bar: bool = None,
|
progress_bar: bool | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Run OCRmyPDF on one PDF or image.
|
"""Run OCRmyPDF on one PDF or image.
|
||||||
|
|
||||||
For most arguments, see documentation for the equivalent command line parameter.
|
For most arguments, see documentation for the equivalent command line parameter.
|
||||||
|
|
||||||
|
This API takes a threading lock, because OCRmyPDF uses global state in particular
|
||||||
|
for the plugin system. The jobs parameter will be used to create a pool of
|
||||||
|
worker threads or processes at different times, subject to change. A Python
|
||||||
|
process can only run one OCRmyPDF task at a time.
|
||||||
|
|
||||||
|
To run parallelize instances OCRmyPDF, use separate Python processes to scale
|
||||||
|
horizontally. Generally speaking you should set jobs=sqrt(cpu_count) and run
|
||||||
|
sqrt(cpu_count) processes as a starting point. If you have files with a high page
|
||||||
|
count, run fewer processes and more jobs per process. If you have a lot of short
|
||||||
|
files, run more processes and fewer jobs per process.
|
||||||
|
|
||||||
A few specific arguments are discussed here:
|
A few specific arguments are discussed here:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -277,6 +290,7 @@ def ocr( # pylint: disable=unused-argument
|
|||||||
When a stream is used as output, whether via a writable object or
|
When a stream is used as output, whether via a writable object or
|
||||||
``"-"``, some final validation steps are not performed (we do not read
|
``"-"``, some final validation steps are not performed (we do not read
|
||||||
back the stream after it is written).
|
back the stream after it is written).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ocrmypdf.MissingDependencyError: If a required dependency program is missing or
|
ocrmypdf.MissingDependencyError: If a required dependency program is missing or
|
||||||
was not found on PATH.
|
was not found on PATH.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
from __future__ import annotations
|
"""Plugins in this package are automatically loaded by ocrmypdf."""
|
||||||
|
|
||||||
# This file exists only mark builtin_plugins as a package.
|
from __future__ import annotations
|
||||||
# The plugin manager will not load it, so anything defined here may not be
|
|
||||||
# processed as a module.
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ WorkerInit = Callable[[Queue, UserInit, int], None]
|
|||||||
|
|
||||||
|
|
||||||
def log_listener(q: Queue):
|
def log_listener(q: Queue):
|
||||||
"""Listen to the worker processes and forward the messages to logging
|
"""Listen to the worker processes and forward the messages to logging.
|
||||||
|
|
||||||
For simplicity this is a thread rather than a process. Only one process
|
For simplicity this is a thread rather than a process. Only one process
|
||||||
should actually write to sys.stderr or whatever we're using, so if this is
|
should actually write to sys.stderr or whatever we're using, so if this is
|
||||||
@@ -39,7 +39,6 @@ def log_listener(q: Queue):
|
|||||||
See:
|
See:
|
||||||
https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
|
https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
record = q.get()
|
record = q.get()
|
||||||
@@ -55,12 +54,12 @@ def log_listener(q: Queue):
|
|||||||
|
|
||||||
|
|
||||||
def process_sigbus(*args):
|
def process_sigbus(*args):
|
||||||
|
"""Handle SIGBUS signal at the worker level."""
|
||||||
raise InputFileError("A worker process lost access to an input file")
|
raise InputFileError("A worker process lost access to an input file")
|
||||||
|
|
||||||
|
|
||||||
def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
||||||
"""Initialize a process pool worker"""
|
"""Initialize a process pool worker."""
|
||||||
|
|
||||||
# Ignore SIGINT (our parent process will kill us gracefully)
|
# Ignore SIGINT (our parent process will kill us gracefully)
|
||||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||||
|
|
||||||
@@ -82,6 +81,7 @@ def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
||||||
|
"""Begin a thread pool worker."""
|
||||||
del q # unused but required argument
|
del q # unused but required argument
|
||||||
del loglevel # unused but required argument
|
del loglevel # unused but required argument
|
||||||
# As a thread, block SIGBUS so the main thread deals with it...
|
# As a thread, block SIGBUS so the main thread deals with it...
|
||||||
@@ -164,14 +164,17 @@ class StandardExecutor(Executor):
|
|||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def get_executor(progressbar_class):
|
def get_executor(progressbar_class):
|
||||||
|
"""Return the default executor."""
|
||||||
return StandardExecutor(pbar_class=progressbar_class)
|
return StandardExecutor(pbar_class=progressbar_class)
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def get_progressbar_class():
|
def get_progressbar_class():
|
||||||
|
"""Return the default progress bar class."""
|
||||||
return tqdm
|
return tqdm
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def get_logging_console():
|
def get_logging_console():
|
||||||
|
"""Return the default logging console handler."""
|
||||||
return logging.StreamHandler(stream=TqdmConsole(sys.stderr))
|
return logging.StreamHandler(stream=TqdmConsole(sys.stderr))
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import logging
|
|||||||
|
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
from ocrmypdf._exec import ghostscript
|
from ocrmypdf._exec import ghostscript
|
||||||
from ocrmypdf._validation import HOCR_OK_LANGS
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
from ocrmypdf.subprocess import check_external_program
|
from ocrmypdf.subprocess import check_external_program
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def check_options(options):
|
def check_options(options):
|
||||||
|
"""Check that the options are valid for this plugin."""
|
||||||
check_external_program(
|
check_external_program(
|
||||||
program='gs',
|
program='gs',
|
||||||
package='ghostscript',
|
package='ghostscript',
|
||||||
@@ -46,6 +46,7 @@ def rasterize_pdf_page(
|
|||||||
rotation,
|
rotation,
|
||||||
filter_vector,
|
filter_vector,
|
||||||
):
|
):
|
||||||
|
"""Rasterize a single page of a PDF file using Ghostscript."""
|
||||||
ghostscript.rasterize_pdf(
|
ghostscript.rasterize_pdf(
|
||||||
input_file,
|
input_file,
|
||||||
output_file,
|
output_file,
|
||||||
@@ -69,6 +70,7 @@ def generate_pdfa(
|
|||||||
pdfa_part,
|
pdfa_part,
|
||||||
progressbar_class,
|
progressbar_class,
|
||||||
):
|
):
|
||||||
|
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
|
||||||
ghostscript.generate_pdfa(
|
ghostscript.generate_pdfa(
|
||||||
pdf_pages=[*pdf_pages, pdfmark],
|
pdf_pages=[*pdf_pages, pdfmark],
|
||||||
output_file=output_file,
|
output_file=output_file,
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
from ocrmypdf._exec import tesseract
|
from ocrmypdf._exec import tesseract
|
||||||
|
from ocrmypdf._jobcontext import PageContext
|
||||||
from ocrmypdf.cli import numeric, str_to_int
|
from ocrmypdf.cli import numeric, str_to_int
|
||||||
from ocrmypdf.helpers import clamp
|
from ocrmypdf.helpers import clamp
|
||||||
|
from ocrmypdf.imageops import calculate_downsample, downsample_image
|
||||||
from ocrmypdf.pluginspec import OcrEngine
|
from ocrmypdf.pluginspec import OcrEngine
|
||||||
from ocrmypdf.subprocess import check_external_program
|
from ocrmypdf.subprocess import check_external_program
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ def add_options(parser):
|
|||||||
metavar='METHOD',
|
metavar='METHOD',
|
||||||
help=(
|
help=(
|
||||||
"Set Tesseract 5.0+ input image thresholding mode. This may improve OCR "
|
"Set Tesseract 5.0+ input image thresholding mode. This may improve OCR "
|
||||||
"results on low quality images or those that contain high constrast color. "
|
"results on low quality images or those that contain high contrast color. "
|
||||||
"legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu "
|
"legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu "
|
||||||
"algorithm with improved sort for background color changes; sauvola is "
|
"algorithm with improved sort for background color changes; sauvola is "
|
||||||
"based on local standard deviation."
|
"based on local standard deviation."
|
||||||
@@ -69,8 +73,48 @@ def add_options(parser):
|
|||||||
default=180.0,
|
default=180.0,
|
||||||
type=numeric(float, 0),
|
type=numeric(float, 0),
|
||||||
metavar='SECONDS',
|
metavar='SECONDS',
|
||||||
help='Give up on OCR after the timeout, but copy the preprocessed page '
|
help=(
|
||||||
'into the final output',
|
"Give up on OCR after the timeout, but copy the preprocessed page "
|
||||||
|
"into the final output."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tess.add_argument(
|
||||||
|
'--tesseract-non-ocr-timeout',
|
||||||
|
default=180.0,
|
||||||
|
type=numeric(float, 0),
|
||||||
|
metavar='SECONDS',
|
||||||
|
help=(
|
||||||
|
"Give up on non-OCR operations such as deskewing and orientation "
|
||||||
|
"after timeout. This is a separate timeout from --tesseract-timeout "
|
||||||
|
"because these operations are not as expensive as OCR."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tess.add_argument(
|
||||||
|
'--tesseract-downsample-large-images',
|
||||||
|
action='store_true',
|
||||||
|
help=(
|
||||||
|
"Downsample large images before OCR. Tesseract has an upper limit on the "
|
||||||
|
"size images it will support. If this argument is given, OCRmyPDF will "
|
||||||
|
"downsample large images to fit Tesseract. This may reduce OCR quality, "
|
||||||
|
"on large images the most desirable text is usually larger. If this "
|
||||||
|
"parameter is not supplied, Tesseract will error out and produce no OCR "
|
||||||
|
"on the page in question. This argument should be used with a high value "
|
||||||
|
"of --tesseract-timeout to ensure Tesseract has enough to time."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tess.add_argument(
|
||||||
|
'--tesseract-downsample-above',
|
||||||
|
action='store',
|
||||||
|
type=numeric(int, 100, 32767),
|
||||||
|
default=32767,
|
||||||
|
help=(
|
||||||
|
"Downsample images larger than this size pixel size in either dimension "
|
||||||
|
"before OCR. --tesseract-downsample-large-images downsamples only when "
|
||||||
|
"an image exceeds Tesseract's internal limits. This argument causes "
|
||||||
|
"downsampling to occur when an image exceeds the given size. This may "
|
||||||
|
"reduce OCR quality, but on large images the most desirable text is "
|
||||||
|
"usually larger."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
tess.add_argument(
|
tess.add_argument(
|
||||||
'--user-words',
|
'--user-words',
|
||||||
@@ -132,6 +176,25 @@ def validate(pdfinfo, options):
|
|||||||
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
|
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
|
||||||
|
"""Filter the image before OCR.
|
||||||
|
|
||||||
|
Tesseract cannot handle images with more than 32767 pixels in either axis,
|
||||||
|
or more than 2**31 bytes. This function resizes the image to fit within
|
||||||
|
those limits.
|
||||||
|
"""
|
||||||
|
threshold = min(page.options.tesseract_downsample_above, 32767)
|
||||||
|
|
||||||
|
options = page.options
|
||||||
|
if options.tesseract_downsample_large_images:
|
||||||
|
size = calculate_downsample(
|
||||||
|
image, max_size=(threshold, threshold), max_bytes=(2**31) - 1
|
||||||
|
)
|
||||||
|
image = downsample_image(image, size)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
class TesseractOcrEngine(OcrEngine):
|
class TesseractOcrEngine(OcrEngine):
|
||||||
"""Implements OCR with Tesseract."""
|
"""Implements OCR with Tesseract."""
|
||||||
|
|
||||||
@@ -156,7 +219,7 @@ class TesseractOcrEngine(OcrEngine):
|
|||||||
return tesseract.get_orientation(
|
return tesseract.get_orientation(
|
||||||
input_file,
|
input_file,
|
||||||
engine_mode=options.tesseract_oem,
|
engine_mode=options.tesseract_oem,
|
||||||
timeout=options.tesseract_timeout,
|
timeout=options.tesseract_non_ocr_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -165,7 +228,7 @@ class TesseractOcrEngine(OcrEngine):
|
|||||||
input_file,
|
input_file,
|
||||||
languages=options.languages,
|
languages=options.languages,
|
||||||
engine_mode=options.tesseract_oem,
|
engine_mode=options.tesseract_oem,
|
||||||
timeout=options.tesseract_timeout,
|
timeout=options.tesseract_non_ocr_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+13
-2
@@ -15,7 +15,7 @@ T = TypeVar('T', int, float)
|
|||||||
|
|
||||||
|
|
||||||
def numeric(basetype: Callable[[Any], T], min_: T | None = None, max_: T | None = None):
|
def numeric(basetype: Callable[[Any], T], min_: T | None = None, max_: T | None = None):
|
||||||
"""Validator for numeric params"""
|
"""Validator for numeric params."""
|
||||||
min_ = basetype(min_) if min_ is not None else None
|
min_ = basetype(min_) if min_ is not None else None
|
||||||
max_ = basetype(max_) if max_ is not None else None
|
max_ = basetype(max_) if max_ is not None else None
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ def str_to_int(mapping: Mapping[str, int]):
|
|||||||
|
|
||||||
|
|
||||||
class ArgumentParser(argparse.ArgumentParser):
|
class ArgumentParser(argparse.ArgumentParser):
|
||||||
"""Override parser's default behavior of calling sys.exit()
|
"""Override parser's default behavior of calling sys.exit().
|
||||||
|
|
||||||
https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code
|
https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code
|
||||||
|
|
||||||
@@ -57,13 +57,21 @@ class ArgumentParser(argparse.ArgumentParser):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize the parser."""
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._api_mode = False
|
self._api_mode = False
|
||||||
|
|
||||||
def enable_api_mode(self):
|
def enable_api_mode(self):
|
||||||
|
"""Enable API mode.
|
||||||
|
|
||||||
|
When set, the parser will not call sys.exit() on error. OCRmyPDF was originally
|
||||||
|
a command line program, but now it has an API. The API works by synthesizing
|
||||||
|
command line arguments.
|
||||||
|
"""
|
||||||
self._api_mode = True
|
self._api_mode = True
|
||||||
|
|
||||||
def error(self, message):
|
def error(self, message):
|
||||||
|
"""Override the default argparse error behavior."""
|
||||||
if not self._api_mode:
|
if not self._api_mode:
|
||||||
super().error(message)
|
super().error(message)
|
||||||
return
|
return
|
||||||
@@ -74,11 +82,13 @@ class LanguageSetAction(argparse.Action):
|
|||||||
"""Manages a list of languages."""
|
"""Manages a list of languages."""
|
||||||
|
|
||||||
def __init__(self, option_strings, dest, default=None, **kwargs):
|
def __init__(self, option_strings, dest, default=None, **kwargs):
|
||||||
|
"""Initialize the action."""
|
||||||
if default is None:
|
if default is None:
|
||||||
default = set()
|
default = set()
|
||||||
super().__init__(option_strings, dest, default=default, **kwargs)
|
super().__init__(option_strings, dest, default=default, **kwargs)
|
||||||
|
|
||||||
def __call__(self, parser, namespace, values, option_string=None):
|
def __call__(self, parser, namespace, values, option_string=None):
|
||||||
|
"""Add a language to the set."""
|
||||||
dest = getattr(namespace, self.dest)
|
dest = getattr(namespace, self.dest)
|
||||||
if '+' in values:
|
if '+' in values:
|
||||||
dest.update(lang for lang in values.split('+'))
|
dest.update(lang for lang in values.split('+'))
|
||||||
@@ -87,6 +97,7 @@ class LanguageSetAction(argparse.Action):
|
|||||||
|
|
||||||
|
|
||||||
def get_parser():
|
def get_parser():
|
||||||
|
"""Get the main CLI parser."""
|
||||||
parser = ArgumentParser(
|
parser = ArgumentParser(
|
||||||
prog=_PROGRAM_NAME,
|
prog=_PROGRAM_NAME,
|
||||||
allow_abbrev=True,
|
allow_abbrev=True,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class ExitCodeException(Exception):
|
|||||||
message = ""
|
message = ""
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
"""Return a string representation of the exception."""
|
||||||
super_msg = super().__str__() # Don't do str(super())
|
super_msg = super().__str__() # Don't do str(super())
|
||||||
if self.message:
|
if self.message:
|
||||||
return self.message.format(super_msg)
|
return self.message.format(super_msg)
|
||||||
@@ -94,7 +95,7 @@ class EncryptedPdfError(ExitCodeException):
|
|||||||
|
|
||||||
exit_code = ExitCode.encrypted_pdf
|
exit_code = ExitCode.encrypted_pdf
|
||||||
message = dedent(
|
message = dedent(
|
||||||
'''\
|
"""\
|
||||||
Input PDF is encrypted. The encryption must be removed to
|
Input PDF is encrypted. The encryption must be removed to
|
||||||
perform OCR.
|
perform OCR.
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ class EncryptedPdfError(ExitCodeException):
|
|||||||
|
|
||||||
You can remove the encryption using
|
You can remove the encryption using
|
||||||
qpdf --decrypt [--password=[password]] infilename
|
qpdf --decrypt [--password=[password]] infilename
|
||||||
'''
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Extra plugins. These are not automatically inserted when ocrmypdf is run.
|
||||||
|
|
||||||
|
You can use these plugins by specifying them on the command line, e.g.:
|
||||||
|
ocrmypdf --plugin ocrmypdf.extra_plugins.semfree ...
|
||||||
|
"""
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def split_every(n: int, iterable: Iterable) -> Iterator:
|
|||||||
|
|
||||||
|
|
||||||
def process_sigbus(*args):
|
def process_sigbus(*args):
|
||||||
|
"""Handle SIGBUS signal at the worker level."""
|
||||||
raise InputFileError("A worker process lost access to an input file")
|
raise InputFileError("A worker process lost access to an input file")
|
||||||
|
|
||||||
|
|
||||||
@@ -61,20 +62,21 @@ class ConnectionLogHandler(logging.handlers.QueueHandler):
|
|||||||
"""Handler used by child processes to forward log messages to parent."""
|
"""Handler used by child processes to forward log messages to parent."""
|
||||||
|
|
||||||
def __init__(self, conn: Connection) -> None:
|
def __init__(self, conn: Connection) -> None:
|
||||||
|
"""Initialize the handler."""
|
||||||
# sets the parent's queue to None - parent only touches queue
|
# sets the parent's queue to None - parent only touches queue
|
||||||
# in enqueue() which we override
|
# in enqueue() which we override
|
||||||
super().__init__(None) # type: ignore
|
super().__init__(None) # type: ignore
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
|
|
||||||
def enqueue(self, record):
|
def enqueue(self, record):
|
||||||
|
"""Enqueue a log message."""
|
||||||
self.conn.send(('log', record))
|
self.conn.send(('log', record))
|
||||||
|
|
||||||
|
|
||||||
def process_loop(
|
def process_loop(
|
||||||
conn: Connection, user_init: Callable[[], None], loglevel, task, task_args
|
conn: Connection, user_init: Callable[[], None], loglevel, task, task_args
|
||||||
):
|
):
|
||||||
"""Initialize a process pool worker"""
|
"""Initialize a process pool worker."""
|
||||||
|
|
||||||
# Install SIGBUS handler (so our parent process can abort somewhat gracefully)
|
# Install SIGBUS handler (so our parent process can abort somewhat gracefully)
|
||||||
with suppress(AttributeError): # Windows and Cygwin do not have SIGBUS
|
with suppress(AttributeError): # Windows and Cygwin do not have SIGBUS
|
||||||
# Windows and Cygwin do not have pthread_sigmask or SIGBUS
|
# Windows and Cygwin do not have pthread_sigmask or SIGBUS
|
||||||
@@ -166,8 +168,7 @@ class LambdaExecutor(Executor):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if msg_type == MessageType.result:
|
if msg_type == MessageType.result:
|
||||||
if task_finished:
|
task_finished(msg, pbar)
|
||||||
task_finished(msg, pbar)
|
|
||||||
elif msg_type == 'log':
|
elif msg_type == 'log':
|
||||||
record = msg
|
record = msg
|
||||||
logger = logging.getLogger(record.name)
|
logger = logging.getLogger(record.name)
|
||||||
@@ -185,14 +186,20 @@ class LambdaExecutor(Executor):
|
|||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def get_executor(progressbar_class):
|
def get_executor(progressbar_class):
|
||||||
|
"""Return a LambdaExecutor instance."""
|
||||||
return LambdaExecutor(pbar_class=progressbar_class)
|
return LambdaExecutor(pbar_class=progressbar_class)
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def get_logging_console():
|
def get_logging_console():
|
||||||
|
"""Return a logging.StreamHandler instance."""
|
||||||
return logging.StreamHandler()
|
return logging.StreamHandler()
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def get_progressbar_class():
|
def get_progressbar_class():
|
||||||
|
"""Return a NullProgressBar instance.
|
||||||
|
|
||||||
|
This executor cannot use a progress bar.
|
||||||
|
"""
|
||||||
return NullProgressBar
|
return NullProgressBar
|
||||||
|
|||||||
+25
-3
@@ -49,6 +49,7 @@ class Resolution(Generic[T]):
|
|||||||
__slots__ = ('x', 'y')
|
__slots__ = ('x', 'y')
|
||||||
|
|
||||||
def __init__(self, x: T, y: T):
|
def __init__(self, x: T, y: T):
|
||||||
|
"""Construct a Resolution object."""
|
||||||
self.x = x
|
self.x = x
|
||||||
self.y = y
|
self.y = y
|
||||||
|
|
||||||
@@ -57,9 +58,11 @@ class Resolution(Generic[T]):
|
|||||||
CONVERSION_ERROR = 0.002
|
CONVERSION_ERROR = 0.002
|
||||||
|
|
||||||
def round(self, ndigits: int) -> Resolution:
|
def round(self, ndigits: int) -> Resolution:
|
||||||
|
"""Round to ndigits after the decimal point."""
|
||||||
return Resolution(round(self.x, ndigits), round(self.y, ndigits))
|
return Resolution(round(self.x, ndigits), round(self.y, ndigits))
|
||||||
|
|
||||||
def to_int(self) -> Resolution[int]:
|
def to_int(self) -> Resolution[int]:
|
||||||
|
"""Round to nearest integer."""
|
||||||
return Resolution(int(round(self.x)), int(round(self.y)))
|
return Resolution(int(round(self.x)), int(round(self.y)))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -68,10 +71,12 @@ class Resolution(Generic[T]):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_square(self) -> bool:
|
def is_square(self) -> bool:
|
||||||
|
"""True if the resolution is square (x == y)."""
|
||||||
return self._isclose(self.x, self.y)
|
return self._isclose(self.x, self.y)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_finite(self) -> bool:
|
def is_finite(self) -> bool:
|
||||||
|
"""True if both x and y are finite numbers."""
|
||||||
if isinstance(self.x, SupportsFloat) and isinstance(self.y, SupportsFloat):
|
if isinstance(self.x, SupportsFloat) and isinstance(self.y, SupportsFloat):
|
||||||
return isfinite(self.x) and isfinite(self.y)
|
return isfinite(self.x) and isfinite(self.y)
|
||||||
return True
|
return True
|
||||||
@@ -79,6 +84,7 @@ class Resolution(Generic[T]):
|
|||||||
def take_max(
|
def take_max(
|
||||||
self, vals: Iterable[Any], yvals: Iterable[Any] | None = None
|
self, vals: Iterable[Any], yvals: Iterable[Any] | None = None
|
||||||
) -> Resolution:
|
) -> Resolution:
|
||||||
|
"""Return a new Resolution object with the maximum resolution of inputs."""
|
||||||
if yvals is not None:
|
if yvals is not None:
|
||||||
return Resolution(max(self.x, *vals), max(self.y, *yvals))
|
return Resolution(max(self.x, *vals), max(self.y, *yvals))
|
||||||
max_x, max_y = self.x, self.y
|
max_x, max_y = self.x, self.y
|
||||||
@@ -88,18 +94,23 @@ class Resolution(Generic[T]):
|
|||||||
return Resolution(max_x, max_y)
|
return Resolution(max_x, max_y)
|
||||||
|
|
||||||
def flip_axis(self) -> Resolution[T]:
|
def flip_axis(self) -> Resolution[T]:
|
||||||
|
"""Return a new Resolution object with x and y swapped."""
|
||||||
return Resolution(self.y, self.x)
|
return Resolution(self.y, self.x)
|
||||||
|
|
||||||
def __getitem__(self, idx: int | slice) -> T:
|
def __getitem__(self, idx: int | slice) -> T:
|
||||||
|
"""Support [0] and [1] indexing."""
|
||||||
return (self.x, self.y)[idx]
|
return (self.x, self.y)[idx]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
"""Return a string representation of the resolution."""
|
||||||
return f"{self.x:f}x{self.y:f}"
|
return f"{self.x:f}x{self.y:f}"
|
||||||
|
|
||||||
def __repr__(self): # pragma: no cover
|
def __repr__(self): # pragma: no cover
|
||||||
|
"""Return a repr() of the resolution."""
|
||||||
return f"Resolution({self.x}x{self.y} dpi)"
|
return f"Resolution({self.x}x{self.y} dpi)"
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
"""Return True if the resolution is equal to another resolution."""
|
||||||
if isinstance(other, tuple) and len(other) == 2:
|
if isinstance(other, tuple) and len(other) == 2:
|
||||||
other = Resolution(*other)
|
other = Resolution(*other)
|
||||||
if not isinstance(other, Resolution):
|
if not isinstance(other, Resolution):
|
||||||
@@ -108,7 +119,7 @@ class Resolution(Generic[T]):
|
|||||||
|
|
||||||
|
|
||||||
class NeverRaise(Exception):
|
class NeverRaise(Exception):
|
||||||
"""An exception that is never raised"""
|
"""An exception that is never raised."""
|
||||||
|
|
||||||
|
|
||||||
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
|
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
|
||||||
@@ -153,6 +164,10 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
|
|||||||
|
|
||||||
|
|
||||||
def samefile(file1: os.PathLike, file2: os.PathLike):
|
def samefile(file1: os.PathLike, file2: os.PathLike):
|
||||||
|
"""Return True if two files are the same file.
|
||||||
|
|
||||||
|
Attempts to account for different relative paths to the same file.
|
||||||
|
"""
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
return file1 == file2
|
return file1 == file2
|
||||||
else:
|
else:
|
||||||
@@ -170,7 +185,7 @@ def monotonic(seq: Sequence) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def page_number(input_file: os.PathLike) -> int:
|
def page_number(input_file: os.PathLike) -> int:
|
||||||
"""Get one-based page number implied by filename (000002.pdf -> 2)"""
|
"""Get one-based page number implied by filename (000002.pdf -> 2)."""
|
||||||
return int(os.path.basename(os.fspath(input_file))[0:6])
|
return int(os.path.basename(os.fspath(input_file))[0:6])
|
||||||
|
|
||||||
|
|
||||||
@@ -273,13 +288,20 @@ def clamp(n, smallest, largest): # mypy doesn't understand types for this
|
|||||||
|
|
||||||
|
|
||||||
def remove_all_log_handlers(logger):
|
def remove_all_log_handlers(logger):
|
||||||
"Remove all log handlers, usually used in a child process."
|
"""Remove all log handlers, usually used in a child process.
|
||||||
|
|
||||||
|
The child process inherits the log handlers from the parent process when
|
||||||
|
a fork occurs. Typically we want to remove all log handlers in the child
|
||||||
|
process so that the child process can set up a single queue handler to
|
||||||
|
forward log messages to the parent process.
|
||||||
|
"""
|
||||||
for handler in logger.handlers[:]:
|
for handler in logger.handlers[:]:
|
||||||
logger.removeHandler(handler)
|
logger.removeHandler(handler)
|
||||||
handler.close() # To ensure handlers with opened resources are released
|
handler.close() # To ensure handlers with opened resources are released
|
||||||
|
|
||||||
|
|
||||||
def pikepdf_enable_mmap():
|
def pikepdf_enable_mmap():
|
||||||
|
"""Enable pikepdf mmap."""
|
||||||
# try:
|
# try:
|
||||||
# if pikepdf._qpdf.set_access_default_mmap(True):
|
# if pikepdf._qpdf.set_access_default_mmap(True):
|
||||||
# log.debug("pikepdf mmap enabled")
|
# log.debug("pikepdf mmap enabled")
|
||||||
|
|||||||
@@ -99,11 +99,10 @@ class HocrTransformError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class HocrTransform:
|
class HocrTransform:
|
||||||
|
"""A class for converting documents from the hOCR format.
|
||||||
|
|
||||||
"""
|
|
||||||
A class for converting documents from the hOCR format.
|
|
||||||
For details of the hOCR format, see:
|
For details of the hOCR format, see:
|
||||||
http://kba.cloud/hocr-spec/
|
http://kba.cloud/hocr-spec/.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
box_pattern = re.compile(r'bbox((\s+\d+){4})')
|
box_pattern = re.compile(r'bbox((\s+\d+){4})')
|
||||||
@@ -119,6 +118,7 @@ class HocrTransform:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *, hocr_filename: str | Path, dpi: float):
|
def __init__(self, *, hocr_filename: str | Path, dpi: float):
|
||||||
|
"""Initialize the HocrTransform object."""
|
||||||
self.dpi = dpi
|
self.dpi = dpi
|
||||||
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
||||||
|
|
||||||
@@ -143,9 +143,7 @@ class HocrTransform:
|
|||||||
raise HocrTransformError("hocr file is missing page dimensions")
|
raise HocrTransformError("hocr file is missing page dimensions")
|
||||||
|
|
||||||
def __str__(self): # pragma: no cover
|
def __str__(self): # pragma: no cover
|
||||||
"""
|
"""Return the textual content of the HTML body."""
|
||||||
Return the textual content of the HTML body
|
|
||||||
"""
|
|
||||||
if self.hocr is None:
|
if self.hocr is None:
|
||||||
return ''
|
return ''
|
||||||
body = self.hocr.find(self._child_xpath('body'))
|
body = self.hocr.find(self._child_xpath('body'))
|
||||||
@@ -155,9 +153,7 @@ class HocrTransform:
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
def _get_element_text(self, element: Element):
|
def _get_element_text(self, element: Element):
|
||||||
"""
|
"""Return the textual content of the element and its children."""
|
||||||
Return the textual content of the element and its children
|
|
||||||
"""
|
|
||||||
text = ''
|
text = ''
|
||||||
if element.text is not None:
|
if element.text is not None:
|
||||||
text += element.text
|
text += element.text
|
||||||
@@ -169,10 +165,7 @@ class HocrTransform:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def element_coordinates(cls, element: Element) -> Rect:
|
def element_coordinates(cls, element: Element) -> Rect:
|
||||||
"""
|
"""Get coordinates of the bounding box around an element."""
|
||||||
Returns a tuple containing the coordinates of the bounding box around
|
|
||||||
an element
|
|
||||||
"""
|
|
||||||
out = Rect._make(0 for _ in range(4))
|
out = Rect._make(0 for _ in range(4))
|
||||||
if 'title' in element.attrib:
|
if 'title' in element.attrib:
|
||||||
matches = cls.box_pattern.search(element.attrib['title'])
|
matches = cls.box_pattern.search(element.attrib['title'])
|
||||||
@@ -183,9 +176,7 @@ class HocrTransform:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def baseline(cls, element: Element) -> tuple[float, float]:
|
def baseline(cls, element: Element) -> tuple[float, float]:
|
||||||
"""
|
"""Get baseline's slope and intercept."""
|
||||||
Returns a tuple containing the baseline slope and intercept.
|
|
||||||
"""
|
|
||||||
if 'title' in element.attrib:
|
if 'title' in element.attrib:
|
||||||
matches = cls.baseline_pattern.search(element.attrib['title'])
|
matches = cls.baseline_pattern.search(element.attrib['title'])
|
||||||
if matches:
|
if matches:
|
||||||
@@ -193,9 +184,7 @@ class HocrTransform:
|
|||||||
return (0.0, 0.0)
|
return (0.0, 0.0)
|
||||||
|
|
||||||
def pt_from_pixel(self, pxl) -> Rect:
|
def pt_from_pixel(self, pxl) -> Rect:
|
||||||
"""
|
"""Returns the quantity in PDF units (pt) given quantity in pixels."""
|
||||||
Returns the quantity in PDF units (pt) given quantity in pixels
|
|
||||||
"""
|
|
||||||
return Rect._make((c / self.dpi * inch) for c in pxl)
|
return Rect._make((c / self.dpi * inch) for c in pxl)
|
||||||
|
|
||||||
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
|
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
|
||||||
@@ -206,21 +195,9 @@ class HocrTransform:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def replace_unsupported_chars(cls, s: str) -> str:
|
def replace_unsupported_chars(cls, s: str) -> str:
|
||||||
"""
|
"""Replaces characters with those available in the Helvetica typeface."""
|
||||||
Given an input string, returns the corresponding string that:
|
|
||||||
* is available in the Helvetica facetype
|
|
||||||
* does not contain any ligature (to allow easy search in the PDF file)
|
|
||||||
"""
|
|
||||||
return s.translate(cls.ligatures)
|
return s.translate(cls.ligatures)
|
||||||
|
|
||||||
def topdown_position(self, element):
|
|
||||||
pxl_line_coords = self.element_coordinates(element)
|
|
||||||
line_box = self.pt_from_pixel(pxl_line_coords)
|
|
||||||
# Coordinates here are still in the hocr coordinate system, so 0 on the y axis
|
|
||||||
# is the top of the page and increasing values of y will move towards the
|
|
||||||
# bottom of the page.
|
|
||||||
return line_box.y2
|
|
||||||
|
|
||||||
def to_pdf(
|
def to_pdf(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -231,8 +208,8 @@ class HocrTransform:
|
|||||||
invisible_text: bool = False,
|
invisible_text: bool = False,
|
||||||
interword_spaces: bool = False,
|
interword_spaces: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Creates a PDF file with an image superimposed on top of the text.
|
||||||
Creates a PDF file with an image superimposed on top of the text.
|
|
||||||
Text is positioned according to the bounding box of the lines in
|
Text is positioned according to the bounding box of the lines in
|
||||||
the hOCR file.
|
the hOCR file.
|
||||||
The image need not be identical to the image used to create the hOCR
|
The image need not be identical to the image used to create the hOCR
|
||||||
@@ -323,6 +300,7 @@ class HocrTransform:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def polyval(cls, poly, x): # pragma: no cover
|
def polyval(cls, poly, x): # pragma: no cover
|
||||||
|
"""Calculate the value of a polynomial at a point."""
|
||||||
return x * poly[0] + poly[1]
|
return x * poly[0] + poly[1]
|
||||||
|
|
||||||
def _do_line(
|
def _do_line(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -53,20 +53,24 @@ class XrefExt(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
def img_name(root: Path, xref: Xref, ext: str) -> Path:
|
def img_name(root: Path, xref: Xref, ext: str) -> Path:
|
||||||
|
"""Return the name of an image file for a given xref and extension."""
|
||||||
return root / f'{xref:08d}{ext}'
|
return root / f'{xref:08d}{ext}'
|
||||||
|
|
||||||
|
|
||||||
def png_name(root: Path, xref: Xref) -> Path:
|
def png_name(root: Path, xref: Xref) -> Path:
|
||||||
|
"""Return the name of a PNG file for a given xref."""
|
||||||
return img_name(root, xref, '.png')
|
return img_name(root, xref, '.png')
|
||||||
|
|
||||||
|
|
||||||
def jpg_name(root: Path, xref: Xref) -> Path:
|
def jpg_name(root: Path, xref: Xref) -> Path:
|
||||||
|
"""Return the name of a JPEG file for a given xref."""
|
||||||
return img_name(root, xref, '.jpg')
|
return img_name(root, xref, '.jpg')
|
||||||
|
|
||||||
|
|
||||||
def extract_image_filter(
|
def extract_image_filter(
|
||||||
pike: Pdf, root: Path, image: Stream, xref: Xref
|
pike: Pdf, root: Path, image: Stream, xref: Xref
|
||||||
) -> tuple[PdfImage, tuple[Name, Object]] | None:
|
) -> tuple[PdfImage, tuple[Name, Object]] | None:
|
||||||
|
"""Determine if an image is extractable."""
|
||||||
del pike # unused args
|
del pike # unused args
|
||||||
del root
|
del root
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ def extract_image_filter(
|
|||||||
def extract_image_jbig2(
|
def extract_image_jbig2(
|
||||||
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||||
) -> XrefExt | None:
|
) -> XrefExt | None:
|
||||||
|
"""Extract an image, saving it as a JBIG2 file."""
|
||||||
del options # unused arg
|
del options # unused arg
|
||||||
|
|
||||||
result = extract_image_filter(pike, root, image, xref)
|
result = extract_image_filter(pike, root, image, xref)
|
||||||
@@ -165,6 +170,7 @@ def extract_image_jbig2(
|
|||||||
def extract_image_generic(
|
def extract_image_generic(
|
||||||
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||||
) -> XrefExt | None:
|
) -> XrefExt | None:
|
||||||
|
"""Generic image extraction."""
|
||||||
result = extract_image_filter(pike, root, image, xref)
|
result = extract_image_filter(pike, root, image, xref)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
@@ -230,7 +236,7 @@ def extract_images(
|
|||||||
options,
|
options,
|
||||||
extract_fn: Callable[..., XrefExt | None],
|
extract_fn: Callable[..., XrefExt | None],
|
||||||
) -> Iterator[tuple[int, XrefExt]]:
|
) -> Iterator[tuple[int, XrefExt]]:
|
||||||
"""Extract image using extract_fn
|
"""Extract image using extract_fn.
|
||||||
|
|
||||||
Enumerate images on each page, lookup their xref/ID number in the PDF.
|
Enumerate images on each page, lookup their xref/ID number in the PDF.
|
||||||
Exclude images that are soft masks (i.e. alpha transparency related).
|
Exclude images that are soft masks (i.e. alpha transparency related).
|
||||||
@@ -244,7 +250,6 @@ def extract_images(
|
|||||||
it does a tuple should be returned: (xref, ext) where .ext is the file
|
it does a tuple should be returned: (xref, ext) where .ext is the file
|
||||||
extension. extract_fn must also extract the file it finds interesting.
|
extension. extract_fn must also extract the file it finds interesting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
include_xrefs: MutableSet[Xref] = set()
|
include_xrefs: MutableSet[Xref] = set()
|
||||||
exclude_xrefs: MutableSet[Xref] = set()
|
exclude_xrefs: MutableSet[Xref] = set()
|
||||||
pageno_for_xref = {}
|
pageno_for_xref = {}
|
||||||
@@ -289,8 +294,7 @@ def extract_images(
|
|||||||
def extract_images_generic(
|
def extract_images_generic(
|
||||||
pike: Pdf, root: Path, options
|
pike: Pdf, root: Path, options
|
||||||
) -> tuple[list[Xref], list[Xref]]:
|
) -> tuple[list[Xref], list[Xref]]:
|
||||||
"""Extract any >=2bpp image we think we can improve"""
|
"""Extract any >=2bpp image we think we can improve."""
|
||||||
|
|
||||||
jpegs = []
|
jpegs = []
|
||||||
pngs = []
|
pngs = []
|
||||||
for _, xref_ext in extract_images(pike, root, options, extract_image_generic):
|
for _, xref_ext in extract_images(pike, root, options, extract_image_generic):
|
||||||
@@ -304,8 +308,7 @@ def extract_images_generic(
|
|||||||
|
|
||||||
|
|
||||||
def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefExt]]:
|
def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefExt]]:
|
||||||
"""Extract any bitonal image that we think we can improve as JBIG2"""
|
"""Extract any bitonal image that we think we can improve as JBIG2."""
|
||||||
|
|
||||||
jbig2_groups = defaultdict(list)
|
jbig2_groups = defaultdict(list)
|
||||||
for pageno, xref_ext in extract_images(pike, root, options, extract_image_jbig2):
|
for pageno, xref_ext in extract_images(pike, root, options, extract_image_jbig2):
|
||||||
group = pageno // options.jbig2_page_group_size
|
group = pageno // options.jbig2_page_group_size
|
||||||
@@ -318,7 +321,7 @@ def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefE
|
|||||||
def _produce_jbig2_images(
|
def _produce_jbig2_images(
|
||||||
jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor
|
jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Produce JBIG2 images from their groups"""
|
"""Produce JBIG2 images from their groups."""
|
||||||
|
|
||||||
def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]):
|
def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]):
|
||||||
for group, xref_exts in groups.items():
|
for group, xref_exts in groups.items():
|
||||||
@@ -423,6 +426,8 @@ def _optimize_jpeg(args: tuple[Xref, Path, Path, int]) -> tuple[Xref, Path | Non
|
|||||||
def transcode_jpegs(
|
def transcode_jpegs(
|
||||||
pike: Pdf, jpegs: Sequence[Xref], root: Path, options, executor: Executor
|
pike: Pdf, jpegs: Sequence[Xref], root: Path, options, executor: Executor
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Optimize JPEGs according to optimization settings."""
|
||||||
|
|
||||||
def jpeg_args() -> Iterator[tuple[Xref, Path, Path, int]]:
|
def jpeg_args() -> Iterator[tuple[Xref, Path, Path, int]]:
|
||||||
for xref in jpegs:
|
for xref in jpegs:
|
||||||
in_jpg = jpg_name(root, xref)
|
in_jpg = jpg_name(root, xref)
|
||||||
@@ -481,6 +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:
|
def deflate_jpegs(pike: Pdf, root: Path, options, executor: Executor) -> None:
|
||||||
|
"""Apply FlateDecode to JPEGs.
|
||||||
|
|
||||||
|
This is a lossless compression method that is supported by all PDF viewers,
|
||||||
|
and generally results in a smaller file size compared to straight DCTDecode
|
||||||
|
images.
|
||||||
|
"""
|
||||||
jpegs = []
|
jpegs = []
|
||||||
for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg):
|
for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg):
|
||||||
xref = xref_ext.xref
|
xref = xref_ext.xref
|
||||||
@@ -568,6 +579,7 @@ def transcode_pngs(
|
|||||||
options,
|
options,
|
||||||
executor,
|
executor,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Apply lossy transcoding to PNGs."""
|
||||||
modified: MutableSet[Xref] = set()
|
modified: MutableSet[Xref] = set()
|
||||||
if options.optimize >= 2:
|
if options.optimize >= 2:
|
||||||
png_quality = (
|
png_quality = (
|
||||||
@@ -614,6 +626,7 @@ def optimize(
|
|||||||
save_settings,
|
save_settings,
|
||||||
executor: Executor = DEFAULT_EXECUTOR,
|
executor: Executor = DEFAULT_EXECUTOR,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
"""Optimize images in a PDF file."""
|
||||||
options = context.options
|
options = context.options
|
||||||
if options.optimize == 0:
|
if options.optimize == 0:
|
||||||
safe_symlink(input_file, output_file)
|
safe_symlink(input_file, output_file)
|
||||||
@@ -670,11 +683,12 @@ def optimize(
|
|||||||
|
|
||||||
|
|
||||||
def main(infile, outfile, level, jobs=1):
|
def main(infile, outfile, level, jobs=1):
|
||||||
|
"""Entry point for direct optimization of a file."""
|
||||||
from shutil import copy # pylint: disable=import-outside-toplevel
|
from shutil import copy # pylint: disable=import-outside-toplevel
|
||||||
from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
|
from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
class OptimizeOptions:
|
class OptimizeOptions:
|
||||||
"""Emulate ocrmypdf's options"""
|
"""Emulate ocrmypdf's options."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy
|
self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""
|
"""Utilities for PDF/A production and confirmation with Ghostspcript."""
|
||||||
Utilities for PDF/A production and confirmation with Ghostspcript.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,8 +23,8 @@ def _postscript_objdef(
|
|||||||
alias: str,
|
alias: str,
|
||||||
dictionary: dict[str, str],
|
dictionary: dict[str, str],
|
||||||
*,
|
*,
|
||||||
stream_name: str = None,
|
stream_name: str | None = None,
|
||||||
stream_data: bytes = None,
|
stream_data: bytes | None = None,
|
||||||
) -> Iterator[str]:
|
) -> Iterator[str]:
|
||||||
assert (stream_name is None) == (stream_data is None)
|
assert (stream_name is None) == (stream_data is None)
|
||||||
|
|
||||||
@@ -75,7 +73,7 @@ def _make_postscript(icc_name: str, icc_data: bytes, colors: int) -> Iterator[st
|
|||||||
|
|
||||||
|
|
||||||
def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
||||||
"""Create a Postscript PDFMARK file for Ghostscript PDF/A conversion
|
"""Create a Postscript PDFMARK file for Ghostscript PDF/A conversion.
|
||||||
|
|
||||||
pdfmark is an extension to the Postscript language that describes some PDF
|
pdfmark is an extension to the Postscript language that describes some PDF
|
||||||
features like bookmarks and annotations. It was originally specified Adobe
|
features like bookmarks and annotations. It was originally specified Adobe
|
||||||
@@ -84,7 +82,7 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
|||||||
Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript
|
Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript
|
||||||
to create a PDF/A, we need to create a pdfmark file with the necessary metadata.
|
to create a PDF/A, we need to create a pdfmark file with the necessary metadata.
|
||||||
|
|
||||||
This function takes care of the many version-specific bugs and pecularities in
|
This function takes care of the many version-specific bugs and peculiarities in
|
||||||
Ghostscript's handling of pdfmark.
|
Ghostscript's handling of pdfmark.
|
||||||
|
|
||||||
The only information we put in specifies that we want the file to be a
|
The only information we put in specifies that we want the file to be a
|
||||||
@@ -118,7 +116,6 @@ def file_claims_pdfa(filename: Path):
|
|||||||
This only checks if the XMP metadata contains a PDF/A marker. It does not
|
This only checks if the XMP metadata contains a PDF/A marker. It does not
|
||||||
do full PDF/A validation.
|
do full PDF/A validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with pikepdf.open(filename) as pdf:
|
with pikepdf.open(filename) as pdf:
|
||||||
pdfmeta = pdf.open_metadata()
|
pdfmeta = pdf.open_metadata()
|
||||||
if not pdfmeta.pdfa_status:
|
if not pdfmeta.pdfa_status:
|
||||||
|
|||||||
@@ -7,3 +7,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo
|
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo
|
||||||
|
|
||||||
|
__all__ = ["Colorspace", "Encoding", "PageInfo", "PdfInfo"]
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class TextMarker:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_stack(graphobjs):
|
def _normalize_stack(graphobjs):
|
||||||
"""Convert runs of qQ's in the stack into single graphobjs"""
|
"""Convert runs of qQ's in the stack into single graphobjs."""
|
||||||
for operands, operator in graphobjs:
|
for operands, operator in graphobjs:
|
||||||
operator = str(operator)
|
operator = str(operator)
|
||||||
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
|
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
|
||||||
@@ -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
|
undefined in the spec, but we just pretend nothing happened and leave the
|
||||||
CTM unchanged.
|
CTM unchanged.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
stack = []
|
stack = []
|
||||||
ctm = PdfMatrix(initial_shorthand)
|
ctm = PdfMatrix(initial_shorthand)
|
||||||
xobject_settings: list[XobjectSettings] = []
|
xobject_settings: list[XobjectSettings] = []
|
||||||
@@ -307,7 +306,6 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
|||||||
/MediaBox.
|
/MediaBox.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
|
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
|
||||||
|
|
||||||
# Calculate the width and height of the image in PDF units
|
# Calculate the width and height of the image in PDF units
|
||||||
@@ -324,7 +322,12 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
|||||||
|
|
||||||
|
|
||||||
class ImageInfo:
|
class ImageInfo:
|
||||||
"""Information about an image found in a PDF."""
|
"""Information about an image found in a PDF.
|
||||||
|
|
||||||
|
This gathers information from pikepdf and pdfminer.six, and is pickle-able
|
||||||
|
so that it can be passed to a worker process, unlike objects from those
|
||||||
|
libraries.
|
||||||
|
"""
|
||||||
|
|
||||||
DPI_PREC = Decimal('1.000')
|
DPI_PREC = Decimal('1.000')
|
||||||
|
|
||||||
@@ -339,6 +342,7 @@ class ImageInfo:
|
|||||||
inline: PdfInlineImage | None = None,
|
inline: PdfInlineImage | None = None,
|
||||||
shorthand=None,
|
shorthand=None,
|
||||||
):
|
):
|
||||||
|
"""Initialize an ImageInfo."""
|
||||||
self._name = str(name)
|
self._name = str(name)
|
||||||
self._shorthand = shorthand
|
self._shorthand = shorthand
|
||||||
|
|
||||||
@@ -405,45 +409,62 @@ class ImageInfo:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
"""Name of the image as it appears in the PDF."""
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type_(self):
|
def type_(self):
|
||||||
|
"""Type of image, either 'image' or 'stencil'."""
|
||||||
return self._type
|
return self._type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width(self):
|
def width(self):
|
||||||
|
"""Width of the image in pixels."""
|
||||||
return self._width
|
return self._width
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def height(self):
|
def height(self):
|
||||||
|
"""Height of the image in pixels."""
|
||||||
return self._height
|
return self._height
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bpc(self):
|
def bpc(self):
|
||||||
|
"""Bits per component."""
|
||||||
return self._bpc
|
return self._bpc
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color(self):
|
def color(self):
|
||||||
|
"""Colorspace of the image."""
|
||||||
return self._color if self._color is not None else '?'
|
return self._color if self._color is not None else '?'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def comp(self):
|
def comp(self):
|
||||||
|
"""Number of components/channels in the image."""
|
||||||
return self._comp if self._comp is not None else '?'
|
return self._comp if self._comp is not None else '?'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enc(self):
|
def enc(self):
|
||||||
|
"""Encoding of the image."""
|
||||||
return self._enc if self._enc is not None else 'image'
|
return self._enc if self._enc is not None else 'image'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def renderable(self):
|
def renderable(self) -> bool:
|
||||||
|
"""Whether the image is renderable.
|
||||||
|
|
||||||
|
Some PDFs in the wild have invalid images that are not renderable.
|
||||||
|
"""
|
||||||
return self.dpi.is_finite and self.width >= 0 and self.height >= 0
|
return self.dpi.is_finite and self.width >= 0 and self.height >= 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dpi(self):
|
def dpi(self):
|
||||||
|
"""Dots per inch of the image.
|
||||||
|
|
||||||
|
Calculated based on where and how the image is drawn in the PDF.
|
||||||
|
"""
|
||||||
return _get_dpi(self._shorthand, (self._width, self._height))
|
return _get_dpi(self._shorthand, (self._width, self._height))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return a string representation of the image."""
|
||||||
return (
|
return (
|
||||||
f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} "
|
f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} "
|
||||||
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
|
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
|
||||||
@@ -451,8 +472,7 @@ class ImageInfo:
|
|||||||
|
|
||||||
|
|
||||||
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
||||||
"Find inline images in the contentstream"
|
"""Find inline images in the contentstream."""
|
||||||
|
|
||||||
for n, inline in enumerate(contentsinfo.inline_images):
|
for n, inline in enumerate(contentsinfo.inline_images):
|
||||||
yield ImageInfo(
|
yield ImageInfo(
|
||||||
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
||||||
@@ -460,7 +480,7 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
|||||||
|
|
||||||
|
|
||||||
def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
||||||
"""Search for all XObject-based images in the container
|
"""Search for all XObject-based images in the container.
|
||||||
|
|
||||||
Usually the container is a page, but it could also be a Form XObject
|
Usually the container is a page, but it could also be a Form XObject
|
||||||
that contains images. Filter out the Form XObjects which are dealt with
|
that contains images. Filter out the Form XObjects which are dealt with
|
||||||
@@ -471,7 +491,6 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
|||||||
since the object does not know its own name.
|
since the object does not know its own name.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if '/Resources' not in container:
|
if '/Resources' not in container:
|
||||||
return
|
return
|
||||||
resources = container['/Resources']
|
resources = container['/Resources']
|
||||||
@@ -488,14 +507,13 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
|||||||
def _find_regular_images(
|
def _find_regular_images(
|
||||||
container: Object, contentsinfo: ContentsInfo
|
container: Object, contentsinfo: ContentsInfo
|
||||||
) -> Iterator[ImageInfo]:
|
) -> Iterator[ImageInfo]:
|
||||||
"""Find images stored in the container's /Resources /XObject
|
"""Find images stored in the container's /Resources /XObject.
|
||||||
|
|
||||||
Usually the container is a page, but it could also be a Form XObject
|
Usually the container is a page, but it could also be a Form XObject
|
||||||
that contains images.
|
that contains images.
|
||||||
|
|
||||||
Generates images with their DPI at time of drawing.
|
Generates images with their DPI at time of drawing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for pdfimage, xobj in _image_xobjects(container):
|
for pdfimage, xobj in _image_xobjects(container):
|
||||||
if xobj not in contentsinfo.name_index:
|
if xobj not in contentsinfo.name_index:
|
||||||
continue
|
continue
|
||||||
@@ -512,7 +530,7 @@ def _find_regular_images(
|
|||||||
|
|
||||||
|
|
||||||
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
||||||
"""Find any images that are in Form XObjects in the container
|
"""Find any images that are in Form XObjects in the container.
|
||||||
|
|
||||||
The container may be a page, or a parent Form XObject.
|
The container may be a page, or a parent Form XObject.
|
||||||
|
|
||||||
@@ -525,7 +543,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
|||||||
xobjs = resources['/XObject'].as_dict()
|
xobjs = resources['/XObject'].as_dict()
|
||||||
for xobj in xobjs:
|
for xobj in xobjs:
|
||||||
candidate = xobjs[xobj]
|
candidate = xobjs[xobj]
|
||||||
if candidate['/Subtype'] != '/Form':
|
if candidate is None or candidate['/Subtype'] != '/Form':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
form_xobject = candidate
|
form_xobject = candidate
|
||||||
@@ -546,7 +564,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
|||||||
def _process_content_streams(
|
def _process_content_streams(
|
||||||
*, pdf: Pdf, container: Object, shorthand=None
|
*, pdf: Pdf, container: Object, shorthand=None
|
||||||
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
||||||
"""Find all individual instances of images drawn in the container
|
"""Find all individual instances of images drawn in the container.
|
||||||
|
|
||||||
Usually the container is a page, but it may also be a Form XObject.
|
Usually the container is a page, but it may also be a Form XObject.
|
||||||
|
|
||||||
@@ -563,7 +581,6 @@ def _process_content_streams(
|
|||||||
downsampling.
|
downsampling.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if container.get('/Type') == '/Page' and '/Contents' in container:
|
if container.get('/Type') == '/Page' and '/Contents' in container:
|
||||||
initial_shorthand = shorthand or UNIT_SQUARE
|
initial_shorthand = shorthand or UNIT_SQUARE
|
||||||
elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form':
|
elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form':
|
||||||
@@ -595,8 +612,7 @@ def _process_content_streams(
|
|||||||
|
|
||||||
|
|
||||||
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
|
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
|
||||||
"""Smarter text detection that ignores text in margins"""
|
"""Smarter text detection that ignores text in margins."""
|
||||||
|
|
||||||
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
||||||
|
|
||||||
margin_ratio = 0.125
|
margin_ratio = 0.125
|
||||||
@@ -608,10 +624,11 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
def rects_intersect(a: FloatRect, b: FloatRect) -> bool:
|
def rects_intersect(a: FloatRect, b: FloatRect) -> bool:
|
||||||
"""
|
"""Check if two 4-tuple rects intersect.
|
||||||
|
|
||||||
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
|
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
|
||||||
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
|
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
|
||||||
Formula assumes all boxes are in first quadrant
|
Formula assumes all boxes are in first quadrant.
|
||||||
"""
|
"""
|
||||||
return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1]
|
return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1]
|
||||||
|
|
||||||
@@ -624,7 +641,7 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
|
|||||||
|
|
||||||
|
|
||||||
def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]:
|
def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]:
|
||||||
"""Extract only limited content from text boxes
|
"""Extract only limited content from text boxes.
|
||||||
|
|
||||||
We do this to save memory and ensure that our objects are pickleable.
|
We do this to save memory and ensure that our objects are pickleable.
|
||||||
"""
|
"""
|
||||||
@@ -740,6 +757,7 @@ class PageInfo:
|
|||||||
check_pages: Container[int],
|
check_pages: Container[int],
|
||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
):
|
):
|
||||||
|
"""Initialize a PageInfo object."""
|
||||||
self._pageno = pageno
|
self._pageno = pageno
|
||||||
self._infile = infile
|
self._infile = infile
|
||||||
self._detailed_analysis = detailed_analysis
|
self._detailed_analysis = detailed_analysis
|
||||||
@@ -816,40 +834,56 @@ class PageInfo:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def pageno(self) -> int:
|
def pageno(self) -> int:
|
||||||
|
"""Return page number (0-based)."""
|
||||||
return self._pageno
|
return self._pageno
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_text(self) -> bool:
|
def has_text(self) -> bool:
|
||||||
|
"""Return True if page has text, False if not or unknown."""
|
||||||
return bool(self._has_text)
|
return bool(self._has_text)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_corrupt_text(self) -> bool:
|
def has_corrupt_text(self) -> bool:
|
||||||
|
"""Return True if page has corrupt text, False if not or unknown."""
|
||||||
if not self._detailed_analysis:
|
if not self._detailed_analysis:
|
||||||
raise NotImplementedError('Did not do detailed analysis')
|
raise NotImplementedError('Did not do detailed analysis')
|
||||||
return any(tbox.is_corrupt for tbox in self._textboxes)
|
return any(tbox.is_corrupt for tbox in self._textboxes)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_vector(self) -> bool:
|
def has_vector(self) -> bool:
|
||||||
|
"""Return True if page has vector graphics, False if not or unknown.
|
||||||
|
|
||||||
|
Vector graphics are sometimes used to draw fonts, so it may not be
|
||||||
|
obvious on visual inspection whether a page has text or not.
|
||||||
|
"""
|
||||||
return bool(self._has_vector)
|
return bool(self._has_vector)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width_inches(self) -> Decimal:
|
def width_inches(self) -> Decimal:
|
||||||
|
"""Return width of page in inches."""
|
||||||
return self._width_inches
|
return self._width_inches
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def height_inches(self) -> Decimal:
|
def height_inches(self) -> Decimal:
|
||||||
|
"""Return height of page in inches."""
|
||||||
return self._height_inches
|
return self._height_inches
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width_pixels(self) -> int:
|
def width_pixels(self) -> int:
|
||||||
|
"""Return width of page in pixels."""
|
||||||
return int(round(float(self.width_inches) * self.dpi.x))
|
return int(round(float(self.width_inches) * self.dpi.x))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def height_pixels(self) -> int:
|
def height_pixels(self) -> int:
|
||||||
|
"""Return height of page in pixels."""
|
||||||
return int(round(float(self.height_inches) * self.dpi.y))
|
return int(round(float(self.height_inches) * self.dpi.y))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rotation(self) -> int:
|
def rotation(self) -> int:
|
||||||
|
"""Return rotation of page in degrees.
|
||||||
|
|
||||||
|
Will only be a multiple of 90.
|
||||||
|
"""
|
||||||
return self._rotate
|
return self._rotate
|
||||||
|
|
||||||
@rotation.setter
|
@rotation.setter
|
||||||
@@ -860,10 +894,13 @@ class PageInfo:
|
|||||||
raise ValueError("rotation must be a cardinal angle")
|
raise ValueError("rotation must be a cardinal angle")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def images(self):
|
def images(self) -> list[ImageInfo]:
|
||||||
|
"""Return images."""
|
||||||
return self._images
|
return self._images
|
||||||
|
|
||||||
def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None):
|
def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None):
|
||||||
|
"""Return textareas bounding boxes in PDF coordinates on the page."""
|
||||||
|
|
||||||
def predicate(obj, want_visible, want_corrupt):
|
def predicate(obj, want_visible, want_corrupt):
|
||||||
result = True
|
result = True
|
||||||
if want_visible is not None:
|
if want_visible is not None:
|
||||||
@@ -883,22 +920,26 @@ class PageInfo:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def dpi(self) -> Resolution:
|
def dpi(self) -> Resolution:
|
||||||
|
"""Return DPI needed to render all images on the page."""
|
||||||
if self._dpi is None:
|
if self._dpi is None:
|
||||||
return Resolution(0.0, 0.0)
|
return Resolution(0.0, 0.0)
|
||||||
return self._dpi
|
return self._dpi
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def userunit(self) -> Decimal:
|
def userunit(self) -> Decimal:
|
||||||
|
"""Return user unit of page."""
|
||||||
return self._userunit
|
return self._userunit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_version(self) -> str:
|
def min_version(self) -> str:
|
||||||
|
"""Return minimum PDF version needed to render this page."""
|
||||||
if self.userunit is not None:
|
if self.userunit is not None:
|
||||||
return '1.6'
|
return '1.6'
|
||||||
else:
|
else:
|
||||||
return '1.5'
|
return '1.5'
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return string representation."""
|
||||||
return (
|
return (
|
||||||
f'<PageInfo '
|
f'<PageInfo '
|
||||||
f'pageno={self.pageno} {self.width_inches}"x{self.height_inches}" '
|
f'pageno={self.pageno} {self.width_inches}"x{self.height_inches}" '
|
||||||
@@ -910,7 +951,7 @@ DEFAULT_EXECUTOR = SerialExecutor()
|
|||||||
|
|
||||||
|
|
||||||
class PdfInfo:
|
class PdfInfo:
|
||||||
"""Get summary information about a PDF"""
|
"""Get summary information about a PDF."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -918,10 +959,11 @@ class PdfInfo:
|
|||||||
*,
|
*,
|
||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
progbar: bool = False,
|
progbar: bool = False,
|
||||||
max_workers: int = None,
|
max_workers: int | None = None,
|
||||||
check_pages=None,
|
check_pages=None,
|
||||||
executor: Executor = DEFAULT_EXECUTOR,
|
executor: Executor = DEFAULT_EXECUTOR,
|
||||||
):
|
):
|
||||||
|
"""Initialize."""
|
||||||
self._infile = infile
|
self._infile = infile
|
||||||
if check_pages is None:
|
if check_pages is None:
|
||||||
check_pages = range(0, 1_000_000_000)
|
check_pages = range(0, 1_000_000_000)
|
||||||
@@ -948,42 +990,56 @@ class PdfInfo:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def pages(self) -> Sequence[PageInfo | None]:
|
def pages(self) -> Sequence[PageInfo | None]:
|
||||||
|
"""Return list of PageInfo objects, one per page in the PDF."""
|
||||||
return self._pages
|
return self._pages
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_version(self) -> str:
|
def min_version(self) -> str:
|
||||||
|
"""Return minimum PDF version needed to render this PDF."""
|
||||||
# The minimum PDF is the maximum version that any particular page needs
|
# The minimum PDF is the maximum version that any particular page needs
|
||||||
return max(page.min_version for page in self.pages if page)
|
return max(page.min_version for page in self.pages if page)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_userunit(self) -> bool:
|
def has_userunit(self) -> bool:
|
||||||
|
"""Return True if any page has a user unit."""
|
||||||
return any(page.userunit != 1.0 for page in self.pages if page)
|
return any(page.userunit != 1.0 for page in self.pages if page)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_acroform(self) -> bool:
|
def has_acroform(self) -> bool:
|
||||||
|
"""Return True if any page has an AcroForm."""
|
||||||
return self._has_acroform
|
return self._has_acroform
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str | Path:
|
def filename(self) -> str | Path:
|
||||||
|
"""Return filename of PDF."""
|
||||||
if not isinstance(self._infile, (str, Path)):
|
if not isinstance(self._infile, (str, Path)):
|
||||||
raise NotImplementedError("can't get filename from stream")
|
raise NotImplementedError("can't get filename from stream")
|
||||||
return self._infile
|
return self._infile
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def needs_rendering(self) -> bool:
|
def needs_rendering(self) -> bool:
|
||||||
|
"""Return True if PDF contains XFA forms.
|
||||||
|
|
||||||
|
XFA forms are not supported by most standard PDF renderers, so we
|
||||||
|
need to detect and suppress them.
|
||||||
|
"""
|
||||||
return self._needs_rendering
|
return self._needs_rendering
|
||||||
|
|
||||||
def __getitem__(self, item) -> PageInfo:
|
def __getitem__(self, item) -> PageInfo:
|
||||||
|
"""Return PageInfo object for page number `item`."""
|
||||||
return self._pages[item]
|
return self._pages[item]
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
|
"""Return number of pages in PDF."""
|
||||||
return len(self._pages)
|
return len(self._pages)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return string representation."""
|
||||||
return f"<PdfInfo('...'), page count={len(self)}>"
|
return f"<PdfInfo('...'), page count={len(self)}>"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""Run as a script."""
|
||||||
import argparse # pylint: disable=import-outside-toplevel
|
import argparse # pylint: disable=import-outside-toplevel
|
||||||
from pprint import pprint # pylint: disable=import-outside-toplevel
|
from pprint import pprint # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,16 @@ original_pdfsimplefont_init = PDFSimpleFont.__init__
|
|||||||
|
|
||||||
|
|
||||||
def pdfsimplefont__init__(self, descriptor, widths, spec):
|
def pdfsimplefont__init__(self, descriptor, widths, spec):
|
||||||
|
"""Monkeypatch pdfminer.six PDFSimpleFont.__init__.
|
||||||
|
|
||||||
|
If there is no ToUnicode and no Encoding, pdfminer.six assumes that Unicode
|
||||||
|
conversion is possible. This is incorrect, according to PDF Reference Manual
|
||||||
|
9.10.2. This patch fixes that.
|
||||||
|
"""
|
||||||
# Font encoding is specified either by a name of
|
# Font encoding is specified either by a name of
|
||||||
# built-in encoding or a dictionary that describes
|
# built-in encoding or a dictionary that describes
|
||||||
# the differences.
|
# the differences.
|
||||||
original_pdfsimplefont_init(self, descriptor, widths, spec)
|
original_pdfsimplefont_init(self, descriptor, widths, spec)
|
||||||
# pdfminer is incorrect. If there is no ToUnicode and no Encoding, do not
|
|
||||||
# assume Unicode conversion is possible. RM 9.10.2
|
|
||||||
if not self.unicode_map and 'Encoding' not in spec:
|
if not self.unicode_map and 'Encoding' not in spec:
|
||||||
self.cid2unicode = {}
|
self.cid2unicode = {}
|
||||||
return
|
return
|
||||||
@@ -48,6 +52,13 @@ PDFSimpleFont.__init__ = pdfsimplefont__init__
|
|||||||
|
|
||||||
|
|
||||||
def pdftype3font__pscript5_get_height(self):
|
def pdftype3font__pscript5_get_height(self):
|
||||||
|
"""Monkeypatch for PScript5.dll PDFs.
|
||||||
|
|
||||||
|
The height of Type3 fonts is known to be incorrect in PScript5.dll
|
||||||
|
generated PDFs. This patch attempts to correct the height by
|
||||||
|
using the bbox height if it is available, otherwise using the
|
||||||
|
ascent and descent.
|
||||||
|
"""
|
||||||
h = self.bbox[3] - self.bbox[1]
|
h = self.bbox[3] - self.bbox[1]
|
||||||
if h == 0:
|
if h == 0:
|
||||||
h = self.ascent - self.descent
|
h = self.ascent - self.descent
|
||||||
@@ -55,15 +66,27 @@ def pdftype3font__pscript5_get_height(self):
|
|||||||
|
|
||||||
|
|
||||||
def pdftype3font__pscript5_get_descent(self):
|
def pdftype3font__pscript5_get_descent(self):
|
||||||
|
"""Monkeypatch for PScript5.dll PDFs.
|
||||||
|
|
||||||
|
The descent of Type3 fonts is known to be incorrect in PScript5.dll
|
||||||
|
generated PDFs. This patch attempts to correct the descent by
|
||||||
|
using the vscale.
|
||||||
|
"""
|
||||||
return self.descent * copysign(1.0, self.vscale)
|
return self.descent * copysign(1.0, self.vscale)
|
||||||
|
|
||||||
|
|
||||||
def pdftype3font__pscript5_get_ascent(self):
|
def pdftype3font__pscript5_get_ascent(self):
|
||||||
|
"""Monkeypatch for PScript5.dll PDFs.
|
||||||
|
|
||||||
|
The ascent of Type3 fonts is known to be incorrect in PScript5.dll
|
||||||
|
generated PDFs. This patch attempts to correct the ascent by
|
||||||
|
using the vscale.
|
||||||
|
"""
|
||||||
return self.ascent * copysign(1.0, self.vscale)
|
return self.ascent * copysign(1.0, self.vscale)
|
||||||
|
|
||||||
|
|
||||||
class LTStateAwareChar(LTChar):
|
class LTStateAwareChar(LTChar):
|
||||||
"""A subclass of LTChar that tracks text render mode at time of drawing"""
|
"""A subclass of LTChar that tracks text render mode at time of drawing."""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'rendermode',
|
'rendermode',
|
||||||
@@ -96,6 +119,7 @@ class LTStateAwareChar(LTChar):
|
|||||||
graphicstate,
|
graphicstate,
|
||||||
textstate,
|
textstate,
|
||||||
):
|
):
|
||||||
|
"""Initialize."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
matrix,
|
matrix,
|
||||||
font,
|
font,
|
||||||
@@ -111,7 +135,7 @@ class LTStateAwareChar(LTChar):
|
|||||||
self.rendermode = textstate.render
|
self.rendermode = textstate.render
|
||||||
|
|
||||||
def is_compatible(self, obj):
|
def is_compatible(self, obj):
|
||||||
"""Check if characters can be combined into a textline
|
"""Check if characters can be combined into a textline.
|
||||||
|
|
||||||
We consider characters compatible if:
|
We consider characters compatible if:
|
||||||
- the Unicode mapping is known, and both have the same render mode
|
- the Unicode mapping is known, and both have the same render mode
|
||||||
@@ -129,11 +153,13 @@ class LTStateAwareChar(LTChar):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_text(self):
|
def get_text(self):
|
||||||
|
"""Get text from this character."""
|
||||||
if isinstance(self._text, tuple):
|
if isinstance(self._text, tuple):
|
||||||
return '\ufffd' # standard 'Unknown symbol'
|
return '\ufffd' # standard 'Unknown symbol'
|
||||||
return self._text
|
return self._text
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return a string representation of this object."""
|
||||||
return (
|
return (
|
||||||
f"<{self.__class__.__name__} "
|
f"<{self.__class__.__name__} "
|
||||||
f"{bbox2str(self.bbox)} "
|
f"{bbox2str(self.bbox)} "
|
||||||
@@ -146,19 +172,22 @@ class LTStateAwareChar(LTChar):
|
|||||||
|
|
||||||
|
|
||||||
class TextPositionTracker(PDFLayoutAnalyzer):
|
class TextPositionTracker(PDFLayoutAnalyzer):
|
||||||
"""A page layout analyzer that pays attention to text visibility"""
|
"""A page layout analyzer that pays attention to text visibility."""
|
||||||
|
|
||||||
def __init__(self, rsrcmgr, pageno=1, laparams=None):
|
def __init__(self, rsrcmgr, pageno=1, laparams=None):
|
||||||
|
"""Initialize the layout analyzer."""
|
||||||
super().__init__(rsrcmgr, pageno, laparams)
|
super().__init__(rsrcmgr, pageno, laparams)
|
||||||
self.textstate = None
|
self.textstate = None
|
||||||
self.result = None
|
self.result = None
|
||||||
self.cur_item = None # not defined in pdfminer code as it should be
|
self.cur_item = None # not defined in pdfminer code as it should be
|
||||||
|
|
||||||
def begin_page(self, page, ctm):
|
def begin_page(self, page, ctm):
|
||||||
|
"""Begin processing of a page."""
|
||||||
super().begin_page(page, ctm)
|
super().begin_page(page, ctm)
|
||||||
self.cur_item = LTPage(self.pageno, page.mediabox)
|
self.cur_item = LTPage(self.pageno, page.mediabox)
|
||||||
|
|
||||||
def end_page(self, page):
|
def end_page(self, page):
|
||||||
|
"""End processing of a page."""
|
||||||
assert not self._stack, str(len(self._stack))
|
assert not self._stack, str(len(self._stack))
|
||||||
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
||||||
if self.laparams is not None:
|
if self.laparams is not None:
|
||||||
@@ -167,12 +196,14 @@ class TextPositionTracker(PDFLayoutAnalyzer):
|
|||||||
self.receive_layout(self.cur_item)
|
self.receive_layout(self.cur_item)
|
||||||
|
|
||||||
def render_string(self, textstate, seq, ncs, graphicstate):
|
def render_string(self, textstate, seq, ncs, graphicstate):
|
||||||
|
"""Respond to render string event by updating text state."""
|
||||||
self.textstate = textstate.copy()
|
self.textstate = textstate.copy()
|
||||||
super().render_string(self.textstate, seq, ncs, graphicstate)
|
super().render_string(self.textstate, seq, ncs, graphicstate)
|
||||||
|
|
||||||
def render_char(
|
def render_char(
|
||||||
self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate
|
self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate
|
||||||
):
|
):
|
||||||
|
"""Respond to render char event by updating text state."""
|
||||||
try:
|
try:
|
||||||
text = font.to_unichr(cid)
|
text = font.to_unichr(cid)
|
||||||
assert isinstance(text, str), str(type(text))
|
assert isinstance(text, str), str(type(text))
|
||||||
@@ -197,17 +228,21 @@ class TextPositionTracker(PDFLayoutAnalyzer):
|
|||||||
return item.adv
|
return item.adv
|
||||||
|
|
||||||
def handle_undefined_char(self, font, cid):
|
def handle_undefined_char(self, font, cid):
|
||||||
|
"""Handle undefined character."""
|
||||||
# log.info('undefined: %r, %r', font, cid)
|
# log.info('undefined: %r, %r', font, cid)
|
||||||
return (font.fontname, cid)
|
return (font.fontname, cid)
|
||||||
|
|
||||||
def receive_layout(self, ltpage):
|
def receive_layout(self, ltpage):
|
||||||
|
"""Receive layout handler."""
|
||||||
self.result = ltpage
|
self.result = ltpage
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self):
|
||||||
|
"""Get the result of the analysis."""
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
def get_page_analysis(infile, pageno, pscript5_mode):
|
def get_page_analysis(infile, pageno, pscript5_mode):
|
||||||
|
"""Get the page analysis for a given page."""
|
||||||
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||||
disable_boxes_flow = None
|
disable_boxes_flow = None
|
||||||
dev = TextPositionTracker(
|
dev = TextPositionTracker(
|
||||||
@@ -248,6 +283,7 @@ def get_page_analysis(infile, pageno, pscript5_mode):
|
|||||||
|
|
||||||
|
|
||||||
def get_text_boxes(obj):
|
def get_text_boxes(obj):
|
||||||
|
"""Get the text boxes attached to the current node."""
|
||||||
for child in obj:
|
for child in obj:
|
||||||
if isinstance(child, (LTTextBox)):
|
if isinstance(child, (LTTextBox)):
|
||||||
yield child
|
yield child
|
||||||
|
|||||||
+40
-15
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
|||||||
hookspec = pluggy.HookspecMarker('ocrmypdf')
|
hookspec = pluggy.HookspecMarker('ocrmypdf')
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
|
# mypy: disable-error-code=empty-body
|
||||||
|
|
||||||
|
|
||||||
@hookspec(firstresult=True)
|
@hookspec(firstresult=True)
|
||||||
@@ -43,7 +44,7 @@ def get_logging_console() -> Handler:
|
|||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def initialize(plugin_manager: pluggy.PluginManager):
|
def initialize(plugin_manager: pluggy.PluginManager) -> None:
|
||||||
"""Called when this plugin is first loaded into OCRmyPDF.
|
"""Called when this plugin is first loaded into OCRmyPDF.
|
||||||
|
|
||||||
The primary intended use of this is for plugins to check compatibility with other
|
The primary intended use of this is for plugins to check compatibility with other
|
||||||
@@ -99,6 +100,8 @@ def check_options(options: Namespace) -> None:
|
|||||||
ocrmypdf.exceptions.ExitCodeException: If options are not acceptable
|
ocrmypdf.exceptions.ExitCodeException: If options are not acceptable
|
||||||
and the application should terminate gracefully with an informative
|
and the application should terminate gracefully with an informative
|
||||||
message and error code.
|
message and error code.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
@@ -127,6 +130,8 @@ def get_executor(progressbar_class) -> Executor:
|
|||||||
Note:
|
Note:
|
||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -159,7 +164,6 @@ def get_progressbar_class():
|
|||||||
Here is how OCRmyPDF will use the progress bar:
|
Here is how OCRmyPDF will use the progress bar:
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
pbar_class = pm.hook.get_progressbar_class()
|
pbar_class = pm.hook.get_progressbar_class()
|
||||||
with pbar_class(**tqdm_kwargs) as pbar:
|
with pbar_class(**tqdm_kwargs) as pbar:
|
||||||
...
|
...
|
||||||
@@ -181,6 +185,8 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
|
|||||||
ocrmypdf.exceptions.ExitCodeException: If options or pdfinfo are not acceptable
|
ocrmypdf.exceptions.ExitCodeException: If options or pdfinfo are not acceptable
|
||||||
and the application should terminate gracefully with an informative
|
and the application should terminate gracefully with an informative
|
||||||
message and error code.
|
message and error code.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
@@ -218,6 +224,8 @@ def rasterize_pdf_page(
|
|||||||
Note:
|
Note:
|
||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -228,23 +236,32 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
|
|||||||
"""Called to filter the image before it is sent to OCR.
|
"""Called to filter the image before it is sent to OCR.
|
||||||
|
|
||||||
This is the image that OCR sees, not what the user sees when they view the
|
This is the image that OCR sees, not what the user sees when they view the
|
||||||
PDF. If ``redo_ocr`` is enabled, portions of the image will be masked so
|
PDF. In certain modes such as ``--redo-ocr``, portions of the image may be
|
||||||
they are not shown to OCR. The main use of this hook is expected to be hiding
|
masked out to hide them from OCR.
|
||||||
content from OCR.
|
|
||||||
|
The main uses of this hook are expected to be hiding content from OCR,
|
||||||
|
conditioning images to OCR better with filters, and adjusting images to
|
||||||
|
match any constraints imposed by the OCR engine.
|
||||||
|
|
||||||
The input image may be color, grayscale, or monochrome, and the
|
The input image may be color, grayscale, or monochrome, and the
|
||||||
output image may differ. The pixel width and height of the
|
output image may differ. For example, if you know that a custom OCR engine
|
||||||
output image must be identical to the input image, or misalignment between
|
does not care about the color of the text, you could convert the image to
|
||||||
the OCR text layer and visual position of the text will occur. Likewise,
|
it to grayscale or monochrome.
|
||||||
the output must be a faithful representation of the input, or alignment
|
|
||||||
errors may occurs.
|
|
||||||
|
|
||||||
Tesseract OCR only deals with monochrome images, and internally converts
|
Generally speaking, the output image should be a faithful representation of
|
||||||
non-monochrome images to OCR.
|
of the input image. You *may* change the pixel width and height of the
|
||||||
|
the input image, but you must not change the aspect ratio, and you must
|
||||||
|
calculate the DPI of the output image based on the new pixel width and
|
||||||
|
height or the OCR text layer will be misaligned with the visual position.
|
||||||
|
|
||||||
|
The built-in Tesseract OCR engine uses this hook itself to downsample
|
||||||
|
very large images to fit its constraints.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -269,7 +286,7 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
|
|||||||
to enforce these constraints; it is up to the plugin to do sensible things.
|
to enforce these constraints; it is up to the plugin to do sensible things.
|
||||||
|
|
||||||
OCRmyPDF will create the PDF page based on the image format used (unless the
|
OCRmyPDF will create the PDF page based on the image format used (unless the
|
||||||
hook is overriden). If you convert the image to a JPEG, the output page will
|
hook is overridden). If you convert the image to a JPEG, the output page will
|
||||||
be created as a JPEG, etc. If you change the colorspace, that change will be
|
be created as a JPEG, etc. If you change the colorspace, that change will be
|
||||||
kept. Note that the OCRmyPDF image optimization stage, if enabled, may
|
kept. Note that the OCRmyPDF image optimization stage, if enabled, may
|
||||||
ultimately chose a different format.
|
ultimately chose a different format.
|
||||||
@@ -281,6 +298,8 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
|
|||||||
Note:
|
Note:
|
||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -323,6 +342,8 @@ def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -
|
|||||||
Note:
|
Note:
|
||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -381,7 +402,8 @@ class OcrEngine(ABC):
|
|||||||
"""Returns the set of all languages that are supported by the engine.
|
"""Returns the set of all languages that are supported by the engine.
|
||||||
|
|
||||||
Languages are typically given in 3-letter ISO 3166-1 codes, but actually
|
Languages are typically given in 3-letter ISO 3166-1 codes, but actually
|
||||||
can be any value understood by the OCR engine."""
|
can be any value understood by the OCR engine.
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -413,6 +435,9 @@ class OcrEngine(ABC):
|
|||||||
a single page PDF with no visible content of any kind, sized
|
a single page PDF with no visible content of any kind, sized
|
||||||
to the dimensions implied by the input_file's width, height
|
to the dimensions implied by the input_file's width, height
|
||||||
and DPI. The image will be grafted onto the input PDF page.
|
and DPI. The image will be grafted onto the input PDF page.
|
||||||
|
output_text: The expected name of a text file containing the
|
||||||
|
recognized text.
|
||||||
|
options: The command line options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +499,7 @@ def generate_pdfa(
|
|||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
|
|
||||||
See also:
|
See Also:
|
||||||
https://github.com/tqdm/tqdm
|
https://github.com/tqdm/tqdm
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Utilities to measure OCR quality"""
|
"""Utilities to measure OCR quality."""
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
"""Wrappers to manage subprocess calls"""
|
"""Wrappers to manage subprocess calls."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -34,20 +34,25 @@ def run(
|
|||||||
check: bool = False,
|
check: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> CompletedProcess:
|
) -> CompletedProcess:
|
||||||
"""Wrapper around :py:func:`subprocess.run`
|
"""Wrapper around :py:func:`subprocess.run`.
|
||||||
|
|
||||||
The main purpose of this wrapper is to log subprocess output in an orderly
|
The main purpose of this wrapper is to log subprocess output in an orderly
|
||||||
fashion that indentifies the responsible subprocess. An additional
|
fashion that identifies the responsible subprocess. An additional
|
||||||
task is that this function goes to greater lengths to find possible Windows
|
task is that this function goes to greater lengths to find possible Windows
|
||||||
locations of our dependencies when they are not on the system PATH.
|
locations of our dependencies when they are not on the system PATH.
|
||||||
|
|
||||||
Arguments should be identical to ``subprocess.run``, except for following:
|
Arguments should be identical to ``subprocess.run``, except for following:
|
||||||
|
|
||||||
Arguments:
|
Args:
|
||||||
|
args: Positional arguments to pass to ``subprocess.run``.
|
||||||
|
env: A set of environment variables. If None, the OS environment is used.
|
||||||
logs_errors_to_stdout: If True, indicates that the process writes its error
|
logs_errors_to_stdout: If True, indicates that the process writes its error
|
||||||
messages to stdout rather than stderr, so stdout should be logged
|
messages to stdout rather than stderr, so stdout should be logged
|
||||||
if there is an error. If False, stderr is logged. Could be used with
|
if there is an error. If False, stderr is logged. Could be used with
|
||||||
stderr=STDOUT, stdout=PIPE for example.
|
stderr=STDOUT, stdout=PIPE for example.
|
||||||
|
check: If True, raise an exception if the process exits with a non-zero
|
||||||
|
status code. If False, the return value will indicate success or failure.
|
||||||
|
kwargs: Additional arguments to pass to ``subprocess.run``.
|
||||||
"""
|
"""
|
||||||
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
|
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
|
||||||
|
|
||||||
@@ -141,7 +146,7 @@ def get_version(
|
|||||||
regex=r'(\d+(\.\d+)*)',
|
regex=r'(\d+(\.\d+)*)',
|
||||||
env: OsEnviron | None = None,
|
env: OsEnviron | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get the version of the specified program
|
"""Get the version of the specified program.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
program: The program to version check.
|
program: The program to version check.
|
||||||
@@ -323,7 +328,6 @@ def check_external_program(
|
|||||||
version_parser: A class that should be used to parse and compare version
|
version_parser: A class that should be used to parse and compare version
|
||||||
numbers. Used when version numbers do not follow standard conventions.
|
numbers. Used when version numbers do not follow standard conventions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
found_version = version_checker()
|
found_version = version_checker()
|
||||||
except (CalledProcessError, FileNotFoundError) as e:
|
except (CalledProcessError, FileNotFoundError) as e:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ else:
|
|||||||
spec=['HKEYType', 'EnumKey', 'EnumValue', 'HKEY_LOCAL_MACHINE', 'OpenKey']
|
spec=['HKEYType', 'EnumKey', 'EnumValue', 'HKEY_LOCAL_MACHINE', 'OpenKey']
|
||||||
)
|
)
|
||||||
# mypy does not understand winreg.HKeyType where winreg is a Mock (fair enough!)
|
# mypy does not understand winreg.HKeyType where winreg is a Mock (fair enough!)
|
||||||
HKEYType: TypeAlias = Any
|
HKEYType: TypeAlias = Any # type: ignore
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -169,8 +169,7 @@ SHIMS = [
|
|||||||
|
|
||||||
|
|
||||||
def fix_windows_args(program: str, args, env):
|
def fix_windows_args(program: str, args, env):
|
||||||
"""Adjust our desired program and command line arguments for use on Windows"""
|
"""Adjust our desired program and command line arguments for use on Windows."""
|
||||||
|
|
||||||
# If we are running a .py on Windows, ensure we call it with this Python
|
# If we are running a .py on Windows, ensure we call it with this Python
|
||||||
# (to support test suite shims)
|
# (to support test suite shims)
|
||||||
if program.lower().endswith('.py'):
|
if program.lower().endswith('.py'):
|
||||||
@@ -188,7 +187,7 @@ def fix_windows_args(program: str, args, env):
|
|||||||
|
|
||||||
|
|
||||||
def unique_everseen(iterable: Iterable[T], key: Callable[[T], Tkey]) -> Iterator[T]:
|
def unique_everseen(iterable: Iterable[T], key: Callable[[T], Tkey]) -> Iterator[T]:
|
||||||
"List unique elements, preserving order."
|
"""List unique elements, preserving order."""
|
||||||
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
|
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
|
||||||
# unique_everseen('ABBCcAD', str.lower) --> A B C D
|
# unique_everseen('ABBCcAD', str.lower) --> A B C D
|
||||||
seen: set[Tkey] = set()
|
seen: set[Tkey] = set()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Tests."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
+6
-7
@@ -3,12 +3,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, CompletedProcess, run
|
from subprocess import CompletedProcess, run
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -71,10 +69,13 @@ def outtxt(tmp_path) -> Path:
|
|||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def no_outpdf(tmp_path) -> Path:
|
def no_outpdf(tmp_path) -> Path:
|
||||||
"""This just documents the fact that a test is not expected to produce
|
"""Document fact that a test is not expected to produce output.
|
||||||
|
|
||||||
|
This just documents the fact that a test is not expected to produce
|
||||||
output. Unfortunately an assertion failure inside a test fixture produces
|
output. Unfortunately an assertion failure inside a test fixture produces
|
||||||
an error rather than a test failure, so no testing is done. It's up to
|
an error rather than a test failure, so no testing is done. It's up to
|
||||||
the test to confirm that no output file was created."""
|
the test to confirm that no output file was created.
|
||||||
|
"""
|
||||||
return tmp_path / 'no_output.pdf'
|
return tmp_path / 'no_output.pdf'
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
The return code must always be checked or the test may declare a failure
|
||||||
to be pass.
|
to be pass.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
api_args = [str(input_file), str(output_file)] + [
|
api_args = [str(input_file), str(output_file)] + [
|
||||||
str(arg) for arg in args if arg is not None
|
str(arg) for arg in args if arg is not None
|
||||||
]
|
]
|
||||||
@@ -128,7 +128,6 @@ def run_ocrmypdf(
|
|||||||
If an exception is thrown this fact will be returned as part of the result
|
If an exception is thrown this fact will be returned as part of the result
|
||||||
text and return code rather than exception objects.
|
text and return code rather than exception objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
p_args = (
|
p_args = (
|
||||||
[sys.executable, '-m', 'ocrmypdf']
|
[sys.executable, '-m', 'ocrmypdf']
|
||||||
+ [str(arg) for arg in args if arg is not None]
|
+ [str(arg) for arg in args if arg is not None]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Tesseract bad utf8
|
"""Tesseract bad utf8.
|
||||||
|
|
||||||
In some cases, some versions of Tesseract can output binary gibberish or data
|
In some cases, some versions of Tesseract can output binary gibberish or data
|
||||||
that is not UTF-8 compatible, so we are forced to check that we can convert it
|
that is not UTF-8 compatible, so we are forced to check that we can convert it
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
"""Cache output of tesseract to speed up test suite
|
"""Cache output of tesseract to speed up test suite.
|
||||||
|
|
||||||
The cache is keyed by by the input test file The input arguments are slugged
|
The cache is keyed by by the input test file The input arguments are slugged
|
||||||
into a hideous filename that more or less represents them literally. Joined
|
into a hideous filename that more or less represents them literally. Joined
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
"""Tesseract no-op/fixed rotate plugin
|
"""Tesseract no-op/fixed rotate plugin.
|
||||||
|
|
||||||
To quickly run tests where getting OCR output is not necessary and we want to test
|
To quickly run tests where getting OCR output is not necessary and we want to test
|
||||||
the rotation pipeline.
|
the rotation pipeline.
|
||||||
@@ -28,13 +28,15 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<title></title>
|
<title></title>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
<meta name='ocr-system' content='tesseract 4.1.1' />
|
<meta name='ocr-system' content='tesseract 4.1.1' />
|
||||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
<meta name='ocr-capabilities'
|
||||||
|
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
|
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||||
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
|
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
|
||||||
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
|
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
|
||||||
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}">
|
||||||
|
<span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
"""Tesseract no-op plugin
|
"""Tesseract no-op plugin.
|
||||||
|
|
||||||
To quickly run tests where getting OCR output is not necessary.
|
To quickly run tests where getting OCR output is not necessary.
|
||||||
|
|
||||||
@@ -26,13 +26,15 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<title></title>
|
<title></title>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
<meta name='ocr-system' content='tesseract 4.1.1' />
|
<meta name='ocr-system' content='tesseract 4.1.1' />
|
||||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
<meta name='ocr-capabilities'
|
||||||
|
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
|
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||||
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
|
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
|
||||||
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
|
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
|
||||||
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}">
|
||||||
|
<span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ocrmypdf.helpers import check_pdf
|
from ocrmypdf.helpers import check_pdf
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from subprocess import PIPE, run
|
from subprocess import run
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from __future__ import annotations
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import pytest
|
|
||||||
|
|
||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ocrmypdf._sync import configure_debug_logging
|
from ocrmypdf._sync import configure_debug_logging
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -446,7 +446,7 @@ def test_linearized_pdf_and_indirect_object(resources, outpdf):
|
|||||||
|
|
||||||
|
|
||||||
def test_very_high_dpi(resources, outpdf):
|
def test_very_high_dpi(resources, outpdf):
|
||||||
"Checks for a Decimal quantize error with high DPI, etc"
|
"""Checks for a Decimal quantize error with high DPI, etc."""
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / '2400dpi.pdf',
|
resources / '2400dpi.pdf',
|
||||||
outpdf,
|
outpdf,
|
||||||
|
|||||||
+40
-62
@@ -22,16 +22,9 @@ from ocrmypdf.pdfinfo import PdfInfo
|
|||||||
|
|
||||||
from .conftest import check_ocrmypdf, run_ocrmypdf
|
from .conftest import check_ocrmypdf, run_ocrmypdf
|
||||||
|
|
||||||
try:
|
|
||||||
import fitz
|
|
||||||
except ImportError:
|
|
||||||
fitz = None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
|
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
|
||||||
def test_preserve_docinfo(output_type, resources, outpdf):
|
def test_preserve_docinfo(output_type, resources, outpdf):
|
||||||
pdf_before = pikepdf.open(resources / 'graph.pdf')
|
|
||||||
|
|
||||||
output = check_ocrmypdf(
|
output = check_ocrmypdf(
|
||||||
resources / 'graph.pdf',
|
resources / 'graph.pdf',
|
||||||
outpdf,
|
outpdf,
|
||||||
@@ -40,14 +33,13 @@ def test_preserve_docinfo(output_type, resources, outpdf):
|
|||||||
'--plugin',
|
'--plugin',
|
||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
|
with pikepdf.open(resources / 'graph.pdf') as pdf_before, pikepdf.open(
|
||||||
pdf_after = pikepdf.open(output)
|
output
|
||||||
|
) as pdf_after:
|
||||||
for key in ('/Title', '/Author'):
|
for key in ('/Title', '/Author'):
|
||||||
assert pdf_before.docinfo[key] == pdf_after.docinfo[key]
|
assert pdf_before.docinfo[key] == pdf_after.docinfo[key]
|
||||||
|
pdfa_info = file_claims_pdfa(str(output))
|
||||||
pdfa_info = file_claims_pdfa(str(output))
|
assert pdfa_info['output'] == output_type
|
||||||
assert pdfa_info['output'] == output_type
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
|
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
|
||||||
@@ -71,23 +63,20 @@ def test_override_metadata(output_type, resources, outpdf):
|
|||||||
|
|
||||||
assert p.returncode == ExitCode.ok, p.stderr
|
assert p.returncode == ExitCode.ok, p.stderr
|
||||||
|
|
||||||
before = pikepdf.open(input_file)
|
with pikepdf.open(input_file) as before, pikepdf.open(outpdf) as after:
|
||||||
after = pikepdf.open(outpdf)
|
assert after.docinfo.Title == german, after.docinfo
|
||||||
|
assert after.docinfo.Author == chinese, after.docinfo
|
||||||
|
assert after.docinfo.get('/Keywords', '') == ''
|
||||||
|
|
||||||
assert after.docinfo.Title == german, after.docinfo
|
before_date = decode_pdf_date(str(before.docinfo.CreationDate))
|
||||||
assert after.docinfo.Author == chinese, after.docinfo
|
after_date = decode_pdf_date(str(after.docinfo.CreationDate))
|
||||||
assert after.docinfo.get('/Keywords', '') == ''
|
assert before_date == after_date
|
||||||
|
|
||||||
before_date = decode_pdf_date(str(before.docinfo.CreationDate))
|
pdfa_info = file_claims_pdfa(outpdf)
|
||||||
after_date = decode_pdf_date(str(after.docinfo.CreationDate))
|
assert pdfa_info['output'] == output_type
|
||||||
assert before_date == after_date
|
|
||||||
|
|
||||||
pdfa_info = file_claims_pdfa(outpdf)
|
|
||||||
assert pdfa_info['output'] == output_type
|
|
||||||
|
|
||||||
|
|
||||||
def test_high_unicode(resources, no_outpdf):
|
def test_high_unicode(resources, no_outpdf):
|
||||||
|
|
||||||
# Ghostscript doesn't support high Unicode, so neither do we, to be
|
# Ghostscript doesn't support high Unicode, so neither do we, to be
|
||||||
# safe
|
# safe
|
||||||
input_file = resources / 'c02-22.pdf'
|
input_file = resources / 'c02-22.pdf'
|
||||||
@@ -107,10 +96,10 @@ def test_high_unicode(resources, no_outpdf):
|
|||||||
assert p.returncode == ExitCode.bad_args, p.stderr
|
assert p.returncode == ExitCode.bad_args, p.stderr
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not fitz, reason="test uses fitz")
|
|
||||||
@pytest.mark.parametrize('ocr_option', ['--skip-text', '--force-ocr'])
|
@pytest.mark.parametrize('ocr_option', ['--skip-text', '--force-ocr'])
|
||||||
@pytest.mark.parametrize('output_type', ['pdf', 'pdfa'])
|
@pytest.mark.parametrize('output_type', ['pdf', 'pdfa'])
|
||||||
def test_bookmarks_preserved(output_type, ocr_option, resources, outpdf):
|
def test_bookmarks_preserved(output_type, ocr_option, resources, outpdf):
|
||||||
|
fitz = pytest.importorskip('fitz')
|
||||||
input_file = resources / 'toc.pdf'
|
input_file = resources / 'toc.pdf'
|
||||||
before_toc = fitz.Document(str(input_file)).get_toc()
|
before_toc = fitz.Document(str(input_file)).get_toc()
|
||||||
|
|
||||||
@@ -148,23 +137,24 @@ def test_creation_date_preserved(output_type, resources, infile, outpdf):
|
|||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
|
|
||||||
pdf_before = pikepdf.open(input_file)
|
with pikepdf.open(input_file) as pdf_before, pikepdf.open(outpdf) as pdf_after:
|
||||||
pdf_after = pikepdf.open(outpdf)
|
before = pdf_before.trailer.get('/Info', {})
|
||||||
|
after = pdf_after.trailer.get('/Info', {})
|
||||||
|
|
||||||
before = pdf_before.trailer.get('/Info', {})
|
if not before:
|
||||||
after = pdf_after.trailer.get('/Info', {})
|
assert after.get('/CreationDate', '') != ''
|
||||||
|
else:
|
||||||
|
# We expect that the creation date stayed the same
|
||||||
|
date_before = decode_pdf_date(str(before['/CreationDate']))
|
||||||
|
date_after = decode_pdf_date(str(after['/CreationDate']))
|
||||||
|
assert seconds_between_dates(date_before, date_after) < 1000
|
||||||
|
|
||||||
if not before:
|
# We expect that the modified date is quite recent
|
||||||
assert after.get('/CreationDate', '') != ''
|
date_after = decode_pdf_date(str(after['/ModDate']))
|
||||||
else:
|
assert (
|
||||||
# We expect that the creation date stayed the same
|
seconds_between_dates(date_after, datetime.datetime.now(timezone.utc))
|
||||||
date_before = decode_pdf_date(str(before['/CreationDate']))
|
< 1000
|
||||||
date_after = decode_pdf_date(str(after['/CreationDate']))
|
)
|
||||||
assert seconds_between_dates(date_before, date_after) < 1000
|
|
||||||
|
|
||||||
# We expect that the modified date is quite recent
|
|
||||||
date_after = decode_pdf_date(str(after['/ModDate']))
|
|
||||||
assert seconds_between_dates(date_after, datetime.datetime.now(timezone.utc)) < 1000
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -231,17 +221,6 @@ def test_xml_metadata_preserved(
|
|||||||
'pdf:keywords',
|
'pdf:keywords',
|
||||||
]
|
]
|
||||||
acquired_properties = ['dc:format']
|
acquired_properties = ['dc:format']
|
||||||
might_change_properties = [
|
|
||||||
'dc:date',
|
|
||||||
'pdf:pdfversion',
|
|
||||||
'pdf:Producer',
|
|
||||||
'xmp:CreateDate',
|
|
||||||
'xmp:ModifyDate',
|
|
||||||
'xmp:MetadataDate',
|
|
||||||
'xmp:CreatorTool',
|
|
||||||
'xmpMM:DocumentId',
|
|
||||||
'xmpMM:DnstanceId',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Cleanup messy data structure
|
# Cleanup messy data structure
|
||||||
# Top level is key-value mapping of namespaces to keys under namespace,
|
# Top level is key-value mapping of namespaces to keys under namespace,
|
||||||
@@ -292,10 +271,9 @@ def test_kodak_toc(resources, outpdf):
|
|||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
|
|
||||||
p = pikepdf.open(outpdf)
|
with pikepdf.open(outpdf) as p:
|
||||||
|
if pikepdf.Name.First in p.Root.Outlines:
|
||||||
if pikepdf.Name.First in p.Root.Outlines:
|
assert isinstance(p.Root.Outlines.First, pikepdf.Dictionary)
|
||||||
assert isinstance(p.Root.Outlines.First, pikepdf.Dictionary)
|
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_fixup_warning(resources, outdir, caplog):
|
def test_metadata_fixup_warning(resources, outdir, caplog):
|
||||||
@@ -313,10 +291,10 @@ def test_metadata_fixup_warning(resources, outdir, caplog):
|
|||||||
assert record.levelname != 'WARNING', "Unexpected warning"
|
assert record.levelname != 'WARNING', "Unexpected warning"
|
||||||
|
|
||||||
# Now add some metadata that will not be copyable
|
# Now add some metadata that will not be copyable
|
||||||
graph = pikepdf.open(outdir / 'graph.pdf')
|
with pikepdf.open(outdir / 'graph.pdf') as graph:
|
||||||
with graph.open_metadata() as meta:
|
with graph.open_metadata() as meta:
|
||||||
meta['prism2:publicationName'] = 'OCRmyPDF Test'
|
meta['prism2:publicationName'] = 'OCRmyPDF Test'
|
||||||
graph.save(outdir / 'graph_mod.pdf')
|
graph.save(outdir / 'graph_mod.pdf')
|
||||||
|
|
||||||
context = PdfContext(
|
context = PdfContext(
|
||||||
options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([])
|
options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([])
|
||||||
|
|||||||
+10
-10
@@ -100,14 +100,14 @@ def test_jbig2_lossy(lossy, resources, outpdf):
|
|||||||
|
|
||||||
check_ocrmypdf(*args)
|
check_ocrmypdf(*args)
|
||||||
|
|
||||||
pdf = pikepdf.open(outpdf)
|
with pikepdf.open(outpdf) as pdf:
|
||||||
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
||||||
assert pim.filters[0] == '/JBIG2Decode'
|
assert pim.filters[0] == '/JBIG2Decode'
|
||||||
|
|
||||||
if lossy:
|
if lossy:
|
||||||
assert '/JBIG2Globals' in pim.decode_parms[0]
|
assert '/JBIG2Globals' in pim.decode_parms[0]
|
||||||
else:
|
else:
|
||||||
assert len(pim.decode_parms) == 0
|
assert len(pim.decode_parms) == 0
|
||||||
|
|
||||||
|
|
||||||
@needs_pngquant
|
@needs_pngquant
|
||||||
@@ -134,9 +134,9 @@ def test_flate_to_jbig2(resources, outdir):
|
|||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
|
|
||||||
pdf = pikepdf.open(outdir / 'out.pdf')
|
with pikepdf.open(outdir / 'out.pdf') as pdf:
|
||||||
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
||||||
assert pim.filters[0] == '/JBIG2Decode'
|
assert pim.filters[0] == '/JBIG2Decode'
|
||||||
|
|
||||||
|
|
||||||
@needs_pngquant
|
@needs_pngquant
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ VECTOR_RESOLUTION = Resolution(_pipeline.VECTOR_PAGE_DPI, _pipeline.VECTOR_PAGE_
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_dpi_needed(image, text, vector, result, rgb_image, outdir):
|
def test_dpi_needed(image, text, vector, result, rgb_image, outdir):
|
||||||
|
|
||||||
c = Canvas(str(outdir / 'dpi.pdf'), pagesize=(5 * inch, 5 * inch))
|
c = Canvas(str(outdir / 'dpi.pdf'), pagesize=(5 * inch, 5 * inch))
|
||||||
if image:
|
if image:
|
||||||
c.drawImage(rgb_image, 1 * inch, 1 * inch, width=1 * inch, height=1 * inch)
|
c.drawImage(rgb_image, 1 * inch, 1 * inch, width=1 * inch, height=1 * inch)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ocrmypdf import quality as qual
|
from ocrmypdf import quality as qual
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,13 +153,14 @@ def test_autorotate_threshold(threshold, op, comparison_threshold, resources, ou
|
|||||||
|
|
||||||
|
|
||||||
def test_rotated_skew_timeout(resources, outpdf):
|
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.
|
/Rotate tag and intentionally skewed by altering the transformation matrix.
|
||||||
|
|
||||||
This tests for a bug where the combination of preprocessing and a tesseract
|
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.
|
timeout produced a page whose dimensions did not match the original's.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
input_file = resources / 'rotated_skew.pdf'
|
input_file = resources / 'rotated_skew.pdf'
|
||||||
in_pageinfo = PdfInfo(input_file)[0]
|
in_pageinfo = PdfInfo(input_file)[0]
|
||||||
|
|
||||||
@@ -190,8 +191,7 @@ def test_rotated_skew_timeout(resources, outpdf):
|
|||||||
), "Expected page rotation to be baked in"
|
), "Expected page rotation to be baked in"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="tesseract timeout blocks tesseract based deskew")
|
def test_rotate_deskew_ocr_timeout(resources, outdir):
|
||||||
def test_rotate_deskew_timeout(resources, outdir):
|
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / 'rotated_skew.pdf',
|
resources / 'rotated_skew.pdf',
|
||||||
outdir / 'deskewed.pdf',
|
outdir / 'deskewed.pdf',
|
||||||
@@ -237,11 +237,11 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
|
|||||||
**IMG2PDF_KWARGS,
|
**IMG2PDF_KWARGS,
|
||||||
)
|
)
|
||||||
mempdf.seek(0)
|
mempdf.seek(0)
|
||||||
pike = pikepdf.open(mempdf)
|
with pikepdf.open(mempdf) as pdf:
|
||||||
pike.pages[0].Rotate = page_angle
|
pdf.pages[0].Rotate = page_angle
|
||||||
target = outdir / f'{prefix}_{image_angle}_{page_angle}.pdf'
|
target = outdir / f'{prefix}_{image_angle}_{page_angle}.pdf'
|
||||||
pike.save(target)
|
pdf.save(target)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
reference = make_rotate_test('ref', 0, 0)
|
reference = make_rotate_test('ref', 0, 0)
|
||||||
test = make_rotate_test('test', image_angle, page_angle)
|
test = make_rotate_test('test', image_angle, page_angle)
|
||||||
|
|||||||
+1
-4
@@ -4,13 +4,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
from subprocess import DEVNULL, PIPE, run
|
||||||
from pathlib import Path
|
|
||||||
from subprocess import DEVNULL, PIPE, Popen, run
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ocrmypdf.exceptions import ExitCode
|
|
||||||
from ocrmypdf.helpers import check_pdf
|
from ocrmypdf.helpers import check_pdf
|
||||||
|
|
||||||
from .conftest import run_ocrmypdf
|
from .conftest import run_ocrmypdf
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ from os import fspath
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
||||||
from ocrmypdf._validation import check_options
|
from ocrmypdf._validation import check_options
|
||||||
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
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
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,13 @@ def poster(resources):
|
|||||||
return resources / 'poster.pdf'
|
return resources / 'poster.pdf'
|
||||||
|
|
||||||
|
|
||||||
def test_userunit_ghostscript_fails(poster, no_outpdf, caplog):
|
@pytest.mark.parametrize("mode", ['pdf', 'pdfa'])
|
||||||
result = run_ocrmypdf_api(poster, no_outpdf, '--output-type=pdfa')
|
def test_userunit_pdf_passes(mode, poster, outpdf):
|
||||||
assert result == ExitCode.input_file
|
|
||||||
assert 'not supported by Ghostscript' in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_userunit_pdf_passes(poster, outpdf):
|
|
||||||
before = PdfInfo(poster)
|
before = PdfInfo(poster)
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
poster,
|
poster,
|
||||||
outpdf,
|
outpdf,
|
||||||
'--output-type=pdf',
|
f'--output-type={mode}',
|
||||||
'--plugin',
|
'--plugin',
|
||||||
'tests/plugins/tesseract_cache.py',
|
'tests/plugins/tesseract_cache.py',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ def test_version_comparison():
|
|||||||
vd.check_external_program(
|
vd.check_external_program(
|
||||||
program="tesseract",
|
program="tesseract",
|
||||||
package="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',
|
need_version='4.1.1',
|
||||||
version_parser=TesseractVersion,
|
version_parser=TesseractVersion,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user