Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc5dc93f3 | ||
|
|
c1e18bb825 | ||
|
|
58282ea0fb | ||
|
|
891da7834c | ||
|
|
5c229d48d5 | ||
|
|
53f660cf35 | ||
|
|
7b66ca68f2 | ||
|
|
ba71c3ffbd | ||
|
|
6707ad427a | ||
|
|
5b84549716 | ||
|
|
c74f2ee6e8 | ||
|
|
b32dd9f9d3 | ||
|
|
fb8b161f6c | ||
|
|
baddd6d233 | ||
|
|
6f554c6ae8 | ||
|
|
a71e4488b3 | ||
|
|
72156b5653 | ||
|
|
9fa471e053 | ||
|
|
31ef2fe907 | ||
|
|
9a8ec4b210 | ||
|
|
75aad4cc79 | ||
|
|
4b27feca98 | ||
|
|
45522cd15f | ||
|
|
677d9a4e76 |
+2
-2
@@ -66,7 +66,7 @@ matrix:
|
||||
- tesseract-ocr-fra
|
||||
- unpaper
|
||||
- os: osx
|
||||
osx_image: xcode8
|
||||
osx_image: xcode9.2
|
||||
language: generic
|
||||
|
||||
before_cache:
|
||||
@@ -83,7 +83,7 @@ before_install: |
|
||||
sudo dpkg -i packages/unpaper_6.1-1.deb
|
||||
fi
|
||||
elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
|
||||
brew update && brew bundle --file=.travis/Brewfile
|
||||
brew update --quiet && brew bundle --file=.travis/Brewfile --quiet
|
||||
pip3 install --upgrade pip
|
||||
pip3 install wheel
|
||||
fi
|
||||
|
||||
@@ -4,7 +4,6 @@ brew 'exempi'
|
||||
brew 'ghostscript'
|
||||
brew 'jbig2enc'
|
||||
brew 'leptonica'
|
||||
brew 'libtiff'
|
||||
brew 'openjpeg'
|
||||
brew 'pngquant'
|
||||
brew 'qpdf'
|
||||
|
||||
+3
-1
@@ -213,4 +213,6 @@ Currently optimization attempts to find more efficient encodings for images. The
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf --optimize 3 in.pdf out.pdf # Make it as small as possible
|
||||
ocrmypdf --optimize 3 in.pdf out.pdf # Make it small
|
||||
|
||||
Some users may consider enabling lossy JBIG2. See: :ref:`jbig2-lossy`.
|
||||
|
||||
@@ -20,3 +20,16 @@ For all other Linux, you must build a JBIG2 encoder from source:
|
||||
./autogen.sh
|
||||
./configure && make
|
||||
[sudo] make install
|
||||
|
||||
.. _jbig2-lossy:
|
||||
|
||||
Lossy mode JBIG2
|
||||
----------------
|
||||
|
||||
OCRmyPDF provides lossy mode JBIG2 as an advanced feature. Users should `review the technical concerns with JBIG2 in lossy mode <https://abbyy.technology/en:kb:tip:jbig2_compression_and_ocr>`_ and decide if this feature is acceptable for their use case.
|
||||
|
||||
JBIG2 lossy mode does achieve higher compression ratios than any other monochrome (bitonal) compression technology; for large text documents the savings are considerable. JBIG2 lossless still gives great compression ratios and is a major improvement over the older CCITT G4 standard. As explained above, there is some risk of substitution errors.
|
||||
|
||||
To turn on JBIG2 lossy mode, add the argument ``--jbig2-lossy``. ``--optimize {1,2,3}`` are necessary for the argument to take effect also required. Also, a JBIG2 encoder must be installed as described in the previous section.
|
||||
|
||||
*ocrmypdf v7.0 and v7.1 used lossy mode by default.*
|
||||
|
||||
@@ -13,6 +13,30 @@ Note that it is licensed under GPLv3, so scripts that ``import ocrmypdf`` and ar
|
||||
find: [^`]\#([0-9]{1,3})[^0-9]
|
||||
replace: `#$1 <https://github.com/jbarlow83/OCRmyPDF/issues/$1>`_
|
||||
|
||||
|
||||
v7.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
|
||||
------
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
# installation
|
||||
cffi == 1.11.5
|
||||
img2pdf == 0.3.0
|
||||
pikepdf == 0.3.3
|
||||
pikepdf == 0.3.4
|
||||
Pillow >= 5.0.0, != 5.1.0 ; sys_platform == "darwin"
|
||||
pycparser == 2.18
|
||||
python-xmp-toolkit == 2.0.1
|
||||
|
||||
+65
-37
@@ -269,17 +269,17 @@ optimizing = parser.add_argument_group(
|
||||
optimizing.add_argument(
|
||||
'-O', '--optimize', type=int, choices=range(0, 4), default=1,
|
||||
help=("Control how PDF is optimized after processing:"
|
||||
"0 - do not optimize;"
|
||||
"1 - do safe, lossless optimizations (default);"
|
||||
"2 - do lossy optimizations; "
|
||||
"3 - do aggressive lossy optimizations"
|
||||
"0 - do not optimize; "
|
||||
"1 - do safe, lossless optimizations (default); "
|
||||
"2 - do some lossy optimizations; "
|
||||
"3 - do aggressive lossy optimizations (including lossy JBIG2)"
|
||||
)
|
||||
)
|
||||
optimizing.add_argument(
|
||||
'--jpeg-quality', type=numeric(int, 0, 100), default=0, metavar='Q',
|
||||
help=("Adjust JPEG quality level for JPEG optimization. "
|
||||
"100 is best quality and largest output size; "
|
||||
"1 is lowest quality and smallest output"
|
||||
"1 is lowest quality and smallest output; "
|
||||
"0 uses the default."
|
||||
)
|
||||
)
|
||||
@@ -294,6 +294,18 @@ optimizing.add_argument(
|
||||
"Values have same meaning as with --jpeg-quality"
|
||||
)
|
||||
)
|
||||
optimizing.add_argument(
|
||||
'--jbig2-lossy', action='store_true',
|
||||
help=("Enable JBIG2 lossy mode (better compression, not suitable for some "
|
||||
"use cases - see documentation)."
|
||||
)
|
||||
)
|
||||
optimizing.add_argument(
|
||||
'--jbig2-page-group-size', type=numeric(int, 1, 10000), default=0,
|
||||
metavar='N',
|
||||
# Adjust number of pages to consider at once for JBIG2 compression
|
||||
help=argparse.SUPPRESS
|
||||
)
|
||||
|
||||
advanced = parser.add_argument_group(
|
||||
"Advanced",
|
||||
@@ -466,21 +478,34 @@ def check_options_sidecar(options, log):
|
||||
options.sidecar = options.output_file + '.txt'
|
||||
|
||||
|
||||
def _optional_program_check(name, version_fn, min_version, for_argument):
|
||||
def _optional_program_required(name, version_fn, min_version, for_argument):
|
||||
try:
|
||||
if version_fn() < min_version:
|
||||
raise MissingDependencyError(
|
||||
"The installed '{}' is not supported. "
|
||||
"Install version {} or newer.".format(name, min_version))
|
||||
except FileNotFoundError:
|
||||
except (FileNotFoundError, MissingDependencyError):
|
||||
raise MissingDependencyError(
|
||||
"Install the '{}' program to use {}.".format(name, for_argument))
|
||||
|
||||
|
||||
def _optional_program_recommended(name, version_fn, min_version, for_argument):
|
||||
try:
|
||||
if version_fn() < min_version:
|
||||
raise MissingDependencyError(
|
||||
"The installed '{}' is not supported. "
|
||||
"Install version {} or newer.".format(name, min_version))
|
||||
except (FileNotFoundError, MissingDependencyError):
|
||||
complain(
|
||||
"For best results, install the optional program '{}' to use the "
|
||||
"argument {}.".format(name, for_argument)
|
||||
)
|
||||
|
||||
|
||||
def check_options_preprocessing(options, log):
|
||||
if any((options.clean, options.clean_final)):
|
||||
from .exec import unpaper
|
||||
_optional_program_check(
|
||||
_optional_program_required(
|
||||
'unpaper', unpaper.version, '6.1', '--clean, --clean-final'
|
||||
)
|
||||
|
||||
@@ -495,13 +520,29 @@ def check_options_ocr_behavior(options, log):
|
||||
def check_options_optimizing(options, log):
|
||||
if options.optimize >= 2:
|
||||
from .exec import pngquant, jbig2enc
|
||||
_optional_program_check(
|
||||
_optional_program_required(
|
||||
'pngquant', pngquant.version, '2.0.1', '--optimize {2,3}'
|
||||
)
|
||||
_optional_program_check(
|
||||
|
||||
if options.jbig2_lossy:
|
||||
_optional_program_required(
|
||||
'jbig2', jbig2enc.version, '0.28', '--jbig2-lossy'
|
||||
)
|
||||
elif options.optimize >= 2:
|
||||
# Although we use JBIG2 for optimize=1, don't nag about it unless the
|
||||
# user is asking for more optimization
|
||||
_optional_program_recommended(
|
||||
'jbig2', jbig2enc.version, '0.28', '--optimize {2,3}'
|
||||
)
|
||||
|
||||
if options.optimize == 0 and any([
|
||||
options.jbig2_lossy, options.png_quality, options.jpeg_quality
|
||||
]):
|
||||
log.warning(
|
||||
"The arguments --jbig2-lossy, --png-quality, and --jpeg-quality "
|
||||
"will be ignored because --optimize=0."
|
||||
)
|
||||
|
||||
|
||||
def check_options_advanced(options, log):
|
||||
if options.tesseract_oem and not tesseract.v4():
|
||||
@@ -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.
|
||||
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)
|
||||
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:"
|
||||
log.error(msg + '\n' + exc_value)
|
||||
exit_code = ExitCode.child_process_error
|
||||
elif exc_name == 'ocrmypdf.exceptions.EncryptedPdfError':
|
||||
log.error(textwrap.dedent("""\
|
||||
Input PDF is encrypted. The encryption must be removed to
|
||||
perform OCR.
|
||||
|
||||
For information about this PDF's security use
|
||||
qpdf --show-encryption infilename
|
||||
|
||||
You can remove the encryption using
|
||||
qpdf --decrypt [--password=[password]] infilename
|
||||
|
||||
"""))
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
elif exc_name == 'ocrmypdf.exceptions.PdfMergeFailedError':
|
||||
log.error(textwrap.dedent("""\
|
||||
Failed to merge PDF image layer with OCR layer
|
||||
|
||||
Usually this happens because the input PDF file is mal-formed and
|
||||
ocrmypdf cannot automatically correct the problem on its own.
|
||||
|
||||
Try using
|
||||
ocrmypdf --pdf-renderer sandwich [..other args..]
|
||||
"""))
|
||||
exit_code = ExitCode.input_file
|
||||
elif exc_name.startswith('ocrmypdf.exceptions.'):
|
||||
base_exc_name = exc_name.replace('ocrmypdf.exceptions.', '')
|
||||
exc_class = getattr(ocrmypdf_exceptions, base_exc_name)
|
||||
exit_code = exc_class.exit_code
|
||||
if exc_msg:
|
||||
log.error(exc_msg)
|
||||
elif exc_name == 'PIL.Image.DecompressionBombError':
|
||||
msg = cleanup_ruffus_error_message(exc_value)
|
||||
msg += ("\nUse the --max-image-mpixels argument to set increase the "
|
||||
|
||||
@@ -169,17 +169,16 @@ def _traverse_toc(pdf_base, visitor_fn, log):
|
||||
continue
|
||||
item = node[key]
|
||||
if not item.is_indirect:
|
||||
# or not isinstance(item, pikepdf.Dictionary):
|
||||
# # If there is garbage data, replace the key with an indirect
|
||||
# # ref to None. Kodak Capture Desktop produces keys like these.
|
||||
# log.error('Removing invalid reference from TOC: %s', repr(item))
|
||||
# Direct references are not allowed here, but it's not clear
|
||||
# what we should do if we find any. Removing them is an option:
|
||||
# node[key] = pdf_base.make_indirect(None)
|
||||
continue
|
||||
objgen = item.objgen
|
||||
if objgen not in visited:
|
||||
queue.add(objgen)
|
||||
|
||||
visitor_fn(pdf_base, node, log)
|
||||
if visitor_fn:
|
||||
visitor_fn(pdf_base, node, 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
|
||||
# references in the ToC, so we want to resolve them to null before we
|
||||
# create any references, or the ToC will be corrupted
|
||||
_traverse_toc(pdf_base, lambda *args: None, log)
|
||||
_traverse_toc(pdf_base, None, log)
|
||||
|
||||
procset = pdf_base.make_indirect(
|
||||
pikepdf.Object.parse(b'[ /PDF /Text /ImageB /ImageC /ImageI ]'))
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
|
||||
from enum import IntEnum
|
||||
from textwrap import dedent
|
||||
|
||||
class ExitCode(IntEnum):
|
||||
ok = 0
|
||||
@@ -36,6 +37,13 @@ class ExitCode(IntEnum):
|
||||
|
||||
class ExitCodeException(Exception):
|
||||
exit_code = ExitCode.other_error
|
||||
message = ""
|
||||
|
||||
def __str__(self):
|
||||
super_msg = super().__str__() # Don't do str(super())
|
||||
if self.message:
|
||||
return self.message.format(super_msg)
|
||||
return super_msg
|
||||
|
||||
|
||||
class BadArgsError(ExitCodeException):
|
||||
@@ -44,7 +52,15 @@ class BadArgsError(ExitCodeException):
|
||||
|
||||
class PdfMergeFailedError(ExitCodeException):
|
||||
exit_code = ExitCode.input_file
|
||||
message = dedent('''\
|
||||
Failed to merge PDF image layer with OCR layer
|
||||
|
||||
Usually this happens because the input PDF file is malformed and
|
||||
ocrmypdf cannot automatically correct the problem on its own.
|
||||
|
||||
Try using
|
||||
ocrmypdf --pdf-renderer sandwich [..other args..]
|
||||
''')
|
||||
|
||||
class MissingDependencyError(ExitCodeException):
|
||||
exit_code = ExitCode.missing_dependency
|
||||
@@ -76,7 +92,18 @@ class SubprocessOutputError(ExitCodeException):
|
||||
|
||||
class EncryptedPdfError(ExitCodeException):
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
message = dedent('''\
|
||||
Input PDF is encrypted. The encryption must be removed to
|
||||
perform OCR.
|
||||
|
||||
For information about this PDF's security use
|
||||
qpdf --show-encryption infilename
|
||||
|
||||
You can remove the encryption using
|
||||
qpdf --decrypt [--password=[password]] infilename
|
||||
''')
|
||||
|
||||
|
||||
class TesseractConfigError(ExitCodeException):
|
||||
exit_code = ExitCode.invalid_config
|
||||
message = "Error occurred while parsing a Tesseract configuration file"
|
||||
|
||||
@@ -24,7 +24,6 @@ from subprocess import run, STDOUT, PIPE, CalledProcessError
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
|
||||
def get_version(program, *,
|
||||
version_arg='--version', regex=r'(\d+(\.\d+)*)'):
|
||||
"Get the version of the specified program"
|
||||
|
||||
@@ -30,7 +30,6 @@ def version():
|
||||
return get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def available():
|
||||
try:
|
||||
version()
|
||||
@@ -44,10 +43,24 @@ def convert_group(*, cwd, infiles, out_prefix):
|
||||
'jbig2',
|
||||
'-b',
|
||||
out_prefix,
|
||||
'-s',
|
||||
'-s', # symbol mode (lossy)
|
||||
# '-r', # refinement mode (lossless symbol mode, currently disabled in
|
||||
# jbig2)
|
||||
'-p',
|
||||
]
|
||||
args.extend(infiles)
|
||||
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
|
||||
proc.check_returncode()
|
||||
return proc
|
||||
return proc
|
||||
|
||||
|
||||
def convert_single(*, cwd, infile, outfile):
|
||||
args = [
|
||||
'jbig2',
|
||||
'-p',
|
||||
infile
|
||||
]
|
||||
with open(outfile, 'wb') as fstdout:
|
||||
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
||||
proc.check_returncode()
|
||||
return proc
|
||||
|
||||
@@ -23,7 +23,7 @@ import os
|
||||
import shutil
|
||||
|
||||
from . import get_version
|
||||
from ..exceptions import ExitCode
|
||||
from ..exceptions import ExitCode, MissingDependencyError
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -31,6 +31,14 @@ def version():
|
||||
return get_version('pngquant', regex=r'(\d+(\.\d+)*).*')
|
||||
|
||||
|
||||
def available():
|
||||
try:
|
||||
version()
|
||||
except MissingDependencyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def quantize(input_file, output_file, quality_min, quality_max):
|
||||
args = [
|
||||
'pngquant',
|
||||
@@ -42,4 +50,4 @@ def quantize(input_file, output_file, quality_min, quality_max):
|
||||
input_file
|
||||
]
|
||||
proc = run(args)
|
||||
proc.check_returncode()
|
||||
proc.check_returncode()
|
||||
|
||||
@@ -190,6 +190,10 @@ def tesseract_log_output(log, stdout, input_file):
|
||||
pass # Appears to be spurious/problem with nonwhite borders
|
||||
elif 'Error in boxClipToRectangle' in line:
|
||||
pass # Always appears with pixScanForForeground message
|
||||
elif 'parameter not found: ' in line.lower():
|
||||
log.error(prefix + line.strip())
|
||||
problem = line.split('found: ')[1]
|
||||
raise TesseractConfigError(problem)
|
||||
elif 'error' in line.lower() or 'exception' in line.lower():
|
||||
log.error(prefix + line.strip())
|
||||
elif 'warning' in line.lower():
|
||||
@@ -263,8 +267,6 @@ def generate_hocr(input_file, output_files, language: list, engine_mode,
|
||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_file)
|
||||
if b'read_params_file: parameter not found' in e.output:
|
||||
raise TesseractConfigError() from e
|
||||
if b'Image too large' in e.output:
|
||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||
return
|
||||
@@ -350,9 +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)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_image)
|
||||
if b'read_params_file: parameter not found' in e.output:
|
||||
raise TesseractConfigError() from e
|
||||
|
||||
if b'Image too large' in e.output:
|
||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||
return
|
||||
|
||||
+173
-79
@@ -30,7 +30,6 @@ from . import leptonica
|
||||
from .helpers import re_symlink, fspath
|
||||
from .exec import pngquant, jbig2enc
|
||||
|
||||
PAGE_GROUP_SIZE = 10
|
||||
DEFAULT_JPEG_QUALITY = 75
|
||||
DEFAULT_PNG_QUALITY = 70
|
||||
|
||||
@@ -51,26 +50,34 @@ def tif_name(root, xref):
|
||||
return img_name(root, xref, '.tif')
|
||||
|
||||
|
||||
def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
pngs, jpegs, options):
|
||||
def extract_image_filter(pike, root, log, image, xref):
|
||||
if image.Subtype != '/Image':
|
||||
return False
|
||||
return None
|
||||
if image.Length < 100:
|
||||
log.debug("Skipping small image, xref {}".format(xref))
|
||||
return False
|
||||
log.debug("Skipping small image, xref %s", xref)
|
||||
return None
|
||||
|
||||
pim = pikepdf.PdfImage(image)
|
||||
|
||||
if len(pim.filter_decodeparms) > 1:
|
||||
log.debug("Skipping multiply filtered, xref {}".format(xref))
|
||||
return False
|
||||
log.debug("Skipping multiply filtered, xref %s", xref)
|
||||
return None
|
||||
filtdp = pim.filter_decodeparms[0]
|
||||
|
||||
if pim.bits_per_component > 8:
|
||||
return False # Don't mess with wide gamut images
|
||||
return None # Don't mess with wide gamut images
|
||||
|
||||
if filtdp[0] == '/JPXDecode':
|
||||
return False # Don't do JPEG2000
|
||||
return None # Don't do JPEG2000
|
||||
|
||||
return pim, filtdp
|
||||
|
||||
|
||||
def extract_image_jbig2(*, pike, root, log, image, xref, options):
|
||||
result = extract_image_filter(pike, root, log, image, xref)
|
||||
if result is None:
|
||||
return None
|
||||
pim, filtdp = result
|
||||
|
||||
if pim.bits_per_component == 1 \
|
||||
and filtdp != '/JBIG2Decode' \
|
||||
@@ -81,9 +88,18 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
ext = pim.extract_to(stream=f)
|
||||
imgname.rename(imgname.with_suffix(ext))
|
||||
except pikepdf.UnsupportedImageTypeError:
|
||||
return False
|
||||
jbig2s.append((xref, ext))
|
||||
elif filtdp[0] == '/DCTDecode' \
|
||||
return None
|
||||
return xref, ext
|
||||
return None
|
||||
|
||||
|
||||
def extract_image_generic(*, pike, root, log, image, xref, options):
|
||||
result = extract_image_filter(pike, root, log, image, xref)
|
||||
if result is None:
|
||||
return None
|
||||
pim, filtdp = result
|
||||
|
||||
if filtdp[0] == '/DCTDecode' \
|
||||
and options.optimize >= 2:
|
||||
# This is a simple heuristic derived from some training data, that has
|
||||
# about a 70% chance of guessing whether the JPEG is high quality,
|
||||
@@ -92,7 +108,7 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
# bytes_per_pixel = int(raw_jpeg.Length) / (w * h)
|
||||
# jpeg_quality_estimate = 117.0 * (bytes_per_pixel ** 0.213)
|
||||
# if jpeg_quality_estimate < 65:
|
||||
# return False
|
||||
# return None
|
||||
|
||||
# We could get the ICC profile here, but there's no need to look at it
|
||||
# for quality transcoding
|
||||
@@ -107,88 +123,111 @@ def extract_image(*, pike, root, log, image, xref, jbig2s,
|
||||
ext = pim.extract_to(stream=f)
|
||||
imgname.rename(imgname.with_suffix(ext))
|
||||
except pikepdf.UnsupportedImageTypeError:
|
||||
return False
|
||||
jpegs.append(xref)
|
||||
return None
|
||||
return xref, ext
|
||||
elif pim.indexed \
|
||||
and pim.colorspace in pim.SIMPLE_COLORSPACES \
|
||||
and options.optimize >= 3:
|
||||
# Try to improve on indexed images - these are far from low hanging
|
||||
# fruit in most cases
|
||||
pim.as_pil_image().save(png_name(root, xref))
|
||||
pngs.append(xref)
|
||||
return xref, '.png'
|
||||
elif not pim.indexed and pim.colorspace in pim.SIMPLE_COLORSPACES:
|
||||
# An optimization opportunity here, not currently taken, is directly
|
||||
# generating a PNG from compressed data
|
||||
pim.as_pil_image().save(png_name(root, xref))
|
||||
pngs.append(xref)
|
||||
return xref, '.png'
|
||||
else:
|
||||
return False
|
||||
return None
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def extract_images(pike, root, log, options):
|
||||
# Extract images we can improve
|
||||
changed_xrefs = set()
|
||||
jbig2_groups = defaultdict(lambda: [])
|
||||
jpegs = []
|
||||
pngs = []
|
||||
def extract_images(pike, root, log, options, extract_fn):
|
||||
"""Extract image using extract_fn
|
||||
|
||||
extract_fn decides where the image is interesting in this case
|
||||
"""
|
||||
|
||||
include_xrefs = set()
|
||||
exclude_xrefs = set()
|
||||
errors = 0
|
||||
for pageno, page in enumerate(pike.pages):
|
||||
group, _ = divmod(pageno, PAGE_GROUP_SIZE)
|
||||
try:
|
||||
xobjs = page.Resources.XObject
|
||||
except AttributeError:
|
||||
continue
|
||||
for imname, image in dict(xobjs).items():
|
||||
for _imname, image in dict(xobjs).items():
|
||||
if image.objgen[1] != 0:
|
||||
continue # Ignore images in an incremental PDF
|
||||
xref = image.objgen[0]
|
||||
if xref in changed_xrefs:
|
||||
continue # Don't improve same image twice
|
||||
try:
|
||||
result = extract_image(
|
||||
pike=pike, root=root, log=log, image=image,
|
||||
xref=xref, jbig2s=jbig2_groups[group], pngs=pngs,
|
||||
jpegs=jpegs, options=options
|
||||
)
|
||||
if result:
|
||||
changed_xrefs.add(xref)
|
||||
except Exception as e:
|
||||
log.debug("Image {} xref {}".format(imname, xref))
|
||||
log.debug(repr(e))
|
||||
errors += 1
|
||||
if hasattr(image, 'SMask'):
|
||||
# Ignore soft masks
|
||||
smask_xref = image.SMask.objgen[0]
|
||||
exclude_xrefs.add(smask_xref)
|
||||
include_xrefs.add(xref)
|
||||
|
||||
working_xrefs = include_xrefs - exclude_xrefs
|
||||
for xref in working_xrefs:
|
||||
image = pike.get_object((xref, 0))
|
||||
try:
|
||||
result = extract_fn(
|
||||
pike=pike, root=root, log=log, image=image,
|
||||
xref=xref, options=options
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug("Image xref %s", xref)
|
||||
log.debug(repr(e))
|
||||
errors += 1
|
||||
else:
|
||||
if result:
|
||||
_, ext = result
|
||||
yield pageno, xref, ext
|
||||
|
||||
|
||||
def extract_images_generic(pike, root, log, options):
|
||||
"""Extract any >=2bpp image we think we can improve"""
|
||||
|
||||
jpegs = []
|
||||
pngs = []
|
||||
for _, xref, ext in extract_images(
|
||||
pike, root, log, options, extract_image_generic):
|
||||
log.debug('xref = %s ext = %s', xref, ext)
|
||||
if ext == '.png':
|
||||
pngs.append(xref)
|
||||
elif ext == '.jpg':
|
||||
jpegs.append(xref)
|
||||
log.debug(
|
||||
"Optimizable images: "
|
||||
"JPEGs: %s PNGs: %s", len(jpegs), len(pngs)
|
||||
)
|
||||
return jpegs, pngs
|
||||
|
||||
|
||||
def extract_images_jbig2(pike, root, log, options):
|
||||
"""Extract any bitonal image that we think we can improve as JBIG2"""
|
||||
|
||||
jbig2_groups = defaultdict(list)
|
||||
for pageno, xref, ext in extract_images(
|
||||
pike, root, log, options, extract_image_jbig2):
|
||||
group = pageno // options.jbig2_page_group_size
|
||||
jbig2_groups[group].append((xref, ext))
|
||||
|
||||
# Elide empty groups
|
||||
jbig2_groups = {group: xrefs for group, xrefs in jbig2_groups.items()
|
||||
if len(xrefs) > 0}
|
||||
log.debug(
|
||||
"Optimizable images: "
|
||||
"JBIG2 groups: {} JPEGs: {} PNGs: {} Errors: {}".format(
|
||||
len(jbig2_groups), len(jpegs), len(pngs), errors
|
||||
))
|
||||
|
||||
return jbig2_groups, jpegs, pngs
|
||||
"JBIG2 groups: %s", (len(jbig2_groups),)
|
||||
)
|
||||
return jbig2_groups
|
||||
|
||||
|
||||
def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
"""
|
||||
Convert a group of JBIG2 images and insert into PDF.
|
||||
def _produce_jbig2_images(jbig2_groups, root, log, options):
|
||||
"""Produce JBIG2 images from their groups"""
|
||||
|
||||
We use a group because JBIG2 works best with a symbol dictionary that spans
|
||||
multiple pages. When inserted back into the PDF, each JBIG2 must reference
|
||||
the symbol dictionary it is associated with. So convert a group at a time,
|
||||
and replace their streams with a parameter set that points to the
|
||||
appropriate dictionary.
|
||||
|
||||
If too many pages shared the same dictionary JBIG2 encoding becomes more
|
||||
expensive and less efficient.
|
||||
|
||||
"""
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=options.jobs) as executor:
|
||||
futures = []
|
||||
for group, xref_exts in jbig2_groups.items():
|
||||
def jbig2_group_futures(executor, root, groups):
|
||||
for group, xref_exts in groups.items():
|
||||
prefix = 'group{:08d}'.format(group)
|
||||
future = executor.submit(
|
||||
jbig2enc.convert_group,
|
||||
@@ -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),
|
||||
out_prefix=prefix
|
||||
)
|
||||
futures.append(future)
|
||||
yield future
|
||||
|
||||
def jbig2_single_futures(executor, root, groups):
|
||||
for group, xref_exts in groups.items():
|
||||
prefix = 'group{:08d}'.format(group)
|
||||
# Second loop is to ensure multiple images per page are unpacked
|
||||
for n, xref_ext in enumerate(xref_exts):
|
||||
xref, ext = xref_ext
|
||||
future = executor.submit(
|
||||
jbig2enc.convert_single,
|
||||
cwd=fspath(root),
|
||||
infile=img_name(root, xref, ext),
|
||||
outfile=root / ('{}.{:04d}'.format(prefix, n))
|
||||
)
|
||||
yield future
|
||||
|
||||
if options.jbig2_page_group_size > 1:
|
||||
jbig2_futures = jbig2_group_futures
|
||||
else:
|
||||
jbig2_futures = jbig2_single_futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=options.jobs) as executor:
|
||||
futures = jbig2_futures(executor, root, jbig2_groups)
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
proc = future.result()
|
||||
log.debug(proc.stderr.decode())
|
||||
|
||||
|
||||
def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
"""Convert images to JBIG2 and insert into PDF.
|
||||
|
||||
When the JBIG2 page group size is > 1 we do several JBIG2 images at once
|
||||
and build a symbol dictionary that will span several pages. Each JBIG2
|
||||
image must reference to its symbol dictionary. If too many pages shared the
|
||||
same dictionary JBIG2 encoding becomes more expensive and less efficient.
|
||||
The default value of 10 was determined through testing. Currently this
|
||||
must be lossy encoding since jbig2enc does not support refinement coding.
|
||||
|
||||
When the JBIG2 symbolic coder is not used, each JBIG2 stands on its own
|
||||
and needs no dictionary. Currently this is must be lossless JBIG2.
|
||||
"""
|
||||
|
||||
_produce_jbig2_images(jbig2_groups, root, log, options)
|
||||
|
||||
for group, xref_exts in jbig2_groups.items():
|
||||
prefix = 'group{:08d}'.format(group)
|
||||
jbig2_globals_data = (root / (prefix + '.sym')).read_bytes()
|
||||
jbig2_globals = pikepdf.Stream(pike, jbig2_globals_data)
|
||||
jbig2_symfile = root / (prefix + '.sym')
|
||||
if jbig2_symfile.exists():
|
||||
jbig2_globals_data = jbig2_symfile.read_bytes()
|
||||
jbig2_globals = pikepdf.Stream(pike, jbig2_globals_data)
|
||||
jbig2_globals_dict = pikepdf.Dictionary({
|
||||
'/JBIG2Globals': jbig2_globals
|
||||
})
|
||||
elif options.jbig2_page_group_size == 1:
|
||||
jbig2_globals_dict = None
|
||||
else:
|
||||
raise FileNotFoundError(jbig2_symfile)
|
||||
|
||||
for n, xref_ext in enumerate(xref_exts):
|
||||
xref, _ = xref_ext
|
||||
@@ -213,9 +301,7 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
im_obj = pike.get_object(xref, 0)
|
||||
im_obj.write(
|
||||
jbig2_im_data, pikepdf.Name('/JBIG2Decode'),
|
||||
pikepdf.Dictionary({
|
||||
'/JBIG2Globals': jbig2_globals
|
||||
})
|
||||
jbig2_globals_dict
|
||||
)
|
||||
|
||||
|
||||
@@ -232,9 +318,9 @@ def transcode_jpegs(pike, jpegs, root, log, options):
|
||||
im.save(fspath(opt_jpg),
|
||||
optimize=True,
|
||||
quality=options.jpeg_quality)
|
||||
# pylint: disable=E1101
|
||||
# pylint: disable=no-member
|
||||
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
||||
log.debug("xref {}, jpeg, made larger - skip".format(xref))
|
||||
log.debug("xref %s, jpeg, made larger - skip", xref)
|
||||
continue
|
||||
|
||||
compdata = leptonica.CompressedData.open(opt_jpg)
|
||||
@@ -326,21 +412,25 @@ def optimize(
|
||||
if options.png_quality == 0:
|
||||
options.png_quality = \
|
||||
DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
||||
if options.jbig2_page_group_size == 0:
|
||||
options.jbig2_page_group_size = \
|
||||
10 if options.jbig2_lossy else 1
|
||||
|
||||
pike = pikepdf.Pdf.open(input_file)
|
||||
|
||||
root = Path(output_file).parent / 'images'
|
||||
root.mkdir(exist_ok=True) # pylint: disable=E1101
|
||||
jbig2_groups, jpegs, pngs = extract_images(
|
||||
pike, root, log, options)
|
||||
root.mkdir(exist_ok=True) # pylint: disable=no-member
|
||||
|
||||
convert_to_jbig2(pike, jbig2_groups, root, log, options)
|
||||
jpegs, pngs = extract_images_generic(pike, root, log, options)
|
||||
transcode_jpegs(pike, jpegs, root, log, options)
|
||||
transcode_pngs(pike, pngs, root, log, options)
|
||||
|
||||
# Not object_stream_mode + preserve_pdfa generates noncompliant PDFs
|
||||
jbig2_groups = extract_images_jbig2(pike, root, log, options)
|
||||
convert_to_jbig2(pike, jbig2_groups, root, log, options)
|
||||
|
||||
target_file = Path(output_file).with_suffix('.opt.pdf')
|
||||
pike.save(target_file, preserve_pdfa=True)
|
||||
pike.save(target_file, preserve_pdfa=True,
|
||||
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||
|
||||
input_size = Path(input_file).stat().st_size
|
||||
output_size = Path(target_file).stat().st_size
|
||||
@@ -363,11 +453,14 @@ def main(infile, outfile, level, jobs=1):
|
||||
class OptimizeOptions:
|
||||
"""Emulate ocrmypdf's options"""
|
||||
|
||||
def __init__(self, jobs, optimize, jpeg_quality, png_quality):
|
||||
def __init__(
|
||||
self, jobs, optimize, jpeg_quality, png_quality, jb2lossy):
|
||||
self.jobs = jobs
|
||||
self.optimize = optimize
|
||||
self.jpeg_quality = jpeg_quality
|
||||
self.png_quality = png_quality
|
||||
self.jbig2_page_group_size = 0
|
||||
self.jbig2_lossy = jb2lossy
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
log = logging.getLogger()
|
||||
@@ -377,7 +470,8 @@ def main(infile, outfile, level, jobs=1):
|
||||
jobs=jobs,
|
||||
optimize=int(level),
|
||||
jpeg_quality=0, # Use default
|
||||
png_quality=0
|
||||
png_quality=0,
|
||||
jb2lossy=False
|
||||
)
|
||||
ctx.set_options(options)
|
||||
|
||||
|
||||
+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"
|
||||
|
||||
p, out, err = run_ocrmypdf(input_file, output_file, *args, env=env)
|
||||
print(err) # ensure py.test collects the output, use -s to view
|
||||
# ensure py.test collects the output, use -s to view
|
||||
print(err, file=sys.stderr)
|
||||
assert p.returncode == 0
|
||||
assert os.path.exists(str(output_file)), "Output file not created"
|
||||
assert os.stat(str(output_file)).st_size > 100, "PDF too small or empty"
|
||||
|
||||
+1
-1
@@ -595,7 +595,7 @@ THIS FILE IS INVALID
|
||||
resources / 'ccitt.pdf', outdir / 'out.pdf',
|
||||
'--pdf-renderer', renderer,
|
||||
'--tesseract-config', cfg_file)
|
||||
assert "parameter not found" in err, "No error message"
|
||||
assert "parameter not found" in err.lower(), "No error message"
|
||||
assert p.returncode == ExitCode.invalid_config
|
||||
|
||||
|
||||
|
||||
+48
-3
@@ -22,9 +22,11 @@ import logging
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import pikepdf
|
||||
|
||||
from ocrmypdf import optimize as opt
|
||||
from ocrmypdf.exec.ghostscript import rasterize_pdf
|
||||
from ocrmypdf.exec import jbig2enc
|
||||
from ocrmypdf.exec import jbig2enc, pngquant
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
|
||||
@@ -53,10 +55,53 @@ def test_mono_not_inverted(resources, outdir):
|
||||
assert im.getpixel((0, 0)) == 255, "Expected white background"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not jbig2enc.available(), reason='need jbig2enc')
|
||||
def test_jpg_png_params(resources, outpdf, spoof_tesseract_noop):
|
||||
check_ocrmypdf(
|
||||
resources / 'crom.png', outpdf, '--image-dpi', '200',
|
||||
'--optimize', '2', '--jpg-quality', '50', '--png-quality', '20',
|
||||
'--optimize', '3', '--jpg-quality', '50', '--png-quality', '20',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not jbig2enc.available(), reason='need jbig2enc')
|
||||
@pytest.mark.parametrize('lossy', [False, True])
|
||||
def test_jbig2_lossy(lossy, resources, outpdf, spoof_tesseract_noop):
|
||||
args = [
|
||||
resources / 'ccitt.pdf', outpdf, '--image-dpi', '200',
|
||||
'--optimize', 3, '--jpg-quality', '50', '--png-quality', '20'
|
||||
]
|
||||
if lossy:
|
||||
args.append('--jbig2-lossy')
|
||||
|
||||
check_ocrmypdf(*args, env=spoof_tesseract_noop)
|
||||
|
||||
pdf = pikepdf.open(outpdf)
|
||||
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
||||
assert pim.filters[0] == '/JBIG2Decode'
|
||||
|
||||
if lossy:
|
||||
assert '/JBIG2Globals' in pim.decode_parms[0]
|
||||
else:
|
||||
assert len(pim.decode_parms) == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not jbig2enc.available() or not pngquant.available(),
|
||||
reason='need jbig2enc and pngquant')
|
||||
def test_flate_to_jbig2(resources, outdir, spoof_tesseract_noop):
|
||||
# This test requires an image that pngquant is capable of converting to
|
||||
# to 1bpp - so use an existing 1bpp image, convert up, confirm it can
|
||||
# convert down
|
||||
im = Image.open(fspath(resources / 'typewriter.png'))
|
||||
assert im.mode in ('1', 'P')
|
||||
im = im.convert('L')
|
||||
im.save(fspath(outdir / 'type8.png'))
|
||||
|
||||
check_ocrmypdf(
|
||||
outdir / 'type8.png', outdir / 'out.pdf',
|
||||
'--image-dpi', '100', '--png-quality', '10', '--optimize', '3',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
|
||||
pdf = pikepdf.open(outdir / 'out.pdf')
|
||||
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
||||
assert pim.filters[0] == '/JBIG2Decode'
|
||||
|
||||
+8
-4
@@ -29,8 +29,7 @@ from pathlib import Path
|
||||
spoof = pytest.helpers.spoof
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ensure_tess4():
|
||||
def _ensure_tess4():
|
||||
if tesseract.v4():
|
||||
# "tesseract" on $PATH is already v4
|
||||
return os.environ.copy()
|
||||
@@ -49,6 +48,11 @@ def ensure_tess4():
|
||||
raise EnvironmentError("Can't find Tesseract 4")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ensure_tess4():
|
||||
return _ensure_tess4()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def modified_os_environ(env):
|
||||
old_env = os.environ.copy()
|
||||
@@ -63,8 +67,8 @@ def tess4_available():
|
||||
|
||||
"""
|
||||
try:
|
||||
# ensure_tess4 locates the tess4 binary we are going to check
|
||||
env = ensure_tess4()
|
||||
# _ensure_tess4 locates the tess4 binary we are going to check
|
||||
env = _ensure_tess4()
|
||||
with modified_os_environ(env):
|
||||
# Now jump into this environment and make sure it really is Tess4
|
||||
return tesseract.v4() and tesseract.has_textonly_pdf()
|
||||
|
||||
Reference in New Issue
Block a user