From 2e6ba2df8c81e997dca04032d0e13bee5def81e5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 16 Feb 2019 14:55:20 -0800 Subject: [PATCH 1/7] optimize: fix recoding of PNGs Previously we opened pngquant-compressed PNGs with transcoding because the transcode free function in Leptonica didn't seem to work. This mean Leptonica may have thrown away the hard of pngquant if didn't understand the encoding. This change resolves the issue and allows us to open PNG encoded data and insert it into a PDF without transcoding. Should improve encoding quality. --- src/ocrmypdf/optimize.py | 52 ++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index c2d5d87c..3eb21c9b 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -271,7 +271,7 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options): 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. + and needs no dictionary. Currently this must be lossless JBIG2. """ _produce_jbig2_images(jbig2_groups, root, log, options) @@ -319,7 +319,7 @@ def transcode_jpegs(pike, jpegs, root, log, options): im_obj.write(compdata.read(), filter=Name.DCTDecode) -def transcode_pngs(pike, pngs, root, log, options): +def transcode_pngs(pike, images, image_name_fn, root, log, options): if options.optimize >= 2: png_quality = ( max(10, options.png_quality - 10), @@ -328,38 +328,50 @@ def transcode_pngs(pike, pngs, root, log, options): with concurrent.futures.ThreadPoolExecutor( max_workers=options.jobs ) as executor: - for xref in pngs: + for xref in images: + log.info(image_name_fn(root, xref)) executor.submit( pngquant.quantize, - png_name(root, xref), + image_name_fn(root, xref), png_name(root, xref), png_quality[0], png_quality[1], ) - for xref in pngs: + for xref in images: im_obj = pike.get_object(xref, 0) - # Open, transcode (!), package for PDF try: - pix = leptonica.Pix.open(png_name(root, xref)) - if pix.depth == 1: - pix = pix.invert() # PDF assumes 1 is black for monochrome - compdata = pix.generate_pdf_ci_data(leptonica.lept.L_FLATE_ENCODE, 0) + compdata = leptonica.CompressedData.open(png_name(root, xref)) except leptonica.LeptonicaError as e: log.error(e) continue - # This is what we should be doing: open the compressed data without - # transcoding. However this shifts each pixel row by one for some - # reason. - # compdata = leptonica.CompressedData.open(png_name(root, xref)) + # If re-coded image is larger don't use it if len(compdata) > int(im_obj.stream_dict.Length): - continue # If we produced a larger image, don't use + log.debug( + f"pngquant: pngquant did not improve over original image " + f"{len(compdata)} > {int(im_obj.stream_dict.Length)}" + ) + continue - predictor = None - if compdata.predictor > 0: - predictor = Dictionary(Predictor=compdata.predictor) + # We have to set the PDF predictor + # According to Leptonica source, PDF readers don't actually need us + # to specify the correct predictor, they just need a value of either + # 1 - there is no predictor + # 10-14 - there is a predictor + # Knowing that a predictor was used is the key information. From there + # the PNG decoder can infer the rest from the file. + # In practice the predictor should be Paeth, 14, so we'll use that. + # See: + # - PDF RM 7.4.4.4 Table 10 + # - https://github.com/DanBloomberg/leptonica/blob/master/src/pdfio2.c#L757 + predictor = 14 if compdata.predictor > 0 else 1 + dparms = Dictionary(predictor=predictor) + if predictor > 1: + dparms.BitsPerComponent = compdata.bps # Yes this is redundant + dparms.Colors = compdata.spp + dparms.Columns = compdata.w im_obj.BitsPerComponent = compdata.bps im_obj.Width = compdata.w @@ -384,7 +396,7 @@ def transcode_pngs(pike, pngs, root, log, options): elif compdata.spp == 4: cs = Name.DeviceCMYK im_obj.ColorSpace = cs - im_obj.write(compdata.read(), filter=Name.FlateDecode, decode_parms=predictor) + im_obj.write(compdata.read(), filter=Name.FlateDecode, decode_parms=dparms) def optimize(input_file, output_file, log, context): @@ -408,7 +420,7 @@ def optimize(input_file, output_file, log, context): jpegs, pngs = extract_images_generic(pike, root, log, options) transcode_jpegs(pike, jpegs, root, log, options) - transcode_pngs(pike, pngs, root, log, options) + transcode_pngs(pike, pngs, png_name, root, log, options) jbig2_groups = extract_images_jbig2(pike, root, log, options) convert_to_jbig2(pike, jbig2_groups, root, log, options) From b27b92fbf30e105b171b8fad88fc879ae04614c2 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 16 Feb 2019 14:57:38 -0800 Subject: [PATCH 2/7] optimize: on aggressive settings try JPG to PNG transcoding If the color count of an image is low such as when black and white documents are scanned in color, PNG with lossy quantization may produce a superior encoding to JPEG. This is expensive to test however. --- src/ocrmypdf/exec/pngquant.py | 45 +++++++++++++++++++++++++---------- src/ocrmypdf/optimize.py | 3 +++ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/ocrmypdf/exec/pngquant.py b/src/ocrmypdf/exec/pngquant.py index 4ecf0f27..536dca1c 100644 --- a/src/ocrmypdf/exec/pngquant.py +++ b/src/ocrmypdf/exec/pngquant.py @@ -17,6 +17,9 @@ from functools import lru_cache from subprocess import run +from tempfile import NamedTemporaryFile + +from PIL import Image from . import get_version from ..exceptions import MissingDependencyError @@ -36,16 +39,32 @@ def available(): def quantize(input_file, output_file, quality_min, quality_max): - args = [ - 'pngquant', - '--force', - '--skip-if-larger', - '--output', - output_file, - '--quality', - f'{quality_min}-{quality_max}', - '--', - input_file, - ] - proc = run(args) - proc.check_returncode() + if input_file.endswith('.jpg'): + im = Image.open(input_file) + with NamedTemporaryFile(suffix='.png') as tmp: + im.save(tmp) + args = [ + 'pngquant', + '--force', + '--skip-if-larger', + '--output', + output_file, + '--quality', + f'{quality_min}-{quality_max}', + '--', + tmp.name, + ] + run(args) + else: + args = [ + 'pngquant', + '--force', + '--skip-if-larger', + '--output', + output_file, + '--quality', + f'{quality_min}-{quality_max}', + '--', + input_file, + ] + run(args) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 3eb21c9b..0508ab9c 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -420,6 +420,9 @@ def optimize(input_file, output_file, log, context): jpegs, pngs = extract_images_generic(pike, root, log, options) transcode_jpegs(pike, jpegs, root, log, options) + if options.optimize >= 2: + # Try pngifying the jpegs + transcode_pngs(pike, jpegs, jpg_name, root, log, options) transcode_pngs(pike, pngs, png_name, root, log, options) jbig2_groups = extract_images_jbig2(pike, root, log, options) From 497c531112c4e3c8b1d79aa574e37c147a26f208 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 16 Feb 2019 21:45:44 -0800 Subject: [PATCH 3/7] optimize: update comments --- src/ocrmypdf/optimize.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 0508ab9c..4cf33e23 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -309,7 +309,7 @@ def transcode_jpegs(pike, jpegs, root, log, options): # https://github.com/python-pillow/Pillow/issues/1144 with Image.open(fspath(in_jpg)) as im: im.save(fspath(opt_jpg), optimize=True, quality=options.jpeg_quality) - # pylint: disable=no-member + if opt_jpg.stat().st_size > in_jpg.stat().st_size: log.debug("xref %s, jpeg, made larger - skip", xref) continue @@ -340,14 +340,17 @@ def transcode_pngs(pike, images, image_name_fn, root, log, options): for xref in images: im_obj = pike.get_object(xref, 0) - # Open, transcode (!), package for PDF try: compdata = leptonica.CompressedData.open(png_name(root, xref)) except leptonica.LeptonicaError as e: + # Most likely this means file not found, i.e. quantize did not + # produce an improved version log.error(e) continue - # If re-coded image is larger don't use it + # If re-coded image is larger don't use it - we test here because + # pngquant knows the size of the temporary output file but not the actual + # object in the PDF if len(compdata) > int(im_obj.stream_dict.Length): log.debug( f"pngquant: pngquant did not improve over original image " @@ -355,13 +358,16 @@ def transcode_pngs(pike, images, image_name_fn, root, log, options): ) continue - # We have to set the PDF predictor + # When a PNG is inserted into a PDF, we more or less copy the IDAT section from + # the PDF and transfer the rest of the PNG headers to PDF image metadata. + # One thing we have to do is tell the PDF reader whether a predictor was used + # on the image before Flate encoding. (Typically one is.) # According to Leptonica source, PDF readers don't actually need us - # to specify the correct predictor, they just need a value of either - # 1 - there is no predictor + # to specify the correct predictor, they just need a value of either: + # 1 - no predictor # 10-14 - there is a predictor - # Knowing that a predictor was used is the key information. From there - # the PNG decoder can infer the rest from the file. + # Leptonica's compdata->predictor only tells TRUE or FALSE + # From there the PNG decoder can infer the rest from the file. # In practice the predictor should be Paeth, 14, so we'll use that. # See: # - PDF RM 7.4.4.4 Table 10 @@ -369,7 +375,7 @@ def transcode_pngs(pike, images, image_name_fn, root, log, options): predictor = 14 if compdata.predictor > 0 else 1 dparms = Dictionary(predictor=predictor) if predictor > 1: - dparms.BitsPerComponent = compdata.bps # Yes this is redundant + dparms.BitsPerComponent = compdata.bps # Yes, this is redundant dparms.Colors = compdata.spp dparms.Columns = compdata.w @@ -378,6 +384,8 @@ def transcode_pngs(pike, images, image_name_fn, root, log, options): im_obj.Height = compdata.h if compdata.ncolors > 0: + # .ncolors is the number of colors in the palette, not the number of + # colors used in a true color image palette_pdf_string = compdata.get_palette_pdf_string() palette_data = pikepdf.Object.parse(palette_pdf_string) palette_stream = pikepdf.Stream(pike, bytes(palette_data)) @@ -416,7 +424,7 @@ def optimize(input_file, output_file, log, context): pike = pikepdf.Pdf.open(input_file) root = Path(output_file).parent / 'images' - root.mkdir(exist_ok=True) # pylint: disable=no-member + root.mkdir(exist_ok=True) jpegs, pngs = extract_images_generic(pike, root, log, options) transcode_jpegs(pike, jpegs, root, log, options) From 4f69ace86842f4a13d9bca96788aa7d6c6007527 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 16 Feb 2019 21:57:36 -0800 Subject: [PATCH 4/7] optimize: fix all JBIG2 images binned on last page During some past refactor it appears we now end up treating all JBIG2 images as if they appeared on the last page in the file. This bug had no visual side ffects but probably led to suboptimal JBIG2 encoding. --- src/ocrmypdf/optimize.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 4cf33e23..d5be80d4 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -149,11 +149,22 @@ def extract_image_generic(*, pike, root, log, image, xref, options): def extract_images(pike, root, log, options, extract_fn): """Extract image using extract_fn - extract_fn decides whether the image is interesting in this case + Enumerate images on each page, lookup their xref/ID number in the PDF. + Exclude images that are soft masks (i.e. alpha transparency related). + Record the page number on which an image is first used, since images may be + used on multiple pages (or multiple times on the same page). + + Current we do not check Form XObjects or other objects that may contain + images, and we don't evaluate alternate images or thumbnails. + + extract_fn must decide if wants to extract the image in this context. If + it does a tuple should be returned: (xref, ext) where .ext is the file + extension. extract_fn must also extract the file it finds interesting. """ include_xrefs = set() exclude_xrefs = set() + pageno_for_xref = {} errors = 0 for pageno, page in enumerate(pike.pages): try: @@ -169,6 +180,8 @@ def extract_images(pike, root, log, options, extract_fn): smask_xref = image.SMask.objgen[0] exclude_xrefs.add(smask_xref) include_xrefs.add(xref) + if xref not in pageno_for_xref: + pageno_for_xref[xref] = pageno working_xrefs = include_xrefs - exclude_xrefs for xref in working_xrefs: @@ -178,13 +191,12 @@ def extract_images(pike, root, log, options, 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)) + log.debug("Image xref %s, error %s", xref, repr(e)) errors += 1 else: if result: _, ext = result - yield pageno, xref, ext + yield pageno_for_xref[xref], xref, ext def extract_images_generic(pike, root, log, options): @@ -198,7 +210,7 @@ def extract_images_generic(pike, root, log, options): pngs.append(xref) elif ext == '.jpg': jpegs.append(xref) - log.debug("Optimizable images: " "JPEGs: %s PNGs: %s", len(jpegs), len(pngs)) + log.debug("Optimizable images: JPEGs: %s PNGs: %s", len(jpegs), len(pngs)) return jpegs, pngs @@ -216,7 +228,7 @@ def extract_images_jbig2(pike, root, log, options): jbig2_groups = { group: xrefs for group, xrefs in jbig2_groups.items() if len(xrefs) > 0 } - log.debug("Optimizable images: " "JBIG2 groups: %s", (len(jbig2_groups),)) + log.debug("Optimizable images: JBIG2 groups: %s", (len(jbig2_groups),)) return jbig2_groups From 2c56b0935cca62b39bd4642cfd135d3f37342ec7 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 17 Feb 2019 16:27:44 -0800 Subject: [PATCH 5/7] docs: minor --- docs/introduction.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/introduction.rst b/docs/introduction.rst index 1f567958..2ba37664 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -112,7 +112,7 @@ Web front-ends The Docker image ``ocrmypdf-alpine`` provides a web service front-end that allows files to submitted over HTTP and the results "downloaded". This is an HTTP server intended to simplify web services deployments; it is not intended to be deployed on the public internet and no real security measures to speak of. -In addition, the following integrations are available: +In addition, the following third-party integrations are available: * `Nextcloud OCR `_ is a free software plugin for the Nextcloud private cloud software From 01d2ea309f80dbcceec657054d6b0f80076f59ae Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 3 Mar 2019 14:57:15 -0800 Subject: [PATCH 6/7] Fix Predictor name and photometric flip --- src/ocrmypdf/optimize.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index d5be80d4..f442c02f 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -25,7 +25,7 @@ from pathlib import Path from PIL import Image import pikepdf -from pikepdf import Name, Dictionary +from pikepdf import Name, Dictionary, Array from . import leptonica from ._jobcontext import JobContext @@ -385,7 +385,7 @@ def transcode_pngs(pike, images, image_name_fn, root, log, options): # - PDF RM 7.4.4.4 Table 10 # - https://github.com/DanBloomberg/leptonica/blob/master/src/pdfio2.c#L757 predictor = 14 if compdata.predictor > 0 else 1 - dparms = Dictionary(predictor=predictor) + dparms = Dictionary(Predictor=predictor) if predictor > 1: dparms.BitsPerComponent = compdata.bps # Yes, this is redundant dparms.Colors = compdata.spp @@ -410,7 +410,11 @@ def transcode_pngs(pike, images, image_name_fn, root, log, options): cs = palette else: if compdata.spp == 1: - cs = Name.DeviceGray + # PDF interprets binary-1 as black in 1bpp, but PNG sets + # black to 0 for 1bpp. Create a palette that informs the PDF + # of the mapping. + palette = [Name.Indexed, Name.DeviceGray, 1, b"\xff\x00"] + cs = palette elif compdata.spp == 3: cs = Name.DeviceRGB elif compdata.spp == 4: From 66586bdaabd6f9721ea86a54de8d1e2e033e211f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 3 Mar 2019 14:59:59 -0800 Subject: [PATCH 7/7] optimize: Disable jpg->png migration Needs more testing before release --- src/ocrmypdf/optimize.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index f442c02f..b4dc866f 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -444,9 +444,9 @@ def optimize(input_file, output_file, log, context): jpegs, pngs = extract_images_generic(pike, root, log, options) transcode_jpegs(pike, jpegs, root, log, options) - if options.optimize >= 2: - # Try pngifying the jpegs - transcode_pngs(pike, jpegs, jpg_name, root, log, options) + # if options.optimize >= 2: + # Try pngifying the jpegs + # transcode_pngs(pike, jpegs, jpg_name, root, log, options) transcode_pngs(pike, pngs, png_name, root, log, options) jbig2_groups = extract_images_jbig2(pike, root, log, options)