Merge branch 'feature/optimization-fixes'

This commit is contained in:
James R. Barlow
2019-03-03 15:00:20 -08:00
3 changed files with 103 additions and 45 deletions
+1 -1
View File
@@ -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 <https://github.com/janis91/ocr>`_ is a free software plugin for the Nextcloud private cloud software
+32 -13
View File
@@ -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)
+70 -31
View File
@@ -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
@@ -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
@@ -271,7 +283,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)
@@ -309,7 +321,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
@@ -319,7 +331,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,44 +340,64 @@ 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:
# Most likely this means file not found, i.e. quantize did not
# produce an improved version
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 - 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):
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)
# 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 - no predictor
# 10-14 - there is a predictor
# 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
# - 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
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))
@@ -378,13 +410,17 @@ def transcode_pngs(pike, pngs, 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:
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):
@@ -404,11 +440,14 @@ 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)
transcode_pngs(pike, pngs, 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)
convert_to_jbig2(pike, jbig2_groups, root, log, options)