diff --git a/docs/advanced.md b/docs/advanced.md index f8061f2d..b5c67884 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -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 diff --git a/docs/cookbook.md b/docs/cookbook.md index 434df25a..673e4d4b 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -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. diff --git a/docs/introduction.md b/docs/introduction.md index 70811218..29fc7b79 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -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. diff --git a/docs/optimizer.md b/docs/optimizer.md index 6eba6cc8..bc51e355 100644 --- a/docs/optimizer.md +++ b/docs/optimizer.md @@ -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. diff --git a/misc/completion/ocrmypdf.bash b/misc/completion/ocrmypdf.bash index 7e9f235d..cf5ecf82 100644 --- a/misc/completion/ocrmypdf.bash +++ b/misc/completion/ocrmypdf.bash @@ -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 diff --git a/misc/completion/ocrmypdf.fish b/misc/completion/ocrmypdf.fish index 00d321fc..22304f64 100644 --- a/misc/completion/ocrmypdf.fish +++ b/misc/completion/ocrmypdf.fish @@ -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" diff --git a/src/ocrmypdf/_exec/ghostscript.py b/src/ocrmypdf/_exec/ghostscript.py index 6bf14df2..6457fe43 100644 --- a/src/ocrmypdf/_exec/ghostscript.py +++ b/src/ocrmypdf/_exec/ghostscript.py @@ -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", diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index 1a6896fb..489790aa 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -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) diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 46b5e331..0bb8b640 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -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, diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index f2fa34eb..21f73420 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -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