feat: add comprehensive validators to OCROptions

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
This commit is contained in:
James R. Barlow
2025-12-13 11:42:23 -08:00
co-authored by aider
parent 7bb3a97208
commit 66a3e8508e
2 changed files with 131 additions and 6 deletions
+115
View File
@@ -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.
+16 -6
View File
@@ -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():