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:
co-authored by
Claude Opus 4.5
parent
9ea804aff5
commit
b4f9673364
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user