Add OcrOptions as first-class argument to ocr() function

Allow passing an OcrOptions object directly to ocr() as the first
positional argument, providing a cleaner API for programmatic use.
The old-style API with individual parameters remains fully supported.
This commit is contained in:
James R. Barlow
2026-01-20 10:20:52 -08:00
parent 2f4280b66c
commit ef88ba3f95
2 changed files with 269 additions and 64 deletions
+2
View File
@@ -11,6 +11,7 @@ from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
from ocrmypdf._concurrent import Executor
from ocrmypdf._defaults import PROGRAM_NAME
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipelines._common import (
configure_debug_logging,
)
@@ -67,6 +68,7 @@ __all__ = [
'OcrClass',
'OcrElement',
'OcrEngine',
'OcrOptions',
'OrientationConfidence',
'OutputFileAccessError',
'PageContext',
+267 -64
View File
@@ -47,7 +47,7 @@ from collections.abc import Iterable, Sequence
from enum import IntEnum
from io import IOBase
from pathlib import Path
from typing import BinaryIO
from typing import BinaryIO, overload
from warnings import warn
from ocrmypdf._logging import PageNumberFilter
@@ -58,6 +58,7 @@ from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
from ocrmypdf._validation import check_options
from ocrmypdf.cli import ArgumentParser, get_parser
from ocrmypdf.exceptions import ExitCode
StrPath = Path | str | bytes
PathOrIO = BinaryIO | StrPath
@@ -232,6 +233,56 @@ def configure_logging(
return log
def _check_no_conflicting_ocr_params(
locals_dict: dict,
kwargs: dict,
excluded: set[str] | None = None,
) -> None:
"""Check that no individual OCR parameters conflict with OcrOptions.
When a user passes an OcrOptions object, they should not also pass
individual OCR parameters (except plugins/plugin_manager which are
handled separately).
Args:
locals_dict: The locals() dict from the calling function.
kwargs: The **kwargs dict from the calling function.
excluded: Parameter names to exclude from conflict checking.
Raises:
ValueError: If conflicting parameters are found.
"""
if excluded is None:
excluded = set()
# Parameters that are allowed alongside OcrOptions
allowed_with_options = {
'input_file_or_options',
'options', # The OcrOptions object itself after assignment
'plugins',
'plugin_manager',
'kwargs',
} | excluded
# Check all locals that are OCR parameters (not None and not allowed)
conflicts = [
name
for name, value in locals_dict.items()
if value is not None and name not in allowed_with_options
]
# Check kwargs
conflicts.extend(kwargs.keys())
if conflicts:
raise ValueError(
f"When passing OcrOptions as the first argument, do not pass "
f"additional OCR parameters. Conflicting parameters: "
f"{', '.join(sorted(conflicts))}. "
f"Set these values in OcrOptions instead."
)
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
) -> OcrOptions:
@@ -292,8 +343,19 @@ def create_options(
raise TypeError(f"Failed to create OcrOptions: {e}") from e
def ocr( # noqa: D417
input_file: PathOrIO,
@overload
def ocr(
options: OcrOptions,
/,
*,
plugins: Iterable[Path | str] | None = None,
plugin_manager: OcrmypdfPluginManager | None = None,
) -> ExitCode: ...
@overload
def ocr(
input_file_or_options: PathOrIO,
output_file: PathOrIO,
*,
language: Iterable[str] | None = None,
@@ -315,6 +377,67 @@ def ocr( # noqa: D417
oversample: int | None = None,
remove_vectors: bool | None = None,
mode: str | None = None,
force_ocr: bool | None = None,
skip_text: bool | None = None,
redo_ocr: bool | None = None,
skip_big: float | None = None,
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,
pages: str | None = None,
max_image_mpixels: float | None = None,
tesseract_config: Iterable[str] | None = None,
tesseract_pagesegmode: int | None = None,
tesseract_oem: int | None = None,
tesseract_thresholding: int | None = None,
pdf_renderer: str | None = None,
rasterizer: str | 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 | None = None,
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,
invalidate_digital_signatures: bool | None = None,
plugins: Iterable[Path | str] | None = None,
plugin_manager: OcrmypdfPluginManager | None = None,
keep_temporary_files: bool | None = None,
progress_bar: bool | None = None,
**kwargs,
) -> ExitCode: ...
def ocr( # noqa: D417
input_file_or_options: PathOrIO | OcrOptions,
output_file: PathOrIO | None = None,
*,
language: Iterable[str] | None = None,
image_dpi: int | None = None,
output_type: str | None = None,
sidecar: PathOrIO | None = None,
jobs: int | None = None,
use_threads: bool | None = None,
title: str | None = None,
author: str | None = None,
subject: str | None = None,
keywords: str | None = None,
rotate_pages: bool | None = None,
remove_background: bool | None = None,
deskew: bool | None = None,
clean: bool | None = None,
clean_final: bool | None = None,
unpaper_args: str | None = None,
oversample: int | None = None,
remove_vectors: bool | None = None,
mode: str | None = None,
force_ocr: bool | None = None, # Legacy, use mode='force' instead
skip_text: bool | None = None, # Legacy, use mode='skip' instead
redo_ocr: bool | None = None, # Legacy, use mode='redo' instead
@@ -346,13 +469,28 @@ def ocr( # noqa: D417
continue_on_soft_render_error: bool | None = None,
invalidate_digital_signatures: bool | None = None,
plugins: Iterable[Path | str] | None = None,
plugin_manager=None,
plugin_manager: OcrmypdfPluginManager | None = None,
keep_temporary_files: bool | None = None,
progress_bar: bool | None = None,
**kwargs,
):
) -> ExitCode:
"""Run OCRmyPDF on one PDF or image.
This function supports two calling conventions:
**New style (recommended):**
>>> from ocrmypdf import ocr
>>> from ocrmypdf._options import OcrOptions
>>> options = OcrOptions(
... input_file="input.pdf",
... output_file="output.pdf",
... languages=["eng"],
... )
>>> ocr(options)
**Old style:**
>>> ocr("input.pdf", "output.pdf", language=["eng"])
For most arguments, see documentation for the equivalent command line parameter.
This API takes a threading lock, because OCRmyPDF uses global state in particular
@@ -369,24 +507,33 @@ def ocr( # noqa: D417
A few specific arguments are discussed here:
Args:
input_file_or_options: Either an OcrOptions object containing all settings,
or a path/stream for the input file (old-style API).
output_file: Output file path or stream. Required when using old-style API
with input_file as first argument. Must be None when passing OcrOptions.
use_threads: Use worker threads instead of processes. This reduces
performance but may make debugging easier since it is easier to set
breakpoints.
input_file: If a :class:`pathlib.Path`, ``str`` or ``bytes``, this is
interpreted as file system path to the input file. If the object
appears to be a readable stream (with methods such as ``.read()``
and ``.seek()``), the object will be read in its entirety and saved to
a temporary file. If ``input_file`` is ``"-"``, standard input will be
read.
output_file: If a :class:`pathlib.Path`, ``str`` or ``bytes``, this is
interpreted as file system path to the output file. If the object
appears to be a writable stream (with methods such as ``.write()`` and
``.seek()``), the output will be written to this stream. If
``output_file`` is ``"-"``, the output will be written to ``sys.stdout``
(provided that standard output does not seem to be a terminal device).
When a stream is used as output, whether via a writable object or
``"-"``, some final validation steps are not performed (we do not read
back the stream after it is written).
plugins: List of plugin paths to load. Can be passed alongside OcrOptions.
plugin_manager: Pre-configured plugin manager. Can be passed alongside
OcrOptions.
For input_file (old-style API): If a :class:`pathlib.Path`, ``str`` or
``bytes``, this is interpreted as file system path to the input file.
If the object appears to be a readable stream (with methods such as
``.read()`` and ``.seek()``), the object will be read in its entirety
and saved to a temporary file. If ``input_file`` is ``"-"``, standard
input will be read.
For output_file (old-style API): If a :class:`pathlib.Path`, ``str`` or
``bytes``, this is interpreted as file system path to the output file.
If the object appears to be a writable stream (with methods such as
``.write()`` and ``.seek()``), the output will be written to this
stream. If ``output_file`` is ``"-"``, the output will be written to
``sys.stdout`` (provided that standard output does not seem to be a
terminal device). When a stream is used as output, whether via a
writable object or ``"-"``, some final validation steps are not
performed (we do not read back the stream after it is written).
Raises:
ocrmypdf.MissingDependencyError: If a required dependency program is missing or
@@ -405,61 +552,117 @@ def ocr( # noqa: D417
OCRmyPDF does not remove passwords.
ocrmypdf.TesseractConfigError: If Tesseract reported its configuration was not
valid.
ValueError: If OcrOptions is passed along with other OCR parameters, or if
both plugins and plugin_manager are provided.
TypeError: If output_file is missing when using the old-style API.
Returns:
:class:`ocrmypdf.ExitCode`
"""
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
# Detect calling convention: OcrOptions object vs individual parameters
if isinstance(input_file_or_options, OcrOptions):
# New-style API: OcrOptions passed directly
options = input_file_or_options
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
# Check for conflicting parameters (all should be None except plugins/plugin_manager)
_check_no_conflicting_ocr_params(locals(), kwargs)
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'input_file', 'output_file', 'kwargs', 'plugin_manager'}
}
create_options_kwargs.update(kwargs)
# plugins and plugin_manager can still be passed alongside OcrOptions
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
parser = get_parser()
with _api_lock:
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
# Use plugins from OcrOptions if not explicitly passed
if plugins is None:
plugins = options.plugins or []
# Get parser and let plugins add their options
parser = get_parser()
plugin_manager.add_options(parser=parser)
if isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins) if plugins else []
if 'verbose' in kwargs:
warn("ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging().")
# Warn about deprecated jbig2 options and remove from kwargs
if jbig2_lossy:
warn(
"jbig2_lossy is deprecated and will be ignored. "
"Lossy JBIG2 has been removed due to character substitution risks."
# Run the pipeline with the OcrOptions
with _api_lock:
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
create_options_kwargs.pop('jbig2_lossy', None)
if jbig2_page_group_size:
warn("jbig2_page_group_size is deprecated and will be ignored.")
create_options_kwargs.pop('jbig2_page_group_size', None)
options = create_options(
input_file=input_file,
output_file=output_file,
parser=parser,
**create_options_kwargs,
)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager)
parser = get_parser()
plugin_manager.add_options(parser=parser)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager)
else:
# Old-style API: positional arguments
input_file = input_file_or_options
if output_file is None:
raise TypeError(
"ocr() missing required argument: 'output_file'. "
"Either pass output_file as the second argument, or pass "
"an OcrOptions object as the first argument."
)
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k
not in {
'input_file_or_options',
'input_file',
'output_file',
'kwargs',
'plugin_manager',
}
}
create_options_kwargs.update(kwargs)
parser = get_parser()
with _api_lock:
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
# Get parser and let plugins add their options
parser = get_parser()
plugin_manager.add_options(parser=parser)
if 'verbose' in kwargs:
warn(
"ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging()."
)
# Warn about deprecated jbig2 options and remove from kwargs
if jbig2_lossy:
warn(
"jbig2_lossy is deprecated and will be ignored. "
"Lossy JBIG2 has been removed due to character substitution risks."
)
create_options_kwargs.pop('jbig2_lossy', None)
if jbig2_page_group_size:
warn("jbig2_page_group_size is deprecated and will be ignored.")
create_options_kwargs.pop('jbig2_page_group_size', None)
options = create_options(
input_file=input_file,
output_file=output_file,
parser=parser,
**create_options_kwargs,
)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager)
def _pdf_to_hocr( # noqa: D417