pyWebLayout/tests/test_monospace_rendering.py
Duncan Tourolle ae15fe54e8
All checks were successful
Python CI / test (push) Successful in 5m17s
new style handling
2025-06-22 13:42:15 +02:00

484 lines
19 KiB
Python

"""
Comprehensive mono-space font tests for rendering, line-breaking, and hyphenation.
Mono-space fonts provide predictable behavior for testing layout algorithms
since each character has the same width. This makes it easier to verify
correct text flow, line breaking, and hyphenation behavior.
"""
import unittest
import os
from PIL import Image, ImageFont
import numpy as np
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.page import Page, Container
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle
from pyWebLayout.style.layout import Alignment
from pyWebLayout.abstract.inline import Word
class TestMonospaceRendering(unittest.TestCase):
"""Test rendering behavior with mono-space fonts."""
def setUp(self):
"""Set up test fixtures with mono-space font."""
# Try to find a mono-space font on the system
self.monospace_font_path = self._find_monospace_font()
# Create mono-space font instances for testing
self.mono_font_12 = Font(
font_path=self.monospace_font_path,
font_size=12,
colour=(0, 0, 0)
)
self.mono_font_16 = Font(
font_path=self.monospace_font_path,
font_size=16,
colour=(0, 0, 0)
)
# Calculate character width for mono-space font
test_char = Text("X", self.mono_font_12)
self.char_width_12 = test_char.width
test_char_16 = Text("X", self.mono_font_16)
self.char_width_16 = test_char_16.width
print(f"Mono-space character width (12pt): {self.char_width_12}px")
print(f"Mono-space character width (16pt): {self.char_width_16}px")
def _find_monospace_font(self):
"""Find a suitable mono-space font on the system."""
# Common mono-space font paths
possible_fonts = [
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" ,
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
for font_path in possible_fonts:
if os.path.exists(font_path):
return font_path
# If no mono-space font found, return None to use default
print("Warning: No mono-space font found, using default font")
return None
def test_character_width_consistency(self):
"""Test that all characters have the same width in mono-space font."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Test various characters to ensure consistent width
test_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"
widths = []
for char in test_chars:
text_obj = Text("A"+char+"A", self.mono_font_12)
widths.append(text_obj.width)
# All widths should be the same (or very close due to rendering differences)
min_width = min(widths)
max_width = max(widths)
width_variance = max_width - min_width
print(f"Character width range: {min_width}-{max_width}px (variance: {width_variance}px)")
# Allow small variance for anti-aliasing effects
self.assertLessEqual(width_variance, 2, "Mono-space characters should have consistent width")
def test_predictable_text_width(self):
"""Test that text width is predictable based on character count."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
test_strings = [
"A",
"AB",
"ABC",
"ABCD",
"ABCDE",
"ABCDEFGHIJ",
"ABCDEFGHIJKLMNOPQRST"
]
for text_str in test_strings:
text_obj = Text(text_str, self.mono_font_12)
expected_width = len(text_str) * self.char_width_12
actual_width = text_obj.width
# Allow small variance for rendering differences
width_diff = abs(actual_width - expected_width)
print(f"Text '{text_str}': expected {expected_width}px, actual {actual_width}px, diff {width_diff}px")
self.assertLessEqual(width_diff, len(text_str) + 2,
f"Text width should be predictable for '{text_str}'")
def test_line_capacity_calculation(self):
"""Test that we can predict how many characters fit on a line."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Create lines of different widths
line_widths = [100, 200, 300, 500, 800]
for line_width in line_widths:
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(line_width, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Calculate expected capacity
# Account for spacing between words (minimum 3px)
chars_per_word = 10 # Average word length for estimation
word_width = chars_per_word * self.char_width_12
# Estimate how many words can fit
estimated_words = line_width // (word_width + 3) # +3 for minimum spacing
# Test by adding words until line is full
words_added = 0
test_word = "A" * chars_per_word # 10-character word
while True:
result = line.add_word(test_word, self.mono_font_12)
if result is not None: # Word didn't fit
break
words_added += 1
# Prevent infinite loop
if words_added > 50:
break
print(f"Line width {line_width}px: estimated {estimated_words} words, actual {words_added} words")
# The actual should be reasonably close to estimated
self.assertGreaterEqual(words_added, max(1, estimated_words - 2),
f"Should fit at least {max(1, estimated_words - 2)} words")
self.assertLessEqual(words_added, estimated_words + 2,
f"Should not fit more than {estimated_words + 2} words")
def test_word_breaking_behavior(self):
"""Test word breaking and hyphenation with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Create a narrow line that forces word breaking
narrow_width = self.char_width_12 * 15 # Space for about 15 characters
line = Line(
spacing=(2, 6),
origin=(0, 0),
size=(narrow_width, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Test with a long word that should be hyphenated
long_word = "supercalifragilisticexpialidocious" # 34 characters
result = line.add_word(long_word, self.mono_font_12)
# The word should be partially added (hyphenated) or rejected
if result is None:
# Word fit completely (shouldn't happen with our narrow line)
self.fail("Long word should not fit completely in narrow line")
else:
# Word was partially added or rejected
remaining_text = result
# Check that some text was added to the line
self.assertGreater(len(line.text_objects), 0, "Some text should be added to line")
# Check that remaining text is shorter than original
if remaining_text:
self.assertLess(len(remaining_text), len(long_word),
"Remaining text should be shorter than original")
print(f"Original word: '{long_word}' ({len(long_word)} chars)")
if line.text_objects:
added_text = line.text_objects[0].text
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
print(f"Remaining: '{remaining_text}' ({len(remaining_text)} chars)")
def test_alignment_with_monospace(self):
"""Test different alignment modes with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
line_width = self.char_width_12 * 20 # 20 characters wide
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
test_words = ["HELLO", "WORLD", "TEST"] # Known character counts: 5, 5, 4
for alignment in alignments:
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(line_width, 20),
font=self.mono_font_12,
halign=alignment
)
# Add all test words
for word in test_words:
result = line.add_word(word, self.mono_font_12)
if result is not None:
break # Word didn't fit
# Render the line to test alignment
line_image = line.render()
# Basic validation that line rendered successfully
self.assertIsInstance(line_image, Image.Image)
self.assertEqual(line_image.size, (line_width, 20))
print(f"Line with {alignment.name} alignment rendered successfully")
def test_hyphenation_points(self):
"""Test hyphenation at specific points with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Test words that should hyphenate at predictable points
test_cases = [
("hyphenation", ["hy-", "phen-", "ation"]), # Expected breaks
("computer", ["com-", "put-", "er"]),
("beautiful", ["beau-", "ti-", "ful"]),
("information", ["in-", "for-", "ma-", "tion"])
]
for word, expected_parts in test_cases:
# Create Word object for hyphenation testing
word_obj = Word(word, self.mono_font_12)
if word_obj.hyphenate():
parts_count = word_obj.get_hyphenated_part_count()
print(f"Word '{word}' hyphenated into {parts_count} parts:")
actual_parts = []
for i in range(parts_count):
part = word_obj.get_hyphenated_part(i)
actual_parts.append(part)
print(f" Part {i}: '{part}'")
# Verify that parts can be rendered and have expected widths
for part in actual_parts:
text_obj = Text(part, self.mono_font_12)
expected_width = len(part) * self.char_width_12
# Allow variance for hyphen and rendering differences
width_diff = abs(text_obj.width - expected_width)
self.assertLessEqual(width_diff, 13,
f"Hyphenated part '{part}' should have predictable width")
else:
print(f"Word '{word}' could not be hyphenated")
def test_line_overflow_scenarios(self):
"""Test various line overflow scenarios with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Test case 1: Single character that barely fits
char_line = Line(
spacing=(1, 3),
origin=(0, 0),
size=(self.char_width_12 + 2, 20), # Just enough for one character
font=self.mono_font_12,
halign=Alignment.LEFT
)
result = char_line.add_word("A", self.mono_font_12)
self.assertIsNone(result, "Single character should fit in character-sized line")
# Test case 2: Word that's exactly the line width
exact_width = self.char_width_12 * 5 # Exactly 5 characters
exact_line = Line(
spacing=(0, 2),
origin=(0, 0),
size=(exact_width, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
result = exact_line.add_word("HELLO", self.mono_font_12) # Exactly 5 characters
# This might fit or might not depending on margins - test that it behaves consistently
print(f"Word 'HELLO' in exact-width line: {'fit' if result is None else 'did not fit'}")
# Test case 3: Multiple short words vs one long word
multi_word_line = Line(
spacing=(3, 6),
origin=(0, 0),
size=(self.char_width_12 * 20, 20), # 20 characters
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Add multiple short words
short_words = ["CAT", "DOG", "BIRD", "FISH"] # 3 chars each
words_added = 0
for word in short_words:
result = multi_word_line.add_word(word, self.mono_font_12)
if result is not None:
break
words_added += 1
print(f"Added {words_added} short words to 20-character line")
# Should be able to add at least 2 words (3 chars + 3 spacing + 3 chars = 9 chars)
self.assertGreaterEqual(words_added, 2, "Should fit at least 2 short words")
def test_spacing_calculation_accuracy(self):
"""Test that spacing calculations are accurate with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
line_width = self.char_width_12 * 30 # 30 characters
# Test justify alignment which distributes spacing
justify_line = Line(
spacing=(2, 10),
origin=(0, 0),
size=(line_width, 20),
font=self.mono_font_12,
halign=Alignment.JUSTIFY
)
# Add words that should allow for even spacing
words = ["WORD", "WORD", "WORD"] # 3 words, 4 characters each = 12 characters
# Remaining space: 30 - 12 = 18 characters for spacing
# 2 spaces between 3 words = 9 characters per space
for word in words:
result = justify_line.add_word(word, self.mono_font_12)
if result is not None:
break
# Render and verify
line_image = justify_line.render()
self.assertIsInstance(line_image, Image.Image)
print(f"Justified line with calculated spacing rendered successfully")
# Test that text objects are positioned correctly
text_objects = justify_line.text_objects
if len(text_objects) >= 2:
# Calculate actual spacing between words
first_word_end = text_objects[0].width
second_word_start = 0 # This would need to be calculated from positioning
print(f"Added {len(text_objects)} words to justified line")
def save_test_output(self, test_name: str, image: Image.Image):
"""Save test output image for visual inspection."""
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_path = os.path.join(output_dir, f"monospace_{test_name}.png")
image.save(output_path)
print(f"Test output saved to: {output_path}")
def test_complete_paragraph_layout(self):
"""Test a complete paragraph layout with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Create a page for paragraph layout
page = Page(size=(800, 600))
# Test paragraph with known character counts
test_text = (
"This is a test paragraph with mono-space font rendering. "
"Each character should have exactly the same width, making "
"line breaking and text flow calculations predictable and "
"testable. We can verify that word wrapping occurs at the "
"expected positions based on character counts and spacing."
)
# Create container for the paragraph
paragraph_container = Container(
origin=(0, 0),
size=(400, 200), # Fixed width for predictable wrapping
direction='vertical',
spacing=2,
padding=(10, 10, 10, 10)
)
# Split text into words and create lines
words = test_text.split()
current_line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(380, 20), # 400 - 20 for padding
font=self.mono_font_12,
halign=Alignment.LEFT
)
lines_created = 0
words_processed = 0
for word in words:
result = current_line.add_word(word, self.mono_font_12)
if result is not None:
# Word didn't fit, start new line
if len(current_line.text_objects) > 0:
paragraph_container.add_child(current_line)
lines_created += 1
# Create new line
current_line = Line(
spacing=(3, 8),
origin=(0, lines_created * 22), # 20 height + 2 spacing
size=(380, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Try to add the word to the new line
result = current_line.add_word(word, self.mono_font_12)
if result is not None:
# Word still doesn't fit, might need hyphenation
print(f"Warning: Word '{word}' doesn't fit even on new line")
else:
words_processed += 1
else:
words_processed += 1
# Add the last line if it has content
if len(current_line.text_objects) > 0:
paragraph_container.add_child(current_line)
lines_created += 1
# Add paragraph to page
page.add_child(paragraph_container)
# Render the complete page
page_image = page.render()
print(f"Paragraph layout: {words_processed}/{len(words)} words processed, {lines_created} lines created")
# Save output for visual inspection
self.save_test_output("paragraph_layout", page_image)
# Basic validation
self.assertGreater(lines_created, 1, "Should create multiple lines")
self.assertGreater(words_processed, len(words) * 0.8, "Should process most words")
if __name__ == '__main__':
# Create output directory for test results
if not os.path.exists("test_output"):
os.makedirs("test_output")
unittest.main(verbosity=2)