Duncan Tourolle 41dc904755
All checks were successful
Python CI / test (3.10) (push) Successful in 2m17s
Python CI / test (3.12) (push) Successful in 2m6s
Python CI / test (3.13) (push) Successful in 2m1s
fixed issue where last word was counted for spacing
2025-11-10 15:22:18 +01:00

719 lines
28 KiB
Python

from __future__ import annotations
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult
from .box import Box
from pyWebLayout.style import Alignment, Font, TextDecoration
from pyWebLayout.abstract import Word
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import Link
from PIL import ImageDraw
from typing import Tuple, List, Optional
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, bool]:
"""
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)
"""
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, bool]:
"""
Calculate spacing and position for left-aligned text objects.
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
Args:
text_objects (List[Text]): A list of text objects to be laid out.
available_width (int): The total width available for layout.
min_spacing (int): Minimum spacing between text objects.
max_spacing (int): Maximum spacing between text objects.
Returns:
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
"""
# Handle single word case
if len(text_objects) <= 1:
return 0, 0, False
# Calculate the total length of all text objects
text_length = sum([text.width for text in text_objects])
# Calculate number of gaps between texts
num_gaps = len(text_objects) - 1
# Calculate minimum space needed (text + minimum gaps)
min_total_width = text_length + (min_spacing * num_gaps)
# Check if we have overflow (CREngine pattern: always use min_spacing for
# overflow)
if min_total_width > available_width:
return min_spacing, 0, True # Overflow - but use safe minimum spacing
# Calculate residual space left after accounting for text lengths
residual_space = available_width - text_length
# Calculate ideal spacing
actual_spacing = residual_space // num_gaps
# Clamp within bounds (CREngine pattern: respect max_spacing)
if actual_spacing > max_spacing:
return max_spacing, 0, False
elif actual_spacing < min_spacing:
# Ensure we never return spacing less than min_spacing
return min_spacing, 0, False
else:
return actual_spacing, 0, False # Use calculated spacing
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, bool]:
"""Center/right alignment uses minimum spacing with calculated start position."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
# Handle single word case
if len(text_objects) <= 1:
if self._alignment == Alignment.CENTER:
start_position = (available_width - word_length) // 2
else: # RIGHT
start_position = available_width - word_length
return 0, max(0, start_position), False
actual_spacing = residual_space // (len(text_objects) - 1)
ideal_space = (min_spacing + max_spacing) / 2
if actual_spacing > 0.5 * (min_spacing + max_spacing):
actual_spacing = 0.5 * (min_spacing + max_spacing)
content_length = word_length + (len(text_objects) - 1) * actual_spacing
if self._alignment == Alignment.CENTER:
start_position = (available_width - content_length) // 2
else:
start_position = available_width - content_length
if actual_spacing < min_spacing:
return actual_spacing, max(0, start_position), True
return ideal_space, max(0, start_position), False
class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with full justification."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Justified alignment distributes space to fill the entire line width."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
num_gaps = max(1, len(text_objects) - 1)
actual_spacing = residual_space // num_gaps
ideal_space = (min_spacing + max_spacing) // 2
# can we touch the end?
if actual_spacing < max_spacing:
if actual_spacing < min_spacing:
# Ensure we never return spacing less than min_spacing
return min_spacing, 0, True
return max(min_spacing, actual_spacing), 0, False
return ideal_space, 0, False
class Text(Renderable, Queriable):
"""
Concrete implementation for rendering text.
This class handles the visual representation of text fragments.
"""
def __init__(
self,
text: str,
style: Font,
draw: ImageDraw.Draw,
source: Optional[Word] = None,
line: Optional[Line] = None):
"""
Initialize a Text object.
Args:
text: The text content to render
style: The font style to use for rendering
"""
super().__init__()
self._text = text
self._style = style
self._line = line
self._source = source
self._origin = np.array([0, 0])
self._draw = draw
# Calculate dimensions
self._calculate_dimensions()
def _calculate_dimensions(self):
"""Calculate the width and height of the text based on the font metrics"""
# Get the size using PIL's text size functionality
font = self._style.font
self._width = self._draw.textlength(self._text, font=font)
ascent, descent = font.getmetrics()
self._ascent = ascent
self._middle_y = ascent - descent / 2
@classmethod
def from_word(cls, word: Word, draw: ImageDraw.Draw):
return cls(word.text, word.style, draw)
@property
def text(self) -> str:
"""Get the text content"""
return self._text
@property
def style(self) -> Font:
"""Get the text style"""
return self._style
@property
def origin(self) -> np.ndarray:
"""Get the origin of the text"""
return self._origin
@property
def line(self) -> Optional[Line]:
"""Get the line containing this text"""
return self._line
@line.setter
def line(self, line):
"""Set the line containing this text"""
self._line = line
@property
def width(self) -> int:
"""Get the width of the text"""
return self._width
@property
def size(self) -> int:
"""Get the width and height of the text"""
# Return actual rendered height (ascent + descent) not just font_size
ascent, descent = self._style.font.getmetrics()
actual_height = ascent + descent
return np.array((self._width, actual_height))
def set_origin(self, origin: np.generic):
"""Set the origin (left baseline ("ls")) of this text element"""
self._origin = origin
def add_line(self, line):
"""Add this text to a line"""
self._line = line
def in_object(self, point: np.generic):
"""
Check if a point is in the text object.
Override Queriable.in_object() because Text uses baseline-anchored positioning.
The origin is at the baseline (anchor="ls"), not the top-left corner.
Args:
point: The coordinates to check
Returns:
True if the point is within the text bounds
"""
point_array = np.array(point)
# Text origin is at baseline, so visual top is origin[1] - ascent
visual_top = self._origin[1] - self._ascent
visual_bottom = self._origin[1] + (self.size[1] - self._ascent)
# Check if point is within bounds
# X: origin[0] to origin[0] + width
# Y: visual_top to visual_bottom
return (self._origin[0] <= point_array[0] < self._origin[0] + self.size[0] and
visual_top <= point_array[1] < visual_bottom)
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Apply text decoration (underline or strikethrough).
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
if self._style.decoration == TextDecoration.UNDERLINE:
# Draw underline at about 90% of the height
y_position = self._origin[1] - 0.1 * self._style.font_size
line_width = max(1, int(self._style.font_size / 15))
# Determine end x-coordinate
end_x = self._origin[0] + self._width
# If next text also has underline decoration, extend to connect them
if (next_text is not None and
next_text.style.decoration == TextDecoration.UNDERLINE and
next_text.style.colour == self._style.colour):
# Extend the underline through the spacing to connect with next word
end_x += spacing
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
fill=self._style.colour, width=line_width)
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
# Draw strikethrough at about 50% of the height
y_position = self._origin[1] + self._middle_y
line_width = max(1, int(self._style.font_size / 15))
# Determine end x-coordinate
end_x = self._origin[0] + self._width
# If next text also has strikethrough decoration, extend to connect them
if (next_text is not None and
next_text.style.decoration == TextDecoration.STRIKETHROUGH and
next_text.style.colour == self._style.colour):
# Extend the strikethrough through the spacing to connect with next word
end_x += spacing
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
fill=self._style.colour, width=line_width)
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the text to an image.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
Returns:
A PIL Image containing the rendered text
"""
# Draw the text background if specified
if self._style.background and self._style.background[3] > 0: # If alpha > 0
self._draw.rectangle([self._origin, self._origin +
self._size], fill=self._style.background)
# Draw the text using baseline as anchor point ("ls" = left-baseline)
# This ensures the origin represents the baseline, not the top-left
self._draw.text(
(self.origin[0],
self._origin[1]),
self._text,
font=self._style.font,
fill=self._style.colour,
anchor="ls")
# Apply any text decorations with knowledge of next text
self._apply_decoration(next_text, spacing)
class Line(Box):
"""
A line of text consisting of Text objects with consistent spacing.
Each Text represents a word or word fragment that can be rendered.
"""
def __init__(self,
spacing: Tuple[int,
int],
origin,
size,
draw: ImageDraw.Draw,
font: Optional[Font] = None,
callback=None,
sheet=None,
mode=None,
halign=Alignment.CENTER,
valign=Alignment.CENTER,
previous=None,
min_word_length_for_brute_force: int = 8,
min_chars_before_hyphen: int = 2,
min_chars_after_hyphen: int = 2):
"""
Initialize a new line.
Args:
spacing: A tuple of (min_spacing, max_spacing) between words
origin: The top-left position of the line
size: The width and height of the line
font: The default font to use for text in this line
callback: Optional callback function
sheet: Optional image sheet
mode: Optional image mode
halign: Horizontal alignment of text within the line
valign: Vertical alignment of text within the line
previous: Reference to the previous line
min_word_length_for_brute_force: Minimum word length to attempt brute force hyphenation (default: 8)
min_chars_before_hyphen: Minimum characters before hyphen in any split (default: 2)
min_chars_after_hyphen: Minimum characters after hyphen in any split (default: 2)
"""
super().__init__(origin, size, callback, sheet, mode, halign, valign)
self._text_objects: List['Text'] = [] # Store Text objects directly
self._spacing = spacing # (min_spacing, max_spacing)
self._font = font if font else Font() # Use default font if none provided
self._current_width = 0 # Track the current width used
self._words: List['Word'] = []
self._previous = previous
self._next = None
ascent, descent = self._font.font.getmetrics()
# Store baseline as offset from line origin (top), not absolute position
self._baseline = ascent
self._draw = draw
self._spacing_render = (spacing[0] + spacing[1]) // 2
self._position_render = 0
# Hyphenation configuration parameters
self._min_word_length_for_brute_force = min_word_length_for_brute_force
self._min_chars_before_hyphen = min_chars_before_hyphen
self._min_chars_after_hyphen = min_chars_after_hyphen
# 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]:
"""Get the list of Text objects in this line"""
return self._text_objects
def set_next(self, line: Line):
"""Set the next line in sequence"""
self._next = line
def add_word(self,
word: 'Word',
part: Optional[Text] = None) -> Tuple[bool,
Optional['Text']]:
"""
Add a word to this line using intelligent word fitting strategies.
Args:
word: The word to add to the line
part: Optional pretext from a previous hyphenated word
Returns:
Tuple of (success, overflow_text):
- success: True if word/part was added, False if it couldn't fit
- overflow_text: Remaining text if word was hyphenated, None otherwise
"""
# First, add any pretext from previous hyphenation
if part is not None:
self._text_objects.append(part)
self._words.append(word)
part.add_line(self)
# Try to add the full word - create LinkText for LinkedWord, regular Text
# otherwise
if isinstance(word, LinkedWord):
# Import here to avoid circular dependency
from .functional import LinkText
# Create a LinkText which includes the link functionality
# LinkText constructor needs: (link, text, font, draw, source, line)
# But LinkedWord itself contains the link properties
# We'll create a Link object from the LinkedWord properties
link = Link(
location=word.location,
link_type=word.link_type,
callback=word.link_callback,
params=word.params,
title=word.link_title
)
text = LinkText(
link,
word.text,
word.style,
self._draw,
source=word,
line=self)
else:
text = Text.from_word(word, self._draw)
self._text_objects.append(text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
if not overflow:
# Word fits! Add it completely
self._words.append(word)
word.add_concete(text)
text.add_line(self)
self._position_render = position
self._spacing_render = spacing
return True, None
# Word doesn't fit, remove it and try hyphenation
_ = self._text_objects.pop()
# Step 1: Try pyphen hyphenation
pyphen_splits = word.possible_hyphenation()
valid_splits = []
if pyphen_splits:
# Create Text objects for each possible split and check if they fit
for pair in pyphen_splits:
first_part_text = pair[0] + "-"
second_part_text = pair[1]
# Validate minimum character requirements
if len(pair[0]) < self._min_chars_before_hyphen:
continue
if len(pair[1]) < self._min_chars_after_hyphen:
continue
# Create Text objects
first_text = Text(
first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Check if first part fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
_ = self._text_objects.pop()
if not overflow:
# This split fits! Add it to valid options
valid_splits.append((first_text, second_text, spacing, position))
# Step 2: If we have valid pyphen splits, choose the best one
if valid_splits:
# Select the split with the best (minimum) spacing
best_split = min(valid_splits, key=lambda x: x[2])
first_text, second_text, spacing, position = best_split
# Apply the split
self._text_objects.append(first_text)
first_text.line = self
word.add_concete((first_text, second_text))
self._spacing_render = spacing
self._position_render = position
self._words.append(word)
return True, second_text
# Step 3: Try brute force hyphenation (only for long words)
if len(word.text) >= self._min_word_length_for_brute_force:
# Calculate available space for the word
word_length = sum([text.width for text in self._text_objects])
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
remaining = self._size[0] - word_length - spacing_length
if remaining > 0:
# Create a hyphenated version to measure
test_text = Text(word.text + "-", word.style, self._draw)
if test_text.width > 0:
# Calculate what fraction of the hyphenated word fits
fraction = remaining / test_text.width
# Convert fraction to character position
# We need at least min_chars_before_hyphen and leave at least
# min_chars_after_hyphen
max_split_pos = len(word.text) - self._min_chars_after_hyphen
min_split_pos = self._min_chars_before_hyphen
# Calculate ideal split position based on available space
ideal_split = int(fraction * len(word.text))
split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
# Ensure we meet minimum requirements
if (split_pos >= self._min_chars_before_hyphen and
len(word.text) - split_pos >= self._min_chars_after_hyphen):
# Create the split
first_part_text = word.text[:split_pos] + "-"
second_part_text = word.text[split_pos:]
first_text = Text(
first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Verify the first part actually fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
if not overflow:
# Brute force split works!
first_text.line = self
second_text.line = self
word.add_concete((first_text, second_text))
self._spacing_render = spacing
self._position_render = position
self._words.append(word)
return True, second_text
else:
# Doesn't fit, remove it
_ = self._text_objects.pop()
# Step 4: Word cannot be hyphenated or split, move to next line
return False, None
def render(self):
"""
Render the line with all its text objects using the alignment handler system.
Returns:
A PIL Image containing the rendered line
"""
# Recalculate spacing and position for current text objects to ensure accuracy
if len(self._text_objects) > 0:
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._spacing_render = spacing
self._position_render = position
y_cursor = self._origin[1] + self._baseline
# Start x_cursor at line origin plus any alignment offset
x_cursor = self._origin[0] + self._position_render
for i, text in enumerate(self._text_objects):
# Update text draw context to current draw context
text._draw = self._draw
text.set_origin(np.array([x_cursor, y_cursor]))
# Determine next text object for continuous decoration
next_text = self._text_objects[i + 1] if i + \
1 < len(self._text_objects) else None
# Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render)
# Add text width, then spacing only if there are more words
x_cursor += text.width
if i < len(self._text_objects) - 1:
x_cursor += self._spacing_render
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
"""
Find which Text object contains the given point.
Uses Queriable.in_object() mixin for hit-testing.
Args:
point: (x, y) coordinates to query
Returns:
QueryResult from the text object at that point, or None
"""
point_array = np.array(point)
# Check each text object in this line
for text_obj in self._text_objects:
# Use Queriable mixin's in_object() for hit-testing
if isinstance(text_obj, Queriable) and text_obj.in_object(point_array):
# Extract metadata based on text type
origin = text_obj._origin
size = text_obj.size
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
# Bounds should be (x, visual_top, width, height) for proper
# highlighting
visual_top = int(origin[1] - text_obj._ascent)
bounds = (
int(origin[0]),
visual_top,
int(size[0]) if hasattr(size, '__getitem__') else 0,
int(size[1]) if hasattr(size, '__getitem__') else 0
)
# Import here to avoid circular dependency
from .functional import LinkText, ButtonText
if isinstance(text_obj, LinkText):
result = QueryResult(
object=text_obj,
object_type="link",
bounds=bounds,
text=text_obj._text,
is_interactive=True,
link_target=text_obj._link.location if hasattr(
text_obj,
'_link') else None)
elif isinstance(text_obj, ButtonText):
result = QueryResult(
object=text_obj,
object_type="button",
bounds=bounds,
text=text_obj._text,
is_interactive=True,
callback=text_obj._callback if hasattr(
text_obj,
'_callback') else None)
else:
result = QueryResult(
object=text_obj,
object_type="text",
bounds=bounds,
text=text_obj._text if hasattr(text_obj, '_text') else None
)
result.parent_line = self
return result
return None