feat: make pdfa-image-compression=auto lossless at -O0

Ghostscript's `auto` image compression heuristic can transcode lossless
images to JPEG during PDF/A generation, which is surprising at
optimization levels that otherwise promise lossless-only operations
(issue #1124).

`--pdfa-image-compression=auto` (the default) now coerces to lossless at
-O0 so Ghostscript will not transcode lossless images to JPEG. -O1 and
above continue to defer to Ghostscript's heuristic; -O1 (the default
level) is kept as a historical exception because coercing it to lossless
substantially bloats output. Users wanting guaranteed lossless image
handling can pass --pdfa-image-compression=lossless or use -O0.

Also make `lossless` pass existing JPEGs through unchanged
(-dPassThroughJPEGImages=true) instead of re-encoding them with a
lossless codec, which only inflates already-lossy data.
This commit is contained in:
James R. Barlow
2026-06-10 13:10:43 -07:00
parent 5cb5d7a682
commit 7e42d3c771
5 changed files with 125 additions and 8 deletions
+11 -4
View File
@@ -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
+12
View File
@@ -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
+5
View File
@@ -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",
+37 -3
View File
@@ -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,
+60 -1
View File
@@ -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