feat: add comprehensive validators to OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user