Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8753dc790 | ||
|
|
ef43d7e016 | ||
|
|
17a5b8b43c | ||
|
|
13d11e76e5 | ||
|
|
61069660a2 | ||
|
|
685a06c93d | ||
|
|
6cdf68363a | ||
|
|
522ff3c21a | ||
|
|
10245dc954 | ||
|
|
3d4f80639d | ||
|
|
db9a22c9dd | ||
|
|
31683530f8 | ||
|
|
0e550a1c6d | ||
|
|
b17fb61389 | ||
|
|
d640c2ded3 | ||
|
|
a0ac448d52 | ||
|
|
e3ba13e365 | ||
|
|
0cd04abc4e | ||
|
|
ee81f3968f | ||
|
|
21cacad93b | ||
|
|
3589f4e7d1 | ||
|
|
1cdc2591e5 | ||
|
|
e05f9575a8 | ||
|
|
10c703e119 | ||
|
|
0ac15dd0b2 | ||
|
|
808b24d59f | ||
|
|
c082526dea | ||
|
|
33cdabaf65 | ||
|
|
94f8e36601 | ||
|
|
865002c7be | ||
|
|
5d0cc0a092 | ||
|
|
6c427f82ea | ||
|
|
e7a44ba87a | ||
|
|
c311768452 | ||
|
|
f53fedee63 | ||
|
|
87838127b0 | ||
|
|
4db4df5c72 | ||
|
|
11125c5367 | ||
|
|
e648411067 | ||
|
|
11365575d7 | ||
|
|
845cb5c40c | ||
|
|
b699e158be | ||
|
|
603da52026 | ||
|
|
8d0765a5e0 | ||
|
|
1ca327e13b | ||
|
|
f504fd1875 | ||
|
|
cf7c20ca16 | ||
|
|
b00fe3dc5d | ||
|
|
e6aa3a4299 | ||
|
|
24f1b57288 | ||
|
|
43302d7e12 | ||
|
|
fed0226761 | ||
|
|
27e22b4f07 | ||
|
|
79382a6039 | ||
|
|
7788d94c4a | ||
|
|
33bfba8449 | ||
|
|
1d0584c644 | ||
|
|
84b9d4d021 | ||
|
|
41efd3bf0f | ||
|
|
776ada6713 | ||
|
|
f3593c915d | ||
|
|
dfe31a2f6d | ||
|
|
0c43963d69 | ||
|
|
f29fe7f23e | ||
|
|
04996caac3 | ||
|
|
13917c051c | ||
|
|
8182fe9c92 | ||
|
|
1950acfbda | ||
|
|
fca6403083 | ||
|
|
c4e2fce1ef | ||
|
|
3546479658 | ||
|
|
72442fa3d0 | ||
|
|
8f714b1375 | ||
|
|
cb05c1d122 | ||
|
|
b0ad07bc5f | ||
|
|
514038d4ec | ||
|
|
50d76e7f6c | ||
|
|
6c78a46285 | ||
|
|
863d560632 | ||
|
|
73934c854c | ||
|
|
2be8eeec2c | ||
|
|
3dfde479e2 | ||
|
|
aea1862644 | ||
|
|
3b406112d0 | ||
|
|
fcc4c2d371 | ||
|
|
3de18ed612 | ||
|
|
93cca42e20 | ||
|
|
2d0ac4707c | ||
|
|
7d208175cf | ||
|
|
ea69e868ed | ||
|
|
beea603ab3 | ||
|
|
7966192d6e | ||
|
|
5acbd7a252 | ||
|
|
aed955ca8c |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# OCRmyPDF
|
# OCRmyPDF
|
||||||
#
|
#
|
||||||
|
|
||||||
FROM ubuntu:21.04 as base
|
FROM ubuntu:22.04 as base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Run with verbosity or higher `-v1` to see more detailed logging. This informatio
|
|||||||
**Example file**
|
**Example file**
|
||||||
If your issue is a problem that affects only certain files, and we will require an input file (PDF or image) that demonstrates your issue.
|
If your issue is a problem that affects only certain files, and we will require an input file (PDF or image) that demonstrates your issue.
|
||||||
|
|
||||||
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/jbarlow83/OCRmyPDF/wiki) for OCRmyPDF's author only.
|
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/ocrmypdf/OCRmyPDF/wiki) for OCRmyPDF's author only.
|
||||||
|
|
||||||
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
|
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ A clear and concise description of any alternative solutions or features you've
|
|||||||
**Example file**
|
**Example file**
|
||||||
If your issue concerns how OCRmyPDF processes certain files, and please provide an example file that helps illustrate how OCRmyPDF's output could be improve.
|
If your issue concerns how OCRmyPDF processes certain files, and please provide an example file that helps illustrate how OCRmyPDF's output could be improve.
|
||||||
|
|
||||||
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/jbarlow83/OCRmyPDF/wiki) for OCRmyPDF's author only.
|
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/ocrmypdf/OCRmyPDF/wiki) for OCRmyPDF's author only.
|
||||||
|
|
||||||
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
|
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
|
||||||
|
|
||||||
|
|||||||
@@ -21,17 +21,19 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-18.04
|
- os: ubuntu-18.04
|
||||||
python: 3.7
|
python: "3.7"
|
||||||
- os: ubuntu-20.04
|
- os: ubuntu-20.04
|
||||||
python: 3.8
|
python: "3.8"
|
||||||
- os: ubuntu-20.04
|
- os: ubuntu-20.04
|
||||||
python: 3.9
|
python: "3.9"
|
||||||
|
- os: ubuntu-20.04
|
||||||
|
python: "3.10"
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
python: 3.9
|
python: "3.9"
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
python: "pypy-3.7"
|
python: "pypy-3.8"
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
python: 3.9
|
python: "3.9"
|
||||||
tesseract5: true
|
tesseract5: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -120,7 +122,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest]
|
os: [macos-latest]
|
||||||
python: ["3.9"]
|
python: ["3.9", "3.10"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -175,7 +177,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-latest]
|
os: [windows-latest]
|
||||||
python: ["3.9"]
|
python: ["3.9", "3.10"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -194,7 +196,7 @@ jobs:
|
|||||||
- name: Install system packages
|
- name: Install system packages
|
||||||
run: |
|
run: |
|
||||||
choco install --yes --no-progress --pre tesseract
|
choco install --yes --no-progress --pre tesseract
|
||||||
choco install --yes --no-progress --ignore-checksums ghostscript pngquant
|
choco install --yes --no-progress --ignore-checksums ghostscript
|
||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.0.1
|
rev: v4.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
@@ -8,26 +8,26 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: debug-statements
|
- id: debug-statements
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: 5.9.3
|
rev: 5.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
args: ["--profile", "black"]
|
args: ["--profile", "black"]
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 21.9b0
|
rev: 22.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python
|
language_version: python
|
||||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||||
rev: v1.19.0
|
rev: v1.20.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: setup-cfg-fmt
|
- id: setup-cfg-fmt
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.29.0
|
rev: v2.32.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: ["--py37-plus"]
|
args: ["--py37-plus"]
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v0.910-1
|
rev: v0.950
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<img src="docs/images/logo.svg" width="240" alt="OCRmyPDF">
|
<img src="docs/images/logo.svg" width="240" alt="OCRmyPDF">
|
||||||
|
|
||||||
[](https://github.com/jbarlow83/OCRmyPDF/actions/workflows/build.yml) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs] ![Python versions][pyversions]
|
[](https://github.com/ocrmypdf/OCRmyPDF/actions/workflows/build.yml) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs] ![Python versions][pyversions]
|
||||||
|
|
||||||
[azure]: https://dev.azure.com/jim0585/ocrmypdf/_apis/build/status/jbarlow83.OCRmyPDF?branchName=master
|
|
||||||
[travis]: https://travis-ci.org/jbarlow83/OCRmyPDF.svg?branch=master "Travis build status"
|
|
||||||
[pypi]: https://img.shields.io/pypi/v/ocrmypdf.svg "PyPI version"
|
[pypi]: https://img.shields.io/pypi/v/ocrmypdf.svg "PyPI version"
|
||||||
[homebrew]: https://img.shields.io/homebrew/v/ocrmypdf.svg "Homebrew version"
|
[homebrew]: https://img.shields.io/homebrew/v/ocrmypdf.svg "Homebrew version"
|
||||||
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
|
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
|
||||||
@@ -106,7 +104,7 @@ ocrmypdf --help
|
|||||||
|
|
||||||
Our [documentation is served on Read the Docs](https://ocrmypdf.readthedocs.io/en/latest/index.html).
|
Our [documentation is served on Read the Docs](https://ocrmypdf.readthedocs.io/en/latest/index.html).
|
||||||
|
|
||||||
Please report issues on our [GitHub issues](https://github.com/jbarlow83/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
Upstream-Name: OCRmyPDF
|
Upstream-Name: OCRmyPDF
|
||||||
Upstream-Contact: James R. Barlow <barlow.jim@gmail.com>
|
Upstream-Contact: James R. Barlow <barlow.jim@gmail.com>
|
||||||
Source: https://github.com/jbarlow83/OCRmyPDF
|
Source: https://github.com/ocrmypdf/OCRmyPDF
|
||||||
|
|
||||||
Files: *
|
Files: *
|
||||||
Copyright:
|
Copyright:
|
||||||
|
|||||||
+5
-5
@@ -67,11 +67,11 @@ without modifying the PDF. This is to ensure that PDFs that were
|
|||||||
previously OCRed or were "born digital" rather than scanned are not
|
previously OCRed or were "born digital" rather than scanned are not
|
||||||
processed.
|
processed.
|
||||||
|
|
||||||
If ``--skip-text`` is issued, then no OCR will be performed on pages
|
If ``--skip-text`` is issued, then no image processing or OCR will be
|
||||||
that already have text. The page will be copied to the output. This may
|
performed on pages that already have text. The page will be copied to
|
||||||
be useful for documents that contain both "born digital" and scanned
|
the output. This may be useful for documents that contain both "born
|
||||||
content, or to use OCRmyPDF to normalize and convert to PDF/A regardless
|
digital" and scanned content, or to use OCRmyPDF to normalize and
|
||||||
of their contents.
|
convert to PDF/A regardless of their contents.
|
||||||
|
|
||||||
If ``--redo-ocr`` is issued, then a detailed text analysis is performed.
|
If ``--redo-ocr`` is issued, then a detailed text analysis is performed.
|
||||||
Text is categorized as either visible or invisible. Invisible text (OCR)
|
Text is categorized as either visible or invisible. Invisible text (OCR)
|
||||||
|
|||||||
+2
-2
@@ -42,7 +42,7 @@ extensions = [
|
|||||||
# Extension settings
|
# Extension settings
|
||||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||||
napoleon_use_rtype = False
|
napoleon_use_rtype = False
|
||||||
issues_github_path = "jbarlow83/OCRmyPDF"
|
issues_github_path = "ocrmypdf/OCRmyPDF"
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
@@ -63,7 +63,7 @@ master_doc = 'index'
|
|||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = 'ocrmypdf'
|
project = 'ocrmypdf'
|
||||||
copyright = (
|
copyright = (
|
||||||
'2021, James R. Barlow. Licensed under Creative Commons Attribution-ShareAlike 4.0.'
|
'2022, James R. Barlow. Licensed under Creative Commons Attribution-ShareAlike 4.0.'
|
||||||
)
|
)
|
||||||
author = 'James R. Barlow'
|
author = 'James R. Barlow'
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -243,10 +243,11 @@ You can also optimize all images without performing any OCR:
|
|||||||
|
|
||||||
ocrmypdf --tesseract-timeout=0 --optimize 3 --skip-text input.pdf output.pdf
|
ocrmypdf --tesseract-timeout=0 --optimize 3 --skip-text input.pdf output.pdf
|
||||||
|
|
||||||
Perform OCR only certain pages
|
Process only certain pages
|
||||||
------------------------------
|
--------------------------
|
||||||
|
|
||||||
You can ask OCRmyPDF to only apply OCR to certain pages.
|
You can ask OCRmyPDF to only apply `image processing <#image-processing>`__
|
||||||
|
and OCR to certain pages.
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -260,10 +261,10 @@ overlap pages. OCRmyPDF does not currently account for document page numbers,
|
|||||||
such as an introduction section of a book that uses Roman numerals. It simply
|
such as an introduction section of a book that uses Roman numerals. It simply
|
||||||
counts the number of virtual pieces of paper since the start.
|
counts the number of virtual pieces of paper since the start.
|
||||||
|
|
||||||
Regardless of the argument to ``--pages``, OCRmyPDF will optimize all pages in
|
Regardless of the argument to ``--pages``, OCRmyPDF will optimize all pages/images
|
||||||
the file and convert it to PDF/A, unless you disable those options. In this
|
in the file and convert it to PDF/A, unless you disable those options. Both of these
|
||||||
example, we want to OCR only the title and otherwise change the PDF as little
|
steps are "whole file" operations. In this example, we want to OCR only the title
|
||||||
as possible:
|
and otherwise change the PDF as little as possible:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ Using the Docker image on the command line
|
|||||||
==========================================
|
==========================================
|
||||||
|
|
||||||
**Unlike typical Docker containers**, in this section the OCRmyPDF Docker
|
**Unlike typical Docker containers**, in this section the OCRmyPDF Docker
|
||||||
container is emphemeral – it runs for one OCR job and terminates, just like a
|
container is ephemeral – it runs for one OCR job and terminates, just like a
|
||||||
command line program. We are using Docker to deliver an application (as opposed
|
command line program. We are using Docker to deliver an application (as opposed
|
||||||
to the more conventional case, where a Docker container runs as a server).
|
to the more conventional case, where a Docker container runs as a server).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="256"
|
||||||
|
height="256"
|
||||||
|
viewBox="0 0 256 256.00001"
|
||||||
|
version="1.1"
|
||||||
|
xml:space="preserve"
|
||||||
|
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5"
|
||||||
|
id="svg270"
|
||||||
|
sodipodi:docname="logo-square-256.svg"
|
||||||
|
inkscape:export-filename="/home/jb/src/ocrmypdf/docs/images/logo-square.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:serif="http://www.serif.com/"><metadata
|
||||||
|
id="metadata276"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs274" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="2396"
|
||||||
|
inkscape:window-height="1691"
|
||||||
|
id="namedview272"
|
||||||
|
showgrid="false"
|
||||||
|
lock-margins="false"
|
||||||
|
inkscape:zoom="2.0079523"
|
||||||
|
inkscape:cx="189.74554"
|
||||||
|
inkscape:cy="54.533168"
|
||||||
|
inkscape:window-x="26"
|
||||||
|
inkscape:window-y="23"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg270"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
width="256px"
|
||||||
|
fit-margin-top="0"
|
||||||
|
fit-margin-left="0"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0" />
|
||||||
|
<g
|
||||||
|
id="svg"
|
||||||
|
transform="matrix(0.48534351,0,0,0.4057699,1.8106874,71.192214)">
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="520"
|
||||||
|
height="280"
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="rect188" />
|
||||||
|
<g
|
||||||
|
transform="matrix(1.03522,0,0,1.23823,-69.7528,-83.422)"
|
||||||
|
id="g267">
|
||||||
|
<g
|
||||||
|
transform="translate(243.977,20.0703)"
|
||||||
|
id="g218">
|
||||||
|
<g
|
||||||
|
id="Page">
|
||||||
|
<g
|
||||||
|
transform="matrix(0.961773,0,0,1.05962,6.19811,-3.01071)"
|
||||||
|
id="g192">
|
||||||
|
<path
|
||||||
|
d="m 328.5,97.682 c 0,-1.217 -0.517,-2.386 -1.444,-3.264 -7.03,-6.66 -37.614,-35.638 -44.828,-42.474 -0.977,-0.925 -2.327,-1.448 -3.738,-1.448 -13.997,0 -90.407,0 -111.151,0 -2.871,0 -5.198,2.113 -5.198,4.718 0,27.837 0,170.351 0,198.186 0,2.605 2.327,4.717 5.197,4.717 24.904,0 131.821,0 156.2,0 2.74,0 4.962,-2.016 4.962,-4.504 0,-24.345 0,-139.717 0,-155.931 z"
|
||||||
|
style="fill:#fdfdfd;stroke:#333333;stroke-width:3.95px"
|
||||||
|
id="path190" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="Dog-ear"
|
||||||
|
serif:id="Dog ear"
|
||||||
|
transform="translate(-4,2)">
|
||||||
|
<path
|
||||||
|
d="m 277.072,48.496 v 45.352 c 0,1.324 0.526,2.593 1.462,3.529 0.936,0.936 2.205,1.462 3.529,1.462 12.485,0 44.078,0 44.078,0"
|
||||||
|
style="fill:#f5f5f5;stroke:#333333;stroke-width:4px"
|
||||||
|
id="path194" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="translate(-29.6816,-0.395178)"
|
||||||
|
id="g216">
|
||||||
|
<g
|
||||||
|
transform="matrix(1.00243,0,0,1.11818,-144.72,-8.80181)"
|
||||||
|
id="g200">
|
||||||
|
<path
|
||||||
|
d="m 465.73,119.654 c 0,-2.049 -1.856,-3.713 -4.142,-3.713 H 310.259 c -2.286,0 -4.142,1.664 -4.142,3.713 v 63.454 c 0,2.049 1.856,3.713 4.142,3.713 h 151.329 c 2.286,0 4.142,-1.664 4.142,-3.713 z"
|
||||||
|
style="fill:#f80000;stroke:#ffffff;stroke-width:3.77px"
|
||||||
|
id="path198" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(1.24571,0,0,1.35864,116.812,84.3924)"
|
||||||
|
id="g214">
|
||||||
|
<g
|
||||||
|
transform="matrix(64,0,0,64,42.1437,77.6203)"
|
||||||
|
id="g204">
|
||||||
|
<path
|
||||||
|
d="m 0.084,0 v -0.68 h 0.213 c 0.074,0 0.137,0.017 0.19,0.05 0.053,0.034 0.079,0.09 0.079,0.168 0,0.077 -0.028,0.134 -0.085,0.17 -0.057,0.037 -0.121,0.055 -0.193,0.055 H 0.213 V 0 Z m 0.209,-0.572 h -0.08 v 0.228 h 0.082 c 0.039,0 0.07,-0.009 0.094,-0.027 0.024,-0.017 0.037,-0.045 0.04,-0.083 0,-0.044 -0.012,-0.075 -0.036,-0.092 -0.024,-0.017 -0.057,-0.026 -0.1,-0.026 z"
|
||||||
|
style="fill:#ffffff;fill-rule:nonzero"
|
||||||
|
id="path202" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(64,0,0,64,79.7117,77.6203)"
|
||||||
|
id="g208">
|
||||||
|
<path
|
||||||
|
d="M 0.332,0 H 0.084 v -0.68 h 0.252 c 0.105,0 0.182,0.032 0.233,0.095 0.051,0.063 0.076,0.144 0.076,0.241 0,0.105 -0.027,0.189 -0.082,0.251 C 0.508,-0.031 0.431,0 0.332,0 Z M 0.337,-0.57 H 0.213 v 0.461 H 0.33 c 0.055,0 0.099,-0.018 0.132,-0.054 C 0.495,-0.199 0.511,-0.259 0.511,-0.344 0.511,-0.415 0.497,-0.47 0.469,-0.51 0.441,-0.55 0.397,-0.57 0.337,-0.57 Z"
|
||||||
|
style="fill:#ffffff;fill-rule:nonzero"
|
||||||
|
id="path206" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(64,0,0,64,123.424,77.6203)"
|
||||||
|
id="g212">
|
||||||
|
<path
|
||||||
|
d="M 0.405,-0.288 H 0.213 V 0 H 0.084 v -0.68 h 0.385 l 0.02,0.102 H 0.213 v 0.189 h 0.173 z"
|
||||||
|
style="fill:#ffffff;fill-rule:nonzero"
|
||||||
|
id="path210" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(1,0,0,1.52217,67.3796,10.7507)"
|
||||||
|
id="g222">
|
||||||
|
<rect
|
||||||
|
x="23.500999"
|
||||||
|
y="81.300003"
|
||||||
|
width="162.30499"
|
||||||
|
height="61.77"
|
||||||
|
style="fill:#b4d5ff"
|
||||||
|
id="rect220" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(0.967536,0,0,0.961535,5.90498,47.9703)"
|
||||||
|
id="g236">
|
||||||
|
<g
|
||||||
|
transform="matrix(90.4804,0,0,90.4804,82.6698,167.705)"
|
||||||
|
id="g226">
|
||||||
|
<path
|
||||||
|
d="m 0.057,-0.337 c 0,-0.105 0.027,-0.19 0.082,-0.257 0.055,-0.066 0.132,-0.1 0.231,-0.102 0.107,0 0.186,0.034 0.237,0.103 0.051,0.069 0.077,0.152 0.077,0.249 0,0.105 -0.027,0.191 -0.082,0.258 -0.055,0.067 -0.133,0.1 -0.232,0.1 C 0.264,0.014 0.185,-0.02 0.134,-0.089 0.083,-0.157 0.057,-0.24 0.057,-0.337 Z m 0.135,-0.001 c 0,0.071 0.014,0.13 0.043,0.175 0.029,0.045 0.073,0.068 0.134,0.068 0.055,0 0.098,-0.02 0.131,-0.061 0.033,-0.041 0.049,-0.103 0.049,-0.188 0,-0.071 -0.014,-0.129 -0.043,-0.174 -0.029,-0.045 -0.073,-0.068 -0.134,-0.068 -0.053,0 -0.097,0.022 -0.13,0.067 -0.033,0.045 -0.05,0.105 -0.05,0.181 z"
|
||||||
|
style="fill:#333333;fill-rule:nonzero"
|
||||||
|
id="path224" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(90.4804,0,0,90.4804,147.906,167.705)"
|
||||||
|
id="g230">
|
||||||
|
<path
|
||||||
|
d="M 0.505,-0.557 C 0.473,-0.567 0.448,-0.574 0.429,-0.579 0.41,-0.583 0.388,-0.585 0.361,-0.585 c -0.054,0 -0.096,0.022 -0.125,0.066 -0.029,0.044 -0.044,0.104 -0.044,0.181 0,0.066 0.012,0.123 0.037,0.171 0.025,0.048 0.066,0.072 0.124,0.072 0.029,0 0.056,-0.003 0.081,-0.009 0.025,-0.006 0.047,-0.013 0.068,-0.022 L 0.551,-0.03 C 0.525,-0.017 0.494,-0.006 0.457,0.002 0.42,0.01 0.388,0.014 0.36,0.014 0.254,0.014 0.177,-0.02 0.129,-0.088 0.081,-0.156 0.057,-0.239 0.057,-0.337 c 0,-0.105 0.027,-0.19 0.08,-0.257 0.053,-0.067 0.129,-0.1 0.228,-0.1 0.02,0 0.048,0.003 0.083,0.01 0.035,0.007 0.068,0.018 0.097,0.034 z"
|
||||||
|
style="fill:#333333;fill-rule:nonzero"
|
||||||
|
id="path228" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(90.4804,0,0,90.4804,199.751,167.705)"
|
||||||
|
id="g234">
|
||||||
|
<path
|
||||||
|
d="m 0.293,-0.572 h -0.08 v 0.208 h 0.082 c 0.039,0 0.071,-0.008 0.096,-0.024 0.025,-0.015 0.038,-0.041 0.038,-0.077 0,-0.038 -0.012,-0.065 -0.036,-0.082 -0.024,-0.017 -0.057,-0.025 -0.1,-0.025 z M 0.479,0 0.335,-0.26 C 0.328,-0.259 0.32,-0.259 0.312,-0.259 0.304,-0.258 0.296,-0.258 0.288,-0.258 H 0.213 V 0 H 0.084 v -0.68 h 0.213 c 0.074,0 0.137,0.017 0.19,0.051 0.053,0.034 0.079,0.087 0.079,0.158 0,0.042 -0.011,0.078 -0.032,0.108 -0.022,0.031 -0.05,0.054 -0.084,0.071 L 0.617,0 Z"
|
||||||
|
style="fill:#333333;fill-rule:nonzero"
|
||||||
|
id="path232" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(0.916882,0,0,1,121.475,-32.6535)"
|
||||||
|
id="g246">
|
||||||
|
<g
|
||||||
|
transform="matrix(86.953,0,0,86.953,152.996,241.878)"
|
||||||
|
id="g240">
|
||||||
|
<path
|
||||||
|
d="M 0.479,-0.428 C 0.5,-0.451 0.527,-0.47 0.562,-0.484 c 0.034,-0.013 0.065,-0.02 0.092,-0.02 0.066,0 0.113,0.019 0.141,0.058 0.027,0.039 0.041,0.086 0.041,0.142 V 0 H 0.705 v -0.298 c 0,-0.031 -0.007,-0.054 -0.022,-0.071 -0.015,-0.016 -0.036,-0.024 -0.064,-0.024 -0.019,0 -0.038,0.005 -0.059,0.015 -0.021,0.01 -0.039,0.021 -0.056,0.034 0.001,0.007 0.001,0.013 0.002,0.02 0.001,0.007 0.001,0.013 0.001,0.02 V 0 H 0.376 v -0.298 c 0,-0.031 -0.007,-0.054 -0.022,-0.071 -0.015,-0.016 -0.036,-0.024 -0.063,-0.024 -0.017,0 -0.033,0.003 -0.05,0.01 -0.017,0.007 -0.034,0.016 -0.049,0.027 V 0 H 0.062 V -0.485 H 0.13 l 0.032,0.044 c 0.022,-0.02 0.049,-0.035 0.08,-0.047 0.031,-0.011 0.058,-0.016 0.083,-0.016 0.038,0 0.07,0.007 0.095,0.02 0.025,0.014 0.045,0.033 0.059,0.056 z"
|
||||||
|
style="fill:#333333;fill-rule:nonzero"
|
||||||
|
id="path238" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(86.953,0,0,86.953,228.906,241.878)"
|
||||||
|
id="g244">
|
||||||
|
<path
|
||||||
|
d="M 0.156,0.023 0.179,-0.034 0.006,-0.467 0.14,-0.485 0.252,-0.191 0.358,-0.485 H 0.495 L 0.278,0.064 C 0.263,0.103 0.236,0.137 0.197,0.165 0.158,0.193 0.118,0.212 0.075,0.222 L 0.029,0.115 C 0.052,0.105 0.077,0.093 0.104,0.079 0.13,0.064 0.147,0.046 0.156,0.023 Z"
|
||||||
|
style="fill:#333333;fill-rule:nonzero"
|
||||||
|
id="path242" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="Selectors"
|
||||||
|
transform="matrix(0.965977,0,0,0.807602,67.3796,67.3718)">
|
||||||
|
<g
|
||||||
|
id="Right-selector"
|
||||||
|
serif:id="Right selector">
|
||||||
|
<g
|
||||||
|
transform="matrix(1.03522,0,0,1.23823,2.07044,0)"
|
||||||
|
id="g250">
|
||||||
|
<path
|
||||||
|
d="M 185.806,161.156 V 67.132"
|
||||||
|
style="fill:none;stroke:#4c9fff;stroke-width:4px;stroke-linecap:butt"
|
||||||
|
id="path248" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(1.03522,0,0,1.23823,161.788,169.469)"
|
||||||
|
id="g254">
|
||||||
|
<circle
|
||||||
|
cx="31.523001"
|
||||||
|
cy="34.313999"
|
||||||
|
r="10.021"
|
||||||
|
style="fill:#4c9fff;stroke:#4c9fff;stroke-width:4px;stroke-linecap:butt"
|
||||||
|
id="circle252" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="Left-selector"
|
||||||
|
serif:id="Left selector">
|
||||||
|
<g
|
||||||
|
transform="matrix(1.03522,0,0,1.23823,-170.092,0)"
|
||||||
|
id="g259">
|
||||||
|
<path
|
||||||
|
d="M 185.806,161.156 V 67.132"
|
||||||
|
style="fill:none;stroke:#4c9fff;stroke-width:4px;stroke-linecap:butt"
|
||||||
|
id="path257" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
transform="matrix(1.03522,0,0,1.23823,-10.3742,28.2274)"
|
||||||
|
id="g263">
|
||||||
|
<circle
|
||||||
|
cx="31.523001"
|
||||||
|
cy="34.313999"
|
||||||
|
r="10.021"
|
||||||
|
style="fill:#4c9fff;stroke:#4c9fff;stroke-width:4px;stroke-linecap:butt"
|
||||||
|
id="circle261" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
+70
-107
@@ -12,21 +12,23 @@ system/platform. This version may be out of date, however.
|
|||||||
|
|
||||||
These platforms have one-liner installs:
|
These platforms have one-liner installs:
|
||||||
|
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Debian, Ubuntu | ``apt install ocrmypdf`` |
|
| Debian, Ubuntu | ``apt install ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Fedora | ``dnf install ocrmypdf`` |
|
| Fedora | ``dnf install ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| macOS | ``brew install ocrmypdf`` |
|
| macOS | ``brew install ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| FreeBSD | ``pkg install py38-ocrmypdf`` |
|
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Conda (WSL, macOS, Linux) | ``conda install ocrmypdf`` |
|
| Conda (WSL, macOS, Linux) | ``conda install ocrmypdf`` |
|
||||||
+-------------------------------+-------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
|
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
||||||
|
+-------------------------------+-----------------------------------------+
|
||||||
|
|
||||||
More detailed procedures are outlined below. If you want to do a manual
|
More detailed procedures are outlined below. If you want to do a manual
|
||||||
install, or install a more recent version than your platform provides, read on.
|
install, or install a more recent version than your platform provides, read on.
|
||||||
@@ -42,10 +44,10 @@ Debian and Ubuntu 18.04 or newer
|
|||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
.. |deb-stable| image:: https://repology.org/badge/version-for-repo/debian_stable/ocrmypdf.svg
|
.. |deb-stable| image:: https://repology.org/badge/version-for-repo/debian_stable/ocrmypdf.svg
|
||||||
:alt: Debian 9 stable ("stretch")
|
:alt: Debian stable
|
||||||
|
|
||||||
.. |deb-testing| image:: https://repology.org/badge/version-for-repo/debian_testing/ocrmypdf.svg
|
.. |deb-testing| image:: https://repology.org/badge/version-for-repo/debian_testing/ocrmypdf.svg
|
||||||
:alt: Debian 10 testing ("buster")
|
:alt: Debian testing
|
||||||
|
|
||||||
.. |deb-unstable| image:: https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
|
.. |deb-unstable| image:: https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
|
||||||
:alt: Debian unstable
|
:alt: Debian unstable
|
||||||
@@ -56,8 +58,8 @@ Debian and Ubuntu 18.04 or newer
|
|||||||
.. |ubu-2004| image:: https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg
|
.. |ubu-2004| image:: https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg
|
||||||
:alt: Ubuntu 20.04 LTS
|
:alt: Ubuntu 20.04 LTS
|
||||||
|
|
||||||
.. |ubu-2110| image:: https://repology.org/badge/version-for-repo/ubuntu_21_10/ocrmypdf.svg
|
.. |ubu-2204| image:: https://repology.org/badge/version-for-repo/ubuntu_22_04/ocrmypdf.svg
|
||||||
:alt: Ubuntu 21.10
|
:alt: Ubuntu 22.04 LTS
|
||||||
|
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| **OCRmyPDF versions in Debian & Ubuntu** |
|
| **OCRmyPDF versions in Debian & Ubuntu** |
|
||||||
@@ -66,7 +68,7 @@ Debian and Ubuntu 18.04 or newer
|
|||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |deb-stable| |deb-testing| |deb-unstable| |
|
| |deb-stable| |deb-testing| |deb-unstable| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |ubu-1804| |ubu-2004| |ubu-2110| |
|
| |ubu-1804| |ubu-2004| |ubu-2204| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
|
|
||||||
Users of Debian 9 ("stretch") or later, or Ubuntu 18.04 or later, including users
|
Users of Debian 9 ("stretch") or later, or Ubuntu 18.04 or later, including users
|
||||||
@@ -80,8 +82,7 @@ As indicated in the table above, Debian and Ubuntu releases may lag
|
|||||||
behind the latest version. If the version available for your platform is
|
behind the latest version. If the version available for your platform is
|
||||||
out of date, you could opt to install the latest version from source.
|
out of date, you could opt to install the latest version from source.
|
||||||
See `Installing HEAD revision from
|
See `Installing HEAD revision from
|
||||||
sources <#installing-head-revision-from-sources>`__. Ubuntu 16.10 to 17.10
|
sources <#installing-head-revision-from-sources>`__.
|
||||||
inclusive also had ocrmypdf, but these versions are end of life.
|
|
||||||
|
|
||||||
For full details on version availability for your platform, check the
|
For full details on version availability for your platform, check the
|
||||||
`Debian Package Tracker <https://tracker.debian.org/pkg/ocrmypdf>`__ or
|
`Debian Package Tracker <https://tracker.debian.org/pkg/ocrmypdf>`__ or
|
||||||
@@ -91,19 +92,19 @@ For full details on version availability for your platform, check the
|
|||||||
|
|
||||||
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder.
|
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder.
|
||||||
OCRmyPDF works fine without it but will produce larger output files.
|
OCRmyPDF works fine without it but will produce larger output files.
|
||||||
If you build jbig2enc from source, ocrmypdf 7.0.0 and later will
|
If you build jbig2enc from source, ocrmypdf will
|
||||||
automatically detect it (specifically the ``jbig2`` binary) on the
|
automatically detect it (specifically the ``jbig2`` binary) on the
|
||||||
``PATH``. To add JBIG2 encoding, see :ref:`jbig2`.
|
``PATH``. To add JBIG2 encoding, see :ref:`jbig2`.
|
||||||
|
|
||||||
Fedora
|
Fedora
|
||||||
------
|
------
|
||||||
|
|
||||||
.. |fedora-34| image:: https://repology.org/badge/version-for-repo/fedora_34/ocrmypdf.svg
|
|
||||||
:alt: Fedora 34
|
|
||||||
|
|
||||||
.. |fedora-35| image:: https://repology.org/badge/version-for-repo/fedora_35/ocrmypdf.svg
|
.. |fedora-35| image:: https://repology.org/badge/version-for-repo/fedora_35/ocrmypdf.svg
|
||||||
:alt: Fedora 35
|
:alt: Fedora 35
|
||||||
|
|
||||||
|
.. |fedora-36| image:: https://repology.org/badge/version-for-repo/fedora_36/ocrmypdf.svg
|
||||||
|
:alt: Fedora 36
|
||||||
|
|
||||||
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||||
:alt: Fedore Rawhide
|
:alt: Fedore Rawhide
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ Fedora
|
|||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |latest| |
|
| |latest| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |fedora-34| |fedora-35| |fedora-rawhide| |
|
| |fedora-35| |fedora-36| |fedora-rawhide| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
|
|
||||||
Users of Fedora 29 or later may simply
|
Users of Fedora 29 or later may simply
|
||||||
@@ -138,9 +139,29 @@ from sources <#installing-head-revision-from-sources>`__.
|
|||||||
|
|
||||||
.. _ubuntu-lts-latest:
|
.. _ubuntu-lts-latest:
|
||||||
|
|
||||||
Installing the latest version on Ubuntu 20.04 LTS
|
Installing the latest version on Ubuntu 22.04 LTS
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
|
Ubuntu 22.04 includes ocrmypdf 13.4.0 - you can install that with
|
||||||
|
``apt install ocrmypdf``. To install a more recent version for the current
|
||||||
|
user, follow these steps:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get -y install ocrmypdf python3-pip
|
||||||
|
|
||||||
|
pip install --user --upgrade ocrmypdf
|
||||||
|
|
||||||
|
If you get the message ``WARNING: The script ocrmypdf is installed in
|
||||||
|
'/home/$USER/.local/bin' which is not on PATH.``, you may need to re-login
|
||||||
|
or open a new shell, or manually add this to your user's PATH.
|
||||||
|
|
||||||
|
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||||
|
|
||||||
|
Ubuntu 20.04 LTS
|
||||||
|
----------------
|
||||||
|
|
||||||
Ubuntu 20.04 includes ocrmypdf 9.6.0 - you can install that with ``apt``. To
|
Ubuntu 20.04 includes ocrmypdf 9.6.0 - you can install that with ``apt``. To
|
||||||
install a more recent version, uninstall the system-provided version of
|
install a more recent version, uninstall the system-provided version of
|
||||||
ocrmypdf, and install the following dependencies:
|
ocrmypdf, and install the following dependencies:
|
||||||
@@ -171,6 +192,8 @@ To install for the current user only:
|
|||||||
export PATH=$HOME/.local/bin:$PATH
|
export PATH=$HOME/.local/bin:$PATH
|
||||||
pip3 install --user ocrmypdf
|
pip3 install --user ocrmypdf
|
||||||
|
|
||||||
|
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||||
|
|
||||||
Ubuntu 18.04 LTS
|
Ubuntu 18.04 LTS
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
@@ -291,46 +314,6 @@ To install OCRmyPDF for Alpine Linux:
|
|||||||
|
|
||||||
apk add ocrmypdf
|
apk add ocrmypdf
|
||||||
|
|
||||||
Mageia 7
|
|
||||||
--------
|
|
||||||
|
|
||||||
There is no OS-level packaging available for Mageia, so you must install the
|
|
||||||
dependencies:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
# As root user
|
|
||||||
urpmi.update -a
|
|
||||||
urpmi \
|
|
||||||
ghostscript \
|
|
||||||
icc-profiles-openicc \
|
|
||||||
jbig2dec \
|
|
||||||
pngquant \
|
|
||||||
python3-pip \
|
|
||||||
python3-distutils-extra \
|
|
||||||
python3-pkg-resources \
|
|
||||||
python3-reportlab \
|
|
||||||
qpdf \
|
|
||||||
tesseract \
|
|
||||||
tesseract-osd \
|
|
||||||
tesseract-eng \
|
|
||||||
tesseract-fra
|
|
||||||
|
|
||||||
To install ocrmypdf for the system:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
# As root user
|
|
||||||
pip3 install ocrmypdf
|
|
||||||
ldconfig
|
|
||||||
|
|
||||||
Or, to install for the current user only:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
export PATH=$HOME/.local/bin:$PATH
|
|
||||||
pip3 install --user ocrmypdf
|
|
||||||
|
|
||||||
Other Linux packages
|
Other Linux packages
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
@@ -365,19 +348,6 @@ languages you can optionally install them all:
|
|||||||
|
|
||||||
brew install tesseract-lang # Optional: Install all language packs
|
brew install tesseract-lang # Optional: Install all language packs
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Users who previously installed OCRmyPDF on macOS using
|
|
||||||
``pip install ocrmypdf`` should remove the pip version
|
|
||||||
(``pip3 uninstall ocrmypdf``) before switching to the Homebrew
|
|
||||||
version.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Users who previously installed OCRmyPDF from the private tap should
|
|
||||||
switch to the mainline version (``brew untap jbarlow83/ocrmypdf``)
|
|
||||||
and install from there.
|
|
||||||
|
|
||||||
Manual installation on macOS
|
Manual installation on macOS
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
@@ -411,19 +381,19 @@ Update the homebrew pip:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
You can then install OCRmyPDF from PyPI, for the current user:
|
You can then install OCRmyPDF from PyPI, for the current user:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install --user ocrmypdf
|
pip install --user ocrmypdf
|
||||||
|
|
||||||
or system-wide:
|
or system-wide:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install ocrmypdf
|
pip install ocrmypdf
|
||||||
|
|
||||||
The command line program should now be available:
|
The command line program should now be available:
|
||||||
|
|
||||||
@@ -485,8 +455,8 @@ to change the PATH.
|
|||||||
Windows Subsystem for Linux
|
Windows Subsystem for Linux
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
#. Install Ubuntu 20.04 for Windows Subsystem for Linux, if not already installed.
|
#. Install Ubuntu 22.04 for Windows Subsystem for Linux, if not already installed.
|
||||||
#. Follow the procedure to install :ref:`OCRmyPDF on Ubuntu 20.04 <ubuntu-lts-latest>`.
|
#. Follow the procedure to install :ref:`OCRmyPDF on Ubuntu 22.04 <ubuntu-lts-latest>`.
|
||||||
#. Open the Windows command prompt and create a symlink:
|
#. Open the Windows command prompt and create a symlink:
|
||||||
|
|
||||||
.. code-block:: powershell
|
.. code-block:: powershell
|
||||||
@@ -558,16 +528,13 @@ your command prompt can run the docker "hello world" container.
|
|||||||
Installing on FreeBSD
|
Installing on FreeBSD
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
.. image:: https://repology.org/badge/version-for-repo/freebsd/python:ocrmypdf.svg
|
.. image:: https://repology.org/badge/version-for-repo/freebsd/ocrmypdf.svg
|
||||||
:alt: FreeBSD
|
:alt: FreeBSD
|
||||||
:target: https://repology.org/project/python:ocrmypdf/versions
|
:target: https://repology.org/project/ocrmypdf/versions
|
||||||
|
|
||||||
FreeBSD 11.3, 12.0, 12.1-RELEASE and 13.0-CURRENT are supported. Other
|
|
||||||
versions likely work but have not been tested.
|
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pkg install py38-ocrmypdf
|
pkg install textproc/py-ocrmypdf
|
||||||
|
|
||||||
To install a more recent version, you could attempt to first install the system
|
To install a more recent version, you could attempt to first install the system
|
||||||
version with ``pkg``, then use ``pip install --user ocrmypdf``.
|
version with ``pkg``, then use ``pip install --user ocrmypdf``.
|
||||||
@@ -616,18 +583,18 @@ try:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install --user ocrmypdf
|
pip install --user ocrmypdf
|
||||||
|
|
||||||
You should then be able to run ``ocrmypdf --version`` and see that the
|
You should then be able to run ``ocrmypdf --version`` and see that the
|
||||||
latest version was located.
|
latest version was located.
|
||||||
|
|
||||||
Since ``pip3 install --user`` does not work correctly on some platforms,
|
Since ``pip install --user`` does not work correctly on some platforms,
|
||||||
notably Ubuntu 16.04 and older, and the Homebrew version of Python,
|
notably Ubuntu 16.04 and older, and the Homebrew version of Python,
|
||||||
instead use this for a system wide installation:
|
instead use this for a system wide installation:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install ocrmypdf
|
pip install ocrmypdf
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
@@ -643,13 +610,9 @@ OCRmyPDF currently requires these external programs and libraries to be
|
|||||||
installed, and must be satisfied using the operating system package
|
installed, and must be satisfied using the operating system package
|
||||||
manager. ``pip`` cannot provide them.
|
manager. ``pip`` cannot provide them.
|
||||||
|
|
||||||
|
The following versions are required:
|
||||||
|
|
||||||
- Python 3.7 or newer
|
- Python 3.7 or newer
|
||||||
- Ghostscript 9.15 or newer
|
|
||||||
- Tesseract 4.0.0-beta or newer
|
|
||||||
|
|
||||||
As of ocrmypdf 7.2.1, the following versions are recommended:
|
|
||||||
|
|
||||||
- Python 3.9 or newer
|
|
||||||
- Ghostscript 9.23 or newer
|
- Ghostscript 9.23 or newer
|
||||||
- Tesseract 4.0.0 or newer
|
- Tesseract 4.0.0 or newer
|
||||||
- jbig2enc 0.29 or newer
|
- jbig2enc 0.29 or newer
|
||||||
@@ -696,7 +659,7 @@ environment:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install git+https://github.com/jbarlow83/OCRmyPDF.git
|
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
|
|
||||||
Or, to install in `development
|
Or, to install in `development
|
||||||
mode <https://pythonhosted.org/setuptools/setuptools.html#development-mode>`__,
|
mode <https://pythonhosted.org/setuptools/setuptools.html#development-mode>`__,
|
||||||
@@ -704,18 +667,18 @@ allowing customization of OCRmyPDF, use the ``-e`` flag:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
pip3 install -e git+https://github.com/jbarlow83/OCRmyPDF.git
|
pip install -e git+https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
|
|
||||||
You may find it easiest to install in a virtual environment, rather than
|
You may find it easiest to install in a virtual environment, rather than
|
||||||
system-wide:
|
system-wide:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
git clone -b master https://github.com/jbarlow83/OCRmyPDF.git
|
git clone -b master https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
python3 -m venv
|
python3 -m venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
cd OCRmyPDF
|
cd OCRmyPDF
|
||||||
pip3 install .
|
pip install .
|
||||||
|
|
||||||
However, ``ocrmypdf`` will only be accessible on the system PATH when
|
However, ``ocrmypdf`` will only be accessible on the system PATH when
|
||||||
you activate the virtual environment.
|
you activate the virtual environment.
|
||||||
@@ -738,8 +701,8 @@ To install all of the development and test requirements:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
git clone -b master https://github.com/jbarlow83/OCRmyPDF.git
|
git clone -b master https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
python3 -m venv
|
python -m venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
cd OCRmyPDF
|
cd OCRmyPDF
|
||||||
pip install -e .[test]
|
pip install -e .[test]
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ For all other Linux, you must build a JBIG2 encoder from source:
|
|||||||
|
|
||||||
.. _jbig2-lossy:
|
.. _jbig2-lossy:
|
||||||
|
|
||||||
|
Dependencies include libtoolize and libleptonica, which on Ubuntu systems
|
||||||
|
are packaged as libtool and libleptonica-dev.
|
||||||
|
|
||||||
Lossy mode JBIG2
|
Lossy mode JBIG2
|
||||||
================
|
================
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,33 @@ to what languages it should search for. Multiple languages can be
|
|||||||
requested using either ``-l eng+fra`` (English and French) or
|
requested using either ``-l eng+fra`` (English and French) or
|
||||||
``-l eng -l fra``.
|
``-l eng -l fra``.
|
||||||
|
|
||||||
|
Gentoo users
|
||||||
|
============
|
||||||
|
|
||||||
|
On Gentoo the package ``app-text/tessdata_fast``, which ``app-text/tesseract`` depends on, handles Tesseract languages.
|
||||||
|
It accepts USE flags to select what languages should be installed, these can be set in ``/etc/portage/package.use``.
|
||||||
|
Alternatively one can globally set the `L10N use extension <https://wiki.gentoo.org/wiki/Localization/Guide#L10N>`__ in ``/etc/portage/make.conf``.
|
||||||
|
This enables these languages for all packages (e.g. including aspell).
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# Display a list of all Tesseract language packs
|
||||||
|
equery uses app-text/tessdata_fast
|
||||||
|
|
||||||
|
# Add English and German language support for Tesseract only
|
||||||
|
echo 'app-text/tessdata_fast l10n_de l10n_en' >> /etc/portage/package.use
|
||||||
|
|
||||||
|
# Add global English and German language support (the `l10n_` from equery has to be omited)
|
||||||
|
echo L10N="de en" >> /etc/portage/make.conf
|
||||||
|
|
||||||
|
# update system to reflect changed USE flags
|
||||||
|
emerge --update --deep --newuse @world
|
||||||
|
|
||||||
|
You can then pass the ``-l LANG`` argument to OCRmyPDF to give a hint as
|
||||||
|
to what languages it should search for. Multiple languages can be
|
||||||
|
requested using either ``-l eng+fra`` (English and French) or
|
||||||
|
``-l eng -l fra``.
|
||||||
|
|
||||||
macOS users
|
macOS users
|
||||||
===========
|
===========
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Maintainer notes
|
Maintainer notes
|
||||||
================
|
================
|
||||||
|
|
||||||
This is for those who package OCRmyPDF for downstream use. (Thank you
|
This is for those who package OCRmyPDF for downstream use. (Thank you
|
||||||
for your hard work.)
|
for your hard work.)
|
||||||
|
|
||||||
Known ports/packagers
|
Known ports/packagers
|
||||||
@@ -25,7 +25,7 @@ Non-Python dependencies
|
|||||||
|
|
||||||
Note that we have non-Python dependencies. In particular, OCRmyPDF requires
|
Note that we have non-Python dependencies. In particular, OCRmyPDF requires
|
||||||
Ghostscript and Tesseract OCR to be installed and needs to be able to locate their
|
Ghostscript and Tesseract OCR to be installed and needs to be able to locate their
|
||||||
binaries on the system PATH. On Windows, OCRmyPDF will also check the registry
|
binaries on the system PATH. On Windows, OCRmyPDF will also check the registry
|
||||||
for their locations.
|
for their locations.
|
||||||
|
|
||||||
Tesseract OCR relies on SIMD for performance and only has proper support for this
|
Tesseract OCR relies on SIMD for performance and only has proper support for this
|
||||||
@@ -38,13 +38,13 @@ OCRmyPDF uses setuptools-scm for versioning, which derives the version from
|
|||||||
Git as a single source of truth. This may be unsuitable for some distributions, e.g.
|
Git as a single source of truth. This may be unsuitable for some distributions, e.g.
|
||||||
to indicate that your distribution modifies OCRmyPDF in some way.
|
to indicate that your distribution modifies OCRmyPDF in some way.
|
||||||
|
|
||||||
You can patch the ``__version__`` variable in ``src/ocrmypdf/_version.py`` if
|
You can patch the ``__version__`` variable in ``src/ocrmypdf/_version.py`` if
|
||||||
necessary.
|
necessary.
|
||||||
|
|
||||||
OCRmyPDF uses setuptools-scm-git-archive to ensure that tarballs downloaded from
|
OCRmyPDF uses setuptools-scm-git-archive to ensure that tarballs downloaded from
|
||||||
GitHub contain version information. Unfortunately, these tarballs are not always
|
GitHub contain version information. Unfortunately, these tarballs are not always
|
||||||
deterministic. See this
|
deterministic. See this
|
||||||
`issue <https://github.com/jbarlow83/OCRmyPDF/issues/841#issuecomment-936562696>`_.
|
`issue <https://github.com/ocrmypdf/OCRmyPDF/issues/841#issuecomment-936562696>`_.
|
||||||
|
|
||||||
jbig2enc
|
jbig2enc
|
||||||
--------
|
--------
|
||||||
|
|||||||
@@ -152,6 +152,16 @@ hooks. As such, you cannot "chain" a series of plugin filters together in this
|
|||||||
way. Instead, a single hook implementation should be responsible for any such
|
way. Instead, a single hook implementation should be responsible for any such
|
||||||
chaining operations.
|
chaining operations.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
========
|
||||||
|
|
||||||
|
* OCRmyPDF's test suite contains several plugins that are used to simulate certain
|
||||||
|
test conditions.
|
||||||
|
* `ocrmypdf-papermerge <https://github.com/papermerge/OCRmyPDF_papermerge>`_ is
|
||||||
|
a production plugin that integrates OCRmyPDF and the Papermerge document
|
||||||
|
management system.
|
||||||
|
|
||||||
|
|
||||||
Custom command line arguments
|
Custom command line arguments
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
|||||||
+102
-4
@@ -12,11 +12,109 @@ may be unreliable. Use the API to depend on precise behavior.
|
|||||||
The public API may be useful in scripts that launch OCRmyPDF processes or that
|
The public API may be useful in scripts that launch OCRmyPDF processes or that
|
||||||
wish to use some of its features for working with PDFs.
|
wish to use some of its features for working with PDFs.
|
||||||
|
|
||||||
|
The most recent release of OCRmyPDF is |OCRmyPDF PyPI|. Any newer versions
|
||||||
|
referred to in these notes may exist the main branch but have not been
|
||||||
|
tagged yet.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Python 3.6 reaches end of life on December 23, 2021. We have already ended support
|
Attention maintainers: that these release notes may be updated with information
|
||||||
for Python 3.6 but might release fixes for critical issues if necessary before that
|
about a forthcoming release that has not been tagged yet. A release is only
|
||||||
date.
|
official when it's tagged and posted to PyPI.
|
||||||
|
|
||||||
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
v13.5.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Added a new ``optimize_pdf`` pluginhook, making it possible to create plugins that
|
||||||
|
replace or enhance OCRmyPDF's PDF optimizer.
|
||||||
|
- Removed all max version restrictions. Our new policy is to blacklist known-bad releases
|
||||||
|
and only block known-bad versions of dependencies.
|
||||||
|
- The naming schema for object that holds all OCR text that OCRmyPDF inserts has
|
||||||
|
changed. This has always been an implementation detail (and remains so), but possibly,
|
||||||
|
someone was relying on it and would appreciate the heads-up.
|
||||||
|
- Cleanup.
|
||||||
|
|
||||||
|
v13.4.7
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed PermissionError when cleaning up temporary files in rare cases. :issue:`974`
|
||||||
|
- Fixed PermissionError when calling ``os.nice`` on platforms that lack it. :issue:`973`
|
||||||
|
- Suppressed some warnings from libxmp during tests.
|
||||||
|
|
||||||
|
v13.4.6
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Convert error on corrupt ICC profiles into a warning. Thanks to @oscherler.
|
||||||
|
|
||||||
|
v13.4.5
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Remove upper bound on pdfminer.six version.
|
||||||
|
- Documentation.
|
||||||
|
|
||||||
|
v13.4.4
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Updated pdfminer.six version.
|
||||||
|
- Docker image changed to Ubuntu 22.04 now that it is released and provides the
|
||||||
|
dependencies we need. This seems more consistent than our recent change to
|
||||||
|
Debian.
|
||||||
|
|
||||||
|
v13.4.3
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fix error on pytest.skip() with older versions of pytest.
|
||||||
|
- Documentation updates.
|
||||||
|
|
||||||
|
v13.4.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Worked around a
|
||||||
|
`major regression in Ghostscript 9.56.0 <https://bugs.ghostscript.com/show_bug.cgi?id=705187>`__
|
||||||
|
where **all OCR text is stripped out of the PDF**. It simply removes all text,
|
||||||
|
even generated by software other than OCRmyPDF. Fortunately, we can ask
|
||||||
|
Ghostscript 9.56.0 to use its old behavior that worked correctly for our purposes.
|
||||||
|
Users must avoid the combination (Ghostscript 9.56.0, ocrmypdf <13.4.2) since
|
||||||
|
older versions of OCRmyPDF have no way of detecting that this particular
|
||||||
|
version of Ghostscript removes all OCR text.
|
||||||
|
- Marked pdfminer 20220319 as supported.
|
||||||
|
- Fixed some deprecation warnings from recent versions of Pillow and pytest.
|
||||||
|
- Test suite now covers Python 3.10 (Python 3.10 worked fine before, but was not
|
||||||
|
being tested).
|
||||||
|
- Docker image now uses debian:bookworm-slim as the base image to fix the Docker
|
||||||
|
image build.
|
||||||
|
|
||||||
|
v13.4.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Temporarily make threads rather than processes the default executor worker, due
|
||||||
|
to a persistent deadlock issue when processes are used. Add a new command line
|
||||||
|
argument ``--no-use-threads`` to disable this.
|
||||||
|
|
||||||
|
v13.4.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed test failures when using pikepdf 5.0.0.
|
||||||
|
- Various improvements to the optimizer. In particular, we now recognize PDF images
|
||||||
|
that are encoded with both deflate (PNG) and DCT (JPEG), and also produce PDF
|
||||||
|
with images compressed with deflate and DCT, since this often yields file size
|
||||||
|
improvements compared to plain DCT.
|
||||||
|
|
||||||
|
v13.3.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Made a harmless but "scary" exception after failing to optimize an image less scary.
|
||||||
|
- Added a warning if a page image is too large for unpaper to clean. The image is
|
||||||
|
passed through without cleaning. This is due to a hard-coded limitation in a
|
||||||
|
C library used by unpaper so it cannot be rectified easily.
|
||||||
|
- We now use better default settings when calling img2pdf.
|
||||||
|
- We no longer try to optimize images that we failed to save in certain situations.
|
||||||
|
- We now account for some differences in text output from Tesseract 5 compared to
|
||||||
|
Tesseract 4.
|
||||||
|
- Better handling of Ghostscript producing empty images when attempting to rasterize
|
||||||
|
page images.
|
||||||
|
|
||||||
v13.2.0
|
v13.2.0
|
||||||
=======
|
=======
|
||||||
@@ -65,7 +163,7 @@ v13.0.0
|
|||||||
Tesseract 5.x has implemented improvements to thresholding, so this feature will be
|
Tesseract 5.x has implemented improvements to thresholding, so this feature will be
|
||||||
redundant anyway.
|
redundant anyway.
|
||||||
- ``--deskew`` was previous calculated by a Leptonica algorithm. We now use a feature
|
- ``--deskew`` was previous calculated by a Leptonica algorithm. We now use a feature
|
||||||
of Tesseract to term the appropriate the angle to deskew a page. The deskew angle
|
of Tesseract to find the appropriate the angle to deskew a page. The deskew angle
|
||||||
according to Tesseract may differ from Leptonica's algorithm. At least in theory,
|
according to Tesseract may differ from Leptonica's algorithm. At least in theory,
|
||||||
Tesseract's deskew angle is informed by a more complex analysis than Leptonica,
|
Tesseract's deskew angle is informed by a more complex analysis than Leptonica,
|
||||||
so this should improve results in general. We also use Pillow to perform the
|
so this should improve results in general. We also use Pillow to perform the
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,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, stdout=PIPE, stderr=PIPE, encoding="utf-8")
|
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8")
|
||||||
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')
|
||||||
|
|||||||
@@ -99,3 +99,8 @@ module = [
|
|||||||
'libxmp.utils'
|
'libxmp.utils'
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pylint.basic]
|
||||||
|
good-names = ["i", "j", "k", "ex", "Run", "_", "e", "p", "im", "w", "h", "m", "x", "y", "a", "b", "fp", "n", "f", "s", "v", "q", "dx", "dy"]
|
||||||
|
logging-format-style = "old"
|
||||||
|
disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "logging-fstring-interpolation", "missing-function-docstring", "too-few-public-methods"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = ocrmypdf
|
|||||||
description = OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched
|
description = OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched
|
||||||
long_description = file: README.md
|
long_description = file: README.md
|
||||||
long_description_content_type = text/markdown
|
long_description_content_type = text/markdown
|
||||||
url = https://github.com/jbarlow83/OCRmyPDF
|
url = https://github.com/ocrmypdf/OCRmyPDF
|
||||||
author = James R. Barlow
|
author = James R. Barlow
|
||||||
author_email = james@purplerock.ca
|
author_email = james@purplerock.ca
|
||||||
license = MPL-2.0
|
license = MPL-2.0
|
||||||
@@ -39,19 +39,19 @@ keywords =
|
|||||||
scanning
|
scanning
|
||||||
project_urls =
|
project_urls =
|
||||||
Documentation = https://ocrmypdf.readthedocs.io/
|
Documentation = https://ocrmypdf.readthedocs.io/
|
||||||
Source = https://github.com/jbarlow83/ocrmypdf
|
Source = https://github.com/ocrmypdf/OCRmyPDF
|
||||||
Tracker = https://github.com/jbarlow83/ocrmypdf/issues
|
Tracker = https://github.com/ocrmypdf/OCRmyPDF/issues
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
packages = find:
|
packages = find:
|
||||||
install_requires =
|
install_requires =
|
||||||
Pillow>=8.2.0
|
Pillow>=8.2.0
|
||||||
coloredlogs>=14.0 # strictly optional
|
coloredlogs>=14.0 # strictly optional
|
||||||
img2pdf>=0.3.0,<0.5 # pure Python
|
img2pdf>=0.3.0 # pure Python
|
||||||
packaging>=20
|
packaging>=20
|
||||||
pdfminer.six!=20200720,>=20191110,<=20211012
|
pdfminer.six!=20200720,>=20191110
|
||||||
pikepdf>=4.0.0
|
pikepdf!=5.0.0,>=4.0.0
|
||||||
pluggy>=0.13.0,<2
|
pluggy>=0.13.0
|
||||||
reportlab>=3.5.66
|
reportlab>=3.5.66
|
||||||
tqdm>=4
|
tqdm>=4
|
||||||
importlib-metadata>=4;python_version<'3.8' # until Python 3.8
|
importlib-metadata>=4;python_version<'3.8' # until Python 3.8
|
||||||
@@ -87,9 +87,9 @@ test =
|
|||||||
pytest-xdist>=2.2.0
|
pytest-xdist>=2.2.0
|
||||||
python-xmp-toolkit==2.0.1 # also requires apt-get install libexempi3
|
python-xmp-toolkit==2.0.1 # also requires apt-get install libexempi3
|
||||||
watcher =
|
watcher =
|
||||||
watchdog>=1.0.2,<3
|
watchdog>=1.0.2
|
||||||
webservice =
|
webservice =
|
||||||
Flask>=1,<3
|
Flask>=1
|
||||||
|
|
||||||
[options.package_data]
|
[options.package_data]
|
||||||
ocrmypdf =
|
ocrmypdf =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""setup.py to support older setuptools and pip."""
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: ocrmypdf
|
||||||
|
title: OCRmyPDF
|
||||||
|
base: core20
|
||||||
|
version: git
|
||||||
|
summary: OCRmyPDF adds optical character recognition (OCR) to PDFs
|
||||||
|
description: OCRmyPDF packaged for snap
|
||||||
|
grade: stable
|
||||||
|
confinement: strict
|
||||||
|
icon: docs/images/logo-square-256.svg
|
||||||
|
license: MPL-2.0
|
||||||
|
|
||||||
|
architectures: [amd64]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/4.00/tessdata
|
||||||
|
GS_LIB: $SNAP/usr/share/ghostscript/9.50/Resource/Init
|
||||||
|
GS_FONTPATH: $SNAP/usr/share/ghostscript/9.50/Resource/Font
|
||||||
|
LD_LIBRARY_PATH: $SNAP/usr/lib/x86_64-linux-gnu
|
||||||
|
|
||||||
|
apps:
|
||||||
|
ocrmypdf:
|
||||||
|
command: usr/bin/snapcraft-preload python3 -m ocrmypdf
|
||||||
|
plugs:
|
||||||
|
- desktop
|
||||||
|
- desktop-legacy
|
||||||
|
- wayland
|
||||||
|
- x11
|
||||||
|
- home
|
||||||
|
- removable-media
|
||||||
|
|
||||||
|
parts:
|
||||||
|
snapcraft-preload:
|
||||||
|
source: https://github.com/sergiusens/snapcraft-preload.git
|
||||||
|
plugin: cmake
|
||||||
|
cmake-parameters:
|
||||||
|
- -DCMAKE_INSTALL_PREFIX=/usr -DLIBPATH=/usr/lib
|
||||||
|
build-packages:
|
||||||
|
- on amd64:
|
||||||
|
- gcc-multilib
|
||||||
|
- g++-multilib
|
||||||
|
stage-packages:
|
||||||
|
- lib32stdc++6
|
||||||
|
|
||||||
|
ocrmypdf:
|
||||||
|
plugin: python
|
||||||
|
source: https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
|
|
||||||
|
stage-packages:
|
||||||
|
- ghostscript
|
||||||
|
- icc-profiles-free
|
||||||
|
- liblept5
|
||||||
|
- libxml2
|
||||||
|
- pngquant
|
||||||
|
- tesseract-ocr-all
|
||||||
|
- unpaper
|
||||||
|
- qpdf
|
||||||
|
- zlib1g
|
||||||
|
|
||||||
|
python-packages:
|
||||||
|
- cffi
|
||||||
|
- pdfminer.six
|
||||||
|
- pikepdf
|
||||||
|
- Pillow
|
||||||
|
- pluggy
|
||||||
|
- reportlab
|
||||||
|
- setuptools
|
||||||
|
- tqdm
|
||||||
|
- pipe
|
||||||
|
|
||||||
|
override-build: |
|
||||||
|
snapcraftctl build
|
||||||
|
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Adds OCR layer to PDFs."""
|
||||||
|
|
||||||
from pluggy import HookimplMarker as _HookimplMarker
|
from pluggy import HookimplMarker as _HookimplMarker
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""ocrmypdf command line entrypoint."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from multiprocessing import set_start_method
|
from multiprocessing import set_start_method
|
||||||
|
|
||||||
from ocrmypdf import __version__
|
from ocrmypdf import __version__
|
||||||
@@ -34,7 +36,7 @@ def sigbus(*args):
|
|||||||
def run(args=None):
|
def run(args=None):
|
||||||
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
|
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
|
||||||
|
|
||||||
if hasattr(os, 'nice'):
|
with suppress(AttributeError, PermissionError):
|
||||||
os.nice(5)
|
os.nice(5)
|
||||||
|
|
||||||
verbosity = options.verbose
|
verbosity = options.verbose
|
||||||
@@ -62,7 +64,7 @@ def run(args=None):
|
|||||||
log.error(e)
|
log.error(e)
|
||||||
return ExitCode.missing_dependency
|
return ExitCode.missing_dependency
|
||||||
|
|
||||||
if hasattr(signal, 'SIGBUS'):
|
with suppress(AttributeError, OSError):
|
||||||
signal.signal(signal.SIGBUS, sigbus)
|
signal.signal(signal.SIGBUS, sigbus)
|
||||||
|
|
||||||
result = run_pipeline(options=options, plugin_manager=plugin_manager)
|
result = run_pipeline(options=options, plugin_manager=plugin_manager)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""OCRmyPDF concurrency abstractions."""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Callable, Iterable, Optional
|
from typing import Callable, Iterable, Optional
|
||||||
@@ -14,6 +16,8 @@ def _task_noop(*_args, **_kwargs):
|
|||||||
|
|
||||||
|
|
||||||
class NullProgressBar:
|
class NullProgressBar:
|
||||||
|
"""Progress bar API that takes no actions."""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -28,6 +32,8 @@ class NullProgressBar:
|
|||||||
|
|
||||||
|
|
||||||
class Executor(ABC):
|
class Executor(ABC):
|
||||||
|
"""Abstract concurrent executor."""
|
||||||
|
|
||||||
pool_lock = threading.Lock()
|
pool_lock = threading.Lock()
|
||||||
pbar_class = NullProgressBar
|
pbar_class = NullProgressBar
|
||||||
|
|
||||||
|
|||||||
@@ -14,41 +14,36 @@ import sys
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import which
|
|
||||||
from subprocess import PIPE, CalledProcessError
|
from subprocess import PIPE, CalledProcessError
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
from ocrmypdf.exceptions import SubprocessOutputError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
||||||
|
|
||||||
|
# Remove this workaround when we require Pillow >= 10
|
||||||
|
try:
|
||||||
|
Transpose = Image.Transpose # type: ignore
|
||||||
|
except AttributeError:
|
||||||
|
# Pillow 9 shim
|
||||||
|
Transpose = Image # type: ignore
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
missing_gs_error = """
|
|
||||||
---------------------------------------------------------------------
|
|
||||||
This error normally occurs when ocrmypdf find can't Ghostscript.
|
|
||||||
Please ensure Ghostscript is installed and its location is added to
|
|
||||||
the system PATH environment variable.
|
|
||||||
|
|
||||||
For details see:
|
|
||||||
https://ocrmypdf.readthedocs.io/en/latest/installation.html
|
|
||||||
---------------------------------------------------------------------
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Most reliable what to get the bitness of Python interpreter, according to Python docs
|
# Most reliable what to get the bitness of Python interpreter, according to Python docs
|
||||||
_is_64bit = sys.maxsize > 2 ** 32
|
_IS_64BIT = sys.maxsize > 2**32
|
||||||
|
|
||||||
_gswin = None
|
_GSWIN = None
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
if _is_64bit:
|
if _IS_64BIT:
|
||||||
_gswin = 'gswin64c'
|
_GSWIN = 'gswin64c'
|
||||||
else:
|
else:
|
||||||
_gswin = 'gswin32c'
|
_GSWIN = 'gswin32c'
|
||||||
|
|
||||||
GS = _gswin if _gswin else 'gs'
|
GS = _GSWIN if _GSWIN else 'gs'
|
||||||
del _gswin
|
del _GSWIN
|
||||||
|
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
@@ -71,7 +66,8 @@ def jpeg_passthrough_available() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _gs_error_reported(stream) -> bool:
|
def _gs_error_reported(stream) -> bool:
|
||||||
return True if re.search(r'error', stream, flags=re.IGNORECASE) else False
|
match = re.search(r'error', stream, flags=re.IGNORECASE)
|
||||||
|
return bool(match)
|
||||||
|
|
||||||
|
|
||||||
def rasterize_pdf(
|
def rasterize_pdf(
|
||||||
@@ -107,7 +103,7 @@ def rasterize_pdf(
|
|||||||
+ [
|
+ [
|
||||||
'-o',
|
'-o',
|
||||||
'-',
|
'-',
|
||||||
'-sstdout=%stderr',
|
'-sstdout=%stderr', # Literal %s, not string interpolation
|
||||||
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
||||||
'-f',
|
'-f',
|
||||||
fspath(input_file),
|
fspath(input_file),
|
||||||
@@ -118,29 +114,38 @@ def rasterize_pdf(
|
|||||||
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
log.error(e.stderr.decode(errors='replace'))
|
log.error(e.stderr.decode(errors='replace'))
|
||||||
raise SubprocessOutputError('Ghostscript rasterizing failed')
|
raise SubprocessOutputError('Ghostscript rasterizing failed') from e
|
||||||
else:
|
else:
|
||||||
stderr = p.stderr.decode(errors='replace')
|
stderr = p.stderr.decode(errors='replace')
|
||||||
if _gs_error_reported(stderr):
|
if _gs_error_reported(stderr):
|
||||||
log.error(stderr)
|
log.error(stderr)
|
||||||
|
|
||||||
with Image.open(BytesIO(p.stdout)) as im:
|
try:
|
||||||
if rotation is not None:
|
with Image.open(BytesIO(p.stdout)) as im:
|
||||||
log.debug("Rotating output by %i", rotation)
|
if rotation is not None:
|
||||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
log.debug("Rotating output by %i", rotation)
|
||||||
# counterclockwise so this cancels out the rotation
|
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||||
if rotation == 90:
|
# counterclockwise so this cancels out the rotation
|
||||||
im = im.transpose(Image.ROTATE_90)
|
if rotation == 90:
|
||||||
elif rotation == 180:
|
im = im.transpose(Transpose.ROTATE_90)
|
||||||
im = im.transpose(Image.ROTATE_180)
|
elif rotation == 180:
|
||||||
elif rotation == 270:
|
im = im.transpose(Transpose.ROTATE_180)
|
||||||
im = im.transpose(Image.ROTATE_270)
|
elif rotation == 270:
|
||||||
if rotation % 180 == 90:
|
im = im.transpose(Transpose.ROTATE_270)
|
||||||
page_dpi = page_dpi.flip_axis()
|
if rotation % 180 == 90:
|
||||||
im.save(fspath(output_file), dpi=page_dpi)
|
page_dpi = page_dpi.flip_axis()
|
||||||
|
im.save(fspath(output_file), dpi=page_dpi)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
log.error(
|
||||||
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
|
"an invalid page image file."
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class GhostscriptFollower:
|
class GhostscriptFollower:
|
||||||
|
"""Parses the output of Ghostscript and uses it to update the progress bar."""
|
||||||
|
|
||||||
re_process = re.compile(r"Processing pages \d+ through (\d+).")
|
re_process = re.compile(r"Processing pages \d+ through (\d+).")
|
||||||
re_page = re.compile(r"Page (\d+)")
|
re_page = re.compile(r"Page (\d+)")
|
||||||
|
|
||||||
@@ -161,8 +166,7 @@ class GhostscriptFollower:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
m = self.re_page.match(line.strip())
|
if self.re_page.match(line.strip()):
|
||||||
if m:
|
|
||||||
self.progressbar.update()
|
self.progressbar.update()
|
||||||
|
|
||||||
|
|
||||||
@@ -203,14 +207,18 @@ def generate_pdfa(
|
|||||||
# Older versions of Ghostscript expect a leading slash in
|
# Older versions of Ghostscript expect a leading slash in
|
||||||
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
|
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
|
||||||
# git commit fe1c025d.
|
# git commit fe1c025d.
|
||||||
strategy = ('/' + strategy) if version() < '9.19' else strategy
|
gs_version = version()
|
||||||
|
strategy = ('/' + strategy) if gs_version < '9.19' else strategy
|
||||||
|
|
||||||
if version() == '9.23':
|
if gs_version == '9.23':
|
||||||
# 9.23: added JPEG passthrough as a new feature, but with a bug that
|
# 9.23: added JPEG passthrough as a new feature, but with a bug that
|
||||||
# incorrectly formats some images. Fixed as of 9.24. So we disable this
|
# incorrectly formats some images. Fixed as of 9.24. So we disable this
|
||||||
# feature for 9.23.
|
# feature for 9.23.
|
||||||
# https://bugs.ghostscript.com/show_bug.cgi?id=699216
|
# https://bugs.ghostscript.com/show_bug.cgi?id=699216
|
||||||
compression_args.append('-dPassThroughJPEGImages=false')
|
compression_args.append('-dPassThroughJPEGImages=false')
|
||||||
|
elif gs_version == '9.56.0':
|
||||||
|
# 9.56.0 breaks our OCR...?
|
||||||
|
compression_args.append('-dNEWPDF=false')
|
||||||
|
|
||||||
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
||||||
# is set; see:
|
# is set; see:
|
||||||
@@ -233,7 +241,7 @@ def generate_pdfa(
|
|||||||
"-dPDFACompatibilityPolicy=1",
|
"-dPDFACompatibilityPolicy=1",
|
||||||
"-o",
|
"-o",
|
||||||
"-",
|
"-",
|
||||||
"-sstdout=%stderr",
|
"-sstdout=%stderr", # Literal %s, not string interpolation
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from math import pi
|
|||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
|
from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
|
||||||
from typing import Dict, Iterator, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -55,9 +55,11 @@ TESSERACT_THRESHOLDING_METHODS: Dict[str, int] = {
|
|||||||
|
|
||||||
|
|
||||||
class TesseractLoggerAdapter(logging.LoggerAdapter):
|
class TesseractLoggerAdapter(logging.LoggerAdapter):
|
||||||
|
"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
|
||||||
return '[tesseract] %s' % (msg), kwargs
|
return f'[tesseract] {msg}', kwargs
|
||||||
|
|
||||||
|
|
||||||
TESSERACT_VERSION_PATTERN = r"""
|
TESSERACT_VERSION_PATTERN = r"""
|
||||||
@@ -105,6 +107,7 @@ TESSERACT_VERSION_PATTERN = r"""
|
|||||||
|
|
||||||
|
|
||||||
class TesseractVersion(Version):
|
class TesseractVersion(Version):
|
||||||
|
"Modify standard packaging.Version regex to support Tesseract idiosyncracies."
|
||||||
_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
|
||||||
)
|
)
|
||||||
@@ -169,14 +172,14 @@ def tess_base_args(langs: List[str], engine_mode: Optional[int]) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_tesseract_output(binary_output: bytes) -> Dict[str, str]:
|
def _parse_tesseract_output(binary_output: bytes) -> Dict[str, str]:
|
||||||
def g():
|
def gen():
|
||||||
for line in binary_output.decode().splitlines():
|
for line in binary_output.decode().splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
parts = line.split(':', maxsplit=2)
|
parts = line.split(':', maxsplit=2)
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
yield parts[0].strip(), parts[1].strip()
|
yield parts[0].strip(), parts[1].strip()
|
||||||
|
|
||||||
return {k: v for k, v in g()}
|
return dict(gen())
|
||||||
|
|
||||||
|
|
||||||
def get_orientation(
|
def get_orientation(
|
||||||
@@ -205,10 +208,10 @@ def get_orientation(
|
|||||||
|
|
||||||
osd = _parse_tesseract_output(p.stdout)
|
osd = _parse_tesseract_output(p.stdout)
|
||||||
angle = int(osd.get('Orientation in degrees', 0))
|
angle = int(osd.get('Orientation in degrees', 0))
|
||||||
oc = OrientationConfidence(
|
orient_conf = OrientationConfidence(
|
||||||
angle=angle, confidence=float(osd.get('Orientation confidence', 0))
|
angle=angle, confidence=float(osd.get('Orientation confidence', 0))
|
||||||
)
|
)
|
||||||
return oc
|
return orient_conf
|
||||||
|
|
||||||
|
|
||||||
def get_deskew(
|
def get_deskew(
|
||||||
@@ -344,7 +347,7 @@ def generate_hocr(
|
|||||||
_generate_null_hocr(output_hocr, output_text, input_file)
|
_generate_null_hocr(output_hocr, output_text, input_file)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
tesseract_log_output(e.output)
|
tesseract_log_output(e.output)
|
||||||
if b'Image too large' in e.output:
|
if b'Image too large' in e.output or b'Empty page!!' in e.output:
|
||||||
_generate_null_hocr(output_hocr, output_text, input_file)
|
_generate_null_hocr(output_hocr, output_text, input_file)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -416,7 +419,7 @@ def generate_pdf(
|
|||||||
use_skip_page(output_pdf, output_text)
|
use_skip_page(output_pdf, output_text)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
tesseract_log_output(e.output)
|
tesseract_log_output(e.output)
|
||||||
if b'Image too large' in e.output:
|
if b'Image too large' in e.output or b'Empty page!!' in e.output:
|
||||||
use_skip_page(output_pdf, output_text)
|
use_skip_page(output_pdf, output_text)
|
||||||
return
|
return
|
||||||
raise SubprocessOutputError() from e
|
raise SubprocessOutputError() from e
|
||||||
|
|||||||
@@ -13,69 +13,119 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT
|
from subprocess import PIPE, STDOUT
|
||||||
from tempfile import TemporaryDirectory
|
from typing import Iterator, List, Optional, Tuple, Union
|
||||||
from typing import List, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
||||||
from ocrmypdf.subprocess import get_version
|
from ocrmypdf.subprocess import get_version, run
|
||||||
from ocrmypdf.subprocess import run as external_run
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
else:
|
||||||
|
from tempfile import TemporaryDirectory as _TemporaryDirectory
|
||||||
|
|
||||||
|
class TemporaryDirectory(_TemporaryDirectory):
|
||||||
|
"""Shim to consume ignore_cleanup_errors kwarg on Python 3.9 and older.
|
||||||
|
|
||||||
|
The argument is consumed without action. If users are getting errors related
|
||||||
|
to temporary file cleanup, they should upgrade to Python 3.10 which properly
|
||||||
|
cleans up temporary directories on Windows.
|
||||||
|
|
||||||
|
See: https://github.com/python/cpython/pull/24793
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ignore_cleanup_errors=False, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
del _TemporaryDirectory
|
||||||
|
|
||||||
|
|
||||||
|
UNPAPER_IMAGE_PIXEL_LIMIT = 256 * 1024 * 1024
|
||||||
|
|
||||||
DecFloat = Union[Decimal, float]
|
DecFloat = Union[Decimal, float]
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UnpaperImageTooLargeError(Exception):
|
||||||
|
"""To capture details when an image is too large for unpaper."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
message="Image with size {}x{} is too large for cleaning with 'unpaper'.",
|
||||||
|
):
|
||||||
|
self.w = w
|
||||||
|
self.h = h
|
||||||
|
self.message = message.format(w, h)
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
def version() -> str:
|
def version() -> str:
|
||||||
return get_version('unpaper')
|
return get_version('unpaper')
|
||||||
|
|
||||||
|
|
||||||
def _setup_unpaper_io(tmpdir: Path, input_file: Path) -> Tuple[Path, Path]:
|
SUFFIXES = {'1': '.pbm', 'L': '.pgm', 'RGB': '.ppm'}
|
||||||
SUFFIXES = {'1': '.pbm', 'L': '.pgm', 'RGB': '.ppm'}
|
|
||||||
with Image.open(input_file) as im:
|
|
||||||
im_modified = False
|
def _convert_image(im: Image.Image) -> Tuple[Image.Image, bool, str]:
|
||||||
if im.mode not in SUFFIXES:
|
im_modified = False
|
||||||
log.info("Converting image to other colorspace")
|
|
||||||
try:
|
if im.mode not in SUFFIXES:
|
||||||
if im.mode == 'P' and len(im.getcolors()) == 2:
|
log.info("Converting image to other colorspace")
|
||||||
im = im.convert(mode='1')
|
|
||||||
else:
|
|
||||||
im = im.convert(mode='RGB')
|
|
||||||
except OSError as e:
|
|
||||||
raise MissingDependencyError(
|
|
||||||
"Could not convert image with type " + im.mode
|
|
||||||
) from e
|
|
||||||
else:
|
|
||||||
im_modified = True
|
|
||||||
try:
|
try:
|
||||||
suffix = SUFFIXES[im.mode]
|
if im.mode == 'P' and len(im.getcolors()) == 2:
|
||||||
except KeyError:
|
im = im.convert(mode='1')
|
||||||
|
else:
|
||||||
|
im = im.convert(mode='RGB')
|
||||||
|
except OSError as e:
|
||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
"Failed to convert image to a supported format."
|
"Could not convert image with type " + im.mode
|
||||||
) from None
|
) from e
|
||||||
|
|
||||||
if im_modified or input_file.suffix != '.pnm':
|
|
||||||
input_pnm = tmpdir / 'input.pnm'
|
|
||||||
im.save(input_pnm, format='PPM')
|
|
||||||
else:
|
else:
|
||||||
# No changes, PNG input, just use the file we already have
|
im_modified = True
|
||||||
input_pnm = input_file
|
try:
|
||||||
output_pnm = tmpdir / f'output{suffix}'
|
suffix = SUFFIXES[im.mode]
|
||||||
return input_pnm, output_pnm
|
except KeyError:
|
||||||
|
raise MissingDependencyError(
|
||||||
|
"Failed to convert image to a supported format."
|
||||||
|
) from None
|
||||||
|
return im, im_modified, suffix
|
||||||
|
|
||||||
|
|
||||||
def run(
|
@contextmanager
|
||||||
|
def _setup_unpaper_io(input_file: Path) -> Iterator[Tuple[Path, Path, Path]]:
|
||||||
|
with Image.open(input_file) as im:
|
||||||
|
if im.width * im.height >= UNPAPER_IMAGE_PIXEL_LIMIT:
|
||||||
|
raise UnpaperImageTooLargeError(w=im.width, h=im.height)
|
||||||
|
im, im_modified, suffix = _convert_image(im)
|
||||||
|
|
||||||
|
with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
||||||
|
tmppath = Path(tmpdir)
|
||||||
|
if im_modified or input_file.suffix != '.pnm':
|
||||||
|
input_pnm = tmppath / 'input.pnm'
|
||||||
|
im.save(input_pnm, format='PPM')
|
||||||
|
else:
|
||||||
|
# No changes, PNG input, just use the file we already have
|
||||||
|
input_pnm = input_file
|
||||||
|
|
||||||
|
output_pnm = tmppath / f'output{suffix}'
|
||||||
|
yield input_pnm, output_pnm, tmppath
|
||||||
|
|
||||||
|
|
||||||
|
def run_unpaper(
|
||||||
input_file: Path, output_file: Path, *, dpi: DecFloat, mode_args: List[str]
|
input_file: Path, output_file: Path, *, dpi: DecFloat, mode_args: List[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
args_unpaper = ['unpaper', '-v', '--dpi', str(round(dpi, 6))] + mode_args
|
args_unpaper = ['unpaper', '-v', '--dpi', str(round(dpi, 6))] + mode_args
|
||||||
|
|
||||||
with TemporaryDirectory() as tmpdir:
|
with _setup_unpaper_io(input_file) as (input_pnm, output_pnm, tmpdir):
|
||||||
input_pnm, output_pnm = _setup_unpaper_io(Path(tmpdir), input_file)
|
|
||||||
|
|
||||||
# To prevent any shenanigans from accepting arbitrary parameters in
|
# To prevent any shenanigans from accepting arbitrary parameters in
|
||||||
# --unpaper-args, we:
|
# --unpaper-args, we:
|
||||||
# 1) run with cwd set to a tmpdir with only unpaper's files
|
# 1) run with cwd set to a tmpdir with only unpaper's files
|
||||||
@@ -84,7 +134,7 @@ def run(
|
|||||||
# This should ensure that a user cannot clobber some other file with
|
# This should ensure that a user cannot clobber some other file with
|
||||||
# their unpaper arguments (whether intentionally or otherwise)
|
# their unpaper arguments (whether intentionally or otherwise)
|
||||||
args_unpaper.extend([os.fspath(input_pnm), os.fspath(output_pnm)])
|
args_unpaper.extend([os.fspath(input_pnm), os.fspath(output_pnm)])
|
||||||
external_run(
|
run(
|
||||||
args_unpaper,
|
args_unpaper,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
check=True,
|
check=True,
|
||||||
@@ -117,7 +167,7 @@ def clean(
|
|||||||
*,
|
*,
|
||||||
dpi: DecFloat,
|
dpi: DecFloat,
|
||||||
unpaper_args: Optional[List[str]] = None,
|
unpaper_args: Optional[List[str]] = None,
|
||||||
):
|
) -> Path:
|
||||||
default_args = [
|
default_args = [
|
||||||
'--layout',
|
'--layout',
|
||||||
'none',
|
'none',
|
||||||
@@ -131,4 +181,9 @@ def clean(
|
|||||||
]
|
]
|
||||||
if not unpaper_args:
|
if not unpaper_args:
|
||||||
unpaper_args = default_args
|
unpaper_args = default_args
|
||||||
run(input_file, output_file, dpi=dpi, mode_args=unpaper_args)
|
try:
|
||||||
|
run_unpaper(input_file, output_file, dpi=dpi, mode_args=unpaper_args)
|
||||||
|
return output_file
|
||||||
|
except UnpaperImageTooLargeError as e:
|
||||||
|
log.warning(str(e))
|
||||||
|
return input_file
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""For grafting text-only PDF pages onto freeform PDF pages."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -16,7 +16,6 @@ from pikepdf import (
|
|||||||
Name,
|
Name,
|
||||||
Object,
|
Object,
|
||||||
Operator,
|
Operator,
|
||||||
Page,
|
|
||||||
Pdf,
|
Pdf,
|
||||||
PdfError,
|
PdfError,
|
||||||
PdfMatrix,
|
PdfMatrix,
|
||||||
@@ -81,6 +80,8 @@ def strip_invisible_text(pdf, page):
|
|||||||
|
|
||||||
|
|
||||||
class OcrGrafter:
|
class OcrGrafter:
|
||||||
|
"""Manages grafting text-only PDFs onto regular PDFs."""
|
||||||
|
|
||||||
def __init__(self, context):
|
def __init__(self, context):
|
||||||
self.context = context
|
self.context = context
|
||||||
self.path_base = context.origin
|
self.path_base = context.origin
|
||||||
@@ -236,6 +237,8 @@ class OcrGrafter:
|
|||||||
):
|
):
|
||||||
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
|
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
log.debug("Grafting")
|
log.debug("Grafting")
|
||||||
if Path(textpdf).stat().st_size == 0:
|
if Path(textpdf).stat().st_size == 0:
|
||||||
return
|
return
|
||||||
@@ -282,7 +285,7 @@ class OcrGrafter:
|
|||||||
|
|
||||||
base_resources = _ensure_dictionary(base_page, Name.Resources)
|
base_resources = _ensure_dictionary(base_page, Name.Resources)
|
||||||
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
||||||
text_xobj_name = Name('/' + str(uuid.uuid4()))
|
text_xobj_name = Name.random(prefix="OCR-")
|
||||||
xobj = self.pdf_base.make_stream(pdf_text_contents)
|
xobj = self.pdf_base.make_stream(pdf_text_contents)
|
||||||
base_xobjs[text_xobj_name] = xobj
|
base_xobjs[text_xobj_name] = xobj
|
||||||
xobj.Type = Name.XObject
|
xobj.Type = Name.XObject
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Defines context objects that are passed to child processes/threads."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -83,7 +84,7 @@ class PageContext:
|
|||||||
The path will be based in a common temporary folder and have a prefix based
|
The path will be based in a common temporary folder and have a prefix based
|
||||||
on the page number.
|
on the page number.
|
||||||
"""
|
"""
|
||||||
return self.work_folder / ("%06d_%s" % (self.pageno + 1, name))
|
return self.work_folder / f"{(self.pageno + 1):06d}_{name}"
|
||||||
|
|
||||||
def __getstate__(self):
|
def __getstate__(self):
|
||||||
state = self.__dict__.copy()
|
state = self.__dict__.copy()
|
||||||
|
|||||||
@@ -4,15 +4,17 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Logging support classes."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
|
||||||
class PageNumberFilter(logging.Filter):
|
class PageNumberFilter(logging.Filter):
|
||||||
|
"""Insert PDF page number that emitted log message to log record."""
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
pageno = getattr(record, 'pageno', None)
|
pageno = getattr(record, 'pageno', None)
|
||||||
if isinstance(pageno, int):
|
if isinstance(pageno, int):
|
||||||
|
|||||||
+45
-25
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""OCRmyPDF page processing pipeline functions."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -32,12 +33,18 @@ from ocrmypdf.exceptions import (
|
|||||||
PriorOcrFoundError,
|
PriorOcrFoundError,
|
||||||
UnsupportedImageFormatError,
|
UnsupportedImageFormatError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.helpers import Resolution, safe_symlink
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
||||||
from ocrmypdf.hocrtransform import HocrTransform
|
from ocrmypdf.hocrtransform import HocrTransform
|
||||||
from ocrmypdf.optimize import optimize
|
|
||||||
from ocrmypdf.pdfa import generate_pdfa_ps
|
from ocrmypdf.pdfa import generate_pdfa_ps
|
||||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo
|
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo
|
||||||
|
|
||||||
|
# Remove this workaround when we require Pillow >= 10
|
||||||
|
try:
|
||||||
|
BICUBIC = Image.Resampling.BICUBIC # type: ignore
|
||||||
|
except AttributeError:
|
||||||
|
# Pillow 9 shim
|
||||||
|
BICUBIC = Image.BICUBIC # type: ignore
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
VECTOR_PAGE_DPI = 400
|
VECTOR_PAGE_DPI = 400
|
||||||
@@ -98,8 +105,8 @@ def triage_image_file(input_file, output_file, options):
|
|||||||
img2pdf.convert(
|
img2pdf.convert(
|
||||||
os.fspath(input_file),
|
os.fspath(input_file),
|
||||||
layout_fun=layout_fun,
|
layout_fun=layout_fun,
|
||||||
with_pdfrw=False,
|
|
||||||
outputstream=outf,
|
outputstream=outf,
|
||||||
|
**IMG2PDF_KWARGS,
|
||||||
)
|
)
|
||||||
log.info("Successfully converted to PDF, processing...")
|
log.info("Successfully converted to PDF, processing...")
|
||||||
except img2pdf.ImageOpenError as e:
|
except img2pdf.ImageOpenError as e:
|
||||||
@@ -325,7 +332,8 @@ def is_ocr_required(page_context: PageContext):
|
|||||||
ocr_required = False
|
ocr_required = False
|
||||||
log.warning(
|
log.warning(
|
||||||
"page too big, skipping OCR "
|
"page too big, skipping OCR "
|
||||||
f"({(pixel_count / 1_000_000):.1f} MPixels > {options.skip_big:.1f} MPixels --skip-big)"
|
f"({(pixel_count / 1_000_000):.1f} MPixels > "
|
||||||
|
f"{options.skip_big:.1f} MPixels --skip-big)"
|
||||||
)
|
)
|
||||||
return ocr_required
|
return ocr_required
|
||||||
|
|
||||||
@@ -423,8 +431,8 @@ def rasterize(
|
|||||||
output_file = page_context.get_path(f'rasterize{output_tag}.png')
|
output_file = page_context.get_path(f'rasterize{output_tag}.png')
|
||||||
pageinfo = page_context.pageinfo
|
pageinfo = page_context.pageinfo
|
||||||
|
|
||||||
def at_least(cs):
|
def at_least(colorspace):
|
||||||
return max(device_idx, colorspaces.index(cs))
|
return max(device_idx, colorspaces.index(colorspace))
|
||||||
|
|
||||||
for image in pageinfo.images:
|
for image in pageinfo.images:
|
||||||
if image.type_ != 'image':
|
if image.type_ != 'image':
|
||||||
@@ -464,10 +472,10 @@ def rasterize(
|
|||||||
|
|
||||||
def preprocess_remove_background(input_file: Path, page_context: PageContext):
|
def preprocess_remove_background(input_file: Path, page_context: PageContext):
|
||||||
if any(image.bpc > 1 for image in page_context.pageinfo.images):
|
if any(image.bpc > 1 for image in page_context.pageinfo.images):
|
||||||
output_file = page_context.get_path('pp_rm_bg.png')
|
|
||||||
# leptonica.remove_background(input_file, output_file)
|
|
||||||
raise NotImplementedError("--remove-background is temporarily not implemented")
|
raise NotImplementedError("--remove-background is temporarily not implemented")
|
||||||
return output_file
|
# output_file = page_context.get_path('pp_rm_bg.png')
|
||||||
|
# leptonica.remove_background(input_file, output_file)
|
||||||
|
# return output_file
|
||||||
else:
|
else:
|
||||||
log.info("background removal skipped on mono page")
|
log.info("background removal skipped on mono page")
|
||||||
return input_file
|
return input_file
|
||||||
@@ -484,7 +492,9 @@ def preprocess_deskew(input_file: Path, page_context: PageContext):
|
|||||||
# According to Pillow docs, .rotate() will automatically use Image.NEAREST
|
# According to Pillow docs, .rotate() will automatically use Image.NEAREST
|
||||||
# resampling if image is mode '1' or 'P'
|
# resampling if image is mode '1' or 'P'
|
||||||
deskewed = im.rotate(
|
deskewed = im.rotate(
|
||||||
deskew_angle_degrees, resample=Image.BICUBIC, fillcolor='white'
|
deskew_angle_degrees,
|
||||||
|
resample=BICUBIC,
|
||||||
|
fillcolor=ImageColor.getcolor('white', mode=im.mode),
|
||||||
)
|
)
|
||||||
deskewed.save(output_file, dpi=dpi)
|
deskewed.save(output_file, dpi=dpi)
|
||||||
|
|
||||||
@@ -494,13 +504,12 @@ def preprocess_deskew(input_file: Path, page_context: PageContext):
|
|||||||
def preprocess_clean(input_file: Path, page_context: PageContext):
|
def preprocess_clean(input_file: Path, page_context: PageContext):
|
||||||
output_file = page_context.get_path('pp_clean.png')
|
output_file = page_context.get_path('pp_clean.png')
|
||||||
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
|
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
|
||||||
unpaper.clean(
|
return unpaper.clean(
|
||||||
input_file,
|
input_file,
|
||||||
output_file,
|
output_file,
|
||||||
dpi=dpi.x,
|
dpi=dpi.x,
|
||||||
unpaper_args=page_context.options.unpaper_args,
|
unpaper_args=page_context.options.unpaper_args,
|
||||||
)
|
)
|
||||||
return output_file
|
|
||||||
|
|
||||||
|
|
||||||
def create_ocr_image(image: Path, page_context: PageContext):
|
def create_ocr_image(image: Path, page_context: PageContext):
|
||||||
@@ -613,7 +622,7 @@ def create_pdf_page_from_image(
|
|||||||
|
|
||||||
layout_fun = img2pdf.get_layout_fun(pagesize)
|
layout_fun = img2pdf.get_layout_fun(pagesize)
|
||||||
img2pdf.convert(
|
img2pdf.convert(
|
||||||
imfile, with_pdfrw=False, layout_fun=layout_fun, outputstream=pdf
|
imfile, layout_fun=layout_fun, outputstream=pdf, **IMG2PDF_KWARGS
|
||||||
)
|
)
|
||||||
log.debug('convert done')
|
log.debug('convert done')
|
||||||
|
|
||||||
@@ -808,13 +817,14 @@ def metadata_fixup(working_file: Path, context: PdfContext):
|
|||||||
missing = set(meta_original.keys()) - set(meta.keys())
|
missing = set(meta_original.keys()) - set(meta.keys())
|
||||||
report_on_metadata(missing)
|
report_on_metadata(missing)
|
||||||
|
|
||||||
|
optimizing = context.plugin_manager.hook.is_optimization_enabled(
|
||||||
|
context=context
|
||||||
|
)
|
||||||
pdf.save(
|
pdf.save(
|
||||||
output_file,
|
output_file,
|
||||||
**get_pdf_save_settings(options.output_type),
|
**get_pdf_save_settings(options.output_type),
|
||||||
linearize=( # Don't linearize if optimize() will be linearizing too
|
linearize=( # Don't linearize if optimize() will be linearizing too
|
||||||
should_linearize(working_file, context)
|
not optimizing and should_linearize(working_file, context)
|
||||||
if options.optimize == 0
|
|
||||||
else False
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -823,12 +833,22 @@ def metadata_fixup(working_file: Path, context: PdfContext):
|
|||||||
|
|
||||||
def optimize_pdf(input_file: Path, context: PdfContext, executor: Executor):
|
def optimize_pdf(input_file: Path, context: PdfContext, executor: Executor):
|
||||||
output_file = context.get_path('optimize.pdf')
|
output_file = context.get_path('optimize.pdf')
|
||||||
save_settings = dict(
|
output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
|
||||||
|
input_pdf=input_file,
|
||||||
|
output_pdf=output_file,
|
||||||
|
context=context,
|
||||||
|
executor=executor,
|
||||||
linearize=should_linearize(input_file, context),
|
linearize=should_linearize(input_file, context),
|
||||||
**get_pdf_save_settings(context.options.output_type),
|
|
||||||
)
|
)
|
||||||
optimize(input_file, output_file, context, save_settings, executor)
|
|
||||||
return output_file
|
input_size = input_file.stat().st_size
|
||||||
|
output_size = output_file.stat().st_size
|
||||||
|
if output_size > 0:
|
||||||
|
ratio = input_size / output_size
|
||||||
|
savings = 1 - output_size / input_size
|
||||||
|
log.info(f"Optimize ratio: {ratio:.2f} savings: {(savings):.1%}")
|
||||||
|
|
||||||
|
return output_pdf, messages
|
||||||
|
|
||||||
|
|
||||||
def enumerate_compress_ranges(iterable):
|
def enumerate_compress_ranges(iterable):
|
||||||
@@ -850,8 +870,8 @@ def enumerate_compress_ranges(iterable):
|
|||||||
def merge_sidecars(txt_files: Iterable[Optional[Path]], context: PdfContext):
|
def merge_sidecars(txt_files: Iterable[Optional[Path]], context: PdfContext):
|
||||||
output_file = context.get_path('sidecar.txt')
|
output_file = context.get_path('sidecar.txt')
|
||||||
with open(output_file, 'w', encoding="utf-8") as stream:
|
with open(output_file, 'w', encoding="utf-8") as stream:
|
||||||
for (frm, to), txt_file in enumerate_compress_ranges(txt_files):
|
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
|
||||||
if frm != 1:
|
if from_ != 1:
|
||||||
stream.write('\f') # Form feed between pages
|
stream.write('\f') # Form feed between pages
|
||||||
if txt_file:
|
if txt_file:
|
||||||
with open(txt_file, encoding="utf-8") as in_:
|
with open(txt_file, encoding="utf-8") as in_:
|
||||||
@@ -864,10 +884,10 @@ def merge_sidecars(txt_files: Iterable[Optional[Path]], context: PdfContext):
|
|||||||
else:
|
else:
|
||||||
stream.write(txt)
|
stream.write(txt)
|
||||||
else:
|
else:
|
||||||
if frm != to:
|
if from_ != to_:
|
||||||
pages = f'{frm}-{to}'
|
pages = f'{from_}-{to_}'
|
||||||
else:
|
else:
|
||||||
pages = f'{frm}'
|
pages = f'{from_}'
|
||||||
stream.write(f'[OCR skipped on page(s) {pages}]')
|
stream.write(f'[OCR skipped on page(s) {pages}]')
|
||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Plugin manager using pluggy."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib
|
import importlib
|
||||||
@@ -101,12 +102,11 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
|
|||||||
|
|
||||||
|
|
||||||
def get_plugin_manager(plugins: List[Union[str, Path]], builtins=True):
|
def get_plugin_manager(plugins: List[Union[str, Path]], builtins=True):
|
||||||
pm = OcrmypdfPluginManager(
|
return OcrmypdfPluginManager(
|
||||||
project_name='ocrmypdf',
|
project_name='ocrmypdf',
|
||||||
plugins=plugins,
|
plugins=plugins,
|
||||||
builtins=builtins,
|
builtins=builtins,
|
||||||
)
|
)
|
||||||
return pm
|
|
||||||
|
|
||||||
|
|
||||||
def get_parser_options_plugins(
|
def get_parser_options_plugins(
|
||||||
@@ -120,3 +120,6 @@ def get_parser_options_plugins(
|
|||||||
|
|
||||||
options = parser.parse_args(args=args)
|
options = parser.parse_args(args=args)
|
||||||
return parser, options, plugin_manager
|
return parser, options, plugin_manager
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager', 'get_parser_options_plugins']
|
||||||
|
|||||||
+19
-9
@@ -4,6 +4,8 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Implements the concurrent and page synchronous parts of the pipeline."""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
@@ -16,7 +18,7 @@ from concurrent.futures.thread import BrokenThreadPool
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from typing import List, NamedTuple, Optional, Tuple, cast
|
from typing import List, NamedTuple, Optional, Sequence, Tuple, cast
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
|
||||||
@@ -68,7 +70,9 @@ from ocrmypdf.pdfa import file_claims_pdfa
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PageResult(NamedTuple): # pylint: disable=inherit-non-class
|
class PageResult(NamedTuple):
|
||||||
|
"""Result when a page is finished processing."""
|
||||||
|
|
||||||
pageno: int
|
pageno: int
|
||||||
pdf_page_from_image: Optional[Path]
|
pdf_page_from_image: Optional[Path]
|
||||||
ocr: Optional[Path]
|
ocr: Optional[Path]
|
||||||
@@ -226,7 +230,9 @@ def exec_page_sync(page_context: PageContext) -> PageResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def post_process(pdf_file: Path, context: PdfContext, executor: Executor) -> Path:
|
def post_process(
|
||||||
|
pdf_file: Path, context: PdfContext, executor: Executor
|
||||||
|
) -> Tuple[Path, Sequence[str]]:
|
||||||
pdf_out = pdf_file
|
pdf_out = pdf_file
|
||||||
if context.options.output_type.startswith('pdfa'):
|
if context.options.output_type.startswith('pdfa'):
|
||||||
ps_stub_out = generate_postscript_stub(context)
|
ps_stub_out = generate_postscript_stub(context)
|
||||||
@@ -244,7 +250,7 @@ def worker_init(max_pixels: int) -> None:
|
|||||||
pikepdf_enable_mmap()
|
pikepdf_enable_mmap()
|
||||||
|
|
||||||
|
|
||||||
def exec_concurrent(context: PdfContext, executor: Executor) -> None:
|
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||||
"""Execute the pipeline concurrently"""
|
"""Execute the pipeline concurrently"""
|
||||||
|
|
||||||
# Run exec_page_sync on every page context
|
# Run exec_page_sync on every page context
|
||||||
@@ -296,13 +302,15 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> None:
|
|||||||
# Merge layers to one single pdf
|
# Merge layers to one single pdf
|
||||||
pdf = ocrgraft.finalize()
|
pdf = ocrgraft.finalize()
|
||||||
|
|
||||||
|
messages: List[str] = []
|
||||||
if options.output_type != 'none':
|
if options.output_type != 'none':
|
||||||
# PDF/A and metadata
|
# PDF/A and metadata
|
||||||
log.info("Postprocessing...")
|
log.info("Postprocessing...")
|
||||||
pdf = post_process(pdf, context, executor)
|
pdf, messages = post_process(pdf, context, executor)
|
||||||
|
|
||||||
# Copy PDF file to destination
|
# Copy PDF file to destination
|
||||||
copy_final(pdf, options.output_file, context)
|
copy_final(pdf, options.output_file, context)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
def configure_debug_logging(
|
def configure_debug_logging(
|
||||||
@@ -382,7 +390,7 @@ def run_pipeline(
|
|||||||
validate_pdfinfo_options(context)
|
validate_pdfinfo_options(context)
|
||||||
|
|
||||||
# Execute the pipeline
|
# Execute the pipeline
|
||||||
exec_concurrent(context, executor)
|
optimize_messages = exec_concurrent(context, executor)
|
||||||
|
|
||||||
if options.output_file == '-':
|
if options.output_file == '-':
|
||||||
log.info("Output sent to stdout")
|
log.info("Output sent to stdout")
|
||||||
@@ -408,7 +416,9 @@ def run_pipeline(
|
|||||||
if not check_pdf(options.output_file):
|
if not check_pdf(options.output_file):
|
||||||
log.warning('Output file: The generated PDF is INVALID')
|
log.warning('Output file: The generated PDF is INVALID')
|
||||||
return ExitCode.invalid_output_pdf
|
return ExitCode.invalid_output_pdf
|
||||||
report_output_file_size(options, start_input_file, options.output_file)
|
report_output_file_size(
|
||||||
|
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:
|
||||||
@@ -425,7 +435,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) as e:
|
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 "
|
||||||
@@ -435,7 +445,7 @@ def run_pipeline(
|
|||||||
except (
|
except (
|
||||||
BrokenProcessPool if not api else NeverRaise,
|
BrokenProcessPool if not api else NeverRaise,
|
||||||
BrokenThreadPool if not api else NeverRaise,
|
BrokenThreadPool if not api else NeverRaise,
|
||||||
) as e:
|
):
|
||||||
log.exception(
|
log.exception(
|
||||||
"A worker process was terminated unexpectedly. This is known to occur if "
|
"A worker process was terminated unexpectedly. This is known to occur if "
|
||||||
"processing your file takes all available swap space and RAM. It may "
|
"processing your file takes all available swap space and RAM. It may "
|
||||||
|
|||||||
+30
-65
@@ -5,6 +5,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Validate a work order from API or command line."""
|
||||||
|
|
||||||
import locale
|
import locale
|
||||||
import logging
|
import logging
|
||||||
@@ -13,19 +14,19 @@ import sys
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
from typing import List, Set, Tuple
|
from typing import List, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import PIL
|
import PIL
|
||||||
|
|
||||||
from ocrmypdf._exec import jbig2enc, pngquant, unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
BadArgsError,
|
BadArgsError,
|
||||||
InputFileError,
|
InputFileError,
|
||||||
MissingDependencyError,
|
MissingDependencyError,
|
||||||
OutputFileAccessError,
|
OutputFileAccessError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.helpers import is_file_writable, monotonic, safe_symlink, samefile
|
from ocrmypdf.helpers import is_file_writable, monotonic, safe_symlink
|
||||||
from ocrmypdf.hocrtransform import HOCR_OK_LANGS
|
from ocrmypdf.hocrtransform import HOCR_OK_LANGS
|
||||||
from ocrmypdf.subprocess import check_external_program
|
from ocrmypdf.subprocess import check_external_program
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def check_platform():
|
def check_platform():
|
||||||
if os.name == 'nt' and sys.maxsize <= 2 ** 32: # pragma: no cover
|
if os.name == 'nt' and sys.maxsize <= 2**32: # pragma: no cover
|
||||||
# 32-bit interpreter on Windows
|
# 32-bit interpreter on Windows
|
||||||
log.error(
|
log.error(
|
||||||
"You are running OCRmyPDF in a 32-bit (x86) Python interpreter."
|
"You are running OCRmyPDF in a 32-bit (x86) Python interpreter."
|
||||||
@@ -146,13 +147,13 @@ def check_options_preprocessing(options):
|
|||||||
def _pages_from_ranges(ranges: str) -> Set[int]:
|
def _pages_from_ranges(ranges: str) -> Set[int]:
|
||||||
pages: List[int] = []
|
pages: List[int] = []
|
||||||
page_groups = ranges.replace(' ', '').split(',')
|
page_groups = ranges.replace(' ', '').split(',')
|
||||||
for g in page_groups:
|
for group in page_groups:
|
||||||
if not g:
|
if not group:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
start, end = g.split('-')
|
start, end = group.split('-')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pages.append(int(g) - 1)
|
pages.append(int(group) - 1)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
new_pages = list(range(int(start) - 1, int(end)))
|
new_pages = list(range(int(start) - 1, int(end)))
|
||||||
@@ -162,7 +163,7 @@ def _pages_from_ranges(ranges: str) -> Set[int]:
|
|||||||
) from None
|
) from None
|
||||||
pages.extend(new_pages)
|
pages.extend(new_pages)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise BadArgsError(f"invalid page subrange '{g}'") from None
|
raise BadArgsError(f"invalid page subrange '{group}'") from None
|
||||||
|
|
||||||
if not pages:
|
if not pages:
|
||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
@@ -193,37 +194,6 @@ def check_options_ocr_behavior(options):
|
|||||||
options.pages = _pages_from_ranges(options.pages)
|
options.pages = _pages_from_ranges(options.pages)
|
||||||
|
|
||||||
|
|
||||||
def check_options_optimizing(options):
|
|
||||||
if options.optimize >= 2:
|
|
||||||
check_external_program(
|
|
||||||
program='pngquant',
|
|
||||||
package='pngquant',
|
|
||||||
version_checker=pngquant.version,
|
|
||||||
need_version='2.0.1',
|
|
||||||
required_for='--optimize {2,3}',
|
|
||||||
)
|
|
||||||
|
|
||||||
if options.optimize >= 2:
|
|
||||||
# Although we use JBIG2 for optimize=1, don't nag about it unless the
|
|
||||||
# user is asking for more optimization
|
|
||||||
check_external_program(
|
|
||||||
program='jbig2',
|
|
||||||
package='jbig2enc',
|
|
||||||
version_checker=jbig2enc.version,
|
|
||||||
need_version='0.28',
|
|
||||||
required_for='--optimize {2,3} | --jbig2-lossy',
|
|
||||||
recommended=True if not options.jbig2_lossy else False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if options.optimize == 0 and any(
|
|
||||||
[options.jbig2_lossy, options.png_quality, options.jpeg_quality]
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
"The arguments --jbig2-lossy, --png-quality, and --jpeg-quality "
|
|
||||||
"will be ignored because --optimize=0."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_options_advanced(options):
|
def check_options_advanced(options):
|
||||||
if options.pdfa_image_compression != 'auto' and not options.output_type.startswith(
|
if options.pdfa_image_compression != 'auto' and not options.output_type.startswith(
|
||||||
'pdfa'
|
'pdfa'
|
||||||
@@ -237,13 +207,13 @@ def check_options_advanced(options):
|
|||||||
def check_options_metadata(options):
|
def check_options_metadata(options):
|
||||||
docinfo = [options.title, options.author, options.keywords, options.subject]
|
docinfo = [options.title, options.author, options.keywords, options.subject]
|
||||||
for s in (m for m in docinfo if m):
|
for s in (m for m in docinfo if m):
|
||||||
for c in s:
|
for char in s:
|
||||||
if unicodedata.category(c) == 'Co' or ord(c) >= 0x10000:
|
if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000:
|
||||||
|
hexchar = hex(ord(char))[2:].upper()
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"One of the metadata strings contains "
|
"One of the metadata strings contains "
|
||||||
"an unsupported Unicode character: '{}' (U+{})".format(
|
"an unsupported Unicode character: "
|
||||||
c, hex(ord(c))[2:].upper()
|
f"{char} (U+{hexchar})"
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -261,7 +231,6 @@ def _check_options(options, plugin_manager, ocr_engine_languages):
|
|||||||
check_options_sidecar(options)
|
check_options_sidecar(options)
|
||||||
check_options_preprocessing(options)
|
check_options_preprocessing(options)
|
||||||
check_options_ocr_behavior(options)
|
check_options_ocr_behavior(options)
|
||||||
check_options_optimizing(options)
|
|
||||||
check_options_advanced(options)
|
check_options_advanced(options)
|
||||||
check_options_pillow(options)
|
check_options_pillow(options)
|
||||||
plugin_manager.hook.check_options(options=options)
|
plugin_manager.hook.check_options(options=options)
|
||||||
@@ -293,7 +262,7 @@ def create_input_file(options, work_folder: Path) -> Tuple[Path, str]:
|
|||||||
target = work_folder / 'origin'
|
target = work_folder / 'origin'
|
||||||
safe_symlink(options.input_file, target)
|
safe_symlink(options.input_file, target)
|
||||||
return target, os.fspath(options.input_file)
|
return target, os.fspath(options.input_file)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError as e:
|
||||||
msg = f"File not found - {options.input_file}"
|
msg = f"File not found - {options.input_file}"
|
||||||
if Path('/.dockerenv').exists(): # pragma: no cover
|
if Path('/.dockerenv').exists(): # pragma: no cover
|
||||||
msg += (
|
msg += (
|
||||||
@@ -304,7 +273,7 @@ def create_input_file(options, work_folder: Path) -> Tuple[Path, str]:
|
|||||||
"\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)
|
raise InputFileError(msg) from e
|
||||||
|
|
||||||
|
|
||||||
def check_requested_output_file(options):
|
def check_requested_output_file(options):
|
||||||
@@ -324,7 +293,16 @@ def check_requested_output_file(options):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def report_output_file_size(options, input_file, output_file):
|
def report_output_file_size(
|
||||||
|
options,
|
||||||
|
input_file: Path,
|
||||||
|
output_file: Path,
|
||||||
|
optimize_messages: Optional[Sequence[str]] = None,
|
||||||
|
file_overhead: int = 4000,
|
||||||
|
page_overhead: int = 3000,
|
||||||
|
):
|
||||||
|
if optimize_messages is None:
|
||||||
|
optimize_messages = []
|
||||||
try:
|
try:
|
||||||
output_size = Path(output_file).stat().st_size
|
output_size = Path(output_file).stat().st_size
|
||||||
input_size = Path(input_file).stat().st_size
|
input_size = Path(input_file).stat().st_size
|
||||||
@@ -333,9 +311,7 @@ def report_output_file_size(options, input_file, output_file):
|
|||||||
with pikepdf.open(output_file) as p:
|
with pikepdf.open(output_file) as p:
|
||||||
# Overhead constants obtained by estimating amount of data added by OCR
|
# Overhead constants obtained by estimating amount of data added by OCR
|
||||||
# PDF/A conversion, and possible XMP metadata addition, with compression
|
# PDF/A conversion, and possible XMP metadata addition, with compression
|
||||||
FILE_OVERHEAD = 4000
|
reasonable_overhead = file_overhead + page_overhead * len(p.pages)
|
||||||
OCR_PER_PAGE_OVERHEAD = 3000
|
|
||||||
reasonable_overhead = FILE_OVERHEAD + OCR_PER_PAGE_OVERHEAD * len(p.pages)
|
|
||||||
ratio = output_size / input_size
|
ratio = output_size / input_size
|
||||||
reasonable_ratio = output_size / (input_size + reasonable_overhead)
|
reasonable_ratio = output_size / (input_size + reasonable_overhead)
|
||||||
if reasonable_ratio < 1.35 or input_size < 25000:
|
if reasonable_ratio < 1.35 or input_size < 25000:
|
||||||
@@ -355,19 +331,8 @@ def report_output_file_size(options, input_file, output_file):
|
|||||||
f"The argument --{arg.replace('_', '-')} was issued, causing transcoding."
|
f"The argument --{arg.replace('_', '-')} was issued, causing transcoding."
|
||||||
)
|
)
|
||||||
|
|
||||||
if options.optimize == 0:
|
reasons.extend(optimize_messages)
|
||||||
reasons.append("Optimization was disabled.")
|
|
||||||
else:
|
|
||||||
image_optimizers = {
|
|
||||||
'jbig2': jbig2enc.available(),
|
|
||||||
'pngquant': pngquant.available(),
|
|
||||||
}
|
|
||||||
for name, available in image_optimizers.items():
|
|
||||||
if not available:
|
|
||||||
reasons.append(
|
|
||||||
f"The optional dependency '{name}' was not found, so some image "
|
|
||||||
f"optimizations could not be attempted."
|
|
||||||
)
|
|
||||||
if options.output_type.startswith('pdfa'):
|
if options.output_type.startswith('pdfa'):
|
||||||
reasons.append("PDF/A conversion was enabled. (Try `--output-type pdf`.)")
|
reasons.append("PDF/A conversion was enabled. (Try `--output-type pdf`.)")
|
||||||
if options.plugins:
|
if options.plugins:
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Get version by introspecting package information.
|
||||||
|
|
||||||
|
OCRmyPDF uses setuptools_scm to derive version from git tags.
|
||||||
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from importlib_metadata import version as _package_version
|
from importlib_metadata import version as _package_version
|
||||||
|
|||||||
+15
-11
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Functions for using ocrmypdf as an API."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -25,7 +26,10 @@ from ocrmypdf.helpers import is_iterable_notstr
|
|||||||
try:
|
try:
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
coloredlogs = None
|
coloredlogs = None # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
if coloredlogs:
|
||||||
|
from humanfriendly.terminal import enable_ansi_support
|
||||||
|
|
||||||
|
|
||||||
StrPath = Union[Path, AnyStr]
|
StrPath = Union[Path, AnyStr]
|
||||||
@@ -37,6 +41,7 @@ _api_lock = threading.Lock()
|
|||||||
class Verbosity(IntEnum):
|
class Verbosity(IntEnum):
|
||||||
"""Verbosity level for configure_logging."""
|
"""Verbosity level for configure_logging."""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
quiet = -1 #: Suppress most messages
|
quiet = -1 #: Suppress most messages
|
||||||
default = 0 #: Default level of logging
|
default = 0 #: Default level of logging
|
||||||
debug = 1 #: Output ocrmypdf debug messages
|
debug = 1 #: Output ocrmypdf debug messages
|
||||||
@@ -116,16 +121,15 @@ def configure_logging(
|
|||||||
fmt = '%(pageno)s%(message)s'
|
fmt = '%(pageno)s%(message)s'
|
||||||
|
|
||||||
use_colors = progress_bar_friendly
|
use_colors = progress_bar_friendly
|
||||||
if not coloredlogs:
|
formatter = None
|
||||||
use_colors = False
|
if coloredlogs and use_colors:
|
||||||
if use_colors:
|
use_colors = enable_ansi_support()
|
||||||
if os.name == 'nt':
|
|
||||||
use_colors = coloredlogs.enable_ansi_support()
|
|
||||||
if use_colors:
|
if use_colors:
|
||||||
use_colors = coloredlogs.terminal_supports_colors()
|
use_colors = coloredlogs.terminal_supports_colors()
|
||||||
if use_colors:
|
if use_colors:
|
||||||
formatter = coloredlogs.ColoredFormatter(fmt=fmt)
|
formatter = coloredlogs.ColoredFormatter(fmt=fmt)
|
||||||
else:
|
|
||||||
|
if not formatter:
|
||||||
formatter = logging.Formatter(fmt=fmt)
|
formatter = logging.Formatter(fmt=fmt)
|
||||||
|
|
||||||
console.setFormatter(formatter)
|
console.setFormatter(formatter)
|
||||||
@@ -193,7 +197,7 @@ def create_options(
|
|||||||
else:
|
else:
|
||||||
cmdline.append(os.fspath(output_file))
|
cmdline.append(os.fspath(output_file))
|
||||||
|
|
||||||
parser._api_mode = True
|
parser.enable_api_mode()
|
||||||
options = parser.parse_args(cmdline)
|
options = parser.parse_args(cmdline)
|
||||||
for keyword, val in deferred:
|
for keyword, val in deferred:
|
||||||
setattr(options, keyword, val)
|
setattr(options, keyword, val)
|
||||||
@@ -295,7 +299,7 @@ def ocr( # pylint: disable=unused-argument
|
|||||||
text already, and settings did not tell us to proceed.
|
text already, and settings did not tell us to proceed.
|
||||||
ocrmypdf.InputFileError: Any other problem with the input file.
|
ocrmypdf.InputFileError: Any other problem with the input file.
|
||||||
ocrmypdf.SubprocessOutputError: Any error related to executing a subprocess.
|
ocrmypdf.SubprocessOutputError: Any error related to executing a subprocess.
|
||||||
ocrmypdf.EncryptedPdfERror: If the input PDF is encrypted (password protected).
|
ocrmypdf.EncryptedPdfError: If the input PDF is encrypted (password protected).
|
||||||
OCRmyPDF does not remove passwords.
|
OCRmyPDF does not remove passwords.
|
||||||
ocrmypdf.TesseractConfigError: If Tesseract reported its configuration was not
|
ocrmypdf.TesseractConfigError: If Tesseract reported its configuration was not
|
||||||
valid.
|
valid.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
|
||||||
|
"""OCRmyPDF's multiprocessing/multithreading abstraction layer."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
@@ -21,7 +23,6 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from multiprocessing.pool import Pool, ThreadPool
|
|
||||||
from typing import Callable, Iterable, Type, Union
|
from typing import Callable, Iterable, Type, Union
|
||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
@@ -44,7 +45,8 @@ def log_listener(q: Queue):
|
|||||||
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
|
||||||
made into a process the main application needs to be directed to it.
|
made into a process the main application needs to be directed to it.
|
||||||
|
|
||||||
See https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
|
See:
|
||||||
|
https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -89,6 +91,8 @@ 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:
|
||||||
|
del q # 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...
|
||||||
with suppress(AttributeError):
|
with suppress(AttributeError):
|
||||||
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGBUS})
|
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGBUS})
|
||||||
@@ -98,6 +102,8 @@ def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class StandardExecutor(Executor):
|
class StandardExecutor(Executor):
|
||||||
|
"""Standard OCRmyPDF concurrent task executor."""
|
||||||
|
|
||||||
def _execute(
|
def _execute(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""OCRmyPDF automatically installs these filters as plugins."""
|
||||||
|
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
|
||||||
|
"""Built-in plugin to implement PDF page rasterization and PDF/A production."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# © 2022 James R. Barlow: github.com/jbarlow83
|
||||||
|
#
|
||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
|
||||||
|
"""Built-in plugin to implement PDF page optimization."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence, Tuple
|
||||||
|
|
||||||
|
from ocrmypdf import PdfContext, hookimpl
|
||||||
|
from ocrmypdf._concurrent import Executor
|
||||||
|
from ocrmypdf._exec import jbig2enc, pngquant
|
||||||
|
from ocrmypdf._pipeline import get_pdf_save_settings
|
||||||
|
from ocrmypdf.cli import numeric
|
||||||
|
from ocrmypdf.optimize import optimize
|
||||||
|
from ocrmypdf.subprocess import check_external_program
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def add_options(parser):
|
||||||
|
optimizing = parser.add_argument_group(
|
||||||
|
"Optimization options", "Control how the PDF is optimized after OCR"
|
||||||
|
)
|
||||||
|
optimizing.add_argument(
|
||||||
|
'-O',
|
||||||
|
'--optimize',
|
||||||
|
type=int,
|
||||||
|
choices=range(0, 4),
|
||||||
|
default=1,
|
||||||
|
help=(
|
||||||
|
"Control how PDF is optimized after processing:"
|
||||||
|
"0 - do not optimize; "
|
||||||
|
"1 - do safe, lossless optimizations (default); "
|
||||||
|
"2 - do lossy JPEG and JPEG2000 optimizations; "
|
||||||
|
"3 - do more aggressive lossy JPEG and JPEG2000 optimizations. "
|
||||||
|
"To enable lossy JBIG2, see --jbig2-lossy."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
optimizing.add_argument(
|
||||||
|
'--jpeg-quality',
|
||||||
|
type=numeric(int, 0, 100),
|
||||||
|
default=0,
|
||||||
|
metavar='Q',
|
||||||
|
help=(
|
||||||
|
"Adjust JPEG quality level for JPEG optimization. "
|
||||||
|
"100 is best quality and largest output size; "
|
||||||
|
"1 is lowest quality and smallest output; "
|
||||||
|
"0 uses the default."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
optimizing.add_argument(
|
||||||
|
'--jpg-quality',
|
||||||
|
type=numeric(int, 0, 100),
|
||||||
|
default=0,
|
||||||
|
metavar='Q',
|
||||||
|
dest='jpeg_quality',
|
||||||
|
help=argparse.SUPPRESS, # Alias for --jpeg-quality
|
||||||
|
)
|
||||||
|
optimizing.add_argument(
|
||||||
|
'--png-quality',
|
||||||
|
type=numeric(int, 0, 100),
|
||||||
|
default=0,
|
||||||
|
metavar='Q',
|
||||||
|
help=(
|
||||||
|
"Adjust PNG quality level to use when quantizing PNGs. "
|
||||||
|
"Values have same meaning as with --jpeg-quality"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
optimizing.add_argument(
|
||||||
|
'--jbig2-lossy',
|
||||||
|
action='store_true',
|
||||||
|
help=(
|
||||||
|
"Enable JBIG2 lossy mode (better compression, not suitable for some "
|
||||||
|
"use cases - see documentation). Only takes effect if --optimize 1 or "
|
||||||
|
"higher is also enabled."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
optimizing.add_argument(
|
||||||
|
'--jbig2-page-group-size',
|
||||||
|
type=numeric(int, 1, 10000),
|
||||||
|
default=0,
|
||||||
|
metavar='N',
|
||||||
|
# Adjust number of pages to consider at once for JBIG2 compression
|
||||||
|
help=argparse.SUPPRESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def check_options(options):
|
||||||
|
if options.optimize >= 2:
|
||||||
|
check_external_program(
|
||||||
|
program='pngquant',
|
||||||
|
package='pngquant',
|
||||||
|
version_checker=pngquant.version,
|
||||||
|
need_version='2.0.1',
|
||||||
|
required_for='--optimize {2,3}',
|
||||||
|
)
|
||||||
|
|
||||||
|
if options.optimize >= 2:
|
||||||
|
# Although we use JBIG2 for optimize=1, don't nag about it unless the
|
||||||
|
# user is asking for more optimization
|
||||||
|
check_external_program(
|
||||||
|
program='jbig2',
|
||||||
|
package='jbig2enc',
|
||||||
|
version_checker=jbig2enc.version,
|
||||||
|
need_version='0.28',
|
||||||
|
required_for='--optimize {2,3} | --jbig2-lossy',
|
||||||
|
recommended=True if not options.jbig2_lossy else False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if options.optimize == 0 and any(
|
||||||
|
[options.jbig2_lossy, options.png_quality, options.jpeg_quality]
|
||||||
|
):
|
||||||
|
log.warning(
|
||||||
|
"The arguments --jbig2-lossy, --png-quality, and --jpeg-quality "
|
||||||
|
"will be ignored because --optimize=0."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def optimize_pdf(
|
||||||
|
input_pdf: Path,
|
||||||
|
output_pdf: Path,
|
||||||
|
context: PdfContext,
|
||||||
|
executor: Executor,
|
||||||
|
linearize: bool,
|
||||||
|
) -> Tuple[Path, Sequence[str]]:
|
||||||
|
save_settings = dict(
|
||||||
|
linearize=linearize,
|
||||||
|
**get_pdf_save_settings(context.options.output_type),
|
||||||
|
)
|
||||||
|
result_path = optimize(input_pdf, output_pdf, context, save_settings, executor)
|
||||||
|
messages = []
|
||||||
|
if context.options.optimize == 0:
|
||||||
|
messages.append("Optimization was disabled.")
|
||||||
|
else:
|
||||||
|
image_optimizers = {
|
||||||
|
'jbig2': jbig2enc.available(),
|
||||||
|
'pngquant': pngquant.available(),
|
||||||
|
}
|
||||||
|
for name, available in image_optimizers.items():
|
||||||
|
if not available:
|
||||||
|
messages.append(
|
||||||
|
f"The optional dependency '{name}' was not found, so some image "
|
||||||
|
f"optimizations could not be attempted."
|
||||||
|
)
|
||||||
|
return result_path, messages
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def is_optimization_enabled(context: PdfContext) -> bool:
|
||||||
|
return context.options.optimize != 0
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Built-in plugin to implement OCR using Tesseract."""
|
||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -138,6 +140,8 @@ def validate(pdfinfo, options):
|
|||||||
|
|
||||||
|
|
||||||
class TesseractOcrEngine(OcrEngine):
|
class TesseractOcrEngine(OcrEngine):
|
||||||
|
"""Implements OCR with Tesseract."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def version():
|
def version():
|
||||||
return tesseract.version()
|
return tesseract.version()
|
||||||
|
|||||||
+20
-69
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Command line interface customization and validation."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from typing import Any, Callable, Mapping, Optional, TypeVar
|
from typing import Any, Callable, Mapping, Optional, TypeVar
|
||||||
@@ -42,7 +43,7 @@ def str_to_int(mapping: Mapping[str, int]):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise argparse.ArgumentTypeError(
|
raise argparse.ArgumentTypeError(
|
||||||
f"{s!r} must be one of: {', '.join(mapping.keys())}"
|
f"{s!r} must be one of: {', '.join(mapping.keys())}"
|
||||||
)
|
) from None
|
||||||
|
|
||||||
return _str_to_int
|
return _str_to_int
|
||||||
|
|
||||||
@@ -51,12 +52,20 @@ 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
|
||||||
|
|
||||||
|
OCRmyPDF began as a CLI but eventually acquired an API. The API works inside out,
|
||||||
|
by synthesizing a command line argument. So we subclass the standard parser with
|
||||||
|
one that doesn't call sys.exit(). Obviously this is not the ideal way to do things
|
||||||
|
but it works for us.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._api_mode = False
|
self._api_mode = False
|
||||||
|
|
||||||
|
def enable_api_mode(self):
|
||||||
|
self._api_mode = True
|
||||||
|
|
||||||
def error(self, message):
|
def error(self, message):
|
||||||
if not self._api_mode:
|
if not self._api_mode:
|
||||||
super().error(message)
|
super().error(message)
|
||||||
@@ -65,6 +74,8 @@ class ArgumentParser(argparse.ArgumentParser):
|
|||||||
|
|
||||||
|
|
||||||
class LanguageSetAction(argparse.Action):
|
class LanguageSetAction(argparse.Action):
|
||||||
|
"""Manages a list of languages."""
|
||||||
|
|
||||||
def __init__(self, option_strings, dest, default=None, **kwargs):
|
def __init__(self, option_strings, dest, default=None, **kwargs):
|
||||||
if default is None:
|
if default is None:
|
||||||
default = set()
|
default = set()
|
||||||
@@ -142,7 +153,7 @@ Online documentation is located at:
|
|||||||
'output_file',
|
'output_file',
|
||||||
metavar="output_pdf",
|
metavar="output_pdf",
|
||||||
help="Output searchable PDF file (or '-' to write to standard output). "
|
help="Output searchable PDF file (or '-' to write to standard output). "
|
||||||
"Existing files will be ovewritten. If same as input file, the "
|
"Existing files will be overwritten. If same as input file, the "
|
||||||
"input file will be updated only if processing is successful.",
|
"input file will be updated only if processing is successful.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -228,7 +239,13 @@ Online documentation is located at:
|
|||||||
help=argparse.SUPPRESS,
|
help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
jobcontrol.add_argument(
|
jobcontrol.add_argument(
|
||||||
'--use-threads', action='store_true', help=argparse.SUPPRESS
|
'--use-threads', action='store_true', default=True, help=argparse.SUPPRESS
|
||||||
|
)
|
||||||
|
jobcontrol.add_argument(
|
||||||
|
'--no-use-threads',
|
||||||
|
action='store_false',
|
||||||
|
dest='use_threads',
|
||||||
|
help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
|
|
||||||
metadata = parser.add_argument_group(
|
metadata = parser.add_argument_group(
|
||||||
@@ -335,72 +352,6 @@ Online documentation is located at:
|
|||||||
"but include skipped pages in final output",
|
"but include skipped pages in final output",
|
||||||
)
|
)
|
||||||
|
|
||||||
optimizing = parser.add_argument_group(
|
|
||||||
"Optimization options", "Control how the PDF is optimized after OCR"
|
|
||||||
)
|
|
||||||
optimizing.add_argument(
|
|
||||||
'-O',
|
|
||||||
'--optimize',
|
|
||||||
type=int,
|
|
||||||
choices=range(0, 4),
|
|
||||||
default=1,
|
|
||||||
help=(
|
|
||||||
"Control how PDF is optimized after processing:"
|
|
||||||
"0 - do not optimize; "
|
|
||||||
"1 - do safe, lossless optimizations (default); "
|
|
||||||
"2 - do lossy JPEG and JPEG2000 optimizations; "
|
|
||||||
"3 - do more aggressive lossy JPEG and JPEG2000 optimizations. "
|
|
||||||
"To enable lossy JBIG2, see --jbig2-lossy."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
optimizing.add_argument(
|
|
||||||
'--jpeg-quality',
|
|
||||||
type=numeric(int, 0, 100),
|
|
||||||
default=0,
|
|
||||||
metavar='Q',
|
|
||||||
help=(
|
|
||||||
"Adjust JPEG quality level for JPEG optimization. "
|
|
||||||
"100 is best quality and largest output size; "
|
|
||||||
"1 is lowest quality and smallest output; "
|
|
||||||
"0 uses the default."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
optimizing.add_argument(
|
|
||||||
'--jpg-quality',
|
|
||||||
type=numeric(int, 0, 100),
|
|
||||||
default=0,
|
|
||||||
metavar='Q',
|
|
||||||
dest='jpeg_quality',
|
|
||||||
help=argparse.SUPPRESS, # Alias for --jpeg-quality
|
|
||||||
)
|
|
||||||
optimizing.add_argument(
|
|
||||||
'--png-quality',
|
|
||||||
type=numeric(int, 0, 100),
|
|
||||||
default=0,
|
|
||||||
metavar='Q',
|
|
||||||
help=(
|
|
||||||
"Adjust PNG quality level to use when quantizing PNGs. "
|
|
||||||
"Values have same meaning as with --jpeg-quality"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
optimizing.add_argument(
|
|
||||||
'--jbig2-lossy',
|
|
||||||
action='store_true',
|
|
||||||
help=(
|
|
||||||
"Enable JBIG2 lossy mode (better compression, not suitable for some "
|
|
||||||
"use cases - see documentation). Only takes effect if --optimize 1 or "
|
|
||||||
"higher is also enabled."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
optimizing.add_argument(
|
|
||||||
'--jbig2-page-group-size',
|
|
||||||
type=numeric(int, 1, 10000),
|
|
||||||
default=0,
|
|
||||||
metavar='N',
|
|
||||||
# Adjust number of pages to consider at once for JBIG2 compression
|
|
||||||
help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
|
|
||||||
advanced = parser.add_argument_group(
|
advanced = parser.add_argument_group(
|
||||||
"Advanced", "Advanced options to control OCRmyPDF"
|
"Advanced", "Advanced options to control OCRmyPDF"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""OCRmyPDF's exceptions."""
|
||||||
|
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
|
||||||
|
|
||||||
class ExitCode(IntEnum):
|
class ExitCode(IntEnum):
|
||||||
|
"""OCRmyPDF's exit codes."""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
ok = 0
|
ok = 0
|
||||||
bad_args = 1
|
bad_args = 1
|
||||||
input_file = 2
|
input_file = 2
|
||||||
@@ -26,6 +30,8 @@ class ExitCode(IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
class ExitCodeException(Exception):
|
class ExitCodeException(Exception):
|
||||||
|
"""An exception which should return an exit code with sys.exit()."""
|
||||||
|
|
||||||
exit_code = ExitCode.other_error
|
exit_code = ExitCode.other_error
|
||||||
message = ""
|
message = ""
|
||||||
|
|
||||||
@@ -37,17 +43,24 @@ class ExitCodeException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class BadArgsError(ExitCodeException):
|
class BadArgsError(ExitCodeException):
|
||||||
|
"""Invalid arguments on the command line or API."""
|
||||||
|
|
||||||
exit_code = ExitCode.bad_args
|
exit_code = ExitCode.bad_args
|
||||||
|
|
||||||
|
|
||||||
class PdfMergeFailedError(ExitCodeException):
|
class PdfMergeFailedError(ExitCodeException): # deprecated
|
||||||
|
"""An intermediate PDF can't be merged.
|
||||||
|
|
||||||
|
No longer in use.
|
||||||
|
"""
|
||||||
|
|
||||||
exit_code = ExitCode.input_file
|
exit_code = ExitCode.input_file
|
||||||
message = dedent(
|
message = dedent(
|
||||||
'''\
|
'''\
|
||||||
Failed to merge PDF image layer with OCR layer
|
Failed to merge PDF image layer with OCR layer
|
||||||
|
|
||||||
Usually this happens because the input PDF file is malformed and
|
Usually this happens because the input PDF file is malformed and
|
||||||
ocrmypdf cannot automatically correct the problem on its own.
|
ocrmypdf cannot correct the problem on its own.
|
||||||
|
|
||||||
Try using
|
Try using
|
||||||
ocrmypdf --pdf-renderer sandwich [..other args..]
|
ocrmypdf --pdf-renderer sandwich [..other args..]
|
||||||
@@ -56,34 +69,50 @@ class PdfMergeFailedError(ExitCodeException):
|
|||||||
|
|
||||||
|
|
||||||
class MissingDependencyError(ExitCodeException):
|
class MissingDependencyError(ExitCodeException):
|
||||||
|
"""A third-party dependency is missing."""
|
||||||
|
|
||||||
exit_code = ExitCode.missing_dependency
|
exit_code = ExitCode.missing_dependency
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedImageFormatError(ExitCodeException):
|
class UnsupportedImageFormatError(ExitCodeException):
|
||||||
|
"""The image format is not supported."""
|
||||||
|
|
||||||
exit_code = ExitCode.input_file
|
exit_code = ExitCode.input_file
|
||||||
|
|
||||||
|
|
||||||
class DpiError(ExitCodeException):
|
class DpiError(ExitCodeException):
|
||||||
|
"""Missing information about input image DPI."""
|
||||||
|
|
||||||
exit_code = ExitCode.input_file
|
exit_code = ExitCode.input_file
|
||||||
|
|
||||||
|
|
||||||
class OutputFileAccessError(ExitCodeException):
|
class OutputFileAccessError(ExitCodeException):
|
||||||
|
"""Cannot access the intended output file path."""
|
||||||
|
|
||||||
exit_code = ExitCode.file_access_error
|
exit_code = ExitCode.file_access_error
|
||||||
|
|
||||||
|
|
||||||
class PriorOcrFoundError(ExitCodeException):
|
class PriorOcrFoundError(ExitCodeException):
|
||||||
|
"""This file already has OCR."""
|
||||||
|
|
||||||
exit_code = ExitCode.already_done_ocr
|
exit_code = ExitCode.already_done_ocr
|
||||||
|
|
||||||
|
|
||||||
class InputFileError(ExitCodeException):
|
class InputFileError(ExitCodeException):
|
||||||
|
"""Something is wrong with the input file."""
|
||||||
|
|
||||||
exit_code = ExitCode.input_file
|
exit_code = ExitCode.input_file
|
||||||
|
|
||||||
|
|
||||||
class SubprocessOutputError(ExitCodeException):
|
class SubprocessOutputError(ExitCodeException):
|
||||||
|
"""A subprocess returned an unexpected error."""
|
||||||
|
|
||||||
exit_code = ExitCode.child_process_error
|
exit_code = ExitCode.child_process_error
|
||||||
|
|
||||||
|
|
||||||
class EncryptedPdfError(ExitCodeException):
|
class EncryptedPdfError(ExitCodeException):
|
||||||
|
"""Input PDF is encrypted."""
|
||||||
|
|
||||||
exit_code = ExitCode.encrypted_pdf
|
exit_code = ExitCode.encrypted_pdf
|
||||||
message = dedent(
|
message = dedent(
|
||||||
'''\
|
'''\
|
||||||
@@ -100,5 +129,7 @@ class EncryptedPdfError(ExitCodeException):
|
|||||||
|
|
||||||
|
|
||||||
class TesseractConfigError(ExitCodeException):
|
class TesseractConfigError(ExitCodeException):
|
||||||
|
"""Tesseract config can't be parsed."""
|
||||||
|
|
||||||
exit_code = ExitCode.invalid_config
|
exit_code = ExitCode.invalid_config
|
||||||
message = "Error occurred while parsing a Tesseract configuration file"
|
message = "Error occurred while parsing a Tesseract configuration file"
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ from ocrmypdf.helpers import remove_all_log_handlers
|
|||||||
|
|
||||||
|
|
||||||
class MessageType(Enum):
|
class MessageType(Enum):
|
||||||
exception = auto()
|
"""Implement basic IPC messaging."""
|
||||||
result = auto()
|
|
||||||
complete = auto()
|
exception = auto() # pylint: disable=invalid-name
|
||||||
|
result = auto() # pylint: disable=invalid-name
|
||||||
|
complete = auto() # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
def split_every(n: int, iterable: Iterable) -> Iterator:
|
def split_every(n: int, iterable: Iterable) -> Iterator:
|
||||||
@@ -59,6 +61,8 @@ def process_sigbus(*args):
|
|||||||
|
|
||||||
|
|
||||||
class ConnectionLogHandler(logging.handlers.QueueHandler):
|
class ConnectionLogHandler(logging.handlers.QueueHandler):
|
||||||
|
"""Handler used by child processes to forward log messages to parent."""
|
||||||
|
|
||||||
def __init__(self, conn: Connection) -> None:
|
def __init__(self, conn: Connection) -> None:
|
||||||
# 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
|
||||||
@@ -91,7 +95,7 @@ def process_loop(
|
|||||||
for args in task_args:
|
for args in task_args:
|
||||||
try:
|
try:
|
||||||
result = task(args)
|
result = task(args)
|
||||||
except Exception as e:
|
except Exception as e: # pylint: disable=broad-except
|
||||||
conn.send((MessageType.exception, e))
|
conn.send((MessageType.exception, e))
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -103,6 +107,8 @@ def process_loop(
|
|||||||
|
|
||||||
|
|
||||||
class LambdaExecutor(Executor):
|
class LambdaExecutor(Executor):
|
||||||
|
"""Executor for AWS Lambda or similar environments that lack semaphores."""
|
||||||
|
|
||||||
def _execute(
|
def _execute(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -153,13 +159,13 @@ class LambdaExecutor(Executor):
|
|||||||
|
|
||||||
with self.pbar_class(**tqdm_kwargs) as pbar:
|
with self.pbar_class(**tqdm_kwargs) as pbar:
|
||||||
while connections:
|
while connections:
|
||||||
for r in wait(connections):
|
for result in wait(connections):
|
||||||
if not isinstance(r, Connection):
|
if not isinstance(result, Connection):
|
||||||
raise NotImplementedError("We only support Connection()")
|
raise NotImplementedError("We only support Connection()")
|
||||||
try:
|
try:
|
||||||
msg_type, msg = r.recv()
|
msg_type, msg = result.recv()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
connections.remove(r)
|
connections.remove(result)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if msg_type == MessageType.result:
|
if msg_type == MessageType.result:
|
||||||
@@ -170,7 +176,7 @@ class LambdaExecutor(Executor):
|
|||||||
logger = logging.getLogger(record.name)
|
logger = logging.getLogger(record.name)
|
||||||
logger.handle(record)
|
logger.handle(record)
|
||||||
elif msg_type == MessageType.complete:
|
elif msg_type == MessageType.complete:
|
||||||
connections.remove(r)
|
connections.remove(result)
|
||||||
elif msg_type == MessageType.exception:
|
elif msg_type == MessageType.exception:
|
||||||
for process in processes:
|
for process in processes:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
|
|||||||
+36
-16
@@ -4,6 +4,7 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Support functions."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
@@ -19,10 +20,21 @@ from math import isclose, isfinite
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import img2pdf
|
||||||
import pikepdf
|
import pikepdf
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if Version(img2pdf.__version__) < Version('0.4.0'):
|
||||||
|
IMG2PDF_KWARGS = dict(without_pdfw=True)
|
||||||
|
elif Version(img2pdf.__version__) < Version('0.4.3'):
|
||||||
|
IMG2PDF_KWARGS = dict(engine=img2pdf.Engine.pikepdf)
|
||||||
|
else:
|
||||||
|
IMG2PDF_KWARGS = dict(
|
||||||
|
engine=img2pdf.Engine.pikepdf, rotation=img2pdf.Rotation.ifvalid
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Resolution(namedtuple('Resolution', ('x', 'y'))):
|
class Resolution(namedtuple('Resolution', ('x', 'y'))):
|
||||||
"""The number of pixels per inch in each 2D direction.
|
"""The number of pixels per inch in each 2D direction.
|
||||||
@@ -126,11 +138,11 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
|
|||||||
os.symlink(os.path.abspath(input_file), soft_link_name)
|
os.symlink(os.path.abspath(input_file), soft_link_name)
|
||||||
|
|
||||||
|
|
||||||
def samefile(f1: os.PathLike, f2: os.PathLike):
|
def samefile(file1: os.PathLike, file2: os.PathLike):
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
return f1 == f2
|
return file1 == file2
|
||||||
else:
|
else:
|
||||||
return os.path.samefile(f1, f2)
|
return os.path.samefile(file1, file2)
|
||||||
|
|
||||||
|
|
||||||
def is_iterable_notstr(thing: Any) -> bool:
|
def is_iterable_notstr(thing: Any) -> bool:
|
||||||
@@ -138,9 +150,9 @@ def is_iterable_notstr(thing: Any) -> bool:
|
|||||||
return isinstance(thing, Iterable) and not isinstance(thing, str)
|
return isinstance(thing, Iterable) and not isinstance(thing, str)
|
||||||
|
|
||||||
|
|
||||||
def monotonic(L: Sequence) -> bool:
|
def monotonic(seq: Sequence) -> bool:
|
||||||
"""Does this sequence increase monotonically?"""
|
"""Does this sequence increase monotonically?"""
|
||||||
return all(b > a for a, b in zip(L, L[1:]))
|
return all(b > a for a, b in zip(seq, seq[1:]))
|
||||||
|
|
||||||
|
|
||||||
def page_number(input_file: os.PathLike) -> int:
|
def page_number(input_file: os.PathLike) -> int:
|
||||||
@@ -155,7 +167,7 @@ def available_cpu_count() -> int:
|
|||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
pass
|
pass
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"Could not get CPU count. Assuming one (1) CPU." "Use -j N to set manually."
|
"Could not get CPU count. Assuming one (1) CPU. Use -j N to set manually."
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -179,16 +191,16 @@ def is_file_writable(test_file: os.PathLike) -> bool:
|
|||||||
os.W_OK,
|
os.W_OK,
|
||||||
effective_ids=(os.access in os.supports_effective_ids),
|
effective_ids=(os.access in os.supports_effective_ids),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fp = p.open('wb')
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
try:
|
fp.close()
|
||||||
fp = p.open('wb')
|
with suppress(OSError):
|
||||||
except OSError:
|
p.unlink()
|
||||||
return False
|
return True
|
||||||
else:
|
|
||||||
fp.close()
|
|
||||||
with suppress(OSError):
|
|
||||||
p.unlink()
|
|
||||||
return True
|
|
||||||
except (OSError, RuntimeError) as e:
|
except (OSError, RuntimeError) as e:
|
||||||
log.debug(e)
|
log.debug(e)
|
||||||
log.error(str(e))
|
log.error(str(e))
|
||||||
@@ -209,11 +221,19 @@ def check_pdf(input_file: Path) -> bool:
|
|||||||
else:
|
else:
|
||||||
with pdf:
|
with pdf:
|
||||||
messages = pdf.check()
|
messages = pdf.check()
|
||||||
|
success = True
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if 'error' in msg.lower():
|
if 'error' in msg.lower():
|
||||||
log.error(msg)
|
log.error(msg)
|
||||||
|
success = False
|
||||||
|
elif (
|
||||||
|
"/DecodeParms: operation for dictionary attempted on object "
|
||||||
|
"of type null" in msg
|
||||||
|
):
|
||||||
|
pass # Ignore/spurious warning
|
||||||
else:
|
else:
|
||||||
log.warning(msg)
|
log.warning(msg)
|
||||||
|
success = False
|
||||||
|
|
||||||
sio = StringIO()
|
sio = StringIO()
|
||||||
linearize_msgs = ''
|
linearize_msgs = ''
|
||||||
@@ -228,7 +248,7 @@ def check_pdf(input_file: Path) -> bool:
|
|||||||
if linearize_msgs:
|
if linearize_msgs:
|
||||||
log.warning(linearize_msgs)
|
log.warning(linearize_msgs)
|
||||||
|
|
||||||
if not messages and not linearize_msgs:
|
if success and not linearize_msgs:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
"""Transform .hocr and page image to text PDF."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -99,7 +101,7 @@ HOCR_OK_LANGS = frozenset(
|
|||||||
Element = ElementTree.Element
|
Element = ElementTree.Element
|
||||||
|
|
||||||
|
|
||||||
class Rect(NamedTuple): # pylint: disable=inherit-non-class
|
class Rect(NamedTuple):
|
||||||
"""A rectangle for managing PDF coordinates."""
|
"""A rectangle for managing PDF coordinates."""
|
||||||
|
|
||||||
x1: Any
|
x1: Any
|
||||||
@@ -109,7 +111,7 @@ class Rect(NamedTuple): # pylint: disable=inherit-non-class
|
|||||||
|
|
||||||
|
|
||||||
class HocrTransformError(Exception):
|
class HocrTransformError(Exception):
|
||||||
pass
|
"""Error while applying hOCR transform."""
|
||||||
|
|
||||||
|
|
||||||
class HocrTransform:
|
class HocrTransform:
|
||||||
@@ -287,7 +289,7 @@ class HocrTransform:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
pxl_coords = self.element_coordinates(elem)
|
pxl_coords = self.element_coordinates(elem)
|
||||||
pt = self.pt_from_pixel(pxl_coords)
|
pt = self.pt_from_pixel(pxl_coords) # pylint: disable=invalid-name
|
||||||
|
|
||||||
# draw the bbox border
|
# draw the bbox border
|
||||||
if show_bounding_boxes: # pragma: no cover
|
if show_bounding_boxes: # pragma: no cover
|
||||||
|
|||||||
+125
-40
@@ -4,10 +4,13 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Post-processing image optimization of OCR PDFs."""
|
||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,6 +26,7 @@ from typing import (
|
|||||||
Sequence,
|
Sequence,
|
||||||
Tuple,
|
Tuple,
|
||||||
)
|
)
|
||||||
|
from zlib import compress
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
@@ -31,6 +35,7 @@ from pikepdf import (
|
|||||||
Object,
|
Object,
|
||||||
ObjectStreamMode,
|
ObjectStreamMode,
|
||||||
Pdf,
|
Pdf,
|
||||||
|
PdfError,
|
||||||
PdfImage,
|
PdfImage,
|
||||||
Stream,
|
Stream,
|
||||||
UnsupportedImageTypeError,
|
UnsupportedImageTypeError,
|
||||||
@@ -41,7 +46,7 @@ from ocrmypdf._concurrent import Executor, SerialExecutor
|
|||||||
from ocrmypdf._exec import jbig2enc, pngquant
|
from ocrmypdf._exec import jbig2enc, pngquant
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf.exceptions import OutputFileAccessError
|
from ocrmypdf.exceptions import OutputFileAccessError
|
||||||
from ocrmypdf.helpers import safe_symlink
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, safe_symlink
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,7 +57,9 @@ DEFAULT_PNG_QUALITY = 70
|
|||||||
Xref = NewType('Xref', int)
|
Xref = NewType('Xref', int)
|
||||||
|
|
||||||
|
|
||||||
class XrefExt(NamedTuple): # pylint: disable=inherit-non-class
|
class XrefExt(NamedTuple):
|
||||||
|
"""A PDF xref and image extension pair."""
|
||||||
|
|
||||||
xref: Xref
|
xref: Xref
|
||||||
ext: str
|
ext: str
|
||||||
|
|
||||||
@@ -78,33 +85,49 @@ def extract_image_filter(
|
|||||||
if image.Subtype != Name.Image:
|
if image.Subtype != Name.Image:
|
||||||
return None
|
return None
|
||||||
if image.Length < 100:
|
if image.Length < 100:
|
||||||
log.debug(f"Skipping small image, xref {xref}")
|
log.debug(f"xref {xref}: skipping image with small stream size")
|
||||||
return None
|
return None
|
||||||
if image.Width < 8 or image.Height < 8: # Issue 732
|
if image.Width < 8 or image.Height < 8: # Issue 732
|
||||||
log.debug(f"Skipping oddly sized image, xref {xref}")
|
log.debug(f"xref {xref}: skipping image with unusually small dimensions")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pim = PdfImage(image)
|
pim = PdfImage(image)
|
||||||
|
|
||||||
if len(pim.filter_decodeparms) > 1:
|
if len(pim.filter_decodeparms) > 1:
|
||||||
log.debug(f"Skipping multiply filtered image, xref {xref}")
|
first_filtdp = pim.filter_decodeparms[0]
|
||||||
return None
|
second_filtdp = pim.filter_decodeparms[1]
|
||||||
filtdp = pim.filter_decodeparms[0]
|
if (
|
||||||
|
len(pim.filter_decodeparms) == 2
|
||||||
|
and first_filtdp[0] == Name.FlateDecode
|
||||||
|
and first_filtdp[1].get(Name.Predictor, 1) == 1
|
||||||
|
and second_filtdp[0] == Name.DCTDecode
|
||||||
|
and not second_filtdp[1]
|
||||||
|
):
|
||||||
|
log.debug(
|
||||||
|
f"xref {xref}: found image compressed as /FlateDecode /DCTDecode, "
|
||||||
|
"marked for JPEG optimization"
|
||||||
|
)
|
||||||
|
filtdp = pim.filter_decodeparms[1]
|
||||||
|
else:
|
||||||
|
log.debug(f"xref {xref}: skipping image with multiple compression filters")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
filtdp = pim.filter_decodeparms[0]
|
||||||
|
|
||||||
if pim.bits_per_component > 8:
|
if pim.bits_per_component > 8:
|
||||||
log.debug(f"Skipping wide gamut image, xref {xref}")
|
log.debug(f"xref {xref}: skipping wide gamut image")
|
||||||
return None # Don't mess with wide gamut images
|
return None # Don't mess with wide gamut images
|
||||||
|
|
||||||
if filtdp[0] == Name.JPXDecode:
|
if filtdp[0] == Name.JPXDecode:
|
||||||
log.debug(f"Skipping JPEG2000 image, xref {xref}")
|
log.debug(f"xref {xref}: skipping JPEG2000 image")
|
||||||
return None # Don't do JPEG2000
|
return None # Don't do JPEG2000
|
||||||
|
|
||||||
if filtdp[0] == Name.CCITTFaxDecode and filtdp[1].get('/K', 0) >= 0:
|
if filtdp[0] == Name.CCITTFaxDecode and filtdp[1].get('/K', 0) >= 0:
|
||||||
log.debug(f"Skipping CCITT Group 3 image, xref {xref}")
|
log.debug(f"xref {xref}: skipping CCITT Group 3 image")
|
||||||
return None # pikepdf doesn't support Group 3 yet
|
return None # pikepdf doesn't support Group 3 yet
|
||||||
|
|
||||||
if Name.Decode in image:
|
if Name.Decode in image:
|
||||||
log.debug(f"Skipping image with Decode table, xref {xref}")
|
log.debug(f"xref {xref}: skipping image with Decode table")
|
||||||
return None # Don't mess with custom Decode tables
|
return None # Don't mess with custom Decode tables
|
||||||
|
|
||||||
return pim, filtdp
|
return pim, filtdp
|
||||||
@@ -172,14 +195,6 @@ def extract_image_generic(
|
|||||||
# jpeg_quality_estimate = 117.0 * (bytes_per_pixel ** 0.213)
|
# jpeg_quality_estimate = 117.0 * (bytes_per_pixel ** 0.213)
|
||||||
# if jpeg_quality_estimate < 65:
|
# if jpeg_quality_estimate < 65:
|
||||||
# return None
|
# return None
|
||||||
|
|
||||||
# We could get the ICC profile here, but there's no need to look at it
|
|
||||||
# for quality transcoding
|
|
||||||
# if icc:
|
|
||||||
# stream = BytesIO(raw_jpeg.read_raw_bytes())
|
|
||||||
# iccbytes = icc.read_bytes()
|
|
||||||
# with Image.open(stream) as im:
|
|
||||||
# im.save(jpg_name(root, xref), icc_profile=iccbytes)
|
|
||||||
try:
|
try:
|
||||||
imgname = root / f'{xref:08d}'
|
imgname = root / f'{xref:08d}'
|
||||||
with imgname.open('wb') as f:
|
with imgname.open('wb') as f:
|
||||||
@@ -200,7 +215,11 @@ def extract_image_generic(
|
|||||||
elif not pim.indexed and pim.colorspace in pim.SIMPLE_COLORSPACES:
|
elif not pim.indexed and pim.colorspace in pim.SIMPLE_COLORSPACES:
|
||||||
# An optimization opportunity here, not currently taken, is directly
|
# An optimization opportunity here, not currently taken, is directly
|
||||||
# generating a PNG from compressed data
|
# generating a PNG from compressed data
|
||||||
pim.as_pil_image().save(png_name(root, xref))
|
try:
|
||||||
|
pim.as_pil_image().save(png_name(root, xref))
|
||||||
|
except NotImplementedError:
|
||||||
|
log.warning("PDF contains an atypical image that cannot be optimized.")
|
||||||
|
return None
|
||||||
return XrefExt(xref, '.png')
|
return XrefExt(xref, '.png')
|
||||||
elif (
|
elif (
|
||||||
not pim.indexed
|
not pim.indexed
|
||||||
@@ -251,13 +270,13 @@ def extract_images(
|
|||||||
if image.objgen[1] != 0:
|
if image.objgen[1] != 0:
|
||||||
continue # Ignore images in an incremental PDF
|
continue # Ignore images in an incremental PDF
|
||||||
xref = Xref(image.objgen[0])
|
xref = Xref(image.objgen[0])
|
||||||
if hasattr(image, 'SMask'):
|
if Name.SMask in image:
|
||||||
# Ignore soft masks
|
# Ignore soft masks
|
||||||
smask_xref = Xref(image.SMask.objgen[0])
|
smask_xref = Xref(image.SMask.objgen[0])
|
||||||
exclude_xrefs.add(smask_xref)
|
exclude_xrefs.add(smask_xref)
|
||||||
log.debug(f"Skipping image {smask_xref} because it is an SMask")
|
log.debug(f"xref {smask_xref}: skipping image because it is an SMask")
|
||||||
include_xrefs.add(xref)
|
include_xrefs.add(xref)
|
||||||
log.debug(f"Treating {xref} as an optimization candidate")
|
log.debug(f"xref {xref}: treating as an optimization candidate")
|
||||||
if xref not in pageno_for_xref:
|
if xref not in pageno_for_xref:
|
||||||
pageno_for_xref[xref] = pageno
|
pageno_for_xref[xref] = pageno
|
||||||
|
|
||||||
@@ -269,7 +288,9 @@ def extract_images(
|
|||||||
pike=pike, root=root, image=image, xref=xref, options=options
|
pike=pike, root=root, image=image, xref=xref, options=options
|
||||||
)
|
)
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
log.exception(f"While extracting image xref {xref}, an error occurred")
|
log.exception(
|
||||||
|
f"xref {xref}: While extracting this image, an error occurred"
|
||||||
|
)
|
||||||
errors += 1
|
errors += 1
|
||||||
else:
|
else:
|
||||||
if result:
|
if result:
|
||||||
@@ -290,7 +311,7 @@ def extract_images_generic(
|
|||||||
pngs.append(xref_ext.xref)
|
pngs.append(xref_ext.xref)
|
||||||
elif xref_ext.ext == '.jpg':
|
elif xref_ext.ext == '.jpg':
|
||||||
jpegs.append(xref_ext.xref)
|
jpegs.append(xref_ext.xref)
|
||||||
log.debug("Optimizable images: JPEGs: %s PNGs: %s", len(jpegs), len(pngs))
|
log.debug(f"Optimizable images: JPEGs: {len(jpegs)} PNGs: {len(pngs)}")
|
||||||
return jpegs, pngs
|
return jpegs, pngs
|
||||||
|
|
||||||
|
|
||||||
@@ -302,7 +323,7 @@ def extract_images_jbig2(pike: Pdf, root: Path, options) -> Dict[int, List[XrefE
|
|||||||
group = pageno // options.jbig2_page_group_size
|
group = pageno // options.jbig2_page_group_size
|
||||||
jbig2_groups[group].append(xref_ext)
|
jbig2_groups[group].append(xref_ext)
|
||||||
|
|
||||||
log.debug("Optimizable images: JBIG2 groups: %s", (len(jbig2_groups),))
|
log.debug(f"Optimizable images: JBIG2 groups: {len(jbig2_groups)}")
|
||||||
return jbig2_groups
|
return jbig2_groups
|
||||||
|
|
||||||
|
|
||||||
@@ -401,15 +422,11 @@ def convert_to_jbig2(
|
|||||||
def _optimize_jpeg(args: Tuple[Xref, Path, Path, int]) -> Tuple[Xref, Optional[Path]]:
|
def _optimize_jpeg(args: Tuple[Xref, Path, Path, int]) -> Tuple[Xref, Optional[Path]]:
|
||||||
xref, in_jpg, opt_jpg, jpeg_quality = args
|
xref, in_jpg, opt_jpg, jpeg_quality = args
|
||||||
|
|
||||||
# This may produce a debug warning from PIL
|
|
||||||
# DEBUG:PIL.Image:Error closing: 'NoneType' object has no attribute
|
|
||||||
# 'close'. Seems to be mostly harmless
|
|
||||||
# https://github.com/python-pillow/Pillow/issues/1144
|
|
||||||
with Image.open(in_jpg) as im:
|
with Image.open(in_jpg) as im:
|
||||||
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
|
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
|
||||||
|
|
||||||
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
||||||
log.debug("xref %s, jpeg, made larger - skip", xref)
|
log.debug(f"xref {xref}, jpeg, made larger - skip")
|
||||||
opt_jpg.unlink()
|
opt_jpg.unlink()
|
||||||
return xref, None
|
return xref, None
|
||||||
return xref, opt_jpg
|
return xref, opt_jpg
|
||||||
@@ -436,7 +453,7 @@ def transcode_jpegs(
|
|||||||
use_threads=True, # Processes are significantly slower at this task
|
use_threads=True, # Processes are significantly slower at this task
|
||||||
max_workers=options.jobs,
|
max_workers=options.jobs,
|
||||||
tqdm_kwargs=dict(
|
tqdm_kwargs=dict(
|
||||||
desc="JPEGs",
|
desc="Recompressing JPEGs",
|
||||||
total=len(jpegs),
|
total=len(jpegs),
|
||||||
unit='image',
|
unit='image',
|
||||||
disable=not options.progress_bar,
|
disable=not options.progress_bar,
|
||||||
@@ -447,10 +464,77 @@ def transcode_jpegs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_deflatable_jpeg(
|
||||||
|
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||||
|
) -> Optional[XrefExt]:
|
||||||
|
result = extract_image_filter(pike, root, image, xref)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
_pim, filtdp = result
|
||||||
|
|
||||||
|
if filtdp[0] == Name.DCTDecode and not filtdp[1] and options.optimize >= 1:
|
||||||
|
return XrefExt(xref, '.memory')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _deflate_jpeg(args: Tuple[Pdf, threading.Lock, Xref, int]) -> Tuple[Xref, bytes]:
|
||||||
|
pike, lock, xref, complevel = args
|
||||||
|
with lock:
|
||||||
|
xobj = pike.get_object(xref, 0)
|
||||||
|
try:
|
||||||
|
data = xobj.read_raw_bytes()
|
||||||
|
except PdfError:
|
||||||
|
return xref, b''
|
||||||
|
compdata = compress(data, complevel)
|
||||||
|
if len(compdata) >= len(data):
|
||||||
|
return xref, b''
|
||||||
|
return xref, compdata
|
||||||
|
|
||||||
|
|
||||||
|
def deflate_jpegs(pike: Pdf, root: Path, options, executor: Executor) -> None:
|
||||||
|
jpegs = []
|
||||||
|
for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg):
|
||||||
|
xref = xref_ext.xref
|
||||||
|
log.debug(f'xref {xref}: marking this JPEG as deflatable')
|
||||||
|
jpegs.append(xref)
|
||||||
|
|
||||||
|
complevel = 9 if options.optimize == 3 else 6
|
||||||
|
|
||||||
|
# Our calls to xobj.write() in finish() need coordination
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def deflate_args() -> Iterator:
|
||||||
|
for xref in jpegs:
|
||||||
|
yield pike, lock, xref, complevel
|
||||||
|
|
||||||
|
def finish(result, pbar):
|
||||||
|
xref, compdata = result
|
||||||
|
if len(compdata) > 0:
|
||||||
|
with lock:
|
||||||
|
xobj = pike.get_object(xref, 0)
|
||||||
|
xobj.write(compdata, filter=[Name.FlateDecode, Name.DCTDecode])
|
||||||
|
pbar.update()
|
||||||
|
|
||||||
|
executor(
|
||||||
|
use_threads=True, # We're sharing the pdf directly, must use threads
|
||||||
|
max_workers=options.jobs,
|
||||||
|
tqdm_kwargs=dict(
|
||||||
|
desc="Deflating JPEGs",
|
||||||
|
total=len(jpegs),
|
||||||
|
unit='image',
|
||||||
|
disable=not options.progress_bar,
|
||||||
|
),
|
||||||
|
task=_deflate_jpeg,
|
||||||
|
task_arguments=deflate_args(),
|
||||||
|
task_finished=finish,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _transcode_png(pike: Pdf, filename: Path, xref: Xref) -> bool:
|
def _transcode_png(pike: Pdf, filename: Path, xref: Xref) -> bool:
|
||||||
output = filename.with_suffix('.png.pdf')
|
output = filename.with_suffix('.png.pdf')
|
||||||
with output.open('wb') as f:
|
with output.open('wb') as f:
|
||||||
img2pdf.convert(fspath(filename), outputstream=f)
|
img2pdf.convert(fspath(filename), outputstream=f, **IMG2PDF_KWARGS)
|
||||||
|
|
||||||
with Pdf.open(output) as pdf_image:
|
with Pdf.open(output) as pdf_image:
|
||||||
foreign_image = next(iter(pdf_image.pages[0].images.values()))
|
foreign_image = next(iter(pdf_image.pages[0].images.values()))
|
||||||
@@ -541,11 +625,11 @@ def optimize(
|
|||||||
context,
|
context,
|
||||||
save_settings,
|
save_settings,
|
||||||
executor: Executor = DEFAULT_EXECUTOR,
|
executor: Executor = DEFAULT_EXECUTOR,
|
||||||
) -> None:
|
) -> Path:
|
||||||
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)
|
||||||
return
|
return output_file
|
||||||
|
|
||||||
if options.jpeg_quality == 0:
|
if options.jpeg_quality == 0:
|
||||||
options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
||||||
@@ -560,6 +644,7 @@ def optimize(
|
|||||||
|
|
||||||
jpegs, pngs = extract_images_generic(pike, root, options)
|
jpegs, pngs = extract_images_generic(pike, root, options)
|
||||||
transcode_jpegs(pike, jpegs, root, options, executor)
|
transcode_jpegs(pike, jpegs, root, options, executor)
|
||||||
|
deflate_jpegs(pike, root, options, executor)
|
||||||
# if options.optimize >= 2:
|
# if options.optimize >= 2:
|
||||||
# Try pngifying the jpegs
|
# Try pngifying the jpegs
|
||||||
# transcode_pngs(pike, jpegs, jpg_name, root, options)
|
# transcode_pngs(pike, jpegs, jpg_name, root, options)
|
||||||
@@ -579,9 +664,7 @@ def optimize(
|
|||||||
f"Output file not created after optimizing. We probably ran "
|
f"Output file not created after optimizing. We probably ran "
|
||||||
f"out of disk space in the temporary folder: {tempfile.gettempdir()}."
|
f"out of disk space in the temporary folder: {tempfile.gettempdir()}."
|
||||||
)
|
)
|
||||||
ratio = input_size / output_size
|
|
||||||
savings = 1 - output_size / input_size
|
savings = 1 - output_size / input_size
|
||||||
log.info(f"Optimize ratio: {ratio:.2f} savings: {(savings):.1%}")
|
|
||||||
|
|
||||||
if savings < 0:
|
if savings < 0:
|
||||||
log.info(
|
log.info(
|
||||||
@@ -595,6 +678,8 @@ def optimize(
|
|||||||
else:
|
else:
|
||||||
safe_symlink(target_file, output_file)
|
safe_symlink(target_file, output_file)
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
def main(infile, outfile, level, jobs=1):
|
def main(infile, outfile, level, jobs=1):
|
||||||
from shutil import copy # pylint: disable=import-outside-toplevel
|
from shutil import copy # pylint: disable=import-outside-toplevel
|
||||||
@@ -626,9 +711,9 @@ def main(infile, outfile, level, jobs=1):
|
|||||||
jb2lossy=False,
|
jb2lossy=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
with TemporaryDirectory() as td:
|
with TemporaryDirectory() as tmpdir:
|
||||||
context = PdfContext(options, td, infile, None, None)
|
context = PdfContext(options, tmpdir, infile, None, None)
|
||||||
tmpout = Path(td) / 'out.pdf'
|
tmpout = Path(tmpdir) / 'out.pdf'
|
||||||
optimize(
|
optimize(
|
||||||
infile,
|
infile,
|
||||||
tmpout,
|
tmpout,
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
|||||||
target_filename: filename to save
|
target_filename: filename to save
|
||||||
icc: ICC identifier such as 'sRGB'
|
icc: ICC identifier such as 'sRGB'
|
||||||
References:
|
References:
|
||||||
Adobe PDFMARK Reference: https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/pdfmark_reference.pdf
|
Adobe PDFMARK Reference:
|
||||||
|
https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/pdfmark_reference.pdf
|
||||||
"""
|
"""
|
||||||
if icc != 'sRGB':
|
if icc != 'sRGB':
|
||||||
raise NotImplementedError("Only supporting sRGB")
|
raise NotImplementedError("Only supporting sRGB")
|
||||||
@@ -105,11 +106,11 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
|||||||
bytes_icc_profile = (
|
bytes_icc_profile = (
|
||||||
package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME
|
package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME
|
||||||
).read_bytes()
|
).read_bytes()
|
||||||
ps = '\n'.join(_make_postscript(icc, bytes_icc_profile, 3))
|
postscript = '\n'.join(_make_postscript(icc, bytes_icc_profile, 3))
|
||||||
|
|
||||||
# We should have encoded everything to pure ASCII by this point, and
|
# We should have encoded everything to pure ASCII by this point, and
|
||||||
# to be safe, only allow ASCII in PostScript
|
# to be safe, only allow ASCII in PostScript
|
||||||
Path(target_filename).write_text(ps, encoding='ascii')
|
Path(target_filename).write_text(postscript, encoding='ascii')
|
||||||
return target_filename
|
return target_filename
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,6 @@
|
|||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
|
||||||
|
"""For extracting information about PDFs prior to OCR."""
|
||||||
|
|
||||||
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PdfInfo
|
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PdfInfo
|
||||||
|
|||||||
@@ -6,13 +6,15 @@
|
|||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
|
||||||
|
"""Extract information about the content of a PDF."""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from math import hypot, inf, isclose
|
from math import hypot, inf, isclose
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
@@ -20,11 +22,13 @@ from pathlib import Path
|
|||||||
from typing import (
|
from typing import (
|
||||||
Container,
|
Container,
|
||||||
Dict,
|
Dict,
|
||||||
|
Iterable,
|
||||||
Iterator,
|
Iterator,
|
||||||
List,
|
List,
|
||||||
Mapping,
|
Mapping,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
Optional,
|
Optional,
|
||||||
|
Sequence,
|
||||||
Tuple,
|
Tuple,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
@@ -36,6 +40,7 @@ from pikepdf import (
|
|||||||
PdfImage,
|
PdfImage,
|
||||||
PdfInlineImage,
|
PdfInlineImage,
|
||||||
PdfMatrix,
|
PdfMatrix,
|
||||||
|
UnsupportedImageTypeError,
|
||||||
parse_content_stream,
|
parse_content_stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,11 +51,39 @@ from ocrmypdf.pdfinfo.layout import get_page_analysis, get_text_boxes
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
Colorspace = Enum('Colorspace', 'gray rgb cmyk lab icc index sep devn pattern jpeg2000')
|
|
||||||
|
|
||||||
Encoding = Enum(
|
class Colorspace(Enum):
|
||||||
'Encoding', 'ccitt jpeg jpeg2000 jbig2 asciihex ascii85 lzw flate runlength'
|
"""Description of common image colorspaces in a PDF."""
|
||||||
)
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
gray = auto()
|
||||||
|
rgb = auto()
|
||||||
|
cmyk = auto()
|
||||||
|
lab = auto()
|
||||||
|
icc = auto()
|
||||||
|
index = auto()
|
||||||
|
sep = auto()
|
||||||
|
devn = auto()
|
||||||
|
pattern = auto()
|
||||||
|
jpeg2000 = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Encoding(Enum):
|
||||||
|
"""Description of common image encodings in a PDF."""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
ccitt = auto()
|
||||||
|
jpeg = auto()
|
||||||
|
jpeg2000 = auto()
|
||||||
|
jbig2 = auto()
|
||||||
|
asciihex = auto()
|
||||||
|
ascii85 = auto()
|
||||||
|
lzw = auto()
|
||||||
|
flate = auto()
|
||||||
|
runlength = auto()
|
||||||
|
|
||||||
|
|
||||||
|
FloatRect = Tuple[float, float, float, float]
|
||||||
|
|
||||||
FRIENDLY_COLORSPACE: Dict[str, Colorspace] = {
|
FRIENDLY_COLORSPACE: Dict[str, Colorspace] = {
|
||||||
'/DeviceGray': Colorspace.gray,
|
'/DeviceGray': Colorspace.gray,
|
||||||
@@ -103,18 +136,24 @@ def _is_unit_square(shorthand):
|
|||||||
|
|
||||||
|
|
||||||
class XobjectSettings(NamedTuple):
|
class XobjectSettings(NamedTuple):
|
||||||
|
"""Info about an XObject found in a PDF."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
shorthand: Tuple[float, float, float, float, float, float]
|
shorthand: Tuple[float, float, float, float, float, float]
|
||||||
stack_depth: int
|
stack_depth: int
|
||||||
|
|
||||||
|
|
||||||
class InlineSettings(NamedTuple):
|
class InlineSettings(NamedTuple):
|
||||||
|
"""Info about an inline image found in a PDF."""
|
||||||
|
|
||||||
iimage: PdfInlineImage
|
iimage: PdfInlineImage
|
||||||
shorthand: Tuple[float, float, float, float, float, float]
|
shorthand: Tuple[float, float, float, float, float, float]
|
||||||
stack_depth: int
|
stack_depth: int
|
||||||
|
|
||||||
|
|
||||||
class ContentsInfo(NamedTuple):
|
class ContentsInfo(NamedTuple):
|
||||||
|
"""Info about various objects found in a PDF."""
|
||||||
|
|
||||||
xobject_settings: List[XobjectSettings]
|
xobject_settings: List[XobjectSettings]
|
||||||
inline_images: List[InlineSettings]
|
inline_images: List[InlineSettings]
|
||||||
found_vector: bool
|
found_vector: bool
|
||||||
@@ -123,17 +162,19 @@ class ContentsInfo(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class TextboxInfo(NamedTuple):
|
class TextboxInfo(NamedTuple):
|
||||||
|
"""Info about a text box found in a PDF."""
|
||||||
|
|
||||||
bbox: Tuple[float, float, float, float]
|
bbox: Tuple[float, float, float, float]
|
||||||
is_visible: bool
|
is_visible: bool
|
||||||
is_corrupt: bool
|
is_corrupt: bool
|
||||||
|
|
||||||
|
|
||||||
class VectorMarker:
|
class VectorMarker:
|
||||||
pass
|
"""Sentinel indicating vector drawing operations were found on a page."""
|
||||||
|
|
||||||
|
|
||||||
class TextMarker:
|
class TextMarker:
|
||||||
pass
|
"""Sentinel indicating text drawing operations were found on a page."""
|
||||||
|
|
||||||
|
|
||||||
def _normalize_stack(graphobjs):
|
def _normalize_stack(graphobjs):
|
||||||
@@ -195,7 +236,7 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
|||||||
if len(stack) > 32: # See docstring
|
if len(stack) > 32: # See docstring
|
||||||
if len(stack) > 128:
|
if len(stack) > 128:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"PDF graphics stack overflowed hard limit, operator %i" % n
|
f"PDF graphics stack overflowed hard limit at operator {n}"
|
||||||
)
|
)
|
||||||
warn("PDF graphics stack overflowed spec limit")
|
warn("PDF graphics stack overflowed spec limit")
|
||||||
elif operator == 'Q':
|
elif operator == 'Q':
|
||||||
@@ -281,7 +322,7 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
a, b, c, d, _, _ = ctm_shorthand
|
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
|
||||||
image_drawn = hypot(a, b), hypot(c, d)
|
image_drawn = hypot(a, b), hypot(c, d)
|
||||||
@@ -297,6 +338,8 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
|||||||
|
|
||||||
|
|
||||||
class ImageInfo:
|
class ImageInfo:
|
||||||
|
"""Information about an image found in a PDF."""
|
||||||
|
|
||||||
DPI_PREC = Decimal('1.000')
|
DPI_PREC = Decimal('1.000')
|
||||||
|
|
||||||
_comp: Optional[int]
|
_comp: Optional[int]
|
||||||
@@ -349,13 +392,20 @@ class ImageInfo:
|
|||||||
|
|
||||||
if self._color == Colorspace.icc:
|
if self._color == Colorspace.icc:
|
||||||
# Check the ICC profile to determine actual colorspace
|
# Check the ICC profile to determine actual colorspace
|
||||||
pim_icc = pim.icc
|
try:
|
||||||
if pim_icc.profile.xcolor_space == 'GRAY':
|
pim_icc = pim.icc
|
||||||
self._comp = 1
|
if pim_icc.profile.xcolor_space == 'GRAY':
|
||||||
elif pim_icc.profile.xcolor_space == 'CMYK':
|
self._comp = 1
|
||||||
self._comp = 4
|
elif pim_icc.profile.xcolor_space == 'CMYK':
|
||||||
else:
|
self._comp = 4
|
||||||
self._comp = 3
|
else:
|
||||||
|
self._comp = 3
|
||||||
|
except UnsupportedImageTypeError as ex:
|
||||||
|
self._comp = None
|
||||||
|
logger.warning(
|
||||||
|
f"An image with a corrupt or unreadable ICC profile was found. "
|
||||||
|
f"The output PDF may not match the input PDF visually: {ex}. {self}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if isinstance(self._color, Colorspace):
|
if isinstance(self._color, Colorspace):
|
||||||
self._comp = FRIENDLY_COMP.get(self._color)
|
self._comp = FRIENDLY_COMP.get(self._color)
|
||||||
@@ -408,15 +458,10 @@ class ImageInfo:
|
|||||||
return _get_dpi(self._shorthand, (self._width, self._height))
|
return _get_dpi(self._shorthand, (self._width, self._height))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
class_locals = {
|
|
||||||
attr: getattr(self, attr, None)
|
|
||||||
for attr in dir(self)
|
|
||||||
if not attr.startswith('_')
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
"<ImageInfo '{name}' {type_} {width}x{height} {color} "
|
f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} "
|
||||||
"{comp} {bpc} {enc} {dpi}>"
|
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
|
||||||
).format(**class_locals)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
||||||
@@ -424,7 +469,7 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
|||||||
|
|
||||||
for n, inline in enumerate(contentsinfo.inline_images):
|
for n, inline in enumerate(contentsinfo.inline_images):
|
||||||
yield ImageInfo(
|
yield ImageInfo(
|
||||||
name='inline-%02d' % n, shorthand=inline.shorthand, inline=inline.iimage
|
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -565,10 +610,10 @@ def _process_content_streams(
|
|||||||
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
||||||
|
|
||||||
|
|
||||||
def _page_has_text(text_blocks, 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)
|
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
||||||
|
|
||||||
margin_ratio = 0.125
|
margin_ratio = 0.125
|
||||||
interior_bbox = (
|
interior_bbox = (
|
||||||
@@ -578,7 +623,7 @@ def _page_has_text(text_blocks, page_width, page_height) -> bool:
|
|||||||
margin_ratio * ph, # bottom (first quadrant: bottom < top)
|
margin_ratio * ph, # bottom (first quadrant: bottom < top)
|
||||||
)
|
)
|
||||||
|
|
||||||
def rects_intersect(a, b) -> bool:
|
def rects_intersect(a: FloatRect, b: FloatRect) -> bool:
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -600,19 +645,19 @@ def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]:
|
|||||||
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.
|
||||||
"""
|
"""
|
||||||
for box in textbox_getter(miner):
|
for box in textbox_getter(miner):
|
||||||
first_line = box._objs[0]
|
first_line = box._objs[0] # pylint: disable=protected-access
|
||||||
first_char = first_line._objs[0]
|
first_char = first_line._objs[0] # pylint: disable=protected-access
|
||||||
|
|
||||||
visible = first_char.rendermode != 3
|
visible = first_char.rendermode != 3
|
||||||
corrupt = first_char.get_text() == '\ufffd'
|
corrupt = first_char.get_text() == '\ufffd'
|
||||||
yield TextboxInfo(box.bbox, visible, corrupt)
|
yield TextboxInfo(box.bbox, visible, corrupt)
|
||||||
|
|
||||||
|
|
||||||
worker_pdf = None
|
worker_pdf = None # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
|
def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
|
||||||
global worker_pdf # pylint: disable=global-statement
|
global worker_pdf # pylint: disable=global-statement,invalid-name
|
||||||
pikepdf_enable_mmap()
|
pikepdf_enable_mmap()
|
||||||
|
|
||||||
logging.getLogger('pdfminer').setLevel(pdfminer_loglevel)
|
logging.getLogger('pdfminer').setLevel(pdfminer_loglevel)
|
||||||
@@ -646,8 +691,8 @@ def _pdf_pageinfo_concurrent(
|
|||||||
max_workers,
|
max_workers,
|
||||||
check_pages,
|
check_pages,
|
||||||
detailed_analysis=False,
|
detailed_analysis=False,
|
||||||
):
|
) -> Sequence[Optional['PageInfo']]:
|
||||||
pages = [None] * len(pdf.pages)
|
pages: Sequence[Optional['PageInfo']] = [None] * len(pdf.pages)
|
||||||
|
|
||||||
def update_pageinfo(result, pbar):
|
def update_pageinfo(result, pbar):
|
||||||
page = result
|
page = result
|
||||||
@@ -697,6 +742,8 @@ def _pdf_pageinfo_concurrent(
|
|||||||
|
|
||||||
|
|
||||||
class PageInfo:
|
class PageInfo:
|
||||||
|
"""Information about type of contents on each page in a PDF."""
|
||||||
|
|
||||||
_has_text: Optional[bool]
|
_has_text: Optional[bool]
|
||||||
_has_vector: Optional[bool]
|
_has_vector: Optional[bool]
|
||||||
_images: List[ImageInfo]
|
_images: List[ImageInfo]
|
||||||
@@ -758,15 +805,15 @@ class PageInfo:
|
|||||||
self._has_vector = False
|
self._has_vector = False
|
||||||
self._has_text = False
|
self._has_text = False
|
||||||
self._images = []
|
self._images = []
|
||||||
for ci in _process_content_streams(
|
for info in _process_content_streams(
|
||||||
pdf=pdf, container=page, shorthand=userunit_shorthand
|
pdf=pdf, container=page, shorthand=userunit_shorthand
|
||||||
):
|
):
|
||||||
if isinstance(ci, VectorMarker):
|
if isinstance(info, VectorMarker):
|
||||||
self._has_vector = True
|
self._has_vector = True
|
||||||
elif isinstance(ci, TextMarker):
|
elif isinstance(info, TextMarker):
|
||||||
self._has_text = True
|
self._has_text = True
|
||||||
elif isinstance(ci, ImageInfo):
|
elif isinstance(info, ImageInfo):
|
||||||
self._images.append(ci)
|
self._images.append(info)
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
else:
|
else:
|
||||||
@@ -918,17 +965,17 @@ class PdfInfo:
|
|||||||
self._has_acroform = True
|
self._has_acroform = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pages(self):
|
def pages(self) -> Sequence[Optional[PageInfo]]:
|
||||||
return self._pages
|
return self._pages
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_version(self) -> str:
|
def min_version(self) -> str:
|
||||||
# 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)
|
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 any(page.userunit != 1.0 for page in self.pages)
|
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:
|
||||||
|
|||||||
@@ -4,15 +4,25 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""OCRmyPDF pluggy plugin specification."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from argparse import ArgumentParser, Namespace
|
from argparse import ArgumentParser, Namespace
|
||||||
from logging import Handler
|
from logging import Handler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, AbstractSet, List, NamedTuple, Optional
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
AbstractSet,
|
||||||
|
List,
|
||||||
|
NamedTuple,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
|
|
||||||
|
from ocrmypdf import PdfContext
|
||||||
from ocrmypdf._concurrent import Executor
|
from ocrmypdf._concurrent import Executor
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
|
|
||||||
@@ -132,6 +142,7 @@ 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:
|
||||||
...
|
...
|
||||||
@@ -235,9 +246,9 @@ def filter_page_image(page: 'PageContext', image_filename: Path) -> Path:
|
|||||||
``image_filename``. The hook may overwrite ``image_filename`` with a new file.
|
``image_filename``. The hook may overwrite ``image_filename`` with a new file.
|
||||||
|
|
||||||
The output image should preserve the same physical unit dimensions, that is
|
The output image should preserve the same physical unit dimensions, that is
|
||||||
(width * dpi_x, height * dpi_y). That is, if the image is resized, the DPI
|
``(width * dpi_x, height * dpi_y)``. That is, if the image is resized, the DPI
|
||||||
must be adjusted by the reciprocal. If this is not preserved, the PDF page
|
must be adjusted by the reciprocal. If this is not preserved, the PDF page
|
||||||
will be resized and the OCR layer misaligned. OCRmyPDF does not nothing
|
will be resized and the OCR layer misaligned. OCRmyPDF does nothing
|
||||||
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
|
||||||
@@ -399,8 +410,7 @@ def get_ocr_engine() -> OcrEngine:
|
|||||||
"""Returns an OcrEngine to use for processing this file.
|
"""Returns an OcrEngine to use for processing this file.
|
||||||
|
|
||||||
The OcrEngine may be instantiated multiple times, by both the main process
|
The OcrEngine may be instantiated multiple times, by both the main process
|
||||||
and child process. As such, it must be obtain store any state in ``options``
|
and child process.
|
||||||
or some common location.
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
@@ -456,3 +466,60 @@ def generate_pdfa(
|
|||||||
See also:
|
See also:
|
||||||
https://github.com/tqdm/tqdm
|
https://github.com/tqdm/tqdm
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec(firstresult=True)
|
||||||
|
def optimize_pdf(
|
||||||
|
input_pdf: Path,
|
||||||
|
output_pdf: Path,
|
||||||
|
context: PdfContext,
|
||||||
|
executor: Executor,
|
||||||
|
linearize: bool,
|
||||||
|
) -> Tuple[Path, Sequence[str]]:
|
||||||
|
"""Optimize a PDF after image, OCR and metadata processing.
|
||||||
|
|
||||||
|
If the input_pdf is a PDF/A, the plugin should modify input_pdf in a way
|
||||||
|
that preserves the PDF/A status, or report to the user when this is not possible.
|
||||||
|
|
||||||
|
If the implementation fails to produce a smaller file than the input file, it
|
||||||
|
should return input_pdf instead.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
input_pdf: The input PDF, which has OCR added.
|
||||||
|
output_pdf: The requested filename of the output PDF which should be created
|
||||||
|
by this optimization hook.
|
||||||
|
context: The current context.
|
||||||
|
executor: An initialized executor which may be used during optimization,
|
||||||
|
to distribute optimization tasks.
|
||||||
|
linearize: If True, OCRmyPDF requires ``optimize_pdf`` to return a linearized,
|
||||||
|
also known as fast web view PDF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: If optimization is successful, the hook should return ``output_file``.
|
||||||
|
If optimization does not produce a smaller file, the hook should return
|
||||||
|
``input_file``.
|
||||||
|
Sequence[str]: Any comments that the plugin wishes to report to the user,
|
||||||
|
especially reasons it was not able to further optimize the file. For
|
||||||
|
example, the plugin could report that a required third party was not
|
||||||
|
installed, so a specific optimization was not attempted.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec(firstresult=True)
|
||||||
|
def is_optimization_enabled(context: PdfContext) -> bool:
|
||||||
|
"""For a given PdfContext, OCRmyPDF asks the plugin if optimization is enabled.
|
||||||
|
|
||||||
|
It is assumed that an optimization plugin might be installed but could be
|
||||||
|
disabled by user settings.
|
||||||
|
|
||||||
|
If this returns False, OCRmyPDF will take certain actions to finalize the PDF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the plugin's optimization is enabled.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
args, *, env=None, logs_errors_to_stdout: bool = False, **kwargs
|
args,
|
||||||
|
*,
|
||||||
|
env=None,
|
||||||
|
logs_errors_to_stdout: bool = False,
|
||||||
|
check: bool = False,
|
||||||
|
**kwargs,
|
||||||
) -> CompletedProcess:
|
) -> CompletedProcess:
|
||||||
"""Wrapper around :py:func:`subprocess.run`
|
"""Wrapper around :py:func:`subprocess.run`
|
||||||
|
|
||||||
@@ -50,7 +55,7 @@ def run(
|
|||||||
stderr = None
|
stderr = None
|
||||||
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
|
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
|
||||||
try:
|
try:
|
||||||
proc = subprocess_run(args, env=env, **kwargs)
|
proc = subprocess_run(args, env=env, check=check, **kwargs)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
stderr = getattr(e, stderr_name, None)
|
stderr = getattr(e, stderr_name, None)
|
||||||
raise
|
raise
|
||||||
@@ -111,6 +116,7 @@ def _fix_process_args(args, env, kwargs):
|
|||||||
program = str(args[0])
|
program = str(args[0])
|
||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
from ocrmypdf.subprocess._windows import fix_windows_args
|
from ocrmypdf.subprocess._windows import fix_windows_args
|
||||||
|
|
||||||
args = fix_windows_args(program, args, env)
|
args = fix_windows_args(program, args, env)
|
||||||
@@ -171,42 +177,42 @@ def get_version(
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
missing_program = '''
|
MISSING_PROGRAM = '''
|
||||||
The program '{program}' could not be executed or was not found on your
|
The program '{program}' could not be executed or was not found on your
|
||||||
system PATH.
|
system PATH.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
missing_optional_program = '''
|
MISSING_OPTIONAL_PROGRAM = '''
|
||||||
The program '{program}' could not be executed or was not found on your
|
The program '{program}' could not be executed or was not found on your
|
||||||
system PATH. This program is required when you use the
|
system PATH. This program is required when you use the
|
||||||
{required_for} arguments. You could try omitting these arguments, or install
|
{required_for} arguments. You could try omitting these arguments, or install
|
||||||
the package.
|
the package.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
missing_recommend_program = '''
|
MISSING_RECOMMEND_PROGRAM = '''
|
||||||
The program '{program}' could not be executed or was not found on your
|
The program '{program}' could not be executed or was not found on your
|
||||||
system PATH. This program is recommended when using the {required_for} arguments,
|
system PATH. This program is recommended when using the {required_for} arguments,
|
||||||
but not required, so we will proceed. For best results, install the program.
|
but not required, so we will proceed. For best results, install the program.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
old_version = '''
|
OLD_VERSION = '''
|
||||||
OCRmyPDF requires '{program}' {need_version} or higher. Your system appears
|
OCRmyPDF requires '{program}' {need_version} or higher. Your system appears
|
||||||
to have {found_version}. Please update this program.
|
to have {found_version}. Please update this program.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
old_version_required_for = '''
|
OLD_VERSION_REQUIRED_FOR = '''
|
||||||
OCRmyPDF requires '{program}' {need_version} or higher when run with the
|
OCRmyPDF requires '{program}' {need_version} or higher when run with the
|
||||||
{required_for} arguments. If you omit these arguments, OCRmyPDF may be able to
|
{required_for} arguments. If you omit these arguments, OCRmyPDF may be able to
|
||||||
proceed. For best results, install the program.
|
proceed. For best results, install the program.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
osx_install_advice = '''
|
OSX_INSTALL_ADVICE = '''
|
||||||
If you have homebrew installed, try these command to install the missing
|
If you have homebrew installed, try these command to install the missing
|
||||||
package:
|
package:
|
||||||
brew install {package}
|
brew install {package}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
linux_install_advice = '''
|
LINUX_INSTALL_ADVICE = '''
|
||||||
On systems with the aptitude package manager (Debian, Ubuntu), try these
|
On systems with the aptitude package manager (Debian, Ubuntu), try these
|
||||||
commands:
|
commands:
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
@@ -216,7 +222,7 @@ On RPM-based systems (Red Hat, Fedora), search for instructions on
|
|||||||
installing the RPM for {program}.
|
installing the RPM for {program}.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
windows_install_advice = '''
|
WINDOWS_INSTALL_ADVICE = '''
|
||||||
If not already installed, install the Chocolatey package manager. Then use
|
If not already installed, install the Chocolatey package manager. Then use
|
||||||
a command prompt to install the missing package:
|
a command prompt to install the missing package:
|
||||||
choco install {package}
|
choco install {package}
|
||||||
@@ -234,35 +240,47 @@ def _get_platform():
|
|||||||
|
|
||||||
|
|
||||||
def _error_trailer(program, package, **kwargs):
|
def _error_trailer(program, package, **kwargs):
|
||||||
|
del kwargs
|
||||||
if isinstance(package, Mapping):
|
if isinstance(package, Mapping):
|
||||||
package = package.get(_get_platform(), program)
|
package = package.get(_get_platform(), program)
|
||||||
|
|
||||||
if _get_platform() == 'darwin':
|
if _get_platform() == 'darwin':
|
||||||
log.info(osx_install_advice.format(**locals()))
|
log.info(OSX_INSTALL_ADVICE.format(**locals()))
|
||||||
elif _get_platform() == 'linux':
|
elif _get_platform() == 'linux':
|
||||||
log.info(linux_install_advice.format(**locals()))
|
log.info(LINUX_INSTALL_ADVICE.format(**locals()))
|
||||||
elif _get_platform() == 'windows':
|
elif _get_platform() == 'windows':
|
||||||
log.info(windows_install_advice.format(**locals()))
|
log.info(WINDOWS_INSTALL_ADVICE.format(**locals()))
|
||||||
|
|
||||||
|
|
||||||
def _error_missing_program(program, package, required_for, recommended):
|
def _error_missing_program(program, package, required_for, recommended):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
if recommended:
|
if recommended:
|
||||||
log.warning(missing_recommend_program.format(**locals()))
|
log.warning(MISSING_RECOMMEND_PROGRAM.format(**locals()))
|
||||||
elif required_for:
|
elif required_for:
|
||||||
log.error(missing_optional_program.format(**locals()))
|
log.error(MISSING_OPTIONAL_PROGRAM.format(**locals()))
|
||||||
else:
|
else:
|
||||||
log.error(missing_program.format(**locals()))
|
log.error(MISSING_PROGRAM.format(**locals()))
|
||||||
_error_trailer(**locals())
|
_error_trailer(**locals())
|
||||||
|
|
||||||
|
|
||||||
def _error_old_version(program, package, need_version, found_version, required_for):
|
def _error_old_version(program, package, need_version, found_version, required_for):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
if required_for:
|
if required_for:
|
||||||
log.error(old_version_required_for.format(**locals()))
|
log.error(OLD_VERSION_REQUIRED_FOR.format(**locals()))
|
||||||
else:
|
else:
|
||||||
log.error(old_version.format(**locals()))
|
log.error(OLD_VERSION.format(**locals()))
|
||||||
_error_trailer(**locals())
|
_error_trailer(**locals())
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_leading_v(s):
|
||||||
|
if sys.version_info >= (3, 9):
|
||||||
|
return s.removeprefix('v')
|
||||||
|
|
||||||
|
if s.startswith('v'):
|
||||||
|
return s[1:]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def check_external_program(
|
def check_external_program(
|
||||||
*,
|
*,
|
||||||
program: str,
|
program: str,
|
||||||
@@ -294,19 +312,19 @@ def check_external_program(
|
|||||||
found_version = version_checker()
|
found_version = version_checker()
|
||||||
else: # deprecated
|
else: # deprecated
|
||||||
found_version = version_checker
|
found_version = version_checker
|
||||||
except (CalledProcessError, FileNotFoundError, MissingDependencyError):
|
except (CalledProcessError, FileNotFoundError) as e:
|
||||||
_error_missing_program(program, package, required_for, recommended)
|
_error_missing_program(program, package, required_for, recommended)
|
||||||
if not recommended:
|
if not recommended:
|
||||||
raise MissingDependencyError(program)
|
raise MissingDependencyError(program) from e
|
||||||
|
return
|
||||||
|
except MissingDependencyError:
|
||||||
|
_error_missing_program(program, package, required_for, recommended)
|
||||||
|
if not recommended:
|
||||||
|
raise
|
||||||
return
|
return
|
||||||
|
|
||||||
def remove_leading_v(s):
|
found_version = _remove_leading_v(found_version)
|
||||||
if s.startswith('v'):
|
need_version = _remove_leading_v(need_version)
|
||||||
return s[1:]
|
|
||||||
return s
|
|
||||||
|
|
||||||
found_version = remove_leading_v(found_version)
|
|
||||||
need_version = remove_leading_v(need_version)
|
|
||||||
|
|
||||||
if found_version and version_parser(found_version) < version_parser(need_version):
|
if found_version and version_parser(found_version) < version_parser(need_version):
|
||||||
_error_old_version(program, package, need_version, found_version, required_for)
|
_error_old_version(program, package, need_version, found_version, required_for)
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
# type: ignore
|
||||||
|
# Non-Windows mypy now breaks when trying to typecheck winreg
|
||||||
|
|
||||||
|
"""Find Tesseract and Ghostscript binaries on Windows using the registry."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -14,8 +19,8 @@ from typing import Any, Callable, Iterable, Iterator, Set, Tuple, TypeVar
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import winreg
|
import winreg
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as _notfound_ex:
|
||||||
raise ModuleNotFoundError("This module is for Windows only") from e
|
raise ModuleNotFoundError("This module is for Windows only") from _notfound_ex
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,15 +41,15 @@ def ghostscript_version_key(s: str) -> Tuple[int, int, int]:
|
|||||||
def registry_enum(
|
def registry_enum(
|
||||||
key: winreg.HKEYType, enum_fn: Callable[[winreg.HKEYType, int], T]
|
key: winreg.HKEYType, enum_fn: Callable[[winreg.HKEYType, int], T]
|
||||||
) -> Iterator[T]:
|
) -> Iterator[T]:
|
||||||
LIMIT = 999
|
limit = 999
|
||||||
n = 0
|
n = 0
|
||||||
while n < LIMIT:
|
while n < limit:
|
||||||
try:
|
try:
|
||||||
yield enum_fn(key, n)
|
yield enum_fn(key, n)
|
||||||
n += 1
|
n += 1
|
||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
if n == LIMIT:
|
if n == limit:
|
||||||
raise ValueError(f"Too many registry keys under {key}")
|
raise ValueError(f"Too many registry keys under {key}")
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +62,7 @@ def registry_values(key: winreg.HKEYType) -> Iterator[Tuple[str, Any, int]]:
|
|||||||
|
|
||||||
|
|
||||||
def registry_path_ghostscript(env=None) -> Iterator[Path]:
|
def registry_path_ghostscript(env=None) -> Iterator[Path]:
|
||||||
|
del env # unused (but needed for protocol)
|
||||||
try:
|
try:
|
||||||
with winreg.OpenKey(
|
with winreg.OpenKey(
|
||||||
winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Artifex\GPL Ghostscript"
|
winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Artifex\GPL Ghostscript"
|
||||||
@@ -74,6 +80,7 @@ def registry_path_ghostscript(env=None) -> Iterator[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def registry_path_tesseract(env=None) -> Iterator[Path]:
|
def registry_path_tesseract(env=None) -> Iterator[Path]:
|
||||||
|
del env # unused (but needed for protocol)
|
||||||
try:
|
try:
|
||||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Tesseract-OCR") as k:
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Tesseract-OCR") as k:
|
||||||
for subkey, val, _valtype in registry_values(k):
|
for subkey, val, _valtype in registry_values(k):
|
||||||
|
|||||||
+1
-2
@@ -134,8 +134,7 @@ def run_ocrmypdf(
|
|||||||
|
|
||||||
p = run(
|
p = run(
|
||||||
p_args,
|
p_args,
|
||||||
stdout=PIPE,
|
capture_output=True,
|
||||||
stderr=PIPE,
|
|
||||||
text=text,
|
text=text,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import logging
|
|||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, CalledProcessError, CompletedProcess
|
from subprocess import PIPE, CalledProcessError, CompletedProcess
|
||||||
@@ -176,26 +177,40 @@ def cached_run(options, run_args, **run_kwargs):
|
|||||||
|
|
||||||
|
|
||||||
class CacheOcrEngine(TesseractOcrEngine):
|
class CacheOcrEngine(TesseractOcrEngine):
|
||||||
|
# Concurrent threads (with --use-threads) might try to use different parts
|
||||||
|
# of the OcrEngine, so we need a lock to protect the state of patched
|
||||||
|
# module whenever it's patched. Should refactor ocrmypdf._exec.tesseract so that
|
||||||
|
# it does not to be patched at all for testing.
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_orientation(input_file, options):
|
def get_orientation(input_file, options):
|
||||||
with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
|
with CacheOcrEngine.lock, patch(
|
||||||
|
'ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)
|
||||||
|
):
|
||||||
return TesseractOcrEngine.get_orientation(input_file, options)
|
return TesseractOcrEngine.get_orientation(input_file, options)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_deskew(input_file, options) -> float:
|
def get_deskew(input_file, options) -> float:
|
||||||
with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
|
with CacheOcrEngine.lock, patch(
|
||||||
|
'ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)
|
||||||
|
):
|
||||||
return TesseractOcrEngine.get_deskew(input_file, options)
|
return TesseractOcrEngine.get_deskew(input_file, options)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_hocr(input_file, output_hocr, output_text, options):
|
def generate_hocr(input_file, output_hocr, output_text, options):
|
||||||
with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
|
with CacheOcrEngine.lock, patch(
|
||||||
|
'ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)
|
||||||
|
):
|
||||||
TesseractOcrEngine.generate_hocr(
|
TesseractOcrEngine.generate_hocr(
|
||||||
input_file, output_hocr, output_text, options
|
input_file, output_hocr, output_text, options
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_pdf(input_file, output_pdf, output_text, options):
|
def generate_pdf(input_file, output_pdf, output_text, options):
|
||||||
with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
|
with CacheOcrEngine.lock, patch(
|
||||||
|
'ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)
|
||||||
|
):
|
||||||
TesseractOcrEngine.generate_pdf(
|
TesseractOcrEngine.generate_pdf(
|
||||||
input_file, output_pdf, output_text, options
|
input_file, output_pdf, output_text, options
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ def test_fish():
|
|||||||
['fish', '-n', 'misc/completion/ocrmypdf.fish'],
|
['fish', '-n', 'misc/completion/ocrmypdf.fish'],
|
||||||
check=True,
|
check=True,
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
stdout=PIPE,
|
capture_output=True,
|
||||||
stderr=PIPE,
|
|
||||||
)
|
)
|
||||||
assert proc.stderr == '', proc.stderr
|
assert proc.stderr == '', proc.stderr
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -41,8 +40,7 @@ def test_bash():
|
|||||||
['bash', '-n', 'misc/completion/ocrmypdf.bash'],
|
['bash', '-n', 'misc/completion/ocrmypdf.bash'],
|
||||||
check=True,
|
check=True,
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
stdout=PIPE,
|
capture_output=True,
|
||||||
stderr=PIPE,
|
|
||||||
)
|
)
|
||||||
assert proc.stderr == '', proc.stderr
|
assert proc.stderr == '', proc.stderr
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from ocrmypdf import ExitCode
|
|||||||
from .conftest import run_ocrmypdf_api
|
from .conftest import run_ocrmypdf_api
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(True, reason="--use-threads is currently default")
|
||||||
@pytest.mark.skipif(os.name == 'nt', reason="Windows doesn't have SIGKILL")
|
@pytest.mark.skipif(os.name == 'nt', reason="Windows doesn't have SIGKILL")
|
||||||
def test_simulate_oom_killer(resources, no_outpdf):
|
def test_simulate_oom_killer(resources, no_outpdf):
|
||||||
exitcode = run_ocrmypdf_api(
|
exitcode = run_ocrmypdf_api(
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
||||||
from ocrmypdf.exceptions import ExitCode
|
from ocrmypdf.exceptions import ExitCode
|
||||||
@@ -124,3 +126,20 @@ def test_ghostscript_feature_elision(resources, outpdf):
|
|||||||
'--plugin',
|
'--plugin',
|
||||||
'tests/plugins/gs_feature_elision.py',
|
'tests/plugins/gs_feature_elision.py',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
||||||
|
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
||||||
|
# ghostscript can produce
|
||||||
|
mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
||||||
|
)
|
||||||
|
with pytest.raises(UnidentifiedImageError):
|
||||||
|
rasterize_pdf(
|
||||||
|
resources / 'francais.pdf',
|
||||||
|
no_outpdf,
|
||||||
|
raster_device='pngmono',
|
||||||
|
raster_dpi=Resolution(100, 100),
|
||||||
|
)
|
||||||
|
assert "this is an error" in caplog.text
|
||||||
|
assert "invalid page image file" in caplog.text
|
||||||
|
|||||||
+52
-15
@@ -465,12 +465,18 @@ def test_overlay(resources, outpdf):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_destination_not_writable(resources, outdir):
|
@pytest.fixture
|
||||||
if os.name != 'nt' and (os.getuid() == 0 or os.geteuid() == 0):
|
def protected_file(outdir):
|
||||||
pytest.xfail(reason="root can write to anything")
|
|
||||||
protected_file = outdir / 'protected.pdf'
|
protected_file = outdir / 'protected.pdf'
|
||||||
protected_file.touch()
|
protected_file.touch()
|
||||||
protected_file.chmod(0o400) # Read-only
|
protected_file.chmod(0o400) # Read-only
|
||||||
|
yield protected_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
os.name == 'nt' or os.geteuid() == 0, reason="root can write to anything"
|
||||||
|
)
|
||||||
|
def test_destination_not_writable(resources, protected_file):
|
||||||
p = run_ocrmypdf(
|
p = run_ocrmypdf(
|
||||||
resources / 'jbig2.pdf',
|
resources / 'jbig2.pdf',
|
||||||
protected_file,
|
protected_file,
|
||||||
@@ -480,7 +486,8 @@ def test_destination_not_writable(resources, outdir):
|
|||||||
assert p.returncode == ExitCode.file_access_error, "Expected error"
|
assert p.returncode == ExitCode.file_access_error, "Expected error"
|
||||||
|
|
||||||
|
|
||||||
def test_tesseract_config_valid(resources, outdir):
|
@pytest.fixture
|
||||||
|
def valid_tess_config(outdir):
|
||||||
cfg_file = outdir / 'test.cfg'
|
cfg_file = outdir / 'test.cfg'
|
||||||
with cfg_file.open('w') as f:
|
with cfg_file.open('w') as f:
|
||||||
f.write(
|
f.write(
|
||||||
@@ -490,20 +497,22 @@ language_model_penalty_non_dict_word 0
|
|||||||
language_model_penalty_non_freq_dict_word 0
|
language_model_penalty_non_freq_dict_word 0
|
||||||
'''
|
'''
|
||||||
)
|
)
|
||||||
|
yield cfg_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_tesseract_config_valid(resources, valid_tess_config, outpdf):
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / '3small.pdf',
|
resources / '3small.pdf',
|
||||||
outdir / 'out.pdf',
|
outpdf,
|
||||||
'--tesseract-config',
|
'--tesseract-config',
|
||||||
cfg_file,
|
valid_tess_config,
|
||||||
'--pages',
|
'--pages',
|
||||||
'1',
|
'1',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow # This test sometimes times out in CI
|
@pytest.fixture
|
||||||
@pytest.mark.parametrize('renderer', RENDERERS)
|
def invalid_tess_config(outdir):
|
||||||
def test_tesseract_config_invalid(renderer, resources, outdir):
|
|
||||||
cfg_file = outdir / 'test.cfg'
|
cfg_file = outdir / 'test.cfg'
|
||||||
with cfg_file.open('w') as f:
|
with cfg_file.open('w') as f:
|
||||||
f.write(
|
f.write(
|
||||||
@@ -511,14 +520,19 @@ def test_tesseract_config_invalid(renderer, resources, outdir):
|
|||||||
THIS FILE IS INVALID
|
THIS FILE IS INVALID
|
||||||
'''
|
'''
|
||||||
)
|
)
|
||||||
|
yield cfg_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow # This test sometimes times out in CI
|
||||||
|
@pytest.mark.parametrize('renderer', RENDERERS)
|
||||||
|
def test_tesseract_config_invalid(renderer, resources, invalid_tess_config, outpdf):
|
||||||
p = run_ocrmypdf(
|
p = run_ocrmypdf(
|
||||||
resources / 'ccitt.pdf',
|
resources / 'ccitt.pdf',
|
||||||
outdir / 'out.pdf',
|
outpdf,
|
||||||
'--pdf-renderer',
|
'--pdf-renderer',
|
||||||
renderer,
|
renderer,
|
||||||
'--tesseract-config',
|
'--tesseract-config',
|
||||||
cfg_file,
|
invalid_tess_config,
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
"parameter not found" in p.stderr.lower()
|
"parameter not found" in p.stderr.lower()
|
||||||
@@ -630,8 +644,7 @@ def test_compression_preserved(ocrmypdf_exec, resources, image, outpdf):
|
|||||||
]
|
]
|
||||||
p = run(
|
p = run(
|
||||||
p_args,
|
p_args,
|
||||||
stdout=PIPE,
|
capture_output=True,
|
||||||
stderr=PIPE,
|
|
||||||
stdin=input_stream,
|
stdin=input_stream,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
@@ -691,8 +704,7 @@ def test_compression_changed(ocrmypdf_exec, resources, image, compression, outpd
|
|||||||
]
|
]
|
||||||
p = run(
|
p = run(
|
||||||
p_args,
|
p_args,
|
||||||
stdout=PIPE,
|
capture_output=True,
|
||||||
stderr=PIPE,
|
|
||||||
stdin=input_stream,
|
stdin=input_stream,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
@@ -803,6 +815,9 @@ def test_text_curves(resources, outpdf):
|
|||||||
info = PdfInfo(outpdf)
|
info = PdfInfo(outpdf)
|
||||||
assert len(info.pages[0].images) == 0, "added images to the vector PDF"
|
assert len(info.pages[0].images) == 0, "added images to the vector PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_curves_force(resources, outpdf):
|
||||||
|
with patch('ocrmypdf._pipeline.VECTOR_PAGE_DPI', 100):
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / 'vector.pdf',
|
resources / 'vector.pdf',
|
||||||
outpdf,
|
outpdf,
|
||||||
@@ -924,3 +939,25 @@ def test_outputtype_none(resources, outtxt):
|
|||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
assert p.returncode == ExitCode.ok
|
assert p.returncode == ExitCode.ok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def graph_bad_icc(resources, outdir):
|
||||||
|
synth_input_file = outdir / 'graph-bad-icc.pdf'
|
||||||
|
with pikepdf.open(resources / 'graph.pdf') as pdf:
|
||||||
|
icc = pdf.make_stream(
|
||||||
|
b'invalid icc profile', N=3, Alternate=pikepdf.Name.DeviceRGB
|
||||||
|
)
|
||||||
|
pdf.pages[0].Resources.XObject['/Im0'].ColorSpace = pikepdf.Array(
|
||||||
|
[pikepdf.Name.ICCBased, icc]
|
||||||
|
)
|
||||||
|
pdf.save(synth_input_file)
|
||||||
|
yield synth_input_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_icc(graph_bad_icc, outpdf, caplog):
|
||||||
|
result = run_ocrmypdf_api(graph_bad_icc, outpdf)
|
||||||
|
assert result == ExitCode.ok
|
||||||
|
assert any(
|
||||||
|
'corrupt or unreadable ICC profile' in rec.message for rec in caplog.records
|
||||||
|
)
|
||||||
|
|||||||
+26
-14
@@ -6,10 +6,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import warnings
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from os import fspath
|
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import pytest
|
import pytest
|
||||||
@@ -17,7 +16,7 @@ from pikepdf.models.metadata import decode_pdf_date
|
|||||||
|
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf._pipeline import convert_to_pdfa, metadata_fixup
|
from ocrmypdf._pipeline import convert_to_pdfa, metadata_fixup
|
||||||
from ocrmypdf._plugin_manager import get_plugin_manager
|
from ocrmypdf._plugin_manager import get_parser_options_plugins, get_plugin_manager
|
||||||
from ocrmypdf.cli import get_parser
|
from ocrmypdf.cli import get_parser
|
||||||
from ocrmypdf.exceptions import ExitCode
|
from ocrmypdf.exceptions import ExitCode
|
||||||
from ocrmypdf.pdfa import file_claims_pdfa, generate_pdfa_ps
|
from ocrmypdf.pdfa import file_claims_pdfa, generate_pdfa_ps
|
||||||
@@ -173,6 +172,19 @@ def test_creation_date_preserved(output_type, resources, infile, outpdf):
|
|||||||
assert seconds_between_dates(date_after, datetime.datetime.now(timezone.utc)) < 1000
|
assert seconds_between_dates(date_after, datetime.datetime.now(timezone.utc)) < 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def libxmp_file_to_dict():
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
from libxmp.utils import (
|
||||||
|
file_to_dict, # pylint: disable=import-outside-toplevel
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
pytest.skip("libxmp not available or libexempi3 not installed")
|
||||||
|
return file_to_dict
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'test_file,output_type',
|
'test_file,output_type',
|
||||||
[
|
[
|
||||||
@@ -182,15 +194,12 @@ def test_creation_date_preserved(output_type, resources, infile, outpdf):
|
|||||||
('3small.pdf', 'pdfa'),
|
('3small.pdf', 'pdfa'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_xml_metadata_preserved(test_file, output_type, resources, outpdf):
|
def test_xml_metadata_preserved(
|
||||||
|
libxmp_file_to_dict, test_file, output_type, resources, outpdf
|
||||||
|
):
|
||||||
input_file = resources / test_file
|
input_file = resources / test_file
|
||||||
|
|
||||||
try:
|
before = libxmp_file_to_dict(str(input_file))
|
||||||
from libxmp.utils import file_to_dict # pylint: disable=import-outside-toplevel
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
pytest.skip("libxmp not available or libexempi3 not installed")
|
|
||||||
|
|
||||||
before = file_to_dict(str(input_file))
|
|
||||||
|
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
input_file,
|
input_file,
|
||||||
@@ -202,7 +211,7 @@ def test_xml_metadata_preserved(test_file, output_type, resources, outpdf):
|
|||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
|
|
||||||
after = file_to_dict(str(outpdf))
|
after = libxmp_file_to_dict(str(outpdf))
|
||||||
|
|
||||||
equal_properties = [
|
equal_properties = [
|
||||||
'dc:contributor',
|
'dc:contributor',
|
||||||
@@ -290,8 +299,8 @@ def test_kodak_toc(resources, outpdf):
|
|||||||
|
|
||||||
|
|
||||||
def test_metadata_fixup_warning(resources, outdir, caplog):
|
def test_metadata_fixup_warning(resources, outdir, caplog):
|
||||||
options = get_parser().parse_args(
|
_parser, options, _pm = get_parser_options_plugins(
|
||||||
args=['--output-type', 'pdfa-2', 'graph.pdf', 'out.pdf']
|
['--output-type', 'pdfa-2', 'graph.pdf', 'out.pdf']
|
||||||
)
|
)
|
||||||
|
|
||||||
copyfile(resources / 'graph.pdf', outdir / 'graph.pdf')
|
copyfile(resources / 'graph.pdf', outdir / 'graph.pdf')
|
||||||
@@ -316,6 +325,9 @@ def test_metadata_fixup_warning(resources, outdir, caplog):
|
|||||||
assert any(record.levelname == 'WARNING' for record in caplog.records)
|
assert any(record.levelname == 'WARNING' for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
XMP_MAGIC = b'W5M0MpCehiHzreSzNTczkc9d'
|
||||||
|
|
||||||
|
|
||||||
def test_prevent_gs_invalid_xml(resources, outdir):
|
def test_prevent_gs_invalid_xml(resources, outdir):
|
||||||
generate_pdfa_ps(outdir / 'pdfa.ps')
|
generate_pdfa_ps(outdir / 'pdfa.ps')
|
||||||
copyfile(resources / 'trivial.pdf', outdir / 'layers.rendered.pdf')
|
copyfile(resources / 'trivial.pdf', outdir / 'layers.rendered.pdf')
|
||||||
@@ -342,7 +354,7 @@ def test_prevent_gs_invalid_xml(resources, outdir):
|
|||||||
contents = (outdir / 'pdfa.pdf').read_bytes()
|
contents = (outdir / 'pdfa.pdf').read_bytes()
|
||||||
# Since the XML may be invalid, we scan instead of actually feeding it
|
# Since the XML may be invalid, we scan instead of actually feeding it
|
||||||
# to a parser.
|
# to a parser.
|
||||||
XMP_MAGIC = b'W5M0MpCehiHzreSzNTczkc9d'
|
|
||||||
xmp_start = contents.find(XMP_MAGIC)
|
xmp_start = contents.find(XMP_MAGIC)
|
||||||
xmp_end = contents.rfind(b'<?xpacket end', xmp_start)
|
xmp_end = contents.rfind(b'<?xpacket end', xmp_start)
|
||||||
assert 0 < xmp_start < xmp_end
|
assert 0 < xmp_start < xmp_end
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from PIL import Image, ImageDraw
|
|||||||
from ocrmypdf import optimize as opt
|
from ocrmypdf import optimize as opt
|
||||||
from ocrmypdf._exec import jbig2enc, pngquant
|
from ocrmypdf._exec import jbig2enc, pngquant
|
||||||
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
||||||
|
|
||||||
from .conftest import check_ocrmypdf
|
from .conftest import check_ocrmypdf
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ def test_jbig2_lossy(lossy, resources, outpdf):
|
|||||||
'--image-dpi',
|
'--image-dpi',
|
||||||
'200',
|
'200',
|
||||||
'--optimize',
|
'--optimize',
|
||||||
3,
|
'3',
|
||||||
'--jpg-quality',
|
'--jpg-quality',
|
||||||
'50',
|
'50',
|
||||||
'--png-quality',
|
'--png-quality',
|
||||||
@@ -139,8 +139,8 @@ def test_multiple_pngs(resources, outdir):
|
|||||||
img2pdf.convert(
|
img2pdf.convert(
|
||||||
fspath(resources / 'baiona_colormapped.png'),
|
fspath(resources / 'baiona_colormapped.png'),
|
||||||
fspath(resources / 'baiona_gray.png'),
|
fspath(resources / 'baiona_gray.png'),
|
||||||
with_pdfrw=False,
|
|
||||||
outputstream=inpdf,
|
outputstream=inpdf,
|
||||||
|
**IMG2PDF_KWARGS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def mockquant(input_file, output_file, *_args):
|
def mockquant(input_file, output_file, *_args):
|
||||||
|
|||||||
+16
-8
@@ -4,23 +4,31 @@
|
|||||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
|
|
||||||
from .conftest import check_ocrmypdf
|
from .conftest import check_ocrmypdf
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('optimize', (0, 3))
|
@pytest.mark.parametrize('optimize', (0, 3))
|
||||||
@pytest.mark.parametrize('pdfa_level', (1, 2, 3))
|
@pytest.mark.parametrize('pdfa_level', (1, 2, 3))
|
||||||
def test_pdfa(resources, outpdf, optimize, pdfa_level):
|
def test_pdfa(resources, outpdf, optimize, pdfa_level):
|
||||||
check_ocrmypdf(
|
try:
|
||||||
resources / 'francais.pdf',
|
check_ocrmypdf(
|
||||||
outpdf,
|
resources / 'francais.pdf',
|
||||||
'--plugin',
|
outpdf,
|
||||||
'tests/plugins/tesseract_noop.py',
|
'--plugin',
|
||||||
f'--output-type=pdfa-{pdfa_level}',
|
'tests/plugins/tesseract_noop.py',
|
||||||
f'--optimize={optimize}',
|
f'--output-type=pdfa-{pdfa_level}',
|
||||||
)
|
f'--optimize={optimize}',
|
||||||
|
)
|
||||||
|
except MissingDependencyError as e:
|
||||||
|
if 'pngquant' in str(e) and optimize in (2, 3) and os.name == 'nt':
|
||||||
|
pytest.xfail("pngquant currently not available on Windows")
|
||||||
if pdfa_level in (2, 3):
|
if pdfa_level in (2, 3):
|
||||||
# PDF/A-2 allows ObjStm
|
# PDF/A-2 allows ObjStm
|
||||||
assert b'/ObjStm' in outpdf.read_bytes()
|
assert b'/ObjStm' in outpdf.read_bytes()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from reportlab.pdfgen.canvas import Canvas
|
|||||||
|
|
||||||
from ocrmypdf import pdfinfo
|
from ocrmypdf import pdfinfo
|
||||||
from ocrmypdf.exceptions import InputFileError
|
from ocrmypdf.exceptions import InputFileError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
||||||
from ocrmypdf.pdfinfo import Colorspace, Encoding
|
from ocrmypdf.pdfinfo import Colorspace, Encoding
|
||||||
from ocrmypdf.pdfinfo.layout import PDFPage
|
from ocrmypdf.pdfinfo.layout import PDFPage
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ def test_single_page_image(eight_by_eight, outpdf):
|
|||||||
img2pdf.convert(
|
img2pdf.convert(
|
||||||
bio,
|
bio,
|
||||||
producer="img2pdf",
|
producer="img2pdf",
|
||||||
with_pdfrw=False,
|
|
||||||
layout_fun=layout_fun,
|
layout_fun=layout_fun,
|
||||||
outputstream=f,
|
outputstream=f,
|
||||||
|
**IMG2PDF_KWARGS,
|
||||||
)
|
)
|
||||||
info = pdfinfo.PdfInfo(outpdf)
|
info = pdfinfo.PdfInfo(outpdf)
|
||||||
|
|
||||||
@@ -173,17 +173,15 @@ def test_stack_abuse():
|
|||||||
p = pikepdf.Pdf.new()
|
p = pikepdf.Pdf.new()
|
||||||
|
|
||||||
stream = pikepdf.Stream(p, b'q ' * 35)
|
stream = pikepdf.Stream(p, b'q ' * 35)
|
||||||
with pytest.warns(None) as record:
|
with pytest.warns(UserWarning, match="overflowed"):
|
||||||
pdfinfo.info._interpret_contents(stream)
|
pdfinfo.info._interpret_contents(stream)
|
||||||
assert 'overflowed' in str(record[0].message)
|
|
||||||
|
|
||||||
stream = pikepdf.Stream(p, b'q Q Q Q Q')
|
stream = pikepdf.Stream(p, b'q Q Q Q Q')
|
||||||
with pytest.warns(None) as record:
|
with pytest.warns(UserWarning, match="underflowed") as record:
|
||||||
pdfinfo.info._interpret_contents(stream)
|
pdfinfo.info._interpret_contents(stream)
|
||||||
assert 'underflowed' in str(record[0].message)
|
|
||||||
|
|
||||||
stream = pikepdf.Stream(p, b'q ' * 135)
|
stream = pikepdf.Stream(p, b'q ' * 135)
|
||||||
with pytest.warns(None):
|
with pytest.warns(UserWarning):
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
pdfinfo.info._interpret_contents(stream)
|
pdfinfo.info._interpret_contents(stream)
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,19 @@ from reportlab.pdfgen.canvas import Canvas
|
|||||||
|
|
||||||
from ocrmypdf._exec import ghostscript
|
from ocrmypdf._exec import ghostscript
|
||||||
from ocrmypdf._plugin_manager import get_plugin_manager
|
from ocrmypdf._plugin_manager import get_plugin_manager
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
||||||
from ocrmypdf.pdfinfo import PdfInfo
|
from ocrmypdf.pdfinfo import PdfInfo
|
||||||
|
|
||||||
from .conftest import check_ocrmypdf, run_ocrmypdf
|
from .conftest import check_ocrmypdf, run_ocrmypdf
|
||||||
|
|
||||||
# pylintx: disable=unused-variable
|
# pylintx: disable=unused-variable
|
||||||
|
|
||||||
|
# Remove this workaround when we require Pillow >= 10
|
||||||
|
try:
|
||||||
|
Transpose = Image.Transpose # type: ignore
|
||||||
|
except AttributeError:
|
||||||
|
# Pillow 9 shim
|
||||||
|
Transpose = Image # type: ignore
|
||||||
|
|
||||||
RENDERERS = ['hocr', 'sandwich']
|
RENDERERS = ['hocr', 'sandwich']
|
||||||
|
|
||||||
@@ -222,7 +228,7 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
|
|||||||
with Image.open(fspath(resources / 'typewriter.png')) as im:
|
with Image.open(fspath(resources / 'typewriter.png')) as im:
|
||||||
if image_angle != 0:
|
if image_angle != 0:
|
||||||
ccw_angle = -image_angle % 360
|
ccw_angle = -image_angle % 360
|
||||||
im = im.transpose(getattr(Image, f'ROTATE_{ccw_angle}'))
|
im = im.transpose(getattr(Transpose, f'ROTATE_{ccw_angle}'))
|
||||||
im.save(memimg, format='PNG')
|
im.save(memimg, format='PNG')
|
||||||
memimg.seek(0)
|
memimg.seek(0)
|
||||||
mempdf = BytesIO()
|
mempdf = BytesIO()
|
||||||
@@ -230,6 +236,7 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
|
|||||||
memimg.read(),
|
memimg.read(),
|
||||||
layout_fun=img2pdf.get_fixed_dpi_layout_fun((200, 200)),
|
layout_fun=img2pdf.get_fixed_dpi_layout_fun((200, 200)),
|
||||||
outputstream=mempdf,
|
outputstream=mempdf,
|
||||||
|
**IMG2PDF_KWARGS,
|
||||||
)
|
)
|
||||||
mempdf.seek(0)
|
mempdf.seek(0)
|
||||||
pike = pikepdf.open(mempdf)
|
pike = pikepdf.open(mempdf)
|
||||||
|
|||||||
+3
-16
@@ -30,12 +30,12 @@ def test_stdin(ocrmypdf_exec, resources, outpdf):
|
|||||||
'--plugin',
|
'--plugin',
|
||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
]
|
]
|
||||||
run(p_args, stdout=PIPE, stderr=PIPE, stdin=input_stream, check=True)
|
run(p_args, capture_output=True, stdin=input_stream, check=True)
|
||||||
|
|
||||||
|
|
||||||
def test_stdout(ocrmypdf_exec, resources, outpdf):
|
def test_stdout(ocrmypdf_exec, resources, outpdf):
|
||||||
if 'COV_CORE_DATAFILE' in os.environ:
|
if 'COV_CORE_DATAFILE' in os.environ:
|
||||||
pytest.skip(msg="Coverage uses stdout")
|
pytest.skip("Coverage uses stdout")
|
||||||
|
|
||||||
input_file = str(resources / 'francais.pdf')
|
input_file = str(resources / 'francais.pdf')
|
||||||
output_file = str(outpdf)
|
output_file = str(outpdf)
|
||||||
@@ -53,26 +53,13 @@ def test_stdout(ocrmypdf_exec, resources, outpdf):
|
|||||||
assert check_pdf(output_file)
|
assert check_pdf(output_file)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.version_info >= (3, 7, 0), reason='better utf-8')
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
Path('/etc/alpine-release').exists(), reason="invalid test on alpine"
|
|
||||||
)
|
|
||||||
@pytest.mark.skipif(os.name == 'nt', reason="invalid test on Windows")
|
|
||||||
def test_bad_locale(monkeypatch):
|
|
||||||
monkeypatch.setenv('LC_ALL', 'C')
|
|
||||||
p = run_ocrmypdf('a', 'b')
|
|
||||||
assert p.stdout == '', "stdout not clean"
|
|
||||||
assert p.returncode != 0
|
|
||||||
assert 'configured to use ASCII as encoding' in p.stderr, "should whine"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(
|
@pytest.mark.xfail(
|
||||||
os.name == 'nt' and sys.version_info < (3, 8),
|
os.name == 'nt' and sys.version_info < (3, 8),
|
||||||
reason="Windows does not like this; not sure how to fix",
|
reason="Windows does not like this; not sure how to fix",
|
||||||
)
|
)
|
||||||
def test_dev_null(resources):
|
def test_dev_null(resources):
|
||||||
if 'COV_CORE_DATAFILE' in os.environ:
|
if 'COV_CORE_DATAFILE' in os.environ:
|
||||||
pytest.skip(msg="Coverage uses stdout")
|
pytest.skip("Coverage uses stdout")
|
||||||
|
|
||||||
p = run_ocrmypdf(
|
p = run_ocrmypdf(
|
||||||
resources / 'trivial.pdf',
|
resources / 'trivial.pdf',
|
||||||
|
|||||||
+23
-5
@@ -5,19 +5,24 @@
|
|||||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
from os import fspath
|
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._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, run_ocrmypdf
|
from .conftest import check_ocrmypdf, have_unpaper, ocrmypdf_exec, run_ocrmypdf
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
|
needs_unpaper = pytest.mark.skipif(not have_unpaper(), reason="requires unpaper")
|
||||||
|
|
||||||
|
|
||||||
def test_no_unpaper(resources, no_outpdf):
|
def test_no_unpaper(resources, no_outpdf):
|
||||||
input_ = fspath(resources / "c02-22.pdf")
|
input_ = fspath(resources / "c02-22.pdf")
|
||||||
@@ -45,7 +50,7 @@ def test_old_unpaper(resources, no_outpdf):
|
|||||||
mock.assert_called()
|
mock.assert_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not have_unpaper(), reason="requires unpaper")
|
@needs_unpaper
|
||||||
def test_clean(resources, outpdf):
|
def test_clean(resources, outpdf):
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / "skew.pdf",
|
resources / "skew.pdf",
|
||||||
@@ -56,7 +61,7 @@ def test_clean(resources, outpdf):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not have_unpaper(), reason="requires unpaper")
|
@needs_unpaper
|
||||||
def test_unpaper_args_valid(resources, outpdf):
|
def test_unpaper_args_valid(resources, outpdf):
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / "skew.pdf",
|
resources / "skew.pdf",
|
||||||
@@ -69,7 +74,7 @@ def test_unpaper_args_valid(resources, outpdf):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not have_unpaper(), reason="requires unpaper")
|
@needs_unpaper
|
||||||
def test_unpaper_args_invalid_filename(resources, outpdf):
|
def test_unpaper_args_invalid_filename(resources, outpdf):
|
||||||
p = run_ocrmypdf(
|
p = run_ocrmypdf(
|
||||||
resources / "skew.pdf",
|
resources / "skew.pdf",
|
||||||
@@ -84,7 +89,7 @@ def test_unpaper_args_invalid_filename(resources, outpdf):
|
|||||||
assert p.returncode == ExitCode.bad_args
|
assert p.returncode == ExitCode.bad_args
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not have_unpaper(), reason="requires unpaper")
|
@needs_unpaper
|
||||||
def test_unpaper_args_invalid(resources, outpdf):
|
def test_unpaper_args_invalid(resources, outpdf):
|
||||||
p = run_ocrmypdf(
|
p = run_ocrmypdf(
|
||||||
resources / "skew.pdf",
|
resources / "skew.pdf",
|
||||||
@@ -98,3 +103,16 @@ def test_unpaper_args_invalid(resources, outpdf):
|
|||||||
# Can't tell difference between unpaper choking on bad arguments or some
|
# Can't tell difference between unpaper choking on bad arguments or some
|
||||||
# other unpaper failure
|
# other unpaper failure
|
||||||
assert p.returncode == ExitCode.child_process_error
|
assert p.returncode == ExitCode.child_process_error
|
||||||
|
|
||||||
|
|
||||||
|
@needs_unpaper
|
||||||
|
def test_unpaper_image_too_big(resources, outdir, caplog):
|
||||||
|
with patch('ocrmypdf._exec.unpaper.UNPAPER_IMAGE_PIXEL_LIMIT', 42):
|
||||||
|
infile = resources / 'crom.png'
|
||||||
|
unpaper.clean(infile, outdir / 'out.png', dpi=300) == infile
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
'too large for cleaning' in rec.message
|
||||||
|
for rec in caplog.get_records('call')
|
||||||
|
if rec.levelno == logging.WARNING
|
||||||
|
)
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ def test_mutex_options():
|
|||||||
|
|
||||||
|
|
||||||
def test_optimizing(caplog):
|
def test_optimizing(caplog):
|
||||||
vd.check_options_optimizing(
|
opts = make_opts(optimize=0, jbig2_lossy=True, png_quality=18, jpeg_quality=10)
|
||||||
make_opts(optimize=0, jbig2_lossy=True, png_quality=18, jpeg_quality=10)
|
plugin_manager = get_plugin_manager(opts.plugins)
|
||||||
)
|
vd._check_options(opts, plugin_manager, set())
|
||||||
assert 'will be ignored because' in caplog.text
|
assert 'will be ignored because' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@@ -145,22 +145,16 @@ def test_report_file_size(tmp_path, caplog):
|
|||||||
pdf.Root.Dummy2 = waste_of_space + waste_of_space
|
pdf.Root.Dummy2 = waste_of_space + waste_of_space
|
||||||
pdf.save(out)
|
pdf.save(out)
|
||||||
|
|
||||||
with patch('ocrmypdf._validation.jbig2enc.available', return_value=True), patch(
|
vd.report_output_file_size(opts, in_, out, ['The optional dependency...'])
|
||||||
'ocrmypdf._validation.pngquant.available', return_value=True
|
assert 'optional dependency' in caplog.text
|
||||||
):
|
|
||||||
vd.report_output_file_size(opts, in_, out)
|
|
||||||
assert 'No reason' in caplog.text
|
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
with patch('ocrmypdf._validation.jbig2enc.available', return_value=False), patch(
|
vd.report_output_file_size(opts, in_, out, [])
|
||||||
'ocrmypdf._validation.pngquant.available', return_value=True
|
assert 'No reason' in caplog.text
|
||||||
):
|
|
||||||
vd.report_output_file_size(opts, in_, out)
|
|
||||||
assert 'optional dependency' in caplog.text
|
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
opts = make_opts(in_, out, optimize=0, output_type='pdf')
|
opts = make_opts(in_, out, optimize=0, output_type='pdf')
|
||||||
vd.report_output_file_size(opts, in_, out)
|
vd.report_output_file_size(opts, in_, out, ["Optimization was disabled."])
|
||||||
assert 'disabled' in caplog.text
|
assert 'disabled' in caplog.text
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user