From 0c3745a1a4b9f14250d05c3593a15306a4520081 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 10 Jan 2026 02:23:52 -0800 Subject: [PATCH] Add OCR engine selection framework and null OCR engine Introduce --ocr-engine option to select between OCR engines: - 'auto' (default): Uses Tesseract - 'tesseract': Explicit Tesseract selection - 'none': Skip OCR entirely (for PDF processing only) Key changes: - Extend OcrEngine ABC with generate_ocr() and supports_generate_ocr() for direct OcrElement tree output (bypasses hOCR) - Add get_ocr_engine(options) hook parameter for engine selection - Implement NullOcrEngine for --ocr-engine none - Export OcrElement, OcrClass, BoundingBox from ocrmypdf package - Add ocr_tree support to grafting pipeline This prepares the foundation for pluggable OCR engines while maintaining full backward compatibility with existing Tesseract-based workflows. --- src/ocrmypdf/__init__.py | 12 ++ src/ocrmypdf/_graft.py | 65 +++++-- src/ocrmypdf/_jobcontext.py | 10 +- src/ocrmypdf/_metadata.py | 4 +- src/ocrmypdf/_options.py | 1 + src/ocrmypdf/_pipeline.py | 49 ++++- src/ocrmypdf/_pipelines/_common.py | 11 +- src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py | 1 + src/ocrmypdf/_pipelines/ocr.py | 19 +- src/ocrmypdf/_plugin_manager.py | 10 +- src/ocrmypdf/_validation.py | 6 +- src/ocrmypdf/builtin_plugins/null_ocr.py | 159 ++++++++++++++++ src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 10 +- src/ocrmypdf/cli.py | 9 + src/ocrmypdf/pluginspec.py | 56 +++++- tests/test_api.py | 2 +- tests/test_null_ocr_engine.py | 169 ++++++++++++++++++ tests/test_ocr_engine_interface.py | 131 ++++++++++++++ tests/test_ocr_engine_selection.py | 139 ++++++++++++++ tests/test_pipeline_generate_ocr.py | 103 +++++++++++ 20 files changed, 922 insertions(+), 44 deletions(-) create mode 100644 src/ocrmypdf/builtin_plugins/null_ocr.py create mode 100644 tests/test_null_ocr_engine.py create mode 100644 tests/test_ocr_engine_interface.py create mode 100644 tests/test_ocr_engine_selection.py create mode 100644 tests/test_pipeline_generate_ocr.py diff --git a/src/ocrmypdf/__init__.py b/src/ocrmypdf/__init__.py index 2ca4aff9..fda3b8f7 100644 --- a/src/ocrmypdf/__init__.py +++ b/src/ocrmypdf/__init__.py @@ -34,6 +34,13 @@ from ocrmypdf.exceptions import ( TesseractConfigError, UnsupportedImageFormatError, ) +from ocrmypdf.hocrtransform import ( + Baseline, + BoundingBox, + FontInfo, + OcrClass, + OcrElement, +) from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence hookimpl = _HookimplMarker('ocrmypdf') @@ -41,6 +48,8 @@ hookimpl = _HookimplMarker('ocrmypdf') __all__ = [ '__version__', 'BadArgsError', + 'Baseline', + 'BoundingBox', 'configure_debug_logging', 'configure_logging', 'DpiError', @@ -48,12 +57,15 @@ __all__ = [ 'Executor', 'ExitCode', 'ExitCodeException', + 'FontInfo', 'helpers', 'hocrtransform', 'hookimpl', 'InputFileError', 'MissingDependencyError', 'ocr', + 'OcrClass', + 'OcrElement', 'OcrEngine', 'OrientationConfidence', 'OutputFileAccessError', diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index 2362be35..2ce83fd5 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -63,6 +63,10 @@ class Fpdf2ParsedPage: emplaced_page: bool +# Alias for backward compatibility with plan documentation +Fpdf2DirectPage = Fpdf2ParsedPage + + def _compute_text_misalignment( content_rotation: int, autorotate_correction: int, emplaced_page: bool ) -> int: @@ -221,7 +225,8 @@ class OcrGrafter: self.use_sandwich_renderer = pdf_renderer == 'sandwich' # For fpdf2: accumulate pages before rendering - self.fpdf2_renderer_pages: list[Fpdf2PageInfo] = [] + self.fpdf2_hocr_pages: list[Fpdf2PageInfo] = [] + self.fpdf2_parsed_pages: list[Fpdf2ParsedPage] = [] def graft_page( self, @@ -229,6 +234,7 @@ class OcrGrafter: pageno: int, image: Path | None, ocr_output: Path | None, + ocr_tree: OcrElement | None, autorotate_correction: int, ): """Graft OCR output onto a page of the base PDF. @@ -238,8 +244,13 @@ class OcrGrafter: image: Path to the visible page image PDF, or None if not replacing. ocr_output: Path to OCR output file. For fpdf2 renderer this is an hOCR file; for sandwich renderer this is a text-only PDF. + ocr_tree: OCR tree for fpdf2 renderer. autorotate_correction: Orientation correction in degrees (0, 90, 180, 270). """ + if ocr_output and ocr_tree: + raise ValueError( + 'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer' + ) # Handle image emplacement first emplaced_page = False content_rotation = self.pdfinfo[pageno].rotation @@ -279,41 +290,57 @@ class OcrGrafter: # The hOCR coordinates are in the corrected (upright) coordinate system. # We store autorotate_correction and emplaced_page to set the final # page /Rotate tag after grafting. - if ocr_output: - dpi = self.pdfinfo[pageno].dpi.to_scalar() - self.fpdf2_renderer_pages.append( - Fpdf2PageInfo( + if ocr_tree: + self.fpdf2_parsed_pages.append( + Fpdf2ParsedPage( + ocr_tree=ocr_tree, pageno=pageno, - hocr_path=ocr_output, - dpi=dpi, autorotate_correction=autorotate_correction, emplaced_page=emplaced_page, + dpi=self.pdfinfo[pageno].dpi.to_scalar(), + ) + ) + if ocr_output: + self.fpdf2_hocr_pages.append( + Fpdf2PageInfo( + hocr_path=ocr_output, + pageno=pageno, + autorotate_correction=autorotate_correction, + emplaced_page=emplaced_page, + dpi=self.pdfinfo[pageno].dpi.to_scalar(), ) ) def finalize(self): - if self.fpdf2_renderer_pages: + # Can have hocr OR parsed pages OR neither (no OCR), but not both + assert not (self.fpdf2_hocr_pages and self.fpdf2_parsed_pages), ( + "Can't have both hocr and ocrtree pages" + ) + + if self.fpdf2_hocr_pages: # Render all pages with fpdf2, then graft + parsed_pages = self._parse_hocr_pages() + self.fpdf2_parsed_pages = parsed_pages + + if self.fpdf2_parsed_pages: self._render_and_graft_fpdf2_pages() self.pdf_base.save(self.output_file) self.pdf_base.close() return self.output_file - def _render_and_graft_fpdf2_pages(self): + def _parse_hocr_pages(self): """Render all pages to multi-page PDF with shared fonts, then graft.""" from ocrmypdf.hocrtransform.hocr_parser import HocrParser log.info( - "Rendering %d pages with fpdf2", - len(self.fpdf2_renderer_pages), + "Parsing %d pages with HocrParser", + len(self.fpdf2_hocr_pages), ) - font_dir = Path(__file__).parent / "data" - # Parse all hOCR files and collect OcrElements pages_data: list[Fpdf2ParsedPage] = [] - for page_info in self.fpdf2_renderer_pages: + for page_info in self.fpdf2_hocr_pages: if page_info.hocr_path.stat().st_size == 0: continue # Skip empty pages @@ -334,8 +361,10 @@ class OcrGrafter: ) ) - if not pages_data: - return # No pages to render + return pages_data + + def _render_and_graft_fpdf2_pages(self): + font_dir = Path(__file__).parent / "data" # Render all pages to single PDF multi_page_pdf_path = self.context.get_path('fpdf2_multipage.pdf') @@ -346,7 +375,7 @@ class OcrGrafter: multi_font_manager = MultiFontManager(font_dir) # Build renderer input as (pageno, ocr_tree, dpi) tuples renderer_pages_data = [ - (parsed.pageno, parsed.ocr_tree, parsed.dpi) for parsed in pages_data + (parsed.pageno, parsed.ocr_tree, parsed.dpi) for parsed in self.fpdf2_parsed_pages ] renderer = Fpdf2MultiPageRenderer( pages_data=renderer_pages_data, @@ -358,7 +387,7 @@ class OcrGrafter: # Now graft each page from the multi-page PDF with Pdf.open(multi_page_pdf_path) as pdf_text: - for idx, parsed in enumerate(pages_data): + for idx, parsed in enumerate(self.fpdf2_parsed_pages): # Copy page from multi-page PDF text_page = pdf_text.pages[idx] diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index 10a51f42..c680cc7c 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -7,13 +7,15 @@ from __future__ import annotations from collections.abc import Iterator from pathlib import Path - -from pluggy import PluginManager +from typing import TYPE_CHECKING from ocrmypdf._options import OCROptions from ocrmypdf.pdfinfo import PdfInfo from ocrmypdf.pdfinfo.info import PageInfo +if TYPE_CHECKING: + from ocrmypdf._plugin_manager import OcrmypdfPluginManager + class PdfContext: """Holds the context for a particular run of the pipeline.""" @@ -21,7 +23,7 @@ class PdfContext: options: OCROptions #: The specified options for processing this PDF. origin: Path #: The filename of the original input file. pdfinfo: PdfInfo #: Detailed data for this PDF. - plugin_manager: PluginManager #: PluginManager for processing the current PDF. + plugin_manager: OcrmypdfPluginManager #: PluginManager for processing the current PDF. def __init__( self, @@ -70,7 +72,7 @@ class PageContext: origin: Path #: The filename of the original input file. pageno: int #: This page number (zero-based). pageinfo: PageInfo #: Information on this page. - plugin_manager: PluginManager #: PluginManager for processing the current PDF. + plugin_manager: OcrmypdfPluginManager #: PluginManager for processing the current PDF. def __init__(self, pdf_context: PdfContext, pageno): self.work_folder = pdf_context.work_folder diff --git a/src/ocrmypdf/_metadata.py b/src/ocrmypdf/_metadata.py index 4416b013..cca7d49f 100644 --- a/src/ocrmypdf/_metadata.py +++ b/src/ocrmypdf/_metadata.py @@ -47,7 +47,9 @@ def get_docinfo(base_pdf: Pdf, context: PdfContext) -> dict[str, str]: if options.subject: pdfmark['/Subject'] = options.subject - creator_tag = context.plugin_manager.get_ocr_engine().creator_tag(options) + creator_tag = context.plugin_manager.get_ocr_engine( + options=options + ).creator_tag(options) pdfmark['/Creator'] = f'{PROGRAM_NAME} {OCRMYPF_VERSION} / {creator_tag}' pdfmark['/Producer'] = f'pikepdf {PIKEPDF_VERSION}' diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index aec663fb..a9281d11 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -145,6 +145,7 @@ class OCROptions(BaseModel): # Advanced options max_image_mpixels: float = 250.0 pdf_renderer: str = 'auto' + ocr_engine: str = 'auto' rasterizer: str = 'auto' rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD user_words: os.PathLike | None = None diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 33781a93..5ae76440 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -15,7 +15,10 @@ from contextlib import suppress from io import BytesIO from pathlib import Path from shutil import copyfileobj -from typing import Any, BinaryIO, TypeVar, cast +from typing import TYPE_CHECKING, Any, BinaryIO, TypeVar, cast + +if TYPE_CHECKING: + from ocrmypdf.hocrtransform import OcrElement import img2pdf import pikepdf @@ -457,9 +460,10 @@ def get_orientation_correction(preview: Path, page_context: PageContext) -> int: which points it (hopefully) upright. _graft.py takes care of the orienting the image and text layers. """ - orient_conf = page_context.plugin_manager.get_ocr_engine().get_orientation( - preview, page_context.options + ocr_engine = page_context.plugin_manager.get_ocr_engine( + options=page_context.options ) + orient_conf = ocr_engine.get_orientation(preview, page_context.options) correction = orient_conf.angle % 360 log.info(describe_rotation(page_context, orient_conf, correction)) @@ -600,7 +604,9 @@ def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path: output_file = page_context.get_path('pp_deskew.png') dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context)) - ocr_engine = page_context.plugin_manager.get_ocr_engine() + ocr_engine = page_context.plugin_manager.get_ocr_engine( + options=page_context.options + ) deskew_angle_degrees = ocr_engine.get_deskew(input_file, page_context.options) with Image.open(input_file) as im: @@ -683,7 +689,7 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, hocr_text_out = page_context.get_path('ocr_hocr.txt') options = page_context.options - ocr_engine = page_context.plugin_manager.get_ocr_engine() + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) ocr_engine.generate_hocr( input_file=input_file, output_hocr=hocr_out, @@ -693,6 +699,37 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, return hocr_out, hocr_text_out +def ocr_engine_direct( + input_file: Path, page_context: PageContext +) -> tuple[OcrElement, Path]: + """Run the OCR engine and return OcrElement tree directly. + + This is the modern path for OCR engines that support the generate_ocr() API. + It bypasses hOCR file generation for better performance and richer data. + + Args: + input_file: The image file to OCR. + page_context: The page context with options and path utilities. + + Returns: + A tuple of (OcrElement tree, path to text sidecar file). + """ + text_out = page_context.get_path('ocr_direct.txt') + options = page_context.options + + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) + ocr_tree, text_content = ocr_engine.generate_ocr( + input_file=input_file, + options=options, + page_number=page_context.pageno, + ) + + # Write text sidecar file + text_out.write_text(text_content, encoding='utf-8') + + return ocr_tree, text_out + + def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool: """Determines whether the visible page image should be saved as a JPEG. @@ -784,7 +821,7 @@ def ocr_engine_textonly_pdf( output_text = page_context.get_path('ocr_tess.txt') options = page_context.options - ocr_engine = page_context.plugin_manager.get_ocr_engine() + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) ocr_engine.generate_pdf( input_file=input_image, output_pdf=output_pdf, diff --git a/src/ocrmypdf/_pipelines/_common.py b/src/ocrmypdf/_pipelines/_common.py index 7df92ca7..7dff4844 100644 --- a/src/ocrmypdf/_pipelines/_common.py +++ b/src/ocrmypdf/_pipelines/_common.py @@ -16,7 +16,10 @@ from concurrent.futures.thread import BrokenThreadPool from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import NamedTuple, cast +from typing import TYPE_CHECKING, NamedTuple, cast + +if TYPE_CHECKING: + from ocrmypdf.hocrtransform import OcrElement import PIL import PIL.Image @@ -107,6 +110,9 @@ class PageResult(NamedTuple): orientation_correction: int = 0 """Orientation correction in degrees.""" + ocr_tree: OcrElement | None = None + """Direct OcrElement tree (when using generate_ocr() API).""" + class HOCRResultEncoder(json.JSONEncoder): def default(self, obj): @@ -144,6 +150,9 @@ class HOCRResult: orientation_correction: int = 0 """Orientation correction in degrees.""" + ocr_tree: OcrElement | None = None + """Direct OcrElement tree (when using generate_ocr() API).""" + @classmethod def from_json(cls, json_str: str) -> HOCRResult: """Create an instance from a dict.""" diff --git a/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py b/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py index cc613b23..ded75f98 100644 --- a/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py +++ b/src/ocrmypdf/_pipelines/hocr_to_ocr_pdf.py @@ -68,6 +68,7 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st pageno=result.pageno, image=result.pdf_page_from_image, ocr_output=result.textpdf, + ocr_tree=result.ocr_tree, autorotate_correction=result.orientation_correction, ) pbar.update() diff --git a/src/ocrmypdf/_pipelines/ocr.py b/src/ocrmypdf/_pipelines/ocr.py index 616889cb..7ebccfbb 100644 --- a/src/ocrmypdf/_pipelines/ocr.py +++ b/src/ocrmypdf/_pipelines/ocr.py @@ -23,6 +23,7 @@ from ocrmypdf._pipeline import ( copy_final, is_ocr_required, merge_sidecars, + ocr_engine_direct, ocr_engine_hocr, ocr_engine_textonly_pdf, triage, @@ -49,27 +50,31 @@ from ocrmypdf._validation import ( ) from ocrmypdf.exceptions import ExitCode from ocrmypdf.helpers import available_cpu_count +from ocrmypdf.hocrtransform.ocr_element import OcrElement log = logging.getLogger(__name__) def _image_to_ocr_text( page_context: PageContext, ocr_image_out: Path -) -> tuple[Path, Path]: +) -> tuple[Path | None, Path, OcrElement | None]: """Run OCR engine on image to create OCR PDF and text file.""" options = page_context.options pdf_renderer = options.pdf_renderer # fpdf2 is the default renderer (auto resolves to fpdf2) if pdf_renderer in ('auto', 'fpdf2'): - # fpdf2 renderer uses hOCR as intermediate format. - # The hOCR is passed to the grafting phase where fpdf2 renders it in batch. + # Use generate_ocr() if the engine supports it, otherwise use hOCR path + ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options) + if ocr_engine and ocr_engine.supports_generate_ocr(): + ocr_tree, text_out = ocr_engine_direct(ocr_image_out, page_context) + return None, text_out, ocr_tree ocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context) elif pdf_renderer == 'sandwich': ocr_out, text_out = ocr_engine_textonly_pdf(ocr_image_out, page_context) else: raise NotImplementedError(f"pdf_renderer {pdf_renderer}") - return ocr_out, text_out + return ocr_out, text_out, None def _exec_page_sync(page_context: PageContext) -> PageResult: @@ -82,13 +87,14 @@ def _exec_page_sync(page_context: PageContext) -> PageResult: ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page( page_context ) - ocr_out, text_out = _image_to_ocr_text(page_context, ocr_image_out) + ocr_out, text_out, ocr_tree = _image_to_ocr_text(page_context, ocr_image_out) return PageResult( pageno=page_context.pageno, pdf_page_from_image=pdf_page_from_image_out, ocr=ocr_out, text=text_out, orientation_correction=orientation_correction, + ocr_tree=ocr_tree, ) @@ -113,6 +119,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: pageno=result.pageno, image=result.pdf_page_from_image, ocr_output=result.ocr, + ocr_tree=result.ocr_tree, autorotate_correction=result.orientation_correction, ) pbar.update(0.5) @@ -124,7 +131,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]: max_workers=max_workers, progress_kwargs=dict( total=len(context.pdfinfo), - desc='OCR' if options.tesseract.timeout > 0 else 'Image processing', + desc='OCR' if options.ocr_engine != 'none' else 'Image processing', unit='page', disable=not options.progress_bar, ), diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index a273202f..a896c8dc 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -178,9 +178,13 @@ class OcrmypdfPluginManager: page=page, image_filename=image_filename, output_pdf=output_pdf ) - def get_ocr_engine(self) -> OcrEngine | None: - """Returns an OcrEngine to use for processing.""" - return self._pm.hook.get_ocr_engine() + def get_ocr_engine(self, *, options: OCROptions | None = None) -> OcrEngine | None: + """Returns an OcrEngine to use for processing. + + Args: + options: OCROptions to pass to the hook for engine selection. + """ + return self._pm.hook.get_ocr_engine(options=options) def generate_pdfa( self, diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index fe5df859..480d8c4b 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -16,9 +16,9 @@ from shutil import copyfileobj import pikepdf from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD -from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf._exec import unpaper from ocrmypdf._options import OCROptions +from ocrmypdf._plugin_manager import OcrmypdfPluginManager from ocrmypdf.exceptions import ( BadArgsError, InputFileError, @@ -127,7 +127,9 @@ def _check_plugin_options( plugin_manager.check_options(options=options) # Then check OCR engine language support - ocr_engine_languages = plugin_manager.get_ocr_engine().languages(options) + ocr_engine_languages = plugin_manager.get_ocr_engine(options=options).languages( + options + ) check_options_languages(options, ocr_engine_languages) # Finally, run comprehensive validation using the coordinator diff --git a/src/ocrmypdf/builtin_plugins/null_ocr.py b/src/ocrmypdf/builtin_plugins/null_ocr.py new file mode 100644 index 00000000..409f6f71 --- /dev/null +++ b/src/ocrmypdf/builtin_plugins/null_ocr.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Built-in plugin implementing a null OCR engine (no OCR). + +This plugin provides an OCR engine that produces no text output. It is useful +when users want OCRmyPDF's image processing, PDF/A conversion, or optimization +features without performing actual OCR. + +Usage: + ocrmypdf --ocr-engine none input.pdf output.pdf +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from PIL import Image + +from ocrmypdf import hookimpl +from ocrmypdf.hocrtransform import BoundingBox, OcrClass, OcrElement +from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence + +if TYPE_CHECKING: + from ocrmypdf._options import OCROptions + + +class NullOcrEngine(OcrEngine): + """A no-op OCR engine that produces no text output. + + Use this when you want OCRmyPDF's image processing, PDF/A conversion, + or optimization features without performing actual OCR. + """ + + @staticmethod + def version() -> str: + """Return version string.""" + return "none" + + @staticmethod + def creator_tag(options: OCROptions) -> str: + """Return creator tag for PDF metadata.""" + return "OCRmyPDF (no OCR)" + + def __str__(self) -> str: + """Return human-readable engine name.""" + return "No OCR engine" + + @staticmethod + def languages(options: OCROptions) -> set[str]: + """Return supported languages (empty set for null engine).""" + return set() + + @staticmethod + def get_orientation(input_file: Path, options: OCROptions) -> OrientationConfidence: + """Return neutral orientation (no rotation detected).""" + return OrientationConfidence(angle=0, confidence=0.0) + + @staticmethod + def get_deskew(input_file: Path, options: OCROptions) -> float: + """Return zero deskew angle.""" + return 0.0 + + @staticmethod + def supports_generate_ocr() -> bool: + """Return True - this engine supports the generate_ocr() API.""" + return True + + @staticmethod + def generate_ocr( + input_file: Path, + options: OCROptions, + page_number: int = 0, + ) -> tuple[OcrElement, str]: + """Generate empty OCR results. + + Args: + input_file: The image file (used to get dimensions). + options: OCR options (ignored). + page_number: Page number (stored in result). + + Returns: + A tuple of (empty OcrElement page, empty string). + """ + # Get image dimensions + with Image.open(input_file) as img: + width, height = img.size + dpi_info = img.info.get('dpi', (72, 72)) + dpi = dpi_info[0] if isinstance(dpi_info, tuple) else dpi_info + + # Create empty page element with correct dimensions + page = OcrElement( + ocr_class=OcrClass.PAGE, + bbox=BoundingBox(left=0, top=0, right=width, bottom=height), + dpi=float(dpi), + page_number=page_number, + ) + + return page, "" + + @staticmethod + def generate_hocr( + input_file: Path, + output_hocr: Path, + output_text: Path, + options: OCROptions, + ) -> None: + """Generate empty hOCR file. + + Creates minimal valid hOCR output with no text content. + """ + # Get image dimensions for hOCR bbox + with Image.open(input_file) as img: + width, height = img.size + + hocr_content = f''' + + + + OCRmyPDF - No OCR + + + + +
+
+ + +''' + output_hocr.write_text(hocr_content, encoding='utf-8') + output_text.write_text('', encoding='utf-8') + + @staticmethod + def generate_pdf( + input_file: Path, + output_pdf: Path, + output_text: Path, + options: OCROptions, + ) -> None: + """NullOcrEngine cannot generate PDFs directly. + + Use pdf_renderer='fpdf2' instead of 'sandwich'. + """ + raise NotImplementedError( + "NullOcrEngine cannot generate PDFs directly. " + "Use --pdf-renderer fpdf2 instead of sandwich mode." + ) + + +@hookimpl +def get_ocr_engine(options): + """Return NullOcrEngine when --ocr-engine none is selected.""" + if options is not None: + ocr_engine = getattr(options, 'ocr_engine', 'auto') + if ocr_engine != 'none': + return None + return NullOcrEngine() diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index c9ae6aea..9d3d22da 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -366,6 +366,8 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image: those limits. """ options = page.options + if getattr(options, 'tesseract', None) is None: + return image threshold = min(options.tesseract.downsample_above, 32767) if options.tesseract.downsample_large_images: @@ -465,5 +467,11 @@ class TesseractOcrEngine(OcrEngine): @hookimpl -def get_ocr_engine(): +def get_ocr_engine(options): + """Return TesseractOcrEngine when selected or as default.""" + if options is not None: + ocr_engine = getattr(options, 'ocr_engine', 'auto') + # Tesseract is selected if explicitly requested or if 'auto' + if ocr_engine not in ('auto', 'tesseract'): + return None return TesseractOcrEngine() diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 97ca156a..0361a981 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -376,6 +376,15 @@ Online documentation is located at: "selected. 'sandwich' renders text as a background layer. Legacy 'hocr' " "and 'hocrdebug' options are deprecated and will use fpdf2.", ) + advanced.add_argument( + '--ocr-engine', + choices=['auto', 'tesseract', 'none'], + default='auto', + help="OCR engine to use. 'auto' (default) selects the best available engine. " + "'tesseract' uses Tesseract OCR. " + "'none' skips OCR entirely, useful for PDF/A conversion or image processing " + "without text recognition.", + ) advanced.add_argument( '--rasterizer', choices=['auto', 'ghostscript', 'pypdfium'], diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 4f796f78..6b366fe2 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -25,6 +25,7 @@ if TYPE_CHECKING: # pylint: disable=ungrouped-imports from ocrmypdf._jobcontext import PageContext + from ocrmypdf.hocrtransform import OcrElement from ocrmypdf.pdfinfo import PdfInfo # pylint: enable=ungrouped-imports @@ -484,14 +485,67 @@ class OcrEngine(ABC): options: The command line options. """ + @staticmethod + def supports_generate_ocr() -> bool: + """Return True if this engine supports the generate_ocr() API. + + The pipeline uses this to determine whether to call generate_ocr() + or fall back to generate_hocr(). + + Returns: + False by default. Engines implementing generate_ocr() should + override this to return True. + """ + return False + + @staticmethod + def generate_ocr( + input_file: Path, + options: OCROptions, + page_number: int = 0, + ) -> tuple[OcrElement, str]: + """Generate OCR results as an OcrElement tree. + + This is the modern API for OCR engines. Engines implementing this method + can return structured OCR results directly without intermediate file formats. + + This function executes in a worker thread or worker process. OCRmyPDF + automatically parallelizes OCR over pages. The OCR engine should not + introduce more parallelism. + + Args: + input_file: A page image on which to perform OCR. + options: The command line options. + page_number: Zero-indexed page number (for multi-page context). + + Returns: + A tuple of (OcrElement tree for the page, plain text content). + The OcrElement should have ocr_class=OcrClass.PAGE as its root. + + Note: + This method is optional. Engines that don't implement it should + leave the default implementation, and the pipeline will fall back to + generate_hocr() or generate_pdf(). + """ + raise NotImplementedError("This OcrEngine does not implement generate_ocr()") + @hookspec(firstresult=True) -def get_ocr_engine() -> OcrEngine: # type: ignore[return-value] +def get_ocr_engine(options: OCROptions | None) -> OcrEngine: # type: ignore[return-value] """Returns an OcrEngine to use for processing this file. The OcrEngine may be instantiated multiple times, by both the main process and child process. + When multiple OCR engine plugins are installed, plugins should check + ``options.ocr_engine`` and return ``None`` if they are not the selected + engine. The hook caller will then try the next plugin. + + Args: + options: The current OCROptions, used to determine which engine + to select. May be None for backward compatibility with external + plugins. + Note: This is a :ref:`firstresult hook`. """ diff --git a/tests/test_api.py b/tests/test_api.py index ad7b0936..86c8f688 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -89,7 +89,7 @@ def test_hocr_result_json(): assert ( result.to_json() == '{"pageno": 1, "pdf_page_from_image": {"Path": "a"}, "hocr": {"Path": "b"}, ' - '"textpdf": {"Path": "c"}, "orientation_correction": 180}' + '"textpdf": {"Path": "c"}, "orientation_correction": 180, "ocr_tree": null}' ) assert ocrmypdf._pipelines._common.HOCRResult.from_json(result.to_json()) == result diff --git a/tests/test_null_ocr_engine.py b/tests/test_null_ocr_engine.py new file mode 100644 index 00000000..7798ccce --- /dev/null +++ b/tests/test_null_ocr_engine.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for NullOcrEngine (--ocr-engine none). + +Tests verify that the Null OCR engine exists and functions correctly +for scenarios where users want PDF processing without OCR. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +class TestNullOcrEngineExists: + """Test that NullOcrEngine plugin exists and is loadable.""" + + def test_null_ocr_module_importable(self): + """null_ocr module should be importable.""" + from ocrmypdf.builtin_plugins import null_ocr + + assert null_ocr is not None + + def test_null_ocr_engine_class_exists(self): + """NullOcrEngine class should exist.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + assert NullOcrEngine is not None + + +class TestNullOcrEngineInterface: + """Test NullOcrEngine implements OcrEngine interface.""" + + def test_version_returns_none(self): + """NullOcrEngine.version() should return 'none'.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + assert NullOcrEngine.version() == "none" + + def test_creator_tag(self): + """NullOcrEngine.creator_tag() should indicate no OCR.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + tag = NullOcrEngine.creator_tag(MagicMock()) + tag_lower = tag.lower() + assert "no ocr" in tag_lower or "null" in tag_lower or "none" in tag_lower + + def test_languages_returns_empty_set(self): + """NullOcrEngine.languages() should return empty set.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + langs = NullOcrEngine.languages(MagicMock()) + assert langs == set() + + def test_supports_generate_ocr_returns_true(self): + """NullOcrEngine should support generate_ocr().""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + assert NullOcrEngine.supports_generate_ocr() is True + + def test_get_orientation_returns_zero(self): + """NullOcrEngine.get_orientation() should return angle=0.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + result = NullOcrEngine.get_orientation(Path("test.png"), MagicMock()) + assert result.angle == 0 + + def test_get_deskew_returns_zero(self): + """NullOcrEngine.get_deskew() should return 0.0.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + result = NullOcrEngine.get_deskew(Path("test.png"), MagicMock()) + assert result == 0.0 + + +class TestNullOcrEngineGenerateOcr: + """Test NullOcrEngine.generate_ocr() output.""" + + @pytest.fixture + def sample_image(self, tmp_path): + """Create a simple test image.""" + from PIL import Image + + img_path = tmp_path / "test.png" + img = Image.new('RGB', (100, 100), color='white') + img.save(img_path, dpi=(300, 300)) + return img_path + + def test_generate_ocr_returns_tuple(self, sample_image): + """generate_ocr() should return (OcrElement, str) tuple.""" + from ocrmypdf import OcrElement + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + result = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + assert isinstance(result, tuple) + assert len(result) == 2 + assert isinstance(result[0], OcrElement) + assert isinstance(result[1], str) + + def test_generate_ocr_returns_empty_text(self, sample_image): + """generate_ocr() should return empty text string.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + _, text = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + assert text == "" + + def test_generate_ocr_returns_page_element(self, sample_image): + """generate_ocr() should return OcrElement with ocr_class PAGE.""" + from ocrmypdf import OcrClass + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + ocr_tree, _ = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + assert ocr_tree.ocr_class == OcrClass.PAGE + + def test_generate_ocr_page_has_correct_dimensions(self, sample_image): + """generate_ocr() page element should have image dimensions.""" + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + + ocr_tree, _ = NullOcrEngine.generate_ocr(sample_image, MagicMock(), 0) + + # Image is 100x100 + assert ocr_tree.bbox.right == 100 + assert ocr_tree.bbox.bottom == 100 + + +class TestOcrEngineOption: + """Test --ocr-engine CLI option.""" + + def test_ocr_engine_option_accepted(self): + """CLI should accept --ocr-engine option.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + # Should not raise + args = parser.parse_args(['--ocr-engine', 'none', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'none' + + def test_ocr_engine_choices_include_none(self): + """--ocr-engine should include 'none' as a choice.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + # Find the --ocr-engine action + for action in parser._actions: + if '--ocr-engine' in action.option_strings: + assert 'none' in action.choices + break + else: + pytest.fail("--ocr-engine option not found") + + def test_ocr_engine_choices_include_auto(self): + """--ocr-engine should include 'auto' as default.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + for action in parser._actions: + if '--ocr-engine' in action.option_strings: + assert 'auto' in action.choices + assert action.default == 'auto' + break diff --git a/tests/test_ocr_engine_interface.py b/tests/test_ocr_engine_interface.py new file mode 100644 index 00000000..7ec66d53 --- /dev/null +++ b/tests/test_ocr_engine_interface.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for OcrEngine interface extensions. + +These tests verify that the OcrEngine ABC has the new generate_ocr() method +and that OcrElement classes are exported from the public API. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from ocrmypdf.pluginspec import OcrEngine + + +class TestOcrEngineInterface: + """Test that OcrEngine ABC has required methods.""" + + def test_generate_ocr_method_exists(self): + """OcrEngine must have generate_ocr() method signature.""" + assert hasattr(OcrEngine, 'generate_ocr') + + def test_supports_generate_ocr_method_exists(self): + """OcrEngine must have supports_generate_ocr() method.""" + assert hasattr(OcrEngine, 'supports_generate_ocr') + + def test_supports_generate_ocr_default_false(self): + """Default supports_generate_ocr() should return False.""" + from ocrmypdf.pluginspec import OrientationConfidence + + # Create a minimal concrete implementation + class MinimalEngine(OcrEngine): + @staticmethod + def version(): + return "1.0" + + @staticmethod + def creator_tag(options): + return "test" + + def __str__(self): + return "test" + + @staticmethod + def languages(options): + return set() + + @staticmethod + def get_orientation(input_file, options): + return OrientationConfidence(0, 0.0) + + @staticmethod + def get_deskew(input_file, options): + return 0.0 + + @staticmethod + def generate_hocr(input_file, output_hocr, output_text, options): + pass + + @staticmethod + def generate_pdf(input_file, output_pdf, output_text, options): + pass + + engine = MinimalEngine() + assert engine.supports_generate_ocr() is False + + def test_generate_ocr_raises_not_implemented_by_default(self): + """Default generate_ocr() should raise NotImplementedError.""" + from ocrmypdf.pluginspec import OrientationConfidence + + class MinimalEngine(OcrEngine): + @staticmethod + def version(): + return "1.0" + + @staticmethod + def creator_tag(options): + return "test" + + def __str__(self): + return "test" + + @staticmethod + def languages(options): + return set() + + @staticmethod + def get_orientation(input_file, options): + return OrientationConfidence(0, 0.0) + + @staticmethod + def get_deskew(input_file, options): + return 0.0 + + @staticmethod + def generate_hocr(input_file, output_hocr, output_text, options): + pass + + @staticmethod + def generate_pdf(input_file, output_pdf, output_text, options): + pass + + engine = MinimalEngine() + with pytest.raises(NotImplementedError): + engine.generate_ocr(Path("test.png"), MagicMock(), 0) + + +class TestOcrElementExport: + """Test that OcrElement is exported from public API.""" + + def test_ocrelement_importable_from_ocrmypdf(self): + """OcrElement should be importable from ocrmypdf package.""" + from ocrmypdf import OcrElement + + assert OcrElement is not None + + def test_ocrclass_importable_from_ocrmypdf(self): + """OcrClass should be importable from ocrmypdf package.""" + from ocrmypdf import OcrClass + + assert OcrClass is not None + + def test_boundingbox_importable_from_ocrmypdf(self): + """BoundingBox should be importable from ocrmypdf package.""" + from ocrmypdf import BoundingBox + + assert BoundingBox is not None diff --git a/tests/test_ocr_engine_selection.py b/tests/test_ocr_engine_selection.py new file mode 100644 index 00000000..ac7db0e4 --- /dev/null +++ b/tests/test_ocr_engine_selection.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for OCR engine selection mechanism. + +Tests verify that the --ocr-engine option works correctly and that +engine-specific options are available. +""" + +from __future__ import annotations + +import pytest + + +class TestOcrEngineCliOption: + """Test --ocr-engine CLI option.""" + + def test_ocr_engine_option_exists(self): + """CLI should have --ocr-engine option.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + option_strings = [] + for action in parser._actions: + option_strings.extend(action.option_strings) + + assert '--ocr-engine' in option_strings + + def test_ocr_engine_accepts_tesseract(self): + """--ocr-engine should accept 'tesseract'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['--ocr-engine', 'tesseract', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'tesseract' + + def test_ocr_engine_accepts_auto(self): + """--ocr-engine should accept 'auto'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['--ocr-engine', 'auto', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'auto' + + def test_ocr_engine_accepts_none(self): + """--ocr-engine should accept 'none'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['--ocr-engine', 'none', 'in.pdf', 'out.pdf']) + assert args.ocr_engine == 'none' + + def test_ocr_engine_default_is_auto(self): + """--ocr-engine should default to 'auto'.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + args = parser.parse_args(['in.pdf', 'out.pdf']) + assert args.ocr_engine == 'auto' + + def test_ocr_engine_rejects_invalid(self): + """--ocr-engine should reject invalid values.""" + from ocrmypdf.cli import get_parser + + parser = get_parser() + + with pytest.raises(SystemExit): + parser.parse_args(['--ocr-engine', 'invalid_engine', 'in.pdf', 'out.pdf']) + + +class TestOcrEngineOptionsModel: + """Test OCROptions has ocr_engine field.""" + + def test_ocr_options_has_ocr_engine_field(self): + """OCROptions should have ocr_engine field.""" + from ocrmypdf._options import OCROptions + + # Check field exists in model + assert 'ocr_engine' in OCROptions.model_fields + + +class TestOcrEnginePluginSelection: + """Test that get_ocr_engine() hook selects correct engine based on options.""" + + def test_tesseract_selected_when_auto(self): + """TesseractOcrEngine should be returned when ocr_engine='auto'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine + from ocrmypdf.builtin_plugins import tesseract_ocr + + options = MagicMock() + options.ocr_engine = 'auto' + + engine = tesseract_ocr.get_ocr_engine(options=options) + assert isinstance(engine, TesseractOcrEngine) + + def test_tesseract_selected_when_tesseract(self): + """TesseractOcrEngine should be returned when ocr_engine='tesseract'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine + from ocrmypdf.builtin_plugins import tesseract_ocr + + options = MagicMock() + options.ocr_engine = 'tesseract' + + engine = tesseract_ocr.get_ocr_engine(options=options) + assert isinstance(engine, TesseractOcrEngine) + + def test_null_selected_when_none(self): + """NullOcrEngine should be returned when ocr_engine='none'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins.null_ocr import NullOcrEngine + from ocrmypdf.builtin_plugins import null_ocr + + options = MagicMock() + options.ocr_engine = 'none' + + engine = null_ocr.get_ocr_engine(options=options) + assert isinstance(engine, NullOcrEngine) + + def test_null_returns_none_when_auto(self): + """null_ocr.get_ocr_engine() should return None when ocr_engine='auto'.""" + from unittest.mock import MagicMock + + from ocrmypdf.builtin_plugins import null_ocr + + options = MagicMock() + options.ocr_engine = 'auto' + + engine = null_ocr.get_ocr_engine(options=options) + assert engine is None diff --git a/tests/test_pipeline_generate_ocr.py b/tests/test_pipeline_generate_ocr.py new file mode 100644 index 00000000..17c989e7 --- /dev/null +++ b/tests/test_pipeline_generate_ocr.py @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2025 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for pipeline support of generate_ocr(). + +These tests verify that the pipeline supports the new generate_ocr() API +alongside the existing hOCR path. +""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from ocrmypdf import OcrElement + + +class TestOcrEngineDirect: + """Test the ocr_engine_direct() pipeline function.""" + + def test_ocr_engine_direct_function_exists(self): + """ocr_engine_direct function should exist in _pipeline module.""" + from ocrmypdf import _pipeline + + assert hasattr(_pipeline, 'ocr_engine_direct') + + def test_ocr_engine_direct_returns_tuple(self): + """ocr_engine_direct should return (OcrElement, Path) tuple.""" + from ocrmypdf._pipeline import ocr_engine_direct + + # Mock page context with an engine that supports generate_ocr + mock_context = MagicMock() + mock_engine = MagicMock() + mock_engine.supports_generate_ocr.return_value = True + mock_engine.generate_ocr.return_value = ( + OcrElement(ocr_class='ocr_page', bbox=(0, 0, 100, 100)), + "test text", + ) + mock_context.plugin_manager.get_ocr_engine.return_value = mock_engine + mock_context.get_path.return_value = Path("/tmp/test.txt") + mock_context.pageno = 0 + + with patch('builtins.open', MagicMock()): + result = ocr_engine_direct(Path("test.png"), mock_context) + + assert isinstance(result, tuple) + assert len(result) == 2 + + +class TestPageResultExtension: + """Test PageResult NamedTuple extension.""" + + def test_page_result_has_ocr_tree_field(self): + """PageResult should have ocr_tree field.""" + from ocrmypdf._pipelines._common import PageResult + + # PageResult is a NamedTuple, use _fields + assert 'ocr_tree' in PageResult._fields + + def test_page_result_ocr_tree_default_none(self): + """PageResult.ocr_tree should default to None.""" + from ocrmypdf._pipelines._common import PageResult + + result = PageResult(pageno=0) + assert result.ocr_tree is None + + +class TestFpdf2DirectPage: + """Test Fpdf2DirectPage dataclass for direct OcrElement input.""" + + def test_fpdf2_direct_page_exists(self): + """Fpdf2DirectPage dataclass should exist.""" + from ocrmypdf._graft import Fpdf2DirectPage + + assert Fpdf2DirectPage is not None + + def test_fpdf2_direct_page_has_ocr_tree(self): + """Fpdf2DirectPage should have ocr_tree field.""" + from ocrmypdf._graft import Fpdf2DirectPage + + fields = {f.name for f in dataclasses.fields(Fpdf2DirectPage)} + assert 'ocr_tree' in fields + + +class TestHOCRResultExtension: + """Test HOCRResult dataclass extension.""" + + def test_hocr_result_has_ocr_tree_field(self): + """HOCRResult should have ocr_tree field.""" + from ocrmypdf._pipelines._common import HOCRResult + + fields = {f.name for f in dataclasses.fields(HOCRResult)} + assert 'ocr_tree' in fields + + def test_hocr_result_ocr_tree_default_none(self): + """HOCRResult.ocr_tree should default to None.""" + from ocrmypdf._pipelines._common import HOCRResult + + result = HOCRResult(pageno=0) + assert result.ocr_tree is None