""" 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)