New alignement handlers
Python CI / test (push) Failing after 4m28s

This commit is contained in:
2025-06-08 14:08:29 +02:00
parent 9baafe85bf
commit 8d892bfe28
5 changed files with 636 additions and 232 deletions
+247 -70
View File
@@ -5,8 +5,146 @@ from pyWebLayout.style.layout import Alignment
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.abstract.inline import Word
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple, Union, List, Optional
from typing import Tuple, Union, List, Optional, Protocol
import numpy as np
from abc import ABC, abstractmethod
class AlignmentHandler(ABC):
"""
Abstract base class for text alignment handlers.
Each handler implements a specific alignment strategy.
"""
@abstractmethod
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
"""
Calculate the spacing between words and starting position for the line.
Args:
text_objects: List of Text objects in the line
available_width: Total width available for the line
min_spacing: Minimum spacing between words
max_spacing: Maximum spacing between words
Returns:
Tuple of (spacing_between_words, starting_x_position)
"""
pass
@abstractmethod
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int) -> bool:
"""
Determine if hyphenation should be attempted for better spacing.
Args:
text_objects: Current text objects in the line
word_width: Width of the word trying to be added
available_width: Available width remaining
spacing: Current minimum spacing being used
Returns:
True if hyphenation should be attempted
"""
pass
class LeftAlignmentHandler(AlignmentHandler):
"""Handler for left-aligned text."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
"""Left alignment uses minimum spacing and starts at position 0."""
return min_spacing, 0
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int) -> bool:
"""For left alignment, hyphenate only if the word doesn't fit."""
return word_width > available_width
class CenterRightAlignmentHandler(AlignmentHandler):
"""Handler for center and right-aligned text."""
def __init__(self, alignment: Alignment):
self._alignment = alignment
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
"""Center/right alignment uses minimum spacing with calculated start position."""
if not text_objects:
return min_spacing, 0
total_text_width = sum(text_obj.width for text_obj in text_objects)
num_spaces = len(text_objects) - 1
spacing = min_spacing
if self._alignment == Alignment.RIGHT:
x_pos = available_width - (total_text_width + spacing * num_spaces)
else: # CENTER
x_pos = (available_width - (total_text_width + spacing * num_spaces)) // 2
return spacing, max(0, x_pos)
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int) -> bool:
"""For center/right alignment, hyphenate only if the word doesn't fit."""
return word_width > available_width
class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with optimal spacing."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
"""Justified alignment distributes space evenly between words."""
if not text_objects or len(text_objects) == 1:
# Single word or empty line - use left alignment
return min_spacing, 0
total_text_width = sum(text_obj.width for text_obj in text_objects)
num_spaces = len(text_objects) - 1
available_space = available_width - total_text_width
if num_spaces > 0:
spacing = available_space // num_spaces
# Ensure spacing is within acceptable bounds
spacing = max(min_spacing, min(max_spacing, spacing))
else:
spacing = min_spacing
return spacing, 0
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int) -> bool:
"""
For justified text, consider hyphenation if it would improve spacing quality.
This includes cases where the word fits but would create poor spacing.
"""
if word_width > available_width:
return True
# Calculate what the spacing would be with this word added
if not text_objects:
return False
total_text_width = sum(text_obj.width for text_obj in text_objects) + word_width
num_spaces = len(text_objects) # Will be len(text_objects) after adding the word
available_space = available_width - total_text_width
if num_spaces > 0:
projected_spacing = available_space // num_spaces
# If spacing would be too large, consider hyphenation for better distribution
max_acceptable_spacing = spacing * 2 # Allow up to 2x normal spacing
return projected_spacing > max_acceptable_spacing
return False
class Text(Renderable, Queriable):
@@ -227,6 +365,26 @@ class Line(Box):
self._previous = previous
self._next = None
# Create the appropriate alignment handler
self._alignment_handler = self._create_alignment_handler(halign)
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
"""
Create the appropriate alignment handler based on the alignment type.
Args:
alignment: The alignment type
Returns:
The appropriate alignment handler instance
"""
if alignment == Alignment.LEFT:
return LeftAlignmentHandler()
elif alignment == Alignment.JUSTIFY:
return JustifyAlignmentHandler()
else: # CENTER or RIGHT
return CenterRightAlignmentHandler(alignment)
@property
def text_objects(self) -> List[Text]:
@@ -286,7 +444,7 @@ class Line(Box):
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
"""
Add a word to this line as a Text object.
Add a word to this line as a Text object using intelligent hyphenation decisions.
Args:
text: The text content of the word
@@ -307,65 +465,105 @@ class Line(Box):
spacing_needed = min_spacing if self._text_objects else 0
# Add a small margin to prevent edge cases where words appear to fit but get cropped
# This addresses the issue of lines appearing too short
safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin
# Check if word fits in the line with safety margin
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
if word_width <= available_width:
# Word fits - add it to the line
# Use the alignment handler to decide if hyphenation should be attempted
should_hyphenate = self._alignment_handler.should_try_hyphenation(
self._text_objects, word_width, available_width, min_spacing)
if word_width <= available_width and not should_hyphenate:
# Word fits and alignment handler doesn't suggest hyphenation - add it to the line
text_obj.add_to_line(self)
self._text_objects.append(text_obj)
self._current_width += spacing_needed + word_width
return None
elif should_hyphenate:
# Try hyphenation for better spacing
return self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin)
else:
# Word doesn't fit - try to hyphenate
abstract_word = Word(text, font)
if abstract_word.hyphenate():
# Get the first hyphenated part
first_part_text = abstract_word.get_hyphenated_part(0)
first_part_obj = Text(first_part_text, font)
# Check if first part fits (with safety margin)
if first_part_obj.width <= available_width:
# First part fits - add it to the line
first_part_obj.add_to_line(self)
self._text_objects.append(first_part_obj)
self._current_width += spacing_needed + first_part_obj.width
# Return the remaining part(s)
if abstract_word.get_hyphenated_part_count() > 1:
return abstract_word.get_hyphenated_part(1)
else:
return None
else:
# Even the first hyphenated part doesn't fit
if self._text_objects:
# Line already has words, can't fit this one at all
return text
else:
# Empty line - must fit something or infinite loop
first_part_text = abstract_word.get_hyphenated_part(0)
# If the first part is nearly as long as the original word, this is likely a test
if len(first_part_text.rstrip('-')) >= len(text) * 0.8: # 80% of original length
# This is likely a mocked test scenario - return original word unchanged
return text
else:
# Real scenario with proper hyphenation - try force fitting
return self._force_fit_long_word(text, font, available_width + safety_margin)
# Word doesn't fit and no hyphenation recommended
if self._text_objects:
# Line already has words, can't fit this one at all
return text
else:
# Word cannot be hyphenated
if self._text_objects:
# Line already has words, can't fit this unhyphenatable word
return text
# Empty line with word that's too long - force fit
return self._force_fit_long_word(text, font, available_width + safety_margin)
def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int,
spacing_needed: int, safety_margin: int) -> Union[None, str]:
"""
Try different hyphenation options and choose the best one for spacing.
Args:
text: The text to hyphenate
font: The font to use
available_width: Available width for the word
spacing_needed: Spacing needed before the word
safety_margin: Safety margin for fitting
Returns:
None if the word fits, or remaining text if it doesn't fit
"""
abstract_word = Word(text, font)
if abstract_word.hyphenate():
# Try different hyphenation breakpoints to find the best spacing
best_option = None
best_spacing_quality = float('inf') # Lower is better
for i in range(abstract_word.get_hyphenated_part_count()):
part_text = abstract_word.get_hyphenated_part(i)
part_obj = Text(part_text, font)
if part_obj.width <= available_width:
# Calculate spacing quality with this hyphenation
temp_text_objects = self._text_objects + [part_obj]
spacing, _ = self._alignment_handler.calculate_spacing_and_position(
temp_text_objects, self._size[0], self._spacing[0], self._spacing[1])
# Quality metric: prefer spacing closer to minimum, avoid extremes
spacing_quality = abs(spacing - self._spacing[0])
if spacing_quality < best_spacing_quality:
best_spacing_quality = spacing_quality
best_option = (i, part_obj, part_text)
else:
# Empty line with unhyphenatable word that's too long
# Force-fit as many characters as possible
# Can't fit this part, no point trying longer parts
break
if best_option:
# Use the best hyphenation option
i, part_obj, part_text = best_option
part_obj.add_to_line(self)
self._text_objects.append(part_obj)
self._current_width += spacing_needed + part_obj.width
# Return remaining part(s) if any
if i + 1 < abstract_word.get_hyphenated_part_count():
return abstract_word.get_hyphenated_part(i + 1)
else:
return None
else:
# No hyphenation part fits
if self._text_objects:
return text # Line already has words, can't fit this one
else:
# Empty line - must fit something
return self._force_fit_long_word(text, font, available_width + safety_margin)
else:
# Word cannot be hyphenated
if self._text_objects:
return text # Line already has words, can't fit this unhyphenatable word
else:
# Empty line with unhyphenatable word that's too long
return self._force_fit_long_word(text, font, available_width + safety_margin)
def render(self) -> Image.Image:
"""
Render the line with all its text objects.
Render the line with all its text objects using the alignment handler system.
Returns:
A PIL Image containing the rendered line
@@ -377,30 +575,9 @@ class Line(Box):
if not self._text_objects:
return canvas
# Calculate total width of text objects
total_text_width = sum(text_obj.width for text_obj in self._text_objects)
# Calculate spacing based on alignment and available space
available_space = self._size[0] - total_text_width
num_spaces = len(self._text_objects) - 1
if num_spaces > 0:
if self._halign == Alignment.JUSTIFY:
# For justified text, distribute space evenly between words
spacing = available_space // num_spaces
else:
# Use minimum spacing for other alignments
spacing = self._spacing[0]
else:
spacing = 0
# Calculate starting x position based on alignment
if self._halign == Alignment.LEFT:
x_pos = 0
elif self._halign == Alignment.RIGHT:
x_pos = self._size[0] - (total_text_width + spacing * num_spaces)
else: # CENTER
x_pos = (self._size[0] - (total_text_width + spacing * num_spaces)) // 2
# Use the alignment handler to calculate spacing and position
spacing, x_pos = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
# Vertical alignment - center text vertically in the line
y_pos = (self._size[1] - max(text_obj.height for text_obj in self._text_objects)) // 2