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
+64
View File
@@ -419,6 +419,70 @@ curves. In this case, you may want to use a different color conversion
strategy. The `--color-conversion-strategy` option allows you to select a
different strategy, such as `RGB`.
## Advanced Ghostscript tuning
:::{versionadded} 17.5.0
:::
OCRmyPDF intentionally hides most Ghostscript controls because Ghostscript
is a legacy code path. The preferred PDF/A pipeline in v17+ uses pypdfium2
as the rasterizer and verapdf to validate speculative PDF/A output, with
Ghostscript reserved as a fallback for PDFs that cannot be made compliant
without it. OCRmyPDF's separate optimizer (controlled by `--optimize`,
`--jpeg-quality`, `--png-quality`, etc.) is the supported way to shrink
output PDFs: it gives consistent results across input files, and isolates
Ghostscript so it can focus on producing a PDF/A with as few image
transformations as possible.
The two options below are exposed for advanced users who want to tune
Ghostscript's intermediate PDF/A output directly. Most users will get
more predictable results from the optimizer.
### `--ghostscript-jpeg-quality Q`
Sets Ghostscript's `-dJPEGQ` switch for images that Ghostscript chooses
to recompress to JPEG while building a PDF/A. `Q=0` requests maximum
compression and `Q=100` requests best quality; if the flag is omitted,
OCRmyPDF passes `95` (the historical default). This only affects images
Ghostscript transcodes — existing JPEGs pass through unchanged on modern
Ghostscript releases. For end-to-end JPEG quality tuning, prefer
`--jpeg-quality`, which is implemented by the OCRmyPDF optimizer and is
applied independently of whatever Ghostscript decides to do.
Note: setting both `--ghostscript-jpeg-quality` and `--jpeg-quality` can
result in double JPEG recompression, since the optimizer may re-encode
images that Ghostscript already recompressed. This can degrade quality
in subtle ways.
### `--ghostscript-jpeg-maxdpi DPI`
Enables Ghostscript's image downsampling and caps color, grayscale, and
monochrome image resolution to `DPI`. The downsample threshold is set to
`1.0`, so any image whose effective DPI exceeds the cap will be
downsampled.
Reducing JPEG quality is almost always a better trade than downsampling
at the same compression budget: a 400 DPI JPEG at modest quality usually
looks much better than a 200 DPI JPEG, because the JPEG codec can spend
bits where they count. Downsampling is also dangerous for PDFs that
combine a low-resolution color image with a high-resolution monochrome
mask — capping the mask resolution can produce visible quality loss.
For these reasons, prefer `--jpeg-quality` over `--ghostscript-jpeg-maxdpi`
unless you specifically want to force a hard DPI cap.
Example:
```bash
ocrmypdf --output-type pdfa \
--ghostscript-jpeg-quality 80 \
--ghostscript-jpeg-maxdpi 150 \
in.pdf out.pdf
```
These options only take effect when Ghostscript is invoked for PDF/A
conversion (`--output-type pdfa`, `pdfa-1`, `pdfa-2`, or `pdfa-3`, or
when `--output-type auto` falls back to Ghostscript).
## PDF/A output modes
:::{versionchanged} 17.0.0
+10
View File
@@ -31,6 +31,16 @@ ocrmypdf --output-type pdf input.pdf output.pdf
ocrmypdf --output-type pdfa --pdfa-image-compression jpeg input.pdf output.pdf
```
### Reduce JPEG quality with the optimizer
This is the recommended way to shrink JPEG content in the output. The
optimizer applies regardless of `--output-type`, so it works on both
plain PDFs and Ghostscript-produced PDF/A files.
```bash
ocrmypdf --optimize 2 --jpeg-quality 60 input.pdf output.pdf
```
### Modify a file in place
The file will only be overwritten if OCRmyPDF is successful.
+5
View File
@@ -183,6 +183,11 @@ v17 addresses through alternative codepaths. When Ghostscript is used:
`jpeg` or `lossless` to set all images to one type or the other.
Ghostscript lacks an option to maintain the input image's format.
(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
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning).
Most users should prefer `--jpeg-quality` (applied by the OCRmyPDF
optimizer) over those Ghostscript-scoped controls.
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
one of the standard XMP metadata namespaces for PDFs. In particular,
PRISM Metadata is removed.
+10 -1
View File
@@ -98,7 +98,16 @@ If `pngquant` is installed, OCRmyPDF will use it to perform quantize
paletted images to reduce their size.
The quality of JPEGs may be lowered, on the assumption that a lower
quality image may be suitable for storage after OCR.
quality image may be suitable for storage after OCR. Use `--jpeg-quality`
to control the optimizer's JPEG quality target. The optimizer is the
recommended way to reduce JPEG image sizes: it applies consistently
regardless of whether Ghostscript was used to produce a PDF/A.
If you specifically need to tune Ghostscript's own PDF/A image handling
(for example, to force a hard DPI cap), see
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning)
for the separate `--ghostscript-jpeg-quality` and
`--ghostscript-jpeg-maxdpi` options.
It is not possible to optimize all image types. Uncommon image types may
be skipped by the optimizer.
+3
View File
@@ -46,6 +46,8 @@ __ocrmypdf_arguments()
--rasterizer (PDF page rasterizer)
--rotate-pages-threshold (page rotation confidence)
--pdfa-image-compression (set PDF/A image compression options)
--ghostscript-jpeg-quality (Ghostscript JPEG quality during PDF/A [0..100])
--ghostscript-jpeg-maxdpi (cap Ghostscript image DPI during PDF/A)
--fast-web-view (if file size if above this amount in MB linearize PDF)
--continue-on-soft-render-error (continue after recoverable render errors)
--plugin (name of plugin to import)
@@ -337,6 +339,7 @@ __ocrmypdf_check_previous()
--title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\
--jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\
--ghostscript-jpeg-quality|--ghostscript-jpeg-maxdpi|\
--tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\
--rotate-pages-threshold|--fast-web-view)
# argument required but no completions available
+2
View File
@@ -102,6 +102,8 @@ function __fish_ocrmypdf_pdfa_compression
echo -e "lossless\t"(_ "convert color and grayscale images to lossless (PNG)")
end
complete -c ocrmypdf -x -l pdfa-image-compression -a '(__fish_ocrmypdf_pdfa_compression)' -d "set PDF/A image compression options"
complete -c ocrmypdf -x -l ghostscript-jpeg-quality -d "Ghostscript JPEG quality during PDF/A [0..100]"
complete -c ocrmypdf -x -l ghostscript-jpeg-maxdpi -d "cap Ghostscript image DPI during PDF/A"
complete -c ocrmypdf -x -s j -l jobs -d "how many worker processes to use"
complete -c ocrmypdf -x -l title -d "set metadata"
+34 -8
View File
@@ -278,6 +278,8 @@ def generate_pdfa(
*,
compression: str,
color_conversion_strategy: str,
jpeg_quality: int | None = None,
jpeg_maxdpi: int | None = None,
pdf_version: str = '1.5',
pdfa_part: str = '2',
progressbar_class=None,
@@ -318,15 +320,38 @@ def generate_pdfa(
# Windows has lots of fatal "permission denied" errors
stop_on_error = False
# 1. nb no need to specify ProcessColorModel when ColorConversionStrategy
# `-dJPEGQ=N` tells Ghostscript to use a JPEG quality of N, IF it decides
# to transcode an image to JPEG. When there are existing JPEG images,
# Ghostscript uses passthrough mode, so the quality level is not changed.
# OCRmyPDF's optimizer separately uses the `--jpeg-quality` command line
# option to potentially re-encode JPEG images, regardless of whether
# Ghostscript decided to transcode them to JPEG or not.
# `jpeg_quality=0` is meaningful to Ghostscript (maximum compression), so
# only fall back to the default when the value is None.
effective_jpeg_quality = jpeg_quality if jpeg_quality is not None else 95
# Downsampling images is a blunt-force way to reduce file size and almost
# always degrades quality more than lowering JPEG quality at the original
# resolution. We expose this for users with very specific needs (e.g.
# producing very small files for screen-only viewing); the optimizer is
# usually a better choice.
downsample_args: list[str] = []
if jpeg_maxdpi is not None:
downsample_args = [
"-dDownsampleColorImages=true",
"-dColorImageDownsampleThreshold=1.0",
"-dDownsampleGrayImages=true",
"-dGrayImageDownsampleThreshold=1.0",
"-dDownsampleMonoImages=true",
"-dMonoImageDownsampleThreshold=1.0",
f"-dColorImageResolution={jpeg_maxdpi}",
f"-dGrayImageResolution={jpeg_maxdpi}",
f"-dMonoImageResolution={jpeg_maxdpi}",
]
# nb no need to specify ProcessColorModel when ColorConversionStrategy
# is set; see:
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
# 2. `-dJPEGQ=95` tells Ghostscript to use a JPEG quality of 95, IF it
# decides to transcode an image to JPEG. When there are existing JPEG
# images, Ghostscript uses passthrough mode, so the quality level is not
# changed. OCRmyPDF's optimizer uses the `--jpeg-quality` command line
# option to potentially re-encoded JPEG images, regardless of whether
# Ghostscript decided to transcode them to JPEG or not.
args_gs = (
[
GS,
@@ -340,8 +365,9 @@ def generate_pdfa(
]
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
+ compression_args
+ downsample_args
+ [
"-dJPEGQ=95", # See note above on JPEG quality
f"-dJPEGQ={effective_jpeg_quality}", # See note above on JPEG quality
"-dSubsetFonts=false", # Prevents GS from messing up some encodings
f"-dPDFA={pdfa_part}",
"-dPDFACompatibilityPolicy=1",
+7
View File
@@ -582,6 +582,13 @@ class OcrOptions(BaseModel):
value = getattr(self, flat_name)
if value is not None:
kwargs[field_name] = _convert_value(value)
# Plugin-scoped fields that aren't in the central OcrOptions
# registry: argparse stores them in extra_attrs under the
# namespace_field name.
elif flat_name in self.extra_attrs:
value = self.extra_attrs[flat_name]
if value is not None:
kwargs[field_name] = _convert_value(value)
# Also check direct field name (for fields like jbig2_lossy)
elif field_name in OcrOptions.model_fields:
value = getattr(self, field_name)
@@ -54,6 +54,27 @@ class GhostscriptOptions(BaseModel):
pdfa_image_compression: Annotated[
PdfaImageCompression, Field(description="PDF/A image compression method")
] = PdfaImageCompression.AUTO
jpeg_quality: Annotated[
int | None,
Field(
ge=0,
le=100,
description=(
"JPEG quality (0-100) for Ghostscript image recompression during "
"PDF/A generation; None uses Ghostscript's default."
),
),
] = None
jpeg_maxdpi: Annotated[
int | None,
Field(
ge=1,
description=(
"Maximum DPI for Ghostscript image downsampling during PDF/A "
"generation."
),
),
] = None
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
@@ -86,6 +107,35 @@ class GhostscriptOptions(BaseModel):
"skipped. Not supported for --output-type=pdf ; that setting "
"preserves the original compression of all images.",
)
gs.add_argument(
'--ghostscript-jpeg-quality',
type=int,
metavar='Q',
default=None,
dest=f'{namespace}_jpeg_quality',
help=(
"Advanced: Set Ghostscript's -dJPEGQ for images that Ghostscript "
"transcodes to JPEG during PDF/A generation. 0 is maximum "
"compression; 100 is best quality. If omitted, Ghostscript's "
"default is used. This only affects images Ghostscript chooses "
"to recompress; for general JPEG quality tuning prefer "
"--jpeg-quality, which is applied by the OCRmyPDF optimizer."
),
)
gs.add_argument(
'--ghostscript-jpeg-maxdpi',
type=int,
metavar='DPI',
default=None,
dest=f'{namespace}_jpeg_maxdpi',
help=(
"Advanced: Force Ghostscript to downsample color, grayscale, "
"and monochrome images in PDF/A output to the given maximum DPI. "
"Reducing JPEG quality usually gives better results than "
"downsampling at the same file size, and can degrade quality "
"of high-resolution monochrome masks."
),
)
@hookimpl
@@ -352,6 +402,8 @@ def generate_pdfa(
output_file=output_file,
compression=context.options.ghostscript.pdfa_image_compression,
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
jpeg_quality=context.options.ghostscript.jpeg_quality,
jpeg_maxdpi=context.options.ghostscript.jpeg_maxdpi,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
+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