diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index d93f92ab..3f947a1d 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -5,7 +5,9 @@ from __future__ import annotations +import logging import os +import unicodedata from argparse import Namespace from collections.abc import Iterable, Sequence from copy import copy @@ -16,10 +18,54 @@ 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. @@ -251,6 +297,34 @@ class OCROptions(BaseModel): 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): @@ -264,6 +338,47 @@ class OCROptions(BaseModel): data['pdf_renderer'] = 'hocr' # Default to hocr for auto 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 + model_config = ConfigDict( extra="forbid", # Force use of extra_attrs for unknown fields arbitrary_types_allowed=True, # Allow BinaryIO, Path, etc. diff --git a/tests/test_validation.py b/tests/test_validation.py index 5dd61528..72b8bfcc 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -13,6 +13,7 @@ import pytest from ocrmypdf import _validation as vd from ocrmypdf._concurrent import NullProgressBar, SerialExecutor from ocrmypdf._exec.tesseract import TesseractVersion +from ocrmypdf._options import OCROptions from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf.api import create_options from ocrmypdf.cli import get_parser @@ -41,6 +42,15 @@ def make_opts(*args, **kwargs): return opts +def make_ocr_opts(input_file='a.pdf', output_file='b.pdf', **kwargs): + """Create OCROptions directly for testing Pydantic validation.""" + return OCROptions( + input_file=input_file, + output_file=output_file, + **kwargs + ) + + def test_old_tesseract_error(): with patch( 'ocrmypdf._exec.tesseract.version', @@ -64,16 +74,16 @@ def test_tesseract_not_installed(caplog): def test_lossless_redo(): with pytest.raises(ValueError, match="--redo-ocr is not currently compatible"): - make_opts(redo_ocr=True, deskew=True) + make_ocr_opts(redo_ocr=True, deskew=True) def test_mutex_options(): with pytest.raises(ValueError, match="Choose only one of --force-ocr, --skip-text, --redo-ocr"): - make_opts(force_ocr=True, skip_text=True) + make_ocr_opts(force_ocr=True, skip_text=True) with pytest.raises(ValueError, match="Choose only one of --force-ocr, --skip-text, --redo-ocr"): - make_opts(redo_ocr=True, skip_text=True) + make_ocr_opts(redo_ocr=True, skip_text=True) with pytest.raises(ValueError, match="Choose only one of --force-ocr, --skip-text, --redo-ocr"): - make_opts(redo_ocr=True, force_ocr=True) + make_ocr_opts(redo_ocr=True, force_ocr=True) def test_optimizing(caplog): @@ -85,12 +95,12 @@ def test_optimizing(caplog): def test_pillow_options(): # Test that max_image_mpixels=0 is valid (validation now in OCROptions) - opts = make_opts(max_image_mpixels=0) + opts = make_ocr_opts(max_image_mpixels=0) assert opts.max_image_mpixels == 0 # Test that negative values are rejected with pytest.raises(ValueError, match="max_image_mpixels must be non-negative"): - make_opts(max_image_mpixels=-1) + make_ocr_opts(max_image_mpixels=-1) def test_output_tty():