From 28d6ea0f109054887d5636f56187d64f060bbc7d Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 15 Dec 2025 00:58:11 -0800 Subject: [PATCH] feat: Add CLI generation methods to plugin option models Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) --- src/ocrmypdf/_options.py | 6 +- src/ocrmypdf/_plugin_registry.py | 55 ++-- src/ocrmypdf/api.py | 40 ++- src/ocrmypdf/builtin_plugins/ghostscript.py | 67 +++-- src/ocrmypdf/builtin_plugins/optimize.py | 198 +++++++------ src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 262 +++++++++++------- src/ocrmypdf/cli.py | 7 +- src/ocrmypdf/pluginspec.py | 8 +- tests/test_metadata.py | 7 +- tests/test_unpaper.py | 1 - 10 files changed, 376 insertions(+), 275 deletions(-) diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index abfd0b4e..d771facf 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -151,7 +151,7 @@ class OCROptions(BaseModel): # Plugin option namespaces (for backward compatibility, will be removed in Phase 5) # These will be populated dynamically based on loaded plugins - + # Legacy tesseract options (for backward compatibility) tesseract_config: list[str] = [] tesseract_pagesegmode: int | None = None @@ -161,11 +161,11 @@ class OCROptions(BaseModel): tesseract_non_ocr_timeout: float | None = None tesseract_downsample_above: int = 32767 tesseract_downsample_large_images: bool | None = None - + # Legacy ghostscript options (for backward compatibility) pdfa_image_compression: str | None = None color_conversion_strategy: str = "LeaveColorUnchanged" - + # Legacy jbig2 options (for backward compatibility) jbig2_lossy: bool | None = None jbig2_page_group_size: int | None = None diff --git a/src/ocrmypdf/_plugin_registry.py b/src/ocrmypdf/_plugin_registry.py index 506efc75..3f295624 100644 --- a/src/ocrmypdf/_plugin_registry.py +++ b/src/ocrmypdf/_plugin_registry.py @@ -6,7 +6,6 @@ from __future__ import annotations import logging -from typing import Any, Dict, Type from pydantic import BaseModel, create_model @@ -15,59 +14,65 @@ log = logging.getLogger(__name__) class PluginOptionRegistry: """Registry for plugin option models.""" - + def __init__(self): - self._option_models: Dict[str, Type[BaseModel]] = {} - self._extended_model_cache: Type[BaseModel] | None = None - - def register_option_model(self, namespace: str, model_class: Type[BaseModel]) -> None: + self._option_models: dict[str, type[BaseModel]] = {} + self._extended_model_cache: type[BaseModel] | None = None + + def register_option_model( + self, namespace: str, model_class: type[BaseModel] + ) -> None: """Register a plugin's option model. - + Args: namespace: The namespace for the plugin options (e.g., 'tesseract') model_class: The Pydantic model class for the plugin options """ if namespace in self._option_models: - log.warning(f"Plugin option namespace '{namespace}' already registered, overriding") - + log.warning( + f"Plugin option namespace '{namespace}' already registered, overriding" + ) + self._option_models[namespace] = model_class # Clear cache when new models are registered self._extended_model_cache = None - - log.debug(f"Registered plugin option model for namespace '{namespace}': {model_class.__name__}") - - def get_registered_models(self) -> Dict[str, Type[BaseModel]]: + + log.debug( + f"Registered plugin option model for namespace '{namespace}': {model_class.__name__}" + ) + + def get_registered_models(self) -> dict[str, type[BaseModel]]: """Get all registered plugin option models.""" return self._option_models.copy() - - def get_extended_options_model(self, base_model: Type[BaseModel]) -> Type[BaseModel]: + + def get_extended_options_model( + self, base_model: type[BaseModel] + ) -> type[BaseModel]: """Create an extended options model that includes all registered plugin options. - + Args: base_model: The base OCROptions model to extend - + Returns: A new model class that includes the base model fields plus all plugin option fields """ if self._extended_model_cache is not None: return self._extended_model_cache - + # Start with base model fields model_fields = {} - + # Add plugin option models as nested fields for namespace, model_class in self._option_models.items(): model_fields[namespace] = (model_class, model_class()) - + # Create the extended model self._extended_model_cache = create_model( - 'ExtendedOCROptions', - __base__=base_model, - **model_fields + 'ExtendedOCROptions', __base__=base_model, **model_fields ) - + return self._extended_model_cache - + def clear_cache(self) -> None: """Clear the extended model cache.""" self._extended_model_cache = None diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index d8f92b00..d704be0e 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -75,53 +75,54 @@ def setup_plugin_infrastructure( plugin_manager: pluggy.PluginManager | None = None, ) -> pluggy.PluginManager: """Set up plugin infrastructure with proper initialization. - + This function handles: 1. Creating or validating the plugin manager 2. Calling plugin initialization hooks 3. Setting up plugin option registry - + Args: plugins: List of plugin paths/names to load plugin_manager: Existing plugin manager (if any) - + Returns: Properly initialized plugin manager - + Raises: ValueError: If both plugins and plugin_manager are provided """ if plugins and plugin_manager: raise ValueError("plugins= and plugin_manager are mutually exclusive") - + if not plugins: plugins = [] elif isinstance(plugins, (str, Path)): plugins = [plugins] else: plugins = list(plugins) - + # Create plugin manager if not provided if not plugin_manager: plugin_manager = get_plugin_manager(plugins) - + # Initialize plugins plugin_manager.hook.initialize(plugin_manager=plugin_manager) # pylint: disable=no-member - + # Initialize plugin option registry from ocrmypdf._plugin_registry import PluginOptionRegistry + registry = PluginOptionRegistry() - + # Let plugins register their option models option_models = plugin_manager.hook.register_options() # pylint: disable=no-member for plugin_options in option_models: if plugin_options: # Skip None returns for namespace, model_class in plugin_options.items(): registry.register_option_model(namespace, model_class) - + # Store registry in plugin manager for later access plugin_manager._option_registry = registry - + return plugin_manager @@ -420,10 +421,9 @@ def ocr( # noqa: D417 with _api_lock: # Set up plugin infrastructure with proper initialization plugin_manager = setup_plugin_infrastructure( - plugins=plugins, - plugin_manager=plugin_manager + plugins=plugins, plugin_manager=plugin_manager ) - + # Get parser and let plugins add their options parser = get_parser() plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member @@ -553,10 +553,9 @@ def _pdf_to_hocr( # noqa: D417 with _api_lock: # Set up plugin infrastructure with proper initialization plugin_manager = setup_plugin_infrastructure( - plugins=plugins, - plugin_manager=plugin_manager + plugins=plugins, plugin_manager=plugin_manager ) - + plugin_manager.hook.add_options(parser=get_parser()) # pylint: disable=no-member # Create OCROptions directly @@ -654,12 +653,11 @@ def _hocr_to_ocr_pdf( # noqa: D417 extra_attrs[key] = options_kwargs.pop(key) with _api_lock: - # Set up plugin infrastructure with proper initialization + # Set up plugin infrastructure with proper initialization plugin_manager = setup_plugin_infrastructure( - plugins=plugins, - plugin_manager=plugin_manager + plugins=plugins, plugin_manager=plugin_manager ) - + plugin_manager.hook.add_options(parser=get_parser()) # pylint: disable=no-member # Create OCROptions directly diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index b7a5e63b..59aa2fe7 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -24,9 +24,45 @@ 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" + + 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 @@ -37,29 +73,8 @@ def register_options(): @hookimpl def add_options(parser): - 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.", - ) + # Use the model's CLI generation method + GhostscriptOptions.add_arguments_to_parser(parser) @hookimpl diff --git a/src/ocrmypdf/builtin_plugins/optimize.py b/src/ocrmypdf/builtin_plugins/optimize.py index 23a25fb3..3a8716b6 100644 --- a/src/ocrmypdf/builtin_plugins/optimize.py +++ b/src/ocrmypdf/builtin_plugins/optimize.py @@ -24,13 +24,120 @@ log = logging.getLogger(__name__) class OptimizeOptions(BaseModel): """Options specific to PDF optimization.""" - - level: Annotated[int, Field(ge=0, le=3, description="Optimization level (0=none, 1=safe, 2=lossy, 3=aggressive)")] = 1 - jpeg_quality: Annotated[int, Field(ge=0, le=100, description="JPEG quality level for optimization")] = 0 - png_quality: Annotated[int, Field(ge=0, le=100, description="PNG quality level for optimization")] = 0 - jbig2_lossy: Annotated[bool, Field(description="Enable JBIG2 lossy compression")] = False - jbig2_page_group_size: Annotated[int, Field(ge=1, le=10000, description="Number of pages to consider for JBIG2 compression")] = 0 - jbig2_threshold: Annotated[float, Field(ge=0.4, le=0.9, description="JBIG2 symbol classification threshold")] = 0.85 + + level: Annotated[ + int, + Field( + ge=0, + le=3, + description="Optimization level (0=none, 1=safe, 2=lossy, 3=aggressive)", + ), + ] = 1 + jpeg_quality: Annotated[ + int, Field(ge=0, le=100, description="JPEG quality level for optimization") + ] = 0 + png_quality: Annotated[ + int, Field(ge=0, le=100, description="PNG quality level for optimization") + ] = 0 + jbig2_lossy: Annotated[ + bool, Field(description="Enable JBIG2 lossy compression") + ] = False + jbig2_page_group_size: Annotated[ + int, + Field( + ge=1, + le=10000, + description="Number of pages to consider for JBIG2 compression", + ), + ] = 0 + jbig2_threshold: Annotated[ + float, + Field(ge=0.4, le=0.9, description="JBIG2 symbol classification threshold"), + ] = 0.85 + + @classmethod + def add_arguments_to_parser(cls, parser, namespace: str = 'optimize'): + """Add optimization-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 optimize for backward compatibility) + """ + optimizing = parser.add_argument_group( + "Optimization options", "Control how the PDF is optimized after OCR" + ) + optimizing.add_argument( + '-O', + '--optimize', + type=int, + choices=range(0, 4), + default=1, + help=( + "Control how PDF is optimized after processing:" + "0 - do not optimize; " + "1 - do safe, lossless optimizations (default); " + "2 - do lossy JPEG and JPEG2000 optimizations; " + "3 - do more aggressive lossy JPEG and JPEG2000 optimizations. " + "To enable lossy JBIG2, see --jbig2-lossy." + ), + ) + optimizing.add_argument( + '--jpeg-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + help=( + "Adjust JPEG quality level for JPEG optimization. " + "100 is best quality and largest output size; " + "1 is lowest quality and smallest output; " + "0 uses the default." + ), + ) + optimizing.add_argument( + '--jpg-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + dest='jpeg_quality', + help=argparse.SUPPRESS, # Alias for --jpeg-quality + ) + optimizing.add_argument( + '--png-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + help=( + "Adjust PNG quality level to use when quantizing PNGs. " + "Values have same meaning as with --jpeg-quality" + ), + ) + optimizing.add_argument( + '--jbig2-lossy', + action='store_true', + help=( + "Enable JBIG2 lossy mode (better compression, not suitable for some " + "use cases - see documentation). Only takes effect if --optimize 1 or " + "higher is also enabled." + ), + ) + optimizing.add_argument( + '--jbig2-page-group-size', + type=numeric(int, 1, 10000), + default=0, + metavar='N', + # Adjust number of pages to consider at once for JBIG2 compression + help=argparse.SUPPRESS, + ) + optimizing.add_argument( + '--jbig2-threshold', + type=numeric(float, 0.4, 0.9), + default=0.85, + metavar='T', + help=( + "Adjust JBIG2 symbol code classification threshold " + "(default 0.85), range 0.4 to 0.9." + ), + ) @hookimpl @@ -41,81 +148,8 @@ def register_options(): @hookimpl def add_options(parser): - optimizing = parser.add_argument_group( - "Optimization options", "Control how the PDF is optimized after OCR" - ) - optimizing.add_argument( - '-O', - '--optimize', - type=int, - choices=range(0, 4), - default=1, - help=( - "Control how PDF is optimized after processing:" - "0 - do not optimize; " - "1 - do safe, lossless optimizations (default); " - "2 - do lossy JPEG and JPEG2000 optimizations; " - "3 - do more aggressive lossy JPEG and JPEG2000 optimizations. " - "To enable lossy JBIG2, see --jbig2-lossy." - ), - ) - optimizing.add_argument( - '--jpeg-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - help=( - "Adjust JPEG quality level for JPEG optimization. " - "100 is best quality and largest output size; " - "1 is lowest quality and smallest output; " - "0 uses the default." - ), - ) - optimizing.add_argument( - '--jpg-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - dest='jpeg_quality', - help=argparse.SUPPRESS, # Alias for --jpeg-quality - ) - optimizing.add_argument( - '--png-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - help=( - "Adjust PNG quality level to use when quantizing PNGs. " - "Values have same meaning as with --jpeg-quality" - ), - ) - optimizing.add_argument( - '--jbig2-lossy', - action='store_true', - help=( - "Enable JBIG2 lossy mode (better compression, not suitable for some " - "use cases - see documentation). Only takes effect if --optimize 1 or " - "higher is also enabled." - ), - ) - optimizing.add_argument( - '--jbig2-page-group-size', - type=numeric(int, 1, 10000), - default=0, - metavar='N', - # Adjust number of pages to consider at once for JBIG2 compression - help=argparse.SUPPRESS, - ) - optimizing.add_argument( - '--jbig2-threshold', - type=numeric(float, 0.4, 0.9), - default=0.85, - metavar='T', - help=( - "Adjust JBIG2 symbol code classification threshold " - "(default 0.85), range 0.4 to 0.9." - ), - ) + # Use the model's CLI generation method + OptimizeOptions.add_arguments_to_parser(parser) @hookimpl diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index dd10cbe7..9b53f2a4 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -27,15 +27,160 @@ log = logging.getLogger(__name__) class TesseractOptions(BaseModel): """Options specific to Tesseract OCR engine.""" - - config: Annotated[list[str], Field(description="Additional Tesseract configuration files")] = [] - pagesegmode: Annotated[int | None, Field(ge=0, le=13, description="Set Tesseract page segmentation mode")] = None - oem: Annotated[int | None, Field(ge=0, le=3, description="Set Tesseract OCR engine mode")] = None - thresholding: Annotated[int | None, Field(description="Set Tesseract input image thresholding mode")] = None - timeout: Annotated[float, Field(ge=0, description="Timeout for OCR operations in seconds")] = 180.0 - non_ocr_timeout: Annotated[float, Field(ge=0, description="Timeout for non-OCR operations in seconds")] = 180.0 - downsample_large_images: Annotated[bool, Field(description="Downsample large images before OCR")] = True - downsample_above: Annotated[int, Field(ge=100, le=32767, description="Downsample images larger than this pixel size")] = 32767 + + config: Annotated[ + list[str], Field(description="Additional Tesseract configuration files") + ] = [] + pagesegmode: Annotated[ + int | None, + Field(ge=0, le=13, description="Set Tesseract page segmentation mode"), + ] = None + oem: Annotated[ + int | None, Field(ge=0, le=3, description="Set Tesseract OCR engine mode") + ] = None + thresholding: Annotated[ + int | None, Field(description="Set Tesseract input image thresholding mode") + ] = None + timeout: Annotated[ + float, Field(ge=0, description="Timeout for OCR operations in seconds") + ] = 180.0 + non_ocr_timeout: Annotated[ + float, Field(ge=0, description="Timeout for non-OCR operations in seconds") + ] = 180.0 + downsample_large_images: Annotated[ + bool, Field(description="Downsample large images before OCR") + ] = True + downsample_above: Annotated[ + int, + Field( + ge=100, + le=32767, + description="Downsample images larger than this pixel size", + ), + ] = 32767 + + @classmethod + def add_arguments_to_parser(cls, parser, namespace: str = 'tesseract'): + """Add Tesseract-specific arguments to the argument parser. + + Args: + parser: The argument parser to add arguments to + namespace: The namespace prefix for argument names + """ + tess = parser.add_argument_group( + "Tesseract", "Advanced control of Tesseract OCR" + ) + + tess.add_argument( + f'--{namespace}-config', + action='append', + metavar='CFG', + default=[], + dest=f'{namespace}_config', + help="Additional Tesseract configuration files -- see documentation.", + ) + + tess.add_argument( + f'--{namespace}-pagesegmode', + action='store', + type=int, + metavar='PSM', + choices=range(0, 14), + dest=f'{namespace}_pagesegmode', + help="Set Tesseract page segmentation mode (see tesseract --help).", + ) + + tess.add_argument( + f'--{namespace}-oem', + action='store', + type=int, + metavar='MODE', + choices=range(0, 4), + dest=f'{namespace}_oem', + help=( + "Set Tesseract 4+ OCR engine mode: " + "0 - original Tesseract only; " + "1 - neural nets LSTM only; " + "2 - Tesseract + LSTM; " + "3 - default." + ), + ) + + tess.add_argument( + f'--{namespace}-thresholding', + action='store', + type=str_to_int(tesseract.TESSERACT_THRESHOLDING_METHODS), + default='auto', + metavar='METHOD', + dest=f'{namespace}_thresholding', + help=( + "Set Tesseract 5.0+ input image thresholding mode. This may improve OCR " + "results on low quality images or those that contain high contrast color. " + "legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu " + "algorithm with improved sort for background color changes; sauvola is " + "based on local standard deviation." + ), + ) + + tess.add_argument( + f'--{namespace}-timeout', + default=180.0, + type=numeric(float, 0), + metavar='SECONDS', + dest=f'{namespace}_timeout', + help=( + "Give up on OCR after the timeout, but copy the preprocessed page " + "into the final output. This timeout is only used when using Tesseract " + "for OCR. When Tesseract is used for other operations such as " + "deskewing and orientation, the timeout is controlled by " + f"--{namespace}-non-ocr-timeout." + ), + ) + + tess.add_argument( + f'--{namespace}-non-ocr-timeout', + default=180.0, + type=numeric(float, 0), + metavar='SECONDS', + dest=f'{namespace}_non_ocr_timeout', + help=( + "Give up on non-OCR operations such as deskewing and orientation " + f"after timeout. This is a separate timeout from --{namespace}-timeout " + "because these operations are not as expensive as OCR." + ), + ) + + tess.add_argument( + f'--{namespace}-downsample-large-images', + action=argparse.BooleanOptionalAction, + default=True, + dest=f'{namespace}_downsample_large_images', + help=( + "Downsample large images before OCR. Tesseract has an upper limit on the " + "size images it will support. If this argument is given, OCRmyPDF will " + "downsample large images to fit Tesseract. This may reduce OCR quality, " + "on large images the most desirable text is usually larger. If this " + "parameter is not supplied, Tesseract will error out and produce no OCR " + "on the page in question. This argument should be used with a high value " + f"of --{namespace}-timeout to ensure Tesseract has enough to time." + ), + ) + + tess.add_argument( + f'--{namespace}-downsample-above', + action='store', + type=numeric(int, 100, 32767), + default=32767, + dest=f'{namespace}_downsample_above', + help=( + "Downsample images larger than this size pixel size in either dimension " + f"before OCR. --{namespace}-downsample-large-images downsamples only when " + "an image exceeds Tesseract's internal limits. This argument causes " + "downsampling to occur when an image exceeds the given size. This may " + "reduce OCR quality, but on large images the most desirable text is " + "usually larger." + ), + ) @hookimpl @@ -46,102 +191,11 @@ def register_options(): @hookimpl def add_options(parser): + # Use the model's CLI generation method + TesseractOptions.add_arguments_to_parser(parser) + + # Add user words and patterns (these are not part of TesseractOptions model yet) tess = parser.add_argument_group("Tesseract", "Advanced control of Tesseract OCR") - tess.add_argument( - '--tesseract-config', - action='append', - metavar='CFG', - default=[], - help="Additional Tesseract configuration files -- see documentation.", - ) - tess.add_argument( - '--tesseract-pagesegmode', - action='store', - type=int, - metavar='PSM', - choices=range(0, 14), - help="Set Tesseract page segmentation mode (see tesseract --help).", - ) - tess.add_argument( - '--tesseract-oem', - action='store', - type=int, - metavar='MODE', - choices=range(0, 4), - help=( - "Set Tesseract 4+ OCR engine mode: " - "0 - original Tesseract only; " - "1 - neural nets LSTM only; " - "2 - Tesseract + LSTM; " - "3 - default." - ), - ) - tess.add_argument( - '--tesseract-thresholding', - action='store', - type=str_to_int(tesseract.TESSERACT_THRESHOLDING_METHODS), - default='auto', - metavar='METHOD', - help=( - "Set Tesseract 5.0+ input image thresholding mode. This may improve OCR " - "results on low quality images or those that contain high contrast color. " - "legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu " - "algorithm with improved sort for background color changes; sauvola is " - "based on local standard deviation." - ), - ) - tess.add_argument( - '--tesseract-timeout', - default=180.0, - type=numeric(float, 0), - metavar='SECONDS', - help=( - "Give up on OCR after the timeout, but copy the preprocessed page " - "into the final output. This timeout is only used when using Tesseract " - "for OCR. When Tesseract is used for other operations such as " - "deskewing and orientation, the timeout is controlled by " - "--tesseract-non-ocr-timeout." - ), - ) - tess.add_argument( - '--tesseract-non-ocr-timeout', - default=180.0, - type=numeric(float, 0), - metavar='SECONDS', - help=( - "Give up on non-OCR operations such as deskewing and orientation " - "after timeout. This is a separate timeout from --tesseract-timeout " - "because these operations are not as expensive as OCR." - ), - ) - tess.add_argument( - '--tesseract-downsample-large-images', - action=argparse.BooleanOptionalAction, - default=True, - help=( - "Downsample large images before OCR. Tesseract has an upper limit on the " - "size images it will support. If this argument is given, OCRmyPDF will " - "downsample large images to fit Tesseract. This may reduce OCR quality, " - "on large images the most desirable text is usually larger. If this " - "parameter is not supplied, Tesseract will error out and produce no OCR " - "on the page in question. This argument should be used with a high value " - "of --tesseract-timeout to ensure Tesseract has enough to time." - ), - ) - tess.add_argument( - '--tesseract-downsample-above', - action='store', - type=numeric(int, 100, 32767), - default=32767, - help=( - "Downsample images larger than this size pixel size in either dimension " - "before OCR. --tesseract-downsample-large-images downsamples only when " - "an image exceeds Tesseract's internal limits. This argument causes " - "downsampling to occur when an image exceeds the given size. This may " - "reduce OCR quality, but on large images the most desirable text is " - "usually larger." - ), - ) tess.add_argument( '--user-words', metavar='FILE', diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index c097d026..3ae6dc21 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -15,7 +15,6 @@ import pluggy from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME from ocrmypdf._options import OCROptions -from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._version import __version__ as _VERSION T = TypeVar('T', int, float) @@ -466,7 +465,7 @@ def namespace_to_options(ns) -> OCROptions: # Handle backward compatibility for plugin options # Map CLI arguments to the appropriate fields for now # In Phase 2, this will be handled by plugin option models - + instance = OCROptions(**known_fields) instance.extra_attrs = extra_attrs return instance @@ -489,10 +488,10 @@ def get_options_and_plugins( """ # Import here to avoid circular imports from ocrmypdf.api import setup_plugin_infrastructure - + # First pass: get plugins so we can register their options pre_options, _unused = plugins_only_parser.parse_known_args(args=args) - + # Set up plugin infrastructure with proper initialization plugin_manager = setup_plugin_infrastructure(plugins=pre_options.plugins) diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 01b26c20..2b90330e 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -91,19 +91,19 @@ def add_options(parser: ArgumentParser) -> None: @hookspec def register_options() -> dict[str, type[BaseModel]]: """Return plugin's option models keyed by namespace. - + This hook allows plugins to register their option models with the plugin option registry. The returned dictionary should map namespace strings to Pydantic model classes. - + Returns: Dictionary mapping namespace strings to BaseModel classes - + Example: @hookimpl def register_options(): return {'tesseract': TesseractOptions} - + Note: This hook will be called from the main process during plugin infrastructure setup, before child worker processes are forked. diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 6f31b2b7..80da5f6b 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -15,7 +15,6 @@ from pikepdf.models.metadata import decode_pdf_date from ocrmypdf._jobcontext import PdfContext from ocrmypdf._metadata import metadata_fixup from ocrmypdf._pipeline import convert_to_pdfa -from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf.api import setup_plugin_infrastructure from ocrmypdf.cli import get_options_and_plugins from ocrmypdf.exceptions import ExitCode @@ -333,9 +332,7 @@ def test_metadata_fixup_warning(resources, outdir, caplog): # Use the new setup function instead of get_plugin_manager directly plugin_manager = setup_plugin_infrastructure([]) - context = PdfContext( - options, outdir, outdir / 'graph.pdf', None, plugin_manager - ) + context = PdfContext(options, outdir, outdir / 'graph.pdf', None, plugin_manager) metadata_fixup( working_file=outdir / 'graph.pdf', context=context, pdf_save_settings={} ) @@ -382,7 +379,7 @@ def test_prevent_gs_invalid_xml(resources, outdir): ] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - + # Use the new setup function plugin_manager = setup_plugin_infrastructure([]) context = PdfContext( diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 83da7478..31425cba 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -12,7 +12,6 @@ from packaging.version import Version from ocrmypdf._exec import unpaper from ocrmypdf._validation import check_options -from ocrmypdf.api import setup_plugin_infrastructure from ocrmypdf.cli import get_options_and_plugins from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError