Compare commits

...
24 Commits
Author SHA1 Message Date
James R. Barlow 5bc5dc93f3 v7.2.0 release notes update 2018-10-05 01:27:00 -07:00
James R. Barlow c1e18bb825 optimize: Exclude soft masks (SMasks) from optimization
Soft masks are only allowed to be of colorspace DeviceGray so we
shouldn't use pngquant on them. For now, avoid this exceptional
case by excluded soft masks from optimization.
2018-10-05 01:23:26 -07:00
James R. Barlow 58282ea0fb optimize: more refactoring
Now properly generalized/specialized where it should be
2018-10-04 13:44:51 -07:00
James R. Barlow 891da7834c optimize: refactor image extraction 2018-10-04 12:34:22 -07:00
James R. Barlow 5c229d48d5 optimize: Reorganize so JBIG2 can be performed on images reduced to 1bpp
Closes #297
2018-10-04 11:53:11 -07:00
James R. Barlow 53f660cf35 Travis: use newer macos image 2018-10-04 08:59:40 -07:00
James R. Barlow 7b66ca68f2 ...and document lossy JBIG2 2018-10-04 01:31:53 -07:00
James R. Barlow ba71c3ffbd requirements: request pikepdf 0.3.4 2018-10-04 01:22:03 -07:00
James R. Barlow 6707ad427a v7.2.0 release notes 2018-10-04 01:21:17 -07:00
James R. Barlow 5b84549716 Change JBIG2 lossy mode to require --jbig2-lossy 2018-10-04 01:20:49 -07:00
James R. Barlow c74f2ee6e8 Refactor the detailed error messages 2018-10-04 00:10:59 -07:00
James R. Barlow b32dd9f9d3 Fix lossless JBIG2 when there are multiple JBIG2 images on a single page 2018-10-03 17:40:26 -07:00
James R. Barlow fb8b161f6c Fix suppression of tesseract config error messages 2018-10-03 17:39:50 -07:00
James R. Barlow baddd6d233 Remove libtiff from Brewfile
For some reason, brew complains about it now.
2018-10-03 16:17:59 -07:00
James R. Barlow 6f554c6ae8 tesseract: account for behavior changes when params are missing
Tesseract 4.0-rc1 now accepts invalid parameters in config and
won't return an error anymore. We prefer to raise an error if this
occurs.

See: https://github.com/tesseract-ocr/tesseract/commit/741ea00d7059d8ff7c55797ffc525a461d7f3ced
2018-10-03 15:11:34 -07:00
James R. Barlow a71e4488b3 test: fix pytest warning about direct use of a fixture 2018-10-03 15:04:46 -07:00
James R. Barlow 72156b5653 Degrade more gracefully when --optimize is set but JBIG2 is not present 2018-10-03 14:24:20 -07:00
James R. Barlow 9fa471e053 Test: send stderr to stderr, why don't we? 2018-10-03 14:23:34 -07:00
James R. Barlow 31ef2fe907 test: this error message changed case in newer Tesseract 2018-10-03 13:58:20 -07:00
James R. Barlow 9a8ec4b210 optimize: only enable lossy JBIG2 for -O3 2018-10-03 00:38:58 -07:00
James R. Barlow 75aad4cc79 optimize: Refactor convert_to_jbig2 2018-10-02 23:42:12 -07:00
James R. Barlow 4b27feca98 optimize: Disable JBIG2 lossy mode, use lossless instead 2018-10-01 12:28:54 -07:00
James R. Barlow 45522cd15f weave: clarify comment about garbage data in ToC 2018-09-27 13:48:35 -07:00
James R. Barlow 677d9a4e76 Remove some unhelpful lambdas 2018-09-27 13:48:12 -07:00
18 changed files with 402 additions and 147 deletions
+2 -2
View File
@@ -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
-1
View File
@@ -4,7 +4,6 @@ brew 'exempi'
brew 'ghostscript'
brew 'jbig2enc'
brew 'leptonica'
brew 'libtiff'
brew 'openjpeg'
brew 'pngquant'
brew 'qpdf'
+3 -1
View File
@@ -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`.
+13
View File
@@ -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.*
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 "
+5 -6
View File
@@ -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 ]'))
+27
View File
@@ -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"
-1
View 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"
+16 -3
View File
@@ -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
+10 -2
View File
@@ -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()
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()