Add Encoding.flate_jpeg to recognize deflated JPEG images

FlateDecode+DCTDecode compressed images are essentially deflated JPEGs,
typically created by OCRmyPDF's optimizer. This change ensures pdfinfo
correctly identifies them and should_visible_page_image_use_jpg treats
them as JPEG-origin images, allowing JPEG output when appropriate.
This commit is contained in:
James R. Barlow
2026-01-30 12:53:59 -08:00
parent 3abe8f71c7
commit 0a980fb11b
5 changed files with 91 additions and 6 deletions
+3 -2
View File
@@ -735,7 +735,8 @@ def ocr_engine_direct(
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
"""Determines whether the visible page image should be saved as a JPEG.
If all images were JPEGs originally, permit a JPEG as output.
If all images were JPEGs originally (including FlateDecode+DCTDecode),
permit a JPEG as output.
Args:
pageinfo: The PageInfo object containing information about the page.
@@ -744,7 +745,7 @@ def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
A boolean indicating whether the visible page image should be saved as a JPEG.
"""
return bool(pageinfo.images) and all(
im.enc == Encoding.jpeg for im in pageinfo.images
im.enc in (Encoding.jpeg, Encoding.flate_jpeg) for im in pageinfo.images
)
+12 -4
View File
@@ -108,10 +108,18 @@ class ImageInfo:
self._type = 'image'
self._bpc = int(pim.bits_per_component)
try:
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
except IndexError:
self._enc = None
if (
len(pim.filters) == 2
and pim.filters[0] == '/FlateDecode'
and pim.filters[1] == '/DCTDecode'
):
# Special case: FlateDecode followed by DCTDecode
self._enc = Encoding.flate_jpeg
else:
try:
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
except IndexError:
self._enc = None
try:
self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '')
+1
View File
@@ -36,6 +36,7 @@ class Encoding(Enum):
lzw = auto()
flate = auto()
runlength = auto()
flate_jpeg = auto()
FloatRect = tuple[float, float, float, float]
+49
View File
@@ -131,6 +131,55 @@ def test_jpeg(resources):
assert isclose(pdfimage.dpi.x, 150)
@pytest.fixture
def flate_jpeg_pdf(outpdf):
"""Create a PDF with a FlateDecode+DCTDecode (flate+jpeg) encoded image.
This simulates what OCRmyPDF's optimizer does when it deflates JPEGs.
"""
from zlib import compress
# Create an RGB image and save as JPEG
im = Image.new('RGB', (64, 64), color=(128, 64, 192))
bio = BytesIO()
im.save(bio, format='JPEG')
jpeg_data = bio.getvalue()
# Compress the JPEG data with flate
flate_jpeg_data = compress(jpeg_data)
# Create a PDF with the flate+jpeg image
with pikepdf.Pdf.new() as pdf:
pdf.add_blank_page(page_size=(72, 72))
image_dict = pikepdf.Stream(
pdf,
flate_jpeg_data,
BitsPerComponent=8,
ColorSpace=pikepdf.Name.DeviceRGB,
Filter=[pikepdf.Name.FlateDecode, pikepdf.Name.DCTDecode],
Height=64,
Subtype=pikepdf.Name.Image,
Type=pikepdf.Name.XObject,
Width=64,
)
objname = pdf.pages[0].add_resource(
image_dict, pikepdf.Name.XObject, pikepdf.Name.Im0
)
pdf.pages[0].Contents = pikepdf.Stream(
pdf, b"q 72 0 0 72 0 0 cm %s Do Q" % bytes(objname)
)
pdf.save(outpdf)
return outpdf
def test_flate_jpeg(flate_jpeg_pdf):
"""Test that pdfinfo correctly identifies FlateDecode+DCTDecode as flate_jpeg."""
pdf = pdfinfo.PdfInfo(flate_jpeg_pdf)
pdfimage = pdf[0].images[0]
assert pdfimage.enc == Encoding.flate_jpeg
def test_form_xobject(resources):
filename = resources / 'formxobject.pdf'
+26
View File
@@ -14,6 +14,7 @@ from reportlab.pdfgen.canvas import Canvas
from ocrmypdf import _pipeline, pdfinfo
from ocrmypdf.helpers import Resolution
from ocrmypdf.pdfinfo import Encoding
warnings.filterwarnings(
"ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval"
@@ -150,3 +151,28 @@ def test_dpi_needed(image, text, vector, result, rgb_image, outdir):
)
def test_enumerate_compress_ranges(name, input, output):
assert output == tuple(_pipeline.enumerate_compress_ranges(input))
@pytest.mark.parametrize(
'encodings, expected',
[
# Empty images list returns False
([], False),
# Single JPEG returns True
([Encoding.jpeg], True),
# Single flate_jpeg returns True
([Encoding.flate_jpeg], True),
# Mix of jpeg and flate_jpeg returns True
([Encoding.jpeg, Encoding.flate_jpeg], True),
# Non-JPEG encoding returns False
([Encoding.flate], False),
# Mix with non-JPEG returns False
([Encoding.jpeg, Encoding.flate], False),
([Encoding.flate_jpeg, Encoding.flate], False),
],
)
def test_should_visible_page_image_use_jpg(encodings, expected):
"""Test that should_visible_page_image_use_jpg correctly handles flate_jpeg."""
pageinfo = Mock()
pageinfo.images = [Mock(enc=enc) for enc in encodings]
assert _pipeline.should_visible_page_image_use_jpg(pageinfo) == expected