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
« 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
17logger = logging.getLogger(__name__)
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# ---------------------------------------------------------------------------
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
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
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
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
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
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]]] = {}
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
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.
82 Memory-constrained targets should shrink these; a desktop rendering many font
83 sizes may benefit from raising them.
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
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
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()
113def _space_advance(font) -> Optional[int]:
114 """
115 The font's own advance width for a space, in whole pixels.
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]
129 try:
130 value = int(round(font.getlength(" ")))
131 except (AttributeError, TypeError, ValueError):
132 value = None
134 try:
135 _space_advance_cache[font] = (value,)
136 except TypeError:
137 pass
138 return value
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 }
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.
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.
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.
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.
180 Returns:
181 Tuple of (words preloaded, bytes preloaded).
182 """
183 if not _glyph_fast_path_available:
184 return 0, 0
186 if draw is None:
187 from PIL import Image
188 draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
190 if budget_bytes is None:
191 budget_bytes = _glyph_cache.max_bytes // 2
192 budget_bytes = min(budget_bytes, _glyph_cache.max_bytes)
194 ranked = sorted(entries, key=lambda e: -e[3])
195 if max_words is not None:
196 ranked = ranked[:max_words]
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
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
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
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)
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
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
241 logger.debug("Prewarmed %d words (%.2fMB) into the text caches",
242 words, used / 1e6)
243 return words, used
246class AlignmentHandler(ABC):
247 """
248 Abstract base class for text alignment handlers.
249 Each handler implements a specific alignment strategy.
250 """
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.
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.
276 Returns:
277 Tuple of (spacing_between_words, starting_x_position, overflow)
278 """
281class LeftAlignmentHandler(AlignmentHandler):
282 """Handler for left-aligned text."""
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.
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.
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.
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
314 spacing = min_spacing if natural_spacing is None else natural_spacing
315 spacing = max(min_spacing, min(max_spacing, int(spacing)))
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
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
327 return spacing, 0, overflow
330class CenterRightAlignmentHandler(AlignmentHandler):
331 """Handler for center and right-aligned text."""
333 def __init__(self, alignment: Alignment):
334 self._alignment = alignment
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.
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)
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
361 spacing = min_spacing if natural_spacing is None else natural_spacing
362 spacing = max(min_spacing, min(max_spacing, int(spacing)))
364 num_gaps = len(text_objects) - 1
365 overflow = word_length + (spacing * num_gaps) > available_width
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
373 return spacing, max(0, int(start_position)), overflow
376class JustifyAlignmentHandler(AlignmentHandler):
377 """Handler for justified text with full justification."""
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]] = []
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
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
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.
420 natural_spacing is ignored: filling the measure is the whole point.
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 """
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)
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
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
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
458class Text(Renderable, Queriable):
459 """
460 Concrete implementation for rendering text.
461 This class handles the visual representation of text fragments.
462 """
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.
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
486 # Calculate dimensions
487 self._calculate_dimensions()
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)
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
504 ascent, descent = font.getmetrics()
505 self._ascent = ascent
506 self._middle_y = ascent - descent / 2
508 @classmethod
509 def from_word(cls, word: Word, draw: ImageDraw.Draw):
510 return cls(word.text, word.style, draw)
512 @property
513 def text(self) -> str:
514 """Get the text content"""
515 return self._text
517 @property
518 def style(self) -> Font:
519 """Get the text style"""
520 return self._style
522 @property
523 def origin(self) -> np.ndarray:
524 """Get the origin of the text"""
525 return self._origin
527 @property
528 def line(self) -> Optional[Line]:
529 """Get the line containing this text"""
530 return self._line
532 @line.setter
533 def line(self, line):
534 """Set the line containing this text"""
535 self._line = line
537 @property
538 def width(self) -> int:
539 """Get the width of the text"""
540 return self._width
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))
550 def set_origin(self, origin: np.generic):
551 """Set the origin (left baseline ("ls")) of this text element"""
552 self._origin = origin
554 def add_line(self, line):
555 """Add this text to a line"""
556 self._line = line
558 def in_object(self, point: np.generic):
559 """
560 Check if a point is in the text object.
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.
565 Args:
566 point: The coordinates to check
568 Returns:
569 True if the point is within the text bounds
570 """
571 point_array = np.array(point)
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)
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)
583 def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
584 """
585 Apply text decoration (underline or strikethrough).
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))
596 # Determine end x-coordinate
597 end_x = self._origin[0] + self._width
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
606 self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
607 fill=self._style.colour, width=line_width)
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))
614 # Determine end x-coordinate
615 end_x = self._origin[0] + self._width
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
624 self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
625 fill=self._style.colour, width=line_width)
627 def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
628 """
629 Render the text to an image.
631 Args:
632 next_text: The next Text object in the line (if any)
633 spacing: The spacing to the next text object
635 Returns:
636 A PIL Image containing the rendered text
637 """
639 style = self._style
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)
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")
657 # Apply any text decorations with knowledge of next text
658 if style.decoration != TextDecoration.NONE:
659 self._apply_decoration(next_text, spacing)
661 def _render_from_glyph_cache(self, style) -> bool:
662 """
663 Blit this word from the cached glyph bitmap.
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.
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
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
679 draw = self._draw
680 font = style.font
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
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
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)
699 steps = _glyph_subpixel_steps
700 x_bucket = int((x - x_whole) * steps)
701 y_bucket = int((y - y_whole) * steps)
703 mode = draw.fontmode
704 key = (font, self._text, mode, ink, x_bucket, y_bucket)
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)
713 mask, offset = entry
714 draw.draw.draw_bitmap((x_whole + offset[0], y_whole + offset[1]), mask, ink)
715 return True
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
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 """
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.
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
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)
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
797 # Create the appropriate alignment handler
798 self._alignment_handler = self._create_alignment_handler(halign)
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
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
811 @is_paragraph_end.setter
812 def is_paragraph_end(self, value: bool):
813 self._is_paragraph_end = value
815 @property
816 def render_alignment_handler(self) -> AlignmentHandler:
817 """
818 The handler used to position text when rendering.
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
828 def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
829 """
830 Create the appropriate alignment handler based on the alignment type.
832 Args:
833 alignment: The alignment type
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)
845 @property
846 def text_objects(self) -> List[Text]:
847 """Get the list of Text objects in this line"""
848 return self._text_objects
850 def set_next(self, line: Line):
851 """Set the next line in sequence"""
852 self._next = line
854 @property
855 def _content_width(self) -> float:
856 """Summed width of the line's current contents."""
857 return self._width_prefix[-1]
859 def _push_text(self, text: 'Text'):
860 """
861 Append a Text to the line, keeping the running width sum in step.
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.
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)
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
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)
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.
900 Args:
901 word: The word to add to the line
902 part: Optional pretext from a previous hyphenated word
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)
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()
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
952 # Word doesn't fit, remove it and try hyphenation
953 self._pop_text()
955 # Step 1: Try pyphen hyphenation
956 pyphen_splits = word.possible_hyphenation()
957 valid_splits = []
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]
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
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)
985 # Check if first part fits
986 self._push_text(first_text)
987 spacing, position, overflow = self._measure()
988 self._pop_text()
990 if not overflow:
991 # This split fits! Add it to valid options
992 valid_splits.append((first_text, second_text, spacing, position))
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
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
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
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)
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
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
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))
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):
1038 # Create the split
1039 first_part_text = word.text[:split_pos] + "-"
1040 second_part_text = word.text[split_pos:]
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)
1055 # Verify the first part actually fits
1056 self._push_text(first_text)
1057 spacing, position, overflow = self._measure()
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()
1072 # Step 4: Word cannot be hyphenated or split, move to next line
1073 return False, None
1075 def render(self):
1076 """
1077 Render the line with all its text objects using the alignment handler system.
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
1092 y_cursor = self._origin[1] + self._baseline
1094 # Start x_cursor at line origin plus any alignment offset
1095 x_cursor = self._origin[0] + self._position_render
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)
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]))
1112 # Determine next text object for continuous decoration
1113 next_text = texts[i + 1] if i < last else None
1115 # Get the spacing for this specific gap (variable for justified text)
1116 current_spacing = gaps[i] if i < gap_count else default_spacing
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
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.
1130 Args:
1131 point: (x, y) coordinates to query
1133 Returns:
1134 QueryResult from the text object at that point, or None
1135 """
1136 point_array = np.array(point)
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
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 )
1157 # Import here to avoid circular dependency
1158 from .functional import LinkText, ButtonText
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 )
1188 result.parent_line = self
1189 return result
1191 return None