Files
OCRmyPDF/src/ocrmypdf/_options.py
T
James R. Barlowandaider 1579337ebe refactor: Create OCROptions model with Namespace compatibility
This commit introduces a new `OCROptions` class in `_options.py` that provides:
- Proper typing for OCRmyPDF options
- Pydantic validation
- Backward compatibility with `argparse.Namespace`
- Gradual migration support for the options system

Key changes:
- Added comprehensive option fields with type hints
- Implemented custom attribute access methods
- Created conversion methods between Namespace and OCROptions
- Updated type hints in multiple files to support both types
- Maintained existing validation logic

The new model allows for a step-by-step refactoring of the options handling throughout the project.

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:40:57 -08:00

229 lines
7.6 KiB
Python

# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Internal options model for OCRmyPDF."""
from __future__ import annotations
import os
from argparse import Namespace
from collections.abc import Iterable, Sequence
from copy import copy
from pathlib import Path
from typing import Any, BinaryIO, Union
from pydantic import BaseModel, Field, validator
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
PathOrIO = Union[BinaryIO, Path, str, bytes]
class OCROptions(BaseModel):
"""Internal options model that can masquerade as argparse.Namespace.
This model provides proper typing and validation while maintaining
compatibility with existing code that expects argparse.Namespace behavior.
"""
# I/O options
input_file: PathOrIO
output_file: PathOrIO
sidecar: PathOrIO | None = None
# Core OCR options
languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE])
output_type: str = 'pdfa'
force_ocr: bool = False
skip_text: bool = False
redo_ocr: bool = False
# Job control
jobs: int | None = None
use_threads: bool = True
progress_bar: bool = True
quiet: bool = False
verbose: int = 0
keep_temporary_files: bool = False
# Image processing
image_dpi: int | None = None
deskew: bool = False
clean: bool = False
clean_final: bool = False
rotate_pages: bool = False
remove_background: bool = False
remove_vectors: bool = False
oversample: int = 0
unpaper_args: str | None = None
# OCR behavior
skip_big: float | None = None
pages: str | None = None
invalidate_digital_signatures: bool = False
# Metadata
title: str | None = None
author: str | None = None
subject: str | None = None
keywords: str | None = None
# Optimization
optimize: int | None = None
jpg_quality: int | None = None
png_quality: int | None = None
jbig2_lossy: bool | None = None
jbig2_page_group_size: int | None = None
jbig2_threshold: float | None = None
# Advanced options
max_image_mpixels: float = 250.0
pdf_renderer: str = 'auto'
tesseract_config: Iterable[str] | None = None
tesseract_pagesegmode: int | None = None
tesseract_oem: int | None = None
tesseract_thresholding: int | None = None
tesseract_timeout: float | None = None
tesseract_non_ocr_timeout: float | None = None
tesseract_downsample_above: int | None = None
tesseract_downsample_large_images: bool | None = None
rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD
pdfa_image_compression: str | None = None
color_conversion_strategy: str | None = None
user_words: os.PathLike | None = None
user_patterns: os.PathLike | None = None
fast_web_view: float | None = None
continue_on_soft_render_error: bool | None = None
# Plugin system
plugins: Sequence[Path | str] | None = None
# Store any extra attributes (for plugins and dynamic options)
_extra_attrs: dict[str, Any] = Field(default_factory=dict, exclude=True)
def __getattr__(self, name: str) -> Any:
"""Allow attribute access like argparse.Namespace."""
if name in self._extra_attrs:
return self._extra_attrs[name]
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any) -> None:
"""Allow attribute setting like argparse.Namespace."""
if name.startswith('_') or name in self.__fields__:
super().__setattr__(name, value)
else:
if not hasattr(self, '_extra_attrs'):
super().__setattr__('_extra_attrs', {})
self._extra_attrs[name] = value
def __delattr__(self, name: str) -> None:
"""Allow attribute deletion like argparse.Namespace."""
if name in self.__fields__:
super().__delattr__(name)
elif name in self._extra_attrs:
del self._extra_attrs[name]
else:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
@classmethod
def from_namespace(cls, ns: Namespace) -> OCROptions:
"""Convert argparse.Namespace to OCROptions."""
# Extract known fields
known_fields = {}
extra_attrs = {}
for key, value in vars(ns).items():
if key in cls.__fields__:
known_fields[key] = value
else:
extra_attrs[key] = value
instance = cls(**known_fields)
instance._extra_attrs = extra_attrs
return instance
def to_namespace(self) -> Namespace:
"""Convert back to argparse.Namespace for compatibility."""
ns = Namespace()
# Add pydantic fields
for field_name in self.__fields__:
field_value = getattr(self, field_name)
setattr(ns, field_name, field_value)
# Add extra attributes
for key, value in self._extra_attrs.items():
setattr(ns, key, value)
return ns
@validator('languages')
def validate_languages(cls, v):
"""Ensure languages list is not empty."""
if not v:
return [DEFAULT_LANGUAGE]
return v
@validator('output_type')
def validate_output_type(cls, v):
"""Validate output type is one of the allowed values."""
valid_types = {'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'}
if v not in valid_types:
raise ValueError(f"output_type must be one of {valid_types}")
return v
@validator('pdf_renderer')
def validate_pdf_renderer(cls, v):
"""Validate PDF renderer is one of the allowed values."""
valid_renderers = {'auto', 'hocr', 'sandwich', 'hocrdebug'}
if v not in valid_renderers:
raise ValueError(f"pdf_renderer must be one of {valid_renderers}")
return v
@validator('clean_final')
def validate_clean_final(cls, v, values):
"""If clean_final is True, also set clean to True."""
if v and 'clean' in values:
values['clean'] = True
return v
@validator('jobs')
def validate_jobs(cls, v):
"""Validate jobs is a reasonable number."""
if v is not None and (v < 0 or v > 256):
raise ValueError("jobs must be between 0 and 256")
return v
@validator('verbose')
def validate_verbose(cls, v):
"""Validate verbose level."""
if v < 0 or v > 2:
raise ValueError("verbose must be between 0 and 2")
return v
@validator('oversample')
def validate_oversample(cls, v):
"""Validate oversample DPI."""
if v < 0 or v > 5000:
raise ValueError("oversample must be between 0 and 5000")
return v
@validator('max_image_mpixels')
def validate_max_image_mpixels(cls, v):
"""Validate max image megapixels."""
if v < 0:
raise ValueError("max_image_mpixels must be non-negative")
return v
@validator('rotate_pages_threshold')
def validate_rotate_pages_threshold(cls, v):
"""Validate rotate pages threshold."""
if v < 0 or v > 1000:
raise ValueError("rotate_pages_threshold must be between 0 and 1000")
return v
class Config:
extra = "forbid" # Force use of _extra_attrs for unknown fields
arbitrary_types_allowed = True # Allow BinaryIO, Path, etc.
validate_assignment = True # Validate on attribute assignment