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
+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,