Add unit tests for HocrParser, PdfTextRenderer, and OcrElement

Comprehensive test coverage for the new hocrtransform components:

- test_ocr_element.py: Tests for BoundingBox, Baseline, FontInfo,
  OcrElement dataclass methods (iter_by_class, find_by_class,
  get_text_recursive, words/lines/paragraphs properties)

- test_hocr_parser.py: Tests for parsing hOCR files including
  page/paragraph/line/word extraction, RTL text, rotated text,
  different line types (header, caption), font info, and edge cases

- test_pdf_renderer.py: Tests for PDF rendering including text
  extraction verification, page sizing, multi-line content,
  text direction, baseline handling, textangle rotation, word breaks,
  debug options, and image overlay

Also fixes x_font regex pattern to not capture trailing semicolons.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James R. Barlow
2025-12-21 17:05:49 -08:00
co-authored by Claude Opus 4.5
parent 9ea804aff5
commit b4f9673364
4 changed files with 1346 additions and 1 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ class HocrParser:
_x_font_pattern = re.compile(
r'''
x_font \s+
(\S+) # font name: non-whitespace string
([^\s;]+) # font name: non-whitespace, non-semicolon string
''',
re.VERBOSE,
)
+530
View File
@@ -0,0 +1,530 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Unit tests for HocrParser class."""
from __future__ import annotations
from pathlib import Path
from textwrap import dedent
import pytest
from ocrmypdf.hocrtransform import (
HocrParseError,
HocrParser,
OcrClass,
)
@pytest.fixture
def simple_hocr(tmp_path) -> Path:
"""Create a simple valid hOCR file."""
content = dedent("""\
<?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>Test</title>
</head>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500; ppageno 0'>
<p class='ocr_par' lang='eng' dir='ltr'>
<span class='ocr_line' title='bbox 100 100 900 150; baseline 0.01 -5'>
<span class='ocrx_word' title='bbox 100 100 200 150; x_wconf 95'>Hello</span>
<span class='ocrx_word' title='bbox 250 100 350 150; x_wconf 90'>World</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "simple.hocr"
hocr_file.write_text(content)
return hocr_file
@pytest.fixture
def multiline_hocr(tmp_path) -> Path:
"""Create an hOCR file with multiple lines and paragraphs."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 1000'>
<p class='ocr_par' lang='eng'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150'>Line</span>
<span class='ocrx_word' title='bbox 210 100 280 150'>one</span>
</span>
<span class='ocr_line' title='bbox 100 200 900 250'>
<span class='ocrx_word' title='bbox 100 200 200 250'>Line</span>
<span class='ocrx_word' title='bbox 210 200 280 250'>two</span>
</span>
</p>
<p class='ocr_par' lang='deu'>
<span class='ocr_line' title='bbox 100 400 900 450'>
<span class='ocrx_word' title='bbox 100 400 200 450'>German</span>
<span class='ocrx_word' title='bbox 210 400 280 450'>text</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "multiline.hocr"
hocr_file.write_text(content)
return hocr_file
@pytest.fixture
def rtl_hocr(tmp_path) -> Path:
"""Create an hOCR file with RTL text."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par' lang='ara' dir='rtl'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150'>مرحبا</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "rtl.hocr"
hocr_file.write_text(content)
return hocr_file
@pytest.fixture
def rotated_hocr(tmp_path) -> Path:
"""Create an hOCR file with rotated text (textangle)."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par' lang='eng'>
<span class='ocr_line' title='bbox 100 100 900 150; textangle 5.5'>
<span class='ocrx_word' title='bbox 100 100 200 150'>Rotated</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "rotated.hocr"
hocr_file.write_text(content)
return hocr_file
@pytest.fixture
def header_hocr(tmp_path) -> Path:
"""Create an hOCR file with different line types."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par' lang='eng'>
<span class='ocr_header' title='bbox 100 50 900 100'>
<span class='ocrx_word' title='bbox 100 50 300 100'>Chapter</span>
<span class='ocrx_word' title='bbox 310 50 400 100'>One</span>
</span>
<span class='ocr_line' title='bbox 100 150 900 200'>
<span class='ocrx_word' title='bbox 100 150 200 200'>Body</span>
<span class='ocrx_word' title='bbox 210 150 280 200'>text</span>
</span>
<span class='ocr_caption' title='bbox 100 300 900 350'>
<span class='ocrx_word' title='bbox 100 300 200 350'>Figure</span>
<span class='ocrx_word' title='bbox 210 300 250 350'>1</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "header.hocr"
hocr_file.write_text(content)
return hocr_file
@pytest.fixture
def font_info_hocr(tmp_path) -> Path:
"""Create an hOCR file with font information."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par' lang='eng'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150; x_font Arial; x_fsize 12.5'>Styled</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "font_info.hocr"
hocr_file.write_text(content)
return hocr_file
class TestHocrParserBasic:
"""Basic HocrParser functionality tests."""
def test_parse_simple_hocr(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
assert page.ocr_class == OcrClass.PAGE
assert page.bbox is not None
assert page.bbox.width == 1000
assert page.bbox.height == 500
def test_parse_page_number(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
assert page.page_number == 0
def test_parse_paragraphs(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
assert len(page.paragraphs) == 1
paragraph = page.paragraphs[0]
assert paragraph.ocr_class == OcrClass.PARAGRAPH
assert paragraph.language == "eng"
assert paragraph.direction == "ltr"
def test_parse_lines(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
lines = page.lines
assert len(lines) == 1
line = lines[0]
assert line.ocr_class == OcrClass.LINE
assert line.bbox is not None
assert line.baseline is not None
assert line.baseline.slope == pytest.approx(0.01)
assert line.baseline.intercept == -5
def test_parse_words(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
words = page.words
assert len(words) == 2
assert words[0].text == "Hello"
assert words[1].text == "World"
def test_parse_word_confidence(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
words = page.words
assert words[0].confidence == pytest.approx(0.95)
assert words[1].confidence == pytest.approx(0.90)
def test_parse_word_bbox(self, simple_hocr):
parser = HocrParser(simple_hocr)
page = parser.parse()
word = page.words[0]
assert word.bbox is not None
assert word.bbox.left == 100
assert word.bbox.top == 100
assert word.bbox.right == 200
assert word.bbox.bottom == 150
class TestHocrParserMultiline:
"""Test parsing of multi-line/multi-paragraph hOCR."""
def test_multiple_lines(self, multiline_hocr):
parser = HocrParser(multiline_hocr)
page = parser.parse()
assert len(page.paragraphs) == 2
assert len(page.lines) == 3 # 2 in first par, 1 in second
def test_multiple_paragraphs_languages(self, multiline_hocr):
parser = HocrParser(multiline_hocr)
page = parser.parse()
paragraphs = page.paragraphs
assert paragraphs[0].language == "eng"
assert paragraphs[1].language == "deu"
def test_word_count(self, multiline_hocr):
parser = HocrParser(multiline_hocr)
page = parser.parse()
assert len(page.words) == 6 # 2 + 2 + 2
class TestHocrParserRTL:
"""Test parsing of RTL text."""
def test_rtl_direction(self, rtl_hocr):
parser = HocrParser(rtl_hocr)
page = parser.parse()
paragraph = page.paragraphs[0]
assert paragraph.direction == "rtl"
assert paragraph.language == "ara"
def test_rtl_line_inherits_direction(self, rtl_hocr):
parser = HocrParser(rtl_hocr)
page = parser.parse()
line = page.lines[0]
assert line.direction == "rtl"
class TestHocrParserRotation:
"""Test parsing of rotated text."""
def test_textangle(self, rotated_hocr):
parser = HocrParser(rotated_hocr)
page = parser.parse()
line = page.lines[0]
assert line.textangle == pytest.approx(5.5)
class TestHocrParserLineTypes:
"""Test parsing of different line types."""
def test_header_line(self, header_hocr):
parser = HocrParser(header_hocr)
page = parser.parse()
lines = page.lines
assert len(lines) == 3
# Check line types
line_classes = [line.ocr_class for line in lines]
assert OcrClass.HEADER in line_classes
assert OcrClass.LINE in line_classes
assert OcrClass.CAPTION in line_classes
def test_all_line_types_have_words(self, header_hocr):
parser = HocrParser(header_hocr)
page = parser.parse()
for line in page.lines:
assert len(line.children) > 0
class TestHocrParserFontInfo:
"""Test parsing of font information."""
def test_font_name_and_size(self, font_info_hocr):
parser = HocrParser(font_info_hocr)
page = parser.parse()
word = page.words[0]
assert word.font is not None
assert word.font.name == "Arial"
assert word.font.size == pytest.approx(12.5)
class TestHocrParserErrors:
"""Test error handling in HocrParser."""
def test_missing_file(self, tmp_path):
with pytest.raises(FileNotFoundError):
HocrParser(tmp_path / "nonexistent.hocr")
def test_invalid_xml(self, tmp_path):
hocr_file = tmp_path / "invalid.hocr"
hocr_file.write_text("<html><body>not closed")
with pytest.raises(HocrParseError):
HocrParser(hocr_file)
def test_missing_ocr_page(self, tmp_path):
hocr_file = tmp_path / "no_page.hocr"
hocr_file.write_text("<html><body><p>No ocr_page</p></body></html>")
parser = HocrParser(hocr_file)
with pytest.raises(HocrParseError, match="No ocr_page"):
parser.parse()
def test_missing_page_bbox(self, tmp_path):
hocr_file = tmp_path / "no_bbox.hocr"
hocr_file.write_text(
"<html><body><div class='ocr_page'>No bbox</div></body></html>"
)
parser = HocrParser(hocr_file)
with pytest.raises(HocrParseError, match="bbox"):
parser.parse()
class TestHocrParserEdgeCases:
"""Test edge cases in HocrParser."""
def test_empty_word_text(self, tmp_path):
"""Words with empty text should be skipped."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150'></span>
<span class='ocrx_word' title='bbox 210 100 300 150'>Valid</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "empty_word.hocr"
hocr_file.write_text(content)
parser = HocrParser(hocr_file)
page = parser.parse()
# Only the non-empty word should be parsed
assert len(page.words) == 1
assert page.words[0].text == "Valid"
def test_whitespace_only_word(self, tmp_path):
"""Words with only whitespace should be skipped."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150'> </span>
<span class='ocrx_word' title='bbox 210 100 300 150'>Valid</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "whitespace_word.hocr"
hocr_file.write_text(content)
parser = HocrParser(hocr_file)
page = parser.parse()
assert len(page.words) == 1
assert page.words[0].text == "Valid"
def test_line_without_bbox(self, tmp_path):
"""Lines without bbox should be skipped."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par'>
<span class='ocr_line'>
<span class='ocrx_word' title='bbox 100 100 200 150'>Word</span>
</span>
<span class='ocr_line' title='bbox 100 200 900 250'>
<span class='ocrx_word' title='bbox 100 200 200 250'>Valid</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "no_line_bbox.hocr"
hocr_file.write_text(content)
parser = HocrParser(hocr_file)
page = parser.parse()
# Only line with bbox should be parsed
assert len(page.lines) == 1
assert page.words[0].text == "Valid"
def test_unicode_normalization(self, tmp_path):
"""Text should be NFKC normalized."""
# Use a string with combining characters
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150'>fi</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "unicode.hocr"
hocr_file.write_text(content)
parser = HocrParser(hocr_file)
page = parser.parse()
# fi ligature should be normalized to "fi"
assert page.words[0].text == "fi"
def test_words_directly_under_page(self, tmp_path):
"""Test fallback for words directly under page (no paragraph structure)."""
content = dedent("""\
<?xml version="1.0" encoding="UTF-8"?>
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<span class='ocrx_word' title='bbox 100 100 200 150'>Direct</span>
<span class='ocrx_word' title='bbox 210 100 300 150'>Word</span>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "direct_words.hocr"
hocr_file.write_text(content)
parser = HocrParser(hocr_file)
page = parser.parse()
# Words should be parsed as direct children
assert len(page.children) == 2
assert page.children[0].text == "Direct"
assert page.children[1].text == "Word"
def test_no_namespace(self, tmp_path):
"""Test parsing hOCR without XHTML namespace."""
content = dedent("""\
<html>
<body>
<div class='ocr_page' title='bbox 0 0 1000 500'>
<p class='ocr_par'>
<span class='ocr_line' title='bbox 100 100 900 150'>
<span class='ocrx_word' title='bbox 100 100 200 150'>NoNS</span>
</span>
</p>
</div>
</body>
</html>
""")
hocr_file = tmp_path / "no_namespace.hocr"
hocr_file.write_text(content)
parser = HocrParser(hocr_file)
page = parser.parse()
assert len(page.words) == 1
assert page.words[0].text == "NoNS"
+244
View File
@@ -0,0 +1,244 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Unit tests for OcrElement dataclass and related classes."""
from __future__ import annotations
import pytest
from ocrmypdf.hocrtransform import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
class TestBoundingBox:
"""Tests for BoundingBox dataclass."""
def test_basic_creation(self):
bbox = BoundingBox(left=10, top=20, right=100, bottom=50)
assert bbox.left == 10
assert bbox.top == 20
assert bbox.right == 100
assert bbox.bottom == 50
def test_width_height(self):
bbox = BoundingBox(left=10, top=20, right=110, bottom=70)
assert bbox.width == 100
assert bbox.height == 50
def test_zero_size_box(self):
bbox = BoundingBox(left=10, top=20, right=10, bottom=20)
assert bbox.width == 0
assert bbox.height == 0
def test_invalid_left_right(self):
with pytest.raises(ValueError, match="right.*left"):
BoundingBox(left=100, top=20, right=10, bottom=50)
def test_invalid_top_bottom(self):
with pytest.raises(ValueError, match="bottom.*top"):
BoundingBox(left=10, top=50, right=100, bottom=20)
class TestBaseline:
"""Tests for Baseline dataclass."""
def test_defaults(self):
baseline = Baseline()
assert baseline.slope == 0.0
assert baseline.intercept == 0.0
def test_with_values(self):
baseline = Baseline(slope=0.01, intercept=-5)
assert baseline.slope == 0.01
assert baseline.intercept == -5
class TestFontInfo:
"""Tests for FontInfo dataclass."""
def test_defaults(self):
font = FontInfo()
assert font.name is None
assert font.size is None
assert font.bold is False
assert font.italic is False
def test_with_values(self):
font = FontInfo(name="Arial", size=12.0, bold=True)
assert font.name == "Arial"
assert font.size == 12.0
assert font.bold is True
assert font.italic is False
class TestOcrElement:
"""Tests for OcrElement dataclass."""
def test_minimal_element(self):
elem = OcrElement(ocr_class=OcrClass.WORD, text="hello")
assert elem.ocr_class == "ocrx_word"
assert elem.text == "hello"
assert elem.bbox is None
assert elem.children == []
def test_element_with_bbox(self):
bbox = BoundingBox(left=0, top=0, right=100, bottom=50)
elem = OcrElement(ocr_class=OcrClass.LINE, bbox=bbox)
assert elem.bbox == bbox
assert elem.bbox.width == 100
def test_element_hierarchy(self):
word1 = OcrElement(ocr_class=OcrClass.WORD, text="Hello")
word2 = OcrElement(ocr_class=OcrClass.WORD, text="World")
line = OcrElement(ocr_class=OcrClass.LINE, children=[word1, word2])
paragraph = OcrElement(ocr_class=OcrClass.PARAGRAPH, children=[line])
page = OcrElement(ocr_class=OcrClass.PAGE, children=[paragraph])
assert len(page.children) == 1
assert len(page.children[0].children) == 1
assert len(page.children[0].children[0].children) == 2
def test_iter_by_class_single(self):
word = OcrElement(ocr_class=OcrClass.WORD, text="test")
line = OcrElement(ocr_class=OcrClass.LINE, children=[word])
page = OcrElement(ocr_class=OcrClass.PAGE, children=[line])
words = page.iter_by_class(OcrClass.WORD)
assert len(words) == 1
assert words[0].text == "test"
def test_iter_by_class_multiple(self):
words = [
OcrElement(ocr_class=OcrClass.WORD, text="one"),
OcrElement(ocr_class=OcrClass.WORD, text="two"),
OcrElement(ocr_class=OcrClass.WORD, text="three"),
]
line = OcrElement(ocr_class=OcrClass.LINE, children=words)
page = OcrElement(ocr_class=OcrClass.PAGE, children=[line])
result = page.iter_by_class(OcrClass.WORD)
assert len(result) == 3
assert [w.text for w in result] == ["one", "two", "three"]
def test_iter_by_class_multiple_types(self):
line = OcrElement(ocr_class=OcrClass.LINE)
header = OcrElement(ocr_class=OcrClass.HEADER)
caption = OcrElement(ocr_class=OcrClass.CAPTION)
page = OcrElement(ocr_class=OcrClass.PAGE, children=[line, header, caption])
result = page.iter_by_class(OcrClass.LINE, OcrClass.HEADER)
assert len(result) == 2
def test_find_by_class(self):
word = OcrElement(ocr_class=OcrClass.WORD, text="found")
line = OcrElement(ocr_class=OcrClass.LINE, children=[word])
page = OcrElement(ocr_class=OcrClass.PAGE, children=[line])
result = page.find_by_class(OcrClass.WORD)
assert result is not None
assert result.text == "found"
def test_find_by_class_not_found(self):
line = OcrElement(ocr_class=OcrClass.LINE)
page = OcrElement(ocr_class=OcrClass.PAGE, children=[line])
result = page.find_by_class(OcrClass.WORD)
assert result is None
def test_get_text_recursive_leaf(self):
word = OcrElement(ocr_class=OcrClass.WORD, text="hello")
assert word.get_text_recursive() == "hello"
def test_get_text_recursive_nested(self):
word1 = OcrElement(ocr_class=OcrClass.WORD, text="Hello")
word2 = OcrElement(ocr_class=OcrClass.WORD, text="World")
line = OcrElement(ocr_class=OcrClass.LINE, children=[word1, word2])
assert line.get_text_recursive() == "Hello World"
def test_words_property(self):
words = [
OcrElement(ocr_class=OcrClass.WORD, text="a"),
OcrElement(ocr_class=OcrClass.WORD, text="b"),
]
line = OcrElement(ocr_class=OcrClass.LINE, children=words)
page = OcrElement(ocr_class=OcrClass.PAGE, children=[line])
assert len(page.words) == 2
assert page.words[0].text == "a"
def test_lines_property(self):
line1 = OcrElement(ocr_class=OcrClass.LINE)
line2 = OcrElement(ocr_class=OcrClass.HEADER) # Also a line type
par = OcrElement(ocr_class=OcrClass.PARAGRAPH, children=[line1, line2])
page = OcrElement(ocr_class=OcrClass.PAGE, children=[par])
assert len(page.lines) == 2
def test_paragraphs_property(self):
par1 = OcrElement(ocr_class=OcrClass.PARAGRAPH)
par2 = OcrElement(ocr_class=OcrClass.PARAGRAPH)
page = OcrElement(ocr_class=OcrClass.PAGE, children=[par1, par2])
assert len(page.paragraphs) == 2
def test_direction_ltr(self):
elem = OcrElement(ocr_class=OcrClass.PARAGRAPH, direction="ltr")
assert elem.direction == "ltr"
def test_direction_rtl(self):
elem = OcrElement(ocr_class=OcrClass.PARAGRAPH, direction="rtl")
assert elem.direction == "rtl"
def test_language(self):
elem = OcrElement(ocr_class=OcrClass.PARAGRAPH, language="eng")
assert elem.language == "eng"
def test_baseline(self):
baseline = Baseline(slope=0.01, intercept=-3)
elem = OcrElement(ocr_class=OcrClass.LINE, baseline=baseline)
assert elem.baseline.slope == 0.01
assert elem.baseline.intercept == -3
def test_textangle(self):
elem = OcrElement(ocr_class=OcrClass.LINE, textangle=5.0)
assert elem.textangle == 5.0
def test_confidence(self):
elem = OcrElement(ocr_class=OcrClass.WORD, confidence=0.95)
assert elem.confidence == 0.95
def test_page_properties(self):
elem = OcrElement(
ocr_class=OcrClass.PAGE,
dpi=300.0,
page_number=0,
logical_page_number=1,
)
assert elem.dpi == 300.0
assert elem.page_number == 0
assert elem.logical_page_number == 1
class TestOcrClass:
"""Tests for OcrClass constants."""
def test_class_values(self):
assert OcrClass.PAGE == "ocr_page"
assert OcrClass.PARAGRAPH == "ocr_par"
assert OcrClass.LINE == "ocr_line"
assert OcrClass.WORD == "ocrx_word"
assert OcrClass.HEADER == "ocr_header"
assert OcrClass.CAPTION == "ocr_caption"
def test_line_types_frozenset(self):
assert OcrClass.LINE in OcrClass.LINE_TYPES
assert OcrClass.HEADER in OcrClass.LINE_TYPES
assert OcrClass.CAPTION in OcrClass.LINE_TYPES
assert OcrClass.WORD not in OcrClass.LINE_TYPES
+571
View File
@@ -0,0 +1,571 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Unit tests for PdfTextRenderer class."""
from __future__ import annotations
from io import StringIO
from pathlib import Path
import pytest
from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfparser import PDFParser
from PIL import Image
from ocrmypdf.helpers import check_pdf
from ocrmypdf.hocrtransform import (
Baseline,
BoundingBox,
OcrClass,
OcrElement,
PdfTextRenderer,
)
from ocrmypdf.hocrtransform.pdf_renderer import DebugRenderOptions
def text_from_pdf(filename: Path) -> str:
"""Extract text from a PDF file using pdfminer."""
output_string = StringIO()
with open(filename, 'rb') as in_file:
parser = PDFParser(in_file)
doc = PDFDocument(parser)
rsrcmgr = PDFResourceManager()
device = TextConverter(rsrcmgr, output_string, laparams=LAParams())
interpreter = PDFPageInterpreter(rsrcmgr, device)
for page in PDFPage.create_pages(doc):
interpreter.process_page(page)
return output_string.getvalue()
def create_simple_page(
width: float = 1000,
height: float = 500,
words: list[tuple[str, tuple[float, float, float, float]]] | None = None,
) -> OcrElement:
"""Create a simple OcrElement page for testing.
Args:
width: Page width in pixels
height: Page height in pixels
words: List of (text, (left, top, right, bottom)) tuples
Returns:
OcrElement representing the page
"""
if words is None:
words = [("Hello", (100, 100, 200, 150)), ("World", (250, 100, 350, 150))]
word_elements = [
OcrElement(
ocr_class=OcrClass.WORD,
text=text,
bbox=BoundingBox(left=bbox[0], top=bbox[1], right=bbox[2], bottom=bbox[3]),
)
for text, bbox in words
]
line = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
baseline=Baseline(slope=0.0, intercept=0),
children=word_elements,
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
direction="ltr",
language="eng",
children=[line],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=width, bottom=height),
children=[paragraph],
)
return page
class TestPdfTextRendererBasic:
"""Basic PdfTextRenderer functionality tests."""
def test_render_simple_page(self, tmp_path):
"""Test rendering a simple page with two words."""
page = create_simple_page()
output_pdf = tmp_path / "simple.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
assert output_pdf.exists()
check_pdf(str(output_pdf))
def test_rendered_text_extractable(self, tmp_path):
"""Test that rendered text can be extracted from the PDF."""
page = create_simple_page()
output_pdf = tmp_path / "extractable.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
assert "World" in extracted_text
def test_invisible_text_mode(self, tmp_path):
"""Test that invisible_text=True creates a valid PDF."""
page = create_simple_page()
output_pdf = tmp_path / "invisible.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf, invisible_text=True)
# Text should still be extractable even when invisible
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
def test_visible_text_mode(self, tmp_path):
"""Test that invisible_text=False creates a valid PDF with visible text."""
page = create_simple_page()
output_pdf = tmp_path / "visible.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf, invisible_text=False)
# Text should be extractable
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
class TestPdfTextRendererPageSize:
"""Test page size calculations."""
def test_page_dimensions(self, tmp_path):
"""Test that page dimensions are calculated correctly."""
# 1000x500 pixels at 72 dpi = 1000x500 points
page = create_simple_page(width=1000, height=500)
output_pdf = tmp_path / "dimensions.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
assert renderer.width == pytest.approx(1000.0)
assert renderer.height == pytest.approx(500.0)
renderer.render(out_filename=output_pdf)
def test_high_dpi_page(self, tmp_path):
"""Test page dimensions at higher DPI."""
# 720x360 pixels at 144 dpi = 360x180 points
page = create_simple_page(width=720, height=360)
output_pdf = tmp_path / "high_dpi.pdf"
renderer = PdfTextRenderer(page=page, dpi=144.0)
assert renderer.width == pytest.approx(360.0)
assert renderer.height == pytest.approx(180.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
class TestPdfTextRendererMultiLine:
"""Test rendering of multi-line content."""
def test_multiple_lines(self, tmp_path):
"""Test rendering multiple lines of text."""
line1_words = [
OcrElement(
ocr_class=OcrClass.WORD,
text="Line",
bbox=BoundingBox(left=100, top=100, right=180, bottom=150),
),
OcrElement(
ocr_class=OcrClass.WORD,
text="one",
bbox=BoundingBox(left=190, top=100, right=250, bottom=150),
),
]
line1 = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
baseline=Baseline(slope=0.0, intercept=0),
children=line1_words,
)
line2_words = [
OcrElement(
ocr_class=OcrClass.WORD,
text="Line",
bbox=BoundingBox(left=100, top=200, right=180, bottom=250),
),
OcrElement(
ocr_class=OcrClass.WORD,
text="two",
bbox=BoundingBox(left=190, top=200, right=250, bottom=250),
),
]
line2 = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=200, right=900, bottom=250),
baseline=Baseline(slope=0.0, intercept=0),
children=line2_words,
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=100, right=900, bottom=250),
direction="ltr",
language="eng",
children=[line1, line2],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "multiline.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
extracted_text = text_from_pdf(output_pdf)
assert "Line" in extracted_text
assert "one" in extracted_text
assert "two" in extracted_text
class TestPdfTextRendererTextDirection:
"""Test rendering of different text directions."""
def test_ltr_text(self, tmp_path):
"""Test rendering LTR text."""
page = create_simple_page()
output_pdf = tmp_path / "ltr.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
def test_rtl_text(self, tmp_path):
"""Test rendering RTL text."""
word = OcrElement(
ocr_class=OcrClass.WORD,
text="مرحبا",
bbox=BoundingBox(left=100, top=100, right=200, bottom=150),
)
line = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
baseline=Baseline(slope=0.0, intercept=0),
direction="rtl",
children=[word],
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
direction="rtl",
language="ara",
children=[line],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "rtl.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
class TestPdfTextRendererBaseline:
"""Test baseline handling in rendering."""
def test_sloped_baseline(self, tmp_path):
"""Test rendering with a sloped baseline."""
word = OcrElement(
ocr_class=OcrClass.WORD,
text="Sloped",
bbox=BoundingBox(left=100, top=100, right=200, bottom=150),
)
line = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
baseline=Baseline(slope=0.02, intercept=-5),
children=[word],
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
direction="ltr",
language="eng",
children=[line],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "sloped.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Sloped" in extracted_text
class TestPdfTextRendererTextangle:
"""Test textangle (rotation) handling in rendering."""
def test_rotated_text(self, tmp_path):
"""Test rendering rotated text."""
word = OcrElement(
ocr_class=OcrClass.WORD,
text="Rotated",
bbox=BoundingBox(left=100, top=100, right=200, bottom=150),
)
line = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
baseline=Baseline(slope=0.0, intercept=0),
textangle=5.0,
children=[word],
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
direction="ltr",
language="eng",
children=[line],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "rotated.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Rotated" in extracted_text
class TestPdfTextRendererWordBreaks:
"""Test word break injection."""
def test_word_breaks_english(self, tmp_path):
"""Test that word breaks are injected for English text."""
page = create_simple_page()
output_pdf = tmp_path / "english.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
extracted_text = text_from_pdf(output_pdf)
# Words should be separated
assert "Hello" in extracted_text
assert "World" in extracted_text
def test_no_word_breaks_cjk(self, tmp_path):
"""Test that word breaks are not injected for CJK text."""
words = [
OcrElement(
ocr_class=OcrClass.WORD,
text="你好",
bbox=BoundingBox(left=100, top=100, right=150, bottom=150),
),
OcrElement(
ocr_class=OcrClass.WORD,
text="世界",
bbox=BoundingBox(left=160, top=100, right=210, bottom=150),
),
]
line = OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
baseline=Baseline(slope=0.0, intercept=0),
children=words,
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=100, right=900, bottom=150),
direction="ltr",
language="chi_sim", # Simplified Chinese
children=[line],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "chinese.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
class TestPdfTextRendererDebugOptions:
"""Test debug rendering options."""
def test_debug_render_options_default(self):
"""Test that debug options are disabled by default."""
page = create_simple_page()
renderer = PdfTextRenderer(page=page, dpi=72.0)
assert renderer.render_options.render_paragraph_bbox is False
assert renderer.render_options.render_baseline is False
assert renderer.render_options.render_word_bbox is False
def test_debug_render_options_enabled(self, tmp_path):
"""Test rendering with debug options enabled."""
page = create_simple_page()
output_pdf = tmp_path / "debug.pdf"
debug_opts = DebugRenderOptions(
render_paragraph_bbox=True,
render_baseline=True,
render_word_bbox=True,
render_triangle=True,
)
renderer = PdfTextRenderer(
page=page, dpi=72.0, debug_render_options=debug_opts
)
renderer.render(out_filename=output_pdf, invisible_text=False)
check_pdf(str(output_pdf))
# Text should still be extractable
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
class TestPdfTextRendererWithImage:
"""Test rendering with image overlay."""
def test_render_with_image(self, tmp_path):
"""Test rendering with an image overlaid on text."""
page = create_simple_page()
output_pdf = tmp_path / "with_image.pdf"
# Create a simple test image
image_path = tmp_path / "test.png"
img = Image.new('RGB', (1000, 500), color='white')
img.save(image_path)
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(
out_filename=output_pdf, image_filename=image_path, invisible_text=True
)
check_pdf(str(output_pdf))
# Text should still be extractable under the image
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
class TestPdfTextRendererErrors:
"""Test error handling in PdfTextRenderer."""
def test_invalid_ocr_class(self):
"""Test that non-page elements are rejected."""
line = OcrElement(
ocr_class=OcrClass.LINE, bbox=BoundingBox(left=0, top=0, right=100, bottom=50)
)
with pytest.raises(ValueError, match="ocr_page"):
PdfTextRenderer(page=line, dpi=72.0)
def test_page_without_bbox(self):
"""Test that pages without bbox are rejected."""
page = OcrElement(ocr_class=OcrClass.PAGE)
with pytest.raises(ValueError, match="bounding box"):
PdfTextRenderer(page=page, dpi=72.0)
class TestPdfTextRendererLineTypes:
"""Test rendering of different line types."""
def test_header_line(self, tmp_path):
"""Test rendering header lines."""
word = OcrElement(
ocr_class=OcrClass.WORD,
text="Header",
bbox=BoundingBox(left=100, top=50, right=200, bottom=100),
)
header = OcrElement(
ocr_class=OcrClass.HEADER,
bbox=BoundingBox(left=100, top=50, right=900, bottom=100),
baseline=Baseline(slope=0.0, intercept=0),
children=[word],
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=50, right=900, bottom=100),
direction="ltr",
language="eng",
children=[header],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "header.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Header" in extracted_text
def test_caption_line(self, tmp_path):
"""Test rendering caption lines."""
word = OcrElement(
ocr_class=OcrClass.WORD,
text="Caption",
bbox=BoundingBox(left=100, top=300, right=200, bottom=350),
)
caption = OcrElement(
ocr_class=OcrClass.CAPTION,
bbox=BoundingBox(left=100, top=300, right=900, bottom=350),
baseline=Baseline(slope=0.0, intercept=0),
children=[word],
)
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=BoundingBox(left=100, top=300, right=900, bottom=350),
direction="ltr",
language="eng",
children=[caption],
)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=1000, bottom=500),
children=[paragraph],
)
output_pdf = tmp_path / "caption.pdf"
renderer = PdfTextRenderer(page=page, dpi=72.0)
renderer.render(out_filename=output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Caption" in extracted_text