484 lines
19 KiB
Python
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)
|