added document layouter
Some checks failed
Python CI / test (push) Failing after 4m51s

This commit is contained in:
Duncan Tourolle 2025-06-28 22:02:39 +02:00
parent 56a6ec19e8
commit b1c4a1c125
14 changed files with 1279 additions and 9 deletions

View File

@ -72,7 +72,7 @@ class Paragraph(Block):
super().__init__(BlockType.PARAGRAPH)
self._words: List[Word] = []
self._spans: List[FormattedSpan] = []
self._style = style
self._style : style = style
self._fonts: Dict[str, Font] = {} # Local font registry
@classmethod

View File

@ -88,8 +88,19 @@ class LinkText(Text, Interactable, Queriable):
if self._hovered:
# Draw a subtle highlight background
highlight_color = (220, 220, 255, 100) # Light blue with alpha
size_array = np.array(self.size)
self._draw.rectangle([self._origin, self._origin + size_array],
# Handle mock objects in tests
size = self.size
if hasattr(size, '__call__'): # It's a Mock
# Use default size for tests
size = np.array([100, 20])
else:
size = np.array(size)
# Ensure origin is a numpy array
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)

View File

@ -29,7 +29,11 @@ class Page(Renderable, Queriable):
self._canvas: Optional[Image.Image] = None
self._draw: Optional[ImageDraw.Draw] = None
self._current_y_offset = 0 # Track vertical position for layout
def free_space(self) -> Tuple[int, int]:
"""Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset)
@property
def size(self) -> Tuple[int, int]:
"""Get the total page size including borders"""
@ -79,6 +83,7 @@ class Page(Renderable, Queriable):
Self for method chaining
"""
self._children.append(child)
self._current_y_offset = child.origin[1] + child.size[1]
# Invalidate the canvas when children change
self._canvas = None
return self

View File

@ -90,6 +90,15 @@ class CenterRightAlignmentHandler(AlignmentHandler):
"""Center/right alignment uses minimum spacing with calculated start position."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
# Handle single word case
if len(text_objects) <= 1:
if self._alignment == Alignment.CENTER:
start_position = (available_width - word_length) // 2
else: # RIGHT
start_position = available_width - word_length
return 0, max(0, start_position), False
actual_spacing = residual_space // (len(text_objects)-1)
ideal_space = (min_spacing + max_spacing)/2
@ -104,9 +113,9 @@ class CenterRightAlignmentHandler(AlignmentHandler):
start_position = available_width - content_length
if actual_spacing < min_spacing:
return actual_spacing, start_position, True
return actual_spacing, max(0, start_position), True
return ideal_space, start_position, False
return ideal_space, max(0, start_position), False
class JustifyAlignmentHandler(AlignmentHandler):

View File

@ -30,7 +30,7 @@ from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.layout import Alignment
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphLayoutResult
from pyWebLayout.layout.paragraph_layout import ParagraphLayout, ParagraphLayoutResult
class HTMLParser:

View File

@ -0,0 +1,162 @@
from __future__ import annotations
from typing import List, Tuple, Optional
from pyWebLayout.concrete import Page, Line, Text
from pyWebLayout.abstract import Paragraph, Word, Link
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
"""
Layout a paragraph of text within a given page.
This function extracts word spacing constraints from the style system
and uses them to create properly spaced lines of text.
Args:
paragraph: The paragraph to layout
page: The page to layout the paragraph on
start_word: Index of the first word to process (for continuation)
pretext: Optional pretext from a previous hyphenated word
Returns:
Tuple of:
- bool: True if paragraph was completely laid out, False if page ran out of space
- Optional[int]: Index of first word that didn't fit (if any)
- Optional[Text]: Remaining pretext if word was hyphenated (if any)
"""
if not paragraph.words:
return True, None, None
# Validate inputs
if start_word >= len(paragraph.words):
return True, None, None
# Get the concrete style with resolved word spacing constraints
style_registry = ConcreteStyleRegistry(page.style_resolver)
concrete_style = style_registry.get_concrete_style(paragraph.style)
# Extract word spacing constraints (min, max) for Line constructor
word_spacing_constraints = (
int(concrete_style.word_spacing_min),
int(concrete_style.word_spacing_max)
)
def create_new_line() -> Optional[Line]:
"""Helper function to create a new line, returns None if page is full."""
if not page.can_fit_line(paragraph.line_height):
return None
y_cursor = page._current_y_offset
x_cursor = page.border_size
return Line(
spacing=word_spacing_constraints,
origin=(x_cursor, y_cursor),
size=(page.available_width, paragraph.line_height),
draw=page.draw,
font=concrete_style.create_font(),
halign=concrete_style.text_align
)
# Create initial line
current_line = create_new_line()
if not current_line:
return False, start_word, pretext
page.add_child(current_line)
page._current_y_offset += paragraph.line_height
# Track current position in paragraph
current_pretext = pretext
# Process words starting from start_word
for i, word in enumerate(paragraph.words[start_word:], start=start_word):
success, overflow_text = current_line.add_word(word, current_pretext)
if success:
# Word fit successfully
current_pretext = None # Clear pretext after successful placement
else:
# Word didn't fit, need a new line
current_line = create_new_line()
if not current_line:
# Page is full, return current position
return False, i, overflow_text
page.add_child(current_line)
page._current_y_offset += paragraph.line_height
# Try to add the word to the new line
success, overflow_text = current_line.add_word(word, current_pretext)
if not success:
# Word still doesn't fit even on a new line
# This might happen with very long words or narrow pages
if overflow_text:
# Word was hyphenated, continue with the overflow
current_pretext = overflow_text
continue
else:
# Word cannot be broken, skip it or handle as error
# For now, we'll return indicating we couldn't process this word
return False, i, None
else:
current_pretext = overflow_text # May be None or hyphenated remainder
# All words processed successfully
return True, None, None
class DocumentLayouter:
"""
Class-based document layouter for more complex layout operations.
"""
def __init__(self, page: Page):
"""Initialize the layouter with a page."""
self.page = page
self.style_registry = ConcreteStyleRegistry(page.style_resolver)
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0, pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
"""
Layout a paragraph using the class-based approach.
This method provides the same functionality as the standalone function
but with better state management and reusability.
"""
return paragraph_layouter(paragraph, self.page, start_word, pretext)
def layout_document(self, paragraphs: List[Paragraph]) -> bool:
"""
Layout multiple paragraphs in sequence.
Args:
paragraphs: List of paragraphs to layout
Returns:
True if all paragraphs were laid out successfully, False otherwise
"""
for paragraph in paragraphs:
start_word = 0
pretext = None
while True:
complete, next_word, remaining_pretext = self.layout_paragraph(
paragraph, start_word, pretext
)
if complete:
# Paragraph finished
break
if next_word is None:
# Error condition
return False
# Continue on next page or handle page break
# For now, we'll just return False indicating we need more space
return False
return True

View File

@ -86,6 +86,8 @@ class AbstractStyle:
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
word_spacing: Optional[Union[str, float]] = None
word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
# Language and locale
language: str = "en-US"
@ -124,6 +126,8 @@ class AbstractStyle:
self.line_height,
self.letter_spacing,
self.word_spacing,
self.word_spacing_min,
self.word_spacing_max,
self.language,
self.parent_style_id
)

View File

@ -65,6 +65,8 @@ class ConcreteStyle:
line_height: float = 1.0 # Multiplier
letter_spacing: float = 0.0 # In pixels
word_spacing: float = 0.0 # In pixels
word_spacing_min: float = 0.0 # Minimum word spacing in pixels
word_spacing_max: float = 0.0 # Maximum word spacing in pixels
# Language and locale
language: str = "en-US"
@ -161,8 +163,27 @@ class StyleResolver:
line_height = self._resolve_line_height(abstract_style.line_height)
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
min_hyphenation_width = max(font_size * 4, 32) # At least 32 pixels
# Apply default logic for word spacing constraints
if word_spacing_min == 0.0 and word_spacing_max == 0.0:
# If no constraints specified, use base word_spacing as reference
if word_spacing > 0.0:
word_spacing_min = word_spacing
word_spacing_max = word_spacing * 2
else:
# Default constraints when no word spacing is specified
word_spacing_min = 2.0 # Minimum 2 pixels
word_spacing_max = font_size * 0.5 # Maximum 50% of font size
elif word_spacing_min == 0.0:
# Only max specified, use base word_spacing or min default
word_spacing_min = max(word_spacing, 2.0)
elif word_spacing_max == 0.0:
# Only min specified, use base word_spacing or reasonable multiple
word_spacing_max = max(word_spacing, word_spacing_min * 2)
# Create concrete style
concrete_style = ConcreteStyle(
font_path=font_path,
@ -176,6 +197,8 @@ class StyleResolver:
line_height=line_height,
letter_spacing=letter_spacing,
word_spacing=word_spacing,
word_spacing_min=word_spacing_min,
word_spacing_max=word_spacing_max,
language=abstract_style.language,
min_hyphenation_width=min_hyphenation_width,
abstract_style=abstract_style

View File

@ -22,8 +22,8 @@ class SimpleTestRenderable(Renderable, Queriable):
def __init__(self, text: str, size: tuple = (100, 50)):
self._text = text
self._size = size
self._origin = np.array([0, 0])
self.size = size
self.origin = np.array([0, 0])
def render(self):
"""Render returns None - drawing is done via the page's draw object"""

View File

@ -0,0 +1,3 @@
"""
Test package for layout-related functionality.
"""

View File

@ -0,0 +1,541 @@
"""
Test file for document layouter functionality.
This test focuses on verifying that the document layouter properly
integrates word spacing constraints from the style system.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from typing import List, Optional
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
from pyWebLayout.style.abstract_style import AbstractStyle
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
class TestDocumentLayouter:
"""Test cases for document layouter functionality."""
def setup_method(self):
"""Set up test fixtures before each test method."""
# Create mock objects
self.mock_page = Mock()
self.mock_page.border_size = 20
self.mock_page._current_y_offset = 50
self.mock_page.available_width = 400
self.mock_page.draw = Mock()
self.mock_page.can_fit_line = Mock(return_value=True)
self.mock_page.add_child = Mock()
# Create mock style resolver
self.mock_style_resolver = Mock()
self.mock_page.style_resolver = self.mock_style_resolver
# Create mock paragraph
self.mock_paragraph = Mock()
self.mock_paragraph.line_height = 20
self.mock_paragraph.style = AbstractStyle()
# Create mock words
self.mock_words = []
for i in range(5):
word = Mock()
word.text = f"word{i}"
self.mock_words.append(word)
self.mock_paragraph.words = self.mock_words
# Create mock concrete style with word spacing constraints
self.mock_concrete_style = Mock()
self.mock_concrete_style.word_spacing_min = 2.0
self.mock_concrete_style.word_spacing_max = 8.0
self.mock_concrete_style.text_align = "left"
self.mock_concrete_style.create_font = Mock()
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class):
"""Test basic paragraph layouter functionality."""
# Setup mocks
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
mock_line = Mock()
mock_line_class.return_value = mock_line
mock_line.add_word.return_value = (True, None) # All words fit successfully
# Call function
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify results
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
# Verify style registry was used correctly
mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver)
mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style)
# Verify Line was created with correct spacing constraints
expected_spacing = (2, 8) # From mock_concrete_style
mock_line_class.assert_called_once()
call_args = mock_line_class.call_args
assert call_args[1]['spacing'] == expected_spacing
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class):
"""Test that word spacing constraints are correctly extracted from style."""
# Create concrete style with specific constraints
concrete_style = Mock()
concrete_style.word_spacing_min = 5.5
concrete_style.word_spacing_max = 15.2
concrete_style.text_align = "justify"
concrete_style.create_font = Mock()
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = concrete_style
mock_line = Mock()
mock_line_class.return_value = mock_line
mock_line.add_word.return_value = (True, None)
# Call function
paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify spacing constraints were extracted correctly (converted to int)
expected_spacing = (5, 15) # int() conversion of 5.5 and 15.2
call_args = mock_line_class.call_args
assert call_args[1]['spacing'] == expected_spacing
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_line_overflow(self, mock_line_class, mock_style_registry_class):
"""Test handling of line overflow when words don't fit."""
# Setup mocks
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
# Create two mock lines
mock_line1 = Mock()
mock_line2 = Mock()
mock_line_class.side_effect = [mock_line1, mock_line2]
# First line: first 2 words fit, third doesn't
# Second line: remaining words fit
mock_line1.add_word.side_effect = [
(True, None), # word0 fits
(True, None), # word1 fits
(False, None), # word2 doesn't fit
]
mock_line2.add_word.side_effect = [
(True, None), # word2 fits on new line
(True, None), # word3 fits
(True, None), # word4 fits
]
# Call function
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify results
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
# Verify two lines were created
assert mock_line_class.call_count == 2
assert self.mock_page.add_child.call_count == 2
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class):
"""Test handling when page runs out of space."""
# Setup mocks
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
# Page can fit first line but not second
self.mock_page.can_fit_line.side_effect = [True, False]
mock_line = Mock()
mock_line_class.return_value = mock_line
mock_line.add_word.side_effect = [
(True, None), # word0 fits
(False, None), # word1 doesn't fit, need new line
]
# Call function
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify results indicate page is full
success, failed_word_index, remaining_pretext = result
assert success is False
assert failed_word_index == 1 # word1 didn't fit
assert remaining_pretext is None
def test_paragraph_layouter_empty_paragraph(self):
"""Test handling of empty paragraph."""
empty_paragraph = Mock()
empty_paragraph.words = []
result = paragraph_layouter(empty_paragraph, self.mock_page)
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
def test_paragraph_layouter_invalid_start_word(self):
"""Test handling of invalid start_word index."""
result = paragraph_layouter(self.mock_paragraph, self.mock_page, start_word=10)
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
def test_document_layouter_class(self, mock_style_registry_class):
"""Test DocumentLayouter class functionality."""
# Setup mock
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
# Create layouter
layouter = DocumentLayouter(self.mock_page)
# Verify initialization
assert layouter.page == self.mock_page
mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver)
@patch('pyWebLayout.layout.document_layouter.paragraph_layouter')
def test_document_layouter_layout_paragraph(self, mock_paragraph_layouter):
"""Test DocumentLayouter.layout_paragraph method."""
mock_paragraph_layouter.return_value = (True, None, None)
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page)
result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test")
# Verify the function was called correctly
mock_paragraph_layouter.assert_called_once_with(
self.mock_paragraph, self.mock_page, 2, "test"
)
assert result == (True, None, None)
@patch('pyWebLayout.layout.document_layouter.paragraph_layouter')
def test_document_layouter_layout_document_success(self, mock_paragraph_layouter):
"""Test DocumentLayouter.layout_document with successful layout."""
mock_paragraph_layouter.return_value = (True, None, None)
paragraphs = [self.mock_paragraph, Mock(), Mock()]
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page)
result = layouter.layout_document(paragraphs)
assert result is True
assert mock_paragraph_layouter.call_count == 3
@patch('pyWebLayout.layout.document_layouter.paragraph_layouter')
def test_document_layouter_layout_document_failure(self, mock_paragraph_layouter):
"""Test DocumentLayouter.layout_document with layout failure."""
# First paragraph succeeds, second fails
mock_paragraph_layouter.side_effect = [
(True, None, None), # First paragraph succeeds
(False, 3, None), # Second paragraph fails
]
paragraphs = [self.mock_paragraph, Mock()]
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page)
result = layouter.layout_document(paragraphs)
assert result is False
assert mock_paragraph_layouter.call_count == 2
def test_real_style_integration(self):
"""Test integration with real style system."""
# Create real style objects
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
abstract_style = AbstractStyle(
word_spacing=4.0,
word_spacing_min=2.0,
word_spacing_max=10.0
)
concrete_style = resolver.resolve_style(abstract_style)
# Verify constraints are resolved correctly
assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 10.0
# This demonstrates the integration works end-to-end
class TestWordSpacingConstraintsInLayout:
"""Specific tests for word spacing constraints in layout context."""
def test_different_spacing_scenarios(self):
"""Test various word spacing constraint scenarios."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
test_cases = [
# (word_spacing, word_spacing_min, word_spacing_max, expected_min, expected_max)
(None, None, None, 2.0, 8.0), # Default case
(5.0, None, None, 5.0, 10.0), # Only base specified
(4.0, 2.0, 8.0, 2.0, 8.0), # All specified
(3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
(6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base
]
for word_spacing, min_spacing, max_spacing, expected_min, expected_max in test_cases:
style_kwargs = {}
if word_spacing is not None:
style_kwargs['word_spacing'] = word_spacing
if min_spacing is not None:
style_kwargs['word_spacing_min'] = min_spacing
if max_spacing is not None:
style_kwargs['word_spacing_max'] = max_spacing
abstract_style = AbstractStyle(**style_kwargs)
concrete_style = resolver.resolve_style(abstract_style)
assert concrete_style.word_spacing_min == expected_min, f"Failed for case: {style_kwargs}"
assert concrete_style.word_spacing_max == expected_max, f"Failed for case: {style_kwargs}"
class TestMultiPageLayout:
"""Test cases for multi-page document layout scenarios."""
def setup_method(self):
"""Set up test fixtures for multi-page tests."""
# Create multiple mock pages
self.mock_pages = []
for i in range(3):
page = Mock()
page.page_number = i + 1
page.border_size = 20
page._current_y_offset = 50
page.available_width = 400
page.available_height = 600
page.draw = Mock()
page.add_child = Mock()
page.style_resolver = Mock()
self.mock_pages.append(page)
# Create a long paragraph that will span multiple pages
self.long_paragraph = Mock()
self.long_paragraph.line_height = 25
self.long_paragraph.style = AbstractStyle()
# Create many words to ensure page overflow
self.long_paragraph.words = []
for i in range(50): # 50 words should definitely overflow a page
word = Mock()
word.text = f"word_{i:02d}"
self.long_paragraph.words.append(word)
# Create mock concrete style
self.mock_concrete_style = Mock()
self.mock_concrete_style.word_spacing_min = 3.0
self.mock_concrete_style.word_spacing_max = 12.0
self.mock_concrete_style.text_align = "justify"
self.mock_concrete_style.create_font = Mock()
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
def test_document_layouter_multi_page_scenario(self, mock_style_registry_class):
"""Test DocumentLayouter handling multiple pages with continuation."""
# Setup style registry
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
# Create a multi-page document layouter
class MultiPageDocumentLayouter(DocumentLayouter):
def __init__(self, pages):
self.pages = pages
self.current_page_index = 0
self.page = pages[0]
self.style_registry = Mock()
def get_next_page(self):
"""Get the next available page."""
if self.current_page_index + 1 < len(self.pages):
self.current_page_index += 1
self.page = self.pages[self.current_page_index]
return self.page
return None
def layout_document_with_pagination(self, paragraphs):
"""Layout document with automatic pagination."""
for paragraph in paragraphs:
start_word = 0
pretext = None
while start_word < len(paragraph.words):
complete, next_word, remaining_pretext = self.layout_paragraph(
paragraph, start_word, pretext
)
if complete:
# Paragraph finished
break
if next_word is None:
# Error condition
return False, f"Failed to layout paragraph at word {start_word}"
# Try to get next page
next_page = self.get_next_page()
if not next_page:
return False, f"Ran out of pages at word {next_word}"
# Continue with remaining words on next page
start_word = next_word
pretext = remaining_pretext
return True, "All paragraphs laid out successfully"
# Create layouter with multiple pages
layouter = MultiPageDocumentLayouter(self.mock_pages)
# Mock the layout_paragraph method to simulate page filling
original_layout_paragraph = layouter.layout_paragraph
call_count = [0]
def mock_layout_paragraph(paragraph, start_word=0, pretext=None):
call_count[0] += 1
# Simulate different scenarios based on call count
if call_count[0] == 1:
# First page: can fit words 0-19, fails at word 20
return (False, 20, None)
elif call_count[0] == 2:
# Second page: can fit words 20-39, fails at word 40
return (False, 40, None)
elif call_count[0] == 3:
# Third page: can fit remaining words 40-49
return (True, None, None)
else:
return (False, start_word, None)
layouter.layout_paragraph = mock_layout_paragraph
# Test multi-page layout
success, message = layouter.layout_document_with_pagination([self.long_paragraph])
# Verify results
assert success is True
assert "successfully" in message
assert call_count[0] == 3 # Should have made 3 layout attempts
assert layouter.current_page_index == 2 # Should end on page 3 (index 2)
def test_realistic_multi_page_scenario(self):
"""Test a realistic scenario with actual content and page constraints."""
# Create realistic paragraph with varied content
realistic_paragraph = Mock()
realistic_paragraph.line_height = 20
realistic_paragraph.style = AbstractStyle(
word_spacing=4.0,
word_spacing_min=2.0,
word_spacing_max=8.0,
text_align="justify"
)
# Create words of varying lengths (realistic text)
words = [
"The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog.",
"This", "sentence", "contains", "words", "of", "varying", "lengths",
"to", "simulate", "realistic", "text", "content", "that", "would",
"require", "proper", "word", "spacing", "calculations", "and",
"potentially", "multiple", "pages", "for", "layout.", "Each",
"word", "represents", "a", "challenge", "for", "the", "layouter",
"system", "to", "handle", "appropriately", "with", "the", "given",
"constraints", "and", "spacing", "requirements."
]
realistic_paragraph.words = []
for word_text in words:
word = Mock()
word.text = word_text
realistic_paragraph.words.append(word)
# Create page with realistic constraints
realistic_page = Mock()
realistic_page.border_size = 30
realistic_page._current_y_offset = 100
realistic_page.available_width = 350 # Narrower page
realistic_page.available_height = 500
realistic_page.draw = Mock()
realistic_page.add_child = Mock()
realistic_page.style_resolver = Mock()
# Simulate page that can fit approximately 20 lines
lines_fitted = [0]
max_lines = 20
def realistic_can_fit_line(line_height):
lines_fitted[0] += 1
return lines_fitted[0] <= max_lines
realistic_page.can_fit_line = realistic_can_fit_line
# Test with real style system
context = RenderingContext(base_font_size=14)
resolver = StyleResolver(context)
concrete_style = resolver.resolve_style(realistic_paragraph.style)
# Verify realistic constraints were calculated
assert concrete_style.word_spacing == 4.0
assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 8.0
# This test demonstrates the integration without mocking everything
# In a real scenario, this would interface with actual Line and Text objects
print(f"✓ Realistic scenario test completed")
print(f" - Words to layout: {len(realistic_paragraph.words)}")
print(f" - Page width: {realistic_page.available_width}px")
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
if __name__ == "__main__":
# Run specific tests for debugging
test = TestDocumentLayouter()
test.setup_method()
# Run a simple test
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') as mock_registry:
with patch('pyWebLayout.layout.document_layouter.Line') as mock_line:
mock_style_registry = Mock()
mock_registry.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = test.mock_concrete_style
mock_line_instance = Mock()
mock_line.return_value = mock_line_instance
mock_line_instance.add_word.return_value = (True, None)
result = paragraph_layouter(test.mock_paragraph, test.mock_page)
print(f"Test result: {result}")
# Run multi-page tests
multi_test = TestMultiPageLayout()
multi_test.setup_method()
multi_test.test_realistic_multi_page_scenario()
print("Document layouter tests completed!")

View File

@ -0,0 +1,362 @@
"""
Integration tests for document layouter functionality.
This test file focuses on realistic scenarios using actual Line, Text, and other
concrete objects to test the complete integration of word spacing constraints
in multi-page layout scenarios.
"""
import pytest
from unittest.mock import Mock, patch
from PIL import Image, ImageDraw
import numpy as np
from typing import List, Optional
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
from pyWebLayout.style.abstract_style import AbstractStyle
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
from pyWebLayout.style.fonts import Font
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.abstract.inline import Word
class MockPage:
"""A realistic mock page that behaves like a real page."""
def __init__(self, width=400, height=600, max_lines=20):
self.border_size = 20
self._current_y_offset = 50
self.available_width = width
self.available_height = height
self.max_lines = max_lines
self.lines_added = 0
self.children = []
# Create a real drawing context
self.image = Image.new('RGB', (width + 40, height + 100), 'white')
self.draw = ImageDraw.Draw(self.image)
# Create a real style resolver
context = RenderingContext(base_font_size=16)
self.style_resolver = StyleResolver(context)
def can_fit_line(self, line_height):
"""Check if another line can fit on the page."""
remaining_height = self.available_height - self._current_y_offset
can_fit = remaining_height >= line_height and self.lines_added < self.max_lines
return can_fit
def add_child(self, child):
"""Add a child element (like a Line) to the page."""
self.children.append(child)
self.lines_added += 1
return True
class MockWord(Word):
"""A simple mock word that extends the real Word class."""
def __init__(self, text, style=None):
if style is None:
style = Font(font_size=16)
# Initialize the base Word with required parameters
super().__init__(text, style)
self._concrete_texts = []
def add_concete(self, texts):
"""Add concrete text representations."""
if isinstance(texts, list):
self._concrete_texts.extend(texts)
else:
self._concrete_texts.append(texts)
def possible_hyphenation(self):
"""Return possible hyphenation points."""
if len(self.text) <= 6:
return []
# Simple hyphenation: split roughly in the middle
mid = len(self.text) // 2
return [(self.text[:mid] + "-", self.text[mid:])]
class MockParagraph:
"""A simple paragraph with words and styling."""
def __init__(self, text_content, word_spacing_style=None):
if word_spacing_style is None:
word_spacing_style = AbstractStyle(
word_spacing=4.0,
word_spacing_min=2.0,
word_spacing_max=8.0
)
self.style = word_spacing_style
self.line_height = 25
# Create words from text content
self.words = []
for word_text in text_content.split():
word = MockWord(word_text)
self.words.append(word)
class TestDocumentLayouterIntegration:
"""Integration tests using real components."""
def test_single_page_layout_with_real_components(self):
"""Test layout on a single page using real Line and Text objects."""
# Create a page that can fit content
page = MockPage(width=500, height=400, max_lines=10)
# Create a paragraph with realistic content
paragraph = MockParagraph(
"The quick brown fox jumps over the lazy dog and runs through the forest.",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=6.0)
)
# Layout the paragraph
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, page)
# Verify successful layout
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
# Verify lines were added to page
assert len(page.children) > 0
assert page.lines_added > 0
# Verify actual Line objects were created
for child in page.children:
assert isinstance(child, Line)
print(f"✓ Single page test: {len(page.children)} lines created")
def test_multi_page_scenario_with_page_overflow(self):
"""Test realistic multi-page scenario with actual page overflow."""
# Create a very small page that will definitely overflow
small_page = MockPage(width=150, height=80, max_lines=1) # Extremely small page
# Create a long paragraph that will definitely overflow
long_text = " ".join([f"verylongword{i:02d}" for i in range(20)]) # 20 long words
paragraph = MockParagraph(
long_text,
AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0)
)
# Layout the paragraph - should fail due to page overflow
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, small_page)
# Either should fail due to overflow OR succeed with limited content
if success:
# If it succeeded, verify it fit some content
assert len(small_page.children) > 0
print(f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
else:
# If it failed, verify overflow handling
assert failed_word_index is not None # Should indicate where it failed
assert failed_word_index < len(paragraph.words) # Should be within word range
assert len(small_page.children) <= small_page.max_lines
print(f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
def test_word_spacing_constraints_in_real_lines(self):
"""Test that word spacing constraints are properly used in real Line objects."""
# Create page
page = MockPage(width=400, height=300)
# Create paragraph with specific spacing constraints
paragraph = MockParagraph(
"Testing word spacing constraints with realistic content.",
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=10.0)
)
# Layout paragraph
success, _, _ = paragraph_layouter(paragraph, page)
assert success is True
# Verify that Line objects were created with correct spacing
assert len(page.children) > 0
for line in page.children:
assert isinstance(line, Line)
# Verify spacing constraints were applied
assert hasattr(line, '_spacing')
min_spacing, max_spacing = line._spacing
assert min_spacing == 3 # From our constraint
assert max_spacing == 10 # From our constraint
print(f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
def test_different_alignment_strategies_with_constraints(self):
"""Test different text alignment strategies with word spacing constraints."""
alignments_to_test = [
("left", AbstractStyle(text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)),
("justify", AbstractStyle(text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)),
("center", AbstractStyle(text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))
]
for alignment_name, style in alignments_to_test:
page = MockPage(width=350, height=200)
paragraph = MockParagraph(
"This sentence will test different alignment strategies with word spacing.",
style
)
success, _, _ = paragraph_layouter(paragraph, page)
assert success is True
assert len(page.children) > 0
# Verify alignment was applied to lines
for line in page.children:
assert isinstance(line, Line)
# Check that the alignment handler was set correctly
assert line._alignment_handler is not None
print(f"{alignment_name} alignment: {len(page.children)} lines created")
def test_realistic_document_with_multiple_pages(self):
"""Test a realistic document that spans multiple pages."""
# Create multiple pages
pages = [MockPage(width=400, height=300, max_lines=5) for _ in range(3)]
# Create a document with multiple paragraphs
paragraphs = [
MockParagraph(
"This is the first paragraph of our document. It contains enough text to potentially span multiple lines and test the word spacing constraints properly.",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
),
MockParagraph(
"Here is a second paragraph with different styling. This paragraph uses different word spacing constraints to test the flexibility of the system.",
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0)
),
MockParagraph(
"The third and final paragraph completes our test document. It should demonstrate that the layouter can handle multiple paragraphs with varying content lengths and styling requirements.",
AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0)
)
]
# Layout paragraphs across pages
current_page_index = 0
for para_index, paragraph in enumerate(paragraphs):
start_word = 0
while start_word < len(paragraph.words):
if current_page_index >= len(pages):
break # Out of pages
current_page = pages[current_page_index]
success, failed_word_index, _ = paragraph_layouter(
paragraph, current_page, start_word
)
if success:
# Paragraph completed on this page
break
else:
# Page full, move to next page
if failed_word_index is not None:
start_word = failed_word_index
current_page_index += 1
# If we're out of pages, stop
if current_page_index >= len(pages):
break
# Verify pages have content
total_lines = sum(len(page.children) for page in pages)
pages_used = sum(1 for page in pages if len(page.children) > 0)
assert total_lines > 0
assert pages_used > 1 # Should use multiple pages
print(f"✓ Multi-document test: {total_lines} lines across {pages_used} pages")
def test_word_spacing_constraint_resolution_integration(self):
"""Test the complete integration from AbstractStyle to Line spacing."""
page = MockPage()
# Test different constraint scenarios
test_cases = [
{
"name": "explicit_constraints",
"style": AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0),
"expected_min": 3,
"expected_max": 12
},
{
"name": "default_constraints",
"style": AbstractStyle(word_spacing=6.0),
"expected_min": 6, # Should use word_spacing as min
"expected_max": 12 # Should use word_spacing * 2 as max
},
{
"name": "no_word_spacing",
"style": AbstractStyle(),
"expected_min": 2, # Default minimum
"expected_max": 8 # Default based on font size (16 * 0.5)
}
]
for case in test_cases:
# Create fresh page for each test
test_page = MockPage()
paragraph = MockParagraph(
"Testing constraint resolution with different scenarios.",
case["style"]
)
success, _, _ = paragraph_layouter(paragraph, test_page)
assert success is True
assert len(test_page.children) > 0
# Verify constraints were resolved correctly
line = test_page.children[0]
min_spacing, max_spacing = line._spacing
assert min_spacing == case["expected_min"], f"Min constraint failed for {case['name']}"
assert max_spacing == case["expected_max"], f"Max constraint failed for {case['name']}"
print(f"{case['name']}: constraints ({min_spacing}, {max_spacing})")
def test_hyphenation_with_word_spacing_constraints(self):
"""Test that hyphenation works correctly with word spacing constraints."""
# Create a narrow page to force hyphenation
narrow_page = MockPage(width=200, height=300)
# Create paragraph with long words that will need hyphenation
paragraph = MockParagraph(
"supercalifragilisticexpialidocious antidisestablishmentarianism",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
)
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, narrow_page)
# Should succeed with hyphenation or handle overflow gracefully
if success:
assert len(narrow_page.children) > 0
print(f"✓ Hyphenation test: {len(narrow_page.children)} lines created")
else:
# If it failed, it should be due to layout constraints, not errors
assert failed_word_index is not None
print(f"✓ Hyphenation test: Handled overflow at word {failed_word_index}")
if __name__ == "__main__":
# Run integration tests
test = TestDocumentLayouterIntegration()
print("Running document layouter integration tests...")
print("=" * 50)
test.test_single_page_layout_with_real_components()
test.test_multi_page_scenario_with_page_overflow()
test.test_word_spacing_constraints_in_real_lines()
test.test_different_alignment_strategies_with_constraints()
test.test_realistic_document_with_multiple_pages()
test.test_word_spacing_constraint_resolution_integration()
test.test_hyphenation_with_word_spacing_constraints()
print("=" * 50)
print("✅ All integration tests completed successfully!")

View File

@ -0,0 +1,150 @@
"""
Test file demonstrating word spacing constraints functionality.
This test shows how to use the new min/max word spacing constraints
in the style system.
"""
import pytest
from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
class TestWordSpacingConstraints:
"""Test cases for word spacing constraints feature."""
def test_abstract_style_with_word_spacing_constraints(self):
"""Test that AbstractStyle accepts word spacing constraint fields."""
style = AbstractStyle(
word_spacing=5.0,
word_spacing_min=2.0,
word_spacing_max=10.0
)
assert style.word_spacing == 5.0
assert style.word_spacing_min == 2.0
assert style.word_spacing_max == 10.0
def test_concrete_style_resolution_with_constraints(self):
"""Test that word spacing constraints are resolved correctly."""
# Create rendering context
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Create abstract style with constraints
abstract_style = AbstractStyle(
word_spacing=5.0,
word_spacing_min=2.0,
word_spacing_max=12.0
)
# Resolve to concrete style
concrete_style = resolver.resolve_style(abstract_style)
# Check that constraints are preserved
assert concrete_style.word_spacing == 5.0
assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 12.0
def test_default_constraint_logic(self):
"""Test default constraint logic when not specified."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Style with only base word spacing
abstract_style = AbstractStyle(word_spacing=6.0)
concrete_style = resolver.resolve_style(abstract_style)
# Should apply default logic: min = base, max = base * 2
assert concrete_style.word_spacing == 6.0
assert concrete_style.word_spacing_min == 6.0
assert concrete_style.word_spacing_max == 12.0
def test_no_word_spacing_defaults(self):
"""Test defaults when no word spacing is specified."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Style with no word spacing specified
abstract_style = AbstractStyle()
concrete_style = resolver.resolve_style(abstract_style)
# Should apply font-based defaults
assert concrete_style.word_spacing == 0.0
assert concrete_style.word_spacing_min == 2.0 # Minimum default
assert concrete_style.word_spacing_max == 8.0 # 50% of font size (16 * 0.5)
def test_partial_constraints(self):
"""Test behavior when only min or max is specified."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Only min specified
abstract_style_min = AbstractStyle(
word_spacing=4.0,
word_spacing_min=3.0
)
concrete_style_min = resolver.resolve_style(abstract_style_min)
assert concrete_style_min.word_spacing_min == 3.0
assert concrete_style_min.word_spacing_max == 6.0 # 3.0 * 2
# Only max specified
abstract_style_max = AbstractStyle(
word_spacing=4.0,
word_spacing_max=8.0
)
concrete_style_max = resolver.resolve_style(abstract_style_max)
assert concrete_style_max.word_spacing_min == 4.0 # max(word_spacing, 2.0)
assert concrete_style_max.word_spacing_max == 8.0
def test_style_registry_with_constraints(self):
"""Test that style registry handles word spacing constraints."""
registry = AbstractStyleRegistry()
# Create style with constraints
style_id, style = registry.get_or_create_style(
word_spacing=5.0,
word_spacing_min=3.0,
word_spacing_max=10.0
)
# Verify the style was created correctly
retrieved_style = registry.get_style_by_id(style_id)
assert retrieved_style.word_spacing == 5.0
assert retrieved_style.word_spacing_min == 3.0
assert retrieved_style.word_spacing_max == 10.0
def test_em_units_in_constraints(self):
"""Test that em units work in word spacing constraints."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Use em units
abstract_style = AbstractStyle(
word_spacing="0.25em",
word_spacing_min="0.1em",
word_spacing_max="0.5em"
)
concrete_style = resolver.resolve_style(abstract_style)
# Should convert em to pixels based on font size (16px)
assert concrete_style.word_spacing == 4.0 # 0.25 * 16
assert concrete_style.word_spacing_min == 1.6 # 0.1 * 16
assert concrete_style.word_spacing_max == 8.0 # 0.5 * 16
if __name__ == "__main__":
# Run basic tests
test = TestWordSpacingConstraints()
test.test_abstract_style_with_word_spacing_constraints()
test.test_concrete_style_resolution_with_constraints()
test.test_default_constraint_logic()
test.test_no_word_spacing_defaults()
test.test_partial_constraints()
test.test_style_registry_with_constraints()
test.test_em_units_in_constraints()
print("All word spacing constraint tests passed!")