diff --git a/docs/introduction.md b/docs/introduction.md index 29fc7b79..7d846542 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -178,10 +178,17 @@ v17 addresses through alternative codepaths. When Ghostscript is used: encoding, which may introduce compression artifacts, if Ghostscript PDF/A is enabled. - Ghostscript may transcode grayscale and color images, potentially - lossily, based on an internal algorithm. This - behavior can be suppressed by setting `--pdfa-image-compression` to - `jpeg` or `lossless` to set all images to one type or the other. - Ghostscript lacks an option to maintain the input image's format. + lossily, based on an internal algorithm. By default + (`--pdfa-image-compression=auto`) OCRmyPDF selects lossless image + compression at `-O0` so Ghostscript will not transcode lossless images + to JPEG. At `-O1` (the default optimization level) and above, `auto` + defers to Ghostscript's heuristic instead; `-O1` is a historical + exception, kept for backwards compatibility because coercing it to + lossless can substantially bloat output. You can override this by + setting `--pdfa-image-compression` to `jpeg` or `lossless` to force all + images to one type or the other. `lossless` passes existing JPEGs + through untouched (re-encoding them losslessly would only inflate them) + while encoding non-JPEG images losslessly. (Modern Ghostscript can copy JPEG images without transcoding them.) Advanced users can also tune Ghostscript's image recompression with `--ghostscript-jpeg-quality` and `--ghostscript-jpeg-maxdpi`; see diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index 47369f3f..e50a05ef 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -5,6 +5,18 @@ ## v17.6.0 +- `--pdfa-image-compression=auto` (the default) now selects lossless image + compression at `-O0` so Ghostscript no longer transcodes lossless images to + JPEG during PDF/A generation. At `-O1` and above, `auto` continues to defer + to Ghostscript's heuristic, which may recompress images lossily. `-O1` (the + default level) is kept as a historical exception because coercing it to + lossless can substantially bloat output; users who want guaranteed lossless + image handling should pass `--pdfa-image-compression=lossless` or use `-O0` + ({issue}`1124`). +- `--pdfa-image-compression=lossless` now passes existing JPEG images through + unchanged rather than re-encoding them with a lossless codec. Re-encoding an + already-lossy JPEG losslessly cannot recover quality and only inflates the + file, so JPEGs are preserved while non-JPEG images are encoded losslessly. - OCRmyPDF now validates and repairs malformed page-boundary boxes (``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``, ``/BleedBox``) in its input, following the PDF 2.0 specification. Coordinates written in invalid diff --git a/src/ocrmypdf/_exec/ghostscript.py b/src/ocrmypdf/_exec/ghostscript.py index 5afd6e17..e645743a 100644 --- a/src/ocrmypdf/_exec/ghostscript.py +++ b/src/ocrmypdf/_exec/ghostscript.py @@ -299,6 +299,11 @@ def generate_pdfa( ] elif compression == 'lossless': compression_args = [ + # Re-encoding an existing JPEG with a lossless codec only inflates + # its size: the lossy data is already baked in, so there is nothing + # to gain. Pass JPEGs through untouched and apply lossless (Flate) + # encoding only to images that are not already JPEG. + "-dPassThroughJPEGImages=true", "-dAutoFilterColorImages=false", "-dColorImageFilter=/FlateEncode", "-dAutoFilterGrayImages=false", diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 0bb8b640..a46002ce 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -44,6 +44,30 @@ class PdfaImageCompression(StrEnum): LOSSLESS = 'lossless' +def _resolve_auto_compression( + compression: PdfaImageCompression, optimize_level: int +) -> PdfaImageCompression: + """Resolve 'auto' image compression based on the optimization level. + + At ``-O0`` (no optimization) ``auto`` maps to ``lossless`` so Ghostscript + will not transcode lossless images to JPEG during PDF/A generation. At all + other levels ``auto`` defers to Ghostscript's heuristic, which may + recompress images lossily. + + ``-O1`` is a historical exception: although it is otherwise a + lossless-only optimization level, coercing ``auto`` to ``lossless`` there + can bloat output substantially (Ghostscript's heuristic often picks JPEG + for photographic content), so the default is left alone for backwards + compatibility. Users who want guaranteed lossless image handling at any + level can pass ``--pdfa-image-compression=lossless`` explicitly. + + Explicit ``jpeg`` and ``lossless`` choices are always respected. + """ + if compression == PdfaImageCompression.AUTO and optimize_level == 0: + return PdfaImageCompression.LOSSLESS + return compression + + class GhostscriptOptions(BaseModel): """Options specific to Ghostscript operations.""" @@ -99,9 +123,14 @@ class GhostscriptOptions(BaseModel): choices=[pc.value for pc in PdfaImageCompression], default=PdfaImageCompression.AUTO.value, help="Specify how to compress images in the output PDF/A. 'auto' lets " - "OCRmyPDF decide. 'jpeg' changes all grayscale and color images to " + "OCRmyPDF decide: at -O0 it uses lossless image compression so " + "Ghostscript does not transcode lossless images to JPEG; at -O1 and " + "above it defers to Ghostscript's heuristic, which may recompress " + "images lossily. 'jpeg' changes all grayscale and color images to " "JPEG compression. 'lossless' uses PNG-style lossless compression " - "for all images. Monochrome images are always compressed using a " + "for non-JPEG images and passes existing JPEGs through unchanged " + "(re-encoding them losslessly would only inflate them). Monochrome " + "images are always compressed using a " "lossless codec. Compression settings " "are applied to all pages, including those for which OCR was " "skipped. Not supported for --output-type=pdf ; that setting " @@ -397,10 +426,15 @@ def generate_pdfa( if output_type == 'pdfa': output_type = 'pdfa-2' + compression = _resolve_auto_compression( + context.options.ghostscript.pdfa_image_compression, + context.options.optimize, + ) + ghostscript.generate_pdfa( pdf_pages=[pdfmark, *pdf_pages], output_file=output_file, - compression=context.options.ghostscript.pdfa_image_compression, + compression=compression, color_conversion_strategy=context.options.ghostscript.color_conversion_strategy, jpeg_quality=context.options.ghostscript.jpeg_quality, jpeg_maxdpi=context.options.ghostscript.jpeg_maxdpi, diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 81fc69dd..7878ffe1 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -17,7 +17,11 @@ from PIL import Image, UnidentifiedImageError from ocrmypdf._exec import ghostscript from ocrmypdf._exec.ghostscript import DuplicateFilter, rasterize_pdf -from ocrmypdf.builtin_plugins.ghostscript import _repair_gs106_jpeg_corruption +from ocrmypdf.builtin_plugins.ghostscript import ( + PdfaImageCompression, + _repair_gs106_jpeg_corruption, + _resolve_auto_compression, +) from ocrmypdf.exceptions import ColorConversionNeededError, ExitCode, InputFileError from ocrmypdf.helpers import Resolution from ocrmypdf.pluginspec import GhostscriptRasterDevice @@ -675,3 +679,58 @@ class TestGs106JpegCorruptionRepair: repaired = _repair_gs106_jpeg_corruption(source_path, damaged_path) assert repaired is False, "Should not repair truncation > 15 bytes" assert "JPEG corruption detected" not in caplog.text + + +@pytest.mark.parametrize( + ('compression', 'optimize', 'expected'), + [ + # auto coerces to lossless only at -O0; -O1 is a historical exception + # that keeps Ghostscript's (possibly lossy) heuristic, as do -O2/-O3 + (PdfaImageCompression.AUTO, 0, PdfaImageCompression.LOSSLESS), + (PdfaImageCompression.AUTO, 1, PdfaImageCompression.AUTO), + (PdfaImageCompression.AUTO, 2, PdfaImageCompression.AUTO), + (PdfaImageCompression.AUTO, 3, PdfaImageCompression.AUTO), + # explicit choices are always respected, regardless of optimize level + (PdfaImageCompression.JPEG, 0, PdfaImageCompression.JPEG), + (PdfaImageCompression.JPEG, 1, PdfaImageCompression.JPEG), + (PdfaImageCompression.LOSSLESS, 1, PdfaImageCompression.LOSSLESS), + (PdfaImageCompression.LOSSLESS, 3, PdfaImageCompression.LOSSLESS), + ], +) +def test_resolve_auto_compression(compression, optimize, expected): + assert _resolve_auto_compression(compression, optimize) == expected + + +def _capture_generate_pdfa_args(tmp_path, compression): + """Run generate_pdfa with a mocked Ghostscript and return the argv it built.""" + from subprocess import CompletedProcess + + captured = {} + + def fake_run(args, **kwargs): + captured['args'] = list(args) + return CompletedProcess(args, 0, None, stderr='') + + out = tmp_path / 'out.pdf' + with patch('ocrmypdf._exec.ghostscript.run_polling_stderr', side_effect=fake_run): + ghostscript.generate_pdfa( + pdf_pages=['dummy.pdf'], + output_file=out, + compression=compression, + color_conversion_strategy='RGB', + ) + return captured['args'] + + +def test_lossless_compression_passes_through_jpegs(tmp_path): + # Re-encoding an existing JPEG losslessly only bloats it (the lossy data is + # already baked in), so lossless mode must let Ghostscript pass JPEGs through + # untouched while still keeping lossless images lossless. + args = _capture_generate_pdfa_args(tmp_path, 'lossless') + assert '-dPassThroughJPEGImages=true' in args + assert '-dColorImageFilter=/FlateEncode' in args + + +def test_jpeg_compression_does_not_force_passthrough(tmp_path): + args = _capture_generate_pdfa_args(tmp_path, 'jpeg') + assert '-dPassThroughJPEGImages=true' not in args