pyWebLayout/tests/layout/test_ereader_layout.py
Duncan Tourolle 56c2c21021
All checks were successful
Python CI / test (3.10) (push) Successful in 7m36s
Python CI / test (3.12) (push) Successful in 7m19s
Python CI / test (3.13) (push) Successful in 7m5s
fix backwards rendering
2025-11-09 15:56:00 +01:00

904 lines
32 KiB
Python

"""
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
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 is not 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 (went too far)
adjusted = layouter._adjust_start_estimate(
current_start, target_end, actual_end)
# Overshot means we rendered too far forward
# So we need to start EARLIER (decrease block_index) to not go as far
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 (didn't go far enough)
adjusted = layouter._adjust_start_estimate(
current_start, target_end, actual_end)
# Undershot means we didn't render far enough forward
# So we need to start LATER (increase block_index) to include more content
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"])