Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e54f6ee37f | ||
|
|
2da556bf79 | ||
|
|
b183ad8167 | ||
|
|
9e6b54c7ed | ||
|
|
d3b334c10f | ||
|
|
622f2c4bab | ||
|
|
07b638a394 | ||
|
|
9bee2405d8 | ||
|
|
8f040491bf | ||
|
|
8a18988706 | ||
|
|
47a954514b | ||
|
|
e3b65d4288 | ||
|
|
4704f7ed1d | ||
|
|
3a2745445a | ||
|
|
12e15bab15 | ||
|
|
9593aa4fb9 | ||
|
|
817d520e63 | ||
|
|
700abbb8a5 | ||
|
|
701ef1df3f | ||
|
|
0f5c484b62 | ||
|
|
cc7f2a3f02 | ||
|
|
755b5d87e3 | ||
|
|
e55a4115e1 | ||
|
|
16a6fd2ea9 | ||
|
|
e3fce112ed | ||
|
|
eacd26a68b | ||
|
|
0e88b3c38a | ||
|
|
a2170ef8d6 | ||
|
|
eed0424390 | ||
|
|
5ed05e08b1 | ||
|
|
58b26f6715 | ||
|
|
806daf4284 | ||
|
|
c64bc9329e | ||
|
|
dd01745519 | ||
|
|
501ce726e7 | ||
|
|
03076e89ce | ||
|
|
02f37293ee | ||
|
|
590942ad14 | ||
|
|
2ac028c759 | ||
|
|
2125b5bfab | ||
|
|
b96532caa4 | ||
|
|
995fc58466 | ||
|
|
c023cae299 | ||
|
|
237eaf9130 | ||
|
|
8b9ab25125 | ||
|
|
77e87abe8f | ||
|
|
3be02e1e8d | ||
|
|
64c9ede979 | ||
|
|
5b8d197812 | ||
|
|
2cba62dc4f | ||
|
|
288e28328f | ||
|
|
b8214b3c49 | ||
|
|
8681693994 | ||
|
|
1364c63b7c | ||
|
|
4ba9e8fe25 | ||
|
|
a195713bb4 | ||
|
|
600d31a907 | ||
|
|
be31cec332 | ||
|
|
22a7cd3421 | ||
|
|
8b61d2d521 | ||
|
|
559e5269d2 | ||
|
|
ebf6acb318 | ||
|
|
7acd75f013 | ||
|
|
93623b2226 | ||
|
|
d71fd089cb | ||
|
|
05aa43c856 | ||
|
|
de80fb6bc8 | ||
|
|
8e396f4be2 | ||
|
|
efec6da377 | ||
|
|
00ef53195e | ||
|
|
f564aaf485 | ||
|
|
5ac2d31d0d | ||
|
|
fda890ab47 | ||
|
|
a873278c2a | ||
|
|
e6d64be890 | ||
|
|
0e4d978d20 | ||
|
|
b12c2cfedf | ||
|
|
58cc70725e | ||
|
|
339afb02aa | ||
|
|
7ba0ff5c36 | ||
|
|
ff41fbf673 | ||
|
|
2435cd23ce | ||
|
|
a063cff720 | ||
|
|
0d396e1ac0 | ||
|
|
f5807a2053 | ||
|
|
eb4938a36f | ||
|
|
c5ad530bbf | ||
|
|
d11c428407 | ||
|
|
6182b1f53e | ||
|
|
00fc1a12e2 | ||
|
|
16af753206 | ||
|
|
fa48205bb8 | ||
|
|
f7dbf94071 | ||
|
|
b18e66e2ca | ||
|
|
7a5504dfa5 | ||
|
|
d1cad7bc68 | ||
|
|
c58d5c097c | ||
|
|
46157ca94e | ||
|
|
dd99511bcc | ||
|
|
5bc2efd3c7 | ||
|
|
1b18dbecf5 | ||
|
|
9f82c0eb6e | ||
|
|
68bac1b177 | ||
|
|
1495b78330 | ||
|
|
6f777d2848 | ||
|
|
5650eba848 | ||
|
|
5bc5dc93f3 | ||
|
|
c1e18bb825 | ||
|
|
58282ea0fb | ||
|
|
891da7834c | ||
|
|
5c229d48d5 | ||
|
|
53f660cf35 | ||
|
|
7b66ca68f2 | ||
|
|
ba71c3ffbd | ||
|
|
6707ad427a | ||
|
|
5b84549716 | ||
|
|
c74f2ee6e8 | ||
|
|
b32dd9f9d3 | ||
|
|
fb8b161f6c | ||
|
|
baddd6d233 | ||
|
|
6f554c6ae8 | ||
|
|
a71e4488b3 | ||
|
|
72156b5653 | ||
|
|
9fa471e053 | ||
|
|
31ef2fe907 | ||
|
|
9a8ec4b210 | ||
|
|
75aad4cc79 | ||
|
|
4b27feca98 | ||
|
|
45522cd15f | ||
|
|
677d9a4e76 | ||
|
|
efa7ea4fde | ||
|
|
137a6e45f5 | ||
|
|
29116e1dec | ||
|
|
87193335b9 | ||
|
|
cfd4f8a850 | ||
|
|
eaa324939f | ||
|
|
ef70e538f7 | ||
|
|
b7b912e56a | ||
|
|
4615cf2f1e | ||
|
|
eaf772f80a | ||
|
|
96ba75eabd | ||
|
|
fdfe52c1ad | ||
|
|
932b2e2a29 | ||
|
|
57e489c957 | ||
|
|
17a3fa671c | ||
|
|
2659afb4f6 | ||
|
|
7392115507 | ||
|
|
c54d0c7eaa | ||
|
|
b95eefc65f | ||
|
|
686207ab7f | ||
|
|
517b385fe5 | ||
|
|
795019b0c1 | ||
|
|
3127a73822 | ||
|
|
069ee6c91f | ||
|
|
3aac3a98ca | ||
|
|
268859a304 | ||
|
|
a96710aa7b | ||
|
|
edcc58826a | ||
|
|
7077c8220a | ||
|
|
f7cbf68edd | ||
|
|
68a58ee8a5 | ||
|
|
3109ec5091 | ||
|
|
e0599fe8d7 | ||
|
|
a749240589 | ||
|
|
6decdaa062 | ||
|
|
4d5c9b8cdf | ||
|
|
1e23ea5364 | ||
|
|
cf9a8a91b5 | ||
|
|
05d3a65e94 | ||
|
|
755f6e0979 |
+1
-1
@@ -51,7 +51,7 @@ RUN . /appenv/bin/activate; \
|
||||
# Do this now to make the best use of Docker cache.
|
||||
COPY . /application
|
||||
RUN . /appenv/bin/activate; \
|
||||
pip install -r /application/test_requirements.txt
|
||||
pip install -r /application/requirements/test.txt
|
||||
|
||||
# Remove the junk, including the source version of application since it was
|
||||
# already installed
|
||||
|
||||
+16
-22
@@ -1,47 +1,41 @@
|
||||
# Development environment
|
||||
.bash_history
|
||||
.pylintrc
|
||||
.pytest_cache/
|
||||
.ruffus_history.sqlite
|
||||
.venv/
|
||||
*.pyc
|
||||
*.sublime-*
|
||||
venv*/
|
||||
.venv/
|
||||
pyvenv.cfg
|
||||
tasks.py
|
||||
.bash_history
|
||||
.ruffus_history.sqlite
|
||||
.idea/
|
||||
.pytest_cache/
|
||||
.pylintrc
|
||||
|
||||
# Package building
|
||||
*.egg-info/
|
||||
.cache/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
wheelhouse/
|
||||
|
||||
# Automatically generated files
|
||||
ocrmypdf/lib/_*.py
|
||||
ocrmypdf/version.py
|
||||
docs/_build/
|
||||
docs/_static/
|
||||
docs/_templates/
|
||||
docs/Makefile
|
||||
ocrmypdf/lib/_*.py
|
||||
|
||||
# Code coverage
|
||||
.coverage
|
||||
.coverage*
|
||||
htmlcov/
|
||||
|
||||
# Testing
|
||||
log/
|
||||
.ipynb_checkpoints/
|
||||
.vscode/
|
||||
*.ipynb
|
||||
*.profile
|
||||
/*.pdf
|
||||
/*.qdf
|
||||
*.ipynb
|
||||
.ipynb_checkpoints/
|
||||
/*.png
|
||||
/scratch.py
|
||||
IDEAS
|
||||
log/
|
||||
tests/output/
|
||||
tests/resources/private/
|
||||
tmp/
|
||||
pdfbox-app*.jar
|
||||
.vscode/
|
||||
IDEAS
|
||||
_Dockerfile.local
|
||||
/scratch.py
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
|
||||
formats:
|
||||
- pdf
|
||||
|
||||
requirements_file: requirements/main.txt
|
||||
+4
-4
@@ -66,7 +66,7 @@ matrix:
|
||||
- tesseract-ocr-fra
|
||||
- unpaper
|
||||
- os: osx
|
||||
osx_image: xcode8
|
||||
osx_image: xcode9.2
|
||||
language: generic
|
||||
|
||||
before_cache:
|
||||
@@ -83,7 +83,7 @@ before_install: |
|
||||
sudo dpkg -i packages/unpaper_6.1-1.deb
|
||||
fi
|
||||
elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
|
||||
brew update && brew bundle --file=.travis/Brewfile
|
||||
brew update --quiet && brew bundle --file=.travis/Brewfile --quiet
|
||||
pip3 install --upgrade pip
|
||||
pip3 install wheel
|
||||
fi
|
||||
@@ -91,9 +91,9 @@ before_install: |
|
||||
install:
|
||||
- export PATH=$PWD/bin:$PATH
|
||||
- pip3 install pycparser # py3.7 workaround for https://github.com/eliben/pycparser/issues/251
|
||||
- pip3 install -r requirements.txt
|
||||
- pip3 install -r requirements/main.txt
|
||||
- pip3 install --no-deps .
|
||||
- pip3 install -r test_requirements.txt
|
||||
- pip3 install -r requirements/test.txt
|
||||
|
||||
script:
|
||||
- tesseract --version
|
||||
|
||||
@@ -4,7 +4,6 @@ brew 'exempi'
|
||||
brew 'ghostscript'
|
||||
brew 'jbig2enc'
|
||||
brew 'leptonica'
|
||||
brew 'libtiff'
|
||||
brew 'openjpeg'
|
||||
brew 'pngquant'
|
||||
brew 'qpdf'
|
||||
|
||||
+6
-12
@@ -1,7 +1,5 @@
|
||||
# requirements
|
||||
include requirements.txt
|
||||
include test_requirements.txt
|
||||
include dev_requirements.txt
|
||||
recursive-include requirements *
|
||||
|
||||
# git
|
||||
include .git_archival.txt
|
||||
@@ -12,21 +10,20 @@ recursive-include .docker *
|
||||
|
||||
# tests
|
||||
include .coveragerc
|
||||
recursive-include tests *.bin
|
||||
recursive-include tests *.jpg
|
||||
recursive-include tests *.jsonl
|
||||
recursive-include tests *.png
|
||||
recursive-include tests *.pdf
|
||||
recursive-include tests *.py
|
||||
recursive-include tests *.rst
|
||||
recursive-include tests *.txt
|
||||
recursive-include tests/cache *
|
||||
recursive-exclude tests/output *
|
||||
recursive-exclude tests/output_pageinfo *
|
||||
recursive-exclude tests/resources/private *
|
||||
|
||||
# documentation
|
||||
include LICENSE
|
||||
include *.rst
|
||||
recursive-exclude .github *
|
||||
recursive-exclude .github *
|
||||
recursive-include docs *.py
|
||||
recursive-include docs *.rst
|
||||
recursive-include docs *.svg
|
||||
@@ -42,8 +39,5 @@ exclude .travis*
|
||||
|
||||
|
||||
# code
|
||||
recursive-include src/ocrmypdf *.py
|
||||
exclude ocrmypdf/lib/_leptonica.py
|
||||
|
||||
|
||||
|
||||
exclude src/ocrmypdf/lib/_leptonica.py
|
||||
exclude scratch.py
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
OCRmyPDF
|
||||
========
|
||||
|
||||
[![Travis build status][travis]](https://travis-ci.org/jbarlow83/OCRmyPDF) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs]
|
||||
|
||||
[travis]: https://travis-ci.org/jbarlow83/OCRmyPDF.svg?branch=master "Travis build status"
|
||||
|
||||
[pypi]: https://img.shields.io/pypi/v/ocrmypdf.svg "PyPI version"
|
||||
|
||||
[homebrew]: https://img.shields.io/homebrew/v/ocrmypdf.svg "Homebrew version"
|
||||
|
||||
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
|
||||
|
||||
OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched or copy-pasted.
|
||||
|
||||
```bash
|
||||
ocrmypdf # it's a scriptable command line program
|
||||
-l eng+fra # it supports multiple languages
|
||||
--rotate-pages # it can fix pages that are misrotated
|
||||
--deskew # it can deskew crooked PDFs!
|
||||
--title "My PDF" # it can change output metadata
|
||||
--jobs 4 # it uses multiple cores by default
|
||||
--output-type pdfa # it produces PDF/A by default
|
||||
input_scanned.pdf # takes PDF input (or images)
|
||||
output_searchable.pdf # produces validated PDF output
|
||||
```
|
||||
|
||||
Main features
|
||||
-------------
|
||||
|
||||
- Generates a searchable [PDF/A](https://en.wikipedia.org/?title=PDF/A) file from a regular PDF
|
||||
- Places OCR text accurately below the image to ease copy / paste
|
||||
- Keeps the exact resolution of the original embedded images
|
||||
- When possible, inserts OCR information as a "lossless" operation without disrupting any other content
|
||||
- Optimizes PDF images, often producing files smaller than the input file
|
||||
- If requested deskews and/or cleans the image before performing OCR
|
||||
- Validates input and output files
|
||||
- Distributes work across all available CPU cores
|
||||
- Uses [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) engine
|
||||
- Supports more than [100 languages](https://github.com/tesseract-ocr/tessdata) recognized by Tesseract
|
||||
- Battle-tested on thousands of PDFs, a test suite and continuous integration
|
||||
|
||||
For details: please consult the [documentation](https://ocrmypdf.readthedocs.io/en/latest/).
|
||||
|
||||
Motivation
|
||||
----------
|
||||
|
||||
I searched the web for a free command line tool to OCR PDF files on Linux/UNIX: I found many, but none of them were really satisfying.
|
||||
|
||||
- Either they produced PDF files with misplaced text under the image (making copy/paste impossible)
|
||||
- Or they did not handle accents and multilingual characters
|
||||
- Or they changed the resolution of the embedded images
|
||||
- Or they generated ridiculously large PDF files
|
||||
- Or they crashed when trying to OCR
|
||||
- Or they did not produce valid PDF files
|
||||
- On top of that none of them produced PDF/A files (format dedicated for long time storage)
|
||||
|
||||
...so I decided to develop my own tool.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Linux, UNIX, and macOS are supported. Windows is not directly supported but there is a Docker image available that runs on Windows.
|
||||
|
||||
Users of Debian 9 or later or Ubuntu 16.10 or later may simply
|
||||
|
||||
```bash
|
||||
apt-get install ocrmypdf
|
||||
```
|
||||
|
||||
and users of Fedora 29 or later may simply
|
||||
|
||||
```bash
|
||||
dnf install ocrmypdf
|
||||
```
|
||||
|
||||
and macOS users with Homebrew may simply
|
||||
|
||||
```bash
|
||||
brew install ocrmypdf
|
||||
```
|
||||
|
||||
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
||||
|
||||
Languages
|
||||
---------
|
||||
|
||||
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users, you can often find packages that provide language packs:
|
||||
|
||||
```bash
|
||||
# Display a list of all Tesseract language packs
|
||||
apt-cache search tesseract-ocr
|
||||
|
||||
# Debian/Ubuntu users
|
||||
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language back
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Documentation and support
|
||||
-------------------------
|
||||
|
||||
Once ocrmypdf is installed, the built-in help which explains the command syntax and options can be accessed via:
|
||||
|
||||
```bash
|
||||
ocrmypdf --help
|
||||
```
|
||||
|
||||
Our [documentation is served on Read the Docs](https://ocrmypdf.readthedocs.io/en/latest/index.html).
|
||||
|
||||
If you detect an issue, please:
|
||||
|
||||
- Check whether your issue is already known
|
||||
- If no problem report exists on github, please create one here: <https://github.com/jbarlow83/OCRmyPDF/issues>
|
||||
- Describe your problem thoroughly
|
||||
- Append the console output of the script when running the debug mode (`-v 1` option)
|
||||
- If possible provide your input PDF file as well as the content of the temporary folder (using a file sharing service like Dropbox)
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
Runs on CPython 3.5, 3.6 and 3.7. Requires external program installations of Ghostscript, Tesseract OCR, QPDF, and Leptonica. ocrmypdf is pure Python, but uses CFFI to portably generate library bindings.
|
||||
|
||||
Press & Media
|
||||
-------------
|
||||
|
||||
- [c't 1-2014, page 59](http://heise.de/-2279695): Detailed presentation of OCRmyPDF v1.0 in the leading German IT magazine c't
|
||||
- [heise Open Source, 09/2014: Texterkennung mit OCRmyPDF](http://heise.de/-2356670)
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
The OCRmyPDF software is licensed under the GNU GPLv3. Certain files are covered by other licenses, as noted in their source files.
|
||||
|
||||
The license for each test file varies, and is noted in tests/resources/README.rst. The documentation is licensed under Creative Commons Attribution-ShareAlike 4.0 (CC-BY-SA 4.0).
|
||||
|
||||
OCRmyPDF versions prior to 6.0 were licensed under the MIT License.
|
||||
|
||||
Disclaimer
|
||||
----------
|
||||
|
||||
The software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
OCRmyPDF
|
||||
========
|
||||
|
||||
.. image:: https://travis-ci.org/jbarlow83/OCRmyPDF.svg?branch=master
|
||||
:target: https://travis-ci.org/jbarlow83/OCRmyPDF
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||
:target: https://pypi.org/project/ocrmypdf/
|
||||
|
||||
.. image:: https://img.shields.io/homebrew/v/ocrmypdf.svg
|
||||
:alt: homebrew
|
||||
:target: http://brewformulas.org/Ocrmypdf
|
||||
|
||||
|
||||
OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to
|
||||
be searched or copy-pasted.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf # it's a scriptable command line program
|
||||
-l eng+fra # it supports multiple languages
|
||||
--rotate-pages # it can fix pages that are misrotated
|
||||
--deskew # it can deskew crooked PDFs!
|
||||
--title "My PDF" # it can change output metadata
|
||||
--jobs 4 # it uses multiple cores by default
|
||||
--output-type pdfa # it produces PDF/A by default
|
||||
input_scanned.pdf # takes PDF input (or images)
|
||||
output_searchable.pdf # produces validated PDF output
|
||||
|
||||
|
||||
Main features
|
||||
-------------
|
||||
|
||||
- Generates a searchable
|
||||
`PDF/A <https://en.wikipedia.org/?title=PDF/A>`_ file from a regular PDF
|
||||
- Places OCR text accurately below the image to ease copy / paste
|
||||
- Keeps the exact resolution of the original embedded images
|
||||
- When possible, inserts OCR information as a "lossless" operation without disrupting any other content
|
||||
- Optimizes PDF images, often producing files smaller than the input file
|
||||
- If requested deskews and/or cleans the image before performing OCR
|
||||
- Validates input and output files
|
||||
- Distributes work across all available CPU cores
|
||||
- Uses `Tesseract OCR <https://github.com/tesseract-ocr/tesseract>`_ engine
|
||||
- Supports more than `100 languages <https://github.com/tesseract-ocr/tessdata>`_ recognized by Tesseract
|
||||
- Battle-tested on thousands of PDFs, a test suite and continuous integration
|
||||
|
||||
For details: please consult the `documentation <https://ocrmypdf.readthedocs.io/en/latest/>`_.
|
||||
|
||||
Motivation
|
||||
----------
|
||||
|
||||
I searched the web for a free command line tool to OCR PDF files on
|
||||
Linux/UNIX: I found many, but none of them were really satisfying.
|
||||
|
||||
- Either they produced PDF files with misplaced text under the image (making copy/paste impossible)
|
||||
- Or they did not handle accents and multilingual characters
|
||||
- Or they changed the resolution of the embedded images
|
||||
- Or they generated ridiculously large PDF files
|
||||
- Or they crashed when trying to OCR
|
||||
- Or they did not produce valid PDF files
|
||||
- On top of that none of them produced PDF/A files (format dedicated for long time storage)
|
||||
|
||||
...so I decided to develop my own tool.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Linux, UNIX, and macOS are supported. Windows is not directly supported but there is a Docker image available that runs on Windows.
|
||||
|
||||
Users of Debian 9 or later or Ubuntu 16.10 or later may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
apt-get install ocrmypdf
|
||||
|
||||
and macOS users with Homebrew may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
brew install ocrmypdf
|
||||
|
||||
For everyone else, `see our documentation <https://ocrmypdf.readthedocs.io/en/latest/installation.html>`_ for installation steps.
|
||||
|
||||
Languages
|
||||
---------
|
||||
|
||||
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users,
|
||||
you can often find packages that provide language packs:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Display a list of all Tesseract language packs
|
||||
apt-cache search tesseract-ocr
|
||||
|
||||
# Debian/Ubuntu users
|
||||
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language back
|
||||
|
||||
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.
|
||||
|
||||
Documentation and support
|
||||
-------------------------
|
||||
|
||||
Once ocrmypdf is installed, the built-in help which explains the command syntax and options can be accessed via:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf --help
|
||||
|
||||
Our `documentation is served on Read the Docs <https://ocrmypdf.readthedocs.io/en/latest/index.html>`_.
|
||||
|
||||
If you detect an issue, please:
|
||||
|
||||
- Check whether your issue is already known
|
||||
- If no problem report exists on github, please create one here:
|
||||
https://github.com/jbarlow83/OCRmyPDF/issues
|
||||
- Describe your problem thoroughly
|
||||
- Append the console output of the script when running the debug mode
|
||||
(``-v 1`` option)
|
||||
- If possible provide your input PDF file as well as the content of the
|
||||
temporary folder (using a file sharing service like Dropbox)
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
Runs on CPython 3.5, 3.6 and 3.7. Requires external program installations of Ghostscript, Tesseract OCR, QPDF, and Leptonica. ocrmypdf is pure Python, but uses CFFI to portably generate library bindings.
|
||||
|
||||
Press & Media
|
||||
-------------
|
||||
|
||||
- `c't 1-2014, page 59 <http://heise.de/-2279695>`_:
|
||||
Detailed presentation of OCRmyPDF v1.0 in the leading German IT
|
||||
magazine c't
|
||||
- `heise Open Source, 09/2014: Texterkennung mit
|
||||
OCRmyPDF <http://heise.de/-2356670>`_
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
The OCRmyPDF software is licensed under the GNU GPLv3. Certain files are covered by other licenses, as noted in their source files.
|
||||
|
||||
The license for each test file varies, and is noted in tests/resources/README.rst. The documentation is licensed under Creative Commons Attribution-ShareAlike 4.0 (CC-BY-SA 4.0).
|
||||
|
||||
OCRmyPDF versions prior to 6.0 were licensed under the MIT License.
|
||||
|
||||
Disclaimer
|
||||
----------
|
||||
|
||||
The software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied.
|
||||
Vendored
+1488
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -13,7 +13,9 @@ If a page in a PDF seems to have text, by default OCRmyPDF will exit without mod
|
||||
|
||||
If ``--skip-text`` is issued, then no OCR will be performed on pages that already have text. The page will be copied to the output. This may be useful for documents that contain both "born digital" and scanned content, or to use OCRmyPDF to normalize and convert to PDF/A regardless of their contents.
|
||||
|
||||
If ``--force-ocr`` is issued, then all pages will be rasterized to images, discarding any hidden OCR text, and rasterizing any printable text. This is useful for redoing OCR, for fixing OCR text with a damaged character map (text is selectable but not searchable), and destroying redacted information.
|
||||
If ``--redo-ocr`` is issued, then a detailed text analysis is performed. Text is categorized as either visible or invisible. Invisible text (OCR) is stripped out. Then an image of each page is created with visible text masked out. The page image is sent for OCR, and any additional text is inserted as OCR. If a file contains a mix of text and bitmap images that contain text, OCRmyPDF will locate the additional text in images without disrupting the existing text.
|
||||
|
||||
If ``--force-ocr`` is issued, then all pages will be rasterized to images, discarding any hidden OCR text, and rasterizing any printable text. This is useful for redoing OCR, for fixing OCR text with a damaged character map (text is selectable but not searchable), and destroying redacted information. Any forms and vector graphics will be rasterized as well.
|
||||
|
||||
|
||||
Time and image size limits
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ if on_rtd:
|
||||
def __getattr__(cls, name):
|
||||
return MagicMock()
|
||||
|
||||
MOCK_MODULES = ['pikepdf', 'libxmp', 'libxmp.utils']
|
||||
MOCK_MODULES = ['pikepdf', 'libxmp', 'libxmp.utils', 'ocrmypdf.leptonica']
|
||||
sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
|
||||
|
||||
|
||||
|
||||
+15
-22
@@ -57,7 +57,6 @@ You can increase (decrease) the parameter ``--rotate-pages-threshold`` to make p
|
||||
|
||||
If the page is "just a little off horizontal", like a crooked picture, then you want ``--deskew``. ``--rotate-pages`` is for when the cardinal angle is wrong.
|
||||
|
||||
|
||||
OCR languages other than English
|
||||
""""""""""""""""""""""""""""""""
|
||||
|
||||
@@ -70,7 +69,6 @@ By default OCRmyPDF assumes the document is English.
|
||||
|
||||
Language packs must be installed for all languages specified. See :ref:`Installing additional language packs <lang-packs>`.
|
||||
|
||||
|
||||
Produce PDF and text file containing OCR text
|
||||
"""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
@@ -94,7 +92,7 @@ If you are starting with images, you can just use Tesseract 3.04 or later direct
|
||||
# When there are multiple images
|
||||
tesseract text-file-containing-list-of-image-filenames.txt output-prefix pdf
|
||||
|
||||
Tesseract's PDF output is quite good – OCRmyPDF uses it by internally by default. However, OCRmyPDF has many features not available in Tesseract like like image processing, metadata control, and PDF/A generation.
|
||||
Tesseract's PDF output is quite good – OCRmyPDF uses it internally by default. However, OCRmyPDF has many features not available in Tesseract like like image processing, metadata control, and PDF/A generation.
|
||||
|
||||
Use a program like `img2pdf <https://gitlab.mister-muffin.de/josch/img2pdf>`_ to convert your images to PDFs, and then pipe the results to run ocrmypdf. The `-` tells ocrmypdf to read standard input.
|
||||
|
||||
@@ -116,7 +114,6 @@ If you have multiple images, you must use ``img2pdf`` to convert the images to P
|
||||
|
||||
ImageMagick ``convert`` can also convert a group of images to PDF, but in the author's experience it takes a long time, transcodes unnecessarily and gives poor results.
|
||||
|
||||
|
||||
Image processing
|
||||
----------------
|
||||
|
||||
@@ -132,6 +129,8 @@ OCRmyPDF perform some image processing on each page of a PDF, if desired. The s
|
||||
|
||||
* ``--clean-final`` uses unpaper to clean up pages before OCR and inserts the page into the final output. You will want to review each page to ensure that unpaper did not remove something important.
|
||||
|
||||
* ``-mask-barcodes`` will "cover up" any barcodes detected in the image of a page. Barcodes are known to confuse Tesseract OCR and interfere with the recognition of text on the same baseline as a barcode. The output file will contain the unaltered image of the barcode.
|
||||
|
||||
.. note::
|
||||
|
||||
In many cases image processing will rasterize PDF pages as images, potentially losing quality.
|
||||
@@ -140,7 +139,6 @@ OCRmyPDF perform some image processing on each page of a PDF, if desired. The s
|
||||
|
||||
``--clean-final`` and ``-remove-background`` may leave undesirable visual artifacts in some images where their algorithms have shortcomings. Files should be visually reviewed after using these options.
|
||||
|
||||
|
||||
OCR and correct document skew (crooked scan)
|
||||
""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
@@ -167,28 +165,22 @@ If you set ``--tesseract-timeout 0`` OCRmyPDF will apply its image processing wi
|
||||
ocrmypdf --tesseract-timeout=0 --remove-background input.pdf output.pdf
|
||||
|
||||
|
||||
Redo OCR
|
||||
""""""""
|
||||
Redo existing OCR
|
||||
"""""""""""""""""
|
||||
|
||||
To redo OCR on a file OCRed with other OCR software or a previous version of OCRmyPDF and/or Tesseract, you may use the ``--force-ocr`` argument. Normally, OCRmyPDF does not modify files that already appear to contain OCR text.
|
||||
To redo OCR on a file OCRed with other OCR software or a previous version of OCRmyPDF and/or Tesseract, you may use the ``--redo-ocr`` argument. (Normally, OCRmyPDF will exit with an error if asked to modify a file with OCR.)
|
||||
|
||||
This may be helpful for users who want to take advantage of accuracy improvements in Tesseract 4.0 for files they previously OCRed with an earlier version of Tesseract and OCRmyPDF.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf --force-ocr input.pdf output.pdf
|
||||
ocrmypdf --redo-ocr input.pdf output.pdf
|
||||
|
||||
Note that the method above will force rasterization of all pages, potentially reducing quality or losing vector content.
|
||||
This method will replace OCR without rasterizing, reducing quality or removing vector content. If a file contains a mix of pure digital text and OCR, digital text will be ignored and OCR will be replaced. As such this mode is incompatible with image processing options, since they alter the appearance of the file.
|
||||
|
||||
To ensure quality is preserved, one could extract all of the images and rebuild the PDF for a lossless transformation. This recipe does not work when PDFs contain multiple images per page, as many do in practice. It will also lose any page rotation information.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pdfimages -all old-ocr.pdf prefix # extract all images
|
||||
img2pdf -o temp.pdf prefix* # construct new PDF from the images
|
||||
# review the new PDF to ensure it visually matches the old one
|
||||
ocrmypdf --output-type pdf temp.pdf new-ocr.pdf
|
||||
|
||||
``--output-type pdf`` is used here to avoid using Ghostscript which will also rasterize images.
|
||||
In some cases, existing OCR cannot be detected or replaced. Files produced by OCRmyPDF v2.2 or earlier, for example, are internally represented as having visible text with an opaque image drawn on top. This situation cannot be detected.
|
||||
|
||||
If ``--redo-ocr`` does not work, you can use ``--force-ocr``, which will force rasterization of all pages, potentially reducing quality or losing vector content.
|
||||
|
||||
Improving OCR quality
|
||||
---------------------
|
||||
@@ -199,7 +191,6 @@ Rotating pages and deskewing helps to ensure that the page orientation is correc
|
||||
|
||||
OCR quality will suffer if the resolution of input images is not correct (since the range of pixel sizes that will be checked for possible fonts will also be incorrect).
|
||||
|
||||
|
||||
PDF optimization
|
||||
----------------
|
||||
|
||||
@@ -213,4 +204,6 @@ Currently optimization attempts to find more efficient encodings for images. The
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf --optimize 3 in.pdf out.pdf # Make it as small as possible
|
||||
ocrmypdf --optimize 3 in.pdf out.pdf # Make it small
|
||||
|
||||
Some users may consider enabling lossy JBIG2. See: :ref:`jbig2-lossy`.
|
||||
|
||||
+64
-21
@@ -40,7 +40,7 @@ Debian and Ubuntu 16.10 or newer
|
||||
|
||||
|
||||
+-------------------------------------------+
|
||||
| **OCRmyPDF version** |
|
||||
| **OCRmyPDF versions in Debian & Ubuntu** |
|
||||
+-------------------------------------------+
|
||||
| |latest| |
|
||||
+-------------------------------------------+
|
||||
@@ -55,13 +55,52 @@ Users of Debian 9 ("stretch") or later or Ubuntu 16.10 or later may simply
|
||||
|
||||
apt-get install ocrmypdf
|
||||
|
||||
For full details on version availability, check the `Debian Package Tracker <https://tracker.debian.org/pkg/ocrmypdf>`_ or `Ubuntu launchpad.net <https://launchpad.net/ocrmypdf>`_.
|
||||
As indicated in the table above, Debian and Ubuntu releases may lag 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. See `Installing HEAD revision from sources`_.
|
||||
|
||||
If the version available for your platform is out of date, you could opt to install the latest version from source. See `Installing HEAD revision from sources`_.
|
||||
For full details on version availability for your platform, check the `Debian Package Tracker <https://tracker.debian.org/pkg/ocrmypdf>`_ or `Ubuntu launchpad.net <https://launchpad.net/ocrmypdf>`_.
|
||||
|
||||
.. note::
|
||||
|
||||
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder. OCRmyPDF works fine without it but will produce larger output files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later will automatically detect it on the ``PATH``. To add JBIG2 encoding, see `Installing the JBIG2 encoder`_.
|
||||
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder. OCRmyPDF works fine without it but will produce larger output files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later will automatically detect it (specifically the ``jbig2`` binary) on the ``PATH``. To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
Fedora 29 or newer
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. |fedora-29| image:: https://repology.org/badge/version-for-repo/fedora29/ocrmypdf.svg
|
||||
:alt: Fedora 29
|
||||
|
||||
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||
:alt: Fedore Rawhide
|
||||
|
||||
|
||||
+------------------------------+
|
||||
| **OCRmyPDF version** |
|
||||
+------------------------------+
|
||||
| |latest| |
|
||||
+------------------------------+
|
||||
| |fedora-29| |fedora-rawhide| |
|
||||
+------------------------------+
|
||||
|
||||
Users of Fedora 29 later may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dnf install ocrmypdf
|
||||
|
||||
For full details on version availability, check the `Fedora Package Tracker
|
||||
<https://apps.fedoraproject.org/packages/ocrmypdf>`_.
|
||||
|
||||
If the version available for your platform is out of date, you could opt to
|
||||
install the latest version from source. See `Installing HEAD revision from
|
||||
sources`_.
|
||||
|
||||
.. note::
|
||||
|
||||
OCRmyPDF for Fedora currently omits the JBIG2 encoder due to patent issues.
|
||||
OCRmyPDF works fine without it but will produce larger output files. If you
|
||||
build jbig2enc from source, ocrmypdf 7.0.0 and later will automatically
|
||||
detect it on the ``PATH``. To add JBIG2 encoding, see `Installing the JBIG2
|
||||
encoder`_.
|
||||
|
||||
Installing the latest version on Ubuntu 18.04 LTS
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -92,7 +131,7 @@ Then install the most recent ocrmypdf for the local user and set the user's ``PA
|
||||
export PATH=$HOME/.local/bin:$PATH
|
||||
pip3 install --user ocrmypdf
|
||||
|
||||
To add JBIG2 encoding, see `Installing the JBIG2 encoder`_.
|
||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
Ubuntu 16.04 LTS
|
||||
^^^^^^^^^^^^^^^^
|
||||
@@ -135,7 +174,7 @@ At your option, you may upgrade Ubuntu 16.04 LTS to Tesseract 4.0 for improved O
|
||||
sudo apt-get update
|
||||
sudo apt-get upgrade tesseract-ocr
|
||||
|
||||
To add JBIG2 encoding, see `Installing the JBIG2 encoder`_.
|
||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
Ubuntu 14.04 LTS
|
||||
^^^^^^^^^^^^^^^^
|
||||
@@ -198,7 +237,7 @@ These installation instructions omit the optional dependency ``unpaper``, which
|
||||
wget -q 'https://www.dropbox.com/s/vaq0kbwi6e6au80/unpaper_6.1-1.deb?raw=1' -O unpaper_6.1-1.deb
|
||||
sudo dpkg -i unpaper_6.1-1.deb
|
||||
|
||||
To add JBIG2 encoding, see `Installing the JBIG2 encoder`_.
|
||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
ArchLinux
|
||||
^^^^^^^^^
|
||||
@@ -207,11 +246,13 @@ ArchLinux
|
||||
:alt: ArchLinux
|
||||
:target: https://repology.org/metapackage/ocrmypdf
|
||||
|
||||
The author is aware of an `ArchLinux package for ocrmypdf <https://aur.archlinux.org/packages/ocrmypdf/>`_. It seems like the following command might work.
|
||||
The author is aware of an `ArchLinux User Repository package for ocrmypdf <https://aur.archlinux.org/packages/ocrmypdf/>`_. You can use the following command.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pacman -S ocrmypdf
|
||||
yaourt -S ocrmypdf
|
||||
|
||||
If you have any difficulties with installation, check the repository package page.
|
||||
|
||||
Other Linux packages
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -447,28 +488,30 @@ Since ``pip3 install --user`` does not work correctly on some platforms, notably
|
||||
Requirements for pip and HEAD install
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
OCRmyPDF currently requires these external programs and libraries to be installed:
|
||||
OCRmyPDF currently requires these external programs and libraries to be installed, and must be satisfied using the operating system package manager. ``pip`` cannot provide them.
|
||||
|
||||
- Python 3.5 or newer
|
||||
- Ghostscript 9.15 or newer
|
||||
- libexempi3 2.2.0 or newer
|
||||
- qpdf 7.0.0 or newer
|
||||
- qpdf 8.1.0 or newer
|
||||
- Tesseract 3.04 or newer
|
||||
|
||||
As of ocrmypdf 7.0.0, the following versions are recommended:
|
||||
Using Python 3.5 in production is discouraged. Python 3.6 and 3.7 give much better performance.
|
||||
|
||||
As of ocrmypdf 7.2.1, the following versions are recommended:
|
||||
|
||||
- Python 3.7
|
||||
- Ghostscript 9.23
|
||||
- libexempi3 2.4.5
|
||||
- qpdf 8.1.0
|
||||
- Tesseract 4.0.0-beta1
|
||||
- Ghostscript 9.23 or newer
|
||||
- libexempi3 2.4.5 or newer
|
||||
- qpdf 8.2.1
|
||||
- Tesseract 4.0.0-rc1
|
||||
- jbig2enc 0.29 or newer
|
||||
- pngquant 2.5 or newer
|
||||
- unpaper 6.1
|
||||
|
||||
jbig2enc, pngquant, and unpaper are optional. If missing certain features are disabled. OCRmyPDF will discover them as soon as they are available.
|
||||
|
||||
**jbig2enc**, if present, will be used to optimize the encoding of monochrome images. This can significantly reduce the file size of the output file. It is not required. `jbig2enc <https://github.com/agl/jbig2enc>`_ is not generally available for Ubuntu or Debian due to lingering concerns about patent issues, but can easily be built from source. To add JBIG2 encoding, see `Optional: installing the JBIG2 encoder`_.
|
||||
**jbig2enc**, if present, will be used to optimize the encoding of monochrome images. This can significantly reduce the file size of the output file. It is not required. `jbig2enc <https://github.com/agl/jbig2enc>`_ is not generally available for Ubuntu or Debian due to lingering concerns about patent issues, but can easily be built from source. To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
**pngquant**, if present, is optionally used to optimize the encoding of PNG-style images in PDFs (actually, any that are that losslessly encoded) by lossily quantizing to a smaller color palette. It is only activated then the ``--optimize`` argument is ``2`` or ``3``.
|
||||
|
||||
@@ -476,14 +519,14 @@ jbig2enc, pngquant, and unpaper are optional. If missing certain features are di
|
||||
|
||||
These are in addition to the Python packaging dependencies, meaning that unfortunately, the ``pip install`` command cannot satisfy all of them.
|
||||
|
||||
Python 3.7 and Tesseract 4.0.0-beta.1 are recommended for best OCR results and best performance.
|
||||
|
||||
|
||||
Installing HEAD revision from sources
|
||||
-------------------------------------
|
||||
|
||||
If you have ``git`` and Python 3.5 or newer installed, you can install from source. When the ``pip`` installer runs, it will alert you if dependencies are missing.
|
||||
|
||||
If you prefer to build every from source, you will need to `build pikepdf from source <https://pikepdf.readthedocs.io/en/latest/installation.html#building-from-source>`_. First ensure you can build and install pikepdf.
|
||||
|
||||
To install the HEAD revision from sources in the current Python 3 environment:
|
||||
|
||||
.. code-block:: bash
|
||||
@@ -532,6 +575,6 @@ To install all of the development and test requirements:
|
||||
source venv/bin/activate
|
||||
cd OCRmyPDF
|
||||
pip install -e .
|
||||
pip install -r dev_requirements.txt -r test_requirements.txt
|
||||
pip install -r requirements/dev.txt -r requirements/test.txt
|
||||
|
||||
To add JBIG2 encoding, see `Installing the JBIG2 encoder`_.
|
||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
@@ -64,7 +64,7 @@ In the case of a PDF that is nothing other than a container of images (no rotati
|
||||
|
||||
OCRmyPDF uses several strategies depending on input options and the input PDF itself, but generally speaking it rasterizes a page for OCR and then grafts the OCR back onto the original. As such it can handle complex PDFs and still preserve their contents as much as possible.
|
||||
|
||||
OCRmyPDF also supports a many, many edge cases that have cropped over several years of development. We support PDF features like images inside of Form XObjects, and pages with UserUnit scaling. We support rare image formats like non-monochrome 1-bit images. Thanks to pikepdf and QPDF, we auto-repair PDFs that are damaged. (Not that you need to know what any of these are! You should be able to throw any PDF at it.)
|
||||
OCRmyPDF also supports a many, many edge cases that have cropped over several years of development. We support PDF features like images inside of Form XObjects, and pages with UserUnit scaling. We support rare image formats like non-monochrome 1-bit images. We warn about files you may not to OCR. Thanks to pikepdf and QPDF, we auto-repair PDFs that are damaged. (Not that you need to know what any of these are! You should be able to throw any PDF at it.)
|
||||
|
||||
|
||||
Limitations
|
||||
@@ -76,20 +76,20 @@ OCRmyPDF is limited by the Tesseract OCR engine. As such it experiences these l
|
||||
* It is not capable of recognizing handwriting.
|
||||
* It may find gibberish and report this as OCR output.
|
||||
* If a document contains languages outside of those given in the ``-l LANG`` arguments, results may be poor.
|
||||
* It is not always good at analyzing the natural reading order of documents. For example, it may fail to recognize that a document contains two columns and join text across the columns.
|
||||
* It is not always good at analyzing the natural reading order of documents. For example, it may fail to recognize that a document contains two columns, and may try to join text across columns.
|
||||
* Poor quality scans may produce poor quality OCR. Garbage in, garbage out.
|
||||
* It does not expose information about what font family text belongs to.
|
||||
|
||||
OCRmyPDF is also limited by the PDF specification:
|
||||
|
||||
* PDF encodes the position of text glyphs but does not encode document structure. There is no markup that divides a document in sections, paragraphs, sentences, or even words (since blank spaces are not represented). As such all elements of document structure including the spaces between words must be derived heuristically. Some PDF viewers do a better job of this than others.
|
||||
* Because some popular open source PDF viewers have a particularly hard time with spaces betweem words, OCRmyPDF appends a space to each text element as a workaround. While this mixes document structure with graphical information that ideally should be left to the PDF viewer to interpret, it improves compatibility with some viewers and does not cause problems for better ones.
|
||||
* Because some popular open source PDF viewers have a particularly hard time with spaces betweem words, OCRmyPDF appends a space to each text element as a workaround (when using ``--pdf-renderer hocr``). While this mixes document structure with graphical information that ideally should be left to the PDF viewer to interpret, it improves compatibility with some viewers and does not cause problems for better ones.
|
||||
|
||||
Ghostscript also imposes some limitations:
|
||||
|
||||
* PDFs containing JBIG2-encoded content will be converted to CCITT Group4 encoding, which has lower compression ratios, if Ghostscript PDF/A is enabled.
|
||||
* PDFs containing JPEG 2000-encoded content will be converted to JPEG encoding, which may introduce compression artifacts, if Ghostscript PDF/A is enabled.
|
||||
* Ghostscript may transcode grayscale and color images, either lossy to lossless or lossless to lossy, based on an internal algorithm. This behavior can be suppressed by setting ``--pdfa-image-compression`` to ``jpeg`` or ``lossless`` to set all images to one type or the other. Ghostscript has no option to maintain the input image's format.
|
||||
* Ghostscript may transcode grayscale and color images, either lossy to lossless or lossless to lossy, based on an internal algorithm. This behavior can be suppressed by setting ``--pdfa-image-compression`` to ``jpeg`` or ``lossless`` to set all images to one type or the other. Ghostscript has no option to maintain the input image's format. (Ghostscript 9.25+ can copy JPEG images without transcoding them; earlier versions will transcode.)
|
||||
|
||||
Regarding OCRmyPDF itself:
|
||||
|
||||
|
||||
+17
-2
@@ -1,5 +1,7 @@
|
||||
Installing a JBIG2 encoder
|
||||
==========================
|
||||
.. _jbig2:
|
||||
|
||||
Installing the JBIG2 encoder
|
||||
============================
|
||||
|
||||
Most Linux distributions do not include a JBIG2 encoder since JBIG2 encoding was patented for a long time. All known JBIG2 US patents have expired as of 2017, but it is possible that unknown patents exist.
|
||||
|
||||
@@ -18,3 +20,16 @@ For all other Linux, you must build a JBIG2 encoder from source:
|
||||
./autogen.sh
|
||||
./configure && make
|
||||
[sudo] make install
|
||||
|
||||
.. _jbig2-lossy:
|
||||
|
||||
Lossy mode JBIG2
|
||||
----------------
|
||||
|
||||
OCRmyPDF provides lossy mode JBIG2 as an advanced feature. Users should `review the technical concerns with JBIG2 in lossy mode <https://abbyy.technology/en:kb:tip:jbig2_compression_and_ocr>`_ and decide if this feature is acceptable for their use case.
|
||||
|
||||
JBIG2 lossy mode does achieve higher compression ratios than any other monochrome (bitonal) compression technology; for large text documents the savings are considerable. JBIG2 lossless still gives great compression ratios and is a major improvement over the older CCITT G4 standard. As explained above, there is some risk of substitution errors.
|
||||
|
||||
To turn on JBIG2 lossy mode, add the argument ``--jbig2-lossy``. ``--optimize {1,2,3}`` are necessary for the argument to take effect also required. Also, a JBIG2 encoder must be installed as described in the previous section.
|
||||
|
||||
*ocrmypdf v7.0 and v7.1 used lossy mode by default.*
|
||||
|
||||
+18
-3
@@ -17,12 +17,27 @@ Debian and Ubuntu users
|
||||
# Display a list of all Tesseract language packs
|
||||
apt-cache search tesseract-ocr
|
||||
|
||||
# Debian/Ubuntu users
|
||||
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language back
|
||||
|
||||
# Install Chinese Simplified language pack
|
||||
apt-get install tesseract-ocr-chi-sim
|
||||
|
||||
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+fre`` (English and French) or ``-l eng -l fre``.
|
||||
|
||||
Fedora users
|
||||
------------
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Display a list of all Tesseract language packs
|
||||
dnf search tesseract
|
||||
|
||||
# Install Chinese Simplified language pack
|
||||
dnf install tesseract-langpack-chi_sim
|
||||
|
||||
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+fre`` (English and French) or ``-l eng -l fre``.
|
||||
|
||||
macOS users
|
||||
-----------
|
||||
|
||||
|
||||
+139
-47
@@ -13,6 +13,123 @@ Note that it is licensed under GPLv3, so scripts that ``import ocrmypdf`` and ar
|
||||
find: [^`]\#([0-9]{1,3})[^0-9]
|
||||
replace: `#$1 <https://github.com/jbarlow83/OCRmyPDF/issues/$1>`_
|
||||
|
||||
|
||||
v7.3.1
|
||||
------
|
||||
|
||||
- Fixed performance regression from v7.3.0; fast page analysis was not selected when it should be.
|
||||
|
||||
- Fixed a few exceptions related to the new ``--mask-barcodes`` feature and improved argument checking
|
||||
|
||||
- Added missing detection of TrueType fonts that lack a Unicode mapping
|
||||
|
||||
|
||||
v7.3.0
|
||||
------
|
||||
|
||||
- Added a new feature ``--redo-ocr`` to detect existing OCR in a file, remove it, and redo the OCR. This may be particularly helpful for anyone who wants to take advantage of OCR quality improvements in Tesseract 4.0. Note that OCR added by OCRmyPDF before version 3.0 cannot be detected since it was not properly marked as invisible text in the earliest versions. OCR that constructs a font from visible text, such as Adobe Acrobat's ClearScan.
|
||||
|
||||
- OCRmyPDF's content detection is generally more sophisticated. It learns more about the contents of each PDF and makes better recommendations:
|
||||
|
||||
- OCRmyPDF can now detect when a PDF contains text that cannot be mapped to Unicode (meaning it is readable to human eyes but copy-pastes as gibberish). In these cases it recommends ``--force-ocr`` to make the text searchable.
|
||||
|
||||
- PDFs containing vector objects are now rendered at more appropriate resolution for OCR.
|
||||
|
||||
- We now exit with an error for PDFs that contain Adobe LiveCycle Designer's dynamic XFA forms. Currently the open source community does not have tools to work with these files.
|
||||
|
||||
- OCRmyPDF now warns when a PDF that contains Adobe AcroForms, since such files probably do not need OCR. It can work with these files.
|
||||
|
||||
- Added three new **experimental** features to improve OCR quality in certain conditions. The name, syntax and behavior of these arguments is subject to change. They may also be incompatible with some other features.
|
||||
|
||||
- ``--remove-vectors`` which strips out vector graphics. This can improve OCR quality since OCR will not search artwork for readable text; however, it currently removes "text as curves" as well.
|
||||
|
||||
- ``--mask-barcodes`` to detect and suppress barcodes in files. We have observed that barcodes can interfere with OCR because they are "text-like" but not actually textual.
|
||||
|
||||
- ``--threshold`` which uses a more sophisticated thresholding algorithm than is currently in use in Tesseract OCR. This works around a `known issue in Tesseract 4.0 <https://github.com/tesseract-ocr/tesseract/issues/1990>`_ with dark text on bright backgrounds.
|
||||
|
||||
- Fixed an issue where an error message was not reported when the installed Ghostscript was very old.
|
||||
|
||||
- The PDF optimizer now saves files with object streams enabled when the optimization level is ``--optimize 1`` or higher (the default). This makes files a little bit smaller, but requires PDF 1.5. PDF 1.5 was first released in 2003 and is broadly supported by PDF viewers, but some rudimentary PDF parsers such as PyPDF2 do not understand object streams. You can use the command line tool ``qpdf --object-streams=disable`` or `pikepdf <https://github.com/pikepdf/pikepdf>`_ library to remove them.
|
||||
|
||||
- New dependency: pdfminer.six 20181108. Note this is a fork of the Python 2-only pdfminer.
|
||||
|
||||
- Deprecation notice: At the end of 2018, we will be ending support for Python 3.5 and Tesseract 3.x. OCRmyPDF v7 will continue to work with older versions.
|
||||
|
||||
v7.2.1
|
||||
------
|
||||
|
||||
- Fix compatibility with an API change in pikepdf 0.3.5.
|
||||
|
||||
- A kludge to support Leptonica versions older than 1.72 in the test suite was dropped. Older versions of Leptonica are likely still compatible. The only impact is that a portion of the test suite will be skipped.
|
||||
|
||||
|
||||
v7.2.0
|
||||
------
|
||||
|
||||
**Lossy JBIG2 behavior change**
|
||||
|
||||
A user reported that ocrmypdf was in fact using JBIG2 in **lossy** compression mode. This was not the intended behavior. Users should `review the technical concerns with JBIG2 in lossy mode <https://abbyy.technology/en:kb:tip:jbig2_compression_and_ocr>`_ and decide if this is a concern for their use case.
|
||||
|
||||
JBIG2 lossy mode does achieve higher compression ratios than any other monochrome compression technology; for large text documents the savings are considerable. JBIG2 lossless still gives great compression ratios and is a major improvement over the older CCITT G4 standard.
|
||||
|
||||
Only users who have reviewed the concerns with JBIG2 in lossy mode should opt-in. As such, lossy mode JBIG2 is only turned on when the new argument ``--jbig2-lossy`` is issued. This is independent of the setting for ``--optimize``.
|
||||
|
||||
Users who did not install an optional JBIG2 encoder are unaffected.
|
||||
|
||||
(Thanks to user 'bsdice' for reporting this issue.)
|
||||
|
||||
**Other issues**
|
||||
|
||||
- When the image optimizer quantizes an image to 1 bit per pixel, it will now attempt to further optimize that image as CCITT or JBIG2, instead of keeping it in the "flate" encoding which is not efficient for 1 bpp images. (`#297 <https://github.com/jbarlow83/OCRmyPDF/issues/297>`_)
|
||||
|
||||
- Images in PDFs that are used as soft masks (i.e. transparency masks or alpha channels) are now excluded from optimization.
|
||||
|
||||
- Fixed handling of Tesseract 4.0-rc1 which now accepts invalid Tesseract configuration files, which broke the test suite.
|
||||
|
||||
v7.1.0
|
||||
------
|
||||
|
||||
- Improve the performance of initial text extraction, which is done to determine if a file contains existing text of some kind or not. On large files, this initial processing is now about 20x times faster. (`#299 <https://github.com/jbarlow83/OCRmyPDF/issues/299>`_)
|
||||
|
||||
- pikepdf 0.3.3 is now required.
|
||||
|
||||
- Fixed issue `#231 <https://github.com/jbarlow83/OCRmyPDF/issues/231>`_, a problem with JPEG2000 images where image metadata was only available inside the JPEG2000 file.
|
||||
|
||||
- Fixed some additional Ghostscript 9.25 compatibility issues.
|
||||
|
||||
- Improved handling of KeyboardInterrupt error messages. (`#301 <https://github.com/jbarlow83/OCRmyPDF/issues/301>`_)
|
||||
|
||||
- README.md is now served in GitHub markdown instead of reStructuredText.
|
||||
|
||||
v7.0.6
|
||||
------
|
||||
|
||||
- Blacklist Ghostscript 9.24, now that 9.25 is available and fixes many regressions in 9.24.
|
||||
|
||||
|
||||
v7.0.5
|
||||
------
|
||||
|
||||
- Improve capability with Ghostscript 9.24, and enable the JPEG passthrough feature when this version in installed.
|
||||
|
||||
- Ghostscript 9.24 lost the ability to set PDF title, author, subject and keyword metadata to Unicode strings. OCRmyPDF will set ASCII strings and warn when Unicode is suppressed. Other software may be used to update metadata. This is a short term work around.
|
||||
|
||||
- PDFs generated by Kodak Capture Desktop, or generally PDFs that contain indirect references to null objects in their table of contents, would have an invalid table of contents after processing by OCRmyPDF that might interfere with other viewers. This has been fixed.
|
||||
|
||||
- Detect PDFs generated by Adobe LiveCycle, which can only be displayed in Adobe Acrobat and Reader currently. When these are encountered, exit with an error instead of performing OCR on the "Please wait" error message page.
|
||||
|
||||
v7.0.4
|
||||
------
|
||||
|
||||
- Fix exception thrown when trying to optimize a certain type of PNG embedded in a PDF with the ``-O2``
|
||||
|
||||
- Update to pikepdf 0.3.2, to gain support for optimizing some additional image types that were previously excluded from optimization (CMYK and grayscale). Fixes `#285 <https://github.com/jbarlow83/OCRmyPDF/issues/285>`_.
|
||||
|
||||
v7.0.3
|
||||
------
|
||||
|
||||
- Fix issue `#284 <https://github.com/jbarlow83/OCRmyPDF/issues/284>`_, an error when parsing inline images that have are also image masks, by upgrading pikepdf to 0.3.1
|
||||
|
||||
v7.0.2
|
||||
------
|
||||
|
||||
@@ -76,10 +193,27 @@ v7.0.0
|
||||
|
||||
+ It may be necessary to separately ``pip install pycparser`` to avoid `another Python 3.7 issue <https://github.com/eliben/pycparser/pull/135>`_.
|
||||
|
||||
v6.2.5
|
||||
------
|
||||
|
||||
- Disable a failing test due to Tesseract 4.0rc1 behavior change. Previously, Tesseract would exit with an error message if its configuration was invalid, and OCRmyPDF would intercept this message. Now Tesseract issues a warning, which OCRmyPDF v6.2.5 may relay or ignore. (In v7.x, OCRmyPDF will respond to the warning.)
|
||||
|
||||
- This release branch no longer supports using the optional PyMuPDF installation, since it was removed in v7.x.
|
||||
|
||||
- This release branch no longer supports macOS. macOS users should upgrade to v7.x.
|
||||
|
||||
v6.2.4
|
||||
------
|
||||
|
||||
- Backport Ghostscript 9.25 compatibility fixes, which removes support for setting Unicode metadata
|
||||
- Backport blacklisting Ghostscript 9.24
|
||||
- Older versions of Ghostscript are still supported
|
||||
|
||||
v6.2.3
|
||||
------
|
||||
|
||||
- Fix compatibility with img2pdf >= 0.3.0 by rejecting input images that have an alpha channel
|
||||
- This version will be included in Ubuntu 18.10
|
||||
|
||||
v6.2.2
|
||||
------
|
||||
@@ -342,7 +476,6 @@ v5.0.1
|
||||
|
||||
- Fixed issue `#169 <https://github.com/jbarlow83/OCRmyPDF/issues/169>`_, exception due to failure to create sidecar text files on some versions of Tesseract 3.04, including the jbarlow83/ocrmypdf Docker image
|
||||
|
||||
|
||||
v5.0
|
||||
----
|
||||
|
||||
@@ -358,7 +491,6 @@ v5.0
|
||||
- Fixed issue `#164 <https://github.com/jbarlow83/OCRmyPDF/issues/164>`_, a typo
|
||||
- Removed the command line parameters ``-n`` and ``--just-print`` since they have not worked for some time (reported as Ubuntu bug `#1687308 <https://bugs.launchpad.net/ubuntu/+source/ocrmypdf/+bug/1687308>`_)
|
||||
|
||||
|
||||
v4.5.6
|
||||
------
|
||||
|
||||
@@ -366,21 +498,18 @@ v4.5.6
|
||||
- Fixed issue `#158 <https://github.com/jbarlow83/OCRmyPDF/issues/158>`_, ocrmypdf now stops and terminates if Ghostscript fails on an intermediate step, as it is not possible to proceed.
|
||||
- Fixed issue `#160 <https://github.com/jbarlow83/OCRmyPDF/issues/160>`_, exception thrown on certain invalid arguments instead of error message
|
||||
|
||||
|
||||
v4.5.5
|
||||
------
|
||||
|
||||
- Automated update of macOS homebrew tap
|
||||
- Fixed issue `#154 <https://github.com/jbarlow83/OCRmyPDF/issues/154>`_, KeyError '/Contents' when searching for text on blank pages that have no /Contents record. Note: incomplete fix for this issue.
|
||||
|
||||
|
||||
v4.5.4
|
||||
------
|
||||
|
||||
- Fix ``--skip-big`` raising an exception if a page contains no images (`#152 <https://github.com/jbarlow83/OCRmyPDF/issues/152>`_) (thanks to @TomRaz)
|
||||
- Fix an issue where pages with no images might trigger "cannot write mode P as JPEG" (`#151 <https://github.com/jbarlow83/OCRmyPDF/issues/151>`_)
|
||||
|
||||
|
||||
v4.5.3
|
||||
------
|
||||
|
||||
@@ -389,7 +518,6 @@ v4.5.3
|
||||
- Fixed an issue where the ``tess4`` renderer would duplicate content onto output pages if tesseract failed or timed out
|
||||
- Fixed ``tess4`` renderer not recognized when lossless reconstruction is possible
|
||||
|
||||
|
||||
v4.5.2
|
||||
------
|
||||
|
||||
@@ -397,21 +525,18 @@ v4.5.2
|
||||
- Make "using Tesseract 4.0" warning less ominous
|
||||
- Set up machinery for homebrew OCRmyPDF tap
|
||||
|
||||
|
||||
v4.5.1
|
||||
------
|
||||
|
||||
- Fix issue `#137 <https://github.com/jbarlow83/OCRmyPDF/issues/137>`_, proportions of images with a non-square pixel aspect ratio would be distorted in output for ``--force-ocr`` and some other combinations of flags
|
||||
|
||||
|
||||
v4.5
|
||||
----
|
||||
|
||||
- Exotic PDFs containing "Form XObjects" are now supported (issue `#134 <https://github.com/jbarlow83/OCRmyPDF/issues/134>`_; PDF reference manual 8.10), and images they contain are taken into account when determining the resolution for rasterizing
|
||||
- PDFs containing "Form XObjects" are now supported (issue `#134 <https://github.com/jbarlow83/OCRmyPDF/issues/134>`_; PDF reference manual 8.10), and images they contain are taken into account when determining the resolution for rasterizing
|
||||
- The Tesseract 4 Docker image no longer includes all languages, because it took so long to build something would tend to fail
|
||||
- OCRmyPDF now warns about using ``--pdf-renderer tesseract`` with Tesseract 3.04 or lower due to issues with Ghostscript corrupting the OCR text in these cases
|
||||
|
||||
|
||||
v4.4.2
|
||||
------
|
||||
|
||||
@@ -422,7 +547,6 @@ v4.4.2
|
||||
|
||||
- OCRmyPDF now prevents running the Tesseract 4 renderer with Tesseract 3.04, which was permitted in v4.4 and v4.4.1 but will not work
|
||||
|
||||
|
||||
v4.4.1
|
||||
------
|
||||
|
||||
@@ -449,32 +573,27 @@ v4.4
|
||||
|
||||
+ However, OCRmyPDF's dependency "ruffus" is not re-entrant, so no Python API is available. Scripts should continue to use the command line interface.
|
||||
|
||||
|
||||
v4.3.5
|
||||
------
|
||||
|
||||
- Update documentation to confirm Python 3.6.0 compatibility. No code changes were needed, so many earlier versions are likely supported.
|
||||
|
||||
|
||||
v4.3.4
|
||||
------
|
||||
|
||||
- Fixed "decimal.InvalidOperation: quantize result has too many digits" for high DPI images
|
||||
|
||||
|
||||
v4.3.3
|
||||
------
|
||||
|
||||
- Fixed PDF/A creation with Ghostscript 9.20 properly
|
||||
- Fixed an exception on inline stencil masks with a missing optional parameter
|
||||
|
||||
|
||||
v4.3.2
|
||||
------
|
||||
|
||||
- Fixed a PDF/A creation issue with Ghostscript 9.20 (note: this fix did not actually work)
|
||||
|
||||
|
||||
v4.3.1
|
||||
------
|
||||
|
||||
@@ -485,7 +604,6 @@ v4.3.1
|
||||
- Cleaned up debug output
|
||||
- Tesseract caching in test cases is now more cautious about false cache hits and reproducing exact output, not that any problems were observed
|
||||
|
||||
|
||||
v4.3
|
||||
----
|
||||
|
||||
@@ -503,7 +621,6 @@ v4.2.5
|
||||
- Fixed an issue (`#100 <https://github.com/jbarlow83/OCRmyPDF/issues/100>`_) with PDFs that omit the optional /BitsPerComponent parameter on images
|
||||
- Removed non-free file milk.pdf
|
||||
|
||||
|
||||
v4.2.4
|
||||
------
|
||||
|
||||
@@ -523,20 +640,17 @@ v4.2.3
|
||||
- Added a test case for indirect objects and linearized PDFs
|
||||
- Deprecated the OCRmyPDF.sh shell script
|
||||
|
||||
|
||||
v4.2.2
|
||||
------
|
||||
|
||||
- Improvements to documentation
|
||||
|
||||
|
||||
v4.2.1
|
||||
------
|
||||
|
||||
- Fixed an issue where PDF pages that contained stencil masks would report an incorrect DPI and cause Ghostscript to abort
|
||||
- Implemented stdin streaming
|
||||
|
||||
|
||||
v4.2
|
||||
----
|
||||
|
||||
@@ -575,7 +689,6 @@ v4.1.4
|
||||
|
||||
- Bug fix: monochrome images with an ICC profile attached were incorrectly converted to full color images if lossless reconstruction was not possible due to other settings; consequence was increased file size for these images
|
||||
|
||||
|
||||
v4.1.3
|
||||
------
|
||||
|
||||
@@ -584,14 +697,12 @@ v4.1.3
|
||||
- Fix order of operations for matrix multiplication (no effect on most users)
|
||||
- Add a few leptonica wrapper functions (no effect on most users)
|
||||
|
||||
|
||||
v4.1.2
|
||||
------
|
||||
|
||||
- Replace IEC sRGB ICC profile with Debian's sRGB (from icc-profiles-free) which is more compatible with the MIT license
|
||||
- More helpful error message for an error related to certain types of malformed PDFs
|
||||
|
||||
|
||||
v4.1
|
||||
----
|
||||
|
||||
@@ -599,20 +710,17 @@ v4.1
|
||||
- Fixed problems in error checking if ``unpaper`` is uninstalled or missing at run-time
|
||||
- Fixed problems with "RethrownJobError" errors during error handling that suppressed the useful error messages
|
||||
|
||||
|
||||
v4.0.7
|
||||
------
|
||||
|
||||
- Minor correction to Ghostscript output settings
|
||||
|
||||
|
||||
v4.0.6
|
||||
------
|
||||
|
||||
- Update install instructions
|
||||
- Provide a sRGB profile instead of using Ghostscript's
|
||||
|
||||
|
||||
v4.0.5
|
||||
------
|
||||
|
||||
@@ -626,18 +734,14 @@ v4.0.4
|
||||
|
||||
Released with verbose debug message turned on. Do not use. Skip to v4.0.5.
|
||||
|
||||
|
||||
v4.0.3
|
||||
------
|
||||
|
||||
New features
|
||||
^^^^^^^^^^^^
|
||||
|
||||
- Page orientations detected are now reported in a summary comment
|
||||
|
||||
|
||||
Fixes
|
||||
^^^^^
|
||||
|
||||
- Show stack trace if unexpected errors occur
|
||||
- Treat "too few characters" error message from Tesseract as a reason to skip that page rather than
|
||||
@@ -649,7 +753,7 @@ v4.0.2
|
||||
------
|
||||
|
||||
Fixes
|
||||
^^^^^
|
||||
|
||||
|
||||
- Fixed compatibility with Tesseract 3.04.01 release, particularly its different way of outputting
|
||||
orientation information
|
||||
@@ -661,7 +765,7 @@ v4.0.1
|
||||
------
|
||||
|
||||
Fixes
|
||||
^^^^^
|
||||
|
||||
|
||||
- Fixed a KeyError if tesseract fails to find page orientation information
|
||||
|
||||
@@ -670,7 +774,6 @@ v4.0
|
||||
----
|
||||
|
||||
New features
|
||||
^^^^^^^^^^^^
|
||||
|
||||
- Automatic page rotation (``-r``) is now available. It uses ignores any prior rotation information
|
||||
on PDFs and sets rotation based on the dominant orientation of detectable text. This feature is
|
||||
@@ -680,7 +783,6 @@ New features
|
||||
|
||||
|
||||
Fixes
|
||||
^^^^^
|
||||
|
||||
- Fixed an issue where lossless reconstruction could cause some pages to be appear incorrectly
|
||||
if the page was rotated by the user in Acrobat after being scanned (specifically if it a /Rotate tag)
|
||||
@@ -689,7 +791,6 @@ Fixes
|
||||
|
||||
|
||||
Changes
|
||||
^^^^^^^
|
||||
|
||||
- Logging output is now much easier to read
|
||||
- ``--deskew`` is now performed by Leptonica instead of unpaper (`#25 <https://github.com/jbarlow83/OCRmyPDF/issues/25>`_)
|
||||
@@ -705,7 +806,6 @@ v3.2.1
|
||||
------
|
||||
|
||||
Changes
|
||||
^^^^^^^
|
||||
|
||||
- Fixed issue `#47 <https://github.com/jbarlow83/OCRmyPDF/issues/47>`_ "convert() got and unexpected keyword argument 'dpi'" by upgrading to img2pdf 0.2
|
||||
- Tweaked the Dockerfiles
|
||||
@@ -715,7 +815,6 @@ v3.2
|
||||
----
|
||||
|
||||
New features
|
||||
^^^^^^^^^^^^
|
||||
|
||||
- Lossless reconstruction: when possible, OCRmyPDF will inject text layers without
|
||||
otherwise manipulating the content and layout of a PDF page. For example, a PDF containing a mix
|
||||
@@ -727,7 +826,6 @@ New features
|
||||
for the polyglots among us. It is much larger.
|
||||
|
||||
Changes
|
||||
^^^^^^^
|
||||
|
||||
- JPEG transcoding quality is now 95 instead of the default 75. Bigger file sizes for less degradation.
|
||||
|
||||
@@ -737,7 +835,6 @@ v3.1.1
|
||||
------
|
||||
|
||||
Changes
|
||||
^^^^^^^
|
||||
|
||||
- Fixed bug that caused incorrect page size and DPI calculations on documents with mixed page sizes
|
||||
|
||||
@@ -745,7 +842,6 @@ v3.1
|
||||
----
|
||||
|
||||
Changes
|
||||
^^^^^^^
|
||||
|
||||
- Default output format is now PDF/A-2b instead of PDF/A-1b
|
||||
- Python 3.5 and macOS El Capitan are now supported platforms - no changes were
|
||||
@@ -763,7 +859,6 @@ v3.0
|
||||
----
|
||||
|
||||
New features
|
||||
^^^^^^^^^^^^
|
||||
|
||||
- Easier installation with a Docker container or Python's ``pip`` package manager
|
||||
- Eliminated many external dependencies, so it's easier to setup
|
||||
@@ -786,7 +881,6 @@ New features
|
||||
- Multiple images on the same PDF page are now supported
|
||||
|
||||
Changes
|
||||
^^^^^^^
|
||||
|
||||
- New, robust rewrite in Python 3.4+ with ruffus_ pipelines
|
||||
- Now uses Ghostscript 9.14's improved color conversion model to preserve PDF colors
|
||||
@@ -823,8 +917,7 @@ Changes
|
||||
.. _Unpaper: https://github.com/Flameeyes/unpaper
|
||||
.. _JHOVE: http://jhove.sourceforge.net/
|
||||
|
||||
Release candidates
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
Release candidates^
|
||||
|
||||
- rc9:
|
||||
|
||||
@@ -892,12 +985,11 @@ where ``settings.txt`` contains *one argument per line*, for example:
|
||||
|
||||
|
||||
Fixes
|
||||
^^^^^
|
||||
|
||||
|
||||
- Handling of filenames containing spaces: fixed
|
||||
|
||||
Notes and known issues
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
- Some dependencies may work with lower versions than tested, so try
|
||||
overriding dependencies if they are "in the way" to see if they work.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
check-manifest >= 0.35
|
||||
twine >= 1.8.1
|
||||
coverage >= 4.4
|
||||
coverage >= 4.5
|
||||
GitPython == 2.1.3
|
||||
@@ -1,11 +1,13 @@
|
||||
# requirements.txt can be used to replicate the developer's build environment
|
||||
# setup.py lists a separate set of requirements that are looser to simplify
|
||||
# installation
|
||||
chardet == 3.0.4
|
||||
cffi == 1.11.5
|
||||
img2pdf == 0.3.0
|
||||
pikepdf == 0.3.0
|
||||
img2pdf == 0.3.1
|
||||
pdfminer.six == 20181108
|
||||
pikepdf == 0.3.7
|
||||
Pillow >= 5.0.0, != 5.1.0 ; sys_platform == "darwin"
|
||||
pycparser == 2.18
|
||||
pycparser == 2.19
|
||||
python-xmp-toolkit == 2.0.1
|
||||
reportlab == 3.4.0
|
||||
ruffus == 2.7.0
|
||||
reportlab == 3.5.9
|
||||
ruffus == 2.8.0
|
||||
@@ -1,4 +1,4 @@
|
||||
pytest >= 3.2
|
||||
pytest == 3.9.3
|
||||
pytest-helpers-namespace
|
||||
pytest-xdist
|
||||
pytest-cov
|
||||
@@ -28,7 +28,6 @@ from setuptools import setup, find_packages # nopep8
|
||||
from subprocess import STDOUT, check_output, CalledProcessError # nopep8
|
||||
from collections.abc import Mapping # nopep8
|
||||
import re # nopep8
|
||||
import os # nopep8
|
||||
|
||||
|
||||
missing_program = '''
|
||||
@@ -201,17 +200,18 @@ if 'upload' in sys.argv[1:]:
|
||||
print('Use twine to upload the package - setup.py upload is insecure')
|
||||
sys.exit(1)
|
||||
|
||||
tests_require = open('test_requirements.txt').read().splitlines()
|
||||
tests_require = open('requirements/test.txt', encoding='utf-8').read().splitlines()
|
||||
|
||||
|
||||
def readme():
|
||||
with open('README.rst') as f:
|
||||
with open('README.md', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
setup(
|
||||
name='ocrmypdf',
|
||||
description='OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched',
|
||||
long_description=readme(),
|
||||
long_description_content_type='text/markdown',
|
||||
url='https://github.com/jbarlow83/OCRmyPDF',
|
||||
author='James R. Barlow',
|
||||
author_email='jim@purplerock.ca',
|
||||
@@ -241,6 +241,7 @@ setup(
|
||||
'cffi >= 1.9.1', # to build the leptonica module
|
||||
'pytest-runner', # to enable python setup.py test
|
||||
'setuptools_scm', # so that version will work
|
||||
'setuptools_scm_git_archive' # enable version from github tarballs
|
||||
],
|
||||
use_scm_version={'version_scheme': 'post-release'},
|
||||
cffi_modules=[
|
||||
@@ -248,8 +249,9 @@ setup(
|
||||
],
|
||||
install_requires=[
|
||||
'cffi >= 1.9.1', # must be a setup and install requirement
|
||||
'img2pdf >= 0.2.4, < 0.4', # pure Python, so track HEAD closely
|
||||
'pikepdf >= 0.2.2, < 0.4',
|
||||
'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely
|
||||
'pdfminer.six == 20181108',
|
||||
'pikepdf >= 0.3.7, < 0.4',
|
||||
'Pillow >= 4.0.0, != 5.1.0 ; sys_platform == "darwin"',
|
||||
# Pillow < 4 has BytesIO/TIFF bug w/img2pdf 0.2.3
|
||||
# block 5.1.0, broken wheels
|
||||
@@ -268,4 +270,10 @@ setup(
|
||||
},
|
||||
package_data={'ocrmypdf': ['data/sRGB.icc']},
|
||||
include_package_data=True,
|
||||
zip_safe=False)
|
||||
zip_safe=False,
|
||||
project_urls={
|
||||
'Documentation': 'https://ocrmypdf.readthedocs.io/',
|
||||
'Source': 'https://github.com/jbarlow83/ocrmypdf',
|
||||
'Tracker': 'https://github.com/jbarlow83/ocrmypdf/issues'
|
||||
}
|
||||
)
|
||||
|
||||
+135
-54
@@ -17,7 +17,6 @@
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from tempfile import mkdtemp
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import os
|
||||
@@ -36,7 +35,7 @@ import ruffus.proxy_logger as proxy_logger
|
||||
from ._jobcontext import JobContext, JobContextManager, cleanup_working_files
|
||||
from ._pipeline import build_pipeline
|
||||
from .pdfa import file_claims_pdfa
|
||||
from .helpers import is_iterable_notstr, re_symlink, is_file_writable, \
|
||||
from .helpers import re_symlink, is_file_writable, \
|
||||
available_cpu_count
|
||||
from .exec import tesseract, qpdf, ghostscript
|
||||
from . import PROGRAM_NAME, VERSION
|
||||
@@ -245,19 +244,38 @@ preprocessing.add_argument(
|
||||
'--oversample', metavar='DPI', type=numeric(int, 0, 5000), default=0,
|
||||
help="Oversample images to at least the specified DPI, to improve OCR "
|
||||
"results slightly")
|
||||
preprocessing.add_argument(
|
||||
'--remove-vectors', action='store_true',
|
||||
help="EXPERIMENTAL. Mask out any vector objects in the PDF so that they "
|
||||
"will not be included in OCR. This can eliminate false characters.")
|
||||
preprocessing.add_argument(
|
||||
'--mask-barcodes', action='store_true',
|
||||
help="EXPERIMENTAL. Mask out any barcodes that appear in the PDF so they are not "
|
||||
"considered during OCR. Barcodes can introduce false characters into "
|
||||
"OCR.")
|
||||
preprocessing.add_argument(
|
||||
'--threshold', action='store_true',
|
||||
help="EXPERIMENTAL. Threshold image to 1bpp before sending it to Tesseract for OCR. Can "
|
||||
"improve OCR quality compared to Tesseract's thresholder.")
|
||||
|
||||
ocrsettings = parser.add_argument_group(
|
||||
"OCR options",
|
||||
"Control how OCR is applied")
|
||||
ocrsettings.add_argument(
|
||||
'-f', '--force-ocr', action='store_true',
|
||||
help="Rasterize any fonts or vector objects on each page, apply OCR, and "
|
||||
help="Rasterize any text or vector objects on each page, apply OCR, and "
|
||||
"save the rastered output (this rewrites the PDF)")
|
||||
ocrsettings.add_argument(
|
||||
'-s', '--skip-text', action='store_true',
|
||||
help="Skip OCR on any pages that already contain text, but include the "
|
||||
"page in final output; useful for PDFs that contain a mix of "
|
||||
"images, text pages, and/or previously OCRed pages")
|
||||
ocrsettings.add_argument(
|
||||
'--redo-ocr', action='store_true',
|
||||
help="Attempt to detect and remove the hidden OCR layer from files that "
|
||||
"were previously OCRed with OCRmyPDF or another program. Apply OCR "
|
||||
"to text found in raster images. Existing visible text objects will "
|
||||
"not be changed. If there is no existing OCR, OCR will be added.")
|
||||
ocrsettings.add_argument(
|
||||
'--skip-big', type=numeric(float, 0, 5000), metavar='MPixels',
|
||||
help="Skip OCR on pages larger than the specified amount of megapixels, "
|
||||
@@ -270,17 +288,17 @@ optimizing = parser.add_argument_group(
|
||||
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 optimizations; "
|
||||
"3 - do aggressive lossy optimizations"
|
||||
"0 - do not optimize; "
|
||||
"1 - do safe, lossless optimizations (default); "
|
||||
"2 - do some lossy optimizations; "
|
||||
"3 - do aggressive lossy optimizations (including lossy JBIG2)"
|
||||
)
|
||||
)
|
||||
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"
|
||||
"1 is lowest quality and smallest output; "
|
||||
"0 uses the default."
|
||||
)
|
||||
)
|
||||
@@ -295,6 +313,18 @@ optimizing.add_argument(
|
||||
"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)."
|
||||
)
|
||||
)
|
||||
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",
|
||||
@@ -450,12 +480,18 @@ def check_options_output(options, log):
|
||||
)
|
||||
|
||||
lossless_reconstruction = False
|
||||
if options.pdf_renderer in ('hocr', 'sandwich'):
|
||||
if not any((options.deskew, options.clean_final, options.force_ocr,
|
||||
options.remove_background)):
|
||||
lossless_reconstruction = True
|
||||
if not any((options.deskew, options.clean_final, options.force_ocr,
|
||||
options.remove_background)):
|
||||
lossless_reconstruction = True
|
||||
options.lossless_reconstruction = lossless_reconstruction
|
||||
|
||||
if not options.lossless_reconstruction and options.redo_ocr:
|
||||
raise argparse.ArgumentError(
|
||||
None,
|
||||
"--redo-ocr is not currently compatible with --deskew, "
|
||||
"--clean-final, and --remove-background"
|
||||
)
|
||||
|
||||
|
||||
def check_options_sidecar(options, log):
|
||||
if options.sidecar == '\0':
|
||||
@@ -467,42 +503,81 @@ def check_options_sidecar(options, log):
|
||||
options.sidecar = options.output_file + '.txt'
|
||||
|
||||
|
||||
def _optional_program_check(name, version_fn, min_version, for_argument):
|
||||
def _optional_program_required(name, version_fn, min_version, for_argument):
|
||||
try:
|
||||
if version_fn() < min_version:
|
||||
raise MissingDependencyError(
|
||||
"The installed '{}' is not supported. "
|
||||
"Install version {} or newer.".format(name, min_version))
|
||||
except FileNotFoundError:
|
||||
except (FileNotFoundError, MissingDependencyError):
|
||||
raise MissingDependencyError(
|
||||
"Install the '{}' program to use {}.".format(name, for_argument))
|
||||
|
||||
|
||||
def _optional_program_recommended(name, version_fn, min_version, for_argument):
|
||||
try:
|
||||
if version_fn() < min_version:
|
||||
raise MissingDependencyError(
|
||||
"The installed '{}' is not supported. "
|
||||
"Install version {} or newer.".format(name, min_version))
|
||||
except (FileNotFoundError, MissingDependencyError):
|
||||
complain(
|
||||
"For best results, install the optional program '{}' to use the "
|
||||
"argument {}.".format(name, for_argument)
|
||||
)
|
||||
|
||||
|
||||
def check_options_preprocessing(options, log):
|
||||
if any((options.clean, options.clean_final)):
|
||||
from .exec import unpaper
|
||||
_optional_program_check(
|
||||
_optional_program_required(
|
||||
'unpaper', unpaper.version, '6.1', '--clean, --clean-final'
|
||||
)
|
||||
|
||||
|
||||
def check_options_ocr_behavior(options, log):
|
||||
if options.force_ocr and options.skip_text:
|
||||
exclusive_options = sum(
|
||||
[(1 if opt else 0)
|
||||
for opt in (options.force_ocr, options.skip_text, options.redo_ocr)
|
||||
]
|
||||
)
|
||||
if exclusive_options >= 2:
|
||||
raise argparse.ArgumentError(
|
||||
None,
|
||||
"Error: --force-ocr and --skip-text are mutually exclusive.")
|
||||
"Error: choose only one of --force-ocr, --skip-text, --redo-ocr.")
|
||||
if options.force_ocr and any((options.mask_barcodes, options.threshold)):
|
||||
raise argparse.ArgumentError(
|
||||
'--force-ocr',
|
||||
'Error: --force-ocr currently may not be used with --threshold or --mask-barcodes'
|
||||
)
|
||||
|
||||
|
||||
def check_options_optimizing(options, log):
|
||||
if options.optimize >= 2:
|
||||
from .exec import pngquant, jbig2enc
|
||||
_optional_program_check(
|
||||
_optional_program_required(
|
||||
'pngquant', pngquant.version, '2.0.1', '--optimize {2,3}'
|
||||
)
|
||||
_optional_program_check(
|
||||
|
||||
if options.jbig2_lossy:
|
||||
_optional_program_required(
|
||||
'jbig2', jbig2enc.version, '0.28', '--jbig2-lossy'
|
||||
)
|
||||
elif options.optimize >= 2:
|
||||
# Although we use JBIG2 for optimize=1, don't nag about it unless the
|
||||
# user is asking for more optimization
|
||||
_optional_program_recommended(
|
||||
'jbig2', jbig2enc.version, '0.28', '--optimize {2,3}'
|
||||
)
|
||||
|
||||
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, log):
|
||||
if options.tesseract_oem and not tesseract.v4():
|
||||
@@ -598,52 +673,47 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
||||
description of the error message that occurred."""
|
||||
exit_code = None
|
||||
|
||||
task_name, job_name, exc_name, exc_value, exc_stack = ruffus_five_tuple
|
||||
task_name = task_name # unused
|
||||
job_name = job_name # unused
|
||||
if exc_name == 'builtins.SystemExit':
|
||||
_task_name, _job_name, exc_name, exc_value, exc_stack = ruffus_five_tuple
|
||||
|
||||
if isinstance(exc_name, type):
|
||||
# ruffus is full of mystery... sometimes (probably when the process
|
||||
# group leader is killed) exc_name is the class object of the exception,
|
||||
# rather than a str. So reach into the object and get its name.
|
||||
exc_name = exc_name.__name__
|
||||
|
||||
if exc_name.startswith('ocrmypdf.exceptions.'):
|
||||
base_exc_name = exc_name.replace('ocrmypdf.exceptions.', '')
|
||||
exc_class = getattr(ocrmypdf_exceptions, base_exc_name)
|
||||
exit_code = getattr(exc_class, 'exit_code', ExitCode.other_error)
|
||||
try:
|
||||
if isinstance(exc_value, exc_class):
|
||||
exc_msg = str(exc_value)
|
||||
elif isinstance(exc_value, str):
|
||||
exc_msg = exc_value
|
||||
else:
|
||||
exc_msg = str(exc_class())
|
||||
except Exception:
|
||||
exc_msg = "Unknown"
|
||||
|
||||
if exc_name in ('builtins.SystemExit', 'SystemExit'):
|
||||
match = re.search(r"\.(.+?)\)", exc_value)
|
||||
exit_code_name = match.groups()[0]
|
||||
exit_code = getattr(ExitCode, exit_code_name, 'other_error')
|
||||
elif exc_name == 'ruffus.ruffus_exceptions.MissingInputFileError':
|
||||
log.error(cleanup_ruffus_error_message(exc_value))
|
||||
exit_code = ExitCode.input_file
|
||||
elif exc_name == 'builtins.KeyboardInterrupt':
|
||||
log.error("Interrupted by user")
|
||||
elif exc_name in ('builtins.KeyboardInterrupt', 'KeyboardInterrupt'):
|
||||
# We have to print in this case because the log daemon might be toast
|
||||
print("Interrupted by user", file=sys.stderr)
|
||||
exit_code = ExitCode.ctrl_c
|
||||
elif exc_name == 'subprocess.CalledProcessError':
|
||||
# It's up to the subprocess handler to report something useful
|
||||
msg = "Error occurred while running this command:"
|
||||
log.error(msg + '\n' + exc_value)
|
||||
exit_code = ExitCode.child_process_error
|
||||
elif exc_name == 'ocrmypdf.exceptions.EncryptedPdfError':
|
||||
log.error(textwrap.dedent("""\
|
||||
Input PDF is encrypted. The encryption must be removed to
|
||||
perform OCR.
|
||||
|
||||
For information about this PDF's security use
|
||||
qpdf --show-encryption infilename
|
||||
|
||||
You can remove the encryption using
|
||||
qpdf --decrypt [--password=[password]] infilename
|
||||
|
||||
"""))
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
elif exc_name == 'ocrmypdf.exceptions.PdfMergeFailedError':
|
||||
log.error(textwrap.dedent("""\
|
||||
Failed to merge PDF image layer with OCR layer
|
||||
|
||||
Usually this happens because the input PDF file is mal-formed and
|
||||
ocrmypdf cannot automatically correct the problem on its own.
|
||||
|
||||
Try using
|
||||
ocrmypdf --pdf-renderer sandwich [..other args..]
|
||||
"""))
|
||||
exit_code = ExitCode.input_file
|
||||
elif exc_name.startswith('ocrmypdf.exceptions.'):
|
||||
base_exc_name = exc_name.replace('ocrmypdf.exceptions.', '')
|
||||
exc_class = getattr(ocrmypdf_exceptions, base_exc_name)
|
||||
exit_code = exc_class.exit_code
|
||||
if exc_msg:
|
||||
log.error(exc_msg)
|
||||
elif exc_name == 'PIL.Image.DecompressionBombError':
|
||||
msg = cleanup_ruffus_error_message(exc_value)
|
||||
msg += ("\nUse the --max-image-mpixels argument to set increase the "
|
||||
@@ -751,6 +821,7 @@ def preamble(_log):
|
||||
_log.debug('ocrmypdf ' + VERSION)
|
||||
_log.debug('tesseract ' + tesseract.version())
|
||||
_log.debug('qpdf ' + qpdf.version())
|
||||
_log.debug('gs ' + ghostscript.version())
|
||||
|
||||
|
||||
def check_environ(options, _log):
|
||||
@@ -835,9 +906,11 @@ def report_output_file_size(options, _log, input_file, output_file):
|
||||
""".format(ratio, explanation)))
|
||||
|
||||
|
||||
def run_pipeline():
|
||||
options = parser.parse_args()
|
||||
def run_pipeline(args=None):
|
||||
options = parser.parse_args(args=args)
|
||||
options.verbose_abbreviated_path = 1
|
||||
if os.environ.get('_OCRMYPDF_THREADS'):
|
||||
options.use_threads = True
|
||||
|
||||
if not check_closed_streams(options):
|
||||
return ExitCode.bad_args
|
||||
@@ -858,6 +931,14 @@ def run_pipeline():
|
||||
"security vulnerabilities with certain malformed PDFs. Consider "
|
||||
"upgrading to version 7.0.0 or newer.".format(qpdf.version()))
|
||||
|
||||
if ghostscript.version() == '9.24':
|
||||
complain(
|
||||
"Ghostscript 9.24 contains serious regressions and is not "
|
||||
"supported. Please upgrade to Ghostscript 9.25 or use an older "
|
||||
"version."
|
||||
)
|
||||
return ExitCode.missing_dependency
|
||||
|
||||
# Any changes to options will not take effect for options that are already
|
||||
# bound to function parameters in the pipeline. (For example
|
||||
# options.input_file, options.pdf_renderer are already bound.)
|
||||
|
||||
+105
-19
@@ -33,7 +33,7 @@ from .hocrtransform import HocrTransform
|
||||
from .pdfinfo import PdfInfo, Colorspace
|
||||
from .pdfa import generate_pdfa_ps, encode_pdf_date
|
||||
from .helpers import re_symlink, is_iterable_notstr, page_number, flatten_groups
|
||||
from .exec import ghostscript, tesseract, qpdf
|
||||
from .exec import ghostscript, tesseract
|
||||
from .exceptions import UnsupportedImageFormatError, \
|
||||
DpiError, PriorOcrFoundError, InputFileError, EncryptedPdfError
|
||||
from . import leptonica
|
||||
@@ -162,14 +162,25 @@ def repair_and_parse_pdf(
|
||||
options = context.get_options()
|
||||
copyfile(input_file, output_file)
|
||||
|
||||
detailed_page_analysis = False
|
||||
if options.redo_ocr:
|
||||
detailed_page_analysis = True
|
||||
|
||||
try:
|
||||
pdfinfo = PdfInfo(output_file)
|
||||
pdfinfo = PdfInfo(output_file, detailed_page_analysis=detailed_page_analysis, log=log)
|
||||
except pikepdf.PasswordError as e:
|
||||
raise EncryptedPdfError()
|
||||
except pikepdf.PdfError as e:
|
||||
log.error(e)
|
||||
raise InputFileError()
|
||||
|
||||
if pdfinfo.needs_rendering:
|
||||
log.error(
|
||||
"This PDF contains dynamic XFA forms created by Adobe LiveCycle "
|
||||
"Designer and can only be read by Adobe Acrobat or Adobe Reader."
|
||||
)
|
||||
raise InputFileError()
|
||||
|
||||
if pdfinfo.has_userunit and options.output_type.startswith('pdfa'):
|
||||
log.error(
|
||||
"This input file uses a PDF feature that is not supported "
|
||||
@@ -186,6 +197,25 @@ def repair_and_parse_pdf(
|
||||
"high page count files. Python 3.6 or newer is recommended."
|
||||
)
|
||||
|
||||
if pdfinfo.has_acroform:
|
||||
if options.redo_ocr:
|
||||
log.error(
|
||||
"This PDF has a user fillable form. --redo-ocr is not "
|
||||
"currently possible on such files."
|
||||
)
|
||||
raise PriorOcrFoundError()
|
||||
else:
|
||||
log.warning(
|
||||
"This PDF has a fillable form. Chances are it is a pure digital "
|
||||
"document that does not need OCR."
|
||||
)
|
||||
if not options.force_ocr:
|
||||
log.info(
|
||||
"Use the option --force-ocr to produce an image of the "
|
||||
"form and all filled form fields. The output PDF will be "
|
||||
"'flattened' and will no longer be fillable."
|
||||
)
|
||||
|
||||
context.set_pdfinfo(pdfinfo)
|
||||
log.debug(pdfinfo)
|
||||
|
||||
@@ -199,8 +229,12 @@ def get_pageinfo(input_file, context):
|
||||
|
||||
def get_page_dpi(pageinfo, options):
|
||||
"Get the DPI when nonsquare DPI is tolerable"
|
||||
xres = max(pageinfo.xres or VECTOR_PAGE_DPI, options.oversample or 0)
|
||||
yres = max(pageinfo.yres or VECTOR_PAGE_DPI, options.oversample or 0)
|
||||
xres = max(pageinfo.xres or VECTOR_PAGE_DPI,
|
||||
options.oversample or 0,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0)
|
||||
yres = max(pageinfo.yres or VECTOR_PAGE_DPI,
|
||||
options.oversample or 0,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0)
|
||||
return (float(xres), float(yres))
|
||||
|
||||
|
||||
@@ -212,6 +246,7 @@ def get_page_square_dpi(pageinfo, options):
|
||||
return float(max(
|
||||
(xres * userunit) or VECTOR_PAGE_DPI,
|
||||
(yres * userunit) or VECTOR_PAGE_DPI,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0,
|
||||
options.oversample or 0))
|
||||
|
||||
|
||||
@@ -220,6 +255,7 @@ def get_canvas_square_dpi(pageinfo, options):
|
||||
return float(max(
|
||||
(pageinfo.xres) or VECTOR_PAGE_DPI,
|
||||
(pageinfo.yres) or VECTOR_PAGE_DPI,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0,
|
||||
options.oversample or 0))
|
||||
|
||||
|
||||
@@ -230,7 +266,7 @@ def is_ocr_required(pageinfo, log, options):
|
||||
if pageinfo.has_text:
|
||||
msg = "{0:4d}: page already has text! – {1}"
|
||||
|
||||
if not options.force_ocr and not options.skip_text:
|
||||
if not options.force_ocr and not (options.skip_text or options.redo_ocr):
|
||||
log.error(msg.format(page,
|
||||
"aborting (use --force-ocr to force OCR)"))
|
||||
raise PriorOcrFoundError()
|
||||
@@ -238,6 +274,18 @@ def is_ocr_required(pageinfo, log, options):
|
||||
log.info(msg.format(page,
|
||||
"rasterizing text and running OCR anyway"))
|
||||
ocr_required = True
|
||||
elif options.redo_ocr:
|
||||
if pageinfo.has_corrupt_text:
|
||||
log.warning(msg.format(
|
||||
page,
|
||||
"some text on this page cannot be mapped to characters: "
|
||||
"consider using --force-ocr instead")
|
||||
)
|
||||
raise PriorOcrFoundError() # Wrong error but will do for now
|
||||
else:
|
||||
log.info(msg.format(page,
|
||||
"redoing OCR"))
|
||||
ocr_required = True
|
||||
elif options.skip_text:
|
||||
log.info(msg.format(page,
|
||||
"skipping all processing on this page"))
|
||||
@@ -480,7 +528,8 @@ def rasterize_with_ghostscript(
|
||||
ghostscript.rasterize_pdf(
|
||||
input_file, output_file, xres=canvas_dpi, yres=canvas_dpi,
|
||||
raster_device=device, log=log, page_dpi=(page_dpi, page_dpi),
|
||||
pageno=page_number(input_file), rotation=correction)
|
||||
pageno=page_number(input_file), rotation=correction,
|
||||
filter_vector=options.remove_vectors)
|
||||
|
||||
|
||||
def preprocess_remove_background(
|
||||
@@ -546,12 +595,13 @@ def select_ocr_image(
|
||||
user."""
|
||||
|
||||
image = infiles[0]
|
||||
if context.get_options().force_ocr:
|
||||
options = context.get_options()
|
||||
pageinfo = get_pageinfo(image, context)
|
||||
|
||||
if options.force_ocr:
|
||||
re_symlink(image, output_file, log)
|
||||
return
|
||||
|
||||
pageinfo = get_pageinfo(image, context)
|
||||
|
||||
with Image.open(image) as im:
|
||||
from PIL import ImageColor
|
||||
from PIL import ImageDraw
|
||||
@@ -562,22 +612,39 @@ def select_ocr_image(
|
||||
|
||||
xres, yres = im.info['dpi']
|
||||
log.debug('resolution %r %r', xres, yres)
|
||||
for textarea in pageinfo.get_textareas():
|
||||
|
||||
mask = None # Exclude both visible and invisible text from OCR
|
||||
if options.redo_ocr:
|
||||
mask = True # Mask visible text, but not invisible text
|
||||
|
||||
for textarea in pageinfo.get_textareas(visible=mask, corrupt=None):
|
||||
# Calculate resolution based on the image size and page dimensions
|
||||
# without regard whatever resolution is in pageinfo (may differ or
|
||||
# be None)
|
||||
bbox = textarea
|
||||
pixcoords = [Decimal(bbox[0]) / Decimal(72) * xres,
|
||||
Decimal(bbox[1]) / Decimal(72) * yres,
|
||||
Decimal(bbox[2]) / Decimal(72) * xres,
|
||||
Decimal(bbox[3]) / Decimal(72) * yres]
|
||||
pixcoords = [int(c) for c in pixcoords]
|
||||
bbox = [float(v) for v in textarea]
|
||||
xscale, yscale = float(xres) / 72.0, float(yres) / 72.0
|
||||
pixcoords = [bbox[0] * xscale,
|
||||
im.height - bbox[3] * yscale,
|
||||
bbox[2] * xscale,
|
||||
im.height - bbox[1] * yscale]
|
||||
pixcoords = [int(round(c)) for c in pixcoords]
|
||||
log.debug('blanking %r', pixcoords)
|
||||
draw.rectangle(pixcoords, fill=white)
|
||||
#draw.rectangle(pixcoords, outline=pink)
|
||||
|
||||
del draw
|
||||
if options.mask_barcodes or options.threshold:
|
||||
pix = leptonica.Pix.frompil(im)
|
||||
if options.threshold:
|
||||
pix = pix.masked_threshold_on_background_norm()
|
||||
if options.mask_barcodes:
|
||||
barcodes = pix.locate_barcodes()
|
||||
for barcode in barcodes:
|
||||
decoded, rect = barcode
|
||||
log.info('masking barcode %s %r', decoded, rect)
|
||||
draw.rectangle(rect, fill=white)
|
||||
im = pix.topil()
|
||||
|
||||
del draw
|
||||
# Pillow requires integer DPI
|
||||
dpi = round(xres), round(yres)
|
||||
im.save(output_file, dpi=dpi)
|
||||
@@ -773,7 +840,20 @@ def generate_postscript_stub(
|
||||
options = context.get_options()
|
||||
pdf = pikepdf.open(input_file)
|
||||
pdfmark = get_pdfmark(pdf, options)
|
||||
generate_pdfa_ps(output_file, pdfmark)
|
||||
|
||||
ascii_docinfo = False
|
||||
if ghostscript.version() >= '9.24':
|
||||
ascii_docinfo = True
|
||||
try:
|
||||
for v in pdfmark.values():
|
||||
v.encode('ascii', errors='strict')
|
||||
except UnicodeEncodeError:
|
||||
log.warning(
|
||||
"Ghostscript 9.24+ does not support Unicode strings in "
|
||||
" metadata. These will be converted to ASCII if possible."
|
||||
)
|
||||
|
||||
generate_pdfa_ps(output_file, pdfmark, ascii_docinfo=ascii_docinfo)
|
||||
|
||||
|
||||
def metadata_fixup(
|
||||
@@ -810,7 +890,13 @@ def metadata_fixup(
|
||||
pdfmark = get_pdfmark(metadata, options)
|
||||
pdf = pikepdf.open(layers_file)
|
||||
pdf.metadata = pdf.make_indirect(pikepdf.Dictionary(pdfmark))
|
||||
pdf.save(output_file, stream_data_mode=pikepdf.StreamDataMode.compress)
|
||||
try:
|
||||
pdf.save(output_file, compress_streams=True,
|
||||
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||
except AttributeError:
|
||||
# pikepdf <= 0.3.4
|
||||
pdf.save(output_file,
|
||||
stream_data_mode=pikepdf.StreamDataMode.compress)
|
||||
|
||||
|
||||
def optimize_pdf(
|
||||
|
||||
@@ -38,7 +38,7 @@ import sys
|
||||
import codecs
|
||||
|
||||
|
||||
def verify_python3_env():
|
||||
def verify_python3_env(): # pragma: no cover
|
||||
"""Ensures that the environment is good for unicode on Python 3."""
|
||||
|
||||
# PEP 538 changes in Python 3.7 should make this wrangling unnecessary
|
||||
|
||||
+115
-29
@@ -43,8 +43,54 @@ def _update_page_resources(*, page, font, font_key, procset):
|
||||
resources['/ProcSet'] = procset
|
||||
|
||||
|
||||
def strip_invisible_text(pdf, page, log):
|
||||
stream = []
|
||||
in_text_obj = False
|
||||
render_mode = 0
|
||||
text_objects = []
|
||||
|
||||
page.page_contents_coalesce()
|
||||
for operands, operator in pikepdf.parse_content_stream(page, ''):
|
||||
if not in_text_obj:
|
||||
if operator == pikepdf.Operator('BT'):
|
||||
in_text_obj = True
|
||||
render_mode = 0
|
||||
text_objects.append((operands, operator))
|
||||
else:
|
||||
stream.append((operands, operator))
|
||||
else:
|
||||
if operator == pikepdf.Operator('Tr'):
|
||||
render_mode = operands[0]
|
||||
text_objects.append((operands, operator))
|
||||
if operator == pikepdf.Operator('ET'):
|
||||
in_text_obj = False
|
||||
if render_mode != 3:
|
||||
stream.extend(text_objects)
|
||||
text_objects.clear()
|
||||
|
||||
def convert(op):
|
||||
try:
|
||||
return op.unparse()
|
||||
except AttributeError:
|
||||
return str(op).encode('ascii')
|
||||
|
||||
lines = []
|
||||
|
||||
for operands, operator in stream:
|
||||
if operator == pikepdf.Operator('INLINE IMAGE'):
|
||||
iim = operands[0]
|
||||
line = iim.unparse()
|
||||
else:
|
||||
line = b' '.join(convert(op) for op in operands) + b' ' + operator.unparse()
|
||||
lines.append(line)
|
||||
|
||||
content_stream = b'\n'.join(lines)
|
||||
page.Contents = pikepdf.Stream(pdf, content_stream)
|
||||
|
||||
|
||||
def _weave_layers_graft(
|
||||
*, pdf_base, page_num, text, font, font_key, procset, rotation, log):
|
||||
*, pdf_base, page_num, text, font, font_key, procset, rotation,
|
||||
strip_old_text, log):
|
||||
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
|
||||
|
||||
log.debug("Grafting")
|
||||
@@ -109,6 +155,9 @@ def _weave_layers_graft(
|
||||
|
||||
new_text_layer = pikepdf.Stream(pdf_base, pdf_text_contents)
|
||||
|
||||
if strip_old_text:
|
||||
strip_invisible_text(pdf_base, base_page, log)
|
||||
|
||||
base_page.page_contents_add(new_text_layer, prepend=True)
|
||||
|
||||
_update_page_resources(
|
||||
@@ -137,14 +186,9 @@ def _find_font(text, pdf_base):
|
||||
return font, font_key
|
||||
|
||||
|
||||
def _fix_toc(pdf_base, pageref_remap, log):
|
||||
"""Repair the table of contents
|
||||
|
||||
Whenever we replace a page wholesale, it gets assigned a new objgen number
|
||||
and other references to it within the PDF become invalid, most notably in
|
||||
the table of contents (/Outlines in PDF-speak). In weave_layers we collect
|
||||
pageref_remap, a mapping that describes the new objgen number given an old
|
||||
one. (objgen is a tuple, and the gen is almost always zero.)
|
||||
def _traverse_toc(pdf_base, visitor_fn, log):
|
||||
"""
|
||||
Walk the table of contents, calling visitor_fn() at each node
|
||||
|
||||
The /Outlines data structure is a messy data structure, but rather than
|
||||
navigating hierarchically we just track unique nodes. Enqueue nodes when
|
||||
@@ -152,8 +196,6 @@ def _fix_toc(pdf_base, pageref_remap, log):
|
||||
the two types of object in the table of contents that can be page bookmarks
|
||||
and update the page entry.
|
||||
|
||||
It may ultimately be better to find a way to rebuild a page in place.
|
||||
|
||||
"""
|
||||
|
||||
visited = set()
|
||||
@@ -162,10 +204,53 @@ def _fix_toc(pdf_base, pageref_remap, log):
|
||||
|
||||
if not '/Outlines' in pdf_base.root:
|
||||
return
|
||||
|
||||
queue.add(pdf_base.root.Outlines.objgen)
|
||||
while queue:
|
||||
objgen = queue.pop()
|
||||
visited.add(objgen)
|
||||
node = pdf_base.get_object(objgen)
|
||||
log.debug('fix toc: exploring outline entries at %r', objgen)
|
||||
|
||||
# Enumerate other nodes we could visit from here
|
||||
for key in link_keys:
|
||||
if key not in node:
|
||||
continue
|
||||
item = node[key]
|
||||
if not item.is_indirect:
|
||||
# Direct references are not allowed here, but it's not clear
|
||||
# what we should do if we find any. Removing them is an option:
|
||||
# node[key] = pdf_base.make_indirect(None)
|
||||
continue
|
||||
objgen = item.objgen
|
||||
if objgen not in visited:
|
||||
queue.add(objgen)
|
||||
|
||||
if visitor_fn:
|
||||
visitor_fn(pdf_base, node, log)
|
||||
|
||||
|
||||
def _fix_toc(pdf_base, pageref_remap, log):
|
||||
"""Repair the table of contents
|
||||
|
||||
Whenever we replace a page wholesale, it gets assigned a new objgen number
|
||||
and other references to it within the PDF become invalid, most notably in
|
||||
the table of contents (/Outlines in PDF-speak). In weave_layers we collect
|
||||
pageref_remap, a mapping that describes the new objgen number given an old
|
||||
one. (objgen is a tuple, and the gen is almost always zero.)
|
||||
|
||||
It may ultimately be better to find a way to rebuild a page in place.
|
||||
|
||||
"""
|
||||
|
||||
if not pageref_remap:
|
||||
return
|
||||
|
||||
def remap_dest(dest_node):
|
||||
"""
|
||||
Inner helper function: change the objgen for any page from the old we
|
||||
invalidated to its new one.
|
||||
"""
|
||||
if not isinstance(dest_node, pikepdf.Array):
|
||||
return
|
||||
pageref = dest_node[0]
|
||||
@@ -174,30 +259,24 @@ def _fix_toc(pdf_base, pageref_remap, log):
|
||||
new_objgen = pageref_remap[pageref.objgen]
|
||||
dest_node[0] = pdf_base.get_object(new_objgen)
|
||||
|
||||
queue.add(pdf_base.root.Outlines.objgen)
|
||||
while queue:
|
||||
objgen = queue.pop()
|
||||
visited.add(objgen)
|
||||
node = pdf_base.get_object(objgen)
|
||||
log.debug('fix toc: visiting %r', objgen)
|
||||
|
||||
# Enumerate other nodes we could visit from here
|
||||
for key in link_keys:
|
||||
if key not in node:
|
||||
continue
|
||||
item = node[key]
|
||||
if not item.is_indirect:
|
||||
continue
|
||||
objgen = item.objgen
|
||||
if objgen not in visited:
|
||||
queue.add(objgen)
|
||||
def visit_remap_dest(pdf_base, node, log):
|
||||
"""
|
||||
Visitor function to fix ToC entries
|
||||
|
||||
Test for the two types of references to pages that can occur in ToCs.
|
||||
Both types have the same final format (an indirect reference to the
|
||||
target page).
|
||||
"""
|
||||
if '/Dest' in node:
|
||||
# /Dest reference to another page (old method)
|
||||
remap_dest(node['/Dest'])
|
||||
elif '/A' in node:
|
||||
# /A (action) command set to "GoTo" (newer method)
|
||||
if '/S' in node['/A'] and node['/A']['/S'] == '/GoTo':
|
||||
remap_dest(node['/A']['/D'])
|
||||
|
||||
_traverse_toc(pdf_base, visit_remap_dest, log)
|
||||
|
||||
|
||||
def weave_layers(
|
||||
infiles,
|
||||
@@ -247,6 +326,12 @@ def weave_layers(
|
||||
pdfinfo = context.get_pdfinfo()
|
||||
pagerefs = {}
|
||||
|
||||
# Walk the table of contents first, to trigger pikepdf/qpdf to resolve all
|
||||
# page references in the table of contents. Some PDF generators put invalid
|
||||
# references in the ToC, so we want to resolve them to null before we
|
||||
# create any references, or the ToC will be corrupted
|
||||
_traverse_toc(pdf_base, None, log)
|
||||
|
||||
procset = pdf_base.make_indirect(
|
||||
pikepdf.Object.parse(b'[ /PDF /Text /ImageB /ImageC /ImageI ]'))
|
||||
|
||||
@@ -300,10 +385,11 @@ def weave_layers(
|
||||
|
||||
if text and font:
|
||||
# Graft the text layer onto this page, whether new or old
|
||||
strip_old = context.get_options().redo_ocr
|
||||
_weave_layers_graft(
|
||||
pdf_base=pdf_base, page_num=page_num, text=text, font=font,
|
||||
font_key=font_key, rotation=text_misaligned, procset=procset,
|
||||
log=log
|
||||
strip_old_text=strip_old, log=log
|
||||
)
|
||||
|
||||
# Correct the rotation if applicable
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
|
||||
from enum import IntEnum
|
||||
from textwrap import dedent
|
||||
|
||||
class ExitCode(IntEnum):
|
||||
ok = 0
|
||||
@@ -36,6 +37,13 @@ class ExitCode(IntEnum):
|
||||
|
||||
class ExitCodeException(Exception):
|
||||
exit_code = ExitCode.other_error
|
||||
message = ""
|
||||
|
||||
def __str__(self):
|
||||
super_msg = super().__str__() # Don't do str(super())
|
||||
if self.message:
|
||||
return self.message.format(super_msg)
|
||||
return super_msg
|
||||
|
||||
|
||||
class BadArgsError(ExitCodeException):
|
||||
@@ -44,7 +52,15 @@ class BadArgsError(ExitCodeException):
|
||||
|
||||
class PdfMergeFailedError(ExitCodeException):
|
||||
exit_code = ExitCode.input_file
|
||||
message = dedent('''\
|
||||
Failed to merge PDF image layer with OCR layer
|
||||
|
||||
Usually this happens because the input PDF file is malformed and
|
||||
ocrmypdf cannot automatically correct the problem on its own.
|
||||
|
||||
Try using
|
||||
ocrmypdf --pdf-renderer sandwich [..other args..]
|
||||
''')
|
||||
|
||||
class MissingDependencyError(ExitCodeException):
|
||||
exit_code = ExitCode.missing_dependency
|
||||
@@ -76,7 +92,18 @@ class SubprocessOutputError(ExitCodeException):
|
||||
|
||||
class EncryptedPdfError(ExitCodeException):
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
message = dedent('''\
|
||||
Input PDF is encrypted. The encryption must be removed to
|
||||
perform OCR.
|
||||
|
||||
For information about this PDF's security use
|
||||
qpdf --show-encryption infilename
|
||||
|
||||
You can remove the encryption using
|
||||
qpdf --decrypt [--password=[password]] infilename
|
||||
''')
|
||||
|
||||
|
||||
class TesseractConfigError(ExitCodeException):
|
||||
exit_code = ExitCode.invalid_config
|
||||
message = "Error occurred while parsing a Tesseract configuration file"
|
||||
|
||||
@@ -24,7 +24,6 @@ from subprocess import run, STDOUT, PIPE, CalledProcessError
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
|
||||
def get_version(program, *,
|
||||
version_arg='--version', regex=r'(\d+(\.\d+)*)'):
|
||||
"Get the version of the specified program"
|
||||
|
||||
@@ -32,15 +32,20 @@ def version():
|
||||
|
||||
|
||||
def jpeg_passthrough_available():
|
||||
"""
|
||||
Ghostscript 9.23 introduced JPEG passthrough but it seems to corrupt the
|
||||
last two bytes of certain images, for now we disable it for 9.23 and
|
||||
do not mention it for < 9.23.
|
||||
"""Returns True if the installed version of Ghostscript supports JPEG passthru
|
||||
|
||||
Prior to 9.23, Ghostscript decode and re-encoded JPEGs internally. In 9.23
|
||||
it gained the ability to keep JPEGs unmodified. However, the 9.23
|
||||
implementation was buggy and would deletes the last two bytes of images in
|
||||
some cases, as reported here.
|
||||
https://bugs.ghostscript.com/show_bug.cgi?id=699216
|
||||
|
||||
The issue was fixed for 9.24, hence that is the first version we consider
|
||||
the feature available. (However, we don't use 9.24 at all, so the first
|
||||
version that allows JPEG passthrough is 9.25.
|
||||
|
||||
"""
|
||||
return False
|
||||
return version() >= '9.24'
|
||||
|
||||
|
||||
def _gs_error_reported(stream):
|
||||
@@ -48,8 +53,7 @@ def _gs_error_reported(stream):
|
||||
|
||||
|
||||
def extract_text(input_file, pageno=1):
|
||||
"""
|
||||
Use the txtwrite device to get text layout information out
|
||||
"""Use the txtwrite device to get text layout information out
|
||||
|
||||
For details on options of -dTextFormat see
|
||||
https://www.ghostscript.com/doc/current/VectorDevices.htm#TXT
|
||||
@@ -60,10 +64,18 @@ def extract_text(input_file, pageno=1):
|
||||
<span bbox="left top right bottom" font="..." size="...">
|
||||
<char bbox="...." c="X"/>
|
||||
|
||||
:param pageno: number of page to extract, or all pages if None
|
||||
:return: XML-ish text representation in bytes
|
||||
|
||||
"""
|
||||
|
||||
if pageno is not None:
|
||||
pages = [
|
||||
'-dFirstPage=%i' % pageno,
|
||||
'-dLastPage=%i' % pageno
|
||||
]
|
||||
else:
|
||||
pages = []
|
||||
|
||||
args_gs = [
|
||||
'gs',
|
||||
'-dQUIET',
|
||||
@@ -72,10 +84,9 @@ def extract_text(input_file, pageno=1):
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=txtwrite',
|
||||
'-dTextFormat=0',
|
||||
'-dFirstPage=%i' % pageno,
|
||||
'-dLastPage=%i' % pageno,
|
||||
] + pages + [
|
||||
'-o', '-',
|
||||
input_file
|
||||
fspath(input_file)
|
||||
]
|
||||
|
||||
p = run(args_gs, stdout=PIPE, stderr=PIPE)
|
||||
@@ -90,9 +101,8 @@ def extract_text(input_file, pageno=1):
|
||||
|
||||
|
||||
def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
pageno=1, page_dpi=None, rotation=None):
|
||||
"""
|
||||
Rasterize one page of a PDF at resolution (xres, yres) in canvas units.
|
||||
pageno=1, page_dpi=None, rotation=None, filter_vector=False):
|
||||
"""Rasterize one page of a PDF at resolution (xres, yres) in canvas units.
|
||||
|
||||
The image is sized to match the integer pixels dimensions implied by
|
||||
(xres, yres) even if those numbers are noninteger. The image's DPI will
|
||||
@@ -106,6 +116,8 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
:param log:
|
||||
:param pageno: page number to rasterize (beginning at page 1)
|
||||
:param page_dpi: resolution tuple (x, y) overriding output image DPI
|
||||
:param rotation: 0, 90, 180, 270: clockwise angle to rotate page
|
||||
:param filter_vector: if True, remove vector graphics objects
|
||||
:return:
|
||||
"""
|
||||
res = xres, yres
|
||||
@@ -124,6 +136,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
'-dFirstPage=%i' % pageno,
|
||||
'-dLastPage=%i' % pageno,
|
||||
'-r{0}x{1}'.format(str(int_res[0]), str(int_res[1])),
|
||||
] + (['-dFILTERVECTOR'] if filter_vector else []) + [
|
||||
'-o', tmp.name,
|
||||
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
||||
'-f',
|
||||
@@ -174,6 +187,23 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
|
||||
def generate_pdfa(pdf_pages, output_file, compression, log,
|
||||
threads=1, pdf_version='1.5', pdfa_part='2'):
|
||||
"""Generate a PDF/A.
|
||||
|
||||
The pdf_pages, a list files, will be merged into output_file. One or more
|
||||
PDF files may be merged. One of the files in this list must be a pdfmark
|
||||
file that provides Ghostscript with details on how to perform the PDF/A
|
||||
conversion. By default with we pick PDF/A-2b, but this works for 1 or 3.
|
||||
|
||||
compression can be 'jpeg', 'lossless', or an empty string. In 'jpeg',
|
||||
Ghostscript is instructed to convert color and grayscale images to DCT
|
||||
(JPEG encoding). In 'lossless' Ghostscript is told to convert images to
|
||||
Flate (lossless/PNG). If the parameter is omitted Ghostscript is left to
|
||||
make its own decisions about how to encode images; it appears to use a
|
||||
heuristic to decide how to encode images. As of Ghostscript 9.25, we
|
||||
support passthrough JPEG which allows Ghostscript to avoid transcoding
|
||||
images entirely. (The feature was added in 9.23 but broken, and the 9.24
|
||||
release of Ghostscript had regressions, so we don't support it until 9.25.)
|
||||
"""
|
||||
compression_args = []
|
||||
if compression == 'jpeg':
|
||||
compression_args = [
|
||||
@@ -207,6 +237,9 @@ def generate_pdfa(pdf_pages, output_file, compression, log,
|
||||
compression_args.append('-dPassThroughJPEGImages=false')
|
||||
|
||||
with NamedTemporaryFile(delete=True) as gs_pdf:
|
||||
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
||||
# is set; see:
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
|
||||
args_gs = [
|
||||
"gs",
|
||||
"-dQUIET",
|
||||
@@ -216,8 +249,7 @@ def generate_pdfa(pdf_pages, output_file, compression, log,
|
||||
"-dNumRenderingThreads=" + str(threads),
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-dAutoRotatePages=/None",
|
||||
"-sColorConversionStrategy=" + strategy,
|
||||
"-sProcessColorModel=DeviceRGB"
|
||||
"-sColorConversionStrategy=" + strategy
|
||||
] + compression_args + [
|
||||
"-dJPEGQ=95",
|
||||
"-dPDFA=" + pdfa_part,
|
||||
|
||||
@@ -15,14 +15,11 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from subprocess import CalledProcessError, run, PIPE
|
||||
from subprocess import run, PIPE
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from . import get_version
|
||||
from ..exceptions import ExitCode, MissingDependencyError
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -30,7 +27,6 @@ def version():
|
||||
return get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def available():
|
||||
try:
|
||||
version()
|
||||
@@ -44,10 +40,24 @@ def convert_group(*, cwd, infiles, out_prefix):
|
||||
'jbig2',
|
||||
'-b',
|
||||
out_prefix,
|
||||
'-s',
|
||||
'-s', # symbol mode (lossy)
|
||||
# '-r', # refinement mode (lossless symbol mode, currently disabled in
|
||||
# jbig2)
|
||||
'-p',
|
||||
]
|
||||
args.extend(infiles)
|
||||
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
|
||||
proc.check_returncode()
|
||||
return proc
|
||||
return proc
|
||||
|
||||
|
||||
def convert_single(*, cwd, infile, outfile):
|
||||
args = [
|
||||
'jbig2',
|
||||
'-p',
|
||||
infile
|
||||
]
|
||||
with open(outfile, 'wb') as fstdout:
|
||||
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
||||
proc.check_returncode()
|
||||
return proc
|
||||
|
||||
@@ -15,15 +15,11 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from subprocess import CalledProcessError, run
|
||||
from tempfile import TemporaryFile
|
||||
from subprocess import run
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from . import get_version
|
||||
from ..exceptions import ExitCode
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -31,6 +27,14 @@ def version():
|
||||
return get_version('pngquant', regex=r'(\d+(\.\d+)*).*')
|
||||
|
||||
|
||||
def available():
|
||||
try:
|
||||
version()
|
||||
except MissingDependencyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def quantize(input_file, output_file, quality_min, quality_max):
|
||||
args = [
|
||||
'pngquant',
|
||||
@@ -42,4 +46,4 @@ def quantize(input_file, output_file, quality_min, quality_max):
|
||||
input_file
|
||||
]
|
||||
proc = run(args)
|
||||
proc.check_returncode()
|
||||
proc.check_returncode()
|
||||
|
||||
@@ -18,9 +18,8 @@
|
||||
from subprocess import CalledProcessError, STDOUT, PIPE, run
|
||||
from functools import lru_cache
|
||||
|
||||
from ..exceptions import InputFileError, SubprocessOutputError, \
|
||||
EncryptedPdfError
|
||||
from . import get_version
|
||||
from ..helpers import fspath
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -32,7 +31,7 @@ def check(input_file, log=None):
|
||||
args_qpdf = [
|
||||
'qpdf',
|
||||
'--check',
|
||||
input_file
|
||||
fspath(input_file)
|
||||
]
|
||||
|
||||
if log is None:
|
||||
|
||||
@@ -21,11 +21,11 @@ import shutil
|
||||
from functools import lru_cache
|
||||
from collections import namedtuple
|
||||
from textwrap import dedent
|
||||
from subprocess import CalledProcessError, TimeoutExpired, check_output, STDOUT
|
||||
from subprocess import CalledProcessError, TimeoutExpired, check_output, STDOUT, run, PIPE
|
||||
from contextlib import suppress
|
||||
|
||||
from ..exceptions import MissingDependencyError, TesseractConfigError
|
||||
from ..helpers import page_number
|
||||
from ..helpers import page_number, fspath
|
||||
from . import get_version
|
||||
|
||||
OrientationConfidence = namedtuple(
|
||||
@@ -92,22 +92,33 @@ def psm():
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def languages():
|
||||
def lang_error(output):
|
||||
msg = dedent("""Tesseract failed to report available languages.
|
||||
Output from Tesseract:
|
||||
-----------
|
||||
""")
|
||||
msg += output
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
args_tess = [
|
||||
'tesseract',
|
||||
'--list-langs'
|
||||
]
|
||||
try:
|
||||
langs = check_output(
|
||||
args_tess, universal_newlines=True, stderr=STDOUT)
|
||||
proc = run(
|
||||
args_tess, universal_newlines=True, stdout=PIPE, stderr=STDOUT,
|
||||
check=True
|
||||
)
|
||||
output = proc.stdout
|
||||
except CalledProcessError as e:
|
||||
msg = dedent("""Tesseract failed to report available languages.
|
||||
Output from Tesseract:
|
||||
-----------
|
||||
""")
|
||||
msg += e.output
|
||||
print(msg, file=sys.stderr)
|
||||
lang_error(e.output)
|
||||
raise MissingDependencyError from e
|
||||
return set(lang.strip() for lang in langs.splitlines()[1:])
|
||||
|
||||
header, *rest = output.splitlines()
|
||||
if not header.startswith('List of available languages'):
|
||||
lang_error(output)
|
||||
raise MissingDependencyError
|
||||
return set(lang.strip() for lang in rest)
|
||||
|
||||
|
||||
def tess_base_args(langs, engine_mode):
|
||||
@@ -124,7 +135,7 @@ def tess_base_args(langs, engine_mode):
|
||||
def get_orientation(input_file, engine_mode, timeout: float, log):
|
||||
args_tesseract = tess_base_args(['osd'], engine_mode) + [
|
||||
psm(), '0',
|
||||
input_file,
|
||||
fspath(input_file),
|
||||
'stdout'
|
||||
]
|
||||
|
||||
@@ -190,6 +201,10 @@ def tesseract_log_output(log, stdout, input_file):
|
||||
pass # Appears to be spurious/problem with nonwhite borders
|
||||
elif 'Error in boxClipToRectangle' in line:
|
||||
pass # Always appears with pixScanForForeground message
|
||||
elif 'parameter not found: ' in line.lower():
|
||||
log.error(prefix + line.strip())
|
||||
problem = line.split('found: ')[1]
|
||||
raise TesseractConfigError(problem)
|
||||
elif 'error' in line.lower() or 'exception' in line.lower():
|
||||
log.error(prefix + line.strip())
|
||||
elif 'warning' in line.lower():
|
||||
@@ -263,8 +278,6 @@ def generate_hocr(input_file, output_files, language: list, engine_mode,
|
||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_file)
|
||||
if b'read_params_file: parameter not found' in e.output:
|
||||
raise TesseractConfigError() from e
|
||||
if b'Image too large' in e.output:
|
||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||
return
|
||||
@@ -350,9 +363,6 @@ def generate_pdf(*, input_image, skip_pdf=None, output_pdf, output_text,
|
||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_image)
|
||||
if b'read_params_file: parameter not found' in e.output:
|
||||
raise TesseractConfigError() from e
|
||||
|
||||
if b'Image too large' in e.output:
|
||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||
return
|
||||
|
||||
+312
-122
@@ -20,24 +20,28 @@
|
||||
#
|
||||
# Python FFI wrapper for Leptonica library
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import warnings
|
||||
from tempfile import TemporaryFile
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from ctypes.util import find_library
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
from tempfile import TemporaryFile
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from .lib._leptonica import ffi
|
||||
from .helpers import fspath
|
||||
|
||||
# pylint: disable=w0212
|
||||
|
||||
lept = ffi.dlopen(find_library('lept'))
|
||||
# pylint: disable=protected-access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
lept = ffi.dlopen(find_library('lept'))
|
||||
lept.setMsgSeverity(lept.L_SEVERITY_WARNING)
|
||||
|
||||
|
||||
def stderr(*objs):
|
||||
"""Shorthand print to stderr."""
|
||||
@@ -45,12 +49,13 @@ def stderr(*objs):
|
||||
|
||||
|
||||
class _LeptonicaErrorTrap:
|
||||
"""Context manager to trap errors reported by Leptonica.
|
||||
"""
|
||||
Context manager to trap errors reported by Leptonica.
|
||||
|
||||
Leptonica's error return codes are unreliable to the point of being
|
||||
almost useless. It does, however, write errors to stderr provided that is
|
||||
not disabled at its compile time. Fortunately this is done using error
|
||||
macros so it is very self-consistent.
|
||||
Leptonica's error return codes don't provide much informatino about what
|
||||
went wrong. Leptonica does, however, write more detailed errors to stderr
|
||||
(provided this is not disabled at compile time). The Leptonica source
|
||||
code is very consistent in its use of macros to generate errors.
|
||||
|
||||
This context manager redirects stderr to a temporary file which is then
|
||||
read and parsed for error messages. As a side benefit, debug messages
|
||||
@@ -102,6 +107,8 @@ class _LeptonicaErrorTrap:
|
||||
raise FileNotFoundError()
|
||||
if 'pixWrite: stream not opened' in leptonica_output:
|
||||
raise LeptonicaIOError()
|
||||
if 'index not valid' in leptonica_output:
|
||||
raise IndexError()
|
||||
raise LeptonicaError(leptonica_output)
|
||||
|
||||
return False
|
||||
@@ -115,8 +122,45 @@ class LeptonicaIOError(LeptonicaError):
|
||||
pass
|
||||
|
||||
|
||||
class Pix:
|
||||
"""Wrapper around leptonica's PIX object.
|
||||
class LeptonicaObject:
|
||||
"""General wrapper for Leptonica objects
|
||||
|
||||
When Leptonica returns an object, we bundled it in a wrapper class, which
|
||||
manages its memory. The wrapper class assumes that it will be calling some
|
||||
sort of lept.thingDestroy() function when the instance is deleted. Most
|
||||
Leptonica objects are reference counted, and destroy decrements the
|
||||
refcount.
|
||||
|
||||
Most of the time, when Leptonica returns something, we wrap and it the job
|
||||
is done. When wrapping objects that came from a Leptonica container, like
|
||||
a PIXA returning PIX, the subclass must clone the object before passing it
|
||||
here, to maintain the reference count.
|
||||
|
||||
CFFI ensures that the destroy function is called at garbage collection time
|
||||
so we do not need to mess with __del__.
|
||||
"""
|
||||
|
||||
cdata_destroy = lambda cdata: None
|
||||
LEPTONICA_TYPENAME = ''
|
||||
|
||||
def __init__(self, cdata):
|
||||
if not cdata:
|
||||
raise ValueError('Tried to wrap a NULL ' + self.LEPTONICA_TYPENAME)
|
||||
self._cdata = ffi.gc(cdata, self._destroy)
|
||||
|
||||
@classmethod
|
||||
def _destroy(cls, cdata):
|
||||
"""Destroy some cdata"""
|
||||
# Leptonica API uses double-pointers for its destroy APIs to prevent
|
||||
# dangling pointers. This means we need to put our single pointer,
|
||||
# cdata, in a temporary CDATA**.
|
||||
pp = ffi.new('{} **'.format(cls.LEPTONICA_TYPENAME), cdata)
|
||||
cls.cdata_destroy(pp)
|
||||
|
||||
|
||||
class Pix(LeptonicaObject):
|
||||
"""
|
||||
Wrapper around leptonica's PIX object.
|
||||
|
||||
Leptonica uses referencing counting on PIX objects. Also, many Leptonica
|
||||
functions return the original object with an increased reference count
|
||||
@@ -133,18 +177,17 @@ class Pix:
|
||||
modified objects. This allows convenient chaining:
|
||||
|
||||
>>> Pix.open('filename.jpg').scale((0.5, 0.5)).deskew().show()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, pix):
|
||||
self._pix = ffi.gc(pix, Pix._pix_destroy)
|
||||
LEPTONICA_TYPENAME = "PIX"
|
||||
cdata_destroy = lept.pixDestroy
|
||||
|
||||
def __repr__(self):
|
||||
if self._pix:
|
||||
if self._cdata:
|
||||
s = "<leptonica.Pix image size={0}x{1} depth={2}{4} at 0x{3:x}>"
|
||||
return s.format(self._pix.w, self._pix.h, self._pix.d,
|
||||
int(ffi.cast('intptr_t', self._pix)),
|
||||
'(colormapped)' if self._pix.colormap else '')
|
||||
return s.format(self._cdata.w, self._cdata.h, self._cdata.d,
|
||||
int(ffi.cast('intptr_t', self._cdata)),
|
||||
'(colormapped)' if self._cdata.colormap else '')
|
||||
else:
|
||||
return "<leptonica.Pix image NULL>"
|
||||
|
||||
@@ -157,7 +200,7 @@ class Pix:
|
||||
data = ffi.new('l_uint8 **')
|
||||
size = ffi.new('size_t *')
|
||||
|
||||
err = lept.pixWriteMemPng(data, size, self._pix, 0)
|
||||
err = lept.pixWriteMemPng(data, size, self._cdata, 0)
|
||||
if err != 0:
|
||||
raise LeptonicaIOError("pixWriteMemPng")
|
||||
|
||||
@@ -168,7 +211,7 @@ class Pix:
|
||||
data = ffi.new('l_uint32 **')
|
||||
size = ffi.new('size_t *')
|
||||
|
||||
err = lept.pixSerializeToMemory(self._pix, data, size)
|
||||
err = lept.pixSerializeToMemory(self._cdata, data, size)
|
||||
if err != 0:
|
||||
raise LeptonicaIOError("pixSerializeToMemory")
|
||||
|
||||
@@ -185,32 +228,38 @@ class Pix:
|
||||
cdata_bytes = ffi.new('char[]', state['data'])
|
||||
cdata_uint32 = ffi.cast('l_uint32 *', cdata_bytes)
|
||||
|
||||
pix = lept.pixDeserializeFromMemory(
|
||||
cdata_uint32, len(state['data']))
|
||||
pix = lept.pixDeserializeFromMemory(cdata_uint32, len(state['data']))
|
||||
Pix.__init__(self, pix)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.__getstate__() == other.__getstate__()
|
||||
if not isinstance(other, Pix):
|
||||
return NotImplemented
|
||||
same = ffi.new('l_int32 *', 0)
|
||||
with _LeptonicaErrorTrap():
|
||||
err = lept.pixEqual(self._cdata, other._cdata, same)
|
||||
if err:
|
||||
raise TypeError()
|
||||
return bool(same[0])
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self._pix.w
|
||||
return self._cdata.w
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self._pix.h
|
||||
return self._cdata.h
|
||||
|
||||
@property
|
||||
def depth(self):
|
||||
return self._pix.d
|
||||
return self._cdata.d
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return (self._pix.w, self._pix.h)
|
||||
return (self._cdata.w, self._cdata.h)
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return {'dpi': (self._pix.xres, self._pix.yres)}
|
||||
return {'dpi': (self._cdata.xres, self._cdata.yres)}
|
||||
|
||||
@property
|
||||
def mode(self):
|
||||
@@ -219,7 +268,7 @@ class Pix:
|
||||
return '1'
|
||||
elif self.depth >= 16:
|
||||
return 'RGB'
|
||||
elif not self._pix.colormap:
|
||||
elif not self._cdata.colormap:
|
||||
return 'L'
|
||||
else:
|
||||
return 'P'
|
||||
@@ -251,10 +300,21 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
lept.pixWriteImpliedFormat(
|
||||
os.fsencode(filename),
|
||||
self._pix, jpeg_quality, jpeg_progressive)
|
||||
self._cdata, jpeg_quality, jpeg_progressive)
|
||||
|
||||
@classmethod
|
||||
def frompil(self, pillow_image):
|
||||
"""Create a copy of a PIL.Image from this Pix"""
|
||||
bio = BytesIO()
|
||||
pillow_image.save(bio, format='png', compress_level=1)
|
||||
py_buffer = bio.getbuffer()
|
||||
c_buffer = ffi.from_buffer(py_buffer)
|
||||
with _LeptonicaErrorTrap():
|
||||
pix = Pix(lept.pixReadMem(c_buffer, len(c_buffer)))
|
||||
return pix
|
||||
|
||||
def topil(self):
|
||||
"Returns a PIL.Image version of this Pix"
|
||||
"""Returns a PIL.Image version of this Pix"""
|
||||
from PIL import Image
|
||||
|
||||
# Leptonica manages data in words, so it implicitly does an endian
|
||||
@@ -267,17 +327,17 @@ class Pix:
|
||||
raw_mode = 'ABGR'
|
||||
elif self.mode == '1':
|
||||
raw_mode = '1;I'
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._pix))
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._cdata))
|
||||
else:
|
||||
raw_mode = self.mode
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._pix))
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._cdata))
|
||||
else:
|
||||
raw_mode = self.mode # no endian swap needed
|
||||
|
||||
size = (pix._pix.w, pix._pix.h)
|
||||
bytecount = pix._pix.wpl * 4 * pix._pix.h
|
||||
buf = ffi.buffer(pix._pix.data, bytecount)
|
||||
stride = pix._pix.wpl * 4
|
||||
size = (pix._cdata.w, pix._cdata.h)
|
||||
bytecount = pix._cdata.wpl * 4 * pix._cdata.h
|
||||
buf = ffi.buffer(pix._cdata.data, bytecount)
|
||||
stride = pix._cdata.wpl * 4
|
||||
|
||||
im = Image.frombytes(self.mode, size, buf, 'raw', raw_mode, stride)
|
||||
|
||||
@@ -296,21 +356,21 @@ class Pix:
|
||||
for skew angle
|
||||
"""
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixDeskew(self._pix, reduction_factor))
|
||||
return Pix(lept.pixDeskew(self._cdata, reduction_factor))
|
||||
|
||||
def scale(self, scale_xy):
|
||||
"Returns the pix object rescaled according to the proportions given."
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixScale(self._pix, scale_xy[0], scale_xy[1]))
|
||||
return Pix(lept.pixScale(self._cdata, scale_xy[0], scale_xy[1]))
|
||||
|
||||
def rotate180(self):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixRotate180(ffi.NULL, self._pix))
|
||||
return Pix(lept.pixRotate180(ffi.NULL, self._cdata))
|
||||
|
||||
def rotate_orth(self, quads):
|
||||
"Orthographic rotation, quads: 0-3, number of clockwise rotations"
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixRotateOrth(self._pix, quads))
|
||||
return Pix(lept.pixRotateOrth(self._cdata, quads))
|
||||
|
||||
def find_skew(self):
|
||||
"""Returns a tuple (deskew angle in degrees, confidence value).
|
||||
@@ -320,7 +380,7 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
angle = ffi.new('float *', 0.0)
|
||||
confidence = ffi.new('float *', 0.0)
|
||||
result = lept.pixFindSkew(self._pix, angle, confidence)
|
||||
result = lept.pixFindSkew(self._cdata, angle, confidence)
|
||||
if result == 0:
|
||||
return (angle[0], confidence[0])
|
||||
else:
|
||||
@@ -328,7 +388,7 @@ class Pix:
|
||||
|
||||
def convert_rgb_to_luminance(self):
|
||||
with _LeptonicaErrorTrap():
|
||||
gray_pix = lept.pixConvertRGBToLuminance(self._pix)
|
||||
gray_pix = lept.pixConvertRGBToLuminance(self._cdata)
|
||||
if gray_pix:
|
||||
return Pix(gray_pix)
|
||||
return None
|
||||
@@ -342,7 +402,7 @@ class Pix:
|
||||
"""
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixRemoveColormapGeneral(
|
||||
self._pix, removal_type, lept.L_COPY))
|
||||
self._cdata, removal_type, lept.L_COPY))
|
||||
|
||||
def otsu_adaptive_threshold(
|
||||
self, tile_size=(300, 300), kernel_size=(4, 4), scorefract=0.1):
|
||||
@@ -351,8 +411,9 @@ class Pix:
|
||||
smoothx, smoothy = kernel_size
|
||||
p_pix = ffi.new('PIX **')
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
result = lept.pixOtsuAdaptiveThreshold(
|
||||
self._pix,
|
||||
pix._cdata,
|
||||
sx, sy,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
@@ -369,22 +430,42 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
sx, sy = tile_size
|
||||
smoothx, smoothy = kernel_size
|
||||
if mask is None:
|
||||
mask = ffi.NULL
|
||||
mask = ffi.NULL
|
||||
if isinstance(mask, Pix):
|
||||
mask = mask._pix
|
||||
mask = mask._cdata
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
thresh_pix = lept.pixOtsuThreshOnBackgroundNorm(
|
||||
self._pix,
|
||||
pix._cdata,
|
||||
mask,
|
||||
sx, sy,
|
||||
thresh, mincount, bgval,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
ffi.NULL
|
||||
)
|
||||
if thresh_pix == ffi.NULL:
|
||||
return None
|
||||
)
|
||||
return Pix(thresh_pix)
|
||||
|
||||
def masked_threshold_on_background_norm(
|
||||
self, mask=None, tile_size=(10, 15), thresh=100, mincount=50,
|
||||
kernel_size=(2, 2), scorefract=0.1):
|
||||
with _LeptonicaErrorTrap():
|
||||
sx, sy = tile_size
|
||||
smoothx, smoothy = kernel_size
|
||||
mask = ffi.NULL
|
||||
if isinstance(mask, Pix):
|
||||
mask = mask._cdata
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
thresh_pix = lept.pixMaskedThreshOnBackgroundNorm(
|
||||
pix._cdata,
|
||||
mask,
|
||||
sx, sy,
|
||||
thresh, mincount,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
ffi.NULL
|
||||
)
|
||||
return Pix(thresh_pix)
|
||||
|
||||
def crop_to_foreground(
|
||||
@@ -392,7 +473,7 @@ class Pix:
|
||||
showmorph=0, display=0, pdfdir=ffi.NULL):
|
||||
with _LeptonicaErrorTrap():
|
||||
cropbox = Box(lept.pixFindPageForeground(
|
||||
self._pix,
|
||||
self._cdata,
|
||||
threshold,
|
||||
mindist,
|
||||
erasedist,
|
||||
@@ -401,11 +482,9 @@ class Pix:
|
||||
display,
|
||||
pdfdir))
|
||||
|
||||
print(repr(cropbox))
|
||||
|
||||
cropped_pix = lept.pixClipRectangle(
|
||||
self._pix,
|
||||
cropbox._box,
|
||||
self._cdata,
|
||||
cropbox._cdata,
|
||||
ffi.NULL)
|
||||
|
||||
return Pix(cropped_pix)
|
||||
@@ -414,7 +493,7 @@ class Pix:
|
||||
self, mask=None, grayscale=None, gamma=1.0, black=0, white=255):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixCleanBackgroundToWhite(
|
||||
self._pix,
|
||||
self._cdata,
|
||||
mask or ffi.NULL,
|
||||
grayscale or ffi.NULL,
|
||||
gamma,
|
||||
@@ -425,7 +504,7 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixGammaTRC(
|
||||
ffi.NULL,
|
||||
self._pix,
|
||||
self._cdata,
|
||||
gamma,
|
||||
minval,
|
||||
maxval
|
||||
@@ -438,7 +517,7 @@ class Pix:
|
||||
target_pix = self.remove_colormap(lept.REMOVE_CMAP_BASED_ON_SRC)
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixBackgroundNorm(
|
||||
target_pix._pix,
|
||||
target_pix._cdata,
|
||||
mask or ffi.NULL,
|
||||
grayscale or ffi.NULL,
|
||||
tile_size[0],
|
||||
@@ -462,53 +541,86 @@ class Pix:
|
||||
# implementation of pixCorrelationBinary that overflows on larger
|
||||
# images. Ubuntu 14.04/trusty has 1.70. Ubuntu PPA
|
||||
# ppa:alex-p/tesseract-ocr has leptonlib 1.75.
|
||||
pix1_count = ffi.new('l_int32 *')
|
||||
pix2_count = ffi.new('l_int32 *')
|
||||
pixn_count = ffi.new('l_int32 *')
|
||||
tab8 = Pix.make_pixel_sum_tab8()
|
||||
raise LeptonicaError("Leptonica version is too old")
|
||||
|
||||
lept.pixCountPixels(pix1._pix, pix1_count, tab8)
|
||||
lept.pixCountPixels(pix2._pix, pix2_count, tab8)
|
||||
pixn = Pix(lept.pixAnd(ffi.NULL, pix1._pix, pix2._pix))
|
||||
lept.pixCountPixels(pixn._pix, pixn_count, tab8)
|
||||
|
||||
# Python converts these int32s to larger units as needed
|
||||
# to avoid overflow. Overflow happens easily here.
|
||||
correlation = (
|
||||
(pixn_count[0] * pixn_count[0]) /
|
||||
(pix1_count[0] * pix2_count[0])
|
||||
)
|
||||
return correlation
|
||||
else:
|
||||
correlation = ffi.new('float *', 0.0)
|
||||
result = lept.pixCorrelationBinary(pix1._pix, pix2._pix,
|
||||
correlation)
|
||||
if result != 0:
|
||||
raise LeptonicaError("Correlation failed")
|
||||
return correlation[0]
|
||||
correlation = ffi.new('float *', 0.0)
|
||||
result = lept.pixCorrelationBinary(pix1._cdata, pix2._cdata,
|
||||
correlation)
|
||||
if result != 0:
|
||||
raise LeptonicaError("Correlation failed")
|
||||
return correlation[0]
|
||||
|
||||
def generate_pdf_ci_data(self, type_, quality):
|
||||
"Convert to PDF data, with transcoding"
|
||||
p_compdata = ffi.new('L_COMP_DATA **')
|
||||
result = lept.pixGenerateCIData(self._pix, type_, quality, 0,
|
||||
result = lept.pixGenerateCIData(self._cdata, type_, quality, 0,
|
||||
p_compdata)
|
||||
if result != 0:
|
||||
raise LeptonicaError("Generate PDF data failed")
|
||||
return CompressedData(p_compdata[0])
|
||||
|
||||
def invert(self):
|
||||
return Pix(lept.pixInvert(ffi.NULL, self._pix))
|
||||
return Pix(lept.pixInvert(ffi.NULL, self._cdata))
|
||||
|
||||
@staticmethod
|
||||
def _pix_destroy(pix):
|
||||
p_pix = ffi.new('PIX **', pix)
|
||||
lept.pixDestroy(p_pix)
|
||||
# print('pix destroy ' + repr(pix))
|
||||
def locate_barcodes(self):
|
||||
try:
|
||||
with _LeptonicaErrorTrap():
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
pixa_candidates = PixArray(lept.pixExtractBarcodes(pix._cdata, 0))
|
||||
sarray = StringArray(lept.pixReadBarcodes(
|
||||
pixa_candidates._cdata,
|
||||
lept.L_BF_ANY,
|
||||
lept.L_USE_WIDTHS,
|
||||
ffi.NULL,
|
||||
0
|
||||
))
|
||||
except (LeptonicaError, ValueError) as e:
|
||||
return
|
||||
|
||||
for n, s in enumerate(sarray):
|
||||
decoded = s.decode()
|
||||
if decoded.strip() == '':
|
||||
continue
|
||||
box = pixa_candidates.get_box(n)
|
||||
left, top = box.x, box.y
|
||||
right, bottom = box.x + box.w, box.y + box.h
|
||||
yield (decoded, (left, top, right, bottom))
|
||||
|
||||
def despeckle(self, size):
|
||||
if size == 2:
|
||||
speckle2 = """
|
||||
oooo
|
||||
oC o
|
||||
o o
|
||||
oooo
|
||||
"""
|
||||
sel1 = Sel.from_selstr(speckle2, 'speckle2')
|
||||
sel2 = Sel.create_brick(2, 2, 0, 0, lept.SEL_HIT)
|
||||
elif size == 3:
|
||||
speckle3 = """
|
||||
ooooo
|
||||
oC o
|
||||
o o
|
||||
o o
|
||||
ooooo
|
||||
"""
|
||||
sel1 = Sel.from_selstr(speckle3, 'speckle3')
|
||||
sel2 = Sel.create_brick(3, 3, 0, 0, lept.SEL_HIT)
|
||||
else:
|
||||
raise ValueError(size)
|
||||
|
||||
pixhmt = Pix(lept.pixHMT(ffi.NULL, self._cdata, sel1._cdata))
|
||||
pixdilated = Pix(lept.pixDilate(ffi.NULL, pixhmt._cdata, sel2._cdata))
|
||||
|
||||
pixsub = Pix(lept.pixSubtract(ffi.NULL, self._cdata, pixdilated._cdata))
|
||||
return pixsub
|
||||
|
||||
|
||||
class CompressedData:
|
||||
def __init__(self, compdata):
|
||||
self._compdata = ffi.gc(compdata, CompressedData._destroy)
|
||||
class CompressedData(LeptonicaObject):
|
||||
"""Wrapper for L_COMP_DATA - abstract compressed image data"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'L_COMP_DATA'
|
||||
cdata_destroy = lept.l_CIDataDestroy
|
||||
|
||||
@classmethod
|
||||
def open(cls, path, jpeg_quality=75):
|
||||
@@ -523,64 +635,142 @@ class CompressedData:
|
||||
return CompressedData(p_compdata[0])
|
||||
|
||||
def __len__(self):
|
||||
return self._compdata.nbytescomp
|
||||
return self._cdata.nbytescomp
|
||||
|
||||
def read(self):
|
||||
buf = ffi.buffer(self._compdata.datacomp, self._compdata.nbytescomp)
|
||||
buf = ffi.buffer(self._cdata.datacomp, self._cdata.nbytescomp)
|
||||
return bytes(buf)
|
||||
|
||||
def __getattr__(self, name):
|
||||
if hasattr(self._compdata, name):
|
||||
return getattr(self._compdata, name)
|
||||
if hasattr(self._cdata, name):
|
||||
return getattr(self._cdata, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
def get_palette_pdf_string(self):
|
||||
"Returns palette pre-formatted for use in PDF"
|
||||
buflen = len('< ') + len(' rrggbb') * self._compdata.ncolors + len('>')
|
||||
buf = ffi.buffer(self._compdata.cmapdatahex, buflen)
|
||||
buflen = len('< ') + len(' rrggbb') * self._cdata.ncolors + len('>')
|
||||
buf = ffi.buffer(self._cdata.cmapdatahex, buflen)
|
||||
return bytes(buf)
|
||||
|
||||
@staticmethod
|
||||
def _destroy(compdata):
|
||||
pp = ffi.new('L_COMP_DATA **', compdata)
|
||||
lept.l_CIDataDestroy(pp)
|
||||
|
||||
class PixArray(LeptonicaObject, Sequence):
|
||||
"""Wrapper around PIXA (array of PIX)"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'PIXA'
|
||||
cdata_destroy = lept.pixaDestroy
|
||||
|
||||
def __len__(self):
|
||||
return self._cdata[0].n
|
||||
|
||||
def __getitem__(self, n):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixaGetPix(self._cdata, n, lept.L_CLONE))
|
||||
|
||||
def get_box(self, n):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Box(lept.pixaGetBox(self._cdata, n, lept.L_CLONE))
|
||||
|
||||
|
||||
class Box:
|
||||
"""Wrapper around Leptonica's BOX objects.
|
||||
class Box(LeptonicaObject):
|
||||
"""Wrapper around Leptonica's BOX objects (a pixel rectangle)
|
||||
|
||||
See class Pix for notes about reference counting.
|
||||
Uses x, y, w, h coordinates.
|
||||
"""
|
||||
|
||||
def __init__(self, box):
|
||||
self._box = ffi.gc(box, Box._box_destroy)
|
||||
LEPTONICA_TYPENAME = 'BOX'
|
||||
cdata_destroy = lept.boxDestroy
|
||||
|
||||
def __repr__(self):
|
||||
if self._box:
|
||||
if self._cdata:
|
||||
return '<leptonica.Box x={0} y={1} w={2} h={3}>'.format(
|
||||
self.x, self.y, self.w, self.h)
|
||||
return '<leptonica.Box NULL>'
|
||||
|
||||
@property
|
||||
def x(self):
|
||||
return self._box.x
|
||||
return self._cdata.x
|
||||
|
||||
@property
|
||||
def y(self):
|
||||
return self._box.y
|
||||
return self._cdata.y
|
||||
|
||||
@property
|
||||
def w(self):
|
||||
return self._box.w
|
||||
return self._cdata.w
|
||||
|
||||
@property
|
||||
def h(self):
|
||||
return self._box.h
|
||||
return self._cdata.h
|
||||
|
||||
@staticmethod
|
||||
def _box_destroy(box):
|
||||
p_box = ffi.new('BOX **', box)
|
||||
lept.boxDestroy(p_box)
|
||||
|
||||
class BoxArray(LeptonicaObject, Sequence):
|
||||
"""Wrapper around Leptonica's BOXA (Array of BOX) objects."""
|
||||
|
||||
LEPTONICA_TYPENAME = 'BOXA'
|
||||
cdata_destroy = lept.boxaDestroy
|
||||
|
||||
def __repr__(self):
|
||||
if not self._cdata:
|
||||
return '<BoxArray>'
|
||||
boxes = (repr(box) for box in self)
|
||||
return '<BoxArray [' + ', '.join(boxes) + ']>'
|
||||
|
||||
def __len__(self):
|
||||
return self._cdata.n
|
||||
|
||||
def __getitem__(self, n):
|
||||
if not isinstance(n, int):
|
||||
raise TypeError('list indices must be integers')
|
||||
if 0 <= n < len(self):
|
||||
return Box(lept.boxaGetBox(self._cdata, n, lept.L_CLONE))
|
||||
raise IndexError(n)
|
||||
|
||||
|
||||
class StringArray(LeptonicaObject, Sequence):
|
||||
"""Leptonica SARRAY/string array"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'SARRAY'
|
||||
cdata_destroy = lept.sarrayDestroy
|
||||
|
||||
def __len__(self):
|
||||
return self._cdata.n
|
||||
|
||||
def __getitem__(self, n):
|
||||
if 0 <= n < len(self):
|
||||
return ffi.string(self._cdata.array[n])
|
||||
raise IndexError(n)
|
||||
|
||||
|
||||
class Sel(LeptonicaObject):
|
||||
"""Leptonica 'sel'/selection element for hit-miss transform"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'SEL'
|
||||
cdata_destroy = lept.selDestroy
|
||||
|
||||
@classmethod
|
||||
def from_selstr(cls, selstr, name):
|
||||
# TODO this will strip a horizontal line of don't care's
|
||||
lines = [line.strip() for line in selstr.split('\n') if line.strip()]
|
||||
h = len(lines)
|
||||
w = len(lines[0])
|
||||
lengths = set(len(line) for line in lines)
|
||||
if len(lengths) != 1:
|
||||
raise ValueError("All lines in selstr must be same length")
|
||||
|
||||
repacked = ''.join(line.strip() for line in lines)
|
||||
buf_selstr = ffi.from_buffer(repacked.encode('ascii'))
|
||||
buf_name = ffi.from_buffer(name.encode('ascii'))
|
||||
sel = lept.selCreateFromString(buf_selstr, h, w, buf_name)
|
||||
return cls(sel)
|
||||
|
||||
@classmethod
|
||||
def create_brick(cls, h, w, cy, cx, type_):
|
||||
sel = lept.selCreateBrick(h, w, cy, cx, type_)
|
||||
return cls(sel)
|
||||
|
||||
def __repr__(self):
|
||||
selstr = ffi.gc(lept.selPrintToString(self._cdata), lept.lept_free)
|
||||
return '<Sel \n' + ffi.string(selstr).decode('ascii') + '\n>'
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,6 @@
|
||||
from cffi import FFI
|
||||
|
||||
ffibuilder = FFI()
|
||||
ffibuilder.set_source("ocrmypdf.lib._leptonica", None)
|
||||
ffibuilder.cdef("""
|
||||
typedef signed char l_int8;
|
||||
typedef unsigned char l_uint8;
|
||||
@@ -32,6 +31,8 @@ typedef double l_float64;
|
||||
typedef long long l_int64;
|
||||
typedef unsigned long long l_uint64;
|
||||
|
||||
typedef int l_ok; /*!< return type 0 if OK, 1 on error */
|
||||
|
||||
struct Pix
|
||||
{
|
||||
l_uint32 w; /* width in pixels */
|
||||
@@ -61,6 +62,17 @@ struct PixColormap
|
||||
};
|
||||
typedef struct PixColormap PIXCMAP;
|
||||
|
||||
/*! Array of pix */
|
||||
struct Pixa
|
||||
{
|
||||
l_int32 n; /*!< number of Pix in ptr array */
|
||||
l_int32 nalloc; /*!< number of Pix ptrs allocated */
|
||||
l_uint32 refcount; /*!< reference count (1 if no clones) */
|
||||
struct Pix **pix; /*!< the array of ptrs to pix */
|
||||
struct Boxa *boxa; /*!< array of boxes */
|
||||
};
|
||||
typedef struct Pixa PIXA;
|
||||
|
||||
struct Box
|
||||
{
|
||||
l_int32 x;
|
||||
@@ -72,6 +84,25 @@ struct Box
|
||||
};
|
||||
typedef struct Box BOX;
|
||||
|
||||
/*! Array of Box */
|
||||
struct Boxa
|
||||
{
|
||||
l_int32 n; /*!< number of box in ptr array */
|
||||
l_int32 nalloc; /*!< number of box ptrs allocated */
|
||||
l_uint32 refcount; /*!< reference count (1 if no clones) */
|
||||
struct Box **box; /*!< box ptr array */
|
||||
};
|
||||
typedef struct Boxa BOXA;
|
||||
|
||||
/*! String array: an array of C strings */
|
||||
struct Sarray
|
||||
{
|
||||
l_int32 nalloc; /*!< size of allocated ptr array */
|
||||
l_int32 n; /*!< number of strings allocated */
|
||||
l_int32 refcount; /*!< reference count (1 if no clones) */
|
||||
char **array; /*!< string array */
|
||||
};
|
||||
typedef struct Sarray SARRAY;
|
||||
|
||||
/*! Pdf formatted encoding types */
|
||||
enum {
|
||||
@@ -104,6 +135,18 @@ struct L_Compressed_Data
|
||||
};
|
||||
typedef struct L_Compressed_Data L_COMP_DATA;
|
||||
|
||||
/*! Selection */
|
||||
struct Sel
|
||||
{
|
||||
l_int32 sy; /*!< sel height */
|
||||
l_int32 sx; /*!< sel width */
|
||||
l_int32 cy; /*!< y location of sel origin */
|
||||
l_int32 cx; /*!< x location of sel origin */
|
||||
l_int32 **data; /*!< {0,1,2}; data[i][j] in [row][col] order */
|
||||
char *name; /*!< used to find sel by name */
|
||||
};
|
||||
typedef struct Sel SEL;
|
||||
|
||||
enum {
|
||||
REMOVE_CMAP_TO_BINARY = 0, /*!< remove colormap for conv to 1 bpp */
|
||||
REMOVE_CMAP_TO_GRAYSCALE = 1, /*!< remove colormap for conv to 8 bpp */
|
||||
@@ -122,10 +165,48 @@ enum {
|
||||
/*!< the array with clones (e.g., pix) */
|
||||
};
|
||||
|
||||
/*! Flags for method of extracting barcode widths */
|
||||
enum {
|
||||
L_USE_WIDTHS = 1, /*!< use histogram of barcode widths */
|
||||
L_USE_WINDOWS = 2 /*!< find best window for decoding transitions */
|
||||
};
|
||||
|
||||
/*! Flags for barcode formats */
|
||||
enum {
|
||||
L_BF_UNKNOWN = 0, /*!< unknown format */
|
||||
L_BF_ANY = 1, /*!< try decoding with all known formats */
|
||||
L_BF_CODE128 = 2, /*!< decode with Code128 format */
|
||||
L_BF_EAN8 = 3, /*!< decode with EAN8 format */
|
||||
L_BF_EAN13 = 4, /*!< decode with EAN13 format */
|
||||
L_BF_CODE2OF5 = 5, /*!< decode with Code 2 of 5 format */
|
||||
L_BF_CODEI2OF5 = 6, /*!< decode with Interleaved 2 of 5 format */
|
||||
L_BF_CODE39 = 7, /*!< decode with Code39 format */
|
||||
L_BF_CODE93 = 8, /*!< decode with Code93 format */
|
||||
L_BF_CODABAR = 9, /*!< decode with Code93 format */
|
||||
L_BF_UPCA = 10 /*!< decode with UPC A format */
|
||||
};
|
||||
|
||||
enum {
|
||||
L_SEVERITY_EXTERNAL = 0, /* Get the severity from the environment */
|
||||
L_SEVERITY_ALL = 1, /* Lowest severity: print all messages */
|
||||
L_SEVERITY_DEBUG = 2, /* Print debugging and higher messages */
|
||||
L_SEVERITY_INFO = 3, /* Print informational and higher messages */
|
||||
L_SEVERITY_WARNING = 4, /* Print warning and higher messages */
|
||||
L_SEVERITY_ERROR = 5, /* Print error and higher messages */
|
||||
L_SEVERITY_NONE = 6 /* Highest severity: print no messages */
|
||||
};
|
||||
|
||||
enum {
|
||||
SEL_DONT_CARE = 0,
|
||||
SEL_HIT = 1,
|
||||
SEL_MISS = 2
|
||||
};
|
||||
|
||||
""")
|
||||
|
||||
ffibuilder.cdef("""
|
||||
PIX * pixRead ( const char *filename );
|
||||
PIX * pixReadMem ( const l_uint8 *data, size_t size );
|
||||
PIX * pixScale ( PIX *pixs, l_float32 scalex, l_float32 scaley );
|
||||
l_int32 pixFindSkew ( PIX *pixs, l_float32 *pangle, l_float32 *pconf );
|
||||
l_int32 pixWriteImpliedFormat ( const char *filename, PIX *pix, l_int32 quality, l_int32 progressive );
|
||||
@@ -137,6 +218,11 @@ pixWriteMemPng(l_uint8 **pdata,
|
||||
|
||||
void pixDestroy ( PIX **ppix );
|
||||
|
||||
l_ok
|
||||
pixEqual(PIX *pix1,
|
||||
PIX *pix2,
|
||||
l_int32 *psame);
|
||||
|
||||
PIX *
|
||||
pixEndianByteSwapNew(PIX *pixs);
|
||||
|
||||
@@ -157,6 +243,8 @@ l_int32 pixSerializeToMemory ( PIX *pixs, l_uint32 **pdata, size_t *pnbytes );
|
||||
|
||||
PIX * pixConvertRGBToLuminance(PIX *pixs);
|
||||
|
||||
PIX * pixConvertTo8(PIX *pixs, l_int32 cmapflag);
|
||||
|
||||
PIX * pixRemoveColormap(PIX *pixs, l_int32 type);
|
||||
|
||||
l_int32
|
||||
@@ -182,6 +270,18 @@ pixOtsuThreshOnBackgroundNorm(PIX *pixs,
|
||||
l_float32 scorefract,
|
||||
l_int32 *pthresh);
|
||||
|
||||
PIX *
|
||||
pixMaskedThreshOnBackgroundNorm(PIX *pixs,
|
||||
PIX *pixim,
|
||||
l_int32 sx,
|
||||
l_int32 sy,
|
||||
l_int32 thresh,
|
||||
l_int32 mincount,
|
||||
l_int32 smoothx,
|
||||
l_int32 smoothy,
|
||||
l_float32 scorefract,
|
||||
l_int32 *pthresh);
|
||||
|
||||
PIX *
|
||||
pixCleanBackgroundToWhite(PIX *pixs,
|
||||
PIX *pixim,
|
||||
@@ -265,13 +365,13 @@ pixGetAverageMaskedRGB(PIX *pixs,
|
||||
l_float32 *pgval,
|
||||
l_float32 *pbval);
|
||||
|
||||
PIX *
|
||||
PIX *
|
||||
pixGlobalNormRGB(PIX * pixd,
|
||||
PIX * pixs,
|
||||
l_int32 rval,
|
||||
l_int32 gval,
|
||||
l_int32 bval,
|
||||
l_int32 mapval);
|
||||
l_int32 mapval);
|
||||
|
||||
PIX *
|
||||
pixInvert(PIX * pixd,
|
||||
@@ -289,22 +389,101 @@ pixGenerateCIData(PIX *pixs,
|
||||
l_int32 ascii85,
|
||||
L_COMP_DATA **pcid);
|
||||
|
||||
l_int32
|
||||
l_generateCIDataForPdf(const char *fname,
|
||||
PIX *pix,
|
||||
l_int32 quality,
|
||||
SARRAY *
|
||||
pixProcessBarcodes(PIX *pixs,
|
||||
l_int32 format,
|
||||
l_int32 method,
|
||||
SARRAY **psaw,
|
||||
l_int32 debugflag);
|
||||
|
||||
PIX *
|
||||
pixaGetPix(PIXA *pixa,
|
||||
l_int32 index,
|
||||
l_int32 accesstype);
|
||||
|
||||
BOX*
|
||||
pixaGetBox (PIXA * pixa,
|
||||
l_int32 index,
|
||||
l_int32 accesstype );
|
||||
|
||||
PIXA *
|
||||
pixExtractBarcodes(PIX *pixs,
|
||||
l_int32 debugflag);
|
||||
|
||||
BOXA *
|
||||
pixLocateBarcodes ( PIX *pixs, l_int32 thresh, PIX **ppixb, PIX **ppixm );
|
||||
|
||||
SARRAY *
|
||||
pixReadBarcodes(PIXA *pixa,
|
||||
l_int32 format,
|
||||
l_int32 method,
|
||||
SARRAY **psaw,
|
||||
l_int32 debugflag);
|
||||
|
||||
l_int32
|
||||
l_generateCIDataForPdf(const char *fname,
|
||||
PIX *pix,
|
||||
l_int32 quality,
|
||||
L_COMP_DATA **pcid);
|
||||
|
||||
void
|
||||
|
||||
BOX *
|
||||
boxClone ( BOX *box );
|
||||
|
||||
BOX *
|
||||
boxaGetBox ( BOXA *boxa, l_int32 index, l_int32 accessflag );
|
||||
|
||||
SEL *
|
||||
selCreateFromString ( const char *text, l_int32 h, l_int32 w, const char *name );
|
||||
|
||||
SEL *
|
||||
selCreateBrick ( l_int32 h, l_int32 w, l_int32 cy, l_int32 cx, l_int32 type );
|
||||
|
||||
char *
|
||||
selPrintToString(SEL *sel);
|
||||
|
||||
PIX *
|
||||
pixDilate ( PIX *pixd, PIX *pixs, SEL *sel );
|
||||
|
||||
PIX *
|
||||
pixErode ( PIX *pixd, PIX *pixs, SEL *sel );
|
||||
|
||||
PIX *
|
||||
pixHMT ( PIX *pixd, PIX *pixs, SEL *sel );
|
||||
|
||||
PIX *
|
||||
pixSubtract ( PIX *pixd, PIX *pixs1, PIX *pixs2 );
|
||||
|
||||
void
|
||||
boxDestroy(BOX **pbox);
|
||||
|
||||
void
|
||||
boxaDestroy ( BOXA **pboxa );
|
||||
|
||||
void
|
||||
pixaDestroy(PIXA **ppixa);
|
||||
|
||||
l_ok
|
||||
pixRenderBoxa ( PIX *pix, BOXA *boxa, l_int32 width, l_int32 op );
|
||||
|
||||
void
|
||||
l_CIDataDestroy(L_COMP_DATA **pcid);
|
||||
|
||||
void
|
||||
lept_free(void *ptr);
|
||||
sarrayDestroy(SARRAY **psa);
|
||||
|
||||
void
|
||||
lept_free(void *ptr);
|
||||
|
||||
void selDestroy ( SEL **psel );
|
||||
|
||||
l_int32
|
||||
setMsgSeverity(l_int32 newsev);
|
||||
|
||||
""")
|
||||
|
||||
|
||||
ffibuilder.set_source("ocrmypdf.lib._leptonica", None)
|
||||
|
||||
if __name__ == '__main__':
|
||||
ffibuilder.compile(verbose=True)
|
||||
|
||||
+183
-92
@@ -17,27 +17,21 @@
|
||||
|
||||
from pathlib import Path
|
||||
import concurrent.futures
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import pikepdf
|
||||
try:
|
||||
from pikepdf import Null # pikepdf <= 0.2.2
|
||||
except ImportError:
|
||||
def Null(): # pikepdf > 0.2.2
|
||||
return None
|
||||
|
||||
from ._jobcontext import JobContext
|
||||
from . import leptonica
|
||||
from .helpers import re_symlink, fspath
|
||||
from .exec import pngquant, jbig2enc
|
||||
|
||||
PAGE_GROUP_SIZE = 10
|
||||
DEFAULT_JPEG_QUALITY = 75
|
||||
DEFAULT_PNG_QUALITY = (65, 75)
|
||||
DEFAULT_PNG_QUALITY = 70
|
||||
|
||||
|
||||
def img_name(root, xref, ext):
|
||||
@@ -56,26 +50,34 @@ def tif_name(root, xref):
|
||||
return img_name(root, xref, '.tif')
|
||||
|
||||
|
||||
def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
pngs, jpegs, options):
|
||||
def extract_image_filter(pike, root, log, image, xref):
|
||||
if image.Subtype != '/Image':
|
||||
return False
|
||||
return None
|
||||
if image.Length < 100:
|
||||
log.debug("Skipping small image, xref {}".format(xref))
|
||||
return False
|
||||
log.debug("Skipping small image, xref %s", xref)
|
||||
return None
|
||||
|
||||
pim = pikepdf.PdfImage(image)
|
||||
|
||||
if len(pim.filter_decodeparms) > 1:
|
||||
log.debug("Skipping multiply filtered, xref {}".format(xref))
|
||||
return False
|
||||
log.debug("Skipping multiply filtered, xref %s", xref)
|
||||
return None
|
||||
filtdp = pim.filter_decodeparms[0]
|
||||
|
||||
if pim.bits_per_component > 8:
|
||||
return False # Don't mess with wide gamut images
|
||||
return None # Don't mess with wide gamut images
|
||||
|
||||
if filtdp[0] == '/JPXDecode':
|
||||
return False # Don't do JPEG2000
|
||||
return None # Don't do JPEG2000
|
||||
|
||||
return pim, filtdp
|
||||
|
||||
|
||||
def extract_image_jbig2(*, pike, root, log, image, xref, options):
|
||||
result = extract_image_filter(pike, root, log, image, xref)
|
||||
if result is None:
|
||||
return None
|
||||
pim, filtdp = result
|
||||
|
||||
if pim.bits_per_component == 1 \
|
||||
and filtdp != '/JBIG2Decode' \
|
||||
@@ -86,9 +88,18 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
ext = pim.extract_to(stream=f)
|
||||
imgname.rename(imgname.with_suffix(ext))
|
||||
except pikepdf.UnsupportedImageTypeError:
|
||||
return False
|
||||
jbig2s.append((xref, ext))
|
||||
elif filtdp[0] == '/DCTDecode' \
|
||||
return None
|
||||
return xref, ext
|
||||
return None
|
||||
|
||||
|
||||
def extract_image_generic(*, pike, root, log, image, xref, options):
|
||||
result = extract_image_filter(pike, root, log, image, xref)
|
||||
if result is None:
|
||||
return None
|
||||
pim, filtdp = result
|
||||
|
||||
if filtdp[0] == '/DCTDecode' \
|
||||
and options.optimize >= 2:
|
||||
# This is a simple heuristic derived from some training data, that has
|
||||
# about a 70% chance of guessing whether the JPEG is high quality,
|
||||
@@ -97,7 +108,7 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
# bytes_per_pixel = int(raw_jpeg.Length) / (w * h)
|
||||
# jpeg_quality_estimate = 117.0 * (bytes_per_pixel ** 0.213)
|
||||
# if jpeg_quality_estimate < 65:
|
||||
# return False
|
||||
# return None
|
||||
|
||||
# We could get the ICC profile here, but there's no need to look at it
|
||||
# for quality transcoding
|
||||
@@ -112,88 +123,110 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
ext = pim.extract_to(stream=f)
|
||||
imgname.rename(imgname.with_suffix(ext))
|
||||
except pikepdf.UnsupportedImageTypeError:
|
||||
return False
|
||||
jpegs.append(xref)
|
||||
return None
|
||||
return xref, ext
|
||||
elif pim.indexed \
|
||||
and pim.colorspace in pim.SIMPLE_COLORSPACES \
|
||||
and options.optimize >= 3:
|
||||
# Try to improve on indexed images - these are far from low hanging
|
||||
# fruit in most cases
|
||||
pim.as_pil_image().save(png_name(root, xref))
|
||||
pngs.append(xref)
|
||||
return xref, '.png'
|
||||
elif not pim.indexed and pim.colorspace in pim.SIMPLE_COLORSPACES:
|
||||
# An optimization opportunity here, not currently taken, is directly
|
||||
# generating a PNG from compressed data
|
||||
pim.as_pil_image().save(png_name(root, xref))
|
||||
pngs.append(xref)
|
||||
else:
|
||||
return False
|
||||
return xref, '.png'
|
||||
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def extract_images(pike, root, log, options):
|
||||
# Extract images we can improve
|
||||
changed_xrefs = set()
|
||||
jbig2_groups = defaultdict(lambda: [])
|
||||
jpegs = []
|
||||
pngs = []
|
||||
|
||||
def extract_images(pike, root, log, options, extract_fn):
|
||||
"""Extract image using extract_fn
|
||||
|
||||
extract_fn decides where the image is interesting in this case
|
||||
"""
|
||||
|
||||
include_xrefs = set()
|
||||
exclude_xrefs = set()
|
||||
errors = 0
|
||||
for pageno, page in enumerate(pike.pages):
|
||||
group, _ = divmod(pageno, PAGE_GROUP_SIZE)
|
||||
try:
|
||||
xobjs = page.Resources.XObject
|
||||
except AttributeError:
|
||||
continue
|
||||
for imname, image in dict(xobjs).items():
|
||||
for _imname, image in dict(xobjs).items():
|
||||
if image.objgen[1] != 0:
|
||||
continue # Ignore images in an incremental PDF
|
||||
xref = image.objgen[0]
|
||||
if xref in changed_xrefs:
|
||||
continue # Don't improve same image twice
|
||||
try:
|
||||
result = extract_image(
|
||||
pike=pike, root=root, log=log, image=image,
|
||||
xref=xref, jbig2s=jbig2_groups[group], pngs=pngs,
|
||||
jpegs=jpegs, options=options
|
||||
)
|
||||
if result:
|
||||
changed_xrefs.add(xref)
|
||||
except Exception as e:
|
||||
log.debug("Image {} xref {}".format(imname, xref))
|
||||
log.debug(repr(e))
|
||||
errors += 1
|
||||
if hasattr(image, 'SMask'):
|
||||
# Ignore soft masks
|
||||
smask_xref = image.SMask.objgen[0]
|
||||
exclude_xrefs.add(smask_xref)
|
||||
include_xrefs.add(xref)
|
||||
|
||||
working_xrefs = include_xrefs - exclude_xrefs
|
||||
for xref in working_xrefs:
|
||||
image = pike.get_object((xref, 0))
|
||||
try:
|
||||
result = extract_fn(
|
||||
pike=pike, root=root, log=log, image=image,
|
||||
xref=xref, options=options
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug("Image xref %s", xref)
|
||||
log.debug(repr(e))
|
||||
errors += 1
|
||||
else:
|
||||
if result:
|
||||
_, ext = result
|
||||
yield pageno, xref, ext
|
||||
|
||||
|
||||
def extract_images_generic(pike, root, log, options):
|
||||
"""Extract any >=2bpp image we think we can improve"""
|
||||
|
||||
jpegs = []
|
||||
pngs = []
|
||||
for _, xref, ext in extract_images(
|
||||
pike, root, log, options, extract_image_generic):
|
||||
log.debug('xref = %s ext = %s', xref, ext)
|
||||
if ext == '.png':
|
||||
pngs.append(xref)
|
||||
elif ext == '.jpg':
|
||||
jpegs.append(xref)
|
||||
log.debug(
|
||||
"Optimizable images: "
|
||||
"JPEGs: %s PNGs: %s", len(jpegs), len(pngs)
|
||||
)
|
||||
return jpegs, pngs
|
||||
|
||||
|
||||
def extract_images_jbig2(pike, root, log, options):
|
||||
"""Extract any bitonal image that we think we can improve as JBIG2"""
|
||||
|
||||
jbig2_groups = defaultdict(list)
|
||||
for pageno, xref, ext in extract_images(
|
||||
pike, root, log, options, extract_image_jbig2):
|
||||
group = pageno // options.jbig2_page_group_size
|
||||
jbig2_groups[group].append((xref, ext))
|
||||
|
||||
# Elide empty groups
|
||||
jbig2_groups = {group: xrefs for group, xrefs in jbig2_groups.items()
|
||||
if len(xrefs) > 0}
|
||||
log.debug(
|
||||
"Optimizable images: "
|
||||
"JBIG2 groups: {} JPEGs: {} PNGs: {} Errors: {}".format(
|
||||
len(jbig2_groups), len(jpegs), len(pngs), errors
|
||||
))
|
||||
|
||||
return jbig2_groups, jpegs, pngs
|
||||
"JBIG2 groups: %s", (len(jbig2_groups),)
|
||||
)
|
||||
return jbig2_groups
|
||||
|
||||
|
||||
def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
"""
|
||||
Convert a group of JBIG2 images and insert into PDF.
|
||||
def _produce_jbig2_images(jbig2_groups, root, log, options):
|
||||
"""Produce JBIG2 images from their groups"""
|
||||
|
||||
We use a group because JBIG2 works best with a symbol dictionary that spans
|
||||
multiple pages. When inserted back into the PDF, each JBIG2 must reference
|
||||
the symbol dictionary it is associated with. So convert a group at a time,
|
||||
and replace their streams with a parameter set that points to the
|
||||
appropriate dictionary.
|
||||
|
||||
If too many pages shared the same dictionary JBIG2 encoding becomes more
|
||||
expensive and less efficient.
|
||||
|
||||
"""
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=options.jobs) as executor:
|
||||
futures = []
|
||||
for group, xref_exts in jbig2_groups.items():
|
||||
def jbig2_group_futures(executor, root, groups):
|
||||
for group, xref_exts in groups.items():
|
||||
prefix = 'group{:08d}'.format(group)
|
||||
future = executor.submit(
|
||||
jbig2enc.convert_group,
|
||||
@@ -201,15 +234,64 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
infiles=(img_name(root, xref, ext) for xref, ext in xref_exts),
|
||||
out_prefix=prefix
|
||||
)
|
||||
futures.append(future)
|
||||
yield future
|
||||
|
||||
def jbig2_single_futures(executor, root, groups):
|
||||
for group, xref_exts in groups.items():
|
||||
prefix = 'group{:08d}'.format(group)
|
||||
# Second loop is to ensure multiple images per page are unpacked
|
||||
for n, xref_ext in enumerate(xref_exts):
|
||||
xref, ext = xref_ext
|
||||
future = executor.submit(
|
||||
jbig2enc.convert_single,
|
||||
cwd=fspath(root),
|
||||
infile=img_name(root, xref, ext),
|
||||
outfile=root / ('{}.{:04d}'.format(prefix, n))
|
||||
)
|
||||
yield future
|
||||
|
||||
if options.jbig2_page_group_size > 1:
|
||||
jbig2_futures = jbig2_group_futures
|
||||
else:
|
||||
jbig2_futures = jbig2_single_futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=options.jobs) as executor:
|
||||
futures = jbig2_futures(executor, root, jbig2_groups)
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
proc = future.result()
|
||||
log.debug(proc.stderr.decode())
|
||||
|
||||
|
||||
def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
"""Convert images to JBIG2 and insert into PDF.
|
||||
|
||||
When the JBIG2 page group size is > 1 we do several JBIG2 images at once
|
||||
and build a symbol dictionary that will span several pages. Each JBIG2
|
||||
image must reference to its symbol dictionary. If too many pages shared the
|
||||
same dictionary JBIG2 encoding becomes more expensive and less efficient.
|
||||
The default value of 10 was determined through testing. Currently this
|
||||
must be lossy encoding since jbig2enc does not support refinement coding.
|
||||
|
||||
When the JBIG2 symbolic coder is not used, each JBIG2 stands on its own
|
||||
and needs no dictionary. Currently this is must be lossless JBIG2.
|
||||
"""
|
||||
|
||||
_produce_jbig2_images(jbig2_groups, root, log, options)
|
||||
|
||||
for group, xref_exts in jbig2_groups.items():
|
||||
prefix = 'group{:08d}'.format(group)
|
||||
jbig2_globals_data = (root / (prefix + '.sym')).read_bytes()
|
||||
jbig2_globals = pikepdf.Stream(pike, jbig2_globals_data)
|
||||
jbig2_symfile = root / (prefix + '.sym')
|
||||
if jbig2_symfile.exists():
|
||||
jbig2_globals_data = jbig2_symfile.read_bytes()
|
||||
jbig2_globals = pikepdf.Stream(pike, jbig2_globals_data)
|
||||
jbig2_globals_dict = pikepdf.Dictionary({
|
||||
'/JBIG2Globals': jbig2_globals
|
||||
})
|
||||
elif options.jbig2_page_group_size == 1:
|
||||
jbig2_globals_dict = None
|
||||
else:
|
||||
raise FileNotFoundError(jbig2_symfile)
|
||||
|
||||
for n, xref_ext in enumerate(xref_exts):
|
||||
xref, _ = xref_ext
|
||||
@@ -218,9 +300,7 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
im_obj = pike.get_object(xref, 0)
|
||||
im_obj.write(
|
||||
jbig2_im_data, pikepdf.Name('/JBIG2Decode'),
|
||||
pikepdf.Dictionary({
|
||||
'/JBIG2Globals': jbig2_globals
|
||||
})
|
||||
jbig2_globals_dict
|
||||
)
|
||||
|
||||
|
||||
@@ -237,16 +317,15 @@ def transcode_jpegs(pike, jpegs, root, log, options):
|
||||
im.save(fspath(opt_jpg),
|
||||
optimize=True,
|
||||
quality=options.jpeg_quality)
|
||||
# pylint: disable=E1101
|
||||
# pylint: disable=no-member
|
||||
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
||||
log.debug("xref {}, jpeg, made larger - skip".format(xref))
|
||||
log.debug("xref %s, jpeg, made larger - skip", xref)
|
||||
continue
|
||||
|
||||
compdata = leptonica.CompressedData.open(opt_jpg)
|
||||
im_obj = pike.get_object(xref, 0)
|
||||
im_obj.write(
|
||||
compdata.read(), pikepdf.Name('/DCTDecode'),
|
||||
Null()
|
||||
compdata.read(), filter=pikepdf.Name('/DCTDecode')
|
||||
)
|
||||
|
||||
|
||||
@@ -286,7 +365,7 @@ def transcode_pngs(pike, pngs, root, log, options):
|
||||
if len(compdata) > int(im_obj.stream_dict.Length):
|
||||
continue # If we produced a larger image, don't use
|
||||
|
||||
predictor = Null()
|
||||
predictor = None
|
||||
if compdata.predictor > 0:
|
||||
predictor = pikepdf.Dictionary({'/Predictor': compdata.predictor})
|
||||
|
||||
@@ -309,7 +388,10 @@ def transcode_pngs(pike, pngs, root, log, options):
|
||||
elif compdata.spp == 4:
|
||||
cs = pikepdf.Name('/DeviceCMYK')
|
||||
im_obj.ColorSpace = cs
|
||||
im_obj.write(compdata.read(), pikepdf.Name('/FlateDecode'), predictor)
|
||||
im_obj.write(
|
||||
compdata.read(),
|
||||
filter=pikepdf.Name('/FlateDecode'), decode_parms=predictor
|
||||
)
|
||||
|
||||
|
||||
def optimize(
|
||||
@@ -329,21 +411,26 @@ def optimize(
|
||||
if options.png_quality == 0:
|
||||
options.png_quality = \
|
||||
DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
||||
if options.jbig2_page_group_size == 0:
|
||||
options.jbig2_page_group_size = \
|
||||
10 if options.jbig2_lossy else 1
|
||||
|
||||
pike = pikepdf.Pdf.open(input_file)
|
||||
|
||||
root = Path(output_file).parent / 'images'
|
||||
root.mkdir(exist_ok=True) # pylint: disable=E1101
|
||||
jbig2_groups, jpegs, pngs = extract_images(
|
||||
pike, root, log, options)
|
||||
root.mkdir(exist_ok=True) # pylint: disable=no-member
|
||||
|
||||
convert_to_jbig2(pike, jbig2_groups, root, log, options)
|
||||
jpegs, pngs = extract_images_generic(pike, root, log, options)
|
||||
transcode_jpegs(pike, jpegs, root, log, options)
|
||||
transcode_pngs(pike, pngs, root, log, options)
|
||||
|
||||
# Not object_stream_mode + preserve_pdfa generates noncompliant PDFs
|
||||
jbig2_groups = extract_images_jbig2(pike, root, log, options)
|
||||
convert_to_jbig2(pike, jbig2_groups, root, log, options)
|
||||
|
||||
target_file = Path(output_file).with_suffix('.opt.pdf')
|
||||
pike.save(target_file, preserve_pdfa=True)
|
||||
pike.remove_unreferenced_resources()
|
||||
pike.save(target_file, preserve_pdfa=True,
|
||||
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||
|
||||
input_size = Path(input_file).stat().st_size
|
||||
output_size = Path(target_file).stat().st_size
|
||||
@@ -366,11 +453,14 @@ def main(infile, outfile, level, jobs=1):
|
||||
class OptimizeOptions:
|
||||
"""Emulate ocrmypdf's options"""
|
||||
|
||||
def __init__(self, jobs, optimize, jpeg_quality, png_quality):
|
||||
def __init__(
|
||||
self, jobs, optimize, jpeg_quality, png_quality, jb2lossy):
|
||||
self.jobs = jobs
|
||||
self.optimize = optimize
|
||||
self.jpeg_quality = jpeg_quality
|
||||
self.png_quality = png_quality
|
||||
self.jbig2_page_group_size = 0
|
||||
self.jbig2_lossy = jb2lossy
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
log = logging.getLogger()
|
||||
@@ -380,7 +470,8 @@ def main(infile, outfile, level, jobs=1):
|
||||
jobs=jobs,
|
||||
optimize=int(level),
|
||||
jpeg_quality=0, # Use default
|
||||
png_quality=0
|
||||
png_quality=0,
|
||||
jb2lossy=False
|
||||
)
|
||||
ctx.set_options(options)
|
||||
|
||||
|
||||
+66
-16
@@ -15,12 +15,25 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Generate a PDFA_def.ps file for Ghostscript >= 9.14
|
||||
"""
|
||||
Generate a PDFMARK file for Ghostscript >= 9.14, for PDF/A conversion
|
||||
|
||||
pdfmark is an extension to the Postscript language that describes some PDF
|
||||
features like bookmarks and annotations. It was originally specified Adobe
|
||||
Distiller, for Postscript to PDF conversion:
|
||||
https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/pdfmark_reference.pdf
|
||||
|
||||
Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript
|
||||
to create a PDF/A, we need to create a pdfmark file with the necessary metadata.
|
||||
|
||||
This takes care of the many version-specific bugs and pecularities in
|
||||
Ghostscript's handling of pdfmark.
|
||||
|
||||
"""
|
||||
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
from string import Template
|
||||
import pkg_resources
|
||||
import os
|
||||
@@ -28,7 +41,6 @@ import os
|
||||
from libxmp.utils import file_to_dict
|
||||
from libxmp import consts
|
||||
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
ICC_PROFILE_RELPATH = 'data/sRGB.icc'
|
||||
|
||||
@@ -78,7 +90,8 @@ def
|
||||
|
||||
|
||||
def encode_text_string(s: str) -> str:
|
||||
'''Encode text string to hex string for use in a PDF
|
||||
"""
|
||||
Encode text string to hex string for use in a PDF
|
||||
|
||||
From PDF 32000-1:2008 a string object may be included in hexademical form
|
||||
if it is enclosed in angle brackets. For general Unicode the string should
|
||||
@@ -86,7 +99,7 @@ def encode_text_string(s: str) -> str:
|
||||
ASCII strings could be encoded as PdfDocEncoding literals provided
|
||||
that certain Postscript sequences are escaped. But it's far simpler to
|
||||
encode everything as UTF-16.
|
||||
'''
|
||||
"""
|
||||
|
||||
# Sometimes lazy C programmers leave their NULs at the end of strings they
|
||||
# insert into PDFs
|
||||
@@ -102,8 +115,28 @@ def encode_text_string(s: str) -> str:
|
||||
return ascii_hex_str
|
||||
|
||||
|
||||
def _encode_ascii(s: str) -> str:
|
||||
"""
|
||||
Aggressively strip non-ASCII and PDF escape sequences
|
||||
|
||||
Ghostscript 9.24+ lost support for UTF-16BE in pdfmark files for reasons
|
||||
given in GhostPDL commit e997c683. Our temporary workaround is use ASCII
|
||||
and drop all non-ASCII characters. A slightly improved alternative would
|
||||
be to implement PdfDocEncoding in pikepdf and encode to that, or handle
|
||||
metadata there.
|
||||
"""
|
||||
trans = str.maketrans({
|
||||
'(': '',
|
||||
')': '',
|
||||
'\\': '',
|
||||
'\0': ''
|
||||
})
|
||||
return s.translate(trans).encode('ascii', errors='replace').decode()
|
||||
|
||||
|
||||
def encode_pdf_date(d: datetime) -> str:
|
||||
"""Encode Python datetime object as PDF date string
|
||||
"""
|
||||
Encode Python datetime object as PDF date string
|
||||
|
||||
From Adobe pdfmark manual:
|
||||
(D:YYYYMMDDHHmmSSOHH'mm')
|
||||
@@ -137,6 +170,13 @@ def encode_pdf_date(d: datetime) -> str:
|
||||
|
||||
|
||||
def decode_pdf_date(s: str) -> datetime:
|
||||
"""
|
||||
Decode a pdfmark date to a Python datetime object
|
||||
|
||||
A pdfmark date is a string in a paritcular format. See the pdfmark
|
||||
Reference for the specification.
|
||||
|
||||
"""
|
||||
if s.startswith('D:'):
|
||||
s = s[2:]
|
||||
|
||||
@@ -153,11 +193,13 @@ def decode_pdf_date(s: str) -> datetime:
|
||||
|
||||
|
||||
def _get_pdfmark_dates(pdfmark):
|
||||
"""Encode dates for pdfmark Postscript. The best way to deal with a
|
||||
missing date entry is set it to null, because if the key is omitted
|
||||
Ghostscript will set it to now - we do not want to erase the fact that
|
||||
the value was unknown. Setting to an empty string breaks Ghostscript
|
||||
9.22 as reported here:
|
||||
"""
|
||||
Encode dates in the expected format for pdfmark Postscript
|
||||
|
||||
The best way to deal with amissing date entry is set it to null, because if
|
||||
the key is omitted Ghostscript will set it to now - we do not want to erase
|
||||
the fact that the value was unknown. Setting to an empty string breaks
|
||||
Ghostscript 9.22 as reported here:
|
||||
https://bugs.ghostscript.com/show_bug.cgi?id=699182
|
||||
"""
|
||||
|
||||
@@ -178,7 +220,7 @@ def _get_pdfmark_dates(pdfmark):
|
||||
yield ' {} null'.format(key)
|
||||
|
||||
|
||||
def _get_pdfa_def(icc_profile, icc_identifier, pdfmark):
|
||||
def _get_pdfa_def(icc_profile, icc_identifier, pdfmark, ascii_docinfo=False):
|
||||
"""
|
||||
Create a Postscript pdfmark file for Ghostscript.
|
||||
|
||||
@@ -188,6 +230,9 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark):
|
||||
:param icc_profile: filename of the ICC profile to include in pdfmark
|
||||
:param icc_identifier: ICC identifier such as 'sRGB'
|
||||
:param pdfmark: a dictionary containing keys to include the pdfmark
|
||||
:param ascii_docinfo: if True, the docinfo block must be encoded in pure
|
||||
ASCII and may not contain UTF-16BE-BOM-hex encoded strings, as
|
||||
required for Ghostscript 9.24+
|
||||
|
||||
:returns: a string containing the entire pdfmark
|
||||
|
||||
@@ -198,14 +243,19 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark):
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=697684
|
||||
# Work around this by only adding keys that have a nontrivial value
|
||||
docinfo_keys = ('/Title', '/Author', '/Subject', '/Creator', '/Keywords')
|
||||
docinfo_line_template = ' {key} <{value}>'
|
||||
|
||||
def docinfo_gen():
|
||||
if not ascii_docinfo:
|
||||
docinfo_line_template = ' {key} <{value}>'
|
||||
encode = encode_text_string
|
||||
else:
|
||||
docinfo_line_template = ' {key} ({value})'
|
||||
encode = _encode_ascii
|
||||
yield from _get_pdfmark_dates(pdfmark)
|
||||
for key in docinfo_keys:
|
||||
if key in pdfmark and pdfmark[key].strip() != '':
|
||||
line = docinfo_line_template.format(
|
||||
key=key, value=encode_text_string(pdfmark[key]))
|
||||
key=key, value=encode(pdfmark[key]))
|
||||
yield line
|
||||
docinfo = '\n'.join(docinfo_gen())
|
||||
|
||||
@@ -216,7 +266,7 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark):
|
||||
return result
|
||||
|
||||
|
||||
def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB'):
|
||||
def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB', ascii_docinfo=False):
|
||||
if icc == 'sRGB':
|
||||
icc_profile = SRGB_ICC_PROFILE
|
||||
else:
|
||||
@@ -233,7 +283,7 @@ def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB'):
|
||||
hex_icc_profile = hexlify(bytes_icc_profile)
|
||||
icc_profile = '<' + hex_icc_profile.decode('ascii') + '>'
|
||||
|
||||
ps = _get_pdfa_def(icc_profile, icc, pdfmark)
|
||||
ps = _get_pdfa_def(icc_profile, icc, pdfmark, ascii_docinfo=ascii_docinfo)
|
||||
|
||||
# We should have encoded everything to pure ASCII by this point, and
|
||||
# to be safe, only allow ASCII in PostScript
|
||||
|
||||
@@ -16,19 +16,23 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from decimal import Decimal
|
||||
from math import hypot, isclose
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
|
||||
from .exec import ghostscript
|
||||
from .helpers import fspath
|
||||
from math import hypot, isclose
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
import re
|
||||
|
||||
from pikepdf import PdfMatrix
|
||||
import pikepdf
|
||||
|
||||
from . import ghosttext
|
||||
from .layout import get_page_analysis, get_text_boxes
|
||||
|
||||
from ..helpers import fspath
|
||||
|
||||
|
||||
Colorspace = Enum('Colorspace',
|
||||
'gray rgb cmyk lab icc index sep devn pattern jpeg2000')
|
||||
|
||||
@@ -36,17 +40,6 @@ Encoding = Enum('Encoding',
|
||||
'ccitt jpeg jpeg2000 jbig2 asciihex ascii85 lzw flate ' + \
|
||||
'runlength')
|
||||
|
||||
# Forgive me for I have sinned
|
||||
# I am using regular expressions to parse XML. However the XML in this case,
|
||||
# generated by Ghostscript, is self-consistent enough to be parseable.
|
||||
regex_remove_char_tags = re.compile(br"""
|
||||
<char\b
|
||||
(?: [^>] # anything single character but >
|
||||
| \">\" # special case: trap ">"
|
||||
)*
|
||||
/> # terminate with '/>'
|
||||
""", re.VERBOSE)
|
||||
|
||||
FRIENDLY_COLORSPACE = {
|
||||
'/DeviceGray': Colorspace.gray,
|
||||
'/CalGray': Colorspace.gray,
|
||||
@@ -90,28 +83,6 @@ FRIENDLY_COMP = {
|
||||
|
||||
UNIT_SQUARE = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def _matrix_from_shorthand(shorthand):
|
||||
"""Convert from PDF matrix shorthand to full matrix
|
||||
|
||||
PDF 1.7 spec defines a shorthand for describing the entries of a matrix
|
||||
since the last column is always (0, 0, 1).
|
||||
"""
|
||||
|
||||
a, b, c, d, e, f = map(float, shorthand)
|
||||
return ((a, b, 0),
|
||||
(c, d, 0),
|
||||
(e, f, 1))
|
||||
|
||||
|
||||
def _shorthand_from_matrix(matrix):
|
||||
"""Convert from transformation matrix to PDF shorthand."""
|
||||
a, b = matrix[0][0], matrix[0][1]
|
||||
c, d = matrix[1][0], matrix[1][1]
|
||||
e, f = matrix[2][0], matrix[2][1]
|
||||
return tuple(map(float, (a, b, c, d, e, f)))
|
||||
|
||||
|
||||
def _is_unit_square(shorthand):
|
||||
values = map(float, shorthand)
|
||||
pairwise = zip(values, UNIT_SQUARE)
|
||||
@@ -124,23 +95,30 @@ InlineSettings = namedtuple('InlineSettings',
|
||||
['iimage', 'shorthand', 'stack_depth'])
|
||||
|
||||
ContentsInfo = namedtuple('ContentsInfo',
|
||||
['xobject_settings', 'inline_images', 'found_text'])
|
||||
['xobject_settings', 'inline_images', 'found_vector'])
|
||||
|
||||
TextboxInfo = namedtuple('TextboxInfo',
|
||||
['bbox', 'is_visible', 'is_corrupt'])
|
||||
|
||||
|
||||
class VectorInfo:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def _normalize_stack(operations):
|
||||
"""Convert runs of qQ's in the stack into single operations"""
|
||||
for operands, command in operations:
|
||||
command = str(command)
|
||||
if re.match(r'Q*q+$', command): # Zero or more Q, one or more q
|
||||
for char in command: # Split into individual
|
||||
yield ([], char) # Yield individual
|
||||
|
||||
def _normalize_stack(graphobjs):
|
||||
"""Convert runs of qQ's in the stack into single graphobjs"""
|
||||
for operands, operator in graphobjs:
|
||||
operator = str(operator)
|
||||
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
|
||||
for char in operator: # Split into individual
|
||||
yield ([], char) # Yield individual
|
||||
else:
|
||||
yield (operands, command)
|
||||
yield (operands, operator)
|
||||
|
||||
|
||||
def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
|
||||
"""Interpret the PDF content stream
|
||||
"""Interpret the PDF content stream.
|
||||
|
||||
The stack represents the state of the PDF graphics stack. We are only
|
||||
interested in the current transformation matrix (CTM) so we only track
|
||||
@@ -157,54 +135,53 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
|
||||
page.
|
||||
|
||||
PDF units suit our needs so we initialize ctm to the identity matrix.
|
||||
|
||||
"""
|
||||
|
||||
stack = []
|
||||
ctm = PdfMatrix(initial_shorthand)
|
||||
xobject_settings = []
|
||||
inline_images = []
|
||||
found_text = False
|
||||
text_operators = set(['Tj', 'TJ', '"', "'"])
|
||||
operator_whitelist = """q Q Do cm TJ Tj " ' BI ID EI"""
|
||||
found_vector = False
|
||||
vector_ops = set('S s f F f* B B* b b*'.split())
|
||||
image_ops = set('BI ID EI q Q Do cm'.split())
|
||||
operator_whitelist = ' '.join(vector_ops | image_ops)
|
||||
|
||||
for n, op in enumerate(_normalize_stack(
|
||||
for n, graphobj in enumerate(_normalize_stack(
|
||||
pikepdf.parse_content_stream(contentstream, operator_whitelist))):
|
||||
operands, command = op
|
||||
operands, operator = graphobj
|
||||
|
||||
if command == 'q':
|
||||
if operator == 'q':
|
||||
stack.append(ctm)
|
||||
if len(stack) > 32:
|
||||
raise RuntimeError(
|
||||
"PDF graphics stack overflow, command %i" % n)
|
||||
elif command == 'Q':
|
||||
"PDF graphics stack overflow, operator %i" % n)
|
||||
elif operator == 'Q':
|
||||
try:
|
||||
ctm = stack.pop()
|
||||
except IndexError:
|
||||
raise RuntimeError(
|
||||
"PDF graphics stack underflow, command %i" % n)
|
||||
elif command == 'cm':
|
||||
"PDF graphics stack underflow, operator %i" % n)
|
||||
elif operator == 'cm':
|
||||
ctm = PdfMatrix(operands) @ ctm
|
||||
elif command == 'Do':
|
||||
elif operator == 'Do':
|
||||
image_name = operands[0]
|
||||
settings = XobjectSettings(
|
||||
name=image_name, shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack))
|
||||
xobject_settings.append(settings)
|
||||
elif command == 'INLINE IMAGE':
|
||||
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
|
||||
iimage = operands[0]
|
||||
inline = InlineSettings(
|
||||
iimage=iimage, shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack))
|
||||
inline_images.append(inline)
|
||||
elif command in text_operators:
|
||||
found_text = True
|
||||
|
||||
elif operator in vector_ops:
|
||||
found_vector = True
|
||||
|
||||
return ContentsInfo(
|
||||
xobject_settings=xobject_settings,
|
||||
inline_images=inline_images,
|
||||
found_text=found_text)
|
||||
found_vector=found_vector)
|
||||
|
||||
|
||||
def _get_dpi(ctm_shorthand, image_size):
|
||||
@@ -408,7 +385,6 @@ def _find_regular_images(container, contentsinfo):
|
||||
that contains images.
|
||||
|
||||
Generates images with their DPI at time of drawing.
|
||||
|
||||
"""
|
||||
|
||||
for pdfimage, xobj in _image_xobjects(container):
|
||||
@@ -458,11 +434,11 @@ def _find_form_xobject_images(pdf, container, contentsinfo):
|
||||
# but in practice both Form XObjects and multiple drawing of the
|
||||
# same object are both very rare.
|
||||
ctm_shorthand = settings.shorthand
|
||||
yield from _find_images(
|
||||
yield from _process_content_streams(
|
||||
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand)
|
||||
|
||||
|
||||
def _find_images(*, pdf, container, shorthand=None):
|
||||
def _process_content_streams(*, pdf, container, shorthand=None):
|
||||
"""Find all individual instances of images drawn in the container
|
||||
|
||||
Usually the container is a page, but it may also be a Form XObject.
|
||||
@@ -471,7 +447,7 @@ def _find_images(*, pdf, container, shorthand=None):
|
||||
in an XObject.
|
||||
|
||||
Form XObjects may include inline images, XObject images,
|
||||
and recursively, other Form XObjects; and also vector drawing commands.
|
||||
and recursively, other Form XObjects; and also vector graphic objects.
|
||||
|
||||
Every instance of an image being drawn somewhere is flattened and
|
||||
treated as a unique image, since if the same image is drawn multiple times
|
||||
@@ -503,62 +479,13 @@ def _find_images(*, pdf, container, shorthand=None):
|
||||
|
||||
contentsinfo = _interpret_contents(container, initial_shorthand)
|
||||
|
||||
if contentsinfo.found_vector:
|
||||
yield VectorInfo()
|
||||
yield from _find_inline_images(contentsinfo)
|
||||
yield from _find_regular_images(container, contentsinfo)
|
||||
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
||||
|
||||
|
||||
def _page_get_textblocks(infile, pageno):
|
||||
"""Smarter text detection"""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
gstext = ghostscript.extract_text(infile, pageno+1)
|
||||
|
||||
# Remove all <char /> tags, because they might contain invalid XML entities
|
||||
# like <char bbox="348 596 348 596" c=""/> which chokes on the
|
||||
# inclusion of U+0001. Understandably.
|
||||
# Just remove the whole <char /> tag since we don't use it at all, and they
|
||||
# are only generated as innermost self-closing tags.
|
||||
gstext = regex_remove_char_tags.sub(b' ', gstext)
|
||||
|
||||
if gstext.strip() == '':
|
||||
return []
|
||||
|
||||
try:
|
||||
root = ET.fromstring(gstext)
|
||||
except ET.ParseError as e:
|
||||
return [] # If we can't parse, assume none...
|
||||
|
||||
def blocks():
|
||||
for span in root.findall('.//span'):
|
||||
bbox_str = span.attrib['bbox']
|
||||
font_size = span.attrib['size']
|
||||
pts = [int(pt) for pt in bbox_str.split()]
|
||||
pts[1] = pts[1] - int(float(font_size) + 0.5)
|
||||
bbox = tuple(pts)
|
||||
yield bbox
|
||||
|
||||
def joined_blocks():
|
||||
prev = None
|
||||
for bbox in blocks():
|
||||
if prev is None:
|
||||
prev = bbox
|
||||
if bbox[1] == prev[1] and bbox[3] == prev[3]:
|
||||
gap = prev[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
if gap < height:
|
||||
# Join boxes
|
||||
prev = (prev[0], prev[1], bbox[2], bbox[3])
|
||||
continue
|
||||
# yield previously joined bboxes and start anew
|
||||
yield prev
|
||||
prev = bbox
|
||||
if prev is not None:
|
||||
yield prev
|
||||
|
||||
return [block for block in joined_blocks()]
|
||||
|
||||
|
||||
def _page_has_text(text_blocks, page_width, page_height):
|
||||
"""Smarter text detection that ignores text in margins"""
|
||||
|
||||
@@ -566,18 +493,19 @@ def _page_has_text(text_blocks, page_width, page_height):
|
||||
|
||||
margin_ratio = 0.125
|
||||
interior_bbox = (
|
||||
margin_ratio * pw, margin_ratio * ph,
|
||||
(1 - margin_ratio) * pw, (1 - margin_ratio) * ph
|
||||
margin_ratio * pw, # left
|
||||
(1 - margin_ratio) * ph, # top
|
||||
(1 - margin_ratio) * pw, # right
|
||||
margin_ratio * ph # bottom (first quadrant: bottom < top)
|
||||
)
|
||||
|
||||
def rects_intersect(a, b):
|
||||
"""
|
||||
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
|
||||
Negative signs to account for our coordinates being in the fourth quadrant
|
||||
and the formula assuming the first
|
||||
Formula assumes all boxes are in first quadrant
|
||||
"""
|
||||
return a[0] < b[2] and a[2] > b[0] and -a[1] > -b[3] and -a[3] < -b[1]
|
||||
return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1]
|
||||
|
||||
has_text = False
|
||||
for bbox in text_blocks:
|
||||
@@ -587,21 +515,43 @@ def _page_has_text(text_blocks, page_width, page_height):
|
||||
return has_text
|
||||
|
||||
|
||||
def _pdf_get_pageinfo(pdf, pageno: int, infile):
|
||||
def simplify_textboxes(miner):
|
||||
"""Extract only limited content from text boxes
|
||||
|
||||
We do this to save memory and ensure that our objects are pickleable.
|
||||
"""
|
||||
for box in get_text_boxes(miner):
|
||||
first_line = box._objs[0]
|
||||
first_char = first_line._objs[0]
|
||||
|
||||
visible = (first_char.rendermode != 3)
|
||||
corrupt = (first_char.get_text() == '\ufffd')
|
||||
yield TextboxInfo(box.bbox, visible, corrupt)
|
||||
|
||||
|
||||
def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||
pageinfo = {}
|
||||
pageinfo['pageno'] = pageno
|
||||
pageinfo['images'] = []
|
||||
|
||||
page = pdf.pages[pageno]
|
||||
|
||||
pageinfo['textinfo'] = _page_get_textblocks(fspath(infile), pageno)
|
||||
|
||||
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
|
||||
width_pt = mediabox[2] - mediabox[0]
|
||||
height_pt = mediabox[3] - mediabox[1]
|
||||
|
||||
if xmltext:
|
||||
bboxes = ghosttext.page_get_textblocks(
|
||||
fspath(infile), pageno, xmltext=xmltext, height=height_pt)
|
||||
pageinfo['bboxes'] = bboxes
|
||||
else:
|
||||
pscript5_mode = str(pdf.metadata.get('/Creator')).startswith('PScript5')
|
||||
miner = get_page_analysis(infile, pageno, pscript5_mode)
|
||||
pageinfo['textboxes'] = list(simplify_textboxes(miner))
|
||||
bboxes = (box.bbox for box in pageinfo['textboxes'])
|
||||
|
||||
pageinfo['has_text'] = _page_has_text(
|
||||
pageinfo['textinfo'], width_pt, height_pt)
|
||||
bboxes, width_pt, height_pt
|
||||
)
|
||||
|
||||
userunit = page.get('/UserUnit', Decimal(1.0))
|
||||
if not isinstance(userunit, Decimal):
|
||||
@@ -616,9 +566,16 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile):
|
||||
pageinfo['rotate'] = 0
|
||||
|
||||
userunit_shorthand = (userunit, 0, 0, userunit, 0, 0)
|
||||
pageinfo['images'] = [im for im in
|
||||
_find_images(pdf=pdf, container=page,
|
||||
shorthand=userunit_shorthand)]
|
||||
contentsinfo = [ci for ci in
|
||||
_process_content_streams(pdf=pdf, container=page,
|
||||
shorthand=userunit_shorthand)]
|
||||
|
||||
pageinfo['has_vector'] = False
|
||||
if any(isinstance(ci, VectorInfo) for ci in contentsinfo):
|
||||
pageinfo['has_vector'] = True
|
||||
|
||||
pageinfo['images'] = [im for im in contentsinfo
|
||||
if isinstance(im, ImageInfo)]
|
||||
if pageinfo['images']:
|
||||
xres = Decimal(max(image.xres for image in pageinfo['images']))
|
||||
yres = Decimal(max(image.yres for image in pageinfo['images']))
|
||||
@@ -631,16 +588,31 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile):
|
||||
return pageinfo
|
||||
|
||||
|
||||
def _pdf_get_all_pageinfo(infile):
|
||||
def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None):
|
||||
if not log:
|
||||
log = Mock()
|
||||
|
||||
pdf = pikepdf.open(infile)
|
||||
return [PageInfo(pdf, n, infile) for n in range(len(pdf.pages))]
|
||||
if detailed_analysis:
|
||||
pages_xml = None
|
||||
else:
|
||||
pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None, log=log)
|
||||
|
||||
pages = []
|
||||
for n in range(len(pdf.pages)):
|
||||
page_xml = pages_xml[n] if pages_xml else None
|
||||
page = PageInfo(pdf, n, infile, page_xml, detailed_analysis)
|
||||
pages.append(page)
|
||||
|
||||
return pages, pdf
|
||||
|
||||
|
||||
class PageInfo:
|
||||
def __init__(self, pdf, pageno, infile):
|
||||
def __init__(self, pdf, pageno, infile, xmltext, detailed_analysis=False):
|
||||
self._pageno = pageno
|
||||
self._infile = infile
|
||||
self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile)
|
||||
self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile, xmltext)
|
||||
self._detailed_analysis = detailed_analysis
|
||||
|
||||
@property
|
||||
def pageno(self):
|
||||
@@ -650,6 +622,16 @@ class PageInfo:
|
||||
def has_text(self):
|
||||
return self._pageinfo['has_text']
|
||||
|
||||
@property
|
||||
def has_corrupt_text(self):
|
||||
if not self._detailed_analysis:
|
||||
raise NotImplementedError('Did not do detailed analysis')
|
||||
return any(tbox.is_corrupt for tbox in self._pageinfo['textboxes'])
|
||||
|
||||
@property
|
||||
def has_vector(self):
|
||||
return self._pageinfo['has_vector']
|
||||
|
||||
@property
|
||||
def width_inches(self):
|
||||
return self._pageinfo['width_inches']
|
||||
@@ -681,8 +663,26 @@ class PageInfo:
|
||||
def images(self):
|
||||
return self._pageinfo['images']
|
||||
|
||||
def get_textareas(self):
|
||||
yield from self._pageinfo['textinfo']
|
||||
def get_textareas(self, visible=None, corrupt=None):
|
||||
def predicate(obj, want_visible, want_corrupt):
|
||||
result = True
|
||||
if want_visible is not None:
|
||||
if obj.is_visible != want_visible:
|
||||
result = False
|
||||
if want_corrupt is not None:
|
||||
if obj.is_corrupt != want_corrupt:
|
||||
result = False
|
||||
return result
|
||||
|
||||
if 'textboxes' not in self._pageinfo:
|
||||
if visible is not None and corrupt is not None:
|
||||
raise NotImplementedError(
|
||||
'Ghostscript textboxes cannot be classified')
|
||||
return self._pageinfo['bboxes']
|
||||
|
||||
return (obj.bbox for obj in self._pageinfo['textboxes']
|
||||
if predicate(obj, visible, corrupt))
|
||||
|
||||
|
||||
@property
|
||||
def xres(self):
|
||||
@@ -714,12 +714,14 @@ class PageInfo:
|
||||
|
||||
|
||||
class PdfInfo:
|
||||
"""Get summary information about a PDF
|
||||
"""Get summary information about a PDF"""
|
||||
|
||||
"""
|
||||
def __init__(self, infile):
|
||||
def __init__(self, infile, detailed_page_analysis=False, log=None):
|
||||
self._infile = infile
|
||||
self._pages = _pdf_get_all_pageinfo(infile)
|
||||
self._pages, pdf = _pdf_get_all_pageinfo(
|
||||
infile, detailed_page_analysis, log=log)
|
||||
self._needs_rendering = pdf.root.get('/NeedsRendering', False)
|
||||
self._has_acroform = '/AcroForm' in pdf.root
|
||||
|
||||
@property
|
||||
def pages(self):
|
||||
@@ -734,12 +736,20 @@ class PdfInfo:
|
||||
def has_userunit(self):
|
||||
return any(page.userunit != 1.0 for page in self.pages)
|
||||
|
||||
@property
|
||||
def has_acroform(self):
|
||||
return self._has_acroform
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
if not isinstance(self._infile, (str, Path)):
|
||||
raise NotImplementedError("can't get filename from stream")
|
||||
return self._infile
|
||||
|
||||
@property
|
||||
def needs_rendering(self):
|
||||
return self._needs_rendering
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self._pages[item]
|
||||
|
||||
@@ -749,15 +759,6 @@ class PdfInfo:
|
||||
def __repr__(self):
|
||||
return "<PdfInfo('...'), page count={}>".format(len(self))
|
||||
|
||||
# def __getstate__(self):
|
||||
# state = {'_infile': self._infile}
|
||||
# return state
|
||||
#
|
||||
# def __setstate__(self, state):
|
||||
# self._infile = state['_infile']
|
||||
# self._pages = _pdf_get_all_pageinfo(self._infile)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
@@ -0,0 +1,96 @@
|
||||
# © 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This file is part of OCRmyPDF.
|
||||
#
|
||||
# OCRmyPDF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OCRmyPDF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from ..exec import ghostscript
|
||||
|
||||
# Forgive me for I have sinned
|
||||
# I am using regular expressions to parse XML. However the XML in this case,
|
||||
# generated by Ghostscript, is self-consistent enough to be parseable.
|
||||
regex_remove_char_tags = re.compile(br"""
|
||||
<char\b
|
||||
(?: [^>] # anything single character but >
|
||||
| \">\" # special case: trap ">"
|
||||
)*
|
||||
/> # terminate with '/>'
|
||||
""", re.VERBOSE)
|
||||
|
||||
|
||||
def page_get_textblocks(infile, pageno, xmltext, height):
|
||||
"""Get text boxes out of Ghostscript txtwrite xml"""
|
||||
|
||||
root = xmltext
|
||||
if not hasattr(xmltext, 'findall'):
|
||||
return []
|
||||
|
||||
def blocks():
|
||||
for span in root.findall('.//span'):
|
||||
bbox_str = span.attrib['bbox']
|
||||
font_size = span.attrib['size']
|
||||
pts = [int(pt) for pt in bbox_str.split()]
|
||||
pts[1] = pts[1] - int(float(font_size) + 0.5)
|
||||
bbox_topdown = tuple(pts)
|
||||
bb = bbox_topdown
|
||||
bbox_bottomup = (bb[0], height - bb[3], bb[2], height - bb[1])
|
||||
yield bbox_bottomup
|
||||
|
||||
def joined_blocks():
|
||||
prev = None
|
||||
for bbox in blocks():
|
||||
if prev is None:
|
||||
prev = bbox
|
||||
if bbox[1] == prev[1] and bbox[3] == prev[3]:
|
||||
gap = prev[2] - bbox[0]
|
||||
height = abs(bbox[3] - bbox[1])
|
||||
if gap < height:
|
||||
# Join boxes
|
||||
prev = (prev[0], prev[1], bbox[2], bbox[3])
|
||||
continue
|
||||
# yield previously joined bboxes and start anew
|
||||
yield prev
|
||||
prev = bbox
|
||||
if prev is not None:
|
||||
yield prev
|
||||
|
||||
return [block for block in joined_blocks()]
|
||||
|
||||
|
||||
def extract_text_xml(infile, pdf, pageno=None, log=None):
|
||||
existing_text = ghostscript.extract_text(infile, pageno=None)
|
||||
existing_text = regex_remove_char_tags.sub(b' ', existing_text)
|
||||
|
||||
try:
|
||||
root = ET.fromstringlist([
|
||||
b'<document>\n', existing_text, b'</document>\n'
|
||||
])
|
||||
page_xml = root.findall('page')
|
||||
except ET.ParseError as e:
|
||||
log.error(
|
||||
"An error occurred while attempting to retrieve existing text in "
|
||||
"the input file. Will attempt to continue assuming that there is "
|
||||
"no existing text in the file. The error was:")
|
||||
log.error(e)
|
||||
page_xml = [None] * len(pdf.pages)
|
||||
|
||||
page_count_difference = len(pdf.pages) - len(page_xml)
|
||||
if page_count_difference != 0:
|
||||
log.error("The number of pages in the input file is inconsistent.")
|
||||
if page_count_difference > 0:
|
||||
page_xml.extend([None] * page_count_difference)
|
||||
return page_xml
|
||||
@@ -0,0 +1,237 @@
|
||||
# © 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This file is part of OCRmyPDF.
|
||||
#
|
||||
# OCRmyPDF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OCRmyPDF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
from math import copysign
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pdfminer.encodingdb
|
||||
import pdfminer.pdfdevice
|
||||
import pdfminer.pdfinterp
|
||||
from pdfminer.converter import PDFLayoutAnalyzer
|
||||
from pdfminer.glyphlist import glyphname2unicode
|
||||
from pdfminer.layout import (LAParams, LTChar, LTContainer, LTLayoutContainer,
|
||||
LTPage, LTTextBox, LTTextLine)
|
||||
from pdfminer.pdfdocument import PDFTextExtractionNotAllowed
|
||||
from pdfminer.pdffont import (PDFCIDFont, PDFFont, PDFType3Font,
|
||||
PDFUnicodeNotDefined, PDFSimpleFont)
|
||||
from pdfminer.pdfpage import PDFPage
|
||||
from pdfminer.utils import bbox2str, fsplit, matrix2str
|
||||
|
||||
from ..exceptions import EncryptedPdfError
|
||||
|
||||
STRIP_NAME = re.compile(r'[0-9]+')
|
||||
|
||||
#
|
||||
# Unconditional pdfminer patches
|
||||
#
|
||||
|
||||
def name2unicode(name):
|
||||
"""Fix pdfminer's regex in name2unicode function
|
||||
|
||||
Font cids that are mapped to names of the form /g123 seem to be, by convention
|
||||
characters with no corresponding Unicode entry. These can be subsetted fonts
|
||||
or symbolic fonts. There seems to be no way to map /g123 fonts to Unicode,
|
||||
barring a ToUnicode data structure.
|
||||
"""
|
||||
if name in glyphname2unicode:
|
||||
return glyphname2unicode[name]
|
||||
if name.startswith('g') or name.startswith('a'):
|
||||
raise KeyError(name)
|
||||
if name.startswith('uni'):
|
||||
try:
|
||||
return chr(int(name[3:], 16))
|
||||
except ValueError: # Not hexadecimal
|
||||
raise KeyError(name)
|
||||
m = STRIP_NAME.search(name)
|
||||
if not m:
|
||||
raise KeyError(name)
|
||||
return chr(int(m.group(0)))
|
||||
pdfminer.encodingdb.name2unicode = name2unicode
|
||||
|
||||
original_PDFFont_init = PDFFont.__init__
|
||||
def PDFFont__init__(self, descriptor, widths, default_width=None):
|
||||
original_PDFFont_init(self, descriptor, widths, default_width)
|
||||
# PDF spec says descent should be negative
|
||||
# A font with a positive descent implies it floats entirely above the
|
||||
# baseline, i.e. it's not really a baseline anymore. I have fonts that
|
||||
# claim a positive descent, but treating descent as positive always seems
|
||||
# to misposition text.
|
||||
if self.descent > 0:
|
||||
self.descent = -self.descent
|
||||
PDFFont.__init__ = PDFFont__init__
|
||||
|
||||
original_PDFSimpleFont_init = PDFSimpleFont.__init__
|
||||
def PDFSimpleFont__init__(self, descriptor, widths, spec):
|
||||
# Font encoding is specified either by a name of
|
||||
# built-in encoding or a dictionary that describes
|
||||
# the differences.
|
||||
original_PDFSimpleFont_init(self, descriptor, widths, spec)
|
||||
# pdfminer is incorrect. If there is no ToUnicode and no Encoding, do not
|
||||
# assume Unicode conversion is possible. RM 9.10.2
|
||||
if not self.unicode_map and 'Encoding' not in spec:
|
||||
self.cid2unicode = {}
|
||||
return
|
||||
PDFSimpleFont.__init__ = PDFSimpleFont__init__
|
||||
#
|
||||
# pdfminer patches when creator is PScript5.dll
|
||||
#
|
||||
|
||||
def PDFType3Font__PScript5_get_height(self):
|
||||
h = self.bbox[3]-self.bbox[1]
|
||||
if h == 0:
|
||||
h = self.ascent - self.descent
|
||||
return h * copysign(1.0, self.vscale)
|
||||
|
||||
def PDFType3Font__PScript5_get_descent(self):
|
||||
return self.descent * copysign(1.0, self.vscale)
|
||||
|
||||
def PDFType3Font__PScript5_get_ascent(self):
|
||||
return self.ascent * copysign(1.0, self.vscale)
|
||||
|
||||
|
||||
class LTStateAwareChar(LTChar):
|
||||
"""A subclass of LTChar that tracks text render mode at time of drawing"""
|
||||
|
||||
__slots__ = (
|
||||
'rendermode', '_text', 'matrix', 'fontname', 'adv', 'upright', 'size',
|
||||
'width', 'height', 'bbox', 'x0', 'x1', 'y0', 'y1'
|
||||
)
|
||||
|
||||
def __init__(self, matrix, font, fontsize, scaling, rise, text, textwidth,
|
||||
textdisp, textstate, *args):
|
||||
super().__init__(matrix, font, fontsize, scaling, rise, text, textwidth,
|
||||
textdisp, *args)
|
||||
self.rendermode = textstate.render
|
||||
|
||||
def is_compatible(self, obj):
|
||||
"""Check if characters can be combined into a textline
|
||||
|
||||
We consider characters compatible if:
|
||||
- the Unicode mapping is known, and both have the same render mode
|
||||
- the Unicode mapping is unknown but both are part of the same font
|
||||
"""
|
||||
both_unicode_mapped = (isinstance(self._text, str) and
|
||||
isinstance(obj._text, str))
|
||||
try:
|
||||
if both_unicode_mapped:
|
||||
return self.rendermode == obj.rendermode
|
||||
font0, _ = self._text
|
||||
font1, _ = obj._text
|
||||
return font0 == font1 and self.rendermode == obj.rendermode
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
def get_text(self):
|
||||
if isinstance(self._text, tuple):
|
||||
return '�'
|
||||
return self._text
|
||||
|
||||
def __repr__(self):
|
||||
return ('<%s %s matrix=%s rendermode=%r font=%r adv=%s text=%r>' %
|
||||
(self.__class__.__name__, bbox2str(self.bbox),
|
||||
matrix2str(self.matrix), self.rendermode, self.fontname, self.adv,
|
||||
self.get_text()))
|
||||
|
||||
|
||||
class TextPositionTracker(PDFLayoutAnalyzer):
|
||||
"""A page layout analyzer that pays attention to text visibility"""
|
||||
|
||||
def __init__(self, rsrcmgr, pageno=1, laparams=None):
|
||||
super().__init__(rsrcmgr, pageno, laparams)
|
||||
self.textstate = None
|
||||
self.result = None
|
||||
|
||||
def begin_page(self, page, ctm):
|
||||
super().begin_page(page, ctm)
|
||||
self.cur_item = LTPage(self.pageno, page.mediabox)
|
||||
|
||||
def end_page(self, page):
|
||||
assert not self._stack, str(len(self._stack))
|
||||
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
||||
if self.laparams is not None:
|
||||
self.cur_item.analyze(self.laparams)
|
||||
self.pageno += 1
|
||||
self.receive_layout(self.cur_item)
|
||||
|
||||
def render_string(self, textstate, seq, *args):
|
||||
self.textstate = textstate.copy()
|
||||
super().render_string(self.textstate, seq, *args)
|
||||
|
||||
def render_char(self, matrix, font, fontsize, scaling, rise, cid, *args):
|
||||
try:
|
||||
text = font.to_unichr(cid)
|
||||
assert isinstance(text, str), str(type(text))
|
||||
except PDFUnicodeNotDefined:
|
||||
text = self.handle_undefined_char(font, cid)
|
||||
textwidth = font.char_width(cid)
|
||||
textdisp = font.char_disp(cid)
|
||||
item = LTStateAwareChar(
|
||||
matrix, font, fontsize, scaling, rise, text,
|
||||
textwidth, textdisp, self.textstate, *args)
|
||||
self.cur_item.add(item)
|
||||
return item.adv
|
||||
|
||||
def handle_undefined_char(self, font, cid):
|
||||
#log.info('undefined: %r, %r', font, cid)
|
||||
return (font.fontname, cid)
|
||||
|
||||
def receive_layout(self, ltpage):
|
||||
self.result = ltpage
|
||||
|
||||
def get_result(self):
|
||||
return self.result
|
||||
|
||||
|
||||
def get_page_analysis(infile, pageno, pscript5_mode):
|
||||
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||
dev = TextPositionTracker(rman, laparams=LAParams())
|
||||
interp = pdfminer.pdfinterp.PDFPageInterpreter(rman, dev)
|
||||
|
||||
if pscript5_mode:
|
||||
patcher = patch.multiple(
|
||||
'pdfminer.pdffont.PDFType3Font',
|
||||
spec=True,
|
||||
get_ascent=PDFType3Font__PScript5_get_ascent,
|
||||
get_descent=PDFType3Font__PScript5_get_descent,
|
||||
get_height=PDFType3Font__PScript5_get_height
|
||||
)
|
||||
patcher.start()
|
||||
|
||||
try:
|
||||
with Path(infile).open('rb') as f:
|
||||
page = PDFPage.get_pages(f, pagenos=[pageno], maxpages=0)
|
||||
interp.process_page(next(page))
|
||||
except PDFTextExtractionNotAllowed:
|
||||
raise EncryptedPdfError()
|
||||
finally:
|
||||
if pscript5_mode:
|
||||
patcher.stop()
|
||||
|
||||
return dev.get_result()
|
||||
|
||||
|
||||
def get_text_boxes(obj):
|
||||
for child in obj:
|
||||
if isinstance(child, (LTTextBox)):
|
||||
yield child
|
||||
else:
|
||||
try:
|
||||
yield from get_text_boxes(child)
|
||||
except TypeError:
|
||||
continue
|
||||
+11
-5
@@ -18,13 +18,18 @@
|
||||
import sys
|
||||
import os
|
||||
import platform
|
||||
|
||||
pytest_plugins = ['helpers_namespace']
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
pytest_plugins = ['helpers_namespace']
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from pytest_cov.embed import cleanup_on_sigterm
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
cleanup_on_sigterm()
|
||||
|
||||
# pylint: disable=E1101
|
||||
# pytest.helpers is dynamic so it confuses pylint
|
||||
@@ -134,7 +139,8 @@ def check_ocrmypdf(input_file, output_file, *args, env=None):
|
||||
"Run ocrmypdf and confirmed that a valid file was created"
|
||||
|
||||
p, out, err = run_ocrmypdf(input_file, output_file, *args, env=env)
|
||||
print(err) # ensure py.test collects the output, use -s to view
|
||||
# ensure py.test collects the output, use -s to view
|
||||
print(err, file=sys.stderr)
|
||||
assert p.returncode == 0
|
||||
assert os.path.exists(str(output_file)), "Output file not created"
|
||||
assert os.stat(str(output_file)).st_size > 100, "PDF too small or empty"
|
||||
|
||||
@@ -104,6 +104,16 @@ licensed under the specified license.
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - kcs.pdf
|
||||
- PDF file generated by Kodak Capture Desktop Software 1.2; has invalid table of contents
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - livecycle.pdf
|
||||
- a minimal PDF that claims to use dynamic XFA forms
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - masks.pdf
|
||||
- file containing explicit masks and a stencil mask drawn without a proper transformation matrix; printout of a German Wikipedia article (CC-BY-SA)
|
||||
- @supergrobi
|
||||
@@ -129,11 +139,21 @@ licensed under the specified license.
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - truetype_font_nomapping.pdf
|
||||
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - trivial.pdf
|
||||
- smallest possible valid PDF-1.3 with all required fields
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - type3_font_nomapping.pdf
|
||||
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - vector.pdf
|
||||
- a PDF with vector art and text rendered as curves with no fonts
|
||||
- @Catscratch
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
%PDF-1.7
|
||||
%¿÷¢þ
|
||||
1 0 obj
|
||||
<< /NeedsRendering true /Pages 2 0 R /Type /Catalog >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Contents 4 0 R /MediaBox [ 0 0 300 300 ] /Parent 2 0 R /Resources << >> /Type /Page >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000085 00000 n
|
||||
0000000144 00000 n
|
||||
0000000250 00000 n
|
||||
trailer << /Root 1 0 R /Size 5 /ID [<8088c23e0edc07ef4fbed3daa55f52ed><9d36eee510d20004f32255f5800b6fd7>] >>
|
||||
startxref
|
||||
299
|
||||
%%EOF
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,11 +20,11 @@
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
"""Replicate Ghostscript render failure while allowing rasterizing"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
"""Replicate Ghostscript render failure while allowing rasterizing"""
|
||||
|
||||
|
||||
def real_ghostscript(argv):
|
||||
|
||||
+62
-1
@@ -18,9 +18,14 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
import sys
|
||||
from pickle import dumps, loads
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
import ocrmypdf.leptonica as lept
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
|
||||
def test_colormap_backgroundnorm(resources):
|
||||
@@ -28,3 +33,59 @@ def test_colormap_backgroundnorm(resources):
|
||||
# can handle that case
|
||||
pix = lept.Pix.open(resources / 'baiona_colormapped.png')
|
||||
pix.background_norm()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def crom_pix(resources):
|
||||
pix = lept.Pix.open(resources / 'crom.png')
|
||||
im = Image.open(resources / 'crom.png')
|
||||
return pix, im
|
||||
|
||||
|
||||
def test_pix_basic(crom_pix):
|
||||
pix, im = crom_pix
|
||||
|
||||
assert pix.width == im.width
|
||||
assert pix.height == im.height
|
||||
assert pix.mode == im.mode
|
||||
|
||||
|
||||
def test_pil_conversion(crom_pix):
|
||||
pix, im = crom_pix
|
||||
|
||||
# Check for pixel perfect
|
||||
assert ImageChops.difference(pix.topil(), im).getbbox() is None
|
||||
|
||||
|
||||
def test_pix_otsu(crom_pix):
|
||||
pix, _ = crom_pix
|
||||
im1bpp = pix.otsu_adaptive_threshold()
|
||||
assert im1bpp.mode == '1'
|
||||
|
||||
|
||||
def test_crop(resources):
|
||||
pix = lept.Pix.open(resources / 'linn.png')
|
||||
foreground = pix.crop_to_foreground()
|
||||
assert foreground.width < pix.width
|
||||
|
||||
|
||||
def test_clean_bg(resources):
|
||||
pix = lept.Pix.open(resources / 'congress.jpg')
|
||||
imbg = pix.clean_background_to_white()
|
||||
|
||||
|
||||
def test_pickle(crom_pix):
|
||||
pix, _ = crom_pix
|
||||
pickled = dumps(pix)
|
||||
pix2 = loads(pickled)
|
||||
assert pix.mode == pix2.mode
|
||||
|
||||
|
||||
def test_leptonica_compile(tmpdir):
|
||||
from ocrmypdf.lib.compile_leptonica import ffibuilder
|
||||
|
||||
# Compile the library but build it somewhere that won't interfere with
|
||||
# existing compiled library. Also compile in API mode so that we test
|
||||
# the interfaces, even though we use it ABI mode.
|
||||
ffibuilder.compile(tmpdir=fspath(tmpdir),
|
||||
target=fspath(tmpdir / 'lepttest.*'))
|
||||
|
||||
+38
-5
@@ -28,7 +28,7 @@ import PIL
|
||||
import pytest
|
||||
|
||||
from ocrmypdf.pdfinfo import PdfInfo, Colorspace, Encoding
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
||||
from ocrmypdf.exec import ghostscript, qpdf, tesseract
|
||||
from ocrmypdf.pdfa import file_claims_pdfa
|
||||
from ocrmypdf.leptonica import Pix
|
||||
@@ -190,11 +190,22 @@ def test_force_ocr(spoof_tesseract_cache, resources, outpdf):
|
||||
|
||||
def test_skip_ocr(spoof_tesseract_cache, resources, outpdf):
|
||||
out = check_ocrmypdf(resources / 'graph_ocred.pdf', outpdf, '-s',
|
||||
env=spoof_tesseract_cache)
|
||||
env=spoof_tesseract_cache)
|
||||
pdfinfo = PdfInfo(out)
|
||||
assert pdfinfo[0].has_text
|
||||
|
||||
|
||||
def test_redo_ocr(spoof_tesseract_cache, resources, outpdf):
|
||||
in_ = resources / 'graph_ocred.pdf'
|
||||
before = PdfInfo(in_, detailed_page_analysis=True)
|
||||
out = check_ocrmypdf(in_, outpdf, '--redo-ocr',
|
||||
env=spoof_tesseract_cache)
|
||||
after = PdfInfo(out, detailed_page_analysis=True)
|
||||
assert before[0].has_text and after[0].has_text
|
||||
assert before[0].get_textareas() != after[0].get_textareas(), \
|
||||
"Expected text to be different after re-OCR"
|
||||
|
||||
|
||||
def test_argsfile(spoof_tesseract_noop, resources, outdir):
|
||||
path_argsfile = outdir / 'test_argsfile.txt'
|
||||
with open(str(path_argsfile), 'w') as argsfile:
|
||||
@@ -208,7 +219,7 @@ def test_argsfile(spoof_tesseract_noop, resources, outdir):
|
||||
@pytest.mark.parametrize('renderer', RENDERERS)
|
||||
def test_ocr_timeout(renderer, resources, outpdf):
|
||||
out = check_ocrmypdf(resources / 'skew.pdf', outpdf,
|
||||
'--tesseract-timeout', '0.01',
|
||||
'--tesseract-timeout', '0',
|
||||
'--pdf-renderer', renderer)
|
||||
pdfinfo = PdfInfo(out)
|
||||
assert not pdfinfo[0].has_text
|
||||
@@ -377,7 +388,7 @@ def test_tesseract_image_too_big(renderer, spoof_tesseract_big_image_error,
|
||||
def test_algo4(resources, spoof_tesseract_noop, outpdf):
|
||||
p, _, _ = run_ocrmypdf(resources / 'encrypted_algo4.pdf', outpdf,
|
||||
env=spoof_tesseract_noop)
|
||||
assert p.returncode == ExitCode.ok
|
||||
assert p.returncode == ExitCode.encrypted_pdf
|
||||
|
||||
|
||||
@pytest.mark.parametrize('renderer', RENDERERS)
|
||||
@@ -595,7 +606,7 @@ THIS FILE IS INVALID
|
||||
resources / 'ccitt.pdf', outdir / 'out.pdf',
|
||||
'--pdf-renderer', renderer,
|
||||
'--tesseract-config', cfg_file)
|
||||
assert "parameter not found" in err, "No error message"
|
||||
assert "parameter not found" in err.lower(), "No error message"
|
||||
assert p.returncode == ExitCode.invalid_config
|
||||
|
||||
|
||||
@@ -925,3 +936,25 @@ def test_output_is_symlink(spoof_tesseract_noop, resources, outdir):
|
||||
)
|
||||
assert p.returncode == ExitCode.ok, err
|
||||
assert (outdir / 'out.pdf').stat().st_size > 0, 'target file not created'
|
||||
|
||||
|
||||
def test_livecycle(resources, no_outpdf):
|
||||
p, _, err = run_ocrmypdf(
|
||||
resources / 'livecycle.pdf',
|
||||
no_outpdf
|
||||
)
|
||||
|
||||
assert p.returncode == ExitCode.input_file, err
|
||||
|
||||
|
||||
def test_version_check():
|
||||
from ocrmypdf.exec import get_version
|
||||
|
||||
with pytest.raises(MissingDependencyError):
|
||||
get_version('NOT_FOUND_UNLIKELY_ON_PATH')
|
||||
|
||||
with pytest.raises(MissingDependencyError):
|
||||
get_version('sh', version_arg='-c')
|
||||
|
||||
with pytest.raises(MissingDependencyError):
|
||||
get_version('echo')
|
||||
|
||||
+38
-17
@@ -17,7 +17,6 @@
|
||||
|
||||
|
||||
import pytest
|
||||
import PyPDF2 as pypdf
|
||||
|
||||
from datetime import timezone
|
||||
from pathlib import Path
|
||||
@@ -25,12 +24,15 @@ from shutil import copyfile
|
||||
from unittest.mock import patch
|
||||
import datetime
|
||||
|
||||
import pikepdf
|
||||
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf.helpers import fspath
|
||||
from ocrmypdf.pdfa import (
|
||||
file_claims_pdfa, encode_pdf_date, decode_pdf_date, generate_pdfa_ps,
|
||||
SRGB_ICC_PROFILE
|
||||
)
|
||||
from ocrmypdf.exec import ghostscript
|
||||
|
||||
try:
|
||||
import fitz
|
||||
@@ -51,17 +53,17 @@ spoof = pytest.helpers.spoof
|
||||
])
|
||||
def test_preserve_metadata(spoof_tesseract_noop, output_type,
|
||||
resources, outpdf):
|
||||
pdf_before = pypdf.PdfFileReader(str(resources / 'graph.pdf'))
|
||||
pdf_before = pikepdf.open(resources / 'graph.pdf')
|
||||
|
||||
output = check_ocrmypdf(
|
||||
resources / 'graph.pdf', outpdf,
|
||||
'--output-type', output_type,
|
||||
env=spoof_tesseract_noop)
|
||||
|
||||
pdf_after = pypdf.PdfFileReader(str(output))
|
||||
pdf_after = pikepdf.open(output)
|
||||
|
||||
for key in ('/Title', '/Author'):
|
||||
assert pdf_before.documentInfo[key] == pdf_after.documentInfo[key]
|
||||
assert pdf_before.metadata[key] == pdf_after.metadata[key]
|
||||
|
||||
pdfa_info = file_claims_pdfa(str(output))
|
||||
assert pdfa_info['output'] == output_type
|
||||
@@ -85,15 +87,18 @@ def test_override_metadata(spoof_tesseract_noop, output_type, resources,
|
||||
|
||||
assert p.returncode == ExitCode.ok, err
|
||||
|
||||
before = pypdf.PdfFileReader(str(input_file))
|
||||
after = pypdf.PdfFileReader(outpdf)
|
||||
before = pikepdf.open(input_file)
|
||||
after = pikepdf.open(outpdf)
|
||||
|
||||
assert after.documentInfo['/Title'] == german
|
||||
assert after.documentInfo['/Author'] == chinese
|
||||
assert after.documentInfo.get('/Keywords', '') == ''
|
||||
if ghostscript.version() >= '9.24':
|
||||
pytest.xfail('Ghostscript 9.24+ does not support Unicode DOCINFO')
|
||||
|
||||
before_date = decode_pdf_date(before.documentInfo['/CreationDate'])
|
||||
after_date = decode_pdf_date(after.documentInfo['/CreationDate'])
|
||||
assert after.metadata.Title == german, after.metadata
|
||||
assert after.metadata.Author == chinese, after.metadata
|
||||
assert after.metadata.get('/Keywords', '') == ''
|
||||
|
||||
before_date = decode_pdf_date(str(before.metadata.CreationDate))
|
||||
after_date = decode_pdf_date(str(after.metadata.CreationDate))
|
||||
assert before_date == after_date
|
||||
|
||||
pdfa_info = file_claims_pdfa(outpdf)
|
||||
@@ -146,26 +151,30 @@ def test_creation_date_preserved(spoof_tesseract_noop, output_type, resources,
|
||||
infile, outpdf):
|
||||
input_file = resources / infile
|
||||
|
||||
before = pypdf.PdfFileReader(str(input_file)).getDocumentInfo()
|
||||
check_ocrmypdf(
|
||||
input_file, outpdf, '--output-type', output_type,
|
||||
env=spoof_tesseract_noop)
|
||||
after = pypdf.PdfFileReader(str(outpdf)).getDocumentInfo()
|
||||
|
||||
pdf_before = pikepdf.open(input_file)
|
||||
pdf_after = pikepdf.open(outpdf)
|
||||
|
||||
before = pdf_before.trailer.get('/Info', {})
|
||||
after = pdf_after.trailer.get('/Info', {})
|
||||
|
||||
if not before:
|
||||
# If there was input creation date, none should be output
|
||||
# because of Ghostscript quirks we set it to null
|
||||
# This test would be better if we had a test file with /DocumentInfo but
|
||||
# no /CreationDate, which we don't
|
||||
assert not after.get('/CreationDate')
|
||||
assert after.get('/CreationDate', '') == ''
|
||||
else:
|
||||
# We expect that the creation date stayed the same
|
||||
date_before = decode_pdf_date(before['/CreationDate'])
|
||||
date_after = decode_pdf_date(after['/CreationDate'])
|
||||
date_before = decode_pdf_date(str(before['/CreationDate']))
|
||||
date_after = decode_pdf_date(str(after['/CreationDate']))
|
||||
assert seconds_between_dates(date_before, date_after) < 1000
|
||||
|
||||
# We expect that the modified date is quite recent
|
||||
date_after = decode_pdf_date(after['/ModDate'])
|
||||
date_after = decode_pdf_date(str(after['/ModDate']))
|
||||
assert seconds_between_dates(
|
||||
date_after, datetime.datetime.now(timezone.utc)) < 1000
|
||||
|
||||
@@ -260,3 +269,15 @@ def test_srgb_in_unicode_path(tmpdir):
|
||||
|
||||
with patch('ocrmypdf.pdfa.SRGB_ICC_PROFILE', new=str(dst)):
|
||||
generate_pdfa_ps(dstdir / 'out.ps', {})
|
||||
|
||||
|
||||
def test_kodak_toc(resources, outpdf, spoof_tesseract_noop):
|
||||
output = check_ocrmypdf(
|
||||
resources / 'kcs.pdf', outpdf,
|
||||
'--output-type', 'pdf',
|
||||
env=spoof_tesseract_noop)
|
||||
|
||||
p = pikepdf.open(outpdf)
|
||||
|
||||
if pikepdf.Name.First in p.root.Outlines:
|
||||
assert isinstance(p.root.Outlines.First, pikepdf.Dictionary)
|
||||
|
||||
+48
-3
@@ -22,9 +22,11 @@ import logging
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import pikepdf
|
||||
|
||||
from ocrmypdf import optimize as opt
|
||||
from ocrmypdf.exec.ghostscript import rasterize_pdf
|
||||
from ocrmypdf.exec import jbig2enc
|
||||
from ocrmypdf.exec import jbig2enc, pngquant
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
|
||||
@@ -53,10 +55,53 @@ def test_mono_not_inverted(resources, outdir):
|
||||
assert im.getpixel((0, 0)) == 255, "Expected white background"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not jbig2enc.available(), reason='need jbig2enc')
|
||||
def test_jpg_png_params(resources, outpdf, spoof_tesseract_noop):
|
||||
check_ocrmypdf(
|
||||
resources / 'crom.png', outpdf, '--image-dpi', '200',
|
||||
'--optimize', '2', '--jpg-quality', '50', '--png-quality', '20',
|
||||
'--optimize', '3', '--jpg-quality', '50', '--png-quality', '20',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not jbig2enc.available(), reason='need jbig2enc')
|
||||
@pytest.mark.parametrize('lossy', [False, True])
|
||||
def test_jbig2_lossy(lossy, resources, outpdf, spoof_tesseract_noop):
|
||||
args = [
|
||||
resources / 'ccitt.pdf', outpdf, '--image-dpi', '200',
|
||||
'--optimize', 3, '--jpg-quality', '50', '--png-quality', '20'
|
||||
]
|
||||
if lossy:
|
||||
args.append('--jbig2-lossy')
|
||||
|
||||
check_ocrmypdf(*args, env=spoof_tesseract_noop)
|
||||
|
||||
pdf = pikepdf.open(outpdf)
|
||||
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
||||
assert pim.filters[0] == '/JBIG2Decode'
|
||||
|
||||
if lossy:
|
||||
assert '/JBIG2Globals' in pim.decode_parms[0]
|
||||
else:
|
||||
assert len(pim.decode_parms) == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not jbig2enc.available() or not pngquant.available(),
|
||||
reason='need jbig2enc and pngquant')
|
||||
def test_flate_to_jbig2(resources, outdir, spoof_tesseract_noop):
|
||||
# This test requires an image that pngquant is capable of converting to
|
||||
# to 1bpp - so use an existing 1bpp image, convert up, confirm it can
|
||||
# convert down
|
||||
im = Image.open(fspath(resources / 'typewriter.png'))
|
||||
assert im.mode in ('1', 'P')
|
||||
im = im.convert('L')
|
||||
im.save(fspath(outdir / 'type8.png'))
|
||||
|
||||
check_ocrmypdf(
|
||||
outdir / 'type8.png', outdir / 'out.pdf',
|
||||
'--image-dpi', '100', '--png-quality', '10', '--optimize', '3',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
|
||||
pdf = pikepdf.open(outdir / 'out.pdf')
|
||||
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
||||
assert pim.filters[0] == '/JBIG2Decode'
|
||||
|
||||
+29
-2
@@ -145,13 +145,13 @@ def test_pickle(resources):
|
||||
# For multiprocessing we must be able to pickle our information - if
|
||||
# this fails then we are probably storing some unpickleabe pikepdf or
|
||||
# other external data around
|
||||
filename = resources / 'formxobject.pdf'
|
||||
filename = resources / 'graph_ocred.pdf'
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
pickle.dumps(pdf)
|
||||
|
||||
|
||||
def test_regex():
|
||||
rx = pdfinfo.regex_remove_char_tags
|
||||
rx = pdfinfo.ghosttext.regex_remove_char_tags
|
||||
|
||||
must_match = [
|
||||
b'<char bbox="0 108 0 108" c="/"/>',
|
||||
@@ -169,3 +169,30 @@ def test_regex():
|
||||
assert rx.match(s)
|
||||
for s in must_not_match:
|
||||
assert not rx.match(s)
|
||||
|
||||
|
||||
def test_vector(resources):
|
||||
filename = resources / 'vector.pdf'
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
assert pdf[0].has_vector
|
||||
assert not pdf[0].has_text
|
||||
|
||||
|
||||
def test_ocr_detection(resources):
|
||||
filename = resources / 'graph_ocred.pdf'
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
assert not pdf[0].has_vector
|
||||
assert pdf[0].has_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'testfile', ('truetype_font_nomapping.pdf', 'type3_font_nomapping.pdf')
|
||||
)
|
||||
def test_corrupt_font_detection(resources, testfile):
|
||||
filename = resources / testfile
|
||||
with pytest.raises(NotImplementedError):
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
pdf[0].has_corrupt_text
|
||||
|
||||
pdf = pdfinfo.PdfInfo(filename, detailed_page_analysis=True)
|
||||
assert pdf[0].has_corrupt_text
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# © 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This file is part of OCRmyPDF.
|
||||
#
|
||||
# OCRmyPDF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OCRmyPDF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
|
||||
import ocrmypdf.exec.qpdf as qpdf
|
||||
|
||||
def test_qpdf_error(resources):
|
||||
assert qpdf.check(resources / 'blank.pdf')
|
||||
assert not qpdf.check(__file__)
|
||||
+19
-1
@@ -17,6 +17,7 @@
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from unittest.mock import Mock
|
||||
|
||||
from PIL import Image
|
||||
import pytest
|
||||
@@ -25,7 +26,7 @@ import pikepdf
|
||||
|
||||
from ocrmypdf import leptonica
|
||||
from ocrmypdf.pdfinfo import PdfInfo
|
||||
from ocrmypdf.exec import ghostscript
|
||||
from ocrmypdf.exec import ghostscript, tesseract
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
|
||||
@@ -33,6 +34,11 @@ from ocrmypdf.helpers import fspath
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=w0612
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
leptonica.get_leptonica_version() < 'leptonica-1.72',
|
||||
reason="Leptonica is too old, correlation doesn't work"
|
||||
)
|
||||
|
||||
check_ocrmypdf = pytest.helpers.check_ocrmypdf
|
||||
run_ocrmypdf = pytest.helpers.run_ocrmypdf
|
||||
|
||||
@@ -164,6 +170,8 @@ def test_rotate_deskew_timeout(resources, outdir):
|
||||
check_ocrmypdf(
|
||||
resources / 'rotated_skew.pdf',
|
||||
outdir / 'deskewed.pdf',
|
||||
'--rotate-pages',
|
||||
'--rotate-pages-threshold', '0',
|
||||
'--deskew',
|
||||
'--tesseract-timeout', '0',
|
||||
'--pdf-renderer', 'sandwich'
|
||||
@@ -221,3 +229,13 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
|
||||
assert p.returncode == 0, err
|
||||
|
||||
assert check_monochrome_correlation(outdir, reference, 1, out, 1) > 0.2
|
||||
|
||||
|
||||
def test_tesseract_orientation(resources, tmpdir):
|
||||
pix = leptonica.Pix.open(resources / 'crom.png')
|
||||
pix_rotated = pix.rotate_orth(2) # 180 degrees clockwise
|
||||
pix_rotated.write_implied_format(tmpdir / '000001.png')
|
||||
|
||||
log = Mock()
|
||||
tesseract.get_orientation( # Test results of this are unreliable
|
||||
tmpdir / '000001.png', engine_mode='3', timeout=10, log=log)
|
||||
|
||||
+27
-7
@@ -16,8 +16,9 @@
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
||||
from ocrmypdf.exec import tesseract
|
||||
from ocrmypdf.helpers import fspath
|
||||
from ocrmypdf import pdfinfo
|
||||
import sys
|
||||
import os
|
||||
@@ -29,8 +30,7 @@ from pathlib import Path
|
||||
spoof = pytest.helpers.spoof
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ensure_tess4():
|
||||
def _ensure_tess4():
|
||||
if tesseract.v4():
|
||||
# "tesseract" on $PATH is already v4
|
||||
return os.environ.copy()
|
||||
@@ -44,17 +44,26 @@ def ensure_tess4():
|
||||
tess4 = Path(os.environ['OCRMYPDF_TESS4'])
|
||||
assert tess4.is_file()
|
||||
env['PATH'] = tess4.parent + ':' + env['PATH']
|
||||
env['OCRMYPDF_TESS4'] = os.environ['OCRMYPDF_TESS4']
|
||||
return env
|
||||
|
||||
raise EnvironmentError("Can't find Tesseract 4")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ensure_tess4():
|
||||
return _ensure_tess4()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def modified_os_environ(env):
|
||||
old_env = os.environ.copy()
|
||||
os.environ = env
|
||||
os.environ.update(env)
|
||||
yield
|
||||
os.environ = old_env
|
||||
for key in env:
|
||||
del os.environ[key]
|
||||
if key in old_env:
|
||||
os.environ[key] = old_env[key]
|
||||
|
||||
|
||||
def tess4_available():
|
||||
@@ -63,8 +72,8 @@ def tess4_available():
|
||||
|
||||
"""
|
||||
try:
|
||||
# ensure_tess4 locates the tess4 binary we are going to check
|
||||
env = ensure_tess4()
|
||||
# _ensure_tess4 locates the tess4 binary we are going to check
|
||||
env = _ensure_tess4()
|
||||
with modified_os_environ(env):
|
||||
# Now jump into this environment and make sure it really is Tess4
|
||||
return tesseract.v4() and tesseract.has_textonly_pdf()
|
||||
@@ -145,3 +154,14 @@ def test_content_preservation(ensure_tess4, resources, outpdf):
|
||||
info = pdfinfo.PdfInfo(outpdf)
|
||||
page = info[0]
|
||||
assert len(page.images) > 1, "masks were rasterized"
|
||||
|
||||
|
||||
def test_no_languages(ensure_tess4, tmpdir):
|
||||
env = ensure_tess4
|
||||
(tmpdir / 'tessdata').mkdir()
|
||||
env['TESSDATA_PREFIX'] = fspath(tmpdir)
|
||||
|
||||
with modified_os_environ(env):
|
||||
with pytest.raises(MissingDependencyError):
|
||||
tesseract.languages.cache_clear()
|
||||
tesseract.languages()
|
||||
|
||||
Reference in New Issue
Block a user