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 |
+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
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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,38 @@ 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
|
v7.1.0
|
||||||
------
|
------
|
||||||
|
|
||||||
|
|||||||
@@ -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.3
|
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,11 +201,11 @@ 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.md') as f:
|
with open('README.md', encoding='utf-8') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
|
|||||||
+65
-37
@@ -269,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."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -294,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",
|
||||||
@@ -466,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'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -495,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():
|
||||||
@@ -605,6 +646,18 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
|||||||
# rather than a str. So reach into the object and get its name.
|
# rather than a str. So reach into the object and get its name.
|
||||||
exc_name = exc_name.__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'):
|
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]
|
||||||
@@ -621,34 +674,9 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
|||||||
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 "
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
+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