@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Basic mono-space font tests for predictable character width behavior.
|
||||
|
||||
This test focuses on the fundamental property of mono-space fonts:
|
||||
every character has the same width, making layout calculations predictable.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestMonospaceBasics(unittest.TestCase):
|
||||
"""Basic tests for mono-space font behavior."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test with a mono-space font if available."""
|
||||
# Try to find DejaVu Sans Mono
|
||||
mono_paths = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
"/System/Library/Fonts/Monaco.ttf",
|
||||
"C:/Windows/Fonts/consola.ttf"
|
||||
]
|
||||
self.mono_font_path = None
|
||||
for path in mono_paths:
|
||||
if os.path.exists(path):
|
||||
self.mono_font_path = path
|
||||
break
|
||||
|
||||
if self.mono_font_path:
|
||||
self.font = Font(font_path=self.mono_font_path, font_size=12)
|
||||
|
||||
# Calculate reference character width
|
||||
ref_char = Text("M", self.font)
|
||||
self.char_width = ref_char.width
|
||||
print(f"Using mono-space font: {self.mono_font_path}")
|
||||
print(f"Character width: {self.char_width}px")
|
||||
else:
|
||||
print("No mono-space font found - tests will be skipped")
|
||||
|
||||
def test_character_width_consistency(self):
|
||||
"""Test that all characters have the same width."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test a variety of characters
|
||||
test_chars = "AaBbCc123!@#.,:;'\"()[]{}|-_+=<>"
|
||||
|
||||
widths = []
|
||||
for char in test_chars:
|
||||
text = Text(char, self.font)
|
||||
widths.append(text.width)
|
||||
print(f"'{char}': {text.width}px")
|
||||
|
||||
# All widths should be nearly identical
|
||||
min_width = min(widths)
|
||||
max_width = max(widths)
|
||||
variance = max_width - min_width
|
||||
|
||||
self.assertLessEqual(variance, 2,
|
||||
f"Character width variance should be minimal, got {variance}px")
|
||||
|
||||
def test_predictable_string_width(self):
|
||||
"""Test that string width equals character_width * length."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
test_strings = [
|
||||
"A",
|
||||
"AB",
|
||||
"ABC",
|
||||
"ABCD",
|
||||
"Hello",
|
||||
"Hello World",
|
||||
"123456789"
|
||||
]
|
||||
|
||||
for s in test_strings:
|
||||
text = Text(s, self.font)
|
||||
expected_width = len(s) * self.char_width
|
||||
actual_width = text.width
|
||||
|
||||
# Allow small variance for font rendering
|
||||
diff = abs(actual_width - expected_width)
|
||||
max_allowed_diff = len(s) + 2 # Small tolerance
|
||||
|
||||
print(f"'{s}' ({len(s)} chars): expected {expected_width}px, "
|
||||
f"actual {actual_width}px, diff {diff}px")
|
||||
|
||||
self.assertLessEqual(diff, max_allowed_diff,
|
||||
f"String '{s}' width should be predictable")
|
||||
|
||||
def test_line_capacity_prediction(self):
|
||||
"""Test that we can predict how many characters fit on a line."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test with different line widths
|
||||
test_widths = [100, 200, 300]
|
||||
|
||||
for line_width in test_widths:
|
||||
# Calculate expected character capacity
|
||||
expected_chars = line_width // self.char_width
|
||||
|
||||
# Create a line and fill it with single characters
|
||||
line = Line(
|
||||
spacing=(1, 1), # Minimal spacing
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
chars_added = 0
|
||||
for i in range(expected_chars + 5): # Try a few extra
|
||||
result = line.add_word("X", self.font)
|
||||
if result is not None: # Doesn't fit
|
||||
break
|
||||
chars_added += 1
|
||||
|
||||
print(f"Line width {line_width}px: expected ~{expected_chars} chars, "
|
||||
f"actual {chars_added} chars")
|
||||
|
||||
# Should be reasonably close to prediction
|
||||
self.assertGreaterEqual(chars_added, max(1, expected_chars - 2))
|
||||
self.assertLessEqual(chars_added, expected_chars + 2)
|
||||
|
||||
def test_word_breaking_with_known_widths(self):
|
||||
"""Test word breaking with known character widths."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a line that fits exactly 10 characters
|
||||
line_width = self.char_width * 10
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Try to add a word that's too long
|
||||
long_word = "ABCDEFGHIJKLMNOP" # 16 characters
|
||||
result = line.add_word(long_word, self.font)
|
||||
|
||||
# Word should be broken or rejected
|
||||
if result is None:
|
||||
self.fail("16-character word should not fit in 10-character line")
|
||||
else:
|
||||
print(f"Long word '{long_word}' result: '{result}'")
|
||||
|
||||
# Check that some text was added
|
||||
self.assertGreater(len(line.text_objects), 0,
|
||||
"Some text should be added to the line")
|
||||
|
||||
if line.text_objects:
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
|
||||
|
||||
# Added text should be shorter than original
|
||||
self.assertLess(len(added_text), len(long_word),
|
||||
"Added text should be shorter than original word")
|
||||
|
||||
def test_alignment_visual_differences(self):
|
||||
"""Test that different alignments produce visually different results."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Use a line width that allows for visible alignment differences
|
||||
line_width = self.char_width * 20
|
||||
test_words = ["Hello", "World"]
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "left"),
|
||||
(Alignment.CENTER, "center"),
|
||||
(Alignment.RIGHT, "right"),
|
||||
(Alignment.JUSTIFY, "justify")
|
||||
]
|
||||
|
||||
results = {}
|
||||
|
||||
for alignment, name in alignments:
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=alignment
|
||||
)
|
||||
|
||||
# Add test words
|
||||
for word in test_words:
|
||||
result = line.add_word(word, self.font)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
# Render the line
|
||||
line_image = line.render()
|
||||
results[name] = line_image
|
||||
|
||||
# Save 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"mono_align_{name}.png")
|
||||
line_image.save(output_path)
|
||||
print(f"Saved {name} alignment test to: {output_path}")
|
||||
|
||||
# All alignments should produce valid images
|
||||
for name, image in results.items():
|
||||
self.assertIsInstance(image, Image.Image)
|
||||
self.assertEqual(image.size, (line_width, 20))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Mono-space font testing concepts and demo.
|
||||
|
||||
This test demonstrates why mono-space fonts are valuable for testing
|
||||
rendering, line-breaking, and hyphenation, even when using regular fonts.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.concrete.page import Page, Container
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestMonospaceConcepts(unittest.TestCase):
|
||||
"""Demonstrate mono-space testing concepts."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test with available fonts."""
|
||||
# Use the project's default font
|
||||
self.regular_font = Font(font_size=12)
|
||||
|
||||
# Analyze character width variance
|
||||
test_chars = "iIlLmMwW0O"
|
||||
self.char_analysis = {}
|
||||
|
||||
for char in test_chars:
|
||||
text = Text(char, self.regular_font)
|
||||
self.char_analysis[char] = text.width
|
||||
|
||||
widths = list(self.char_analysis.values())
|
||||
self.min_width = min(widths)
|
||||
self.max_width = max(widths)
|
||||
self.variance = self.max_width - self.min_width
|
||||
|
||||
print(f"\nFont analysis:")
|
||||
print(f"Character width range: {self.min_width}-{self.max_width}px")
|
||||
print(f"Variance: {self.variance}px")
|
||||
|
||||
# Find most uniform character (closest to average)
|
||||
avg_width = sum(widths) / len(widths)
|
||||
self.uniform_char = min(self.char_analysis.keys(),
|
||||
key=lambda c: abs(self.char_analysis[c] - avg_width))
|
||||
print(f"Most uniform character: '{self.uniform_char}' ({self.char_analysis[self.uniform_char]}px)")
|
||||
|
||||
def test_character_width_predictability(self):
|
||||
"""Show why predictable character widths matter for testing."""
|
||||
print("\n=== Character Width Predictability Demo ===")
|
||||
|
||||
# Compare narrow vs wide characters
|
||||
narrow_word = "ill" # Narrow characters
|
||||
wide_word = "WWW" # Wide characters
|
||||
uniform_word = self.uniform_char * 3 # Uniform characters
|
||||
|
||||
narrow_text = Text(narrow_word, self.regular_font)
|
||||
wide_text = Text(wide_word, self.regular_font)
|
||||
uniform_text = Text(uniform_word, self.regular_font)
|
||||
|
||||
print(f"Same length (3 chars), different widths:")
|
||||
print(f" '{narrow_word}': {narrow_text.width}px")
|
||||
print(f" '{wide_word}': {wide_text.width}px")
|
||||
print(f" '{uniform_word}': {uniform_text.width}px")
|
||||
|
||||
# Show the problem this creates for testing
|
||||
width_ratio = wide_text.width / narrow_text.width
|
||||
print(f" Width ratio: {width_ratio:.1f}x")
|
||||
|
||||
if width_ratio > 1.5:
|
||||
print(" → This variance makes line capacity unpredictable!")
|
||||
|
||||
# With mono-space, all would be ~36px (3 chars × 12px each)
|
||||
theoretical_mono = 3 * 12
|
||||
print(f" With mono-space: ~{theoretical_mono}px each")
|
||||
|
||||
def test_line_capacity_challenges(self):
|
||||
"""Show how variable character widths affect line capacity."""
|
||||
print("\n=== Line Capacity Prediction Challenges ===")
|
||||
|
||||
line_width = 120 # Fixed width
|
||||
|
||||
# Test with different character types
|
||||
test_cases = [
|
||||
("narrow", "i" * 20), # 20 narrow chars
|
||||
("wide", "W" * 8), # 8 wide chars
|
||||
("mixed", "Hello World"), # Mixed realistic text
|
||||
("uniform", self.uniform_char * 15) # 15 uniform chars
|
||||
]
|
||||
|
||||
print(f"Line width: {line_width}px")
|
||||
|
||||
for name, test_text in test_cases:
|
||||
text_obj = Text(test_text, self.regular_font)
|
||||
fits = "YES" if text_obj.width <= line_width else "NO"
|
||||
|
||||
print(f" {name:8}: '{test_text[:15]}...' ({len(test_text)} chars)")
|
||||
print(f" Width: {text_obj.width}px, Fits: {fits}")
|
||||
|
||||
print("\nWith mono-space fonts:")
|
||||
char_width = 12 # Theoretical mono-space width
|
||||
capacity = line_width // char_width
|
||||
print(f" Predictable capacity: ~{capacity} characters")
|
||||
print(f" Any {capacity}-character string would fit")
|
||||
|
||||
def test_word_breaking_complexity(self):
|
||||
"""Demonstrate word breaking complexity with variable widths."""
|
||||
print("\n=== Word Breaking Complexity Demo ===")
|
||||
|
||||
# Create a narrow line
|
||||
line_width = 80
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.regular_font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Test different word types
|
||||
test_words = [
|
||||
("narrow", "illillill"), # 9 narrow chars
|
||||
("wide", "WWWWW"), # 5 wide chars
|
||||
("mixed", "Hello"), # 5 mixed chars
|
||||
]
|
||||
|
||||
print(f"Line width: {line_width}px")
|
||||
|
||||
for word_type, word in test_words:
|
||||
# Create fresh line for each test
|
||||
test_line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.regular_font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
word_obj = Text(word, self.regular_font)
|
||||
result = test_line.add_word(word, self.regular_font)
|
||||
|
||||
fits = "YES" if result is None else "NO"
|
||||
|
||||
print(f" {word_type:6}: '{word}' ({len(word)} chars, {word_obj.width}px) → {fits}")
|
||||
|
||||
if result is not None and test_line.text_objects:
|
||||
added = test_line.text_objects[0].text
|
||||
print(f" Added: '{added}', Remaining: '{result}'")
|
||||
|
||||
print("\nWith mono-space fonts, word fitting would be predictable:")
|
||||
char_width = 12
|
||||
capacity = line_width // char_width
|
||||
print(f" Any word ≤ {capacity} characters would fit")
|
||||
print(f" Any word > {capacity} characters would need breaking")
|
||||
|
||||
def test_alignment_consistency(self):
|
||||
"""Show how alignment behavior varies with character widths."""
|
||||
print("\n=== Alignment Consistency Demo ===")
|
||||
|
||||
line_width = 150
|
||||
|
||||
# Test different alignments with various text
|
||||
test_texts = [
|
||||
"ill ill ill", # Narrow characters
|
||||
"WWW WWW WWW", # Wide characters
|
||||
"The cat sat", # Mixed characters
|
||||
]
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "LEFT"),
|
||||
(Alignment.CENTER, "CENTER"),
|
||||
(Alignment.RIGHT, "RIGHT"),
|
||||
(Alignment.JUSTIFY, "JUSTIFY")
|
||||
]
|
||||
|
||||
results = {}
|
||||
|
||||
for align_enum, align_name in alignments:
|
||||
print(f"\n{align_name} alignment:")
|
||||
|
||||
for text in test_texts:
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.regular_font,
|
||||
halign=align_enum
|
||||
)
|
||||
|
||||
# Add words to line
|
||||
words = text.split()
|
||||
for word in words:
|
||||
result = line.add_word(word, self.regular_font)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
# Render and save
|
||||
line_image = line.render()
|
||||
|
||||
# Calculate text coverage
|
||||
text_obj = Text(text.replace(" ", ""), self.regular_font)
|
||||
coverage = text_obj.width / line_width
|
||||
|
||||
print(f" '{text}': {coverage:.1%} line coverage")
|
||||
|
||||
# Save example
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
filename = f"align_{align_name.lower()}_{text.replace(' ', '_')}.png"
|
||||
output_path = os.path.join(output_dir, filename)
|
||||
line_image.save(output_path)
|
||||
|
||||
print("\nWith mono-space fonts:")
|
||||
print(" - Alignment calculations would be simpler")
|
||||
print(" - Spacing distribution would be more predictable")
|
||||
print(" - Visual consistency would be higher")
|
||||
|
||||
def test_hyphenation_decision_factors(self):
|
||||
"""Show factors affecting hyphenation decisions."""
|
||||
print("\n=== Hyphenation Decision Factors ===")
|
||||
|
||||
# Test word that might benefit from hyphenation
|
||||
test_word = "development" # 11 characters
|
||||
word_obj = Text(test_word, self.regular_font)
|
||||
|
||||
print(f"Test word: '{test_word}' ({len(test_word)} chars, {word_obj.width}px)")
|
||||
|
||||
# Test different line widths
|
||||
test_widths = [60, 80, 100, 120, 140]
|
||||
|
||||
for width in test_widths:
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(width, 20),
|
||||
font=self.regular_font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = line.add_word(test_word, self.regular_font)
|
||||
|
||||
if result is None:
|
||||
status = "FITS completely"
|
||||
elif line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
status = f"PARTIAL: '{added}' + '{result}'"
|
||||
else:
|
||||
status = "REJECTED completely"
|
||||
|
||||
# Calculate utilization
|
||||
utilization = word_obj.width / width
|
||||
|
||||
print(f" Width {width:3}px ({utilization:>5.1%} util): {status}")
|
||||
|
||||
print("\nWith mono-space fonts:")
|
||||
char_width = 12
|
||||
word_width_mono = len(test_word) * char_width # 132px
|
||||
print(f" Word would be exactly {word_width_mono}px")
|
||||
print(f" Hyphenation decisions would be based on character count")
|
||||
print(f" Line capacity would be width ÷ {char_width}px per char")
|
||||
|
||||
def test_create_visual_comparison(self):
|
||||
"""Create visual comparison showing the difference."""
|
||||
print("\n=== Creating Visual Comparison ===")
|
||||
|
||||
# Create a page showing the problems with variable width fonts
|
||||
page = Page(size=(600, 400))
|
||||
|
||||
# Create test content
|
||||
test_text = "The quick brown fox jumps over lazy dogs with varying character widths."
|
||||
|
||||
# Split into words and create multiple lines with different alignments
|
||||
words = test_text.split()
|
||||
|
||||
# Create container for demonstration
|
||||
demo_container = Container(
|
||||
origin=(0, 0),
|
||||
size=(580, 380),
|
||||
direction='vertical',
|
||||
spacing=5,
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "Left Aligned"),
|
||||
(Alignment.CENTER, "Center Aligned"),
|
||||
(Alignment.RIGHT, "Right Aligned"),
|
||||
(Alignment.JUSTIFY, "Justified")
|
||||
]
|
||||
|
||||
for align_enum, title in alignments:
|
||||
# Add title
|
||||
from pyWebLayout.style.fonts import FontWeight
|
||||
title_text = Text(title + ":", Font(font_size=14, weight=FontWeight.BOLD))
|
||||
demo_container.add_child(title_text)
|
||||
|
||||
# Create line with this alignment
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(560, 20),
|
||||
font=self.regular_font,
|
||||
halign=align_enum
|
||||
)
|
||||
|
||||
# Add as many words as fit
|
||||
for word in words[:6]: # Limit to first 6 words
|
||||
result = line.add_word(word, self.regular_font)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
demo_container.add_child(line)
|
||||
|
||||
# Add demo to page
|
||||
page.add_child(demo_container)
|
||||
|
||||
# Render and save
|
||||
page_image = page.render()
|
||||
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, "monospace_concepts_demo.png")
|
||||
page_image.save(output_path)
|
||||
|
||||
print(f"Visual demonstration saved to: {output_path}")
|
||||
print("This shows why mono-space fonts make testing more predictable!")
|
||||
|
||||
# Validation
|
||||
self.assertIsInstance(page_image, Image.Image)
|
||||
self.assertEqual(page_image.size, (600, 400))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Ensure output directory exists
|
||||
if not os.path.exists("test_output"):
|
||||
os.makedirs("test_output")
|
||||
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Mono-space font hyphenation tests.
|
||||
|
||||
Tests hyphenation behavior with mono-space fonts where character widths
|
||||
are predictable, making it easier to verify hyphenation logic and
|
||||
line-breaking decisions.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
|
||||
class TestMonospaceHyphenation(unittest.TestCase):
|
||||
"""Test hyphenation behavior with mono-space fonts."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test with mono-space font."""
|
||||
# Try to find a mono-space font
|
||||
mono_paths = [
|
||||
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" ,
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
]
|
||||
|
||||
self.mono_font_path = None
|
||||
for path in mono_paths:
|
||||
if os.path.exists(path):
|
||||
self.mono_font_path = path
|
||||
break
|
||||
|
||||
if self.mono_font_path:
|
||||
self.font = Font(
|
||||
font_path=self.mono_font_path,
|
||||
font_size=14,
|
||||
min_hyphenation_width=20
|
||||
)
|
||||
|
||||
# Calculate character width
|
||||
ref_char = Text("M", self.font)
|
||||
self.char_width = ref_char.width
|
||||
print(f"Using mono-space font: {os.path.basename(self.mono_font_path)}")
|
||||
print(f"Character width: {self.char_width}px")
|
||||
else:
|
||||
print("No mono-space font found - hyphenation tests will be skipped")
|
||||
|
||||
def test_hyphenation_basic_functionality(self):
|
||||
"""Test basic hyphenation with known words."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test words that should hyphenate
|
||||
test_words = [
|
||||
"hyphenation",
|
||||
"development",
|
||||
"information",
|
||||
"character",
|
||||
"beautiful",
|
||||
"computer"
|
||||
]
|
||||
|
||||
for word_text in test_words:
|
||||
word = Word(word_text, self.font)
|
||||
|
||||
if word.hyphenate():
|
||||
parts_count = word.get_hyphenated_part_count()
|
||||
print(f"\nWord: '{word_text}' -> {parts_count} parts")
|
||||
|
||||
# Collect all parts
|
||||
parts = []
|
||||
for i in range(parts_count):
|
||||
part = word.get_hyphenated_part(i)
|
||||
parts.append(part)
|
||||
print(f" Part {i}: '{part}' ({len(part)} chars)")
|
||||
|
||||
# Verify that parts reconstruct the original word
|
||||
reconstructed = ''.join(parts).replace('-', '')
|
||||
self.assertEqual(reconstructed, word_text,
|
||||
f"Hyphenated parts should reconstruct '{word_text}'")
|
||||
|
||||
# Test that each part has predictable width
|
||||
for i, part in enumerate(parts):
|
||||
text_obj = Text(part, self.font)
|
||||
expected_width = len(part) * self.char_width
|
||||
actual_width = text_obj.width
|
||||
|
||||
# Allow small variance for hyphen rendering
|
||||
diff = abs(actual_width - expected_width)
|
||||
max_diff = 5 # pixels tolerance for hyphen
|
||||
|
||||
self.assertLessEqual(diff, max_diff,
|
||||
f"Part '{part}' width should be predictable")
|
||||
else:
|
||||
print(f"Word '{word_text}' cannot be hyphenated")
|
||||
|
||||
def test_hyphenation_line_fitting(self):
|
||||
"""Test that hyphenation helps words fit on lines."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a line that's too narrow for long words
|
||||
narrow_width = self.char_width * 12 # 12 characters
|
||||
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(narrow_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Test with a word that needs hyphenation
|
||||
long_word = "hyphenation" # 11 characters - should barely fit or need hyphenation
|
||||
|
||||
result = line.add_word(long_word, self.font)
|
||||
|
||||
print(f"\nTesting word '{long_word}' in {narrow_width}px line:")
|
||||
print(f"Line capacity: ~{narrow_width // self.char_width} characters")
|
||||
|
||||
if result is None:
|
||||
# Word fit completely
|
||||
print("Word fit completely on line")
|
||||
self.assertGreater(len(line.text_objects), 0, "Line should have text")
|
||||
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added text: '{added_text}'")
|
||||
|
||||
else:
|
||||
# Word was hyphenated or rejected
|
||||
print(f"Word result: '{result}'")
|
||||
|
||||
if len(line.text_objects) > 0:
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
|
||||
print(f"Remaining: '{result}' ({len(result)} chars)")
|
||||
|
||||
# Added part should be shorter than original
|
||||
self.assertLess(len(added_text), len(long_word),
|
||||
"Hyphenated part should be shorter than original")
|
||||
|
||||
# Remaining part should be shorter than original
|
||||
self.assertLess(len(result), len(long_word),
|
||||
"Remaining part should be shorter than original")
|
||||
else:
|
||||
print("No text was added to line")
|
||||
|
||||
def test_hyphenation_vs_no_hyphenation(self):
|
||||
"""Compare behavior with and without hyphenation enabled."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create fonts with and without hyphenation
|
||||
font_with_hyphen = Font(
|
||||
font_path=self.mono_font_path,
|
||||
font_size=14,
|
||||
min_hyphenation_width=20
|
||||
)
|
||||
|
||||
font_no_hyphen = Font(
|
||||
font_path=self.mono_font_path,
|
||||
font_size=14,
|
||||
)
|
||||
|
||||
# Test with a word that benefits from hyphenation
|
||||
test_word = "development" # 11 characters
|
||||
line_width = self.char_width * 8 # 8 characters - too narrow
|
||||
|
||||
# Test with hyphenation enabled
|
||||
line_with_hyphen = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=font_with_hyphen,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result_with_hyphen = line_with_hyphen.add_word(test_word, font_with_hyphen)
|
||||
|
||||
# Test without hyphenation
|
||||
line_no_hyphen = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=font_no_hyphen,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result_no_hyphen = line_no_hyphen.add_word(test_word, font_no_hyphen)
|
||||
|
||||
print(f"\nTesting '{test_word}' in {line_width}px line:")
|
||||
print(f"With hyphenation: {result_with_hyphen}")
|
||||
print(f"Without hyphenation: {result_no_hyphen}")
|
||||
|
||||
# With hyphenation, we might get partial content
|
||||
# Without hyphenation, word should be rejected entirely
|
||||
if result_with_hyphen is None:
|
||||
print("Word fit completely with hyphenation")
|
||||
elif len(line_with_hyphen.text_objects) > 0:
|
||||
added_with_hyphen = line_with_hyphen.text_objects[0].text
|
||||
print(f"Added with hyphenation: '{added_with_hyphen}'")
|
||||
|
||||
if result_no_hyphen is None:
|
||||
print("Word fit completely without hyphenation")
|
||||
elif len(line_no_hyphen.text_objects) > 0:
|
||||
added_no_hyphen = line_no_hyphen.text_objects[0].text
|
||||
print(f"Added without hyphenation: '{added_no_hyphen}'")
|
||||
|
||||
def test_hyphenation_quality_metrics(self):
|
||||
"""Test hyphenation quality with different line widths."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
test_word = "information" # 11 characters
|
||||
|
||||
# Test with different line widths
|
||||
test_widths = [
|
||||
self.char_width * 6, # Very narrow
|
||||
self.char_width * 8, # Narrow
|
||||
self.char_width * 10, # Medium
|
||||
self.char_width * 12, # Wide enough
|
||||
]
|
||||
|
||||
print(f"\nTesting hyphenation quality for '{test_word}':")
|
||||
|
||||
for width in test_widths:
|
||||
capacity = width // self.char_width
|
||||
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = line.add_word(test_word, self.font)
|
||||
|
||||
print(f"\nLine width: {width}px (~{capacity} chars)")
|
||||
|
||||
if result is None:
|
||||
print(" Word fit completely")
|
||||
if line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
print(f" Added: '{added}'")
|
||||
else:
|
||||
print(f" Result: '{result}'")
|
||||
if line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
print(f" Added: '{added}' ({len(added)} chars)")
|
||||
print(f" Remaining: '{result}' ({len(result)} chars)")
|
||||
|
||||
# Calculate hyphenation efficiency
|
||||
chars_used = len(added) - added.count('-') # Don't count hyphens
|
||||
efficiency = chars_used / len(test_word)
|
||||
print(f" Efficiency: {efficiency:.2%}")
|
||||
|
||||
def test_multiple_words_with_hyphenation(self):
|
||||
"""Test adding multiple words where hyphenation affects spacing."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a line that forces interesting hyphenation decisions
|
||||
line_width = self.char_width * 20 # 20 characters
|
||||
|
||||
line = Line(
|
||||
spacing=(3, 6),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.JUSTIFY
|
||||
)
|
||||
|
||||
# Test words that might need hyphenation
|
||||
test_words = ["The", "development", "of", "hyphenation"]
|
||||
|
||||
print(f"\nAdding words to {line_width}px line (~{line_width // self.char_width} chars):")
|
||||
|
||||
words_added = []
|
||||
for word in test_words:
|
||||
result = line.add_word(word, self.font)
|
||||
|
||||
if result is None:
|
||||
print(f" '{word}' - fit completely")
|
||||
words_added.append(word)
|
||||
else:
|
||||
print(f" '{word}' - result: '{result}'")
|
||||
if line.text_objects:
|
||||
last_added = line.text_objects[-1].text
|
||||
print(f" Added: '{last_added}'")
|
||||
words_added.append(last_added)
|
||||
break
|
||||
|
||||
print(f"Final line contains {len(line.text_objects)} text objects")
|
||||
|
||||
# Render the line to test spacing
|
||||
line_image = line.render()
|
||||
|
||||
# Save 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, "mono_hyphenation_multiword.png")
|
||||
line_image.save(output_path)
|
||||
print(f"Saved multi-word hyphenation test to: {output_path}")
|
||||
|
||||
# Basic validation
|
||||
self.assertIsInstance(line_image, Image.Image)
|
||||
self.assertEqual(line_image.size, (line_width, 20))
|
||||
|
||||
def save_hyphenation_example(self, test_name: str, lines: list):
|
||||
"""Save a visual example of hyphenation behavior."""
|
||||
from pyWebLayout.concrete.page import Container
|
||||
|
||||
# Create a container for multiple lines
|
||||
container = Container(
|
||||
origin=(0, 0),
|
||||
size=(400, len(lines) * 25),
|
||||
direction='vertical',
|
||||
spacing=5,
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
|
||||
# Add each line to the container
|
||||
for i, line in enumerate(lines):
|
||||
line._origin = (0, i * 25)
|
||||
container.add_child(line)
|
||||
|
||||
# Render the container
|
||||
container_image = container.render()
|
||||
|
||||
# Save the image
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, f"mono_hyphen_{test_name}.png")
|
||||
container_image.save(output_path)
|
||||
print(f"Saved hyphenation example '{test_name}' to: {output_path}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Test the new abstract/concrete style system.
|
||||
|
||||
This test demonstrates how the new style system addresses the memory efficiency
|
||||
concerns by using abstract styles that can be resolved to concrete styles
|
||||
based on user preferences.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.style.abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign
|
||||
)
|
||||
from pyWebLayout.style.concrete_style import (
|
||||
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
)
|
||||
from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
def test_abstract_style_is_hashable():
|
||||
"""Test that AbstractStyle objects are hashable and can be used as dict keys."""
|
||||
# Create two identical styles
|
||||
style1 = AbstractStyle(
|
||||
font_family=FontFamily.SERIF,
|
||||
font_size=16,
|
||||
font_weight=FontWeight.BOLD,
|
||||
color="red"
|
||||
)
|
||||
|
||||
style2 = AbstractStyle(
|
||||
font_family=FontFamily.SERIF,
|
||||
font_size=16,
|
||||
font_weight=FontWeight.BOLD,
|
||||
color="red"
|
||||
)
|
||||
|
||||
# They should be equal and have the same hash
|
||||
assert style1 == style2
|
||||
assert hash(style1) == hash(style2)
|
||||
|
||||
# They should work as dictionary keys
|
||||
style_dict = {style1: "first", style2: "second"}
|
||||
assert len(style_dict) == 1 # Should be deduplicated
|
||||
assert style_dict[style1] == "second" # Last value wins
|
||||
|
||||
|
||||
def test_abstract_style_registry_deduplication():
|
||||
"""Test that the registry prevents duplicate styles."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
# Create the same style twice
|
||||
style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
|
||||
style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
|
||||
|
||||
# Register both - should get same ID
|
||||
id1, _ = registry.get_or_create_style(style1)
|
||||
id2, _ = registry.get_or_create_style(style2)
|
||||
|
||||
assert id1 == id2 # Same style should get same ID
|
||||
assert registry.get_style_count() == 2 # Only default + our style
|
||||
|
||||
|
||||
def test_style_inheritance():
|
||||
"""Test that style inheritance works properly."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
# Create base style
|
||||
base_style = AbstractStyle(font_size=16, color="black")
|
||||
base_id, _ = registry.get_or_create_style(base_style)
|
||||
|
||||
# Create derived style
|
||||
derived_id, derived_style = registry.create_derived_style(
|
||||
base_id,
|
||||
font_weight=FontWeight.BOLD,
|
||||
color="red"
|
||||
)
|
||||
|
||||
# Resolve effective style
|
||||
effective = registry.resolve_effective_style(derived_id)
|
||||
|
||||
assert effective.font_size == 16 # Inherited from base
|
||||
assert effective.font_weight == FontWeight.BOLD # Overridden
|
||||
assert effective.color == "red" # Overridden
|
||||
|
||||
|
||||
def test_style_resolver_user_preferences():
|
||||
"""Test that user preferences affect concrete style resolution."""
|
||||
# Create rendering context with larger fonts
|
||||
context = RenderingContext(
|
||||
base_font_size=20, # Larger base size
|
||||
font_scale_factor=1.5, # Additional scaling
|
||||
large_text=True # Accessibility preference
|
||||
)
|
||||
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
# Create abstract style with medium size
|
||||
abstract_style = AbstractStyle(font_size=FontSize.MEDIUM)
|
||||
|
||||
# Resolve to concrete style
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36
|
||||
expected_size = int(20 * 1.0 * 1.5 * 1.2)
|
||||
assert concrete_style.font_size == expected_size
|
||||
|
||||
|
||||
def test_style_resolver_color_resolution():
|
||||
"""Test color name resolution."""
|
||||
context = RenderingContext()
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
# Test named colors
|
||||
red_style = AbstractStyle(color="red")
|
||||
concrete_red = resolver.resolve_style(red_style)
|
||||
assert concrete_red.color == (255, 0, 0)
|
||||
|
||||
# Test hex colors
|
||||
hex_style = AbstractStyle(color="#ff0000")
|
||||
concrete_hex = resolver.resolve_style(hex_style)
|
||||
assert concrete_hex.color == (255, 0, 0)
|
||||
|
||||
# Test RGB tuple (should pass through)
|
||||
rgb_style = AbstractStyle(color=(128, 64, 192))
|
||||
concrete_rgb = resolver.resolve_style(rgb_style)
|
||||
assert concrete_rgb.color == (128, 64, 192)
|
||||
|
||||
|
||||
def test_concrete_style_caching():
|
||||
"""Test that concrete styles are cached efficiently."""
|
||||
context = RenderingContext()
|
||||
registry = ConcreteStyleRegistry(StyleResolver(context))
|
||||
|
||||
# Create abstract style
|
||||
abstract_style = AbstractStyle(font_size=16, color="blue")
|
||||
|
||||
# Get font twice - should be cached
|
||||
font1 = registry.get_font(abstract_style)
|
||||
font2 = registry.get_font(abstract_style)
|
||||
|
||||
# Should be the same object (cached)
|
||||
assert font1 is font2
|
||||
|
||||
# Check cache stats
|
||||
stats = registry.get_cache_stats()
|
||||
assert stats["concrete_styles"] == 1
|
||||
assert stats["fonts"] == 1
|
||||
|
||||
|
||||
def test_global_font_scaling():
|
||||
"""Test that global font scaling affects all text."""
|
||||
# Create two contexts with different scaling
|
||||
context_normal = RenderingContext(font_scale_factor=1.0)
|
||||
context_large = RenderingContext(font_scale_factor=2.0)
|
||||
|
||||
resolver_normal = StyleResolver(context_normal)
|
||||
resolver_large = StyleResolver(context_large)
|
||||
|
||||
# Same abstract style
|
||||
abstract_style = AbstractStyle(font_size=16)
|
||||
|
||||
# Resolve with different contexts
|
||||
concrete_normal = resolver_normal.resolve_style(abstract_style)
|
||||
concrete_large = resolver_large.resolve_style(abstract_style)
|
||||
|
||||
# Large should be 2x the size
|
||||
assert concrete_large.font_size == concrete_normal.font_size * 2
|
||||
|
||||
|
||||
def test_memory_efficiency():
|
||||
"""Test that the new system is more memory efficient."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
# Create many "different" styles that are actually the same
|
||||
styles = []
|
||||
for i in range(100):
|
||||
# All these styles are identical
|
||||
style = AbstractStyle(
|
||||
font_size=16,
|
||||
font_weight=FontWeight.NORMAL,
|
||||
color="black"
|
||||
)
|
||||
style_id, _ = registry.get_or_create_style(style)
|
||||
styles.append(style_id)
|
||||
|
||||
# All should reference the same style
|
||||
assert len(set(styles)) == 1 # All IDs are the same
|
||||
assert registry.get_style_count() == 2 # Only default + our style
|
||||
|
||||
# This demonstrates that we don't create duplicate styles
|
||||
|
||||
|
||||
def test_word_style_reference_concept():
|
||||
"""Demonstrate how words would reference styles instead of storing fonts."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
# Create paragraph style
|
||||
para_style = AbstractStyle(font_size=16, color="black")
|
||||
para_id, _ = registry.get_or_create_style(para_style)
|
||||
|
||||
# Create bold word style
|
||||
bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD)
|
||||
bold_id, _ = registry.get_or_create_style(bold_style)
|
||||
|
||||
# Simulate words storing style IDs instead of full Font objects
|
||||
words_data = [
|
||||
{"text": "This", "style_id": para_id},
|
||||
{"text": "is", "style_id": para_id},
|
||||
{"text": "bold", "style_id": bold_id},
|
||||
{"text": "text", "style_id": para_id},
|
||||
]
|
||||
|
||||
# To get the actual font for rendering, we resolve through registry
|
||||
context = RenderingContext()
|
||||
concrete_registry = ConcreteStyleRegistry(StyleResolver(context))
|
||||
|
||||
for word_data in words_data:
|
||||
abstract_style = registry.get_style_by_id(word_data["style_id"])
|
||||
font = concrete_registry.get_font(abstract_style)
|
||||
|
||||
# Now we have the actual Font object for rendering
|
||||
assert font is not None
|
||||
assert hasattr(font, 'font_size')
|
||||
|
||||
# Bold word should have bold weight
|
||||
if word_data["text"] == "bold":
|
||||
assert font.weight == FontWeight.BOLD
|
||||
else:
|
||||
assert font.weight == FontWeight.NORMAL
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
Reference in New Issue
Block a user