Files
OCRmyPDF/src/ocrmypdf/_optimize.py
T

271 lines
9.8 KiB
Python

# © 2018 James R. Barlow: github.com/jbarlow83
#
# This file is part of OCRmyPDF.
#
# OCRmyPDF is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OCRmyPDF is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
from subprocess import run, PIPE
import concurrent.futures
from collections import defaultdict
import struct
from io import BytesIO
from PIL import Image
from .lib import fitz
import pikepdf
from . import leptonica
from .helpers import re_symlink
from .exec import pngquant
PAGE_GROUP_SIZE = 10
SIMPLE_COLORSPACES = ('/DeviceRGB', '/DeviceGray', '/CalRGB', '/CalGray')
def generate_ccitt_header(data, w, h, decode_parms):
# https://stackoverflow.com/questions/2641770/
# https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf
if not decode_parms:
raise ValueError("/CCITTFaxDecode without /DecodeParms")
if decode_parms.get("/K", 1) < 0:
ccitt_group = 4 # Pure two-dimensional encoding (Group 4)
else:
ccitt_group = 3
img_size = len(data)
tiff_header_struct = '<' + '2s' + 'H' + 'L' + 'H' + 'HHLL' * 8 + 'L'
tiff_header = struct.pack(
tiff_header_struct,
b'II', # Byte order indication: Little endian
42, # Version number (always 42)
8, # Offset to first IFD
8, # Number of tags in IFD
256, 4, 1, w, # ImageWidth, LONG, 1, width
257, 4, 1, h, # ImageLength, LONG, 1, length
258, 3, 1, 1, # BitsPerSample, SHORT, 1, 1
259, 3, 1, ccitt_group, # Compression, SHORT, 1, 4 = CCITT Group 4 fax encoding
262, 3, 1, 0, # Thresholding, SHORT, 1, 0 = WhiteIsZero
273, 4, 1, struct.calcsize(tiff_header_struct), # StripOffsets, LONG, 1, length of header
278, 4, 1, h, # RowsPerStrip, LONG, 1, length
279, 4, 1, img_size, # StripByteCounts, LONG, 1, size of image
0 # last IFD
)
return tiff_header
def make_img_name(root, xref):
return str(root / '{:08d}.png'.format(xref))
def extract_images(doc, pike, root, log):
# Extract images we can improve
changed_xrefs = set()
jbig2_groups = defaultdict(lambda: [])
jpegs = []
pngs = []
for pageno, page in enumerate(pike.pages):
group, index = divmod(pageno, PAGE_GROUP_SIZE)
try:
xobjs = page.Resources.XObject
except AttributeError:
continue
for imname, image in dict(xobjs).items():
if image.Subtype != '/Image':
continue
xref = image._objgen[0]
if xref in changed_xrefs:
continue # Don't improve same image twice
bpc = image.get('/BitsPerComponent', 8)
filt = image.get('/Filter', pikepdf.Array([]))
cs = image.get('/ColorSpace', '')
w = int(image.Width)
h = int(image.Height)
if len(filt) == 1:
filt = filt[0]
else:
log.debug("Skipping multiply filtered {}".format(filt))
continue # Not supported: multiple filters
if bpc == 1 and filt != '/JBIG2Decode':
decode_parms = image.get('/DecodeParms')
if filt == '/CCITTFaxDecode':
data = image.read_raw_bytes()
try:
header = generate_ccitt_header(data, w, h, decode_parms)
except ValueError as e:
log.info(e)
continue
stream = BytesIO()
stream.write(header)
stream.write(data)
stream.seek(0)
with Image.open(stream) as im:
im.save(make_img_name(root, xref))
else:
continue
changed_xrefs.add(xref)
jbig2_groups[group].append(xref)
elif filt == '/DCTDecode' and cs in SIMPLE_COLORSPACES:
raw_jpeg = pike._get_object_id(xref, 0)
try:
if raw_jpeg.DecodeParms.ColorTransform != 1:
continue # Don't mess with JPEGs other than YUV
except AttributeError:
pass
raw_jpeg_data = raw_jpeg.read_raw_bytes()
(root / '{:08d}.jpg'.format(xref)).write_bytes(raw_jpeg_data)
changed_xrefs.add(xref)
jpegs.append(xref)
elif filt == '/FlateDecode' and cs in SIMPLE_COLORSPACES:
# raw_png = pike._get_object_id(xref, 0)
# raw_png_data = raw_png.read_raw_bytes()
# (root / '{:08d}.png'.format(xref)).write_bytes(raw_png_data)
pix = fitz.Pixmap(doc, xref)
try:
pix.writePNG(make_img_name(root, xref), savealpha=False)
except RuntimeError as e:
log.error('page {} xref {}'.format(pageno, xref))
log.error(e)
continue
changed_xrefs.add(xref)
pngs.append(xref)
return changed_xrefs, jbig2_groups, jpegs, pngs
def convert_to_jbig2(pike, jbig2_groups, root, log, options):
with concurrent.futures.ThreadPoolExecutor(
max_workers=options.jobs) as executor:
futures = []
for group, xrefs in jbig2_groups.items():
prefix = 'group{:08d}'.format(group)
cmd = ['jbig2', '-b', prefix, '-s', '-p']
cmd.extend(make_img_name(root, xref) for xref in xrefs)
future = executor.submit(
run, cmd, cwd=str(root), stdout=PIPE, stderr=PIPE)
futures.append(future)
for future in concurrent.futures.as_completed(futures):
proc = future.result()
proc.check_returncode()
log.debug(proc.stderr)
for group, xrefs 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)
for n, xref in enumerate(xrefs):
jbig2_im_file = root / (prefix + '.{:04d}'.format(n))
jbig2_im_data = jbig2_im_file.read_bytes()
im_obj = pike._get_object_id(xref, 0)
log.debug(xref)
log.debug(repr(im_obj))
im_obj.write(
jbig2_im_data, pikepdf.Name('/JBIG2Decode'),
pikepdf.Dictionary({
'/JBIG2Globals': jbig2_globals
})
)
log.debug(repr(im_obj))
def transcode_jpegs(pike, jpegs, root):
for xref in jpegs:
pix = leptonica.Pix.read((root / '{:08d}.jpg'.format(xref)))
compdata = pix.generate_pdf_ci_data(leptonica.lept.L_JPEG_ENCODE, 75)
im_obj = pike._get_object_id(xref, 0)
im_obj.write(
compdata.read(), pikepdf.Name('/DCTDecode'),
pikepdf.Null()
)
def transcode_pngs(pike, pngs, root, options):
with concurrent.futures.ThreadPoolExecutor(
max_workers=options.jobs) as executor:
for xref in pngs:
executor.submit(
pngquant.quantize,
make_img_name(root, xref), make_img_name(root, xref), 65, 80)
for xref in pngs:
im_obj = pike._get_object_id(xref, 0)
pix = leptonica.Pix.read(make_img_name(root, xref))
compdata = pix.generate_pdf_ci_data(leptonica.lept.L_FLATE_ENCODE, 0)
predictor = pikepdf.Null()
if compdata.predictor > 0:
predictor = pikepdf.Dictionary({'/Predictor': compdata.predictor})
im_obj.BitsPerComponent = compdata.bps
im_obj.Width = compdata.w
im_obj.Height = compdata.h
if compdata.ncolors > 0:
palette_pdf_string = compdata.get_palette_pdf_string()
palette_data = pikepdf.Object.parse(palette_pdf_string)
palette_stream = pikepdf.Stream(pike, bytes(palette_data))
palette = [pikepdf.Name('/Indexed'), pikepdf.Name('/DeviceRGB'),
compdata.ncolors - 1, palette_stream]
cs = palette
else:
if compdata.spp == 1:
cs = pikepdf.Name('/DeviceGray')
elif compdata.spp == 3:
cs = pikepdf.Name('/DeviceRGB')
elif compdata.spp == 4:
cs = pikepdf.Name('/DeviceCMYK')
im_obj.ColorSpace = cs
im_obj.write(compdata.read(), pikepdf.Name('/FlateDecode'), predictor)
def optimize(
input_file,
output_file,
log,
context):
if not fitz:
re_symlink(input_file, output_file)
return
options = context.get_options()
doc = fitz.open(input_file)
pike = pikepdf.Pdf.open(input_file)
root = Path(output_file).parent / 'images'
root.mkdir()
changed_xrefs, jbig2_groups, jpegs, pngs = extract_images(
doc, pike, root, log)
convert_to_jbig2(pike, jbig2_groups, root, log, options)
transcode_jpegs(pike, jpegs, root)
transcode_pngs(pike, pngs, root, options)
# Not object_stream_mode + preserve_pdfa generates noncompliant PDFs
pike.save(output_file, preserve_pdfa=True)
input_size = Path(input_file).stat().st_size
output_size = Path(output_file).stat().st_size
improvement = input_size / output_size
log.info("Optimize reduced size by {:.3f}".format(improvement))