diff --git a/tests/concrete/test_table_rendering.py b/tests/concrete/test_table_rendering.py new file mode 100644 index 0000000..6fb1c50 --- /dev/null +++ b/tests/concrete/test_table_rendering.py @@ -0,0 +1,600 @@ +""" +Tests for table rendering components. + +This module tests: +- TableStyle: Styling configuration for tables +- TableCellRenderer: Individual cell rendering +- TableRowRenderer: Row rendering with multiple cells +- TableRenderer: Complete table rendering +""" + +import pytest +from PIL import Image, ImageDraw +from pyWebLayout.concrete.table import ( + TableStyle, + TableCellRenderer, + TableRowRenderer, + TableRenderer +) +from pyWebLayout.abstract.block import ( + Table, TableRow, TableCell, Paragraph, Heading, HeadingLevel, + Image as AbstractImage +) +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style import Font + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def sample_font(): + """Create a standard font for testing.""" + return Font(font_size=12, colour=(0, 0, 0)) + + +@pytest.fixture +def sample_canvas(): + """Create a PIL canvas for rendering.""" + return Image.new('RGB', (800, 600), color=(255, 255, 255)) + + +@pytest.fixture +def sample_draw(sample_canvas): + """Create a PIL ImageDraw object.""" + return ImageDraw.Draw(sample_canvas) + + +@pytest.fixture +def default_table_style(): + """Create default table style.""" + return TableStyle() + + +@pytest.fixture +def custom_table_style(): + """Create custom table style.""" + return TableStyle( + border_width=2, + border_color=(100, 100, 100), + cell_padding=(10, 10, 10, 10), + header_bg_color=(200, 200, 200), + cell_bg_color=(250, 250, 250), + alternate_row_color=(240, 240, 240), + cell_spacing=5 + ) + + +@pytest.fixture +def simple_table(sample_font): + """Create a simple table with header and body.""" + table = Table() + table.caption = "Test Table" + + # Header row + header_row = TableRow() + header_cell1 = TableCell(is_header=True) + header_p1 = Paragraph(sample_font) + header_p1.add_word(Word("Column", sample_font)) + header_p1.add_word(Word("1", sample_font)) + header_cell1.add_block(header_p1) + + header_cell2 = TableCell(is_header=True) + header_p2 = Paragraph(sample_font) + header_p2.add_word(Word("Column", sample_font)) + header_p2.add_word(Word("2", sample_font)) + header_cell2.add_block(header_p2) + + header_row.add_cell(header_cell1) + header_row.add_cell(header_cell2) + table.add_row(header_row, section="header") + + # Body row + body_row = TableRow() + body_cell1 = TableCell() + body_p1 = Paragraph(sample_font) + body_p1.add_word(Word("Data", sample_font)) + body_p1.add_word(Word("1", sample_font)) + body_cell1.add_block(body_p1) + + body_cell2 = TableCell() + body_p2 = Paragraph(sample_font) + body_p2.add_word(Word("Data", sample_font)) + body_p2.add_word(Word("2", sample_font)) + body_cell2.add_block(body_p2) + + body_row.add_cell(body_cell1) + body_row.add_cell(body_cell2) + table.add_row(body_row, section="body") + + return table + + +# ============================================================================ +# TableStyle Tests +# ============================================================================ + +class TestTableStyle: + """Tests for TableStyle dataclass.""" + + def test_default_initialization(self): + """Test TableStyle with default values.""" + style = TableStyle() + + assert style.border_width == 1 + assert style.border_color == (0, 0, 0) + assert style.cell_padding == (5, 5, 5, 5) + assert style.header_bg_color == (240, 240, 240) + assert style.header_text_bold is True + assert style.cell_bg_color == (255, 255, 255) + assert style.alternate_row_color == (250, 250, 250) + assert style.cell_spacing == 0 + + def test_custom_initialization(self): + """Test TableStyle with custom values.""" + style = TableStyle( + border_width=3, + border_color=(255, 0, 0), + cell_padding=(10, 15, 20, 25), + header_bg_color=(100, 100, 100), + header_text_bold=False, + cell_bg_color=(200, 200, 200), + alternate_row_color=None, + cell_spacing=10 + ) + + assert style.border_width == 3 + assert style.border_color == (255, 0, 0) + assert style.cell_padding == (10, 15, 20, 25) + assert style.header_bg_color == (100, 100, 100) + assert style.header_text_bold is False + assert style.cell_bg_color == (200, 200, 200) + assert style.alternate_row_color is None + assert style.cell_spacing == 10 + + def test_all_attributes_accessible(self, custom_table_style): + """Test that all style attributes are accessible.""" + # Verify all attributes exist and are accessible + assert hasattr(custom_table_style, 'border_width') + assert hasattr(custom_table_style, 'border_color') + assert hasattr(custom_table_style, 'cell_padding') + assert hasattr(custom_table_style, 'header_bg_color') + assert hasattr(custom_table_style, 'header_text_bold') + assert hasattr(custom_table_style, 'cell_bg_color') + assert hasattr(custom_table_style, 'alternate_row_color') + assert hasattr(custom_table_style, 'cell_spacing') + + +# ============================================================================ +# TableCellRenderer Tests +# ============================================================================ + +class TestTableCellRenderer: + """Tests for TableCellRenderer.""" + + def test_initialization(self, sample_font, sample_draw, default_table_style): + """Test TableCellRenderer initialization.""" + cell = TableCell() + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(100, 50), + draw=sample_draw, + style=default_table_style + ) + + assert cell_renderer._cell == cell + # Origin and size may be numpy arrays, so compare values + import numpy as np + assert np.array_equal(cell_renderer._origin, (10, 10)) + assert np.array_equal(cell_renderer._size, (100, 50)) + assert cell_renderer._draw == sample_draw + assert cell_renderer._style == default_table_style + assert cell_renderer._is_header_section is False + + def test_initialization_with_header(self, sample_font, sample_draw, default_table_style): + """Test TableCellRenderer initialization for header cell.""" + cell = TableCell(is_header=True) + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(100, 50), + draw=sample_draw, + style=default_table_style, + is_header_section=True + ) + + assert cell_renderer._is_header_section is True + + def test_render_empty_cell(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering an empty cell.""" + cell = TableCell() + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(100, 50), + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = cell_renderer.render() + # Render returns None (draws directly on canvas) + assert result is None + + def test_render_cell_with_text(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering a cell with text content.""" + cell = TableCell() + paragraph = Paragraph(sample_font) + paragraph.add_word(Word("Test", sample_font)) + paragraph.add_word(Word("Content", sample_font)) + cell.add_block(paragraph) + + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(200, 50), + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = cell_renderer.render() + assert result is None + + def test_render_header_cell(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering a header cell with different styling.""" + cell = TableCell(is_header=True) + paragraph = Paragraph(sample_font) + paragraph.add_word(Word("Header", sample_font)) + cell.add_block(paragraph) + + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(200, 50), + draw=sample_draw, + style=default_table_style, + is_header_section=True, + canvas=sample_canvas + ) + + result = cell_renderer.render() + assert result is None + + def test_render_cell_with_heading(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering a cell with heading content.""" + cell = TableCell() + heading = Heading(HeadingLevel.H2, sample_font) + heading.add_word(Word("Heading", sample_font)) + cell.add_block(heading) + + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(200, 50), + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = cell_renderer.render() + assert result is None + + def test_in_object(self, sample_font, sample_draw, default_table_style): + """Test in_object method for point detection.""" + cell = TableCell() + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(100, 50), + draw=sample_draw, + style=default_table_style + ) + + # Point inside cell + assert cell_renderer.in_object((50, 30)) == True + + # Point outside cell + assert cell_renderer.in_object((150, 30)) == False + assert cell_renderer.in_object((50, 100)) == False + + def test_properties_access(self, sample_font, sample_draw, default_table_style): + """Test accessing cell renderer properties.""" + import numpy as np + cell = TableCell() + cell_renderer = TableCellRenderer( + cell, + origin=(10, 10), + size=(100, 50), + draw=sample_draw, + style=default_table_style + ) + + # May be numpy arrays + assert np.array_equal(cell_renderer._origin, (10, 10)) + assert np.array_equal(cell_renderer._size, (100, 50)) + + +# ============================================================================ +# TableRowRenderer Tests +# ============================================================================ + +class TestTableRowRenderer: + """Tests for TableRowRenderer.""" + + def test_initialization(self, sample_font, sample_draw, default_table_style): + """Test TableRowRenderer initialization.""" + row = TableRow() + column_widths = [100, 150, 200] + + row_renderer = TableRowRenderer( + row, + origin=(10, 10), + column_widths=column_widths, + row_height=50, + draw=sample_draw, + style=default_table_style + ) + + assert row_renderer._row == row + assert row_renderer._column_widths == column_widths + assert row_renderer._row_height == 50 + assert row_renderer._draw == sample_draw + assert row_renderer._style == default_table_style + assert row_renderer._is_header_section is False + + def test_render_empty_row(self, sample_font, sample_draw, default_table_style): + """Test rendering an empty row.""" + row = TableRow() + + row_renderer = TableRowRenderer( + row, + origin=(10, 10), + column_widths=[100, 100], + row_height=50, + draw=sample_draw, + style=default_table_style + ) + + result = row_renderer.render() + assert result is None + + def test_render_row_with_cells(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering a row with multiple cells.""" + row = TableRow() + + # Add cells to row + for i in range(3): + cell = TableCell() + paragraph = Paragraph(sample_font) + paragraph.add_word(Word(f"Cell{i}", sample_font)) + cell.add_block(paragraph) + row.add_cell(cell) + + row_renderer = TableRowRenderer( + row, + origin=(10, 10), + column_widths=[100, 100, 100], + row_height=50, + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = row_renderer.render() + assert result is None + # Verify cells were created + assert len(row_renderer._cell_renderers) == 3 + + def test_render_row_with_colspan(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering a row with cells that span multiple columns.""" + row = TableRow() + + # Cell with colspan=2 + cell1 = TableCell(colspan=2) + paragraph1 = Paragraph(sample_font) + paragraph1.add_word(Word("Spanning", sample_font)) + cell1.add_block(paragraph1) + row.add_cell(cell1) + + # Normal cell + cell2 = TableCell() + paragraph2 = Paragraph(sample_font) + paragraph2.add_word(Word("Normal", sample_font)) + cell2.add_block(paragraph2) + row.add_cell(cell2) + + row_renderer = TableRowRenderer( + row, + origin=(10, 10), + column_widths=[100, 100, 100], + row_height=50, + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = row_renderer.render() + assert result is None + + +# ============================================================================ +# TableRenderer Tests +# ============================================================================ + +class TestTableRenderer: + """Tests for TableRenderer.""" + + def test_initialization(self, simple_table, sample_draw, default_table_style): + """Test TableRenderer initialization.""" + import numpy as np + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style + ) + + assert table_renderer._table == simple_table + assert np.array_equal(table_renderer._origin, (10, 10)) + assert table_renderer._available_width == 600 + assert table_renderer._draw == sample_draw + assert table_renderer._style == default_table_style + + def test_dimension_calculation(self, simple_table, sample_draw, default_table_style): + """Test table dimension calculation.""" + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style + ) + + # Check that dimensions were calculated + assert len(table_renderer._column_widths) == 2 + assert len(table_renderer._row_heights) == 3 # header, body, footer + assert all(width > 0 for width in table_renderer._column_widths) + + def test_render_simple_table(self, simple_table, sample_draw, sample_canvas, default_table_style): + """Test rendering a complete simple table.""" + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = table_renderer.render() + assert result is None + # Verify rows were created + assert len(table_renderer._row_renderers) == 2 # 1 header + 1 body + + def test_render_table_with_caption(self, simple_table, sample_draw, sample_canvas, default_table_style): + """Test rendering a table with caption.""" + simple_table.caption = "Test Table Caption" + + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = table_renderer.render() + assert result is None + + def test_height_property(self, simple_table, sample_draw, default_table_style): + """Test table height property.""" + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style + ) + + height = table_renderer.height + assert isinstance(height, int) + assert height > 0 + + def test_width_property(self, simple_table, sample_draw, default_table_style): + """Test table width property.""" + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style + ) + + width = table_renderer.width + assert isinstance(width, int) + assert width > 0 + assert width <= 600 # Should not exceed available width + + def test_empty_table(self, sample_draw, default_table_style): + """Test rendering an empty table.""" + empty_table = Table() + + table_renderer = TableRenderer( + empty_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style + ) + + # Should handle gracefully + assert table_renderer is not None + + def test_table_with_footer(self, sample_font, sample_draw, sample_canvas, default_table_style): + """Test rendering a table with footer rows.""" + table = Table() + + # Add header + header_row = TableRow() + header_cell = TableCell(is_header=True) + header_p = Paragraph(sample_font) + header_p.add_word(Word("Header", sample_font)) + header_cell.add_block(header_p) + header_row.add_cell(header_cell) + table.add_row(header_row, section="header") + + # Add body + body_row = TableRow() + body_cell = TableCell() + body_p = Paragraph(sample_font) + body_p.add_word(Word("Body", sample_font)) + body_cell.add_block(body_p) + body_row.add_cell(body_cell) + table.add_row(body_row, section="body") + + # Add footer + footer_row = TableRow() + footer_cell = TableCell() + footer_p = Paragraph(sample_font) + footer_p.add_word(Word("Footer", sample_font)) + footer_cell.add_block(footer_p) + footer_row.add_cell(footer_cell) + table.add_row(footer_row, section="footer") + + table_renderer = TableRenderer( + table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style, + canvas=sample_canvas + ) + + result = table_renderer.render() + assert result is None + assert len(table_renderer._row_renderers) == 3 # header + body + footer + + def test_in_object(self, simple_table, sample_draw, default_table_style): + """Test in_object method for table.""" + table_renderer = TableRenderer( + simple_table, + origin=(10, 10), + available_width=600, + draw=sample_draw, + style=default_table_style + ) + + # Point inside table + assert table_renderer.in_object((50, 50)) == True + + # Point outside table + assert table_renderer.in_object((1000, 1000)) == False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/layout/test_ereader_layout.py b/tests/layout/test_ereader_layout.py new file mode 100644 index 0000000..8c9a7c8 --- /dev/null +++ b/tests/layout/test_ereader_layout.py @@ -0,0 +1,878 @@ +""" +Tests for the ereader layout system components. + +This module tests: +- RenderingPosition: Position tracking and serialization +- ChapterNavigator: Chapter detection and navigation +- FontScaler: Font scaling utilities +- BidirectionalLayouter: Forward/backward page rendering +""" + +import pytest +from pyWebLayout.layout.ereader_layout import ( + RenderingPosition, + ChapterNavigator, + ChapterInfo, + FontScaler, + BidirectionalLayouter +) +from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Table, HList +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style import Font +from pyWebLayout.style.page_style import PageStyle + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def sample_font(): + """Create a standard font for testing.""" + return Font(font_size=12, colour=(0, 0, 0)) + + +@pytest.fixture +def sample_blocks_with_headings(sample_font): + """Create a sample document structure with headings for testing.""" + blocks = [] + + # H1 - Chapter 1 + h1 = Heading(HeadingLevel.H1, sample_font) + h1.add_word(Word("Chapter", sample_font)) + h1.add_word(Word("One", sample_font)) + blocks.append(h1) + + # Paragraph + p1 = Paragraph(sample_font) + p1.add_word(Word("This", sample_font)) + p1.add_word(Word("is", sample_font)) + p1.add_word(Word("content", sample_font)) + blocks.append(p1) + + # H2 - Section 1.1 + h2 = Heading(HeadingLevel.H2, sample_font) + h2.add_word(Word("Section", sample_font)) + h2.add_word(Word("1.1", sample_font)) + blocks.append(h2) + + # Another paragraph + p2 = Paragraph(sample_font) + p2.add_word(Word("More", sample_font)) + p2.add_word(Word("text", sample_font)) + blocks.append(p2) + + # H1 - Chapter 2 + h1_2 = Heading(HeadingLevel.H1, sample_font) + h1_2.add_word(Word("Chapter", sample_font)) + h1_2.add_word(Word("Two", sample_font)) + blocks.append(h1_2) + + return blocks + + +@pytest.fixture +def sample_page_style(): + """Create a standard page style for testing.""" + return PageStyle() + + +# ============================================================================ +# RenderingPosition Tests +# ============================================================================ + +class TestRenderingPosition: + """Tests for the RenderingPosition dataclass.""" + + def test_default_initialization(self): + """Test RenderingPosition initializes with default values.""" + pos = RenderingPosition() + assert pos.chapter_index == 0 + assert pos.block_index == 0 + assert pos.word_index == 0 + assert pos.table_row == 0 + assert pos.table_col == 0 + assert pos.list_item_index == 0 + assert pos.remaining_pretext is None + assert pos.page_y_offset == 0 + + def test_custom_initialization(self): + """Test RenderingPosition with custom values.""" + pos = RenderingPosition( + chapter_index=2, + block_index=5, + word_index=10, + table_row=1, + table_col=2, + list_item_index=3, + remaining_pretext="test", + page_y_offset=100 + ) + assert pos.chapter_index == 2 + assert pos.block_index == 5 + assert pos.word_index == 10 + assert pos.table_row == 1 + assert pos.table_col == 2 + assert pos.list_item_index == 3 + assert pos.remaining_pretext == "test" + assert pos.page_y_offset == 100 + + def test_to_dict_serialization(self): + """Test serialization to dictionary.""" + pos = RenderingPosition( + chapter_index=1, + block_index=2, + word_index=3 + ) + result = pos.to_dict() + + assert isinstance(result, dict) + assert result['chapter_index'] == 1 + assert result['block_index'] == 2 + assert result['word_index'] == 3 + assert 'table_row' in result + assert 'remaining_pretext' in result + + def test_from_dict_deserialization(self): + """Test deserialization from dictionary.""" + data = { + 'chapter_index': 2, + 'block_index': 4, + 'word_index': 6, + 'table_row': 1, + 'table_col': 0, + 'list_item_index': 0, + 'remaining_pretext': None, + 'page_y_offset': 50 + } + pos = RenderingPosition.from_dict(data) + + assert pos.chapter_index == 2 + assert pos.block_index == 4 + assert pos.word_index == 6 + assert pos.page_y_offset == 50 + + def test_round_trip_serialization(self): + """Test serialization and deserialization round trip.""" + original = RenderingPosition( + chapter_index=3, + block_index=7, + word_index=15, + remaining_pretext="hyphen-", + page_y_offset=200 + ) + + # Serialize and deserialize + data = original.to_dict() + restored = RenderingPosition.from_dict(data) + + assert original == restored + + def test_copy_creates_independent_copy(self): + """Test that copy() creates an independent copy.""" + original = RenderingPosition( + chapter_index=1, + block_index=2, + word_index=3 + ) + + copy = original.copy() + + # Verify values match + assert copy.chapter_index == original.chapter_index + assert copy.block_index == original.block_index + assert copy.word_index == original.word_index + + # Modify copy and verify original is unchanged + copy.chapter_index = 99 + copy.word_index = 100 + + assert original.chapter_index == 1 + assert original.word_index == 3 + + def test_equality_same_values(self): + """Test equality comparison with same values.""" + pos1 = RenderingPosition(chapter_index=1, block_index=2) + pos2 = RenderingPosition(chapter_index=1, block_index=2) + + assert pos1 == pos2 + + def test_equality_different_values(self): + """Test equality comparison with different values.""" + pos1 = RenderingPosition(chapter_index=1, block_index=2) + pos2 = RenderingPosition(chapter_index=1, block_index=3) + + assert pos1 != pos2 + + def test_equality_with_non_position(self): + """Test equality comparison with non-RenderingPosition object.""" + pos = RenderingPosition() + + assert pos != "not a position" + assert pos != 42 + assert pos != None + + def test_hashability(self): + """Test that RenderingPosition is hashable and can be used in sets/dicts.""" + pos1 = RenderingPosition(chapter_index=1, block_index=2) + pos2 = RenderingPosition(chapter_index=1, block_index=2) + pos3 = RenderingPosition(chapter_index=1, block_index=3) + + # Test in set + position_set = {pos1, pos2, pos3} + assert len(position_set) == 2 # pos1 and pos2 should be same + + # Test as dict key + position_dict = {pos1: "value1"} + assert position_dict[pos2] == "value1" # pos2 should access same key + + def test_hash_consistency(self): + """Test that hash values are consistent.""" + pos1 = RenderingPosition(chapter_index=5, block_index=10) + pos2 = RenderingPosition(chapter_index=5, block_index=10) + + assert hash(pos1) == hash(pos2) + + +# ============================================================================ +# ChapterNavigator Tests +# ============================================================================ + +class TestChapterNavigator: + """Tests for the ChapterNavigator class.""" + + def test_initialization(self, sample_blocks_with_headings): + """Test ChapterNavigator initialization.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + assert navigator.blocks == sample_blocks_with_headings + assert len(navigator.chapters) > 0 + + def test_build_chapter_map_finds_headings(self, sample_blocks_with_headings): + """Test that chapter map correctly identifies headings.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + # Should find 3 headings: H1, H2, H1 + assert len(navigator.chapters) == 3 + + def test_chapter_info_properties(self, sample_blocks_with_headings): + """Test ChapterInfo contains correct properties.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + first_chapter = navigator.chapters[0] + assert isinstance(first_chapter, ChapterInfo) + assert first_chapter.title == "Chapter One" + assert first_chapter.level == HeadingLevel.H1 + assert isinstance(first_chapter.position, RenderingPosition) + assert first_chapter.block_index == 0 + + def test_heading_text_extraction(self, sample_blocks_with_headings): + """Test extraction of heading text from Word objects.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + # Check extracted titles + titles = [ch.title for ch in navigator.chapters] + assert "Chapter One" in titles + assert "Section 1.1" in titles + assert "Chapter Two" in titles + + def test_chapter_index_tracking(self, sample_blocks_with_headings): + """Test that chapter indices are tracked correctly for H1 headings.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + # Note: The current implementation increments AFTER adding the chapter + # So chapter_index values are: H1=0 (then inc to 1), H2=1, H1=1 (then inc to 2) + # First H1 should be chapter 0 + assert navigator.chapters[0].position.chapter_index == 0 + # H2 gets chapter_index 1 (after first H1 increment) + assert navigator.chapters[1].position.chapter_index == 1 + # Second H1 also gets chapter_index 1 (then increments to 2) + assert navigator.chapters[2].position.chapter_index == 1 + + def test_get_table_of_contents(self, sample_blocks_with_headings): + """Test generation of table of contents.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + toc = navigator.get_table_of_contents() + + assert isinstance(toc, list) + assert len(toc) == 3 + + # Each entry should be (title, level, position) + for entry in toc: + assert isinstance(entry, tuple) + assert len(entry) == 3 + assert isinstance(entry[0], str) # title + assert isinstance(entry[1], HeadingLevel) # level + assert isinstance(entry[2], RenderingPosition) # position + + def test_get_chapter_position_exact_match(self, sample_blocks_with_headings): + """Test finding chapter by exact title match.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + position = navigator.get_chapter_position("Chapter One") + assert position is not None + assert position.block_index == 0 + + def test_get_chapter_position_case_insensitive(self, sample_blocks_with_headings): + """Test case-insensitive chapter title matching.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + position = navigator.get_chapter_position("chapter one") + assert position is not None + + position2 = navigator.get_chapter_position("CHAPTER ONE") + assert position2 is not None + assert position == position2 + + def test_get_chapter_position_not_found(self, sample_blocks_with_headings): + """Test getting position for non-existent chapter.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + position = navigator.get_chapter_position("Nonexistent Chapter") + assert position is None + + def test_get_current_chapter_at_start(self, sample_blocks_with_headings): + """Test getting current chapter at document start.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + position = RenderingPosition(chapter_index=0, block_index=0) + current = navigator.get_current_chapter(position) + + assert current is not None + assert current.title == "Chapter One" + + def test_get_current_chapter_in_middle(self, sample_blocks_with_headings): + """Test getting current chapter in middle of document.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + # Position at block 3 should be in Chapter One + position = RenderingPosition(chapter_index=0, block_index=3) + current = navigator.get_current_chapter(position) + + assert current is not None + assert "Chapter One" in current.title or "Section 1.1" in current.title + + def test_get_current_chapter_at_end(self, sample_blocks_with_headings): + """Test getting current chapter at document end.""" + navigator = ChapterNavigator(sample_blocks_with_headings) + + position = RenderingPosition(chapter_index=1, block_index=4) + current = navigator.get_current_chapter(position) + + assert current is not None + assert current.title == "Chapter Two" + + def test_empty_document_no_chapters(self, sample_font): + """Test navigator with document containing no headings.""" + # Document with only paragraphs + blocks = [ + Paragraph(sample_font), + Paragraph(sample_font) + ] + + navigator = ChapterNavigator(blocks) + + assert len(navigator.chapters) == 0 + assert navigator.get_table_of_contents() == [] + + position = RenderingPosition() + assert navigator.get_current_chapter(position) is None + + def test_multiple_heading_levels(self, sample_font): + """Test navigator with multiple heading levels H1-H6.""" + blocks = [] + + # Create headings of each level + for level in [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, + HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]: + heading = Heading(level, sample_font) + heading.add_word(Word(f"Heading {level.value}", sample_font)) + blocks.append(heading) + + navigator = ChapterNavigator(blocks) + + # Should find all 6 headings + assert len(navigator.chapters) == 6 + + # Note: Due to implementation, H1 increments chapter_index AFTER being added + # So: H1=0 (inc to 1), H2=1, H3=1, H4=1, H5=1, H6=1 + chapter_indices = [ch.position.chapter_index for ch in navigator.chapters] + assert chapter_indices[0] == 0 # H1 gets 0, then increments + assert all(idx == 1 for idx in chapter_indices[1:]) # H2-H6 all get 1 + + +# ============================================================================ +# FontScaler Tests +# ============================================================================ + +class TestFontScaler: + """Tests for the FontScaler utility class.""" + + def test_scale_font_no_change(self, sample_font): + """Test scaling font with factor 1.0 returns same font.""" + scaled = FontScaler.scale_font(sample_font, 1.0) + + # Should return the original font when scale is 1.0 + assert scaled == sample_font + + def test_scale_font_double_size(self, sample_font): + """Test scaling font to double size.""" + original_size = sample_font.font_size + scaled = FontScaler.scale_font(sample_font, 2.0) + + assert scaled.font_size == original_size * 2 + + def test_scale_font_half_size(self, sample_font): + """Test scaling font to half size.""" + original_size = sample_font.font_size + scaled = FontScaler.scale_font(sample_font, 0.5) + + assert scaled.font_size == int(original_size * 0.5) + + def test_scale_font_preserves_color(self, sample_font): + """Test that font scaling preserves color.""" + scaled = FontScaler.scale_font(sample_font, 1.5) + + assert scaled.colour == sample_font.colour + + def test_scale_font_preserves_properties(self): + """Test that font scaling preserves all font properties.""" + font = Font( + font_size=14, + colour=(255, 0, 0), + weight="bold", + style="italic" + ) + + scaled = FontScaler.scale_font(font, 1.5) + + assert scaled.colour == font.colour + assert scaled.weight == font.weight + assert scaled.style == font.style + + def test_scale_font_minimum_size(self): + """Test that font scaling maintains minimum size of 1.""" + font = Font(font_size=2) + + # Scale to very small + scaled = FontScaler.scale_font(font, 0.1) + + # Should be at least 1 + assert scaled.font_size >= 1 + + def test_scale_word_spacing_no_change(self): + """Test scaling word spacing with factor 1.0.""" + spacing = (5, 10) + scaled = FontScaler.scale_word_spacing(spacing, 1.0) + + assert scaled == spacing + + def test_scale_word_spacing_double(self): + """Test scaling word spacing to double.""" + spacing = (5, 10) + scaled = FontScaler.scale_word_spacing(spacing, 2.0) + + assert scaled == (10, 20) + + def test_scale_word_spacing_maintains_minimum(self): + """Test that word spacing maintains minimum values.""" + spacing = (2, 4) + scaled = FontScaler.scale_word_spacing(spacing, 0.1) + + # Min spacing should be at least 1, max at least 2 + assert scaled[0] >= 1 + assert scaled[1] >= 2 + + def test_scale_font_with_large_factor(self): + """Test scaling with very large factor.""" + font = Font(font_size=12) + scaled = FontScaler.scale_font(font, 10.0) + + assert scaled.font_size == 120 + + def test_scale_font_with_small_factor(self): + """Test scaling with very small factor.""" + font = Font(font_size=12) + scaled = FontScaler.scale_font(font, 0.05) + + # Should still be at least 1 + assert scaled.font_size >= 1 + + +# ============================================================================ +# BidirectionalLayouter Tests (Basic) +# ============================================================================ + +class TestBidirectionalLayouter: + """Tests for the BidirectionalLayouter class.""" + + def test_initialization(self, sample_blocks_with_headings, sample_page_style): + """Test BidirectionalLayouter initialization.""" + layouter = BidirectionalLayouter( + sample_blocks_with_headings, + sample_page_style, + page_size=(800, 600) + ) + + assert layouter.blocks == sample_blocks_with_headings + assert layouter.page_style == sample_page_style + assert layouter.page_size == (800, 600) + assert isinstance(layouter.chapter_navigator, ChapterNavigator) + + def test_position_compare_equal(self): + """Test position comparison for equal positions.""" + layouter = BidirectionalLayouter([], PageStyle()) + + pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3) + pos2 = RenderingPosition(chapter_index=1, block_index=2, word_index=3) + + assert layouter._position_compare(pos1, pos2) == 0 + + def test_position_compare_chapter_difference(self): + """Test position comparison with different chapters.""" + layouter = BidirectionalLayouter([], PageStyle()) + + pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3) + pos2 = RenderingPosition(chapter_index=2, block_index=2, word_index=3) + + assert layouter._position_compare(pos1, pos2) == -1 + assert layouter._position_compare(pos2, pos1) == 1 + + def test_position_compare_block_difference(self): + """Test position comparison with different blocks.""" + layouter = BidirectionalLayouter([], PageStyle()) + + pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3) + pos2 = RenderingPosition(chapter_index=1, block_index=5, word_index=3) + + assert layouter._position_compare(pos1, pos2) == -1 + assert layouter._position_compare(pos2, pos1) == 1 + + def test_position_compare_word_difference(self): + """Test position comparison with different words.""" + layouter = BidirectionalLayouter([], PageStyle()) + + pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3) + pos2 = RenderingPosition(chapter_index=1, block_index=2, word_index=10) + + assert layouter._position_compare(pos1, pos2) == -1 + assert layouter._position_compare(pos2, pos1) == 1 + + def test_scale_block_fonts_no_scaling(self, sample_font): + """Test block font scaling with factor 1.0.""" + layouter = BidirectionalLayouter([], PageStyle()) + + paragraph = Paragraph(sample_font) + paragraph.add_word(Word("test", sample_font)) + + scaled = layouter._scale_block_fonts(paragraph, 1.0) + + # Should return same block + assert scaled == paragraph + + def test_estimate_page_start(self): + """Test estimation of page start position.""" + layouter = BidirectionalLayouter([], PageStyle()) + + end_pos = RenderingPosition(chapter_index=0, block_index=20, word_index=0) + + estimated = layouter._estimate_page_start(end_pos, 1.0) + + # Should estimate some blocks before the end position + assert estimated.block_index < end_pos.block_index + assert estimated.block_index >= 0 + + def test_estimate_page_start_with_font_scale(self): + """Test that font scale affects page start estimation.""" + layouter = BidirectionalLayouter([], PageStyle()) + + end_pos = RenderingPosition(chapter_index=0, block_index=20, word_index=0) + + est_normal = layouter._estimate_page_start(end_pos, 1.0) + est_large = layouter._estimate_page_start(end_pos, 2.0) + + # Larger font should estimate fewer blocks + assert est_large.block_index >= est_normal.block_index + + + def test_scale_block_fonts_paragraph(self, sample_font): + """Test scaling fonts in a paragraph block.""" + layouter = BidirectionalLayouter([], PageStyle()) + + paragraph = Paragraph(sample_font) + paragraph.add_word(Word("Hello", sample_font)) + paragraph.add_word(Word("World", sample_font)) + + scaled = layouter._scale_block_fonts(paragraph, 2.0) + + # Should be a new paragraph + assert isinstance(scaled, Paragraph) + assert scaled != paragraph + + # Check that words were scaled (words is a list, not a method) + words = scaled.words if hasattr(scaled, 'words') and isinstance(scaled.words, list) else list(scaled.words_iter()) + assert len(words) >= 2 + + def test_scale_block_fonts_heading(self, sample_font): + """Test scaling fonts in a heading block.""" + layouter = BidirectionalLayouter([], PageStyle()) + + heading = Heading(HeadingLevel.H1, sample_font) + heading.add_word(Word("Title", sample_font)) + + scaled = layouter._scale_block_fonts(heading, 1.5) + + # Should be a new heading + assert isinstance(scaled, Heading) + assert scaled.level == HeadingLevel.H1 + + def test_layout_block_on_page_unknown_type(self, sample_font): + """Test layout of unknown block type skips gracefully.""" + layouter = BidirectionalLayouter([], PageStyle()) + + # Create a mock block that's not a known type + from pyWebLayout.concrete.page import Page + from pyWebLayout.abstract.block import Block, BlockType + page = Page(size=(800, 600), style=PageStyle()) + position = RenderingPosition() + + # Use a simple block (not Paragraph, Heading, Table, or HList) + unknown_block = Block(BlockType.HORIZONTAL_RULE) + + success, new_pos = layouter._layout_block_on_page(unknown_block, page, position, 1.0) + + # Should skip and move to next block + assert success is True + assert new_pos.block_index == 1 + + def test_layout_table_on_page(self, sample_font): + """Test table layout on page (currently skips).""" + layouter = BidirectionalLayouter([], PageStyle()) + + from pyWebLayout.concrete.page import Page + from pyWebLayout.abstract.block import Table + + page = Page(size=(800, 600), style=PageStyle()) + table = Table() + position = RenderingPosition() + + success, new_pos = layouter._layout_table_on_page(table, page, position, 1.0) + + # Currently skips tables + assert success is True + assert new_pos.block_index == 1 + assert new_pos.table_row == 0 + assert new_pos.table_col == 0 + + def test_layout_list_on_page(self, sample_font): + """Test list layout on page (currently skips).""" + layouter = BidirectionalLayouter([], PageStyle()) + + from pyWebLayout.concrete.page import Page + from pyWebLayout.abstract.block import HList + + page = Page(size=(800, 600), style=PageStyle()) + hlist = HList() + position = RenderingPosition() + + success, new_pos = layouter._layout_list_on_page(hlist, page, position, 1.0) + + # Currently skips lists + assert success is True + assert new_pos.block_index == 1 + assert new_pos.list_item_index == 0 + + def test_render_page_forward_simple(self, sample_blocks_with_headings, sample_page_style): + """Test forward page rendering with simple blocks.""" + layouter = BidirectionalLayouter( + sample_blocks_with_headings, + sample_page_style, + page_size=(800, 600) + ) + + position = RenderingPosition() + page, next_pos = layouter.render_page_forward(position, font_scale=1.0) + + # Should render a page + from pyWebLayout.concrete.page import Page + assert isinstance(page, Page) + + # Position should advance + assert next_pos.block_index >= position.block_index + + def test_render_page_forward_with_font_scale(self, sample_blocks_with_headings, sample_page_style): + """Test forward rendering with font scaling.""" + layouter = BidirectionalLayouter( + sample_blocks_with_headings, + sample_page_style, + page_size=(800, 600) + ) + + position = RenderingPosition() + + # Render with normal font + page1, next_pos1 = layouter.render_page_forward(position, font_scale=1.0) + + # Render with larger font (should fit less content) + page2, next_pos2 = layouter.render_page_forward(position, font_scale=2.0) + + # Both should produce pages + assert page1 is not None + assert page2 is not None + + def test_render_page_forward_at_end(self, sample_blocks_with_headings, sample_page_style): + """Test forward rendering at end of document.""" + layouter = BidirectionalLayouter( + sample_blocks_with_headings, + sample_page_style, + page_size=(800, 600) + ) + + # Position at last block + position = RenderingPosition(block_index=len(sample_blocks_with_headings) - 1) + page, next_pos = layouter.render_page_forward(position, font_scale=1.0) + + # Should still render a page + assert page is not None + + def test_render_page_forward_beyond_end(self, sample_blocks_with_headings, sample_page_style): + """Test forward rendering beyond document end.""" + layouter = BidirectionalLayouter( + sample_blocks_with_headings, + sample_page_style, + page_size=(800, 600) + ) + + # Position beyond last block + position = RenderingPosition(block_index=len(sample_blocks_with_headings) + 10) + page, next_pos = layouter.render_page_forward(position, font_scale=1.0) + + # Should handle gracefully + assert page is not None + + def test_render_page_backward_simple(self, sample_blocks_with_headings, sample_page_style): + """Test backward page rendering.""" + layouter = BidirectionalLayouter( + sample_blocks_with_headings, + sample_page_style, + page_size=(800, 600) + ) + + # Start from middle of document + end_position = RenderingPosition(block_index=3) + page, start_pos = layouter.render_page_backward(end_position, font_scale=1.0) + + # Should render a page + assert page is not None + + # Start position should be before or at end position + assert start_pos.block_index <= end_position.block_index + + def test_adjust_start_estimate_overshot(self): + """Test adjustment when forward render overshoots target.""" + layouter = BidirectionalLayouter([], PageStyle()) + + current_start = RenderingPosition(block_index=5) + target_end = RenderingPosition(block_index=10) + actual_end = RenderingPosition(block_index=12) # Overshot + + adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end) + + # Should move start forward (increase block_index) + assert adjusted.block_index > current_start.block_index + + def test_adjust_start_estimate_undershot(self): + """Test adjustment when forward render undershoots target.""" + layouter = BidirectionalLayouter([], PageStyle()) + + current_start = RenderingPosition(block_index=5) + target_end = RenderingPosition(block_index=10) + actual_end = RenderingPosition(block_index=8) # Undershot + + adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end) + + # Should move start backward (decrease block_index) + assert adjusted.block_index <= current_start.block_index + + def test_adjust_start_estimate_exact(self): + """Test adjustment when forward render hits target exactly.""" + layouter = BidirectionalLayouter([], PageStyle()) + + current_start = RenderingPosition(block_index=5) + target_end = RenderingPosition(block_index=10) + actual_end = RenderingPosition(block_index=10) # Exact + + adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end) + + # Should return same or similar position + assert adjusted.block_index >= 0 + + def test_layout_paragraph_on_page_with_pretext(self, sample_font, sample_page_style): + """Test paragraph layout with pretext (hyphenated word continuation).""" + layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600)) + + from pyWebLayout.concrete.page import Page + + paragraph = Paragraph(sample_font) + paragraph.add_word(Word("continuation", sample_font)) + + page = Page(size=(800, 600), style=sample_page_style) + position = RenderingPosition(remaining_pretext="pre-") + + success, new_pos = layouter._layout_paragraph_on_page(paragraph, page, position, 1.0) + + # Should attempt to layout + assert isinstance(success, bool) + assert isinstance(new_pos, RenderingPosition) + + def test_layout_paragraph_success(self, sample_font, sample_page_style): + """Test successful paragraph layout.""" + layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600)) + + from pyWebLayout.concrete.page import Page + + paragraph = Paragraph(sample_font) + paragraph.add_word(Word("Short", sample_font)) + paragraph.add_word(Word("text", sample_font)) + + page = Page(size=(800, 600), style=sample_page_style) + position = RenderingPosition() + + success, new_pos = layouter._layout_paragraph_on_page(paragraph, page, position, 1.0) + + # Should complete successfully + assert isinstance(success, bool) + + def test_layout_heading_on_page(self, sample_font, sample_page_style): + """Test heading layout delegates to paragraph layout.""" + layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600)) + + from pyWebLayout.concrete.page import Page + + heading = Heading(HeadingLevel.H1, sample_font) + heading.add_word(Word("Heading", sample_font)) + heading.add_word(Word("Text", sample_font)) + + page = Page(size=(800, 600), style=sample_page_style) + position = RenderingPosition() + + success, new_pos = layouter._layout_heading_on_page(heading, page, position, 1.0) + + # Should attempt to layout like a paragraph + assert isinstance(success, bool) + assert isinstance(new_pos, RenderingPosition) + + def test_empty_blocks_list(self, sample_page_style): + """Test rendering with empty blocks list.""" + layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600)) + + position = RenderingPosition() + page, next_pos = layouter.render_page_forward(position, font_scale=1.0) + + # Should handle empty document + assert page is not None + assert next_pos == position # No progress possible + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])