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:
+11
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user