Files
OCRmyPDF/src/ocrmypdf/_options.py
T
James R. Barlowandaider 9535b52d06 fix: revert default option values to preserve original behavior
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: set default optimize value to 1 to prevent NoneType comparison

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00

463 lines
16 KiB
Python

# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Internal options model for OCRmyPDF."""
from __future__ import annotations
import json
import logging
import os
import unicodedata
from argparse import Namespace
from collections.abc import Iterable, Sequence
from copy import copy
from io import IOBase
from pathlib import Path
from typing import Any, BinaryIO, Union
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf.exceptions import BadArgsError
from ocrmypdf.helpers import monotonic
log = logging.getLogger(__name__)
PathOrIO = Union[BinaryIO, IOBase, Path, str, bytes]
def _pages_from_ranges(ranges: str) -> set[int]:
"""Convert page range string to set of page numbers."""
pages: list[int] = []
page_groups = ranges.replace(' ', '').split(',')
for group in page_groups:
if not group:
continue
try:
start, end = group.split('-')
except ValueError:
pages.append(int(group) - 1)
else:
try:
new_pages = list(range(int(start) - 1, int(end)))
if not new_pages:
raise BadArgsError(
f"invalid page subrange '{start}-{end}'"
) from None
pages.extend(new_pages)
except ValueError:
raise BadArgsError(f"invalid page subrange '{group}'") from None
if not pages:
raise BadArgsError(
f"The string of page ranges '{ranges}' did not contain any recognizable "
f"page ranges."
)
if not monotonic(pages):
log.warning(
"List of pages to process contains duplicate pages, or pages that are "
"out of order"
)
if any(page < 0 for page in pages):
raise BadArgsError("pages refers to a page number less than 1")
log.debug("OCRing only these pages: %s", pages)
return set(pages)
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 | list[str] | None = (
None # Can be string or list after validation
)
# OCR behavior
skip_big: float | None = None
pages: str | set[int] | None = None # Can be string or set after validation
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 = 1
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
# Compatibility alias for plugins that expect jpeg_quality
@property
def jpeg_quality(self):
"""Compatibility alias for jpg_quality."""
return self.jpg_quality
@jpeg_quality.setter
def jpeg_quality(self, value):
"""Compatibility alias for jpg_quality."""
self.jpg_quality = value
# 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, alias='_extra_attrs'
)
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 type(self).model_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 type(self).model_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.model_fields:
known_fields[key] = value
else:
extra_attrs[key] = value
# Handle special cases for hOCR API
if 'output_folder' in extra_attrs and 'output_file' not in known_fields:
known_fields['output_file'] = '/dev/null' # Placeholder
# Handle case where input_file is missing (e.g., in _hocr_to_ocr_pdf)
if 'work_folder' in extra_attrs and 'input_file' not in known_fields:
known_fields['input_file'] = '/dev/null' # Placeholder
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 type(self).model_fields:
field_value = getattr(self, field_name)
setattr(ns, field_name, field_value)
# Add extra attributes (including computed ones like lossless_reconstruction)
for key, value in self.extra_attrs.items():
setattr(ns, key, value)
return ns
@field_validator('languages')
@classmethod
def validate_languages(cls, v):
"""Ensure languages list is not empty."""
if not v:
return [DEFAULT_LANGUAGE]
return v
@field_validator('output_type')
@classmethod
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
@field_validator('pdf_renderer')
@classmethod
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
@field_validator('clean_final')
@classmethod
def validate_clean_final(cls, v, info):
"""If clean_final is True, also set clean to True."""
if v and hasattr(info, 'data') and 'clean' in info.data:
info.data['clean'] = True
return v
@field_validator('jobs')
@classmethod
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
@field_validator('verbose')
@classmethod
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
@field_validator('oversample')
@classmethod
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
@field_validator('max_image_mpixels')
@classmethod
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
@field_validator('rotate_pages_threshold')
@classmethod
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
@field_validator('title', 'author', 'keywords', 'subject')
@classmethod
def validate_metadata_unicode(cls, v):
"""Validate metadata strings don't contain unsupported Unicode characters."""
if v is None:
return v
for char in v:
if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000:
hexchar = hex(ord(char))[2:].upper()
raise ValueError(
f"Metadata string contains unsupported Unicode character: "
f"{char} (U+{hexchar})"
)
return v
@field_validator('pages')
@classmethod
def validate_pages_format(cls, v):
"""Convert page ranges string to set of page numbers."""
if v is None:
return v
if isinstance(v, set):
return v # Already processed
# Convert string ranges to set of page numbers
return _pages_from_ranges(v)
@model_validator(mode='before')
@classmethod
def handle_special_cases(cls, data):
"""Handle special cases for API compatibility."""
if isinstance(data, dict):
# For hOCR API, output_file might not be present
if 'output_folder' in data and 'output_file' not in data:
data['output_file'] = '/dev/null' # Placeholder
return data
@model_validator(mode='after')
def validate_exclusive_ocr_options(self):
"""Ensure only one of force_ocr, skip_text, redo_ocr is set."""
exclusive_options = sum(
1 for opt in [self.force_ocr, self.skip_text, self.redo_ocr] if opt
)
if exclusive_options >= 2:
raise ValueError("Choose only one of --force-ocr, --skip-text, --redo-ocr.")
return self
@model_validator(mode='after')
def validate_output_type_compatibility(self):
"""Validate output type is compatible with output file."""
if self.output_type == 'none' and str(self.output_file) not in (os.devnull, '-'):
raise ValueError(
"Since you specified `--output-type none`, the output file "
f"{self.output_file} cannot be produced. Set the output file to "
f"`-` to suppress this message."
)
return self
@model_validator(mode='after')
def set_lossless_reconstruction(self):
"""Set lossless_reconstruction based on other options."""
lossless = not any([
self.deskew,
self.clean_final,
self.force_ocr,
self.remove_background,
])
if not lossless and self.redo_ocr:
raise ValueError(
"--redo-ocr is not currently compatible with --deskew, "
"--clean-final, and --remove-background"
)
# Set the computed attribute
self.extra_attrs['lossless_reconstruction'] = lossless
return self
def model_dump_json_safe(self) -> str:
"""Serialize to JSON with special handling for non-serializable types."""
# Create a copy of the model data for serialization
data = self.model_dump()
# Handle special types that don't serialize to JSON directly
def _serialize_value(value):
if isinstance(value, Path):
return {'__type__': 'Path', 'value': str(value)}
elif hasattr(value, 'read') or hasattr(value, 'write'):
# Stream object - replace with placeholder
return {'__type__': 'Stream', 'value': 'stream'}
elif isinstance(value, (list, tuple)):
return [_serialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: _serialize_value(v) for k, v in value.items()}
else:
return value
# Process all fields
serializable_data = {}
for key, value in data.items():
serializable_data[key] = _serialize_value(value)
# Add extra_attrs
if self.extra_attrs:
serializable_data['_extra_attrs'] = _serialize_value(self.extra_attrs)
return json.dumps(serializable_data)
@classmethod
def model_validate_json_safe(cls, json_str: str) -> OCROptions:
"""Reconstruct from JSON with special handling for non-serializable types."""
data = json.loads(json_str)
# Handle special types during deserialization
def _deserialize_value(value):
if isinstance(value, dict) and '__type__' in value:
if value['__type__'] == 'Path':
return Path(value['value'])
elif value['__type__'] == 'Stream':
# For streams, we'll use a placeholder string
return value['value']
else:
return value['value']
elif isinstance(value, list):
return [_deserialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: _deserialize_value(v) for k, v in value.items()}
else:
return value
# Process all fields
deserialized_data = {}
extra_attrs = {}
for key, value in data.items():
if key == '_extra_attrs':
extra_attrs = _deserialize_value(value)
else:
deserialized_data[key] = _deserialize_value(value)
# Create instance
instance = cls(**deserialized_data)
instance.extra_attrs = extra_attrs
return instance
model_config = ConfigDict(
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
)