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