+247
-70
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user