diff --git a/src/ocrmypdf/hocrtransform/hocr_parser.py b/src/ocrmypdf/hocrtransform/hocr_parser.py index 898da4c8..b3088afb 100644 --- a/src/ocrmypdf/hocrtransform/hocr_parser.py +++ b/src/ocrmypdf/hocrtransform/hocr_parser.py @@ -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, ) diff --git a/tests/test_hocr_parser.py b/tests/test_hocr_parser.py new file mode 100644 index 00000000..ddc22eab --- /dev/null +++ b/tests/test_hocr_parser.py @@ -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("""\ + + + + + Test + + +
+

+ + Hello + World + +

+
+ + + """) + 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("""\ + + + +
+

+ + Line + one + + + Line + two + +

+

+ + German + text + +

+
+ + + """) + 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("""\ + + + +
+

+ + مرحبا + +

+
+ + + """) + 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("""\ + + + +
+

+ + Rotated + +

+
+ + + """) + 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("""\ + + + +
+

+ + Chapter + One + + + Body + text + + + Figure + 1 + +

+
+ + + """) + 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("""\ + + + +
+

+ + Styled + +

+
+ + + """) + 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("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("

No ocr_page

") + + 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( + "
No bbox
" + ) + + 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("""\ + + + +
+

+ + + Valid + +

+
+ + + """) + 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("""\ + + + +
+

+ + + Valid + +

+
+ + + """) + 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("""\ + + + +
+

+ + Word + + + Valid + +

+
+ + + """) + 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("""\ + + + +
+

+ + + +

+
+ + + """) + 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("""\ + + + +
+ Direct + Word +
+ + + """) + 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("""\ + + +
+

+ + NoNS + +

+
+ + + """) + 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" diff --git a/tests/test_ocr_element.py b/tests/test_ocr_element.py new file mode 100644 index 00000000..d5785fd7 --- /dev/null +++ b/tests/test_ocr_element.py @@ -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 diff --git a/tests/test_pdf_renderer.py b/tests/test_pdf_renderer.py new file mode 100644 index 00000000..b181d11b --- /dev/null +++ b/tests/test_pdf_renderer.py @@ -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