Add test_rasterizer.py with tests covering:
- Basic rasterizer option validation ('auto', 'ghostscript', 'pypdfium')
- Rasterizer + --rotate-pages interaction
- PDFs with nonstandard MediaBox/TrimBox/CropBox
- Direct hook tests verifying plugins respect the option
Also fix pluggy parameter passing: make 'options' a required parameter
(no default) in the hookspec so pluggy forwards it to implementations.
Update test plugins and test_rotation.py to pass the new parameter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
180 lines
6.0 KiB
Python
180 lines
6.0 KiB
Python
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
"""Built-in plugin to implement PDF page rasterization and PDF/A production."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from packaging.version import Version
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ocrmypdf import hookimpl
|
|
from ocrmypdf._exec import ghostscript
|
|
from ocrmypdf.exceptions import MissingDependencyError
|
|
from ocrmypdf.subprocess import check_external_program
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Currently all blacklisted versions are lower than 9.55, so none need to
|
|
# be added here. If a future version is blacklisted, add it here.
|
|
BLACKLISTED_GS_VERSIONS: frozenset[Version] = frozenset()
|
|
|
|
|
|
class GhostscriptOptions(BaseModel):
|
|
"""Options specific to Ghostscript operations."""
|
|
|
|
color_conversion_strategy: Annotated[
|
|
str, Field(description="Ghostscript color conversion strategy")
|
|
] = "LeaveColorUnchanged"
|
|
pdfa_image_compression: Annotated[
|
|
str, Field(description="PDF/A image compression method")
|
|
] = "auto"
|
|
|
|
@classmethod
|
|
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
|
|
"""Add Ghostscript-specific arguments to the argument parser.
|
|
|
|
Args:
|
|
parser: The argument parser to add arguments to
|
|
namespace: The namespace prefix for argument names (not used for ghostscript for backward compatibility)
|
|
"""
|
|
gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript")
|
|
gs.add_argument(
|
|
'--color-conversion-strategy',
|
|
action='store',
|
|
type=str,
|
|
metavar='STRATEGY',
|
|
choices=ghostscript.COLOR_CONVERSION_STRATEGIES,
|
|
default='LeaveColorUnchanged',
|
|
help="Set Ghostscript color conversion strategy",
|
|
)
|
|
gs.add_argument(
|
|
'--pdfa-image-compression',
|
|
choices=['auto', 'jpeg', 'lossless'],
|
|
default='auto',
|
|
help="Specify how to compress images in the output PDF/A. 'auto' lets "
|
|
"OCRmyPDF decide. '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 "
|
|
"lossless codec. Compression settings "
|
|
"are applied to all pages, including those for which OCR was "
|
|
"skipped. Not supported for --output-type=pdf ; that setting "
|
|
"preserves the original compression of all images.",
|
|
)
|
|
|
|
|
|
@hookimpl
|
|
def register_options():
|
|
"""Register Ghostscript option model."""
|
|
return {'ghostscript': GhostscriptOptions}
|
|
|
|
|
|
@hookimpl
|
|
def add_options(parser):
|
|
# Use the model's CLI generation method
|
|
GhostscriptOptions.add_arguments_to_parser(parser)
|
|
|
|
|
|
@hookimpl
|
|
def check_options(options):
|
|
"""Check that the options are valid for this plugin."""
|
|
check_external_program(
|
|
program='gs',
|
|
package='ghostscript',
|
|
version_checker=ghostscript.version,
|
|
need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55
|
|
)
|
|
gs_version = ghostscript.version()
|
|
if gs_version in BLACKLISTED_GS_VERSIONS:
|
|
raise MissingDependencyError(
|
|
f"Ghostscript {gs_version} contains serious regressions and is not "
|
|
"supported. Please upgrade to a newer version."
|
|
)
|
|
if Version('10.0.0') <= gs_version < Version('10.02.1') and (
|
|
options.skip_text or options.redo_ocr
|
|
):
|
|
raise MissingDependencyError(
|
|
f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) "
|
|
"contain serious regressions that corrupt PDFs with existing text, "
|
|
"such as those processed using --skip-text or --redo-ocr. "
|
|
"Please upgrade to a "
|
|
"newer version, or use --output-type pdf to avoid Ghostscript, or "
|
|
"use --force-ocr to discard existing text."
|
|
)
|
|
|
|
if options.color_conversion_strategy not in ghostscript.COLOR_CONVERSION_STRATEGIES:
|
|
raise ValueError(
|
|
f"Invalid color conversion strategy: {options.color_conversion_strategy}"
|
|
)
|
|
if options.pdfa_image_compression != 'auto' and not options.output_type.startswith(
|
|
'pdfa'
|
|
):
|
|
log.warning(
|
|
"--pdfa-image-compression argument only applies when "
|
|
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
|
|
)
|
|
|
|
|
|
@hookimpl
|
|
def rasterize_pdf_page(
|
|
input_file,
|
|
output_file,
|
|
raster_device,
|
|
raster_dpi,
|
|
pageno,
|
|
page_dpi,
|
|
rotation,
|
|
filter_vector,
|
|
stop_on_soft_error,
|
|
options,
|
|
):
|
|
"""Rasterize a single page of a PDF file using Ghostscript."""
|
|
# Check if user explicitly requested a different rasterizer
|
|
if options is not None and options.rasterizer == 'pypdfium':
|
|
return None # Let pypdfium handle it (it will error in check_options if unavailable)
|
|
|
|
ghostscript.rasterize_pdf(
|
|
input_file,
|
|
output_file,
|
|
raster_device=raster_device,
|
|
raster_dpi=raster_dpi,
|
|
pageno=pageno,
|
|
page_dpi=page_dpi,
|
|
rotation=rotation,
|
|
filter_vector=filter_vector,
|
|
stop_on_error=stop_on_soft_error,
|
|
)
|
|
return output_file
|
|
|
|
|
|
@hookimpl
|
|
def generate_pdfa(
|
|
pdf_pages,
|
|
pdfmark,
|
|
output_file,
|
|
context,
|
|
pdf_version,
|
|
pdfa_part,
|
|
progressbar_class,
|
|
stop_on_soft_error,
|
|
):
|
|
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
|
|
# Normalize output_type at point of use
|
|
output_type = context.options.output_type
|
|
if output_type == 'pdfa':
|
|
output_type = 'pdfa-2'
|
|
|
|
ghostscript.generate_pdfa(
|
|
pdf_pages=[pdfmark, *pdf_pages],
|
|
output_file=output_file,
|
|
compression=context.options.pdfa_image_compression,
|
|
color_conversion_strategy=context.options.color_conversion_strategy,
|
|
pdf_version=pdf_version,
|
|
pdfa_part=pdfa_part,
|
|
progressbar_class=progressbar_class,
|
|
stop_on_error=stop_on_soft_error,
|
|
)
|
|
return output_file
|