Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+1
-1
@@ -51,7 +51,7 @@ RUN . /appenv/bin/activate; \
|
|||||||
# Do this now to make the best use of Docker cache.
|
# Do this now to make the best use of Docker cache.
|
||||||
COPY . /application
|
COPY . /application
|
||||||
RUN . /appenv/bin/activate; \
|
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
|
# Remove the junk, including the source version of application since it was
|
||||||
# already installed
|
# already installed
|
||||||
|
|||||||
+14
-21
@@ -1,47 +1,40 @@
|
|||||||
# Development environment
|
# Development environment
|
||||||
|
.bash_history
|
||||||
|
.pylintrc
|
||||||
|
.pytest_cache/
|
||||||
|
.ruffus_history.sqlite
|
||||||
|
.venv/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
venv*/
|
|
||||||
.venv/
|
|
||||||
pyvenv.cfg
|
|
||||||
tasks.py
|
|
||||||
.bash_history
|
|
||||||
.ruffus_history.sqlite
|
|
||||||
.idea/
|
|
||||||
.pytest_cache/
|
|
||||||
.pylintrc
|
|
||||||
|
|
||||||
# Package building
|
# Package building
|
||||||
*.egg-info/
|
|
||||||
.cache/
|
|
||||||
.eggs/
|
.eggs/
|
||||||
|
*.egg-info/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
wheelhouse/
|
wheelhouse/
|
||||||
|
|
||||||
# Automatically generated files
|
# Automatically generated files
|
||||||
ocrmypdf/lib/_*.py
|
|
||||||
ocrmypdf/version.py
|
|
||||||
docs/_build/
|
docs/_build/
|
||||||
docs/_static/
|
docs/_static/
|
||||||
docs/_templates/
|
docs/_templates/
|
||||||
docs/Makefile
|
docs/Makefile
|
||||||
|
ocrmypdf/lib/_*.py
|
||||||
|
|
||||||
# Code coverage
|
# Code coverage
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
log/
|
.ipynb_checkpoints/
|
||||||
|
.vscode/
|
||||||
|
*.ipynb
|
||||||
|
*.profile
|
||||||
/*.pdf
|
/*.pdf
|
||||||
/*.qdf
|
/*.qdf
|
||||||
*.ipynb
|
/scratch.py
|
||||||
.ipynb_checkpoints/
|
IDEAS
|
||||||
|
log/
|
||||||
tests/output/
|
tests/output/
|
||||||
tests/resources/private/
|
tests/resources/private/
|
||||||
tmp/
|
tmp/
|
||||||
pdfbox-app*.jar
|
|
||||||
.vscode/
|
|
||||||
IDEAS
|
|
||||||
_Dockerfile.local
|
|
||||||
/scratch.py
|
|
||||||
|
|||||||
+4
-4
@@ -66,7 +66,7 @@ matrix:
|
|||||||
- tesseract-ocr-fra
|
- tesseract-ocr-fra
|
||||||
- unpaper
|
- unpaper
|
||||||
- os: osx
|
- os: osx
|
||||||
osx_image: xcode8
|
osx_image: xcode9.2
|
||||||
language: generic
|
language: generic
|
||||||
|
|
||||||
before_cache:
|
before_cache:
|
||||||
@@ -83,7 +83,7 @@ before_install: |
|
|||||||
sudo dpkg -i packages/unpaper_6.1-1.deb
|
sudo dpkg -i packages/unpaper_6.1-1.deb
|
||||||
fi
|
fi
|
||||||
elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
|
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 --upgrade pip
|
||||||
pip3 install wheel
|
pip3 install wheel
|
||||||
fi
|
fi
|
||||||
@@ -91,9 +91,9 @@ before_install: |
|
|||||||
install:
|
install:
|
||||||
- export PATH=$PWD/bin:$PATH
|
- export PATH=$PWD/bin:$PATH
|
||||||
- pip3 install pycparser # py3.7 workaround for https://github.com/eliben/pycparser/issues/251
|
- 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 --no-deps .
|
||||||
- pip3 install -r test_requirements.txt
|
- pip3 install -r requirements/test.txt
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- tesseract --version
|
- tesseract --version
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ brew 'exempi'
|
|||||||
brew 'ghostscript'
|
brew 'ghostscript'
|
||||||
brew 'jbig2enc'
|
brew 'jbig2enc'
|
||||||
brew 'leptonica'
|
brew 'leptonica'
|
||||||
brew 'libtiff'
|
|
||||||
brew 'openjpeg'
|
brew 'openjpeg'
|
||||||
brew 'pngquant'
|
brew 'pngquant'
|
||||||
brew 'qpdf'
|
brew 'qpdf'
|
||||||
|
|||||||
+5
-11
@@ -1,7 +1,5 @@
|
|||||||
# requirements
|
# requirements
|
||||||
include requirements.txt
|
recursive-include requirements *
|
||||||
include test_requirements.txt
|
|
||||||
include dev_requirements.txt
|
|
||||||
|
|
||||||
# git
|
# git
|
||||||
include .git_archival.txt
|
include .git_archival.txt
|
||||||
@@ -12,15 +10,14 @@ recursive-include .docker *
|
|||||||
|
|
||||||
# tests
|
# tests
|
||||||
include .coveragerc
|
include .coveragerc
|
||||||
|
recursive-include tests *.bin
|
||||||
recursive-include tests *.jpg
|
recursive-include tests *.jpg
|
||||||
|
recursive-include tests *.jsonl
|
||||||
recursive-include tests *.png
|
recursive-include tests *.png
|
||||||
recursive-include tests *.pdf
|
recursive-include tests *.pdf
|
||||||
recursive-include tests *.py
|
recursive-include tests *.py
|
||||||
recursive-include tests *.rst
|
recursive-include tests *.rst
|
||||||
recursive-include tests *.txt
|
recursive-include tests *.txt
|
||||||
recursive-include tests/cache *
|
|
||||||
recursive-exclude tests/output *
|
|
||||||
recursive-exclude tests/output_pageinfo *
|
|
||||||
recursive-exclude tests/resources/private *
|
recursive-exclude tests/resources/private *
|
||||||
|
|
||||||
# documentation
|
# documentation
|
||||||
@@ -42,8 +39,5 @@ exclude .travis*
|
|||||||
|
|
||||||
|
|
||||||
# code
|
# code
|
||||||
recursive-include src/ocrmypdf *.py
|
exclude src/ocrmypdf/lib/_leptonica.py
|
||||||
exclude ocrmypdf/lib/_leptonica.py
|
exclude scratch.py
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
OCRmyPDF
|
||||||
|
========
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 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
@@ -213,4 +213,6 @@ Currently optimization attempts to find more efficient encodings for images. The
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. 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`.
|
||||||
|
|||||||
@@ -536,6 +536,6 @@ To install all of the development and test requirements:
|
|||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
cd OCRmyPDF
|
cd OCRmyPDF
|
||||||
pip install -e .
|
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 :ref:`jbig2`.
|
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||||
|
|||||||
@@ -20,3 +20,16 @@ For all other Linux, you must build a JBIG2 encoder from source:
|
|||||||
./autogen.sh
|
./autogen.sh
|
||||||
./configure && make
|
./configure && make
|
||||||
[sudo] make install
|
[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.*
|
||||||
|
|||||||
@@ -13,6 +13,53 @@ Note that it is licensed under GPLv3, so scripts that ``import ocrmypdf`` and ar
|
|||||||
find: [^`]\#([0-9]{1,3})[^0-9]
|
find: [^`]\#([0-9]{1,3})[^0-9]
|
||||||
replace: `#$1 <https://github.com/jbarlow83/OCRmyPDF/issues/$1>`_
|
replace: `#$1 <https://github.com/jbarlow83/OCRmyPDF/issues/$1>`_
|
||||||
|
|
||||||
|
|
||||||
|
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
|
v7.0.6
|
||||||
------
|
------
|
||||||
|
|
||||||
@@ -105,6 +152,13 @@ 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>`_.
|
+ 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.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
|
v6.2.3
|
||||||
------
|
------
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# installation
|
# installation
|
||||||
cffi == 1.11.5
|
cffi == 1.11.5
|
||||||
img2pdf == 0.3.0
|
img2pdf == 0.3.0
|
||||||
pikepdf == 0.3.2
|
pikepdf == 0.3.4
|
||||||
Pillow >= 5.0.0, != 5.1.0 ; sys_platform == "darwin"
|
Pillow >= 5.0.0, != 5.1.0 ; sys_platform == "darwin"
|
||||||
pycparser == 2.18
|
pycparser == 2.18
|
||||||
python-xmp-toolkit == 2.0.1
|
python-xmp-toolkit == 2.0.1
|
||||||
@@ -201,17 +201,18 @@ if 'upload' in sys.argv[1:]:
|
|||||||
print('Use twine to upload the package - setup.py upload is insecure')
|
print('Use twine to upload the package - setup.py upload is insecure')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
tests_require = open('test_requirements.txt').read().splitlines()
|
tests_require = open('requirements/test.txt', encoding='utf-8').read().splitlines()
|
||||||
|
|
||||||
|
|
||||||
def readme():
|
def readme():
|
||||||
with open('README.rst') as f:
|
with open('README.md', encoding='utf-8') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='ocrmypdf',
|
name='ocrmypdf',
|
||||||
description='OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched',
|
description='OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched',
|
||||||
long_description=readme(),
|
long_description=readme(),
|
||||||
|
long_description_content_type='text/markdown',
|
||||||
url='https://github.com/jbarlow83/OCRmyPDF',
|
url='https://github.com/jbarlow83/OCRmyPDF',
|
||||||
author='James R. Barlow',
|
author='James R. Barlow',
|
||||||
author_email='jim@purplerock.ca',
|
author_email='jim@purplerock.ca',
|
||||||
@@ -250,7 +251,7 @@ setup(
|
|||||||
install_requires=[
|
install_requires=[
|
||||||
'cffi >= 1.9.1', # must be a setup and install requirement
|
'cffi >= 1.9.1', # must be a setup and install requirement
|
||||||
'img2pdf >= 0.2.4, < 0.4', # pure Python, so track HEAD closely
|
'img2pdf >= 0.2.4, < 0.4', # pure Python, so track HEAD closely
|
||||||
'pikepdf >= 0.3.2, < 0.4',
|
'pikepdf >= 0.3.3, < 0.4',
|
||||||
'Pillow >= 4.0.0, != 5.1.0 ; sys_platform == "darwin"',
|
'Pillow >= 4.0.0, != 5.1.0 ; sys_platform == "darwin"',
|
||||||
# Pillow < 4 has BytesIO/TIFF bug w/img2pdf 0.2.3
|
# Pillow < 4 has BytesIO/TIFF bug w/img2pdf 0.2.3
|
||||||
# block 5.1.0, broken wheels
|
# block 5.1.0, broken wheels
|
||||||
@@ -269,4 +270,10 @@ setup(
|
|||||||
},
|
},
|
||||||
package_data={'ocrmypdf': ['data/sRGB.icc']},
|
package_data={'ocrmypdf': ['data/sRGB.icc']},
|
||||||
include_package_data=True,
|
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'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
+83
-47
@@ -17,7 +17,6 @@
|
|||||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from collections.abc import Sequence
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -36,7 +35,7 @@ import ruffus.proxy_logger as proxy_logger
|
|||||||
from ._jobcontext import JobContext, JobContextManager, cleanup_working_files
|
from ._jobcontext import JobContext, JobContextManager, cleanup_working_files
|
||||||
from ._pipeline import build_pipeline
|
from ._pipeline import build_pipeline
|
||||||
from .pdfa import file_claims_pdfa
|
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
|
available_cpu_count
|
||||||
from .exec import tesseract, qpdf, ghostscript
|
from .exec import tesseract, qpdf, ghostscript
|
||||||
from . import PROGRAM_NAME, VERSION
|
from . import PROGRAM_NAME, VERSION
|
||||||
@@ -270,17 +269,17 @@ optimizing = parser.add_argument_group(
|
|||||||
optimizing.add_argument(
|
optimizing.add_argument(
|
||||||
'-O', '--optimize', type=int, choices=range(0, 4), default=1,
|
'-O', '--optimize', type=int, choices=range(0, 4), default=1,
|
||||||
help=("Control how PDF is optimized after processing:"
|
help=("Control how PDF is optimized after processing:"
|
||||||
"0 - do not optimize;"
|
"0 - do not optimize; "
|
||||||
"1 - do safe, lossless optimizations (default);"
|
"1 - do safe, lossless optimizations (default); "
|
||||||
"2 - do lossy optimizations; "
|
"2 - do some lossy optimizations; "
|
||||||
"3 - do aggressive lossy optimizations"
|
"3 - do aggressive lossy optimizations (including lossy JBIG2)"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
optimizing.add_argument(
|
optimizing.add_argument(
|
||||||
'--jpeg-quality', type=numeric(int, 0, 100), default=0, metavar='Q',
|
'--jpeg-quality', type=numeric(int, 0, 100), default=0, metavar='Q',
|
||||||
help=("Adjust JPEG quality level for JPEG optimization. "
|
help=("Adjust JPEG quality level for JPEG optimization. "
|
||||||
"100 is best quality and largest output size; "
|
"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."
|
"0 uses the default."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -295,6 +294,18 @@ optimizing.add_argument(
|
|||||||
"Values have same meaning as with --jpeg-quality"
|
"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 = parser.add_argument_group(
|
||||||
"Advanced",
|
"Advanced",
|
||||||
@@ -467,21 +478,34 @@ def check_options_sidecar(options, log):
|
|||||||
options.sidecar = options.output_file + '.txt'
|
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:
|
try:
|
||||||
if version_fn() < min_version:
|
if version_fn() < min_version:
|
||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
"The installed '{}' is not supported. "
|
"The installed '{}' is not supported. "
|
||||||
"Install version {} or newer.".format(name, min_version))
|
"Install version {} or newer.".format(name, min_version))
|
||||||
except FileNotFoundError:
|
except (FileNotFoundError, MissingDependencyError):
|
||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
"Install the '{}' program to use {}.".format(name, for_argument))
|
"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):
|
def check_options_preprocessing(options, log):
|
||||||
if any((options.clean, options.clean_final)):
|
if any((options.clean, options.clean_final)):
|
||||||
from .exec import unpaper
|
from .exec import unpaper
|
||||||
_optional_program_check(
|
_optional_program_required(
|
||||||
'unpaper', unpaper.version, '6.1', '--clean, --clean-final'
|
'unpaper', unpaper.version, '6.1', '--clean, --clean-final'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -496,13 +520,29 @@ def check_options_ocr_behavior(options, log):
|
|||||||
def check_options_optimizing(options, log):
|
def check_options_optimizing(options, log):
|
||||||
if options.optimize >= 2:
|
if options.optimize >= 2:
|
||||||
from .exec import pngquant, jbig2enc
|
from .exec import pngquant, jbig2enc
|
||||||
_optional_program_check(
|
_optional_program_required(
|
||||||
'pngquant', pngquant.version, '2.0.1', '--optimize {2,3}'
|
'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}'
|
'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):
|
def check_options_advanced(options, log):
|
||||||
if options.tesseract_oem and not tesseract.v4():
|
if options.tesseract_oem and not tesseract.v4():
|
||||||
@@ -598,52 +638,45 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
|||||||
description of the error message that occurred."""
|
description of the error message that occurred."""
|
||||||
exit_code = None
|
exit_code = None
|
||||||
|
|
||||||
task_name, job_name, exc_name, exc_value, exc_stack = ruffus_five_tuple
|
_task_name, _job_name, exc_name, exc_value, exc_stack = ruffus_five_tuple
|
||||||
task_name = task_name # unused
|
|
||||||
job_name = job_name # unused
|
if isinstance(exc_name, type):
|
||||||
if exc_name == 'builtins.SystemExit':
|
# 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)
|
||||||
|
else:
|
||||||
|
exc_msg = str(exc_class())
|
||||||
|
except Exception:
|
||||||
|
exc_msg = "Unknown"
|
||||||
|
|
||||||
|
if exc_name in ('builtins.SystemExit', 'SystemExit'):
|
||||||
match = re.search(r"\.(.+?)\)", exc_value)
|
match = re.search(r"\.(.+?)\)", exc_value)
|
||||||
exit_code_name = match.groups()[0]
|
exit_code_name = match.groups()[0]
|
||||||
exit_code = getattr(ExitCode, exit_code_name, 'other_error')
|
exit_code = getattr(ExitCode, exit_code_name, 'other_error')
|
||||||
elif exc_name == 'ruffus.ruffus_exceptions.MissingInputFileError':
|
elif exc_name == 'ruffus.ruffus_exceptions.MissingInputFileError':
|
||||||
log.error(cleanup_ruffus_error_message(exc_value))
|
log.error(cleanup_ruffus_error_message(exc_value))
|
||||||
exit_code = ExitCode.input_file
|
exit_code = ExitCode.input_file
|
||||||
elif exc_name == 'builtins.KeyboardInterrupt':
|
elif exc_name in ('builtins.KeyboardInterrupt', 'KeyboardInterrupt'):
|
||||||
log.error("Interrupted by user")
|
# 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
|
exit_code = ExitCode.ctrl_c
|
||||||
elif exc_name == 'subprocess.CalledProcessError':
|
elif exc_name == 'subprocess.CalledProcessError':
|
||||||
# It's up to the subprocess handler to report something useful
|
# It's up to the subprocess handler to report something useful
|
||||||
msg = "Error occurred while running this command:"
|
msg = "Error occurred while running this command:"
|
||||||
log.error(msg + '\n' + exc_value)
|
log.error(msg + '\n' + exc_value)
|
||||||
exit_code = ExitCode.child_process_error
|
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.'):
|
elif exc_name.startswith('ocrmypdf.exceptions.'):
|
||||||
base_exc_name = exc_name.replace('ocrmypdf.exceptions.', '')
|
if exc_msg:
|
||||||
exc_class = getattr(ocrmypdf_exceptions, base_exc_name)
|
log.error(exc_msg)
|
||||||
exit_code = exc_class.exit_code
|
|
||||||
elif exc_name == 'PIL.Image.DecompressionBombError':
|
elif exc_name == 'PIL.Image.DecompressionBombError':
|
||||||
msg = cleanup_ruffus_error_message(exc_value)
|
msg = cleanup_ruffus_error_message(exc_value)
|
||||||
msg += ("\nUse the --max-image-mpixels argument to set increase the "
|
msg += ("\nUse the --max-image-mpixels argument to set increase the "
|
||||||
@@ -751,6 +784,7 @@ def preamble(_log):
|
|||||||
_log.debug('ocrmypdf ' + VERSION)
|
_log.debug('ocrmypdf ' + VERSION)
|
||||||
_log.debug('tesseract ' + tesseract.version())
|
_log.debug('tesseract ' + tesseract.version())
|
||||||
_log.debug('qpdf ' + qpdf.version())
|
_log.debug('qpdf ' + qpdf.version())
|
||||||
|
_log.debug('gs ' + ghostscript.version())
|
||||||
|
|
||||||
|
|
||||||
def check_environ(options, _log):
|
def check_environ(options, _log):
|
||||||
@@ -835,9 +869,11 @@ def report_output_file_size(options, _log, input_file, output_file):
|
|||||||
""".format(ratio, explanation)))
|
""".format(ratio, explanation)))
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline():
|
def run_pipeline(args=None):
|
||||||
options = parser.parse_args()
|
options = parser.parse_args(args=args)
|
||||||
options.verbose_abbreviated_path = 1
|
options.verbose_abbreviated_path = 1
|
||||||
|
if os.environ.get('_OCRMYPDF_THREADS'):
|
||||||
|
options.use_threads = True
|
||||||
|
|
||||||
if not check_closed_streams(options):
|
if not check_closed_streams(options):
|
||||||
return ExitCode.bad_args
|
return ExitCode.bad_args
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ def repair_and_parse_pdf(
|
|||||||
copyfile(input_file, output_file)
|
copyfile(input_file, output_file)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pdfinfo = PdfInfo(output_file)
|
pdfinfo = PdfInfo(output_file, log=log)
|
||||||
except pikepdf.PasswordError as e:
|
except pikepdf.PasswordError as e:
|
||||||
raise EncryptedPdfError()
|
raise EncryptedPdfError()
|
||||||
except pikepdf.PdfError as e:
|
except pikepdf.PdfError as e:
|
||||||
@@ -830,7 +830,13 @@ def metadata_fixup(
|
|||||||
pdfmark = get_pdfmark(metadata, options)
|
pdfmark = get_pdfmark(metadata, options)
|
||||||
pdf = pikepdf.open(layers_file)
|
pdf = pikepdf.open(layers_file)
|
||||||
pdf.metadata = pdf.make_indirect(pikepdf.Dictionary(pdfmark))
|
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(
|
def optimize_pdf(
|
||||||
|
|||||||
@@ -169,17 +169,16 @@ def _traverse_toc(pdf_base, visitor_fn, log):
|
|||||||
continue
|
continue
|
||||||
item = node[key]
|
item = node[key]
|
||||||
if not item.is_indirect:
|
if not item.is_indirect:
|
||||||
# or not isinstance(item, pikepdf.Dictionary):
|
# Direct references are not allowed here, but it's not clear
|
||||||
# # If there is garbage data, replace the key with an indirect
|
# what we should do if we find any. Removing them is an option:
|
||||||
# # ref to None. Kodak Capture Desktop produces keys like these.
|
|
||||||
# log.error('Removing invalid reference from TOC: %s', repr(item))
|
|
||||||
# node[key] = pdf_base.make_indirect(None)
|
# node[key] = pdf_base.make_indirect(None)
|
||||||
continue
|
continue
|
||||||
objgen = item.objgen
|
objgen = item.objgen
|
||||||
if objgen not in visited:
|
if objgen not in visited:
|
||||||
queue.add(objgen)
|
queue.add(objgen)
|
||||||
|
|
||||||
visitor_fn(pdf_base, node, log)
|
if visitor_fn:
|
||||||
|
visitor_fn(pdf_base, node, log)
|
||||||
|
|
||||||
|
|
||||||
def _fix_toc(pdf_base, pageref_remap, log):
|
def _fix_toc(pdf_base, pageref_remap, log):
|
||||||
@@ -282,7 +281,7 @@ def weave_layers(
|
|||||||
# page references in the table of contents. Some PDF generators put invalid
|
# 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
|
# references in the ToC, so we want to resolve them to null before we
|
||||||
# create any references, or the ToC will be corrupted
|
# create any references, or the ToC will be corrupted
|
||||||
_traverse_toc(pdf_base, lambda *args: None, log)
|
_traverse_toc(pdf_base, None, log)
|
||||||
|
|
||||||
procset = pdf_base.make_indirect(
|
procset = pdf_base.make_indirect(
|
||||||
pikepdf.Object.parse(b'[ /PDF /Text /ImageB /ImageC /ImageI ]'))
|
pikepdf.Object.parse(b'[ /PDF /Text /ImageB /ImageC /ImageI ]'))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
|
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
class ExitCode(IntEnum):
|
class ExitCode(IntEnum):
|
||||||
ok = 0
|
ok = 0
|
||||||
@@ -36,6 +37,13 @@ class ExitCode(IntEnum):
|
|||||||
|
|
||||||
class ExitCodeException(Exception):
|
class ExitCodeException(Exception):
|
||||||
exit_code = ExitCode.other_error
|
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):
|
class BadArgsError(ExitCodeException):
|
||||||
@@ -44,7 +52,15 @@ class BadArgsError(ExitCodeException):
|
|||||||
|
|
||||||
class PdfMergeFailedError(ExitCodeException):
|
class PdfMergeFailedError(ExitCodeException):
|
||||||
exit_code = ExitCode.input_file
|
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):
|
class MissingDependencyError(ExitCodeException):
|
||||||
exit_code = ExitCode.missing_dependency
|
exit_code = ExitCode.missing_dependency
|
||||||
@@ -76,7 +92,18 @@ class SubprocessOutputError(ExitCodeException):
|
|||||||
|
|
||||||
class EncryptedPdfError(ExitCodeException):
|
class EncryptedPdfError(ExitCodeException):
|
||||||
exit_code = ExitCode.encrypted_pdf
|
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):
|
class TesseractConfigError(ExitCodeException):
|
||||||
exit_code = ExitCode.invalid_config
|
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
|
from ..exceptions import MissingDependencyError
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_version(program, *,
|
def get_version(program, *,
|
||||||
version_arg='--version', regex=r'(\d+(\.\d+)*)'):
|
version_arg='--version', regex=r'(\d+(\.\d+)*)'):
|
||||||
"Get the version of the specified program"
|
"Get the version of the specified program"
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ def version():
|
|||||||
|
|
||||||
|
|
||||||
def jpeg_passthrough_available():
|
def jpeg_passthrough_available():
|
||||||
"""
|
"""Returns True if the installed version of Ghostscript supports JPEG passthru
|
||||||
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
|
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
|
it gained the ability to keep JPEGs unmodified. However, the 9.23
|
||||||
@@ -54,8 +53,7 @@ def _gs_error_reported(stream):
|
|||||||
|
|
||||||
|
|
||||||
def extract_text(input_file, pageno=1):
|
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
|
For details on options of -dTextFormat see
|
||||||
https://www.ghostscript.com/doc/current/VectorDevices.htm#TXT
|
https://www.ghostscript.com/doc/current/VectorDevices.htm#TXT
|
||||||
@@ -66,10 +64,18 @@ def extract_text(input_file, pageno=1):
|
|||||||
<span bbox="left top right bottom" font="..." size="...">
|
<span bbox="left top right bottom" font="..." size="...">
|
||||||
<char bbox="...." c="X"/>
|
<char bbox="...." c="X"/>
|
||||||
|
|
||||||
|
:param pageno: number of page to extract, or all pages if None
|
||||||
:return: XML-ish text representation in bytes
|
:return: XML-ish text representation in bytes
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if pageno is not None:
|
||||||
|
pages = [
|
||||||
|
'-dFirstPage=%i' % pageno,
|
||||||
|
'-dLastPage=%i' % pageno
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
pages = []
|
||||||
|
|
||||||
args_gs = [
|
args_gs = [
|
||||||
'gs',
|
'gs',
|
||||||
'-dQUIET',
|
'-dQUIET',
|
||||||
@@ -78,10 +84,9 @@ def extract_text(input_file, pageno=1):
|
|||||||
'-dNOPAUSE',
|
'-dNOPAUSE',
|
||||||
'-sDEVICE=txtwrite',
|
'-sDEVICE=txtwrite',
|
||||||
'-dTextFormat=0',
|
'-dTextFormat=0',
|
||||||
'-dFirstPage=%i' % pageno,
|
] + pages + [
|
||||||
'-dLastPage=%i' % pageno,
|
|
||||||
'-o', '-',
|
'-o', '-',
|
||||||
input_file
|
fspath(input_file)
|
||||||
]
|
]
|
||||||
|
|
||||||
p = run(args_gs, stdout=PIPE, stderr=PIPE)
|
p = run(args_gs, stdout=PIPE, stderr=PIPE)
|
||||||
@@ -97,8 +102,7 @@ def extract_text(input_file, pageno=1):
|
|||||||
|
|
||||||
def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||||
pageno=1, page_dpi=None, rotation=None):
|
pageno=1, page_dpi=None, rotation=None):
|
||||||
"""
|
"""Rasterize one page of a PDF at resolution (xres, yres) in canvas units.
|
||||||
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
|
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
|
(xres, yres) even if those numbers are noninteger. The image's DPI will
|
||||||
@@ -180,6 +184,23 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
|||||||
|
|
||||||
def generate_pdfa(pdf_pages, output_file, compression, log,
|
def generate_pdfa(pdf_pages, output_file, compression, log,
|
||||||
threads=1, pdf_version='1.5', pdfa_part='2'):
|
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 = []
|
compression_args = []
|
||||||
if compression == 'jpeg':
|
if compression == 'jpeg':
|
||||||
compression_args = [
|
compression_args = [
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ def version():
|
|||||||
return get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
|
return get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def available():
|
def available():
|
||||||
try:
|
try:
|
||||||
version()
|
version()
|
||||||
@@ -44,10 +43,24 @@ def convert_group(*, cwd, infiles, out_prefix):
|
|||||||
'jbig2',
|
'jbig2',
|
||||||
'-b',
|
'-b',
|
||||||
out_prefix,
|
out_prefix,
|
||||||
'-s',
|
'-s', # symbol mode (lossy)
|
||||||
|
# '-r', # refinement mode (lossless symbol mode, currently disabled in
|
||||||
|
# jbig2)
|
||||||
'-p',
|
'-p',
|
||||||
]
|
]
|
||||||
args.extend(infiles)
|
args.extend(infiles)
|
||||||
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
|
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
|
||||||
proc.check_returncode()
|
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
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from . import get_version
|
from . import get_version
|
||||||
from ..exceptions import ExitCode
|
from ..exceptions import ExitCode, MissingDependencyError
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -31,6 +31,14 @@ def version():
|
|||||||
return get_version('pngquant', regex=r'(\d+(\.\d+)*).*')
|
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):
|
def quantize(input_file, output_file, quality_min, quality_max):
|
||||||
args = [
|
args = [
|
||||||
'pngquant',
|
'pngquant',
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ def tesseract_log_output(log, stdout, input_file):
|
|||||||
pass # Appears to be spurious/problem with nonwhite borders
|
pass # Appears to be spurious/problem with nonwhite borders
|
||||||
elif 'Error in boxClipToRectangle' in line:
|
elif 'Error in boxClipToRectangle' in line:
|
||||||
pass # Always appears with pixScanForForeground message
|
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():
|
elif 'error' in line.lower() or 'exception' in line.lower():
|
||||||
log.error(prefix + line.strip())
|
log.error(prefix + line.strip())
|
||||||
elif 'warning' in line.lower():
|
elif 'warning' in line.lower():
|
||||||
@@ -263,8 +267,6 @@ def generate_hocr(input_file, output_files, language: list, engine_mode,
|
|||||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
tesseract_log_output(log, e.output, input_file)
|
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:
|
if b'Image too large' in e.output:
|
||||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||||
return
|
return
|
||||||
@@ -350,9 +352,6 @@ def generate_pdf(*, input_image, skip_pdf=None, output_pdf, output_text,
|
|||||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
tesseract_log_output(log, e.output, input_image)
|
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:
|
if b'Image too large' in e.output:
|
||||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -464,30 +464,14 @@ class Pix:
|
|||||||
# implementation of pixCorrelationBinary that overflows on larger
|
# implementation of pixCorrelationBinary that overflows on larger
|
||||||
# images. Ubuntu 14.04/trusty has 1.70. Ubuntu PPA
|
# images. Ubuntu 14.04/trusty has 1.70. Ubuntu PPA
|
||||||
# ppa:alex-p/tesseract-ocr has leptonlib 1.75.
|
# ppa:alex-p/tesseract-ocr has leptonlib 1.75.
|
||||||
pix1_count = ffi.new('l_int32 *')
|
raise LeptonicaError("Leptonica version is too old")
|
||||||
pix2_count = ffi.new('l_int32 *')
|
|
||||||
pixn_count = ffi.new('l_int32 *')
|
|
||||||
tab8 = Pix.make_pixel_sum_tab8()
|
|
||||||
|
|
||||||
lept.pixCountPixels(pix1._pix, pix1_count, tab8)
|
correlation = ffi.new('float *', 0.0)
|
||||||
lept.pixCountPixels(pix2._pix, pix2_count, tab8)
|
result = lept.pixCorrelationBinary(pix1._pix, pix2._pix,
|
||||||
pixn = Pix(lept.pixAnd(ffi.NULL, pix1._pix, pix2._pix))
|
correlation)
|
||||||
lept.pixCountPixels(pixn._pix, pixn_count, tab8)
|
if result != 0:
|
||||||
|
raise LeptonicaError("Correlation failed")
|
||||||
# Python converts these int32s to larger units as needed
|
return correlation[0]
|
||||||
# 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]
|
|
||||||
|
|
||||||
def generate_pdf_ci_data(self, type_, quality):
|
def generate_pdf_ci_data(self, type_, quality):
|
||||||
"Convert to PDF data, with transcoding"
|
"Convert to PDF data, with transcoding"
|
||||||
|
|||||||
+173
-79
@@ -30,7 +30,6 @@ from . import leptonica
|
|||||||
from .helpers import re_symlink, fspath
|
from .helpers import re_symlink, fspath
|
||||||
from .exec import pngquant, jbig2enc
|
from .exec import pngquant, jbig2enc
|
||||||
|
|
||||||
PAGE_GROUP_SIZE = 10
|
|
||||||
DEFAULT_JPEG_QUALITY = 75
|
DEFAULT_JPEG_QUALITY = 75
|
||||||
DEFAULT_PNG_QUALITY = 70
|
DEFAULT_PNG_QUALITY = 70
|
||||||
|
|
||||||
@@ -51,26 +50,34 @@ def tif_name(root, xref):
|
|||||||
return img_name(root, xref, '.tif')
|
return img_name(root, xref, '.tif')
|
||||||
|
|
||||||
|
|
||||||
def extract_image(*, pike, root, log, image, xref, jbig2s,
|
def extract_image_filter(pike, root, log, image, xref):
|
||||||
pngs, jpegs, options):
|
|
||||||
if image.Subtype != '/Image':
|
if image.Subtype != '/Image':
|
||||||
return False
|
return None
|
||||||
if image.Length < 100:
|
if image.Length < 100:
|
||||||
log.debug("Skipping small image, xref {}".format(xref))
|
log.debug("Skipping small image, xref %s", xref)
|
||||||
return False
|
return None
|
||||||
|
|
||||||
pim = pikepdf.PdfImage(image)
|
pim = pikepdf.PdfImage(image)
|
||||||
|
|
||||||
if len(pim.filter_decodeparms) > 1:
|
if len(pim.filter_decodeparms) > 1:
|
||||||
log.debug("Skipping multiply filtered, xref {}".format(xref))
|
log.debug("Skipping multiply filtered, xref %s", xref)
|
||||||
return False
|
return None
|
||||||
filtdp = pim.filter_decodeparms[0]
|
filtdp = pim.filter_decodeparms[0]
|
||||||
|
|
||||||
if pim.bits_per_component > 8:
|
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':
|
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 \
|
if pim.bits_per_component == 1 \
|
||||||
and filtdp != '/JBIG2Decode' \
|
and filtdp != '/JBIG2Decode' \
|
||||||
@@ -81,9 +88,18 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
|||||||
ext = pim.extract_to(stream=f)
|
ext = pim.extract_to(stream=f)
|
||||||
imgname.rename(imgname.with_suffix(ext))
|
imgname.rename(imgname.with_suffix(ext))
|
||||||
except pikepdf.UnsupportedImageTypeError:
|
except pikepdf.UnsupportedImageTypeError:
|
||||||
return False
|
return None
|
||||||
jbig2s.append((xref, ext))
|
return xref, ext
|
||||||
elif filtdp[0] == '/DCTDecode' \
|
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:
|
and options.optimize >= 2:
|
||||||
# This is a simple heuristic derived from some training data, that has
|
# This is a simple heuristic derived from some training data, that has
|
||||||
# about a 70% chance of guessing whether the JPEG is high quality,
|
# about a 70% chance of guessing whether the JPEG is high quality,
|
||||||
@@ -92,7 +108,7 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
|||||||
# bytes_per_pixel = int(raw_jpeg.Length) / (w * h)
|
# bytes_per_pixel = int(raw_jpeg.Length) / (w * h)
|
||||||
# jpeg_quality_estimate = 117.0 * (bytes_per_pixel ** 0.213)
|
# jpeg_quality_estimate = 117.0 * (bytes_per_pixel ** 0.213)
|
||||||
# if jpeg_quality_estimate < 65:
|
# if jpeg_quality_estimate < 65:
|
||||||
# return False
|
# return None
|
||||||
|
|
||||||
# We could get the ICC profile here, but there's no need to look at it
|
# We could get the ICC profile here, but there's no need to look at it
|
||||||
# for quality transcoding
|
# for quality transcoding
|
||||||
@@ -107,88 +123,111 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
|||||||
ext = pim.extract_to(stream=f)
|
ext = pim.extract_to(stream=f)
|
||||||
imgname.rename(imgname.with_suffix(ext))
|
imgname.rename(imgname.with_suffix(ext))
|
||||||
except pikepdf.UnsupportedImageTypeError:
|
except pikepdf.UnsupportedImageTypeError:
|
||||||
return False
|
return None
|
||||||
jpegs.append(xref)
|
return xref, ext
|
||||||
elif pim.indexed \
|
elif pim.indexed \
|
||||||
and pim.colorspace in pim.SIMPLE_COLORSPACES \
|
and pim.colorspace in pim.SIMPLE_COLORSPACES \
|
||||||
and options.optimize >= 3:
|
and options.optimize >= 3:
|
||||||
# Try to improve on indexed images - these are far from low hanging
|
# Try to improve on indexed images - these are far from low hanging
|
||||||
# fruit in most cases
|
# fruit in most cases
|
||||||
pim.as_pil_image().save(png_name(root, xref))
|
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:
|
elif not pim.indexed and pim.colorspace in pim.SIMPLE_COLORSPACES:
|
||||||
# An optimization opportunity here, not currently taken, is directly
|
# An optimization opportunity here, not currently taken, is directly
|
||||||
# generating a PNG from compressed data
|
# generating a PNG from compressed data
|
||||||
pim.as_pil_image().save(png_name(root, xref))
|
pim.as_pil_image().save(png_name(root, xref))
|
||||||
pngs.append(xref)
|
return xref, '.png'
|
||||||
else:
|
else:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def extract_images(pike, root, log, options):
|
def extract_images(pike, root, log, options, extract_fn):
|
||||||
# Extract images we can improve
|
"""Extract image using extract_fn
|
||||||
changed_xrefs = set()
|
|
||||||
jbig2_groups = defaultdict(lambda: [])
|
extract_fn decides where the image is interesting in this case
|
||||||
jpegs = []
|
"""
|
||||||
pngs = []
|
|
||||||
|
include_xrefs = set()
|
||||||
|
exclude_xrefs = set()
|
||||||
errors = 0
|
errors = 0
|
||||||
for pageno, page in enumerate(pike.pages):
|
for pageno, page in enumerate(pike.pages):
|
||||||
group, _ = divmod(pageno, PAGE_GROUP_SIZE)
|
|
||||||
try:
|
try:
|
||||||
xobjs = page.Resources.XObject
|
xobjs = page.Resources.XObject
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
continue
|
continue
|
||||||
for imname, image in dict(xobjs).items():
|
for _imname, image in dict(xobjs).items():
|
||||||
if image.objgen[1] != 0:
|
if image.objgen[1] != 0:
|
||||||
continue # Ignore images in an incremental PDF
|
continue # Ignore images in an incremental PDF
|
||||||
xref = image.objgen[0]
|
xref = image.objgen[0]
|
||||||
if xref in changed_xrefs:
|
if hasattr(image, 'SMask'):
|
||||||
continue # Don't improve same image twice
|
# Ignore soft masks
|
||||||
try:
|
smask_xref = image.SMask.objgen[0]
|
||||||
result = extract_image(
|
exclude_xrefs.add(smask_xref)
|
||||||
pike=pike, root=root, log=log, image=image,
|
include_xrefs.add(xref)
|
||||||
xref=xref, jbig2s=jbig2_groups[group], pngs=pngs,
|
|
||||||
jpegs=jpegs, options=options
|
working_xrefs = include_xrefs - exclude_xrefs
|
||||||
)
|
for xref in working_xrefs:
|
||||||
if result:
|
image = pike.get_object((xref, 0))
|
||||||
changed_xrefs.add(xref)
|
try:
|
||||||
except Exception as e:
|
result = extract_fn(
|
||||||
log.debug("Image {} xref {}".format(imname, xref))
|
pike=pike, root=root, log=log, image=image,
|
||||||
log.debug(repr(e))
|
xref=xref, options=options
|
||||||
errors += 1
|
)
|
||||||
|
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
|
# Elide empty groups
|
||||||
jbig2_groups = {group: xrefs for group, xrefs in jbig2_groups.items()
|
jbig2_groups = {group: xrefs for group, xrefs in jbig2_groups.items()
|
||||||
if len(xrefs) > 0}
|
if len(xrefs) > 0}
|
||||||
log.debug(
|
log.debug(
|
||||||
"Optimizable images: "
|
"Optimizable images: "
|
||||||
"JBIG2 groups: {} JPEGs: {} PNGs: {} Errors: {}".format(
|
"JBIG2 groups: %s", (len(jbig2_groups),)
|
||||||
len(jbig2_groups), len(jpegs), len(pngs), errors
|
)
|
||||||
))
|
return jbig2_groups
|
||||||
|
|
||||||
return jbig2_groups, jpegs, pngs
|
|
||||||
|
|
||||||
|
|
||||||
def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
def _produce_jbig2_images(jbig2_groups, root, log, options):
|
||||||
"""
|
"""Produce JBIG2 images from their groups"""
|
||||||
Convert a group of JBIG2 images and insert into PDF.
|
|
||||||
|
|
||||||
We use a group because JBIG2 works best with a symbol dictionary that spans
|
def jbig2_group_futures(executor, root, groups):
|
||||||
multiple pages. When inserted back into the PDF, each JBIG2 must reference
|
for group, xref_exts in groups.items():
|
||||||
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():
|
|
||||||
prefix = 'group{:08d}'.format(group)
|
prefix = 'group{:08d}'.format(group)
|
||||||
future = executor.submit(
|
future = executor.submit(
|
||||||
jbig2enc.convert_group,
|
jbig2enc.convert_group,
|
||||||
@@ -196,15 +235,64 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
|||||||
infiles=(img_name(root, xref, ext) for xref, ext in xref_exts),
|
infiles=(img_name(root, xref, ext) for xref, ext in xref_exts),
|
||||||
out_prefix=prefix
|
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):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
proc = future.result()
|
proc = future.result()
|
||||||
log.debug(proc.stderr.decode())
|
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():
|
for group, xref_exts in jbig2_groups.items():
|
||||||
prefix = 'group{:08d}'.format(group)
|
prefix = 'group{:08d}'.format(group)
|
||||||
jbig2_globals_data = (root / (prefix + '.sym')).read_bytes()
|
jbig2_symfile = root / (prefix + '.sym')
|
||||||
jbig2_globals = pikepdf.Stream(pike, jbig2_globals_data)
|
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):
|
for n, xref_ext in enumerate(xref_exts):
|
||||||
xref, _ = xref_ext
|
xref, _ = xref_ext
|
||||||
@@ -213,9 +301,7 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
|||||||
im_obj = pike.get_object(xref, 0)
|
im_obj = pike.get_object(xref, 0)
|
||||||
im_obj.write(
|
im_obj.write(
|
||||||
jbig2_im_data, pikepdf.Name('/JBIG2Decode'),
|
jbig2_im_data, pikepdf.Name('/JBIG2Decode'),
|
||||||
pikepdf.Dictionary({
|
jbig2_globals_dict
|
||||||
'/JBIG2Globals': jbig2_globals
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -232,9 +318,9 @@ def transcode_jpegs(pike, jpegs, root, log, options):
|
|||||||
im.save(fspath(opt_jpg),
|
im.save(fspath(opt_jpg),
|
||||||
optimize=True,
|
optimize=True,
|
||||||
quality=options.jpeg_quality)
|
quality=options.jpeg_quality)
|
||||||
# pylint: disable=E1101
|
# pylint: disable=no-member
|
||||||
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
||||||
log.debug("xref {}, jpeg, made larger - skip".format(xref))
|
log.debug("xref %s, jpeg, made larger - skip", xref)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
compdata = leptonica.CompressedData.open(opt_jpg)
|
compdata = leptonica.CompressedData.open(opt_jpg)
|
||||||
@@ -326,21 +412,25 @@ def optimize(
|
|||||||
if options.png_quality == 0:
|
if options.png_quality == 0:
|
||||||
options.png_quality = \
|
options.png_quality = \
|
||||||
DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
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)
|
pike = pikepdf.Pdf.open(input_file)
|
||||||
|
|
||||||
root = Path(output_file).parent / 'images'
|
root = Path(output_file).parent / 'images'
|
||||||
root.mkdir(exist_ok=True) # pylint: disable=E1101
|
root.mkdir(exist_ok=True) # pylint: disable=no-member
|
||||||
jbig2_groups, jpegs, pngs = extract_images(
|
|
||||||
pike, root, log, options)
|
|
||||||
|
|
||||||
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_jpegs(pike, jpegs, root, log, options)
|
||||||
transcode_pngs(pike, pngs, 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')
|
target_file = Path(output_file).with_suffix('.opt.pdf')
|
||||||
pike.save(target_file, preserve_pdfa=True)
|
pike.save(target_file, preserve_pdfa=True,
|
||||||
|
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||||
|
|
||||||
input_size = Path(input_file).stat().st_size
|
input_size = Path(input_file).stat().st_size
|
||||||
output_size = Path(target_file).stat().st_size
|
output_size = Path(target_file).stat().st_size
|
||||||
@@ -363,11 +453,14 @@ def main(infile, outfile, level, jobs=1):
|
|||||||
class OptimizeOptions:
|
class OptimizeOptions:
|
||||||
"""Emulate ocrmypdf's options"""
|
"""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.jobs = jobs
|
||||||
self.optimize = optimize
|
self.optimize = optimize
|
||||||
self.jpeg_quality = jpeg_quality
|
self.jpeg_quality = jpeg_quality
|
||||||
self.png_quality = png_quality
|
self.png_quality = png_quality
|
||||||
|
self.jbig2_page_group_size = 0
|
||||||
|
self.jbig2_lossy = jb2lossy
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
log = logging.getLogger()
|
log = logging.getLogger()
|
||||||
@@ -377,7 +470,8 @@ def main(infile, outfile, level, jobs=1):
|
|||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
optimize=int(level),
|
optimize=int(level),
|
||||||
jpeg_quality=0, # Use default
|
jpeg_quality=0, # Use default
|
||||||
png_quality=0
|
png_quality=0,
|
||||||
|
jb2lossy=False
|
||||||
)
|
)
|
||||||
ctx.set_options(options)
|
ctx.set_options(options)
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ def _encode_ascii(s: str) -> str:
|
|||||||
'(': '',
|
'(': '',
|
||||||
')': '',
|
')': '',
|
||||||
'\\': '',
|
'\\': '',
|
||||||
|
'\0': ''
|
||||||
})
|
})
|
||||||
return s.translate(trans).encode('ascii', errors='replace').decode()
|
return s.translate(trans).encode('ascii', errors='replace').decode()
|
||||||
|
|
||||||
@@ -284,7 +285,7 @@ def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB', ascii_docinfo=False):
|
|||||||
hex_icc_profile = hexlify(bytes_icc_profile)
|
hex_icc_profile = hexlify(bytes_icc_profile)
|
||||||
icc_profile = '<' + hex_icc_profile.decode('ascii') + '>'
|
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
|
# We should have encoded everything to pure ASCII by this point, and
|
||||||
# to be safe, only allow ASCII in PostScript
|
# to be safe, only allow ASCII in PostScript
|
||||||
|
|||||||
+49
-29
@@ -16,12 +16,14 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
# 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 collections import namedtuple
|
||||||
from pathlib import Path
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from math import hypot, isclose
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from .exec import ghostscript
|
from .exec import ghostscript
|
||||||
from .helpers import fspath
|
from .helpers import fspath
|
||||||
@@ -486,27 +488,13 @@ def _find_images(*, pdf, container, shorthand=None):
|
|||||||
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
||||||
|
|
||||||
|
|
||||||
def _page_get_textblocks(infile, pageno):
|
def _page_get_textblocks(infile, pageno, xmltext):
|
||||||
"""Smarter text detection"""
|
"""Smarter text detection"""
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
gstext = ghostscript.extract_text(infile, pageno+1)
|
root = xmltext
|
||||||
|
if not hasattr(xmltext, 'findall'):
|
||||||
# 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 []
|
return []
|
||||||
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(gstext)
|
|
||||||
except ET.ParseError as e:
|
|
||||||
return [] # If we can't parse, assume none...
|
|
||||||
|
|
||||||
def blocks():
|
def blocks():
|
||||||
for span in root.findall('.//span'):
|
for span in root.findall('.//span'):
|
||||||
bbox_str = span.attrib['bbox']
|
bbox_str = span.attrib['bbox']
|
||||||
@@ -565,14 +553,15 @@ def _page_has_text(text_blocks, page_width, page_height):
|
|||||||
return has_text
|
return has_text
|
||||||
|
|
||||||
|
|
||||||
def _pdf_get_pageinfo(pdf, pageno: int, infile):
|
def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||||
pageinfo = {}
|
pageinfo = {}
|
||||||
pageinfo['pageno'] = pageno
|
pageinfo['pageno'] = pageno
|
||||||
pageinfo['images'] = []
|
pageinfo['images'] = []
|
||||||
|
|
||||||
page = pdf.pages[pageno]
|
page = pdf.pages[pageno]
|
||||||
|
|
||||||
pageinfo['textinfo'] = _page_get_textblocks(fspath(infile), pageno)
|
pageinfo['textinfo'] = _page_get_textblocks(
|
||||||
|
fspath(infile), pageno, xmltext=xmltext)
|
||||||
|
|
||||||
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
|
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
|
||||||
width_pt = mediabox[2] - mediabox[0]
|
width_pt = mediabox[2] - mediabox[0]
|
||||||
@@ -609,16 +598,47 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile):
|
|||||||
return pageinfo
|
return pageinfo
|
||||||
|
|
||||||
|
|
||||||
def _pdf_get_all_pageinfo(infile):
|
def _pdf_get_all_pageinfo(infile, log=None):
|
||||||
|
if not log:
|
||||||
|
log = Mock()
|
||||||
|
|
||||||
pdf = pikepdf.open(infile)
|
pdf = pikepdf.open(infile)
|
||||||
return [PageInfo(pdf, n, infile) for n in range(len(pdf.pages))], pdf
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
for n in range(len(pdf.pages)):
|
||||||
|
page = PageInfo(pdf, n, infile, page_xml[n])
|
||||||
|
pages.append(page)
|
||||||
|
|
||||||
|
return pages, pdf
|
||||||
|
|
||||||
|
|
||||||
class PageInfo:
|
class PageInfo:
|
||||||
def __init__(self, pdf, pageno, infile):
|
def __init__(self, pdf, pageno, infile, xmltext):
|
||||||
self._pageno = pageno
|
self._pageno = pageno
|
||||||
self._infile = infile
|
self._infile = infile
|
||||||
self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile)
|
self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile, xmltext)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pageno(self):
|
def pageno(self):
|
||||||
@@ -695,9 +715,9 @@ class PdfInfo:
|
|||||||
"""Get summary information about a PDF
|
"""Get summary information about a PDF
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, infile):
|
def __init__(self, infile, log=None):
|
||||||
self._infile = infile
|
self._infile = infile
|
||||||
self._pages, pdf = _pdf_get_all_pageinfo(infile)
|
self._pages, pdf = _pdf_get_all_pageinfo(infile, log=log)
|
||||||
self._needs_rendering = pdf.root.get('/NeedsRendering', False)
|
self._needs_rendering = pdf.root.get('/NeedsRendering', False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+2
-1
@@ -134,7 +134,8 @@ def check_ocrmypdf(input_file, output_file, *args, env=None):
|
|||||||
"Run ocrmypdf and confirmed that a valid file was created"
|
"Run ocrmypdf and confirmed that a valid file was created"
|
||||||
|
|
||||||
p, out, err = run_ocrmypdf(input_file, output_file, *args, env=env)
|
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 p.returncode == 0
|
||||||
assert os.path.exists(str(output_file)), "Output file not created"
|
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"
|
assert os.stat(str(output_file)).st_size > 100, "PDF too small or empty"
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
"""Replicate Ghostscript render failure while allowing rasterizing"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
"""Replicate Ghostscript render failure while allowing rasterizing"""
|
|
||||||
|
|
||||||
|
|
||||||
def real_ghostscript(argv):
|
def real_ghostscript(argv):
|
||||||
|
|||||||
+1
-1
@@ -595,7 +595,7 @@ THIS FILE IS INVALID
|
|||||||
resources / 'ccitt.pdf', outdir / 'out.pdf',
|
resources / 'ccitt.pdf', outdir / 'out.pdf',
|
||||||
'--pdf-renderer', renderer,
|
'--pdf-renderer', renderer,
|
||||||
'--tesseract-config', cfg_file)
|
'--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
|
assert p.returncode == ExitCode.invalid_config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+48
-3
@@ -22,9 +22,11 @@ import logging
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
from ocrmypdf import optimize as opt
|
from ocrmypdf import optimize as opt
|
||||||
from ocrmypdf.exec.ghostscript import rasterize_pdf
|
from ocrmypdf.exec.ghostscript import rasterize_pdf
|
||||||
from ocrmypdf.exec import jbig2enc
|
from ocrmypdf.exec import jbig2enc, pngquant
|
||||||
from ocrmypdf.helpers import fspath
|
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"
|
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):
|
def test_jpg_png_params(resources, outpdf, spoof_tesseract_noop):
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
resources / 'crom.png', outpdf, '--image-dpi', '200',
|
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
|
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'
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ from ocrmypdf.helpers import fspath
|
|||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
# pylint: disable=w0612
|
# 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
|
check_ocrmypdf = pytest.helpers.check_ocrmypdf
|
||||||
run_ocrmypdf = pytest.helpers.run_ocrmypdf
|
run_ocrmypdf = pytest.helpers.run_ocrmypdf
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -29,8 +29,7 @@ from pathlib import Path
|
|||||||
spoof = pytest.helpers.spoof
|
spoof = pytest.helpers.spoof
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def _ensure_tess4():
|
||||||
def ensure_tess4():
|
|
||||||
if tesseract.v4():
|
if tesseract.v4():
|
||||||
# "tesseract" on $PATH is already v4
|
# "tesseract" on $PATH is already v4
|
||||||
return os.environ.copy()
|
return os.environ.copy()
|
||||||
@@ -49,6 +48,11 @@ def ensure_tess4():
|
|||||||
raise EnvironmentError("Can't find Tesseract 4")
|
raise EnvironmentError("Can't find Tesseract 4")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ensure_tess4():
|
||||||
|
return _ensure_tess4()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def modified_os_environ(env):
|
def modified_os_environ(env):
|
||||||
old_env = os.environ.copy()
|
old_env = os.environ.copy()
|
||||||
@@ -63,8 +67,8 @@ def tess4_available():
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# ensure_tess4 locates the tess4 binary we are going to check
|
# _ensure_tess4 locates the tess4 binary we are going to check
|
||||||
env = ensure_tess4()
|
env = _ensure_tess4()
|
||||||
with modified_os_environ(env):
|
with modified_os_environ(env):
|
||||||
# Now jump into this environment and make sure it really is Tess4
|
# Now jump into this environment and make sure it really is Tess4
|
||||||
return tesseract.v4() and tesseract.has_textonly_pdf()
|
return tesseract.v4() and tesseract.has_textonly_pdf()
|
||||||
|
|||||||
Reference in New Issue
Block a user