456 lines
16 KiB
Python
456 lines
16 KiB
Python
from __future__ import annotations
|
|
from pyWebLayout.base import Renderable, Queriable
|
|
from .box import Box
|
|
from pyWebLayout.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
|
|
import numpy as np
|
|
|
|
|
|
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):
|
|
"""
|
|
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 = None
|
|
self._previous = None
|
|
self._next = None
|
|
self._origin = np.array([0, 0])
|
|
|
|
# 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
|
|
|
|
# GetTextSize is deprecated, using textbbox for better accuracy
|
|
# The bounding box is (left, top, right, bottom)
|
|
try:
|
|
bbox = font.getbbox(self._text)
|
|
self._width = bbox[2] - bbox[0]
|
|
self._height = bbox[3] - bbox[1]
|
|
self._size = (self._width, self._height)
|
|
except AttributeError:
|
|
# Fallback for older PIL versions
|
|
self._width, self._height = font.getsize(self._text)
|
|
self._size = (self._width, self._height)
|
|
|
|
@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 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 height(self) -> int:
|
|
"""Get the height of the text"""
|
|
return self._height
|
|
|
|
@property
|
|
def size(self) -> Tuple[int, int]:
|
|
"""Get the size (width, height) of the text"""
|
|
return self._size
|
|
|
|
def set_origin(self, x: int, y: int):
|
|
"""Set the origin (top-left corner) of this text element"""
|
|
self._origin = np.array([x, y])
|
|
|
|
def add_to_line(self, line):
|
|
"""Add this text to a line"""
|
|
self._line = line
|
|
|
|
def _apply_decoration(self, draw: ImageDraw.Draw):
|
|
"""Apply text decoration (underline or strikethrough)"""
|
|
if self._style.decoration == TextDecoration.UNDERLINE:
|
|
# Draw underline at about 90% of the height
|
|
y_position = int(self._height * 0.9)
|
|
draw.line([(0, y_position), (self._width, y_position)],
|
|
fill=self._style.colour, width=max(1, int(self._style.font_size / 15)))
|
|
|
|
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
|
|
# Draw strikethrough at about 50% of the height
|
|
y_position = int(self._height * 0.5)
|
|
draw.line([(0, y_position), (self._width, y_position)],
|
|
fill=self._style.colour, width=max(1, int(self._style.font_size / 15)))
|
|
|
|
def render(self) -> Image.Image:
|
|
"""
|
|
Render the text to an image.
|
|
|
|
Returns:
|
|
A PIL Image containing the rendered text
|
|
"""
|
|
# Create a transparent image with the appropriate size
|
|
canvas = Image.new('RGBA', self._size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# Draw the text background if specified
|
|
if self._style.background and self._style.background[3] > 0: # If alpha > 0
|
|
draw.rectangle([(0, 0), self._size], fill=self._style.background)
|
|
|
|
# Draw the text
|
|
draw.text((0, 0), self._text, font=self._style.font, fill=self._style.colour)
|
|
|
|
# Apply any text decorations
|
|
self._apply_decoration(draw)
|
|
|
|
return canvas
|
|
|
|
def get_size(self) -> Tuple[int, int]:
|
|
"""Get the size (width, height) of the text"""
|
|
return self._size
|
|
|
|
def in_object(self, point):
|
|
"""Check if a point is within this text object"""
|
|
point_array = np.array(point)
|
|
relative_point = point_array - self._origin
|
|
|
|
# Check if the point is within the text boundaries
|
|
return (0 <= relative_point[0] < self._width and
|
|
0 <= relative_point[1] < self._height)
|
|
class RenderableWord(Renderable, Queriable):
|
|
"""
|
|
A concrete implementation for rendering Word objects.
|
|
This bridges between the abstract Word class and rendering capabilities.
|
|
"""
|
|
|
|
def __init__(self, word: Word):
|
|
"""
|
|
Initialize a new renderable word.
|
|
|
|
Args:
|
|
word: The abstract Word object to render
|
|
"""
|
|
super().__init__()
|
|
self._word = word
|
|
self._text_parts: List[Text] = []
|
|
self._origin = np.array([0, 0])
|
|
self._size = (0, 0)
|
|
|
|
# Initialize with the full word as a single text part
|
|
self._initialize_text_parts()
|
|
|
|
def _initialize_text_parts(self):
|
|
"""Initialize the text parts based on the word's current state"""
|
|
# Clear existing parts
|
|
self._text_parts.clear()
|
|
|
|
if self._word.hyphenated_parts:
|
|
# If the word is hyphenated, create a Text object for each part
|
|
for part in self._word.hyphenated_parts:
|
|
self._text_parts.append(Text(part, self._word.style))
|
|
else:
|
|
# Otherwise, create a single Text object for the whole word
|
|
self._text_parts.append(Text(self._word.text, self._word.style))
|
|
|
|
# Calculate total size
|
|
self._recalculate_size()
|
|
|
|
def _recalculate_size(self):
|
|
"""Recalculate the size of the word based on its text parts"""
|
|
if not self._text_parts:
|
|
self._size = (0, 0)
|
|
return
|
|
|
|
# For a non-hyphenated word, use the size of the single text part
|
|
if len(self._text_parts) == 1:
|
|
self._size = self._text_parts[0].size
|
|
return
|
|
|
|
# For a hyphenated word that's not yet split across lines,
|
|
# calculate the total width and maximum height
|
|
total_width = sum(part.width for part in self._text_parts)
|
|
max_height = max(part.height for part in self._text_parts)
|
|
self._size = (total_width, max_height)
|
|
|
|
@property
|
|
def word(self) -> Word:
|
|
"""Get the abstract Word object"""
|
|
return self._word
|
|
|
|
@property
|
|
def text_parts(self) -> List[Text]:
|
|
"""Get the list of Text objects that make up this word"""
|
|
return self._text_parts
|
|
|
|
def update_from_word(self):
|
|
"""Update the text parts based on changes to the word"""
|
|
self._initialize_text_parts()
|
|
|
|
def get_part_size(self, index: int) -> Tuple[int, int]:
|
|
"""
|
|
Get the size of a specific text part.
|
|
|
|
Args:
|
|
index: The index of the part to query.
|
|
|
|
Returns:
|
|
A tuple (width, height) of the part.
|
|
|
|
Raises:
|
|
IndexError: If the index is out of range.
|
|
"""
|
|
if index >= len(self._text_parts):
|
|
raise IndexError(f"Part index {index} out of range")
|
|
|
|
return self._text_parts[index].size
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
"""Get the total width of the word"""
|
|
return self._size[0]
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
"""Get the height of the word"""
|
|
return self._size[1]
|
|
|
|
def set_origin(self, x: int, y: int):
|
|
"""Set the origin (top-left corner) of this word"""
|
|
self._origin = np.array([x, y])
|
|
|
|
# Update positions of text parts
|
|
x_offset = 0
|
|
for part in self._text_parts:
|
|
part.set_origin(x + x_offset, y)
|
|
x_offset += part.width
|
|
|
|
def render(self) -> Image.Image:
|
|
"""
|
|
Render the word to an image.
|
|
|
|
Returns:
|
|
A PIL Image containing the rendered word
|
|
"""
|
|
# For a non-hyphenated word or if there's only one part, render just that part
|
|
if len(self._text_parts) == 1:
|
|
return self._text_parts[0].render()
|
|
|
|
# For a hyphenated word, create a canvas and paste all parts
|
|
canvas = Image.new('RGBA', self._size, (0, 0, 0, 0))
|
|
|
|
x_offset = 0
|
|
for part in self._text_parts:
|
|
part_img = part.render()
|
|
canvas.paste(part_img, (x_offset, 0), part_img)
|
|
x_offset += part.width
|
|
|
|
return canvas
|
|
|
|
def in_object(self, point):
|
|
"""Check if a point is within this word"""
|
|
point_array = np.array(point)
|
|
|
|
# First check if the point is within the word's boundaries
|
|
relative_point = point_array - self._origin
|
|
if not (0 <= relative_point[0] < self._size[0] and
|
|
0 <= relative_point[1] < self._size[1]):
|
|
return False
|
|
|
|
# Then check which text part contains the point
|
|
x_offset = 0
|
|
for part in self._text_parts:
|
|
part_width = part.width
|
|
if x_offset <= relative_point[0] < x_offset + part_width:
|
|
# The point is within this part's horizontal bounds
|
|
# Adjust the point to be relative to the part
|
|
part_relative_point = relative_point.copy()
|
|
part_relative_point[0] -= x_offset
|
|
return part.in_object(self._origin + part_relative_point)
|
|
|
|
x_offset += part_width
|
|
|
|
return False
|
|
|
|
|
|
class Line(Box):
|
|
"""
|
|
A line of text consisting of words with consistent spacing.
|
|
"""
|
|
|
|
def __init__(self, spacing: Tuple[int, int], origin, size, font: Optional[Font] = None,
|
|
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
|
|
valign=Alignment.CENTER, previous = None):
|
|
"""
|
|
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
|
|
"""
|
|
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
|
self._renderable_words: List[RenderableWord] = []
|
|
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._previous = previous
|
|
self._next = None
|
|
|
|
@property
|
|
def renderable_words(self) -> List[RenderableWord]:
|
|
"""Get the list of renderable words in this line"""
|
|
return self._renderable_words
|
|
|
|
def set_next(self, line: Line):
|
|
"""Set the next line in sequence"""
|
|
self._next = line
|
|
|
|
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
|
|
"""
|
|
Add a word to this line.
|
|
|
|
Args:
|
|
text: The text content of the word
|
|
font: The font to use for this word, or None to use the line's default font
|
|
|
|
Returns:
|
|
None if the word fits, or the remaining text if it doesn't fit
|
|
"""
|
|
if not font:
|
|
font = self._font
|
|
|
|
# Create an abstract word
|
|
abstract_word = Word(text, font)
|
|
|
|
# Create a renderable word
|
|
renderable_word = RenderableWord(abstract_word)
|
|
|
|
# Check if the word fits in the current line with minimum spacing
|
|
min_spacing, max_spacing = self._spacing
|
|
word_width = renderable_word.width
|
|
|
|
# If this is the first word, no spacing is needed
|
|
spacing_needed = min_spacing if self._renderable_words else 0
|
|
|
|
# Check if word fits in the line
|
|
if self._current_width + spacing_needed + word_width <= self._size[0]:
|
|
self._renderable_words.append(renderable_word)
|
|
self._current_width += spacing_needed + word_width
|
|
return None
|
|
else:
|
|
# Try to hyphenate the word if it doesn't fit
|
|
if abstract_word.hyphenate():
|
|
# Update the renderable word to reflect hyphenation
|
|
renderable_word.update_from_word()
|
|
|
|
# Check if first part with hyphen fits
|
|
first_part_size = renderable_word.get_part_size(0)
|
|
if self._current_width + spacing_needed + first_part_size[0] <= self._size[0]:
|
|
# Create a word with just the first part
|
|
first_part_text = abstract_word.get_hyphenated_part(0)
|
|
first_word = Word(first_part_text, font)
|
|
renderable_first_word = RenderableWord(first_word)
|
|
|
|
self._renderable_words.append(renderable_first_word)
|
|
self._current_width += spacing_needed + first_part_size[0]
|
|
|
|
# Return the remaining parts as a single string
|
|
remaining_parts = [abstract_word.get_hyphenated_part(i)
|
|
for i in range(1, abstract_word.get_hyphenated_part_count())]
|
|
return ''.join(remaining_parts)
|
|
|
|
# If we can't hyphenate or first part doesn't fit, return the entire word
|
|
return text
|
|
|
|
def render(self) -> Image.Image:
|
|
"""
|
|
Render the line with all its words.
|
|
|
|
Returns:
|
|
A PIL Image containing the rendered line
|
|
"""
|
|
# Create an image for the line
|
|
canvas = super().render()
|
|
|
|
# If there are no words, return the empty canvas
|
|
if not self._renderable_words:
|
|
return canvas
|
|
|
|
# Calculate total width of words
|
|
total_word_width = sum(word.width for word in self._renderable_words)
|
|
|
|
# Calculate spacing based on alignment and available space
|
|
available_space = self._size[0] - total_word_width
|
|
num_spaces = len(self._renderable_words) - 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_word_width + spacing * num_spaces)
|
|
else: # CENTER
|
|
x_pos = (self._size[0] - (total_word_width + spacing * num_spaces)) // 2
|
|
|
|
# Vertical alignment - center words vertically in the line
|
|
y_pos = (self._size[1] - max(word.height for word in self._renderable_words)) // 2
|
|
|
|
# Render and paste each word onto the line
|
|
for word in self._renderable_words:
|
|
# Set the word's position
|
|
word.set_origin(x_pos, y_pos)
|
|
|
|
# Render the word
|
|
word_img = word.render()
|
|
|
|
# Paste the word onto the canvas
|
|
canvas.paste(word_img, (x_pos, y_pos), word_img)
|
|
|
|
# Move to the next word position
|
|
x_pos += word.width + spacing
|
|
|
|
return canvas
|