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