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

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 

13 

14 

15class AlignmentHandler(ABC): 

16 """ 

17 Abstract base class for text alignment handlers. 

18 Each handler implements a specific alignment strategy. 

19 """ 

20 

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. 

27 

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 

33 

34 Returns: 

35 Tuple of (spacing_between_words, starting_x_position) 

36 """ 

37 

38 

39class LeftAlignmentHandler(AlignmentHandler): 

40 """Handler for left-aligned text.""" 

41 

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. 

50 

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. 

56 

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 

63 

64 # Calculate the total length of all text objects 

65 text_length = sum([text.width for text in text_objects]) 

66 

67 # Calculate number of gaps between texts 

68 num_gaps = len(text_objects) - 1 

69 

70 # Calculate minimum space needed (text + minimum gaps) 

71 min_total_width = text_length + (min_spacing * num_gaps) 

72 

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 

77 

78 # Calculate residual space left after accounting for text lengths 

79 residual_space = available_width - text_length 

80 

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 

91 

92 

93class CenterRightAlignmentHandler(AlignmentHandler): 

94 """Handler for center and right-aligned text.""" 

95 

96 def __init__(self, alignment: Alignment): 

97 self._alignment = alignment 

98 

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 

105 

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 

113 

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) 

118 

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 

124 

125 if actual_spacing < min_spacing: 

126 return actual_spacing, max(0, start_position), True 

127 

128 return ideal_space, max(0, start_position), False 

129 

130 

131class JustifyAlignmentHandler(AlignmentHandler): 

132 """Handler for justified text with full justification.""" 

133 

134 def __init__(self): 

135 # Store variable spacing for each gap to distribute remainder pixels 

136 self._gap_spacings: List[int] = [] 

137 

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. 

143 

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 """ 

148 

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) 

152 

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 

156 

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 

162 

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) 

171 

172 return base_spacing, 0, False 

173 

174 

175class Text(Renderable, Queriable): 

176 """ 

177 Concrete implementation for rendering text. 

178 This class handles the visual representation of text fragments. 

179 """ 

180 

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. 

190 

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 

202 

203 # Calculate dimensions 

204 self._calculate_dimensions() 

205 

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 

214 

215 @classmethod 

216 def from_word(cls, word: Word, draw: ImageDraw.Draw): 

217 return cls(word.text, word.style, draw) 

218 

219 @property 

220 def text(self) -> str: 

221 """Get the text content""" 

222 return self._text 

223 

224 @property 

225 def style(self) -> Font: 

226 """Get the text style""" 

227 return self._style 

228 

229 @property 

230 def origin(self) -> np.ndarray: 

231 """Get the origin of the text""" 

232 return self._origin 

233 

234 @property 

235 def line(self) -> Optional[Line]: 

236 """Get the line containing this text""" 

237 return self._line 

238 

239 @line.setter 

240 def line(self, line): 

241 """Set the line containing this text""" 

242 self._line = line 

243 

244 @property 

245 def width(self) -> int: 

246 """Get the width of the text""" 

247 return self._width 

248 

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)) 

256 

257 def set_origin(self, origin: np.generic): 

258 """Set the origin (left baseline ("ls")) of this text element""" 

259 self._origin = origin 

260 

261 def add_line(self, line): 

262 """Add this text to a line""" 

263 self._line = line 

264 

265 def in_object(self, point: np.generic): 

266 """ 

267 Check if a point is in the text object. 

268 

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. 

271 

272 Args: 

273 point: The coordinates to check 

274 

275 Returns: 

276 True if the point is within the text bounds 

277 """ 

278 point_array = np.array(point) 

279 

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) 

283 

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) 

289 

290 def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0): 

291 """ 

292 Apply text decoration (underline or strikethrough). 

293 

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)) 

302 

303 # Determine end x-coordinate 

304 end_x = self._origin[0] + self._width 

305 

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 

312 

313 self._draw.line([(self._origin[0], y_position), (end_x, y_position)], 

314 fill=self._style.colour, width=line_width) 

315 

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)) 

320 

321 # Determine end x-coordinate 

322 end_x = self._origin[0] + self._width 

323 

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 

330 

331 self._draw.line([(self._origin[0], y_position), (end_x, y_position)], 

332 fill=self._style.colour, width=line_width) 

333 

334 def render(self, next_text: Optional['Text'] = None, spacing: int = 0): 

335 """ 

336 Render the text to an image. 

337 

338 Args: 

339 next_text: The next Text object in the line (if any) 

340 spacing: The spacing to the next text object 

341 

342 Returns: 

343 A PIL Image containing the rendered text 

344 """ 

345 

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) 

350 

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") 

360 

361 # Apply any text decorations with knowledge of next text 

362 self._apply_decoration(next_text, spacing) 

363 

364 

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 """ 

370 

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. 

389 

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 

419 

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 

424 

425 # Create the appropriate alignment handler 

426 self._alignment_handler = self._create_alignment_handler(halign) 

427 

428 def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler: 

429 """ 

430 Create the appropriate alignment handler based on the alignment type. 

431 

432 Args: 

433 alignment: The alignment type 

434 

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) 

444 

445 @property 

446 def text_objects(self) -> List[Text]: 

447 """Get the list of Text objects in this line""" 

448 return self._text_objects 

449 

450 def set_next(self, line: Line): 

451 """Set the next line in sequence""" 

452 self._next = line 

453 

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. 

460 

461 Args: 

462 word: The word to add to the line 

463 part: Optional pretext from a previous hyphenated word 

464 

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) 

475 

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]) 

504 

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 

513 

514 # Word doesn't fit, remove it and try hyphenation 

515 _ = self._text_objects.pop() 

516 

517 # Step 1: Try pyphen hyphenation 

518 pyphen_splits = word.possible_hyphenation() 

519 valid_splits = [] 

520 

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] 

526 

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 

532 

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) 

546 

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() 

552 

553 if not overflow: 

554 # This split fits! Add it to valid options 

555 valid_splits.append((first_text, second_text, spacing, position)) 

556 

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 

562 

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 

571 

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 

578 

579 if remaining > 0: 

580 # Create a hyphenated version to measure 

581 test_text = Text(word.text + "-", word.style, self._draw) 

582 

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 

586 

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 

592 

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)) 

596 

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): 

600 

601 # Create the split 

602 first_part_text = word.text[:split_pos] + "-" 

603 second_part_text = word.text[split_pos:] 

604 

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) 

617 

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]) 

622 

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() 

635 

636 # Step 4: Word cannot be hyphenated or split, move to next line 

637 return False, None 

638 

639 def render(self): 

640 """ 

641 Render the line with all its text objects using the alignment handler system. 

642 

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 

652 

653 y_cursor = self._origin[1] + self._baseline 

654 

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])) 

661 

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 

665 

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 

673 

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 

680 

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. 

685 

686 Args: 

687 point: (x, y) coordinates to query 

688 

689 Returns: 

690 QueryResult from the text object at that point, or None 

691 """ 

692 point_array = np.array(point) 

693 

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 

701 

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 ) 

712 

713 # Import here to avoid circular dependency 

714 from .functional import LinkText, ButtonText 

715 

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 ) 

743 

744 result.parent_line = self 

745 return result 

746 

747 return None