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:
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user