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.
This commit is contained in:
James R. Barlow
2026-01-12 10:11:14 -08:00
parent 664c3e2a8e
commit 0c3745a1a4
20 changed files with 922 additions and 44 deletions
+12
View File
@@ -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',
+47 -18
View File
@@ -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]
+6 -4
View File
@@ -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
+3 -1
View File
@@ -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}'
+1
View File
@@ -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
+43 -6
View File
@@ -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,
+10 -1
View File
@@ -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."""
@@ -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()
+13 -6
View File
@@ -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,
),
+7 -3
View File
@@ -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,
+4 -2
View File
@@ -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
+159
View File
@@ -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'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>OCRmyPDF - No OCR</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='OCRmyPDF null engine'/>
</head>
<body>
<div class='ocr_page' title='bbox 0 0 {width} {height}'>
</div>
</body>
</html>
'''
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()
@@ -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()
+9
View File
@@ -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'],
+55 -1
View File
@@ -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<firstresult>`.
"""
+1 -1
View File
@@ -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
+169
View File
@@ -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
+131
View File
@@ -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
+139
View File
@@ -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
+103
View File
@@ -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