Add --ghostscript-jpeg-quality and --ghostscript-jpeg-maxdpi

Expose Ghostscript's -dJPEGQ and image downsampling switches as
advanced, plugin-scoped options for tuning PDF/A output, without
polluting the central OcrOptions registry. The optimizer's existing
--jpeg-quality remains the recommended JPEG quality control.

- GhostscriptOptions gains jpeg_quality and jpeg_maxdpi fields and CLI
  args (advanced help text). jpeg_quality=0 is honored as Ghostscript's
  maximum compression rather than being silently coerced to the default.
- _exec.ghostscript.generate_pdfa() forwards both values; when
  jpeg_maxdpi is set, downsample threshold is pinned at 1.0.
- _get_plugin_options falls back to extra_attrs for namespaced fields
  so plugins can own their options without registering them centrally.
- Documentation explains the rationale: Ghostscript is the legacy path
  (pypdfium + verapdf is preferred in v17+), the optimizer is the
  supported file-size lever, and lowering quality is almost always a
  better trade than downsampling.
This commit is contained in:
James R. Barlow
2026-05-25 10:20:54 -07:00
parent 08e40f96e8
commit efb83ad64f
10 changed files with 316 additions and 13 deletions
+129 -4
View File
@@ -137,6 +137,129 @@ def test_rasterize_low_dpi_one_axis(francais, outdir):
assert im.info['dpi'] == forced_dpi
def test_generate_pdfa_default_jpeg_quality(outdir):
"""When jpeg_quality is None, Ghostscript receives -dJPEGQ=95 (default)."""
with (
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
):
run_mock.return_value = subprocess.CompletedProcess(
['gs'], returncode=0, stdout='', stderr=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='auto',
color_conversion_strategy='LeaveColorUnchanged',
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=95' in args
# No downsample switches when jpeg_maxdpi is not set
assert not any(a.startswith('-dDownsampleColorImages') for a in args)
assert not any(a.startswith('-dColorImageResolution') for a in args)
def test_generate_pdfa_uses_user_jpeg_quality(outdir):
with (
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
):
run_mock.return_value = subprocess.CompletedProcess(
['gs'], returncode=0, stdout='', stderr=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='jpeg',
color_conversion_strategy='RGB',
jpeg_quality=72,
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=72' in args
assert '-dJPEGQ=95' not in args
def test_generate_pdfa_jpeg_quality_zero_is_max_compression(outdir):
"""Explicit jpeg_quality=0 must reach Ghostscript as -dJPEGQ=0.
Ghostscript accepts 0 as a valid quality value (maximum compression);
it must not be silently replaced by the default 95.
"""
with (
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
):
run_mock.return_value = subprocess.CompletedProcess(
['gs'], returncode=0, stdout='', stderr=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='jpeg',
color_conversion_strategy='RGB',
jpeg_quality=0,
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=0' in args
assert '-dJPEGQ=95' not in args
def test_generate_pdfa_honors_jpeg_maxdpi(outdir):
with (
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
):
run_mock.return_value = subprocess.CompletedProcess(
['gs'], returncode=0, stdout='', stderr=''
)
ghostscript.generate_pdfa(
pdf_pages=[outdir / 'input.pdf'],
output_file=outdir / 'out.pdf',
compression='auto',
color_conversion_strategy='LeaveColorUnchanged',
jpeg_maxdpi=300,
)
args = run_mock.call_args.args[0]
assert '-dJPEGQ=95' in args
assert '-dDownsampleColorImages=true' in args
assert '-dColorImageDownsampleThreshold=1.0' in args
assert '-dDownsampleGrayImages=true' in args
assert '-dGrayImageDownsampleThreshold=1.0' in args
assert '-dDownsampleMonoImages=true' in args
assert '-dMonoImageDownsampleThreshold=1.0' in args
assert '-dColorImageResolution=300' in args
assert '-dGrayImageResolution=300' in args
assert '-dMonoImageResolution=300' in args
def test_ghostscript_jpeg_options_via_cli(resources, outpdf):
"""End-to-end: CLI flags reach the ghostscript plugin namespace."""
with patch(
'ocrmypdf._exec.ghostscript.generate_pdfa',
wraps=ghostscript.generate_pdfa,
) as gen_mock:
run_ocrmypdf_api(
resources / 'francais.pdf',
outpdf,
'--output-type',
'pdfa',
'--ghostscript-jpeg-quality',
'60',
'--ghostscript-jpeg-maxdpi',
'150',
'--plugin',
'tests/plugins/tesseract_noop.py',
)
assert gen_mock.called
call_kwargs = gen_mock.call_args.kwargs
assert call_kwargs['jpeg_quality'] == 60
assert call_kwargs['jpeg_maxdpi'] == 150
def test_gs_render_failure(resources, outpdf, caplog):
exitcode = run_ocrmypdf_api(
resources / 'blank.pdf',
@@ -176,9 +299,9 @@ def test_ghostscript_pdfa_failure(resources, outpdf, caplog):
'--plugin',
'tests/plugins/gs_pdfa_failure.py',
)
assert (
exitcode == ExitCode.pdfa_conversion_failed
), "Unexpected return when PDF/A fails"
assert exitcode == ExitCode.pdfa_conversion_failed, (
"Unexpected return when PDF/A fails"
)
def test_ghostscript_feature_elision(resources, outpdf):
@@ -439,7 +562,9 @@ class TestGs106JpegCorruptionRepair:
repaired_bytes_list.append(obj.read_raw_bytes())
assert len(repaired_bytes_list) == len(original_bytes_list)
for orig, repaired_bytes in zip(original_bytes_list, repaired_bytes_list, strict=False):
for orig, repaired_bytes in zip(
original_bytes_list, repaired_bytes_list, strict=False
):
assert orig == repaired_bytes, "Repaired bytes should match original"
# Check that error/warning was logged