mirror of
https://github.com/ocrmypdf/OCRmyPDF.git
synced 2026-07-30 15:08:10 -04:00
The optimizer is best-effort: any image it cannot process is left unchanged in the output, which remains valid. Previously, an extraction failure (e.g. an exotic colorspace pikepdf cannot transcode) was logged with log.exception, printing a full traceback at ERROR level that alarmed users even though nothing was wrong with the output (issue #846). Trap such failures with a concise warning that the image was left unchanged and the output is still valid, and demote the traceback to debug verbosity for diagnosis.
407 lines
12 KiB
Python
407 lines
12 KiB
Python
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
from io import BytesIO
|
|
from os import fspath
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import img2pdf
|
|
import pikepdf
|
|
import pytest
|
|
from pikepdf import Array, Dictionary, Name
|
|
from PIL import Image, ImageDraw
|
|
|
|
from ocrmypdf import optimize as opt
|
|
from ocrmypdf._exec import jbig2enc, pngquant
|
|
from ocrmypdf._exec.ghostscript import rasterize_pdf
|
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
|
from ocrmypdf.optimize import PdfImage, extract_image_filter
|
|
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
|
from tests.conftest import check_ocrmypdf
|
|
|
|
needs_pngquant = pytest.mark.skipif(
|
|
not pngquant.available(), reason="pngquant not installed"
|
|
)
|
|
needs_jbig2enc = pytest.mark.skipif(
|
|
not jbig2enc.available(), reason="jbig2enc not installed"
|
|
)
|
|
|
|
|
|
# pylint:disable=redefined-outer-name
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def palette(resources):
|
|
return resources / 'palette.pdf'
|
|
|
|
|
|
@needs_pngquant
|
|
@pytest.mark.parametrize('pdf', ['multipage', 'palette'])
|
|
def test_basic(multipage, palette, pdf, outpdf):
|
|
infile = multipage if pdf == 'multipage' else palette
|
|
opt.main(infile, outpdf, level=3)
|
|
|
|
assert 0.98 * Path(outpdf).stat().st_size <= Path(infile).stat().st_size
|
|
|
|
|
|
@needs_pngquant
|
|
def test_mono_not_inverted(resources, outdir):
|
|
infile = resources / '2400dpi.pdf'
|
|
opt.main(infile, outdir / 'out.pdf', level=3)
|
|
|
|
rasterize_pdf(
|
|
outdir / 'out.pdf',
|
|
outdir / 'im.png',
|
|
raster_device=GhostscriptRasterDevice.PNGGRAY,
|
|
raster_dpi=Resolution(10, 10),
|
|
)
|
|
|
|
with Image.open(fspath(outdir / 'im.png')) as im:
|
|
assert im.getpixel((0, 0)) > 240, "Expected white background"
|
|
|
|
|
|
@needs_pngquant
|
|
def test_jpg_png_params(resources, outpdf):
|
|
check_ocrmypdf(
|
|
resources / 'crom.png',
|
|
outpdf,
|
|
'--image-dpi',
|
|
'200',
|
|
'--optimize',
|
|
'3',
|
|
'--jpg-quality',
|
|
'50',
|
|
'--png-quality',
|
|
'20',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
|
|
@needs_jbig2enc
|
|
def test_jbig2_lossless(resources, outpdf):
|
|
"""Test that JBIG2 lossless encoding works without JBIG2Globals."""
|
|
args = [
|
|
resources / 'ccitt.pdf',
|
|
outpdf,
|
|
'--image-dpi',
|
|
'200',
|
|
'--optimize',
|
|
'3',
|
|
'--jpg-quality',
|
|
'50',
|
|
'--png-quality',
|
|
'20',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
'--jbig2-threshold',
|
|
'0.7',
|
|
]
|
|
|
|
check_ocrmypdf(*args)
|
|
|
|
with pikepdf.open(outpdf) as pdf:
|
|
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
|
assert pim.filters[0] == '/JBIG2Decode'
|
|
# Lossless JBIG2 has no JBIG2Globals (no shared symbol dictionary)
|
|
assert len(pim.decode_parms) == 0
|
|
|
|
|
|
@needs_pngquant
|
|
@needs_jbig2enc
|
|
def test_flate_to_jbig2(resources, outdir):
|
|
# 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
|
|
with Image.open(fspath(resources / 'typewriter.png')) as im:
|
|
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',
|
|
'50',
|
|
'--optimize',
|
|
'3',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
with pikepdf.open(outdir / 'out.pdf') as pdf:
|
|
pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values())))
|
|
assert pim.filters[0] == '/JBIG2Decode'
|
|
|
|
|
|
@needs_pngquant
|
|
def test_multiple_pngs(resources, outdir):
|
|
with Path.open(outdir / 'in.pdf', 'wb') as inpdf:
|
|
img2pdf.convert(
|
|
fspath(resources / 'baiona_colormapped.png'),
|
|
fspath(resources / 'baiona_gray.png'),
|
|
outputstream=inpdf,
|
|
**IMG2PDF_KWARGS,
|
|
)
|
|
|
|
def mockquant(input_file, output_file, *_args):
|
|
with Image.open(input_file) as im:
|
|
draw = ImageDraw.Draw(im)
|
|
draw.rectangle((0, 0, im.width, im.height), fill=128)
|
|
im.save(output_file)
|
|
|
|
with patch('ocrmypdf.optimize.pngquant.quantize') as mock:
|
|
mock.side_effect = mockquant
|
|
check_ocrmypdf(
|
|
outdir / 'in.pdf',
|
|
outdir / 'out.pdf',
|
|
'--optimize',
|
|
'3',
|
|
'--jobs',
|
|
'1',
|
|
'--use-threads',
|
|
'--output-type',
|
|
'pdf',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
mock.assert_called()
|
|
|
|
with (
|
|
pikepdf.open(outdir / 'in.pdf') as inpdf,
|
|
pikepdf.open(outdir / 'out.pdf') as outpdf,
|
|
):
|
|
for n in range(len(inpdf.pages)):
|
|
inim = next(iter(inpdf.pages[n].images.values()))
|
|
outim = next(iter(outpdf.pages[n].images.values()))
|
|
assert len(outim.read_raw_bytes()) < len(inim.read_raw_bytes()), n
|
|
|
|
|
|
def test_optimize_off(resources, outpdf):
|
|
check_ocrmypdf(
|
|
resources / 'trivial.pdf',
|
|
outpdf,
|
|
'--optimize=0',
|
|
'--output-type',
|
|
'pdf',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
|
|
def test_group3(resources):
|
|
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
|
im = pdf.pages[0].Resources.XObject['/Im1']
|
|
assert opt.extract_image_filter(im, im.objgen[0]) is not None, (
|
|
"Group 4 should be allowed"
|
|
)
|
|
|
|
im.DecodeParms['/K'] = 0
|
|
assert opt.extract_image_filter(im, im.objgen[0]) is None, (
|
|
"Group 3 should be disallowed"
|
|
)
|
|
|
|
|
|
def test_find_formx(resources):
|
|
with pikepdf.open(resources / 'formxobject.pdf') as pdf:
|
|
working, pagenos = opt._find_image_xrefs(pdf)
|
|
assert len(working) == 1
|
|
xref = next(iter(working))
|
|
assert pagenos[xref] == 0
|
|
|
|
|
|
def test_find_formx_circular_reference(resources, tmp_path, caplog):
|
|
"""Regression for issue #1321.
|
|
|
|
Some PDFs (notably PowerPoint exports) contain Form XObjects that
|
|
reference themselves or each other in a cycle. The recursion guard in
|
|
_find_image_xrefs_container only deduplicates *image* xrefs, so a Form
|
|
XObject cycle would re-enter every branch until the depth limit fired,
|
|
producing thousands of "Recursion depth exceeded" warnings (and minutes
|
|
of wall-clock time on real-world inputs).
|
|
"""
|
|
import logging
|
|
|
|
src = resources / 'formxobject.pdf'
|
|
out = tmp_path / 'circular_form.pdf'
|
|
with pikepdf.open(src) as pdf:
|
|
# /Form1 lives at xref 10. Replace its Resources.XObject with three
|
|
# entries that all point back to /Form1 itself, creating a fan-out
|
|
# cycle of branching factor 3.
|
|
form = pdf.pages[0].obj.Resources.XObject.Form1
|
|
form.Resources.XObject = Dictionary({'/Fm0': form, '/Fm1': form, '/Fm2': form})
|
|
pdf.save(out)
|
|
|
|
caplog.set_level(logging.WARNING, logger='ocrmypdf.optimize')
|
|
with pikepdf.open(out) as pdf:
|
|
opt._find_image_xrefs(pdf)
|
|
|
|
n_warnings = sum(
|
|
1 for r in caplog.records if 'Recursion depth exceeded' in r.getMessage()
|
|
)
|
|
# Without the fix this is in the tens of thousands.
|
|
assert n_warnings == 0, (
|
|
f"Form XObject cycle should be detected without depth-limit warnings; "
|
|
f"got {n_warnings}"
|
|
)
|
|
|
|
|
|
def test_extract_images_traps_errors_as_warning(resources, tmp_path, caplog):
|
|
"""Regression for issue #846.
|
|
|
|
The optimizer is best-effort: any image it cannot process can simply be
|
|
passed through unchanged. When extraction of an image raises (e.g. an
|
|
exotic colorspace pikepdf cannot transcode), the user should see a concise
|
|
warning that the image was left unchanged, not an alarming traceback
|
|
logged at ERROR level.
|
|
"""
|
|
import logging
|
|
from unittest.mock import Mock
|
|
|
|
def boom(*, pdf, root, image, xref, options):
|
|
raise NotImplementedError("synthetic extraction failure")
|
|
|
|
caplog.set_level(logging.DEBUG, logger='ocrmypdf.optimize')
|
|
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
|
results = list(opt.extract_images(pdf, tmp_path, Mock(), boom))
|
|
|
|
# The error is trapped, not propagated, and nothing is extracted.
|
|
assert results == []
|
|
# A friendly warning is emitted...
|
|
assert any(
|
|
r.levelno == logging.WARNING and 'left unchanged' in r.getMessage()
|
|
for r in caplog.records
|
|
)
|
|
# ...and no traceback is logged at ERROR level or above.
|
|
assert not any(r.levelno >= logging.ERROR for r in caplog.records)
|
|
|
|
|
|
def test_extract_image_filter_with_pdf_image():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 200
|
|
image.Width = 10
|
|
image.Height = 10
|
|
image.Filter = [Name.FlateDecode, Name.DCTDecode]
|
|
pdf_image = PdfImage(image)
|
|
image.BitsPerComponent = 8
|
|
assert extract_image_filter(image, None) == (
|
|
pdf_image,
|
|
pdf_image.filter_decodeparms[1],
|
|
)
|
|
|
|
|
|
def test_extract_image_filter_with_non_image():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Form
|
|
assert extract_image_filter(image, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_small_stream_size():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 50
|
|
assert extract_image_filter(image, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_small_dimensions():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 200
|
|
image.Width = 5
|
|
image.Height = 5
|
|
assert extract_image_filter(image, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_multiple_compression_filters():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 200
|
|
image.Width = 10
|
|
image.Height = 10
|
|
image.BitsPerComponent = 8
|
|
image.Filter = [Name.ASCII85Decode, Name.FlateDecode, Name.DCTDecode]
|
|
assert extract_image_filter(image, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_wide_gamut_image():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 200
|
|
image.Width = 10
|
|
image.Height = 10
|
|
image.BitsPerComponent = 16
|
|
image.Filter = Name.FlateDecode
|
|
assert extract_image_filter(image, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_jpeg2000_image():
|
|
im = Image.new('RGB', (10, 10))
|
|
bio = BytesIO()
|
|
im.save(bio, format='JPEG2000')
|
|
pdf = pikepdf.new()
|
|
stream = pdf.make_stream(
|
|
data=bio.getvalue(),
|
|
Subtype=Name.Image,
|
|
Length=200,
|
|
Width=10,
|
|
Height=10,
|
|
BitsPerComponent=8,
|
|
Filter=Name.JPXDecode,
|
|
)
|
|
assert extract_image_filter(stream, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_ccitt_group_3_image():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 200
|
|
image.Width = 10
|
|
image.Height = 10
|
|
image.BitsPerComponent = 1
|
|
image.Filter = Name.CCITTFaxDecode
|
|
image.DecodeParms = Array([Dictionary(K=1)])
|
|
assert extract_image_filter(image, None) is None
|
|
|
|
|
|
# Triggers pikepdf bug
|
|
# def test_extract_image_filter_with_decode_table():
|
|
# image = Dictionary()
|
|
# image.Subtype = Name.Image
|
|
# image.Length = 200
|
|
# image.Width = 10
|
|
# image.Height = 10
|
|
# image.Filter = Name.FlateDecode
|
|
# image.BitsPerComponent = 8
|
|
# image.ColorSpace = Name.DeviceGray
|
|
# image.Decode = [42, 0]
|
|
# assert extract_image_filter(image, None) is None
|
|
|
|
|
|
def test_extract_image_filter_with_rgb_smask_matte():
|
|
image = Dictionary()
|
|
image.Subtype = Name.Image
|
|
image.Length = 200
|
|
image.Width = 10
|
|
image.Height = 10
|
|
image.Filter = Name.FlateDecode
|
|
image.BitsPerComponent = 8
|
|
image.ColorSpace = Name.DeviceRGB
|
|
image.SMask = Dictionary(
|
|
Type=Name.Image,
|
|
Subtype=Name.Image,
|
|
Length=200,
|
|
Width=10,
|
|
Height=10,
|
|
Filter=Name.FlateDecode,
|
|
BitsPerComponent=8,
|
|
ColorSpace=Name.DeviceGray,
|
|
Matte=Array([1, 2, 3]),
|
|
)
|
|
assert extract_image_filter(image, None) is None
|