diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index af9e1c61..e35fb96d 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -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 ) diff --git a/src/ocrmypdf/pdfinfo/_image.py b/src/ocrmypdf/pdfinfo/_image.py index 0686de48..3ae8fef0 100644 --- a/src/ocrmypdf/pdfinfo/_image.py +++ b/src/ocrmypdf/pdfinfo/_image.py @@ -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 '') diff --git a/src/ocrmypdf/pdfinfo/_types.py b/src/ocrmypdf/pdfinfo/_types.py index 2bee4d88..fa5252cd 100644 --- a/src/ocrmypdf/pdfinfo/_types.py +++ b/src/ocrmypdf/pdfinfo/_types.py @@ -36,6 +36,7 @@ class Encoding(Enum): lzw = auto() flate = auto() runlength = auto() + flate_jpeg = auto() FloatRect = tuple[float, float, float, float] diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index 1bb7a160..db705877 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -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' diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4eb37463..90bab0c7 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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