Coverage for pyWebLayout/concrete/text.py: 90%
283 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
1from __future__ import annotations
2from pyWebLayout.core.base import Renderable, Queriable
3from pyWebLayout.core.query import QueryResult
4from .box import Box
5from pyWebLayout.style import Alignment, Font, TextDecoration
6from pyWebLayout.abstract import Word
7from pyWebLayout.abstract.inline import LinkedWord
8from pyWebLayout.abstract.functional import Link
9from PIL import ImageDraw
10from typing import Tuple, List, Optional
11import numpy as np
12from abc import ABC, abstractmethod
15class AlignmentHandler(ABC):
16 """
17 Abstract base class for text alignment handlers.
18 Each handler implements a specific alignment strategy.
19 """
21 @abstractmethod
22 def calculate_spacing_and_position(self, text_objects: List['Text'],
23 available_width: int, min_spacing: int,
24 max_spacing: int) -> Tuple[int, int, bool]:
25 """
26 Calculate the spacing between words and starting position for the line.
28 Args:
29 text_objects: List of Text objects in the line
30 available_width: Total width available for the line
31 min_spacing: Minimum spacing between words
32 max_spacing: Maximum spacing between words
34 Returns:
35 Tuple of (spacing_between_words, starting_x_position)
36 """
39class LeftAlignmentHandler(AlignmentHandler):
40 """Handler for left-aligned text."""
42 def calculate_spacing_and_position(self,
43 text_objects: List['Text'],
44 available_width: int,
45 min_spacing: int,
46 max_spacing: int) -> Tuple[int, int, bool]:
47 """
48 Calculate spacing and position for left-aligned text objects.
49 CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
51 Args:
52 text_objects (List[Text]): A list of text objects to be laid out.
53 available_width (int): The total width available for layout.
54 min_spacing (int): Minimum spacing between text objects.
55 max_spacing (int): Maximum spacing between text objects.
57 Returns:
58 Tuple[int, int, bool]: Spacing, start position, and overflow flag.
59 """
60 # Handle single word case
61 if len(text_objects) <= 1:
62 return 0, 0, False
64 # Calculate the total length of all text objects
65 text_length = sum([text.width for text in text_objects])
67 # Calculate number of gaps between texts
68 num_gaps = len(text_objects) - 1
70 # Calculate minimum space needed (text + minimum gaps)
71 min_total_width = text_length + (min_spacing * num_gaps)
73 # Check if we have overflow (CREngine pattern: always use min_spacing for
74 # overflow)
75 if min_total_width > available_width:
76 return min_spacing, 0, True # Overflow - but use safe minimum spacing
78 # Calculate residual space left after accounting for text lengths
79 residual_space = available_width - text_length
81 # Calculate ideal spacing
82 actual_spacing = residual_space // num_gaps
83 # Clamp within bounds (CREngine pattern: respect max_spacing)
84 if actual_spacing > max_spacing:
85 return max_spacing, 0, False
86 elif actual_spacing < min_spacing: 86 ↛ 88line 86 didn't jump to line 88 because the condition on line 86 was never true
87 # Ensure we never return spacing less than min_spacing
88 return min_spacing, 0, False
89 else:
90 return actual_spacing, 0, False # Use calculated spacing
93class CenterRightAlignmentHandler(AlignmentHandler):
94 """Handler for center and right-aligned text."""
96 def __init__(self, alignment: Alignment):
97 self._alignment = alignment
99 def calculate_spacing_and_position(self, text_objects: List['Text'],
100 available_width: int, min_spacing: int,
101 max_spacing: int) -> Tuple[int, int, bool]:
102 """Center/right alignment uses minimum spacing with calculated start position."""
103 word_length = sum([word.width for word in text_objects])
104 residual_space = available_width - word_length
106 # Handle single word case
107 if len(text_objects) <= 1:
108 if self._alignment == Alignment.CENTER:
109 start_position = (available_width - word_length) // 2
110 else: # RIGHT
111 start_position = available_width - word_length
112 return 0, max(0, start_position), False
114 actual_spacing = residual_space // (len(text_objects) - 1)
115 ideal_space = (min_spacing + max_spacing) / 2
116 if actual_spacing > 0.5 * (min_spacing + max_spacing):
117 actual_spacing = 0.5 * (min_spacing + max_spacing)
119 content_length = word_length + (len(text_objects) - 1) * actual_spacing
120 if self._alignment == Alignment.CENTER:
121 start_position = (available_width - content_length) // 2
122 else:
123 start_position = available_width - content_length
125 if actual_spacing < min_spacing:
126 return actual_spacing, max(0, start_position), True
128 return ideal_space, max(0, start_position), False
131class JustifyAlignmentHandler(AlignmentHandler):
132 """Handler for justified text with full justification."""
134 def __init__(self):
135 # Store variable spacing for each gap to distribute remainder pixels
136 self._gap_spacings: List[int] = []
138 def calculate_spacing_and_position(self, text_objects: List['Text'],
139 available_width: int, min_spacing: int,
140 max_spacing: int) -> Tuple[int, int, bool]:
141 """
142 Justified alignment distributes space to fill the entire line width.
144 For justified text, we ALWAYS try to fill the entire width by distributing
145 space between words, regardless of max_spacing constraints. The only limit
146 is min_spacing to ensure readability.
147 """
149 word_length = sum([word.width for word in text_objects])
150 residual_space = available_width - word_length
151 num_gaps = max(1, len(text_objects) - 1)
153 # For justified text, calculate the actual spacing needed to fill the line
154 base_spacing = int(residual_space // num_gaps)
155 remainder = int(residual_space % num_gaps) # The extra pixels to distribute
157 # Check if we have enough space for minimum spacing
158 if base_spacing < min_spacing: 158 ↛ 160line 158 didn't jump to line 160 because the condition on line 158 was never true
159 # Not enough space - this is overflow
160 self._gap_spacings = [min_spacing] * num_gaps
161 return min_spacing, 0, True
163 # Distribute remainder pixels across the first 'remainder' gaps
164 # This ensures the line fills the entire width exactly
165 self._gap_spacings = []
166 for i in range(num_gaps):
167 if i < remainder: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 self._gap_spacings.append(base_spacing + 1)
169 else:
170 self._gap_spacings.append(base_spacing)
172 return base_spacing, 0, False
175class Text(Renderable, Queriable):
176 """
177 Concrete implementation for rendering text.
178 This class handles the visual representation of text fragments.
179 """
181 def __init__(
182 self,
183 text: str,
184 style: Font,
185 draw: ImageDraw.Draw,
186 source: Optional[Word] = None,
187 line: Optional[Line] = None):
188 """
189 Initialize a Text object.
191 Args:
192 text: The text content to render
193 style: The font style to use for rendering
194 """
195 super().__init__()
196 self._text = text
197 self._style = style
198 self._line = line
199 self._source = source
200 self._origin = np.array([0, 0])
201 self._draw = draw
203 # Calculate dimensions
204 self._calculate_dimensions()
206 def _calculate_dimensions(self):
207 """Calculate the width and height of the text based on the font metrics"""
208 # Get the size using PIL's text size functionality
209 font = self._style.font
210 self._width = self._draw.textlength(self._text, font=font)
211 ascent, descent = font.getmetrics()
212 self._ascent = ascent
213 self._middle_y = ascent - descent / 2
215 @classmethod
216 def from_word(cls, word: Word, draw: ImageDraw.Draw):
217 return cls(word.text, word.style, draw)
219 @property
220 def text(self) -> str:
221 """Get the text content"""
222 return self._text
224 @property
225 def style(self) -> Font:
226 """Get the text style"""
227 return self._style
229 @property
230 def origin(self) -> np.ndarray:
231 """Get the origin of the text"""
232 return self._origin
234 @property
235 def line(self) -> Optional[Line]:
236 """Get the line containing this text"""
237 return self._line
239 @line.setter
240 def line(self, line):
241 """Set the line containing this text"""
242 self._line = line
244 @property
245 def width(self) -> int:
246 """Get the width of the text"""
247 return self._width
249 @property
250 def size(self) -> int:
251 """Get the width and height of the text"""
252 # Return actual rendered height (ascent + descent) not just font_size
253 ascent, descent = self._style.font.getmetrics()
254 actual_height = ascent + descent
255 return np.array((self._width, actual_height))
257 def set_origin(self, origin: np.generic):
258 """Set the origin (left baseline ("ls")) of this text element"""
259 self._origin = origin
261 def add_line(self, line):
262 """Add this text to a line"""
263 self._line = line
265 def in_object(self, point: np.generic):
266 """
267 Check if a point is in the text object.
269 Override Queriable.in_object() because Text uses baseline-anchored positioning.
270 The origin is at the baseline (anchor="ls"), not the top-left corner.
272 Args:
273 point: The coordinates to check
275 Returns:
276 True if the point is within the text bounds
277 """
278 point_array = np.array(point)
280 # Text origin is at baseline, so visual top is origin[1] - ascent
281 visual_top = self._origin[1] - self._ascent
282 visual_bottom = self._origin[1] + (self.size[1] - self._ascent)
284 # Check if point is within bounds
285 # X: origin[0] to origin[0] + width
286 # Y: visual_top to visual_bottom
287 return (self._origin[0] <= point_array[0] < self._origin[0] + self.size[0] and
288 visual_top <= point_array[1] < visual_bottom)
290 def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
291 """
292 Apply text decoration (underline or strikethrough).
294 Args:
295 next_text: The next Text object in the line (if any)
296 spacing: The spacing to the next text object
297 """
298 if self._style.decoration == TextDecoration.UNDERLINE:
299 # Draw underline at about 90% of the height
300 y_position = self._origin[1] - 0.1 * self._style.font_size
301 line_width = max(1, int(self._style.font_size / 15))
303 # Determine end x-coordinate
304 end_x = self._origin[0] + self._width
306 # If next text also has underline decoration, extend to connect them
307 if (next_text is not None and
308 next_text.style.decoration == TextDecoration.UNDERLINE and
309 next_text.style.colour == self._style.colour):
310 # Extend the underline through the spacing to connect with next word
311 end_x += spacing
313 self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
314 fill=self._style.colour, width=line_width)
316 elif self._style.decoration == TextDecoration.STRIKETHROUGH: 316 ↛ 318line 316 didn't jump to line 318 because the condition on line 316 was never true
317 # Draw strikethrough at about 50% of the height
318 y_position = self._origin[1] + self._middle_y
319 line_width = max(1, int(self._style.font_size / 15))
321 # Determine end x-coordinate
322 end_x = self._origin[0] + self._width
324 # If next text also has strikethrough decoration, extend to connect them
325 if (next_text is not None and
326 next_text.style.decoration == TextDecoration.STRIKETHROUGH and
327 next_text.style.colour == self._style.colour):
328 # Extend the strikethrough through the spacing to connect with next word
329 end_x += spacing
331 self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
332 fill=self._style.colour, width=line_width)
334 def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
335 """
336 Render the text to an image.
338 Args:
339 next_text: The next Text object in the line (if any)
340 spacing: The spacing to the next text object
342 Returns:
343 A PIL Image containing the rendered text
344 """
346 # Draw the text background if specified
347 if self._style.background and self._style.background[3] > 0: # If alpha > 0 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 self._draw.rectangle([self._origin, self._origin +
349 self._size], fill=self._style.background)
351 # Draw the text using baseline as anchor point ("ls" = left-baseline)
352 # This ensures the origin represents the baseline, not the top-left
353 self._draw.text(
354 (self.origin[0],
355 self._origin[1]),
356 self._text,
357 font=self._style.font,
358 fill=self._style.colour,
359 anchor="ls")
361 # Apply any text decorations with knowledge of next text
362 self._apply_decoration(next_text, spacing)
365class Line(Box):
366 """
367 A line of text consisting of Text objects with consistent spacing.
368 Each Text represents a word or word fragment that can be rendered.
369 """
371 def __init__(self,
372 spacing: Tuple[int,
373 int],
374 origin,
375 size,
376 draw: ImageDraw.Draw,
377 font: Optional[Font] = None,
378 callback=None,
379 sheet=None,
380 mode=None,
381 halign=Alignment.CENTER,
382 valign=Alignment.CENTER,
383 previous=None,
384 min_word_length_for_brute_force: int = 8,
385 min_chars_before_hyphen: int = 2,
386 min_chars_after_hyphen: int = 2):
387 """
388 Initialize a new line.
390 Args:
391 spacing: A tuple of (min_spacing, max_spacing) between words
392 origin: The top-left position of the line
393 size: The width and height of the line
394 font: The default font to use for text in this line
395 callback: Optional callback function
396 sheet: Optional image sheet
397 mode: Optional image mode
398 halign: Horizontal alignment of text within the line
399 valign: Vertical alignment of text within the line
400 previous: Reference to the previous line
401 min_word_length_for_brute_force: Minimum word length to attempt brute force hyphenation (default: 8)
402 min_chars_before_hyphen: Minimum characters before hyphen in any split (default: 2)
403 min_chars_after_hyphen: Minimum characters after hyphen in any split (default: 2)
404 """
405 super().__init__(origin, size, callback, sheet, mode, halign, valign)
406 self._text_objects: List['Text'] = [] # Store Text objects directly
407 self._spacing = spacing # (min_spacing, max_spacing)
408 self._font = font if font else Font() # Use default font if none provided
409 self._current_width = 0 # Track the current width used
410 self._words: List['Word'] = []
411 self._previous = previous
412 self._next = None
413 ascent, descent = self._font.font.getmetrics()
414 # Store baseline as offset from line origin (top), not absolute position
415 self._baseline = ascent
416 self._draw = draw
417 self._spacing_render = (spacing[0] + spacing[1]) // 2
418 self._position_render = 0
420 # Hyphenation configuration parameters
421 self._min_word_length_for_brute_force = min_word_length_for_brute_force
422 self._min_chars_before_hyphen = min_chars_before_hyphen
423 self._min_chars_after_hyphen = min_chars_after_hyphen
425 # Create the appropriate alignment handler
426 self._alignment_handler = self._create_alignment_handler(halign)
428 def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
429 """
430 Create the appropriate alignment handler based on the alignment type.
432 Args:
433 alignment: The alignment type
435 Returns:
436 The appropriate alignment handler instance
437 """
438 if alignment == Alignment.LEFT:
439 return LeftAlignmentHandler()
440 elif alignment == Alignment.JUSTIFY:
441 return JustifyAlignmentHandler()
442 else: # CENTER or RIGHT
443 return CenterRightAlignmentHandler(alignment)
445 @property
446 def text_objects(self) -> List[Text]:
447 """Get the list of Text objects in this line"""
448 return self._text_objects
450 def set_next(self, line: Line):
451 """Set the next line in sequence"""
452 self._next = line
454 def add_word(self,
455 word: 'Word',
456 part: Optional[Text] = None) -> Tuple[bool,
457 Optional['Text']]:
458 """
459 Add a word to this line using intelligent word fitting strategies.
461 Args:
462 word: The word to add to the line
463 part: Optional pretext from a previous hyphenated word
465 Returns:
466 Tuple of (success, overflow_text):
467 - success: True if word/part was added, False if it couldn't fit
468 - overflow_text: Remaining text if word was hyphenated, None otherwise
469 """
470 # First, add any pretext from previous hyphenation
471 if part is not None:
472 self._text_objects.append(part)
473 self._words.append(word)
474 part.add_line(self)
476 # Try to add the full word - create LinkText for LinkedWord, regular Text
477 # otherwise
478 if isinstance(word, LinkedWord):
479 # Import here to avoid circular dependency
480 from .functional import LinkText
481 # Create a LinkText which includes the link functionality
482 # LinkText constructor needs: (link, text, font, draw, source, line)
483 # But LinkedWord itself contains the link properties
484 # We'll create a Link object from the LinkedWord properties
485 link = Link(
486 location=word.location,
487 link_type=word.link_type,
488 callback=word.link_callback,
489 params=word.params,
490 title=word.link_title
491 )
492 text = LinkText(
493 link,
494 word.text,
495 word.style,
496 self._draw,
497 source=word,
498 line=self)
499 else:
500 text = Text.from_word(word, self._draw)
501 self._text_objects.append(text)
502 spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
503 self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
505 if not overflow:
506 # Word fits! Add it completely
507 self._words.append(word)
508 word.add_concete(text)
509 text.add_line(self)
510 self._position_render = position
511 self._spacing_render = spacing
512 return True, None
514 # Word doesn't fit, remove it and try hyphenation
515 _ = self._text_objects.pop()
517 # Step 1: Try pyphen hyphenation
518 pyphen_splits = word.possible_hyphenation()
519 valid_splits = []
521 if pyphen_splits:
522 # Create Text objects for each possible split and check if they fit
523 for pair in pyphen_splits:
524 first_part_text = pair[0] + "-"
525 second_part_text = pair[1]
527 # Validate minimum character requirements
528 if len(pair[0]) < self._min_chars_before_hyphen: 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true
529 continue
530 if len(pair[1]) < self._min_chars_after_hyphen: 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true
531 continue
533 # Create Text objects
534 first_text = Text(
535 first_part_text,
536 word.style,
537 self._draw,
538 line=self,
539 source=word)
540 second_text = Text(
541 second_part_text,
542 word.style,
543 self._draw,
544 line=self,
545 source=word)
547 # Check if first part fits
548 self._text_objects.append(first_text)
549 spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
550 self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
551 _ = self._text_objects.pop()
553 if not overflow:
554 # This split fits! Add it to valid options
555 valid_splits.append((first_text, second_text, spacing, position))
557 # Step 2: If we have valid pyphen splits, choose the best one
558 if valid_splits:
559 # Select the split with the best (minimum) spacing
560 best_split = min(valid_splits, key=lambda x: x[2])
561 first_text, second_text, spacing, position = best_split
563 # Apply the split
564 self._text_objects.append(first_text)
565 first_text.line = self
566 word.add_concete((first_text, second_text))
567 self._spacing_render = spacing
568 self._position_render = position
569 self._words.append(word)
570 return True, second_text
572 # Step 3: Try brute force hyphenation (only for long words)
573 if len(word.text) >= self._min_word_length_for_brute_force:
574 # Calculate available space for the word
575 word_length = sum([text.width for text in self._text_objects])
576 spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
577 remaining = self._size[0] - word_length - spacing_length
579 if remaining > 0:
580 # Create a hyphenated version to measure
581 test_text = Text(word.text + "-", word.style, self._draw)
583 if test_text.width > 0: 583 ↛ 637line 583 didn't jump to line 637 because the condition on line 583 was always true
584 # Calculate what fraction of the hyphenated word fits
585 fraction = remaining / test_text.width
587 # Convert fraction to character position
588 # We need at least min_chars_before_hyphen and leave at least
589 # min_chars_after_hyphen
590 max_split_pos = len(word.text) - self._min_chars_after_hyphen
591 min_split_pos = self._min_chars_before_hyphen
593 # Calculate ideal split position based on available space
594 ideal_split = int(fraction * len(word.text))
595 split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
597 # Ensure we meet minimum requirements
598 if (split_pos >= self._min_chars_before_hyphen and 598 ↛ 637line 598 didn't jump to line 637 because the condition on line 598 was always true
599 len(word.text) - split_pos >= self._min_chars_after_hyphen):
601 # Create the split
602 first_part_text = word.text[:split_pos] + "-"
603 second_part_text = word.text[split_pos:]
605 first_text = Text(
606 first_part_text,
607 word.style,
608 self._draw,
609 line=self,
610 source=word)
611 second_text = Text(
612 second_part_text,
613 word.style,
614 self._draw,
615 line=self,
616 source=word)
618 # Verify the first part actually fits
619 self._text_objects.append(first_text)
620 spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
621 self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
623 if not overflow: 623 ↛ 625line 623 didn't jump to line 625 because the condition on line 623 was never true
624 # Brute force split works!
625 first_text.line = self
626 second_text.line = self
627 word.add_concete((first_text, second_text))
628 self._spacing_render = spacing
629 self._position_render = position
630 self._words.append(word)
631 return True, second_text
632 else:
633 # Doesn't fit, remove it
634 _ = self._text_objects.pop()
636 # Step 4: Word cannot be hyphenated or split, move to next line
637 return False, None
639 def render(self):
640 """
641 Render the line with all its text objects using the alignment handler system.
643 Returns:
644 A PIL Image containing the rendered line
645 """
646 # Recalculate spacing and position for current text objects to ensure accuracy
647 if len(self._text_objects) > 0:
648 spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
649 self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
650 self._spacing_render = spacing
651 self._position_render = position
653 y_cursor = self._origin[1] + self._baseline
655 # Start x_cursor at line origin plus any alignment offset
656 x_cursor = self._origin[0] + self._position_render
657 for i, text in enumerate(self._text_objects):
658 # Update text draw context to current draw context
659 text._draw = self._draw
660 text.set_origin(np.array([x_cursor, y_cursor]))
662 # Determine next text object for continuous decoration
663 next_text = self._text_objects[i + 1] if i + \
664 1 < len(self._text_objects) else None
666 # Get the spacing for this specific gap (variable for justified text)
667 if isinstance(self._alignment_handler, JustifyAlignmentHandler) and \
668 hasattr(self._alignment_handler, '_gap_spacings') and \
669 i < len(self._alignment_handler._gap_spacings):
670 current_spacing = self._alignment_handler._gap_spacings[i]
671 else:
672 current_spacing = self._spacing_render
674 # Render with next text information for continuous underline/strikethrough
675 text.render(next_text, current_spacing)
676 # Add text width, then spacing only if there are more words
677 x_cursor += text.width
678 if i < len(self._text_objects) - 1:
679 x_cursor += current_spacing
681 def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
682 """
683 Find which Text object contains the given point.
684 Uses Queriable.in_object() mixin for hit-testing.
686 Args:
687 point: (x, y) coordinates to query
689 Returns:
690 QueryResult from the text object at that point, or None
691 """
692 point_array = np.array(point)
694 # Check each text object in this line
695 for text_obj in self._text_objects:
696 # Use Queriable mixin's in_object() for hit-testing
697 if isinstance(text_obj, Queriable) and text_obj.in_object(point_array):
698 # Extract metadata based on text type
699 origin = text_obj._origin
700 size = text_obj.size
702 # Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
703 # Bounds should be (x, visual_top, width, height) for proper
704 # highlighting
705 visual_top = int(origin[1] - text_obj._ascent)
706 bounds = (
707 int(origin[0]),
708 visual_top,
709 int(size[0]) if hasattr(size, '__getitem__') else 0,
710 int(size[1]) if hasattr(size, '__getitem__') else 0
711 )
713 # Import here to avoid circular dependency
714 from .functional import LinkText, ButtonText
716 if isinstance(text_obj, LinkText):
717 result = QueryResult(
718 object=text_obj,
719 object_type="link",
720 bounds=bounds,
721 text=text_obj._text,
722 is_interactive=True,
723 link_target=text_obj._link.location if hasattr(
724 text_obj,
725 '_link') else None)
726 elif isinstance(text_obj, ButtonText): 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true
727 result = QueryResult(
728 object=text_obj,
729 object_type="button",
730 bounds=bounds,
731 text=text_obj._text,
732 is_interactive=True,
733 callback=text_obj._callback if hasattr(
734 text_obj,
735 '_callback') else None)
736 else:
737 result = QueryResult(
738 object=text_obj,
739 object_type="text",
740 bounds=bounds,
741 text=text_obj._text if hasattr(text_obj, '_text') else None
742 )
744 result.parent_line = self
745 return result
747 return None