Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ help:
@printf " %-12s%s\n" "all" "check + format"

install:
$(UV) sync --extra dev
$(UV) sync --group dev

lint: install
$(UV) run $(RUFF) check src tests
Expand Down
19 changes: 10 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,28 @@ requires-python = ">=3.10"
license = {text = "MIT"}

dependencies = [
"ocrbridge-core>=3.0.0",
"ocrbridge-core>=3.1.0",
"easyocr>=1.7.2",
"torch>=2.1.0",
"pdf2image>=1.17.0",
"Pillow>=10.0.0",
"numpy>=1.24.0",
]

[project.optional-dependencies]
build = ["hatchling"]
dev = [
test = [
"pytest~=9.0",
"pytest-cov>=7.0.0",
"pytest-mock>=3.12.0",
"ruff>=0.1.0",
"pyright>=1.1.0",
"python-semantic-release>=10.5.2",
]
test = [
"pytest~=9.0",

[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"pytest-mock>=3.12.0",
"ruff>=0.1.0",
"pyright>=1.1.0",
"python-semantic-release>=9.0",
]

# Entry point for engine discovery
Expand Down
46 changes: 4 additions & 42 deletions src/ocrbridge/engines/easyocr/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@
from typing import Any, Mapping, Sequence, cast

import numpy as np
import pdf2image as _pdf2image
from PIL import Image

from ocrbridge.core import OCREngine, OCRProcessingError, UnsupportedFormatError
from ocrbridge.core.models import OCREngineParams
from ocrbridge.core.utils.hocr import merge_hocr_pages
from ocrbridge.core.utils.pdf import convert_pdf_to_images

from . import hocr as hocr_utils
from .models import EasyOCRParams

pdf2image = cast(Any, _pdf2image)

EasyOCRReader = Any
Point = tuple[float, float]
BoundingBox = Sequence[Point]
Expand Down Expand Up @@ -202,13 +201,7 @@ def _process_pdf(self, pdf_path: Path, params: EasyOCRParams) -> str:
HOCR XML string with all pages combined
"""
# Convert PDF to images
try:
images = cast(
list[Image.Image],
pdf2image.convert_from_path(str(pdf_path), dpi=300, thread_count=2),
)
except Exception as e:
raise OCRProcessingError(f"PDF conversion failed: {str(e)}")
images = convert_pdf_to_images(pdf_path, dpi=300)

# Process each page
if self.reader is None:
Expand Down Expand Up @@ -246,41 +239,10 @@ def _process_pdf(self, pdf_path: Path, params: EasyOCRParams) -> str:
if len(page_hocr_list) == 1:
hocr_content: str = page_hocr_list[0]
else:
hocr_content = self._merge_hocr_pages(page_hocr_list)
hocr_content = merge_hocr_pages(page_hocr_list, system_name="easyocr")

return hocr_content

def _merge_hocr_pages(self, page_hocr_list: list[str]) -> str:
"""Merge multiple HOCR pages into single document.

Args:
page_hocr_list: List of HOCR XML strings, one per page

Returns:
Combined HOCR XML string
"""
# Extract body content from each page and combine
combined_body = ""
for page_hocr in page_hocr_list:
# Extract content between <body> tags
start = page_hocr.find("<body>")
end = page_hocr.find("</body>")
if start != -1 and end != -1:
combined_body += page_hocr[start + 6 : end]

# Wrap in complete HOCR structure
hocr_template = 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">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="ocr-system" content="easyocr" />
</head>
<body>{combined_body}</body>
</html>"""

return hocr_template

def _to_hocr(self, easyocr_results: EasyOCRResults, image_path: Path) -> str:
"""Convert EasyOCR results to HOCR XML format.

Expand Down
73 changes: 13 additions & 60 deletions tests/test_engine_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,10 @@ def test_process_pdf_single_page(self, mocker: Any, tmp_path: Path) -> None:
]
engine.reader = mock_reader

# Mock pdf2image
# Mock convert_pdf_to_images
mock_image = Image.new("RGB", (800, 600), color="white")
mock_convert = mocker.patch(
"ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path",
"ocrbridge.engines.easyocr.engine.convert_pdf_to_images",
return_value=[mock_image],
)

Expand All @@ -228,7 +228,7 @@ def test_process_pdf_single_page(self, mocker: Any, tmp_path: Path) -> None:
result = engine._process_pdf(pdf_path, params)

assert result == "<hocr>page 1</hocr>"
mock_convert.assert_called_once_with(str(pdf_path), dpi=300, thread_count=2)
mock_convert.assert_called_once_with(pdf_path, dpi=300)

def test_process_pdf_multiple_pages(self, mocker: Any, tmp_path: Path) -> None:
"""Test _process_pdf processes multi-page PDF."""
Expand All @@ -240,25 +240,24 @@ def test_process_pdf_multiple_pages(self, mocker: Any, tmp_path: Path) -> None:
]
engine.reader = mock_reader

# Mock pdf2image with 3 pages
# Mock convert_pdf_to_images with 3 pages
mock_images = [
Image.new("RGB", (800, 600), color="white"),
Image.new("RGB", (800, 600), color="white"),
Image.new("RGB", (800, 600), color="white"),
]
mocker.patch(
"ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path",
"ocrbridge.engines.easyocr.engine.convert_pdf_to_images",
return_value=mock_images,
)

# Mock _to_hocr to return different content per page
page_hocrs = ["<hocr>page 1</hocr>", "<hocr>page 2</hocr>", "<hocr>page 3</hocr>"]
mocker.patch.object(engine, "_to_hocr", side_effect=page_hocrs)

# Mock _merge_hocr_pages
mock_merge = mocker.patch.object(
engine,
"_merge_hocr_pages",
# Mock merge_hocr_pages
mock_merge = mocker.patch(
"ocrbridge.engines.easyocr.engine.merge_hocr_pages",
return_value="<hocr>merged</hocr>",
)

Expand All @@ -271,17 +270,17 @@ def test_process_pdf_multiple_pages(self, mocker: Any, tmp_path: Path) -> None:
result = engine._process_pdf(pdf_path, params)

assert result == "<hocr>merged</hocr>"
mock_merge.assert_called_once_with(page_hocrs)
mock_merge.assert_called_once_with(page_hocrs, system_name="easyocr")

def test_process_pdf_conversion_failure(self, mocker: Any, tmp_path: Path) -> None:
"""Test _process_pdf raises error on PDF conversion failure."""
engine = EasyOCREngine()
engine.reader = MagicMock()

# Mock pdf2image to raise exception
# Mock convert_pdf_to_images to raise exception
mocker.patch(
"ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path",
side_effect=Exception("PDF error"),
"ocrbridge.engines.easyocr.engine.convert_pdf_to_images",
side_effect=OCRProcessingError("PDF conversion failed"),
)

pdf_path = tmp_path / "test.pdf"
Expand All @@ -301,7 +300,7 @@ def test_process_pdf_no_reader_raises_error(self, mocker: Any, tmp_path: Path) -

mock_image = Image.new("RGB", (800, 600), color="white")
mocker.patch(
"ocrbridge.engines.easyocr.engine.pdf2image.convert_from_path",
"ocrbridge.engines.easyocr.engine.convert_pdf_to_images",
return_value=[mock_image],
)

Expand All @@ -316,52 +315,6 @@ def test_process_pdf_no_reader_raises_error(self, mocker: Any, tmp_path: Path) -
assert "reader is not initialized" in str(exc_info.value)


class TestMergeHOCRPages:
"""Test suite for _merge_hocr_pages method."""

def test_merge_single_page(self) -> None:
"""Test _merge_hocr_pages with single page returns same content."""
engine = EasyOCREngine()
page_hocr = "<html><body><div>Page 1</div></body></html>"

# When there's only one page, it's returned as-is in process methods
# This tests the merge logic directly
result = engine._merge_hocr_pages([page_hocr])

assert "<div>Page 1</div>" in result
assert "easyocr" in result

def test_merge_multiple_pages(self) -> None:
"""Test _merge_hocr_pages combines multiple pages correctly."""
engine = EasyOCREngine()

page1 = "<html><body><div>Page 1</div></body></html>"
page2 = "<html><body><div>Page 2</div></body></html>"
page3 = "<html><body><div>Page 3</div></body></html>"

result = engine._merge_hocr_pages([page1, page2, page3])

assert "<div>Page 1</div>" in result
assert "<div>Page 2</div>" in result
assert "<div>Page 3</div>" in result
assert result.startswith('<?xml version="1.0"')
assert "easyocr" in result

def test_merge_preserves_structure(self) -> None:
"""Test _merge_hocr_pages creates valid HOCR structure."""
engine = EasyOCREngine()

page1 = "<html><body><div class='ocr_page'>Page 1</div></body></html>"
result = engine._merge_hocr_pages([page1])

assert '<?xml version="1.0"' in result
assert "<html" in result
assert "<head>" in result
assert "<body>" in result
assert "</body>" in result
assert "</html>" in result


class TestToHOCR:
"""Test suite for _to_hocr method."""

Expand Down
Loading