From c69f2933220cb43e9d6ee78a60c21c4a885870d5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 12 Jan 2026 15:23:08 -0800 Subject: [PATCH] Add --mode/-m CLI argument with ProcessingMode enum Introduce a new --mode (-m) argument that consolidates the three mutually exclusive OCR processing options into a single enum: - default: Error if text is found (standard behavior) - force: Rasterize all content and run OCR (replaces --force-ocr) - skip: Skip pages with existing text (replaces --skip-text) - redo: Re-OCR pages, stripping old text layer (replaces --redo-ocr) The legacy flags --force-ocr, --skip-text, and --redo-ocr remain as silent aliases for backward compatibility. Both CLI and API usage continue to work unchanged. --- src/ocrmypdf/_graft.py | 7 +- src/ocrmypdf/_options.py | 97 +++++++++++++++++---- src/ocrmypdf/_pipeline.py | 50 +++++------ src/ocrmypdf/_validation.py | 4 +- src/ocrmypdf/_validation_coordinator.py | 18 ++-- src/ocrmypdf/api.py | 35 +++++--- src/ocrmypdf/builtin_plugins/ghostscript.py | 11 +-- src/ocrmypdf/cli.py | 29 +++++- tests/test_validation.py | 2 +- 9 files changed, 173 insertions(+), 80 deletions(-) diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index 2ce83fd5..1267cbb9 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -27,6 +27,7 @@ from pikepdf import ( ) from ocrmypdf._jobcontext import PdfContext +from ocrmypdf._options import ProcessingMode from ocrmypdf._pipeline import VECTOR_PAGE_DPI @@ -492,8 +493,8 @@ class OcrGrafter: new_text_layer = Stream(self.pdf_base, pdf_draw_xobj) - # Strip old invisible text if redo_ocr is enabled - if self.context.options.redo_ocr: + # Strip old invisible text if redo mode is enabled + if self.context.options.mode == ProcessingMode.redo: strip_invisible_text(self.pdf_base, base_page) # Add text layer to base page @@ -585,7 +586,7 @@ class OcrGrafter: pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n' new_text_layer = Stream(self.pdf_base, pdf_draw_xobj) - if self.context.options.redo_ocr: + if self.context.options.mode == ProcessingMode.redo: strip_invisible_text(self.pdf_base, base_page) base_page.contents_coalesce() base_page.contents_add( diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index a9281d11..f2ab6552 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -10,6 +10,7 @@ import logging import os import unicodedata from collections.abc import Sequence +from enum import StrEnum from io import IOBase from pathlib import Path from typing import Any, BinaryIO @@ -32,6 +33,23 @@ _plugin_option_models: dict[str, type] = {} PathOrIO = BinaryIO | IOBase | Path | str | bytes +class ProcessingMode(StrEnum): + """OCR processing mode for handling pages with existing text. + + This enum controls how OCRmyPDF handles pages that already contain text: + + - ``default``: Error if text is found (standard OCR behavior) + - ``force``: Rasterize all content and run OCR regardless of existing text + - ``skip``: Skip OCR on pages that already have text + - ``redo``: Re-OCR pages, stripping old invisible text layer + """ + + default = 'default' + force = 'force' + skip = 'skip' + redo = 'redo' + + def _pages_from_ranges(ranges: str) -> set[int]: """Convert page range string to set of page numbers.""" pages: list[int] = [] @@ -89,9 +107,23 @@ class OCROptions(BaseModel): # Core OCR options languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE]) output_type: str = 'auto' - force_ocr: bool = False - skip_text: bool = False - redo_ocr: bool = False + mode: ProcessingMode = ProcessingMode.default + + # Backward compatibility properties for force_ocr, skip_text, redo_ocr + @property + def force_ocr(self) -> bool: + """Backward compatibility alias for mode == ProcessingMode.force.""" + return self.mode == ProcessingMode.force + + @property + def skip_text(self) -> bool: + """Backward compatibility alias for mode == ProcessingMode.skip.""" + return self.mode == ProcessingMode.skip + + @property + def redo_ocr(self) -> bool: + """Backward compatibility alias for mode == ProcessingMode.redo.""" + return self.mode == ProcessingMode.redo # Job control jobs: int | None = None @@ -296,31 +328,58 @@ class OCROptions(BaseModel): @model_validator(mode='before') @classmethod def handle_special_cases(cls, data): - """Handle special cases for API compatibility.""" + """Handle special cases for API compatibility and legacy options.""" 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 + + # Convert legacy boolean options (force_ocr, skip_text, redo_ocr) to mode + force = data.pop('force_ocr', None) + skip = data.pop('skip_text', None) + redo = data.pop('redo_ocr', None) + + # Count how many legacy options are set to True + legacy_set = [ + (force, ProcessingMode.force), + (skip, ProcessingMode.skip), + (redo, ProcessingMode.redo), + ] + legacy_true = [(val, mode) for val, mode in legacy_set if val] + legacy_count = len(legacy_true) + + # Get current mode value (may be string or enum) + current_mode = data.get('mode', ProcessingMode.default) + if isinstance(current_mode, str): + current_mode = ProcessingMode(current_mode) + mode_is_set = current_mode != ProcessingMode.default + + if legacy_count > 1: + raise ValueError( + "Choose only one of --force-ocr, --skip-text, --redo-ocr." + ) + + if legacy_count == 1: + expected_mode = legacy_true[0][1] + if mode_is_set and current_mode != expected_mode: + legacy_flag = f"--{expected_mode.value.replace('_', '-')}-ocr" + raise ValueError( + f"Conflicting options: --mode {current_mode.value} " + f"cannot be used with {legacy_flag} or similar legacy flag." + ) + # Set mode from legacy option + data['mode'] = expected_mode + 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_redo_ocr_options(self): - """Validate options compatible with redo_ocr.""" - if self.redo_ocr: + """Validate options compatible with redo mode.""" + if self.mode == ProcessingMode.redo: if self.deskew or self.clean_final or self.remove_background: raise ValueError( - "--redo-ocr is not currently compatible with --deskew, " - "--clean-final, and --remove-background" + "--redo-ocr (or --mode redo) is not currently compatible with " + "--deskew, --clean-final, and --remove-background" ) return self @@ -345,7 +404,7 @@ class OCROptions(BaseModel): [ self.deskew, self.clean_final, - self.force_ocr, + self.mode == ProcessingMode.force, self.remove_background, ] ) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 5ae76440..75fbb8eb 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -28,7 +28,7 @@ from ocrmypdf._concurrent import Executor from ocrmypdf._exec import unpaper from ocrmypdf._jobcontext import PageContext, PdfContext from ocrmypdf._metadata import repair_docinfo_nuls -from ocrmypdf._options import OCROptions +from ocrmypdf._options import OCROptions, ProcessingMode from ocrmypdf.exceptions import ( DigitalSignatureError, DpiError, @@ -233,10 +233,10 @@ def validate_pdfinfo_options(context: PdfContext) -> None: else: raise DigitalSignatureError() if pdfinfo.has_acroform: - if options.redo_ocr: + if options.mode == ProcessingMode.redo: raise InputFileError( - "This PDF has a user fillable form. --redo-ocr is not " - "currently possible on such files." + "This PDF has a user fillable form. --redo-ocr (or --mode redo) " + "is not currently possible on such files." ) else: log.warning( @@ -244,14 +244,14 @@ def validate_pdfinfo_options(context: PdfContext) -> None: "Chances are it is a pure digital " "document that does not need OCR." ) - if not options.force_ocr: + if options.mode != ProcessingMode.force: log.info( - "Use the option --force-ocr to produce an image of the " - "form and all filled form fields. The output PDF will be " - "'flattened' and will no longer be fillable." + "Use the option --force-ocr (or --mode force) to produce an " + "image of the form and all filled form fields. The output PDF " + "will be 'flattened' and will no longer be fillable." ) if pdfinfo.is_tagged: - if options.force_ocr or options.skip_text or options.redo_ocr: + if options.mode != ProcessingMode.default: log.warning( "This PDF is marked as a Tagged PDF. This often indicates " "that the PDF was generated from an office document and does " @@ -328,24 +328,24 @@ def is_ocr_required(page_context: PageContext) -> bool: log.debug(f"skipped {pageinfo.pageno} as requested by --pages {options.pages}") ocr_required = False elif pageinfo.has_text: - if not options.force_ocr and not (options.skip_text or options.redo_ocr): + if options.mode == ProcessingMode.default: raise PriorOcrFoundError( - "page already has text! - aborting (use --force-ocr to force OCR; " - " see also help for the arguments --skip-text and --redo-ocr" + "page already has text! - aborting (use --force-ocr or --mode force " + "to force OCR; see also help for --skip-text, --redo-ocr, and --mode)" ) - elif options.force_ocr: + elif options.mode == ProcessingMode.force: log.info("page already has text! - rasterizing text and running OCR anyway") ocr_required = True - elif options.redo_ocr: + elif options.mode == ProcessingMode.redo: if pageinfo.has_corrupt_text: log.warning( "some text on this page cannot be mapped to characters: " - "consider using --force-ocr instead" + "consider using --force-ocr (or --mode force) instead" ) else: log.info("redoing OCR") ocr_required = True - elif options.skip_text: + elif options.mode == ProcessingMode.skip: log.info("skipping all processing on this page") ocr_required = False elif not pageinfo.images and not options.lossless_reconstruction: @@ -356,14 +356,14 @@ def is_ocr_required(page_context: PageContext) -> bool: # ahead and rasterize. If not forced, then pretend there's no text # on the page at all so we don't lose anything. # This could be made smarter by explicitly searching for vector art. - if options.force_ocr and options.oversample: + if options.mode == ProcessingMode.force and options.oversample: # The user really wants to reprocess this file log.info( "page has no images - " f"rasterizing at {options.oversample} DPI because " - "--force-ocr --oversample was specified" + "--force-ocr --oversample (or --mode force --oversample) was specified" ) - elif options.force_ocr: + elif options.mode == ProcessingMode.force: # Warn the user they might not want to do this log.warning( "page has no images - " @@ -376,8 +376,8 @@ def is_ocr_required(page_context: PageContext) -> bool: log.info( "page has no images - " "skipping all processing on this page to avoid losing detail. " - "Use --force-ocr if you wish to perform OCR on pages that " - "have vector content." + "Use --force-ocr (or --mode force) if you wish to perform OCR on " + "pages that have vector content." ) ocr_required = False @@ -645,11 +645,11 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path: with Image.open(image) as im: log.debug('resolution %r', im.info['dpi']) - if not options.force_ocr: + if options.mode != ProcessingMode.force: # Do not mask text areas when forcing OCR, because we need to OCR # all text areas mask = None # Exclude both visible and invisible text from OCR - if options.redo_ocr: + if options.mode == ProcessingMode.redo: mask = True # Mask visible text, but not invisible text draw = ImageDraw.ImageDraw(im) @@ -1092,8 +1092,8 @@ def _is_safe_pdfa(input_pdf: Path, options) -> bool: if pdfa_status['pass']: return True - # Safe if we rewrote the PDF with force-ocr - if options.force_ocr: + # Safe if we rewrote the PDF with force mode + if options.mode == ProcessingMode.force: return True return False diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 480d8c4b..0429bd27 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -243,13 +243,15 @@ def report_output_file_size( 'clean_final', 'remove_background', 'oversample', - 'force_ocr', } for arg in image_preproc: if getattr(options, arg, False): reasons.append( f"--{arg.replace('_', '-')} was issued, causing transcoding." ) + # Check force_ocr via the backward-compatible property + if options.force_ocr: + reasons.append("--force-ocr (or --mode force) was issued, causing transcoding.") reasons.extend(optimize_messages) diff --git a/src/ocrmypdf/_validation_coordinator.py b/src/ocrmypdf/_validation_coordinator.py index 34cb65f0..f82eedd2 100644 --- a/src/ocrmypdf/_validation_coordinator.py +++ b/src/ocrmypdf/_validation_coordinator.py @@ -97,22 +97,20 @@ class ValidationCoordinator: def _validate_cross_cutting_concerns(self, options: OCROptions) -> None: """Validate cross-cutting concerns that span multiple plugins.""" + from ocrmypdf._options import ProcessingMode + # Handle deprecated pdf_renderer values self._handle_deprecated_pdf_renderer(options) - # Validate mutually exclusive OCR options - exclusive_options = sum( - 1 for opt in [options.force_ocr, options.skip_text, options.redo_ocr] if opt - ) - if exclusive_options >= 2: - raise ValueError("Choose only one of --force-ocr, --skip-text, --redo-ocr.") + # Note: Mutual exclusivity of force_ocr/skip_text/redo_ocr is now enforced + # by the ProcessingMode enum - only one mode can be active at a time. - # Validate redo_ocr compatibility - if options.redo_ocr: + # Validate redo mode compatibility + if options.mode == ProcessingMode.redo: if options.deskew or options.clean_final or options.remove_background: raise ValueError( - "--redo-ocr is not currently compatible with --deskew, " - "--clean-final, and --remove-background" + "--redo-ocr (or --mode redo) is not currently compatible with " + "--deskew, --clean-final, and --remove-background" ) # Validate output type compatibility diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index d2b9a233..c28b6004 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -269,13 +269,16 @@ def create_options( # Remove any kwargs that aren't OCROptions fields and store in extra_attrs extra_attrs = {} ocr_fields = set(OCROptions.model_fields.keys()) + # Legacy mode flags are handled by OCROptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} # Known extra attributes that should be preserved known_extra = {'progress_bar', 'plugins'} for key in list(options_kwargs.keys()): - if key not in ocr_fields and key not in known_extra: - extra_attrs[key] = options_kwargs.pop(key) + if key in ocr_fields or key in legacy_mode_flags or key in known_extra: + continue + extra_attrs[key] = options_kwargs.pop(key) # Create OCROptions directly try: @@ -311,9 +314,10 @@ def ocr( # noqa: D417 unpaper_args: str | None = None, oversample: int | None = None, remove_vectors: bool | None = None, - force_ocr: bool | None = None, - skip_text: bool | None = None, - redo_ocr: 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 skip_big: float | None = None, optimize: int | None = None, jpg_quality: int | None = None, @@ -478,9 +482,10 @@ def _pdf_to_hocr( # noqa: D417 unpaper_args: str | None = None, oversample: int | None = None, remove_vectors: bool | None = None, - force_ocr: bool | None = None, - skip_text: bool | None = None, - redo_ocr: 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 skip_big: float | None = None, pages: str | None = None, max_image_mpixels: float | None = None, @@ -562,11 +567,14 @@ def _pdf_to_hocr( # noqa: D417 # Remove any kwargs that aren't OCROptions fields and store in extra_attrs extra_attrs = {} ocr_fields = set(OCROptions.model_fields.keys()) + # Legacy mode flags are handled by OCROptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} known_extra = {'progress_bar', 'plugins'} for key in list(options_kwargs.keys()): - if key not in ocr_fields and key not in known_extra: - extra_attrs[key] = options_kwargs.pop(key) + if key in ocr_fields or key in legacy_mode_flags or key in known_extra: + continue + extra_attrs[key] = options_kwargs.pop(key) with _api_lock: # Set up plugin infrastructure with proper initialization @@ -675,11 +683,14 @@ def _hocr_to_ocr_pdf( # noqa: D417 # Remove any kwargs that aren't OCROptions fields and store in extra_attrs extra_attrs = {} ocr_fields = set(OCROptions.model_fields.keys()) + # Legacy mode flags are handled by OCROptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} known_extra = {'progress_bar', 'plugins'} for key in list(options_kwargs.keys()): - if key not in ocr_fields and key not in known_extra: - extra_attrs[key] = options_kwargs.pop(key) + if key in ocr_fields or key in legacy_mode_flags or key in known_extra: + continue + extra_attrs[key] = options_kwargs.pop(key) with _api_lock: # Set up plugin infrastructure with proper initialization diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 40abf682..9e132caf 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -15,6 +15,7 @@ from pydantic import BaseModel, Field from ocrmypdf import hookimpl from ocrmypdf._exec import ghostscript +from ocrmypdf._options import ProcessingMode from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.subprocess import check_external_program @@ -117,15 +118,15 @@ def check_options(options): "supported. Please upgrade to a newer version." ) if Version('10.0.0') <= gs_version < Version('10.02.1') and ( - options.skip_text or options.redo_ocr + options.mode in (ProcessingMode.skip, ProcessingMode.redo) ): raise MissingDependencyError( f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) " "contain serious regressions that corrupt PDFs with existing text, " - "such as those processed using --skip-text or --redo-ocr. " - "Please upgrade to a " - "newer version, or use --output-type pdf to avoid Ghostscript, or " - "use --force-ocr to discard existing text." + "such as those processed using --skip-text or --redo-ocr " + "(or --mode skip/redo). Please upgrade to a newer version, or use " + "--output-type pdf to avoid Ghostscript, or use --force-ocr " + "(or --mode force) to discard existing text." ) if gs_version >= Version('10.6.0'): log.warning( diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 0361a981..df2d694a 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -12,7 +12,7 @@ from typing import Any, TypeVar from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME -from ocrmypdf._options import OCROptions +from ocrmypdf._options import OCROptions, ProcessingMode from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf._version import __version__ as _VERSION @@ -308,12 +308,25 @@ Online documentation is located at: ) ocrsettings = parser.add_argument_group("OCR options", "Control how OCR is applied") + ocrsettings.add_argument( + '-m', + '--mode', + choices=[mode.value for mode in ProcessingMode], + default=ProcessingMode.default.value, + help="Processing mode for pages with existing text. " + "'default' errors if text is found. " + "'force' rasterizes all content and runs OCR (same as --force-ocr). " + "'skip' skips pages with existing text (same as --skip-text). " + "'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr).", + ) + # Legacy flags for backward compatibility - these set the mode internally ocrsettings.add_argument( '-f', '--force-ocr', action='store_true', help="Rasterize any text or vector objects on each page, apply OCR, and " - "save the rastered output (this rewrites the PDF)", + "save the rastered output (this rewrites the PDF). " + "Equivalent to --mode force.", ) ocrsettings.add_argument( '-s', @@ -321,7 +334,8 @@ Online documentation is located at: action='store_true', help="Skip OCR on any pages that already contain text, but include the " "page in final output; useful for PDFs that contain a mix of " - "images, text pages, and/or previously OCRed pages", + "images, text pages, and/or previously OCRed pages. " + "Equivalent to --mode skip.", ) ocrsettings.add_argument( '--redo-ocr', @@ -329,7 +343,8 @@ Online documentation is located at: help="Attempt to detect and remove the hidden OCR layer from files that " "were previously OCRed with OCRmyPDF or another program. Apply OCR " "to text found in raster images. Existing visible text objects will " - "not be changed. If there is no existing OCR, OCR will be added.", + "not be changed. If there is no existing OCR, OCR will be added. " + "Equivalent to --mode redo.", ) ocrsettings.add_argument( '--skip-big', @@ -468,9 +483,15 @@ def namespace_to_options(ns) -> OCROptions: known_fields = {} extra_attrs = {} + # Legacy boolean flags that map to mode - handled by OCROptions model validator + legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'} + for key, value in vars(ns).items(): if key in OCROptions.model_fields: known_fields[key] = value + elif key in legacy_mode_flags: + # Pass legacy flags to OCROptions for conversion to mode + known_fields[key] = value else: extra_attrs[key] = value diff --git a/tests/test_validation.py b/tests/test_validation.py index e1dbea99..038da945 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -68,7 +68,7 @@ def test_tesseract_not_installed(caplog): def test_lossless_redo(): - with pytest.raises(ValueError, match="--redo-ocr is not currently compatible"): + with pytest.raises(ValueError, match="--redo-ocr.*is not currently compatible"): make_ocr_opts(redo_ocr=True, deskew=True)