Coverage for pyWebLayout/concrete/text.py: 77%

462 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 20:34 +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 pyWebLayout.core.cache import UsageCache, SizedUsageCache 

10from PIL import ImageDraw, ImageFont 

11from typing import Tuple, List, Optional, Any, Dict 

12import logging 

13import math 

14import numpy as np 

15from abc import ABC, abstractmethod 

16 

17logger = logging.getLogger(__name__) 

18 

19 

20# --------------------------------------------------------------------------- 

21# Text rendering caches 

22# 

23# A page re-measures and re-rasterises the same words constantly: measured over a 

24# novel at 1404x1872, a page issues ~2800 width measurements and ~2500 glyph 

25# rasterisations for fewer than 1000 distinct (font, string) pairs. Caching both 

26# turns a ~225ms page into a ~30ms page. Both caches are bounded so that a long 

27# reading session cannot grow without limit on a memory-constrained device. 

28# --------------------------------------------------------------------------- 

29 

30# Word widths are small floats; 8192 entries costs well under 1MB and comfortably 

31# spans the working set of several chapters at a couple of font sizes. 

32DEFAULT_WIDTH_CACHE_ENTRIES = 8192 

33 

34# Glyph bitmaps are the expensive ones: ~700 bytes each on average at 1404x1872, 

35# so an unbounded cache reaches ~12MB after 40 pages. 4MB holds several pages' 

36# worth of distinct words while leaving headroom on a 512MB Pi Zero 2. 

37DEFAULT_GLYPH_CACHE_BYTES = 4 * 1024 * 1024 

38 

39# PIL rasterises text at sub-pixel horizontal offsets, so a cache keyed only on 

40# (font, string) would quantise every word to a whole pixel. Bucketing the 

41# sub-pixel phase keeps that error negligible at the cost of more entries. 2 steps 

42# holds the mean error to ~3.6/255 -- a fifth of one step of a 16-level e-ink 

43# panel -- while keeping the cache four times smaller than 4 steps would. 

44DEFAULT_GLYPH_SUBPIXEL_STEPS = 2 

45 

46 

47def _glyph_entry_bytes(entry: Tuple[Any, Tuple[int, int]]) -> int: 

48 """Approximate footprint of a cached (mask, offset) pair, in bytes.""" 

49 mask = entry[0] 

50 try: 

51 width, height = mask.size 

52 except (AttributeError, TypeError, ValueError): 

53 return 0 

54 return width * height 

55 

56 

57_width_cache: UsageCache = UsageCache(DEFAULT_WIDTH_CACHE_ENTRIES) 

58_glyph_cache: SizedUsageCache = SizedUsageCache(DEFAULT_GLYPH_CACHE_BYTES, _glyph_entry_bytes) 

59_glyph_subpixel_steps: int = DEFAULT_GLYPH_SUBPIXEL_STEPS 

60 

61# Every Line asks its font for the advance width of a space. That single 

62# FreeTypeFont.getlength(" ") call costs ~18us -- two orders of magnitude more 

63# than getmetrics() -- because PIL shapes the string from scratch each time, and 

64# it lands once per line created, which dominates the cost of laying a line out. 

65# There are only ever a handful of distinct fonts in play, so memoise per font 

66# object. Values are wrapped in a 1-tuple because None is itself a legitimate 

67# result (fonts that cannot report a length) and must not read as a cache miss. 

68_space_advance_cache: Dict[Any, Tuple[Optional[int]]] = {} 

69 

70# Set to False the first time the fast rasterisation path is found to be 

71# unavailable (e.g. a PIL build without the private ImageDraw internals it uses), 

72# after which every Text falls back to ImageDraw.text(). 

73_glyph_fast_path_available: bool = True 

74 

75 

76def configure_text_caches(width_entries: Optional[int] = None, 

77 glyph_bytes: Optional[int] = None, 

78 subpixel_steps: Optional[int] = None): 

79 """ 

80 Tune the text rendering caches. 

81 

82 Memory-constrained targets should shrink these; a desktop rendering many font 

83 sizes may benefit from raising them. 

84 

85 Args: 

86 width_entries: Maximum cached word-width measurements. 

87 glyph_bytes: Maximum total size of cached glyph bitmaps, in bytes. 

88 subpixel_steps: Sub-pixel phase buckets per axis. 1 disables sub-pixel 

89 positioning entirely (smallest cache, slightly softer text). 

90 """ 

91 global _glyph_subpixel_steps 

92 

93 if width_entries is not None: 

94 _width_cache.resize(width_entries) 

95 if glyph_bytes is not None: 

96 _glyph_cache.resize(glyph_bytes) 

97 if subpixel_steps is not None: 

98 if subpixel_steps <= 0: 

99 raise ValueError(f"subpixel_steps must be positive, got {subpixel_steps}") 

100 if subpixel_steps != _glyph_subpixel_steps: 

101 # Cached entries embed the phase bucket in their key. 

102 _glyph_cache.clear() 

103 _glyph_subpixel_steps = subpixel_steps 

104 

105 

106def clear_text_caches(): 

107 """Drop all cached widths and glyph bitmaps.""" 

108 _width_cache.clear() 

109 _glyph_cache.clear() 

110 _space_advance_cache.clear() 

111 

112 

113def _space_advance(font) -> Optional[int]: 

114 """ 

115 The font's own advance width for a space, in whole pixels. 

116 

117 None when the font cannot report one, which is the signal for callers to fall 

118 back to their configured spacing range. 

119 """ 

120 try: 

121 cached = _space_advance_cache.get(font) 

122 except TypeError: 

123 # Unhashable font object; measure without caching. 

124 cached = None 

125 else: 

126 if cached is not None: 

127 return cached[0] 

128 

129 try: 

130 value = int(round(font.getlength(" "))) 

131 except (AttributeError, TypeError, ValueError): 

132 value = None 

133 

134 try: 

135 _space_advance_cache[font] = (value,) 

136 except TypeError: 

137 pass 

138 return value 

139 

140 

141def text_cache_stats() -> Dict[str, Any]: 

142 """Occupancy and hit rates for both text caches, for tuning and diagnostics.""" 

143 return { 

144 'width': _width_cache.stats(), 

145 'glyph': _glyph_cache.stats(), 

146 'glyph_subpixel_steps': _glyph_subpixel_steps, 

147 'glyph_fast_path': _glyph_fast_path_available, 

148 } 

149 

150 

151def prewarm_text_caches(entries, 

152 draw: Optional[ImageDraw.ImageDraw] = None, 

153 budget_bytes: Optional[int] = None, 

154 max_words: Optional[int] = None) -> Tuple[int, int]: 

155 """ 

156 Preload the caches with a document's most frequent words. 

157 

158 A document states its own access distribution up front: the words it uses most 

159 are the words every page will draw. Rasterising them once at open time moves 

160 that work off the page-turn path, and seeding each entry with its document 

161 frequency puts it in the right place in the eviction order immediately, rather 

162 than after the cache has learned it. 

163 

164 This depends on eviction ranking by use count. Under recency eviction the 

165 preloaded entries would be discarded by the first page of unfamiliar text; under 

166 usage ranking a word occurring 4000 times outranks anything met while scanning 

167 and stays resident. Measured over a 50-page trace, preloading cut misses by 27% 

168 with usage ranking against 12% with recency. 

169 

170 Args: 

171 entries: Iterable of ``(font, text, colour, frequency)``, where `font` is a 

172 PIL font object, `colour` the fill the text will be drawn in, and 

173 `frequency` the number of times the word occurs in the document. 

174 draw: An ImageDraw sharing the page's mode, used to resolve ink and font 

175 mode. A scratch RGBA context is used if omitted. 

176 budget_bytes: Cap on bytes to preload. Defaults to half the glyph budget so 

177 that live rendering keeps room to cache what preloading missed. 

178 max_words: Cap on distinct words to preload, before sub-pixel variants. 

179 

180 Returns: 

181 Tuple of (words preloaded, bytes preloaded). 

182 """ 

183 if not _glyph_fast_path_available: 

184 return 0, 0 

185 

186 if draw is None: 

187 from PIL import Image 

188 draw = ImageDraw.Draw(Image.new('RGBA', (1, 1))) 

189 

190 if budget_bytes is None: 

191 budget_bytes = _glyph_cache.max_bytes // 2 

192 budget_bytes = min(budget_bytes, _glyph_cache.max_bytes) 

193 

194 ranked = sorted(entries, key=lambda e: -e[3]) 

195 if max_words is not None: 

196 ranked = ranked[:max_words] 

197 

198 steps = _glyph_subpixel_steps 

199 mode = draw.fontmode 

200 draw_mode = draw.mode 

201 ink_cache: Dict[Any, Any] = {} 

202 words = 0 

203 used = 0 

204 

205 for font, text, colour, frequency in ranked: 

206 if frequency <= 1 or used >= budget_bytes: 

207 break 

208 if not isinstance(font, ImageFont.FreeTypeFont): 

209 continue 

210 

211 try: 

212 ink = ink_cache.get(colour) 

213 if ink is None: 

214 ink, _ = draw._getink(colour) 

215 if ink is None: 

216 continue 

217 ink_cache[colour] = ink 

218 

219 # Measuring is cheap and every layout pass needs it. 

220 _width_cache.put((font, text, draw_mode), 

221 draw.textlength(text, font=font), count=frequency) 

222 

223 # Words land on arbitrary sub-pixel offsets, so cover every horizontal 

224 # phase. Baselines are whole pixels, so only phase 0 is needed 

225 # vertically. 

226 for x_bucket in range(steps): 

227 entry = font.getmask2(text, mode, anchor="ls", ink=ink, 

228 start=(x_bucket / steps, 0.0)) 

229 _glyph_cache.put((font, text, mode, ink, x_bucket, 0), entry, 

230 count=frequency) 

231 used += _glyph_entry_bytes(entry) 

232 words += 1 

233 

234 except AttributeError: 

235 logger.warning("Glyph cache unavailable for this Pillow build; " 

236 "skipping prewarm.", exc_info=True) 

237 return words, used 

238 except (TypeError, ValueError): 

239 continue 

240 

241 logger.debug("Prewarmed %d words (%.2fMB) into the text caches", 

242 words, used / 1e6) 

243 return words, used 

244 

245 

246class AlignmentHandler(ABC): 

247 """ 

248 Abstract base class for text alignment handlers. 

249 Each handler implements a specific alignment strategy. 

250 """ 

251 

252 @abstractmethod 

253 def calculate_spacing_and_position(self, text_objects: List['Text'], 

254 available_width: int, min_spacing: int, 

255 max_spacing: int, 

256 natural_spacing: Optional[int] = None, 

257 total_width: Optional[float] = None 

258 ) -> Tuple[int, int, bool]: 

259 """ 

260 Calculate the spacing between words and starting position for the line. 

261 

262 Args: 

263 text_objects: List of Text objects in the line 

264 available_width: Total width available for the line 

265 min_spacing: Minimum spacing between words 

266 max_spacing: Maximum spacing between words 

267 natural_spacing: The font's own space width. Ragged alignments use it 

268 as a constant gap; justification ignores it. Defaults to 

269 min_spacing when not supplied. 

270 total_width: The summed width of `text_objects`, when the caller 

271 already knows it. Purely an optimisation: a line asks its handler 

272 to re-measure once per candidate word, and summing the whole line 

273 each time makes filling a line quadratic in its word count. Omit 

274 it and the sum is taken here as before. 

275 

276 Returns: 

277 Tuple of (spacing_between_words, starting_x_position, overflow) 

278 """ 

279 

280 

281class LeftAlignmentHandler(AlignmentHandler): 

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

283 

284 def calculate_spacing_and_position(self, 

285 text_objects: List['Text'], 

286 available_width: int, 

287 min_spacing: int, 

288 max_spacing: int, 

289 natural_spacing: Optional[int] = None, 

290 total_width: Optional[float] = None 

291 ) -> Tuple[int, int, bool]: 

292 """ 

293 Calculate spacing and position for left-aligned text objects. 

294 

295 Left-aligned text uses a constant word space and leaves whatever is left 

296 over as a ragged right edge. It must not spread the residual space across 

297 the gaps: that stretches each line by a different amount, which reads as 

298 badly-set justified text rather than as ragged-right. 

299 

300 Args: 

301 text_objects (List[Text]): A list of text objects to be laid out. 

302 available_width (int): The total width available for layout. 

303 min_spacing (int): Minimum spacing between text objects. 

304 max_spacing (int): Maximum spacing between text objects. 

305 natural_spacing (Optional[int]): The font's own space width. 

306 

307 Returns: 

308 Tuple[int, int, bool]: Spacing, start position, and overflow flag. 

309 """ 

310 # Handle single word case 

311 if len(text_objects) <= 1: 

312 return 0, 0, False 

313 

314 spacing = min_spacing if natural_spacing is None else natural_spacing 

315 spacing = max(min_spacing, min(max_spacing, int(spacing))) 

316 

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

318 if total_width is None else total_width) 

319 num_gaps = len(text_objects) - 1 

320 

321 # The spacing is constant whether or not the content fits: tightening a 

322 # full line here would make it differ from its neighbours, which is the 

323 # variation this alignment is supposed to avoid. Report the overflow and 

324 # let line breaking move the offending word instead. 

325 overflow = text_length + (spacing * num_gaps) > available_width 

326 

327 return spacing, 0, overflow 

328 

329 

330class CenterRightAlignmentHandler(AlignmentHandler): 

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

332 

333 def __init__(self, alignment: Alignment): 

334 self._alignment = alignment 

335 

336 def calculate_spacing_and_position(self, text_objects: List['Text'], 

337 available_width: int, min_spacing: int, 

338 max_spacing: int, 

339 natural_spacing: Optional[int] = None, 

340 total_width: Optional[float] = None 

341 ) -> Tuple[int, int, bool]: 

342 """ 

343 Centre/right alignment: constant word space, line shifted as a block. 

344 

345 Like left alignment, the residual space must not be spread across the 

346 gaps - it belongs in the margin. The start position is then derived from 

347 the same spacing that will actually be used, so the line lands where it 

348 was measured to land. 

349 """ 

350 word_length = (sum([word.width for word in text_objects]) 

351 if total_width is None else total_width) 

352 

353 # Handle single word case 

354 if len(text_objects) <= 1: 

355 if self._alignment == Alignment.CENTER: 

356 start_position = (available_width - word_length) // 2 

357 else: # RIGHT 

358 start_position = available_width - word_length 

359 return 0, max(0, int(start_position)), False 

360 

361 spacing = min_spacing if natural_spacing is None else natural_spacing 

362 spacing = max(min_spacing, min(max_spacing, int(spacing))) 

363 

364 num_gaps = len(text_objects) - 1 

365 overflow = word_length + (spacing * num_gaps) > available_width 

366 

367 content_length = word_length + num_gaps * spacing 

368 if self._alignment == Alignment.CENTER: 

369 start_position = (available_width - content_length) // 2 

370 else: 

371 start_position = available_width - content_length 

372 

373 return spacing, max(0, int(start_position)), overflow 

374 

375 

376class JustifyAlignmentHandler(AlignmentHandler): 

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

378 

379 def __init__(self): 

380 # The per-gap spacings are described by a plan rather than stored outright, 

381 # and materialised on demand by the _gap_spacings property below. Fitting a 

382 # line calls this handler once per candidate word and only ever looks at the 

383 # first gap; building the whole list on each of those probes made adding n 

384 # words to a line O(n^2). Only render() reads the full list. 

385 self._gap_uniform: Optional[int] = None 

386 self._gap_residual: int = 0 

387 self._gap_count: int = 0 

388 self._gap_cache: Optional[List[int]] = [] 

389 

390 @property 

391 def _gap_spacings(self) -> List[int]: 

392 """The spacing to apply at each gap, left to right.""" 

393 if self._gap_cache is None: 

394 if self._gap_uniform is not None: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 self._gap_cache = [self._gap_uniform] * self._gap_count 

396 else: 

397 self._gap_cache = self._distribute(self._gap_residual, self._gap_count) 

398 return self._gap_cache 

399 

400 @staticmethod 

401 def _distribute(total: int, num_gaps: int) -> List[int]: 

402 """Split `total` pixels across `num_gaps` gaps by cumulative rounding.""" 

403 gaps = [] 

404 placed = 0 

405 for i in range(1, num_gaps + 1): 

406 cumulative = int(round(total * i / num_gaps)) 

407 gaps.append(cumulative - placed) 

408 placed = cumulative 

409 return gaps 

410 

411 def calculate_spacing_and_position(self, text_objects: List['Text'], 

412 available_width: int, min_spacing: int, 

413 max_spacing: int, 

414 natural_spacing: Optional[int] = None, 

415 total_width: Optional[float] = None 

416 ) -> Tuple[int, int, bool]: 

417 """ 

418 Justified alignment distributes space to fill the entire line width. 

419 

420 natural_spacing is ignored: filling the measure is the whole point. 

421 

422 For justified text, we ALWAYS try to fill the entire width by distributing 

423 space between words, regardless of max_spacing constraints. The only limit 

424 is min_spacing to ensure readability. 

425 """ 

426 

427 word_length = (sum([word.width for word in text_objects]) 

428 if total_width is None else total_width) 

429 residual_space = available_width - word_length 

430 num_gaps = max(1, len(text_objects) - 1) 

431 

432 # Check if we have enough space for minimum spacing 

433 if residual_space // num_gaps < min_spacing: 

434 # Not enough space - this is overflow 

435 self._gap_uniform = min_spacing 

436 self._gap_count = num_gaps 

437 self._gap_cache = None 

438 return min_spacing, 0, True 

439 

440 # Distribute the residual by cumulative rounding rather than by taking a 

441 # floor per gap and scattering the remainder. Word widths are fractional, 

442 # so flooring each gap loses part of a pixel and truncating the remainder 

443 # loses up to another - the line then stops one or two pixels short of the 

444 # margin, and by a different amount on each line, which is visible as a 

445 # ragged right edge on otherwise justified text. Rounding the running 

446 # total makes the gaps sum to the residual exactly. 

447 total = int(round(residual_space)) 

448 self._gap_uniform = None 

449 self._gap_residual = total 

450 self._gap_count = num_gaps 

451 self._gap_cache = None 

452 

453 # The first gap is the whole of the plan that fitting needs, and it falls 

454 # out of the same cumulative rounding as _distribute would give it. 

455 return int(round(total / num_gaps)), 0, False 

456 

457 

458class Text(Renderable, Queriable): 

459 """ 

460 Concrete implementation for rendering text. 

461 This class handles the visual representation of text fragments. 

462 """ 

463 

464 def __init__( 

465 self, 

466 text: str, 

467 style: Font, 

468 draw: ImageDraw.Draw, 

469 source: Optional[Word] = None, 

470 line: Optional[Line] = None): 

471 """ 

472 Initialize a Text object. 

473 

474 Args: 

475 text: The text content to render 

476 style: The font style to use for rendering 

477 """ 

478 super().__init__() 

479 self._text = text 

480 self._style = style 

481 self._line = line 

482 self._source = source 

483 self._origin = np.array([0, 0]) 

484 self._draw = draw 

485 

486 # Calculate dimensions 

487 self._calculate_dimensions() 

488 

489 def _calculate_dimensions(self): 

490 """Calculate the width and height of the text based on the font metrics""" 

491 # Measuring a word costs a FreeType shaping pass, and the same words recur 

492 # constantly within a document, so results are cached per (font, string). 

493 # The draw's image mode is part of the key because PIL derives advance 

494 # widths differently for bilevel ("1") targets. 

495 font = self._style.font 

496 key = (font, self._text, self._draw.mode) 

497 

498 width = _width_cache.get(key) 

499 if width is None: 

500 width = self._draw.textlength(self._text, font=font) 

501 _width_cache.put(key, width) 

502 self._width = width 

503 

504 ascent, descent = font.getmetrics() 

505 self._ascent = ascent 

506 self._middle_y = ascent - descent / 2 

507 

508 @classmethod 

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

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

511 

512 @property 

513 def text(self) -> str: 

514 """Get the text content""" 

515 return self._text 

516 

517 @property 

518 def style(self) -> Font: 

519 """Get the text style""" 

520 return self._style 

521 

522 @property 

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

524 """Get the origin of the text""" 

525 return self._origin 

526 

527 @property 

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

529 """Get the line containing this text""" 

530 return self._line 

531 

532 @line.setter 

533 def line(self, line): 

534 """Set the line containing this text""" 

535 self._line = line 

536 

537 @property 

538 def width(self) -> int: 

539 """Get the width of the text""" 

540 return self._width 

541 

542 @property 

543 def size(self) -> int: 

544 """Get the width and height of the text""" 

545 # Return actual rendered height (ascent + descent) not just font_size 

546 ascent, descent = self._style.font.getmetrics() 

547 actual_height = ascent + descent 

548 return np.array((self._width, actual_height)) 

549 

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

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

552 self._origin = origin 

553 

554 def add_line(self, line): 

555 """Add this text to a line""" 

556 self._line = line 

557 

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

559 """ 

560 Check if a point is in the text object. 

561 

562 Override Queriable.in_object() because Text uses baseline-anchored positioning. 

563 The origin is at the baseline (anchor="ls"), not the top-left corner. 

564 

565 Args: 

566 point: The coordinates to check 

567 

568 Returns: 

569 True if the point is within the text bounds 

570 """ 

571 point_array = np.array(point) 

572 

573 # Text origin is at baseline, so visual top is origin[1] - ascent 

574 visual_top = self._origin[1] - self._ascent 

575 visual_bottom = self._origin[1] + (self.size[1] - self._ascent) 

576 

577 # Check if point is within bounds 

578 # X: origin[0] to origin[0] + width 

579 # Y: visual_top to visual_bottom 

580 return (self._origin[0] <= point_array[0] < self._origin[0] + self.size[0] and 

581 visual_top <= point_array[1] < visual_bottom) 

582 

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

584 """ 

585 Apply text decoration (underline or strikethrough). 

586 

587 Args: 

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

589 spacing: The spacing to the next text object 

590 """ 

591 if self._style.decoration == TextDecoration.UNDERLINE: 591 ↛ 609line 591 didn't jump to line 609 because the condition on line 591 was always true

592 # Draw underline at about 90% of the height 

593 y_position = self._origin[1] - 0.1 * self._style.font_size 

594 line_width = max(1, int(self._style.font_size / 15)) 

595 

596 # Determine end x-coordinate 

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

598 

599 # If next text also has underline decoration, extend to connect them 

600 if (next_text is not None and 

601 next_text.style.decoration == TextDecoration.UNDERLINE and 

602 next_text.style.colour == self._style.colour): 

603 # Extend the underline through the spacing to connect with next word 

604 end_x += spacing 

605 

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

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

608 

609 elif self._style.decoration == TextDecoration.STRIKETHROUGH: 

610 # Draw strikethrough at about 50% of the height 

611 y_position = self._origin[1] + self._middle_y 

612 line_width = max(1, int(self._style.font_size / 15)) 

613 

614 # Determine end x-coordinate 

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

616 

617 # If next text also has strikethrough decoration, extend to connect them 

618 if (next_text is not None and 

619 next_text.style.decoration == TextDecoration.STRIKETHROUGH and 

620 next_text.style.colour == self._style.colour): 

621 # Extend the strikethrough through the spacing to connect with next word 

622 end_x += spacing 

623 

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

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

626 

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

628 """ 

629 Render the text to an image. 

630 

631 Args: 

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

633 spacing: The spacing to the next text object 

634 

635 Returns: 

636 A PIL Image containing the rendered text 

637 """ 

638 

639 style = self._style 

640 

641 # Draw the text background if specified 

642 if style.background and style.background[3] > 0: # If alpha > 0 642 ↛ 643line 642 didn't jump to line 643 because the condition on line 642 was never true

643 self._draw.rectangle([tuple(self._origin), tuple(self._origin + self.size)], 

644 fill=style.background) 

645 

646 # Draw the text using baseline as anchor point ("ls" = left-baseline) 

647 # This ensures the origin represents the baseline, not the top-left 

648 if not self._render_from_glyph_cache(style): 648 ↛ 649line 648 didn't jump to line 649 because the condition on line 648 was never true

649 self._draw.text( 

650 (self.origin[0], 

651 self._origin[1]), 

652 self._text, 

653 font=style.font, 

654 fill=style.colour, 

655 anchor="ls") 

656 

657 # Apply any text decorations with knowledge of next text 

658 if style.decoration != TextDecoration.NONE: 

659 self._apply_decoration(next_text, spacing) 

660 

661 def _render_from_glyph_cache(self, style) -> bool: 

662 """ 

663 Blit this word from the cached glyph bitmap. 

664 

665 Rasterising a word is the single most expensive step in drawing a page, and 

666 the same words recur constantly, so the bitmap PIL would produce is cached 

667 and blitted directly. This reproduces what ImageDraw.text() does internally 

668 (getmask2 followed by draw_bitmap) minus the per-call setup. 

669 

670 Returns: 

671 True if the word was drawn. False means the caller must fall back to 

672 ImageDraw.text(). 

673 """ 

674 global _glyph_fast_path_available 

675 

676 if not _glyph_fast_path_available: 676 ↛ 677line 676 didn't jump to line 677 because the condition on line 676 was never true

677 return False 

678 

679 draw = self._draw 

680 font = style.font 

681 

682 # Bitmap and other non-FreeType fonts do not expose getmask2's anchor and 

683 # sub-pixel arguments; let PIL handle them. 

684 if not isinstance(font, ImageFont.FreeTypeFont): 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true

685 return False 

686 

687 try: 

688 ink, _ = draw._getink(style.colour) 

689 if ink is None: 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true

690 return False 

691 

692 # floor() rather than modf() so the fraction is always in [0, 1), 

693 # keeping bucket indices non-negative for negative coordinates. 

694 x = float(self._origin[0]) 

695 y = float(self._origin[1]) 

696 x_whole = math.floor(x) 

697 y_whole = math.floor(y) 

698 

699 steps = _glyph_subpixel_steps 

700 x_bucket = int((x - x_whole) * steps) 

701 y_bucket = int((y - y_whole) * steps) 

702 

703 mode = draw.fontmode 

704 key = (font, self._text, mode, ink, x_bucket, y_bucket) 

705 

706 entry = _glyph_cache.get(key) 

707 if entry is None: 

708 entry = font.getmask2( 

709 self._text, mode, anchor="ls", ink=ink, 

710 start=(x_bucket / steps, y_bucket / steps)) 

711 _glyph_cache.put(key, entry) 

712 

713 mask, offset = entry 

714 draw.draw.draw_bitmap((x_whole + offset[0], y_whole + offset[1]), mask, ink) 

715 return True 

716 

717 except AttributeError: 

718 # A PIL build without the internals this path relies on. Stop trying. 

719 logger.warning( 

720 "Glyph cache unavailable for this Pillow build; falling back to " 

721 "ImageDraw.text() for all text rendering.", exc_info=True) 

722 _glyph_fast_path_available = False 

723 return False 

724 except (TypeError, ValueError): 

725 # This particular colour/mode combination is not supported by the fast 

726 # path (e.g. an ink PIL cannot resolve). Others may still be. 

727 return False 

728 

729 

730class Line(Box): 

731 """ 

732 A line of text consisting of Text objects with consistent spacing. 

733 Each Text represents a word or word fragment that can be rendered. 

734 """ 

735 

736 def __init__(self, 

737 spacing: Tuple[int, 

738 int], 

739 origin, 

740 size, 

741 draw: ImageDraw.Draw, 

742 font: Optional[Font] = None, 

743 callback=None, 

744 sheet=None, 

745 mode=None, 

746 halign=Alignment.CENTER, 

747 valign=Alignment.CENTER, 

748 previous=None, 

749 min_word_length_for_brute_force: int = 8, 

750 min_chars_before_hyphen: int = 2, 

751 min_chars_after_hyphen: int = 2): 

752 """ 

753 Initialize a new line. 

754 

755 Args: 

756 spacing: A tuple of (min_spacing, max_spacing) between words 

757 origin: The top-left position of the line 

758 size: The width and height of the line 

759 font: The default font to use for text in this line 

760 callback: Optional callback function 

761 sheet: Optional image sheet 

762 mode: Optional image mode 

763 halign: Horizontal alignment of text within the line 

764 valign: Vertical alignment of text within the line 

765 previous: Reference to the previous line 

766 min_word_length_for_brute_force: Minimum word length to attempt brute force hyphenation (default: 8) 

767 min_chars_before_hyphen: Minimum characters before hyphen in any split (default: 2) 

768 min_chars_after_hyphen: Minimum characters after hyphen in any split (default: 2) 

769 """ 

770 super().__init__(origin, size, callback, sheet, mode, halign, valign) 

771 self._text_objects: List['Text'] = [] # Store Text objects directly 

772 # Prefix sums of the widths in _text_objects, kept in step by _push_text / 

773 # _pop_text. Element 0 is the empty sum. See _push_text for the rationale. 

774 self._width_prefix: List[float] = [0.0] 

775 self._spacing = spacing # (min_spacing, max_spacing) 

776 self._font = font if font else Font() # Use default font if none provided 

777 self._current_width = 0 # Track the current width used 

778 self._words: List['Word'] = [] 

779 self._previous = previous 

780 self._next = None 

781 ascent, descent = self._font.font.getmetrics() 

782 # Store baseline as offset from line origin (top), not absolute position 

783 self._baseline = ascent 

784 self._draw = draw 

785 self._spacing_render = (spacing[0] + spacing[1]) // 2 

786 self._position_render = 0 

787 

788 # The font's own space advance. Ragged alignments use this as their 

789 # constant word gap rather than stretching to fill the measure. 

790 self._natural_spacing = _space_advance(self._font.font) 

791 

792 # Hyphenation configuration parameters 

793 self._min_word_length_for_brute_force = min_word_length_for_brute_force 

794 self._min_chars_before_hyphen = min_chars_before_hyphen 

795 self._min_chars_after_hyphen = min_chars_after_hyphen 

796 

797 # Create the appropriate alignment handler 

798 self._alignment_handler = self._create_alignment_handler(halign) 

799 

800 # Set on the final line of a paragraph. Justification stretches a line to 

801 # fill the column, which is wrong for the last line - a three-word tail 

802 # would be spread across the full measure. The last line takes its 

803 # natural width instead, as in every other typesetting system. 

804 self._is_paragraph_end = False 

805 

806 @property 

807 def is_paragraph_end(self) -> bool: 

808 """Whether this is the final line of its paragraph""" 

809 return self._is_paragraph_end 

810 

811 @is_paragraph_end.setter 

812 def is_paragraph_end(self, value: bool): 

813 self._is_paragraph_end = value 

814 

815 @property 

816 def render_alignment_handler(self) -> AlignmentHandler: 

817 """ 

818 The handler used to position text when rendering. 

819 

820 This differs from the fitting handler only for the last line of a 

821 justified paragraph, which is rendered flush left. 

822 """ 

823 if self._is_paragraph_end and isinstance( 

824 self._alignment_handler, JustifyAlignmentHandler): 

825 return LeftAlignmentHandler() 

826 return self._alignment_handler 

827 

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

829 """ 

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

831 

832 Args: 

833 alignment: The alignment type 

834 

835 Returns: 

836 The appropriate alignment handler instance 

837 """ 

838 if alignment == Alignment.LEFT: 

839 return LeftAlignmentHandler() 

840 elif alignment == Alignment.JUSTIFY: 

841 return JustifyAlignmentHandler() 

842 else: # CENTER or RIGHT 

843 return CenterRightAlignmentHandler(alignment) 

844 

845 @property 

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

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

848 return self._text_objects 

849 

850 def set_next(self, line: Line): 

851 """Set the next line in sequence""" 

852 self._next = line 

853 

854 @property 

855 def _content_width(self) -> float: 

856 """Summed width of the line's current contents.""" 

857 return self._width_prefix[-1] 

858 

859 def _push_text(self, text: 'Text'): 

860 """ 

861 Append a Text to the line, keeping the running width sum in step. 

862 

863 Fitting a word is a trial: the candidate is pushed, measured, and popped 

864 again if it did not fit, so the line's contents churn far more often than 

865 they grow. Tracking the sum here rather than re-adding every width on each 

866 measurement is what keeps filling a line linear in its word count. 

867 

868 The sum is kept as a prefix list rather than as one accumulator that is 

869 added to and subtracted from. Widths are floats, so `(total + w) - w` need 

870 not give back `total` exactly, and a drift of one ulp is enough to flip an 

871 overflow decision on a line that ends flush. Truncating a prefix list 

872 restores the earlier total bit for bit, and each entry is built by the same 

873 left-to-right addition sum() would perform. 

874 """ 

875 self._text_objects.append(text) 

876 self._width_prefix.append(self._width_prefix[-1] + text.width) 

877 

878 def _pop_text(self) -> 'Text': 

879 """Remove the last Text from the line, keeping the width sum in step.""" 

880 text = self._text_objects.pop() 

881 self._width_prefix.pop() 

882 return text 

883 

884 def _measure(self, handler: Optional[AlignmentHandler] = None 

885 ) -> Tuple[int, int, bool]: 

886 """Ask an alignment handler to place the line's current contents.""" 

887 if handler is None: 

888 handler = self._alignment_handler 

889 return handler.calculate_spacing_and_position( 

890 self._text_objects, self._size[0], self._spacing[0], self._spacing[1], 

891 self._natural_spacing, self._content_width) 

892 

893 def add_word(self, 

894 word: 'Word', 

895 part: Optional[Text] = None) -> Tuple[bool, 

896 Optional['Text']]: 

897 """ 

898 Add a word to this line using intelligent word fitting strategies. 

899 

900 Args: 

901 word: The word to add to the line 

902 part: Optional pretext from a previous hyphenated word 

903 

904 Returns: 

905 Tuple of (success, overflow_text): 

906 - success: True if word/part was added, False if it couldn't fit 

907 - overflow_text: Remaining text if word was hyphenated, None otherwise 

908 """ 

909 # First, add any pretext from previous hyphenation 

910 if part is not None: 

911 self._push_text(part) 

912 self._words.append(word) 

913 part.add_line(self) 

914 

915 # Try to add the full word - create LinkText for LinkedWord, regular Text 

916 # otherwise 

917 if isinstance(word, LinkedWord): 

918 # Import here to avoid circular dependency 

919 from .functional import LinkText 

920 # Create a LinkText which includes the link functionality 

921 # LinkText constructor needs: (link, text, font, draw, source, line) 

922 # But LinkedWord itself contains the link properties 

923 # We'll create a Link object from the LinkedWord properties 

924 link = Link( 

925 location=word.location, 

926 link_type=word.link_type, 

927 callback=word.link_callback, 

928 params=word.params, 

929 title=word.link_title 

930 ) 

931 text = LinkText( 

932 link, 

933 word.text, 

934 word.style, 

935 self._draw, 

936 source=word, 

937 line=self) 

938 else: 

939 text = Text.from_word(word, self._draw) 

940 self._push_text(text) 

941 spacing, position, overflow = self._measure() 

942 

943 if not overflow: 

944 # Word fits! Add it completely 

945 self._words.append(word) 

946 word.add_concete(text) 

947 text.add_line(self) 

948 self._position_render = position 

949 self._spacing_render = spacing 

950 return True, None 

951 

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

953 self._pop_text() 

954 

955 # Step 1: Try pyphen hyphenation 

956 pyphen_splits = word.possible_hyphenation() 

957 valid_splits = [] 

958 

959 if pyphen_splits: 

960 # Create Text objects for each possible split and check if they fit 

961 for pair in pyphen_splits: 

962 first_part_text = pair[0] + "-" 

963 second_part_text = pair[1] 

964 

965 # Validate minimum character requirements 

966 if len(pair[0]) < self._min_chars_before_hyphen: 966 ↛ 967line 966 didn't jump to line 967 because the condition on line 966 was never true

967 continue 

968 if len(pair[1]) < self._min_chars_after_hyphen: 968 ↛ 969line 968 didn't jump to line 969 because the condition on line 968 was never true

969 continue 

970 

971 # Create Text objects 

972 first_text = Text( 

973 first_part_text, 

974 word.style, 

975 self._draw, 

976 line=self, 

977 source=word) 

978 second_text = Text( 

979 second_part_text, 

980 word.style, 

981 self._draw, 

982 line=self, 

983 source=word) 

984 

985 # Check if first part fits 

986 self._push_text(first_text) 

987 spacing, position, overflow = self._measure() 

988 self._pop_text() 

989 

990 if not overflow: 

991 # This split fits! Add it to valid options 

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

993 

994 # Step 2: If we have valid pyphen splits, choose the best one 

995 if valid_splits: 

996 # Select the split with the best (minimum) spacing 

997 best_split = min(valid_splits, key=lambda x: x[2]) 

998 first_text, second_text, spacing, position = best_split 

999 

1000 # Apply the split 

1001 self._push_text(first_text) 

1002 first_text.line = self 

1003 word.add_concete((first_text, second_text)) 

1004 self._spacing_render = spacing 

1005 self._position_render = position 

1006 self._words.append(word) 

1007 return True, second_text 

1008 

1009 # Step 3: Try brute force hyphenation (only for long words) 

1010 if len(word.text) >= self._min_word_length_for_brute_force: 

1011 # Calculate available space for the word 

1012 word_length = self._content_width 

1013 spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1) 

1014 remaining = self._size[0] - word_length - spacing_length 

1015 

1016 if remaining > 0: 1016 ↛ 1073line 1016 didn't jump to line 1073 because the condition on line 1016 was always true

1017 # Create a hyphenated version to measure 

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

1019 

1020 if test_text.width > 0: 1020 ↛ 1073line 1020 didn't jump to line 1073 because the condition on line 1020 was always true

1021 # Calculate what fraction of the hyphenated word fits 

1022 fraction = remaining / test_text.width 

1023 

1024 # Convert fraction to character position 

1025 # We need at least min_chars_before_hyphen and leave at least 

1026 # min_chars_after_hyphen 

1027 max_split_pos = len(word.text) - self._min_chars_after_hyphen 

1028 min_split_pos = self._min_chars_before_hyphen 

1029 

1030 # Calculate ideal split position based on available space 

1031 ideal_split = int(fraction * len(word.text)) 

1032 split_pos = max(min_split_pos, min(ideal_split, max_split_pos)) 

1033 

1034 # Ensure we meet minimum requirements 

1035 if (split_pos >= self._min_chars_before_hyphen and 1035 ↛ 1073line 1035 didn't jump to line 1073 because the condition on line 1035 was always true

1036 len(word.text) - split_pos >= self._min_chars_after_hyphen): 

1037 

1038 # Create the split 

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

1040 second_part_text = word.text[split_pos:] 

1041 

1042 first_text = Text( 

1043 first_part_text, 

1044 word.style, 

1045 self._draw, 

1046 line=self, 

1047 source=word) 

1048 second_text = Text( 

1049 second_part_text, 

1050 word.style, 

1051 self._draw, 

1052 line=self, 

1053 source=word) 

1054 

1055 # Verify the first part actually fits 

1056 self._push_text(first_text) 

1057 spacing, position, overflow = self._measure() 

1058 

1059 if not overflow: 

1060 # Brute force split works! 

1061 first_text.line = self 

1062 second_text.line = self 

1063 word.add_concete((first_text, second_text)) 

1064 self._spacing_render = spacing 

1065 self._position_render = position 

1066 self._words.append(word) 

1067 return True, second_text 

1068 else: 

1069 # Doesn't fit, remove it 

1070 self._pop_text() 

1071 

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

1073 return False, None 

1074 

1075 def render(self): 

1076 """ 

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

1078 

1079 Returns: 

1080 A PIL Image containing the rendered line 

1081 """ 

1082 # Recalculate spacing and position for current text objects to ensure 

1083 # accuracy. Word fitting used the paragraph's alignment; rendering uses 

1084 # render_alignment_handler, which differs only for the last line of a 

1085 # justified paragraph. 

1086 handler = self.render_alignment_handler 

1087 if len(self._text_objects) > 0: 

1088 spacing, position, overflow = self._measure(handler) 

1089 self._spacing_render = spacing 

1090 self._position_render = position 

1091 

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

1093 

1094 # Start x_cursor at line origin plus any alignment offset 

1095 x_cursor = self._origin[0] + self._position_render 

1096 

1097 # Everything the loop needs that does not vary per word is resolved once. 

1098 # Only justified lines carry per-gap spacings; every other alignment uses 

1099 # the single spacing figured above. 

1100 texts = self._text_objects 

1101 last = len(texts) - 1 

1102 draw = self._draw 

1103 default_spacing = self._spacing_render 

1104 gaps = handler._gap_spacings if isinstance(handler, JustifyAlignmentHandler) else () 

1105 gap_count = len(gaps) 

1106 

1107 for i, text in enumerate(texts): 

1108 # Update text draw context to current draw context 

1109 text._draw = draw 

1110 text.set_origin(np.array([x_cursor, y_cursor])) 

1111 

1112 # Determine next text object for continuous decoration 

1113 next_text = texts[i + 1] if i < last else None 

1114 

1115 # Get the spacing for this specific gap (variable for justified text) 

1116 current_spacing = gaps[i] if i < gap_count else default_spacing 

1117 

1118 # Render with next text information for continuous underline/strikethrough 

1119 text.render(next_text, current_spacing) 

1120 # Add text width, then spacing only if there are more words 

1121 x_cursor += text.width 

1122 if i < last: 

1123 x_cursor += current_spacing 

1124 

1125 def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']: 

1126 """ 

1127 Find which Text object contains the given point. 

1128 Uses Queriable.in_object() mixin for hit-testing. 

1129 

1130 Args: 

1131 point: (x, y) coordinates to query 

1132 

1133 Returns: 

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

1135 """ 

1136 point_array = np.array(point) 

1137 

1138 # Check each text object in this line 

1139 for text_obj in self._text_objects: 

1140 # Use Queriable mixin's in_object() for hit-testing 

1141 if isinstance(text_obj, Queriable) and text_obj.in_object(point_array): 

1142 # Extract metadata based on text type 

1143 origin = text_obj._origin 

1144 size = text_obj.size 

1145 

1146 # Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent 

1147 # Bounds should be (x, visual_top, width, height) for proper 

1148 # highlighting 

1149 visual_top = int(origin[1] - text_obj._ascent) 

1150 bounds = ( 

1151 int(origin[0]), 

1152 visual_top, 

1153 int(size[0]) if hasattr(size, '__getitem__') else 0, 

1154 int(size[1]) if hasattr(size, '__getitem__') else 0 

1155 ) 

1156 

1157 # Import here to avoid circular dependency 

1158 from .functional import LinkText, ButtonText 

1159 

1160 if isinstance(text_obj, LinkText): 

1161 result = QueryResult( 

1162 object=text_obj, 

1163 object_type="link", 

1164 bounds=bounds, 

1165 text=text_obj._text, 

1166 is_interactive=True, 

1167 link_target=text_obj._link.location if hasattr( 

1168 text_obj, 

1169 '_link') else None) 

1170 elif isinstance(text_obj, ButtonText): 1170 ↛ 1171line 1170 didn't jump to line 1171 because the condition on line 1170 was never true

1171 result = QueryResult( 

1172 object=text_obj, 

1173 object_type="button", 

1174 bounds=bounds, 

1175 text=text_obj._text, 

1176 is_interactive=True, 

1177 callback=text_obj._callback if hasattr( 

1178 text_obj, 

1179 '_callback') else None) 

1180 else: 

1181 result = QueryResult( 

1182 object=text_obj, 

1183 object_type="text", 

1184 bounds=bounds, 

1185 text=text_obj._text if hasattr(text_obj, '_text') else None 

1186 ) 

1187 

1188 result.parent_line = self 

1189 return result 

1190 

1191 return None