403 lines
18 KiB
Python
403 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Unit tests for the paragraph layout system with pagination and state management.
|
|
"""
|
|
|
|
import unittest
|
|
import os
|
|
from PIL import Image, ImageDraw
|
|
|
|
from pyWebLayout.abstract.block import Paragraph
|
|
from pyWebLayout.abstract.inline import Word
|
|
from pyWebLayout.style import Font, FontStyle, FontWeight
|
|
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphRenderingState, ParagraphLayoutResult
|
|
from pyWebLayout.style.layout import Alignment
|
|
|
|
|
|
class TestParagraphLayoutSystem(unittest.TestCase):
|
|
"""Test cases for the paragraph layout system"""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures"""
|
|
self.font_style = Font(
|
|
font_path=None,
|
|
font_size=12,
|
|
colour=(0, 0, 0, 255)
|
|
)
|
|
|
|
# Clean up any existing test images
|
|
self.test_images = []
|
|
|
|
def tearDown(self):
|
|
"""Clean up after tests"""
|
|
# Clean up test images
|
|
for img in self.test_images:
|
|
if os.path.exists(img):
|
|
os.remove(img)
|
|
|
|
def _create_test_paragraph(self, text: str) -> Paragraph:
|
|
"""Helper method to create a test paragraph with the given text."""
|
|
paragraph = Paragraph(style=self.font_style)
|
|
|
|
# Split text into words and add them to the paragraph
|
|
words = text.split()
|
|
for word_text in words:
|
|
word = Word(word_text, self.font_style)
|
|
paragraph.add_word(word)
|
|
|
|
return paragraph
|
|
|
|
def test_basic_paragraph_layout(self):
|
|
"""Test basic paragraph layout without height constraints."""
|
|
text = "This is a test paragraph that should be laid out across multiple lines based on the available width."
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
# Create layout manager
|
|
layout = ParagraphLayout(
|
|
line_width=200,
|
|
line_height=20,
|
|
word_spacing=(3, 8),
|
|
line_spacing=2,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
# Layout the paragraph
|
|
lines = layout.layout_paragraph(paragraph)
|
|
|
|
# Assertions
|
|
self.assertIsInstance(lines, list)
|
|
self.assertGreater(len(lines), 0, "Should generate at least one line")
|
|
|
|
# Check that all lines have content
|
|
for i, line in enumerate(lines):
|
|
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
|
|
|
|
# Calculate total height
|
|
total_height = layout.calculate_paragraph_height(paragraph)
|
|
self.assertGreater(total_height, 0, "Total height should be positive")
|
|
|
|
# Create visual representation
|
|
if lines:
|
|
canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255))
|
|
|
|
for i, line in enumerate(lines):
|
|
line_img = line.render()
|
|
y_pos = i * (layout.line_height + layout.line_spacing)
|
|
canvas.paste(line_img, (0, y_pos), line_img)
|
|
|
|
filename = "test_basic_paragraph_layout.png"
|
|
canvas.save(filename)
|
|
self.test_images.append(filename)
|
|
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
|
|
|
def test_pagination_with_height_constraint(self):
|
|
"""Test paragraph layout with height constraints (pagination)."""
|
|
text = "This is a much longer paragraph that will definitely need to be split across multiple pages. It contains many words and should demonstrate how the pagination system works when we have height constraints. The system should be able to break the paragraph at appropriate points and provide information about remaining content that needs to be rendered on subsequent pages."
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=180,
|
|
line_height=18,
|
|
word_spacing=(2, 6),
|
|
line_spacing=3,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
# Test with different page heights
|
|
page_heights = [60, 100, 150]
|
|
|
|
for page_height in page_heights:
|
|
with self.subTest(page_height=page_height):
|
|
result = layout.layout_paragraph_with_pagination(paragraph, page_height)
|
|
|
|
# Assertions
|
|
self.assertIsInstance(result, ParagraphLayoutResult)
|
|
self.assertIsInstance(result.lines, list)
|
|
self.assertGreaterEqual(result.total_height, 0)
|
|
self.assertIsInstance(result.is_complete, bool)
|
|
|
|
if result.state:
|
|
self.assertIsInstance(result.state, ParagraphRenderingState)
|
|
self.assertGreaterEqual(result.state.current_word_index, 0)
|
|
self.assertGreaterEqual(result.state.current_char_index, 0)
|
|
self.assertGreaterEqual(result.state.rendered_lines, 0)
|
|
|
|
# Create visual representation
|
|
if result.lines:
|
|
canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
|
|
|
# Add a border to show the page boundary
|
|
draw = ImageDraw.Draw(canvas)
|
|
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
|
|
|
|
for i, line in enumerate(result.lines):
|
|
line_img = line.render()
|
|
y_pos = i * (layout.line_height + layout.line_spacing)
|
|
if y_pos + layout.line_height <= page_height:
|
|
canvas.paste(line_img, (0, y_pos), line_img)
|
|
|
|
filename = f"test_pagination_{page_height}px.png"
|
|
canvas.save(filename)
|
|
self.test_images.append(filename)
|
|
self.assertTrue(os.path.exists(filename), f"Test image should be created for page height {page_height}")
|
|
|
|
def test_state_management(self):
|
|
"""Test state saving and restoration for resumable rendering."""
|
|
text = "This is a test of the state management system. We will render part of this paragraph, save the state, and then continue rendering from where we left off. This demonstrates how the system can handle interruptions and resume rendering later."
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=150,
|
|
line_height=16,
|
|
word_spacing=(2, 5),
|
|
line_spacing=2,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
# First page - render with height constraint
|
|
page_height = 50
|
|
result1 = layout.layout_paragraph_with_pagination(paragraph, page_height)
|
|
|
|
# Assertions for first page
|
|
self.assertIsInstance(result1, ParagraphLayoutResult)
|
|
self.assertGreater(len(result1.lines), 0, "First page should have lines")
|
|
|
|
if result1.state:
|
|
# Save the state
|
|
state_json = result1.state.to_json()
|
|
self.assertIsInstance(state_json, str, "State should serialize to JSON string")
|
|
|
|
# Create image for first page
|
|
if result1.lines:
|
|
canvas1 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
|
draw = ImageDraw.Draw(canvas1)
|
|
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
|
|
|
|
for i, line in enumerate(result1.lines):
|
|
line_img = line.render()
|
|
y_pos = i * (layout.line_height + layout.line_spacing)
|
|
canvas1.paste(line_img, (0, y_pos), line_img)
|
|
|
|
filename1 = "test_state_page1.png"
|
|
canvas1.save(filename1)
|
|
self.test_images.append(filename1)
|
|
self.assertTrue(os.path.exists(filename1), "First page image should be created")
|
|
|
|
# Continue from saved state on second page
|
|
if not result1.is_complete and result1.remaining_paragraph:
|
|
# Restore state
|
|
restored_state = ParagraphRenderingState.from_json(state_json)
|
|
self.assertIsInstance(restored_state, ParagraphRenderingState)
|
|
self.assertEqual(restored_state.current_word_index, result1.state.current_word_index)
|
|
self.assertEqual(restored_state.current_char_index, result1.state.current_char_index)
|
|
|
|
# Continue rendering
|
|
result2 = layout.layout_paragraph_with_pagination(result1.remaining_paragraph, page_height)
|
|
self.assertIsInstance(result2, ParagraphLayoutResult)
|
|
|
|
# Create image for second page
|
|
if result2.lines:
|
|
canvas2 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
|
draw = ImageDraw.Draw(canvas2)
|
|
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
|
|
|
|
for i, line in enumerate(result2.lines):
|
|
line_img = line.render()
|
|
y_pos = i * (layout.line_height + layout.line_spacing)
|
|
canvas2.paste(line_img, (0, y_pos), line_img)
|
|
|
|
filename2 = "test_state_page2.png"
|
|
canvas2.save(filename2)
|
|
self.test_images.append(filename2)
|
|
self.assertTrue(os.path.exists(filename2), "Second page image should be created")
|
|
|
|
def test_long_word_handling(self):
|
|
"""Test handling of long words that require force-fitting."""
|
|
text = "This paragraph contains supercalifragilisticexpialidocious and other extraordinarily long words that should be handled gracefully."
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=120, # Narrow width to force long word issues
|
|
line_height=18,
|
|
word_spacing=(2, 5),
|
|
line_spacing=2,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
result = layout.layout_paragraph_with_pagination(paragraph, 200) # Generous height
|
|
|
|
# Assertions
|
|
self.assertIsInstance(result, ParagraphLayoutResult)
|
|
self.assertGreater(len(result.lines), 0, "Should generate lines even with long words")
|
|
self.assertIsInstance(result.is_complete, bool)
|
|
|
|
# Verify that all lines have content
|
|
for i, line in enumerate(result.lines):
|
|
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
|
|
|
|
# Create visual representation
|
|
if result.lines:
|
|
total_height = len(result.lines) * (layout.line_height + layout.line_spacing)
|
|
canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255))
|
|
|
|
for i, line in enumerate(result.lines):
|
|
line_img = line.render()
|
|
y_pos = i * (layout.line_height + layout.line_spacing)
|
|
canvas.paste(line_img, (0, y_pos), line_img)
|
|
|
|
filename = "test_long_word_handling.png"
|
|
canvas.save(filename)
|
|
self.test_images.append(filename)
|
|
self.assertTrue(os.path.exists(filename), "Long word test image should be created")
|
|
|
|
def test_multiple_page_scenario(self):
|
|
"""Test a realistic multi-page scenario."""
|
|
text = """This is a comprehensive test of the paragraph layout system with pagination support.
|
|
The system needs to handle various scenarios including normal word wrapping, hyphenation of long words,
|
|
state management for resumable rendering, and proper text flow across multiple pages.
|
|
|
|
When a paragraph is too long to fit on a single page, the system should break it at appropriate
|
|
points and maintain state information so that rendering can be resumed on the next page.
|
|
This is essential for document processing applications where content needs to be paginated
|
|
across multiple pages or screens.
|
|
|
|
The system also needs to handle edge cases such as very long words that don't fit on a single line,
|
|
ensuring that no text is lost and that the rendering process can continue gracefully even
|
|
when encountering challenging content.""".replace('\n', ' ').replace(' ', ' ')
|
|
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=200,
|
|
line_height=20,
|
|
word_spacing=(3, 8),
|
|
line_spacing=3,
|
|
halign=Alignment.JUSTIFY
|
|
)
|
|
|
|
page_height = 80 # Small pages to force pagination
|
|
pages = []
|
|
current_paragraph = paragraph
|
|
page_num = 1
|
|
|
|
while current_paragraph and page_num <= 10: # Safety limit
|
|
result = layout.layout_paragraph_with_pagination(current_paragraph, page_height)
|
|
|
|
# Assertions
|
|
self.assertIsInstance(result, ParagraphLayoutResult)
|
|
|
|
if result.lines:
|
|
# Create page image
|
|
canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# Page border
|
|
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(100, 100, 100), width=1)
|
|
|
|
# Page number
|
|
draw.text((5, page_height-15), f"Page {page_num}", fill=(150, 150, 150))
|
|
|
|
# Content
|
|
for i, line in enumerate(result.lines):
|
|
line_img = line.render()
|
|
y_pos = i * (layout.line_height + layout.line_spacing)
|
|
if y_pos + layout.line_height <= page_height - 20: # Leave space for page number
|
|
canvas.paste(line_img, (0, y_pos), line_img)
|
|
|
|
pages.append(canvas)
|
|
filename = f"test_multipage_page_{page_num}.png"
|
|
canvas.save(filename)
|
|
self.test_images.append(filename)
|
|
self.assertTrue(os.path.exists(filename), f"Page {page_num} image should be created")
|
|
|
|
# Continue with remaining content
|
|
current_paragraph = result.remaining_paragraph
|
|
page_num += 1
|
|
|
|
# Assertions
|
|
self.assertGreater(len(pages), 1, "Should generate multiple pages")
|
|
self.assertLessEqual(page_num, 11, "Should not exceed safety limit")
|
|
|
|
def test_empty_paragraph(self):
|
|
"""Test handling of empty paragraph"""
|
|
paragraph = self._create_test_paragraph("")
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=200,
|
|
line_height=20,
|
|
word_spacing=(3, 8),
|
|
line_spacing=2,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
lines = layout.layout_paragraph(paragraph)
|
|
|
|
# Should handle empty paragraph gracefully
|
|
self.assertIsInstance(lines, list)
|
|
|
|
def test_single_word_paragraph(self):
|
|
"""Test paragraph with single word"""
|
|
paragraph = self._create_test_paragraph("Hello")
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=200,
|
|
line_height=20,
|
|
word_spacing=(3, 8),
|
|
line_spacing=2,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
lines = layout.layout_paragraph(paragraph)
|
|
|
|
# Single word should produce one line
|
|
self.assertGreater(len(lines), 0, "Single word should produce at least one line")
|
|
if len(lines) > 0:
|
|
self.assertGreater(len(lines[0].text_objects), 0, "Line should have content")
|
|
|
|
def test_different_alignments(self):
|
|
"""Test paragraph layout with different alignments"""
|
|
text = "This is a test paragraph for alignment testing with multiple words."
|
|
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
|
|
|
for alignment in alignments:
|
|
with self.subTest(alignment=alignment):
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=200,
|
|
line_height=20,
|
|
word_spacing=(3, 8),
|
|
line_spacing=2,
|
|
halign=alignment
|
|
)
|
|
|
|
lines = layout.layout_paragraph(paragraph)
|
|
|
|
# Should generate lines regardless of alignment
|
|
self.assertIsInstance(lines, list)
|
|
if len(paragraph._words) > 0:
|
|
self.assertGreater(len(lines), 0, f"Should generate lines for {alignment}")
|
|
|
|
def test_calculate_paragraph_height(self):
|
|
"""Test paragraph height calculation"""
|
|
text = "This is a test paragraph for height calculation."
|
|
paragraph = self._create_test_paragraph(text)
|
|
|
|
layout = ParagraphLayout(
|
|
line_width=200,
|
|
line_height=20,
|
|
word_spacing=(3, 8),
|
|
line_spacing=2,
|
|
halign=Alignment.LEFT
|
|
)
|
|
|
|
height = layout.calculate_paragraph_height(paragraph)
|
|
|
|
# Height should be positive
|
|
self.assertGreater(height, 0, "Paragraph height should be positive")
|
|
self.assertIsInstance(height, (int, float))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|