From ed813cec6771abbb0aadfd5f18159024b5bc119d Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 21 Dec 2025 00:23:00 -0800 Subject: [PATCH] feat: add --rasterizer CLI option to select PDF rasterization backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add user control over which rasterizer is used for PDF page rendering: - 'auto' (default): prefers pypdfium when available, falls back to Ghostscript - 'pypdfium': force pypdfium2 (errors if not installed) - 'ghostscript': force traditional Ghostscript rasterizer Changes: - Add rasterizer field with validation to OCROptions model - Add --rasterizer CLI argument in the Advanced options group - Update rasterize_pdf_page hookspec to pass options to plugins - Update pypdfium plugin with check_options hook for availability check - Update both plugins to respect the rasterizer option 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/ocrmypdf/_options.py | 10 ++++++++++ src/ocrmypdf/_pipeline.py | 2 ++ src/ocrmypdf/api.py | 2 ++ src/ocrmypdf/builtin_plugins/ghostscript.py | 5 +++++ src/ocrmypdf/builtin_plugins/pypdfium.py | 19 ++++++++++++++++--- src/ocrmypdf/cli.py | 8 ++++++++ src/ocrmypdf/pluginspec.py | 4 ++++ 7 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index fcbd4f2e..3ae9b0c1 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -147,6 +147,7 @@ class OCROptions(BaseModel): # Advanced options max_image_mpixels: float = 250.0 pdf_renderer: str = 'auto' + rasterizer: str = 'auto' rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD user_words: os.PathLike | None = None user_patterns: os.PathLike | None = None @@ -207,6 +208,15 @@ class OCROptions(BaseModel): raise ValueError(f"pdf_renderer must be one of {valid_renderers}") return v + @field_validator('rasterizer') + @classmethod + def validate_rasterizer(cls, v): + """Validate rasterizer is one of the allowed values.""" + valid_rasterizers = {'auto', 'ghostscript', 'pypdfium'} + if v not in valid_rasterizers: + raise ValueError(f"rasterizer must be one of {valid_rasterizers}") + return v + @field_validator('clean_final') @classmethod def validate_clean_final(cls, v, info): diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index f7fb995f..a6090064 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -405,6 +405,7 @@ def rasterize_preview(input_file: Path, page_context: PageContext) -> Path: rotation=0, filter_vector=False, stop_on_soft_error=not page_context.options.continue_on_soft_render_error, + options=page_context.options, ) return output_file @@ -564,6 +565,7 @@ def rasterize( rotation=correction, filter_vector=remove_vectors, stop_on_soft_error=not page_context.options.continue_on_soft_render_error, + options=page_context.options, ) return output_file diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 67a8c612..a1622edf 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -328,6 +328,7 @@ def ocr( # noqa: D417 tesseract_oem: int | None = None, tesseract_thresholding: int | None = None, pdf_renderer: str | None = None, + rasterizer: str | None = None, tesseract_timeout: float | None = None, tesseract_non_ocr_timeout: float | None = None, tesseract_downsample_above: int | None = None, @@ -481,6 +482,7 @@ def _pdf_to_hocr( # noqa: D417 tesseract_downsample_above: int | None = None, tesseract_downsample_large_images: bool | None = None, rotate_pages_threshold: float | None = None, + rasterizer: str | None = None, user_words: os.PathLike | None = None, user_patterns: os.PathLike | None = None, continue_on_soft_render_error: bool | None = None, diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 59aa2fe7..78d149dc 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -128,8 +128,13 @@ def rasterize_pdf_page( rotation, filter_vector, stop_on_soft_error, + options=None, ): """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, diff --git a/src/ocrmypdf/builtin_plugins/pypdfium.py b/src/ocrmypdf/builtin_plugins/pypdfium.py index 47656639..a187c981 100644 --- a/src/ocrmypdf/builtin_plugins/pypdfium.py +++ b/src/ocrmypdf/builtin_plugins/pypdfium.py @@ -13,13 +13,20 @@ except ImportError: pdfium = None from ocrmypdf import hookimpl +from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.helpers import Resolution log = logging.getLogger(__name__) -# Note: No check_options hook - pypdfium is optional. If pypdfium2 is not -# installed, the rasterize_pdf_page hook returns None and Ghostscript is used. +@hookimpl +def check_options(options): + """Check that pypdfium2 is available if explicitly requested.""" + if options.rasterizer == 'pypdfium' and pdfium is None: + raise MissingDependencyError( + "The --rasterizer pypdfium option requires the pypdfium2 package. " + "Install it with: pip install pypdfium2" + ) def _open_pdf_document(input_file: Path): @@ -123,11 +130,17 @@ def rasterize_pdf_page( rotation: int | None, filter_vector: bool, stop_on_soft_error: bool, + options=None, ) -> Path | None: """Rasterize a single page of a PDF file using pypdfium2. - Returns None if pypdfium2 is not available, allowing Ghostscript to be used. + Returns None if pypdfium2 is not available or if the user has selected + a different rasterizer, allowing Ghostscript to be used. """ + # Check if user explicitly requested a different rasterizer + if options is not None and options.rasterizer == 'ghostscript': + return None # Let Ghostscript handle it + if pdfium is None: return None # Fall back to Ghostscript diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 107430a7..3658090f 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -373,6 +373,14 @@ Online documentation is located at: help="Choose OCR PDF renderer - the default option is to let OCRmyPDF " "choose. See documentation for discussion.", ) + advanced.add_argument( + '--rasterizer', + choices=['auto', 'ghostscript', 'pypdfium'], + default='auto', + help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, " + "falling back to Ghostscript. 'pypdfium' is faster but requires the " + "pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.", + ) advanced.add_argument( '--rotate-pages-threshold', default=DEFAULT_ROTATE_PAGES_THRESHOLD, diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 2b90330e..229e9a27 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -213,6 +213,7 @@ def rasterize_pdf_page( rotation: int | None, filter_vector: bool, stop_on_soft_error: bool, + options: OCROptions | None = None, ) -> Path: # type: ignore[return-value] """Rasterize one page of a PDF at resolution raster_dpi in canvas units. @@ -236,6 +237,9 @@ def rasterize_pdf_page( cannot proceed, it should always raise an exception, regardless of this setting. One "soft error" would be a missing font that is required to properly rasterize the PDF. + options: OCRmyPDF options. Plugins may use this to check settings like + ``options.rasterizer`` to determine whether they should handle the + request or defer to another plugin. Introduced in version 17.0. Returns: Path: output_file if successful