716 lines
28 KiB
Python
716 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)
|
|
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
|
|
|
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
|