new style handling
Python CI / test (push) Successful in 5m17s

This commit is contained in:
2025-06-22 13:42:15 +02:00
parent edac4de5b4
commit ae15fe54e8
11 changed files with 2511 additions and 66 deletions
+223
View File
@@ -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)
+343
View File
@@ -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)
+348
View File
@@ -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)
+483
View File
@@ -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)
+232
View File
@@ -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__])