Layout was dominated by work that was either repeated per word or thrown
away. Rendered output is unchanged: every page hashed byte-for-byte
identical across 3 page sizes, 2 font scales, 2 font families, and 4
alignments x 4 column widths chosen to force heavy hyphenation.
layout, 600x800, 40 pages ~200ms -> ~124ms
layout, 1404x1872, 11 pages ~168ms -> ~111ms
Measured, not guessed. The reflex fix - swapping list comprehensions for
generators - measures slower here (602ns vs 425ns for the width sum), so
those are left alone.
- Line asked its font for the advance width of a space on every
construction. FreeTypeFont.getlength(" ") costs ~18us, two orders of
magnitude more than getmetrics(), and it landed once per line. Memoise
per font object.
- Line.add_word was quadratic in the words on a line. Each candidate word
re-summed every width and rebuilt the whole per-gap spacing list, when
fitting only ever reads the first gap. Gap spacings are now a plan
materialised on demand (only render() reads the list), and widths come
from a prefix sum. The prefix list, rather than one accumulator, is what
keeps this exact: widths are floats, (total + w) - w need not give back
total, and one ulp flips an overflow decision on a line that ends flush.
- RenderingPosition.copy/__eq__/__hash__ all went through
dataclasses.asdict, a deep recursive walk, over 8 immutable scalars.
- paragraph_layouter built a Text per line purely to discard it.
- AbstractStyle.__hash__ rebuilt a 15-tuple containing 5 enums on every
dict lookup; memoised on the frozen instance (1037ns -> 160ns).
- The pyphen dictionary wrapper was rebuilt for every word that overflowed
its line, and word extraction stripped before splitting and tested each
split result for emptiness, neither of which str.split() needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1192 lines
48 KiB
Python
1192 lines
48 KiB
Python
from __future__ import annotations
|
|
from pyWebLayout.core.base import Renderable, Queriable
|
|
from pyWebLayout.core.query import QueryResult
|
|
from .box import Box
|
|
from pyWebLayout.style import Alignment, Font, TextDecoration
|
|
from pyWebLayout.abstract import Word
|
|
from pyWebLayout.abstract.inline import LinkedWord
|
|
from pyWebLayout.abstract.functional import Link
|
|
from pyWebLayout.core.cache import UsageCache, SizedUsageCache
|
|
from PIL import ImageDraw, ImageFont
|
|
from typing import Tuple, List, Optional, Any, Dict
|
|
import logging
|
|
import math
|
|
import numpy as np
|
|
from abc import ABC, abstractmethod
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Text rendering caches
|
|
#
|
|
# A page re-measures and re-rasterises the same words constantly: measured over a
|
|
# novel at 1404x1872, a page issues ~2800 width measurements and ~2500 glyph
|
|
# rasterisations for fewer than 1000 distinct (font, string) pairs. Caching both
|
|
# turns a ~225ms page into a ~30ms page. Both caches are bounded so that a long
|
|
# reading session cannot grow without limit on a memory-constrained device.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Word widths are small floats; 8192 entries costs well under 1MB and comfortably
|
|
# spans the working set of several chapters at a couple of font sizes.
|
|
DEFAULT_WIDTH_CACHE_ENTRIES = 8192
|
|
|
|
# Glyph bitmaps are the expensive ones: ~700 bytes each on average at 1404x1872,
|
|
# so an unbounded cache reaches ~12MB after 40 pages. 4MB holds several pages'
|
|
# worth of distinct words while leaving headroom on a 512MB Pi Zero 2.
|
|
DEFAULT_GLYPH_CACHE_BYTES = 4 * 1024 * 1024
|
|
|
|
# PIL rasterises text at sub-pixel horizontal offsets, so a cache keyed only on
|
|
# (font, string) would quantise every word to a whole pixel. Bucketing the
|
|
# sub-pixel phase keeps that error negligible at the cost of more entries. 2 steps
|
|
# holds the mean error to ~3.6/255 -- a fifth of one step of a 16-level e-ink
|
|
# panel -- while keeping the cache four times smaller than 4 steps would.
|
|
DEFAULT_GLYPH_SUBPIXEL_STEPS = 2
|
|
|
|
|
|
def _glyph_entry_bytes(entry: Tuple[Any, Tuple[int, int]]) -> int:
|
|
"""Approximate footprint of a cached (mask, offset) pair, in bytes."""
|
|
mask = entry[0]
|
|
try:
|
|
width, height = mask.size
|
|
except (AttributeError, TypeError, ValueError):
|
|
return 0
|
|
return width * height
|
|
|
|
|
|
_width_cache: UsageCache = UsageCache(DEFAULT_WIDTH_CACHE_ENTRIES)
|
|
_glyph_cache: SizedUsageCache = SizedUsageCache(DEFAULT_GLYPH_CACHE_BYTES, _glyph_entry_bytes)
|
|
_glyph_subpixel_steps: int = DEFAULT_GLYPH_SUBPIXEL_STEPS
|
|
|
|
# Every Line asks its font for the advance width of a space. That single
|
|
# FreeTypeFont.getlength(" ") call costs ~18us -- two orders of magnitude more
|
|
# than getmetrics() -- because PIL shapes the string from scratch each time, and
|
|
# it lands once per line created, which dominates the cost of laying a line out.
|
|
# There are only ever a handful of distinct fonts in play, so memoise per font
|
|
# object. Values are wrapped in a 1-tuple because None is itself a legitimate
|
|
# result (fonts that cannot report a length) and must not read as a cache miss.
|
|
_space_advance_cache: Dict[Any, Tuple[Optional[int]]] = {}
|
|
|
|
# Set to False the first time the fast rasterisation path is found to be
|
|
# unavailable (e.g. a PIL build without the private ImageDraw internals it uses),
|
|
# after which every Text falls back to ImageDraw.text().
|
|
_glyph_fast_path_available: bool = True
|
|
|
|
|
|
def configure_text_caches(width_entries: Optional[int] = None,
|
|
glyph_bytes: Optional[int] = None,
|
|
subpixel_steps: Optional[int] = None):
|
|
"""
|
|
Tune the text rendering caches.
|
|
|
|
Memory-constrained targets should shrink these; a desktop rendering many font
|
|
sizes may benefit from raising them.
|
|
|
|
Args:
|
|
width_entries: Maximum cached word-width measurements.
|
|
glyph_bytes: Maximum total size of cached glyph bitmaps, in bytes.
|
|
subpixel_steps: Sub-pixel phase buckets per axis. 1 disables sub-pixel
|
|
positioning entirely (smallest cache, slightly softer text).
|
|
"""
|
|
global _glyph_subpixel_steps
|
|
|
|
if width_entries is not None:
|
|
_width_cache.resize(width_entries)
|
|
if glyph_bytes is not None:
|
|
_glyph_cache.resize(glyph_bytes)
|
|
if subpixel_steps is not None:
|
|
if subpixel_steps <= 0:
|
|
raise ValueError(f"subpixel_steps must be positive, got {subpixel_steps}")
|
|
if subpixel_steps != _glyph_subpixel_steps:
|
|
# Cached entries embed the phase bucket in their key.
|
|
_glyph_cache.clear()
|
|
_glyph_subpixel_steps = subpixel_steps
|
|
|
|
|
|
def clear_text_caches():
|
|
"""Drop all cached widths and glyph bitmaps."""
|
|
_width_cache.clear()
|
|
_glyph_cache.clear()
|
|
_space_advance_cache.clear()
|
|
|
|
|
|
def _space_advance(font) -> Optional[int]:
|
|
"""
|
|
The font's own advance width for a space, in whole pixels.
|
|
|
|
None when the font cannot report one, which is the signal for callers to fall
|
|
back to their configured spacing range.
|
|
"""
|
|
try:
|
|
cached = _space_advance_cache.get(font)
|
|
except TypeError:
|
|
# Unhashable font object; measure without caching.
|
|
cached = None
|
|
else:
|
|
if cached is not None:
|
|
return cached[0]
|
|
|
|
try:
|
|
value = int(round(font.getlength(" ")))
|
|
except (AttributeError, TypeError, ValueError):
|
|
value = None
|
|
|
|
try:
|
|
_space_advance_cache[font] = (value,)
|
|
except TypeError:
|
|
pass
|
|
return value
|
|
|
|
|
|
def text_cache_stats() -> Dict[str, Any]:
|
|
"""Occupancy and hit rates for both text caches, for tuning and diagnostics."""
|
|
return {
|
|
'width': _width_cache.stats(),
|
|
'glyph': _glyph_cache.stats(),
|
|
'glyph_subpixel_steps': _glyph_subpixel_steps,
|
|
'glyph_fast_path': _glyph_fast_path_available,
|
|
}
|
|
|
|
|
|
def prewarm_text_caches(entries,
|
|
draw: Optional[ImageDraw.ImageDraw] = None,
|
|
budget_bytes: Optional[int] = None,
|
|
max_words: Optional[int] = None) -> Tuple[int, int]:
|
|
"""
|
|
Preload the caches with a document's most frequent words.
|
|
|
|
A document states its own access distribution up front: the words it uses most
|
|
are the words every page will draw. Rasterising them once at open time moves
|
|
that work off the page-turn path, and seeding each entry with its document
|
|
frequency puts it in the right place in the eviction order immediately, rather
|
|
than after the cache has learned it.
|
|
|
|
This depends on eviction ranking by use count. Under recency eviction the
|
|
preloaded entries would be discarded by the first page of unfamiliar text; under
|
|
usage ranking a word occurring 4000 times outranks anything met while scanning
|
|
and stays resident. Measured over a 50-page trace, preloading cut misses by 27%
|
|
with usage ranking against 12% with recency.
|
|
|
|
Args:
|
|
entries: Iterable of ``(font, text, colour, frequency)``, where `font` is a
|
|
PIL font object, `colour` the fill the text will be drawn in, and
|
|
`frequency` the number of times the word occurs in the document.
|
|
draw: An ImageDraw sharing the page's mode, used to resolve ink and font
|
|
mode. A scratch RGBA context is used if omitted.
|
|
budget_bytes: Cap on bytes to preload. Defaults to half the glyph budget so
|
|
that live rendering keeps room to cache what preloading missed.
|
|
max_words: Cap on distinct words to preload, before sub-pixel variants.
|
|
|
|
Returns:
|
|
Tuple of (words preloaded, bytes preloaded).
|
|
"""
|
|
if not _glyph_fast_path_available:
|
|
return 0, 0
|
|
|
|
if draw is None:
|
|
from PIL import Image
|
|
draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
|
|
|
|
if budget_bytes is None:
|
|
budget_bytes = _glyph_cache.max_bytes // 2
|
|
budget_bytes = min(budget_bytes, _glyph_cache.max_bytes)
|
|
|
|
ranked = sorted(entries, key=lambda e: -e[3])
|
|
if max_words is not None:
|
|
ranked = ranked[:max_words]
|
|
|
|
steps = _glyph_subpixel_steps
|
|
mode = draw.fontmode
|
|
draw_mode = draw.mode
|
|
ink_cache: Dict[Any, Any] = {}
|
|
words = 0
|
|
used = 0
|
|
|
|
for font, text, colour, frequency in ranked:
|
|
if frequency <= 1 or used >= budget_bytes:
|
|
break
|
|
if not isinstance(font, ImageFont.FreeTypeFont):
|
|
continue
|
|
|
|
try:
|
|
ink = ink_cache.get(colour)
|
|
if ink is None:
|
|
ink, _ = draw._getink(colour)
|
|
if ink is None:
|
|
continue
|
|
ink_cache[colour] = ink
|
|
|
|
# Measuring is cheap and every layout pass needs it.
|
|
_width_cache.put((font, text, draw_mode),
|
|
draw.textlength(text, font=font), count=frequency)
|
|
|
|
# Words land on arbitrary sub-pixel offsets, so cover every horizontal
|
|
# phase. Baselines are whole pixels, so only phase 0 is needed
|
|
# vertically.
|
|
for x_bucket in range(steps):
|
|
entry = font.getmask2(text, mode, anchor="ls", ink=ink,
|
|
start=(x_bucket / steps, 0.0))
|
|
_glyph_cache.put((font, text, mode, ink, x_bucket, 0), entry,
|
|
count=frequency)
|
|
used += _glyph_entry_bytes(entry)
|
|
words += 1
|
|
|
|
except AttributeError:
|
|
logger.warning("Glyph cache unavailable for this Pillow build; "
|
|
"skipping prewarm.", exc_info=True)
|
|
return words, used
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
logger.debug("Prewarmed %d words (%.2fMB) into the text caches",
|
|
words, used / 1e6)
|
|
return words, used
|
|
|
|
|
|
class AlignmentHandler(ABC):
|
|
"""
|
|
Abstract base class for text alignment handlers.
|
|
Each handler implements a specific alignment strategy.
|
|
"""
|
|
|
|
@abstractmethod
|
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
|
available_width: int, min_spacing: int,
|
|
max_spacing: int,
|
|
natural_spacing: Optional[int] = None,
|
|
total_width: Optional[float] = None
|
|
) -> Tuple[int, int, bool]:
|
|
"""
|
|
Calculate the spacing between words and starting position for the line.
|
|
|
|
Args:
|
|
text_objects: List of Text objects in the line
|
|
available_width: Total width available for the line
|
|
min_spacing: Minimum spacing between words
|
|
max_spacing: Maximum spacing between words
|
|
natural_spacing: The font's own space width. Ragged alignments use it
|
|
as a constant gap; justification ignores it. Defaults to
|
|
min_spacing when not supplied.
|
|
total_width: The summed width of `text_objects`, when the caller
|
|
already knows it. Purely an optimisation: a line asks its handler
|
|
to re-measure once per candidate word, and summing the whole line
|
|
each time makes filling a line quadratic in its word count. Omit
|
|
it and the sum is taken here as before.
|
|
|
|
Returns:
|
|
Tuple of (spacing_between_words, starting_x_position, overflow)
|
|
"""
|
|
|
|
|
|
class LeftAlignmentHandler(AlignmentHandler):
|
|
"""Handler for left-aligned text."""
|
|
|
|
def calculate_spacing_and_position(self,
|
|
text_objects: List['Text'],
|
|
available_width: int,
|
|
min_spacing: int,
|
|
max_spacing: int,
|
|
natural_spacing: Optional[int] = None,
|
|
total_width: Optional[float] = None
|
|
) -> Tuple[int, int, bool]:
|
|
"""
|
|
Calculate spacing and position for left-aligned text objects.
|
|
|
|
Left-aligned text uses a constant word space and leaves whatever is left
|
|
over as a ragged right edge. It must not spread the residual space across
|
|
the gaps: that stretches each line by a different amount, which reads as
|
|
badly-set justified text rather than as ragged-right.
|
|
|
|
Args:
|
|
text_objects (List[Text]): A list of text objects to be laid out.
|
|
available_width (int): The total width available for layout.
|
|
min_spacing (int): Minimum spacing between text objects.
|
|
max_spacing (int): Maximum spacing between text objects.
|
|
natural_spacing (Optional[int]): The font's own space width.
|
|
|
|
Returns:
|
|
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
|
"""
|
|
# Handle single word case
|
|
if len(text_objects) <= 1:
|
|
return 0, 0, False
|
|
|
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
|
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
|
|
|
text_length = (sum([text.width for text in text_objects])
|
|
if total_width is None else total_width)
|
|
num_gaps = len(text_objects) - 1
|
|
|
|
# The spacing is constant whether or not the content fits: tightening a
|
|
# full line here would make it differ from its neighbours, which is the
|
|
# variation this alignment is supposed to avoid. Report the overflow and
|
|
# let line breaking move the offending word instead.
|
|
overflow = text_length + (spacing * num_gaps) > available_width
|
|
|
|
return spacing, 0, overflow
|
|
|
|
|
|
class CenterRightAlignmentHandler(AlignmentHandler):
|
|
"""Handler for center and right-aligned text."""
|
|
|
|
def __init__(self, alignment: Alignment):
|
|
self._alignment = alignment
|
|
|
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
|
available_width: int, min_spacing: int,
|
|
max_spacing: int,
|
|
natural_spacing: Optional[int] = None,
|
|
total_width: Optional[float] = None
|
|
) -> Tuple[int, int, bool]:
|
|
"""
|
|
Centre/right alignment: constant word space, line shifted as a block.
|
|
|
|
Like left alignment, the residual space must not be spread across the
|
|
gaps - it belongs in the margin. The start position is then derived from
|
|
the same spacing that will actually be used, so the line lands where it
|
|
was measured to land.
|
|
"""
|
|
word_length = (sum([word.width for word in text_objects])
|
|
if total_width is None else total_width)
|
|
|
|
# Handle single word case
|
|
if len(text_objects) <= 1:
|
|
if self._alignment == Alignment.CENTER:
|
|
start_position = (available_width - word_length) // 2
|
|
else: # RIGHT
|
|
start_position = available_width - word_length
|
|
return 0, max(0, int(start_position)), False
|
|
|
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
|
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
|
|
|
num_gaps = len(text_objects) - 1
|
|
overflow = word_length + (spacing * num_gaps) > available_width
|
|
|
|
content_length = word_length + num_gaps * spacing
|
|
if self._alignment == Alignment.CENTER:
|
|
start_position = (available_width - content_length) // 2
|
|
else:
|
|
start_position = available_width - content_length
|
|
|
|
return spacing, max(0, int(start_position)), overflow
|
|
|
|
|
|
class JustifyAlignmentHandler(AlignmentHandler):
|
|
"""Handler for justified text with full justification."""
|
|
|
|
def __init__(self):
|
|
# The per-gap spacings are described by a plan rather than stored outright,
|
|
# and materialised on demand by the _gap_spacings property below. Fitting a
|
|
# line calls this handler once per candidate word and only ever looks at the
|
|
# first gap; building the whole list on each of those probes made adding n
|
|
# words to a line O(n^2). Only render() reads the full list.
|
|
self._gap_uniform: Optional[int] = None
|
|
self._gap_residual: int = 0
|
|
self._gap_count: int = 0
|
|
self._gap_cache: Optional[List[int]] = []
|
|
|
|
@property
|
|
def _gap_spacings(self) -> List[int]:
|
|
"""The spacing to apply at each gap, left to right."""
|
|
if self._gap_cache is None:
|
|
if self._gap_uniform is not None:
|
|
self._gap_cache = [self._gap_uniform] * self._gap_count
|
|
else:
|
|
self._gap_cache = self._distribute(self._gap_residual, self._gap_count)
|
|
return self._gap_cache
|
|
|
|
@staticmethod
|
|
def _distribute(total: int, num_gaps: int) -> List[int]:
|
|
"""Split `total` pixels across `num_gaps` gaps by cumulative rounding."""
|
|
gaps = []
|
|
placed = 0
|
|
for i in range(1, num_gaps + 1):
|
|
cumulative = int(round(total * i / num_gaps))
|
|
gaps.append(cumulative - placed)
|
|
placed = cumulative
|
|
return gaps
|
|
|
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
|
available_width: int, min_spacing: int,
|
|
max_spacing: int,
|
|
natural_spacing: Optional[int] = None,
|
|
total_width: Optional[float] = None
|
|
) -> Tuple[int, int, bool]:
|
|
"""
|
|
Justified alignment distributes space to fill the entire line width.
|
|
|
|
natural_spacing is ignored: filling the measure is the whole point.
|
|
|
|
For justified text, we ALWAYS try to fill the entire width by distributing
|
|
space between words, regardless of max_spacing constraints. The only limit
|
|
is min_spacing to ensure readability.
|
|
"""
|
|
|
|
word_length = (sum([word.width for word in text_objects])
|
|
if total_width is None else total_width)
|
|
residual_space = available_width - word_length
|
|
num_gaps = max(1, len(text_objects) - 1)
|
|
|
|
# Check if we have enough space for minimum spacing
|
|
if residual_space // num_gaps < min_spacing:
|
|
# Not enough space - this is overflow
|
|
self._gap_uniform = min_spacing
|
|
self._gap_count = num_gaps
|
|
self._gap_cache = None
|
|
return min_spacing, 0, True
|
|
|
|
# Distribute the residual by cumulative rounding rather than by taking a
|
|
# floor per gap and scattering the remainder. Word widths are fractional,
|
|
# so flooring each gap loses part of a pixel and truncating the remainder
|
|
# loses up to another - the line then stops one or two pixels short of the
|
|
# margin, and by a different amount on each line, which is visible as a
|
|
# ragged right edge on otherwise justified text. Rounding the running
|
|
# total makes the gaps sum to the residual exactly.
|
|
total = int(round(residual_space))
|
|
self._gap_uniform = None
|
|
self._gap_residual = total
|
|
self._gap_count = num_gaps
|
|
self._gap_cache = None
|
|
|
|
# The first gap is the whole of the plan that fitting needs, and it falls
|
|
# out of the same cumulative rounding as _distribute would give it.
|
|
return int(round(total / num_gaps)), 0, False
|
|
|
|
|
|
class Text(Renderable, Queriable):
|
|
"""
|
|
Concrete implementation for rendering text.
|
|
This class handles the visual representation of text fragments.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
text: str,
|
|
style: Font,
|
|
draw: ImageDraw.Draw,
|
|
source: Optional[Word] = None,
|
|
line: Optional[Line] = None):
|
|
"""
|
|
Initialize a Text object.
|
|
|
|
Args:
|
|
text: The text content to render
|
|
style: The font style to use for rendering
|
|
"""
|
|
super().__init__()
|
|
self._text = text
|
|
self._style = style
|
|
self._line = line
|
|
self._source = source
|
|
self._origin = np.array([0, 0])
|
|
self._draw = draw
|
|
|
|
# Calculate dimensions
|
|
self._calculate_dimensions()
|
|
|
|
def _calculate_dimensions(self):
|
|
"""Calculate the width and height of the text based on the font metrics"""
|
|
# Measuring a word costs a FreeType shaping pass, and the same words recur
|
|
# constantly within a document, so results are cached per (font, string).
|
|
# The draw's image mode is part of the key because PIL derives advance
|
|
# widths differently for bilevel ("1") targets.
|
|
font = self._style.font
|
|
key = (font, self._text, self._draw.mode)
|
|
|
|
width = _width_cache.get(key)
|
|
if width is None:
|
|
width = self._draw.textlength(self._text, font=font)
|
|
_width_cache.put(key, width)
|
|
self._width = width
|
|
|
|
ascent, descent = font.getmetrics()
|
|
self._ascent = ascent
|
|
self._middle_y = ascent - descent / 2
|
|
|
|
@classmethod
|
|
def from_word(cls, word: Word, draw: ImageDraw.Draw):
|
|
return cls(word.text, word.style, draw)
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
"""Get the text content"""
|
|
return self._text
|
|
|
|
@property
|
|
def style(self) -> Font:
|
|
"""Get the text style"""
|
|
return self._style
|
|
|
|
@property
|
|
def origin(self) -> np.ndarray:
|
|
"""Get the origin of the text"""
|
|
return self._origin
|
|
|
|
@property
|
|
def line(self) -> Optional[Line]:
|
|
"""Get the line containing this text"""
|
|
return self._line
|
|
|
|
@line.setter
|
|
def line(self, line):
|
|
"""Set the line containing this text"""
|
|
self._line = line
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
"""Get the width of the text"""
|
|
return self._width
|
|
|
|
@property
|
|
def size(self) -> int:
|
|
"""Get the width and height of the text"""
|
|
# Return actual rendered height (ascent + descent) not just font_size
|
|
ascent, descent = self._style.font.getmetrics()
|
|
actual_height = ascent + descent
|
|
return np.array((self._width, actual_height))
|
|
|
|
def set_origin(self, origin: np.generic):
|
|
"""Set the origin (left baseline ("ls")) of this text element"""
|
|
self._origin = origin
|
|
|
|
def add_line(self, line):
|
|
"""Add this text to a line"""
|
|
self._line = line
|
|
|
|
def in_object(self, point: np.generic):
|
|
"""
|
|
Check if a point is in the text object.
|
|
|
|
Override Queriable.in_object() because Text uses baseline-anchored positioning.
|
|
The origin is at the baseline (anchor="ls"), not the top-left corner.
|
|
|
|
Args:
|
|
point: The coordinates to check
|
|
|
|
Returns:
|
|
True if the point is within the text bounds
|
|
"""
|
|
point_array = np.array(point)
|
|
|
|
# Text origin is at baseline, so visual top is origin[1] - ascent
|
|
visual_top = self._origin[1] - self._ascent
|
|
visual_bottom = self._origin[1] + (self.size[1] - self._ascent)
|
|
|
|
# Check if point is within bounds
|
|
# X: origin[0] to origin[0] + width
|
|
# Y: visual_top to visual_bottom
|
|
return (self._origin[0] <= point_array[0] < self._origin[0] + self.size[0] and
|
|
visual_top <= point_array[1] < visual_bottom)
|
|
|
|
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
|
"""
|
|
Apply text decoration (underline or strikethrough).
|
|
|
|
Args:
|
|
next_text: The next Text object in the line (if any)
|
|
spacing: The spacing to the next text object
|
|
"""
|
|
if self._style.decoration == TextDecoration.UNDERLINE:
|
|
# Draw underline at about 90% of the height
|
|
y_position = self._origin[1] - 0.1 * self._style.font_size
|
|
line_width = max(1, int(self._style.font_size / 15))
|
|
|
|
# Determine end x-coordinate
|
|
end_x = self._origin[0] + self._width
|
|
|
|
# If next text also has underline decoration, extend to connect them
|
|
if (next_text is not None and
|
|
next_text.style.decoration == TextDecoration.UNDERLINE and
|
|
next_text.style.colour == self._style.colour):
|
|
# Extend the underline through the spacing to connect with next word
|
|
end_x += spacing
|
|
|
|
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
|
|
fill=self._style.colour, width=line_width)
|
|
|
|
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
|
|
# Draw strikethrough at about 50% of the height
|
|
y_position = self._origin[1] + self._middle_y
|
|
line_width = max(1, int(self._style.font_size / 15))
|
|
|
|
# Determine end x-coordinate
|
|
end_x = self._origin[0] + self._width
|
|
|
|
# If next text also has strikethrough decoration, extend to connect them
|
|
if (next_text is not None and
|
|
next_text.style.decoration == TextDecoration.STRIKETHROUGH and
|
|
next_text.style.colour == self._style.colour):
|
|
# Extend the strikethrough through the spacing to connect with next word
|
|
end_x += spacing
|
|
|
|
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
|
|
fill=self._style.colour, width=line_width)
|
|
|
|
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
|
"""
|
|
Render the text to an image.
|
|
|
|
Args:
|
|
next_text: The next Text object in the line (if any)
|
|
spacing: The spacing to the next text object
|
|
|
|
Returns:
|
|
A PIL Image containing the rendered text
|
|
"""
|
|
|
|
style = self._style
|
|
|
|
# Draw the text background if specified
|
|
if style.background and style.background[3] > 0: # If alpha > 0
|
|
self._draw.rectangle([tuple(self._origin), tuple(self._origin + self.size)],
|
|
fill=style.background)
|
|
|
|
# Draw the text using baseline as anchor point ("ls" = left-baseline)
|
|
# This ensures the origin represents the baseline, not the top-left
|
|
if not self._render_from_glyph_cache(style):
|
|
self._draw.text(
|
|
(self.origin[0],
|
|
self._origin[1]),
|
|
self._text,
|
|
font=style.font,
|
|
fill=style.colour,
|
|
anchor="ls")
|
|
|
|
# Apply any text decorations with knowledge of next text
|
|
if style.decoration != TextDecoration.NONE:
|
|
self._apply_decoration(next_text, spacing)
|
|
|
|
def _render_from_glyph_cache(self, style) -> bool:
|
|
"""
|
|
Blit this word from the cached glyph bitmap.
|
|
|
|
Rasterising a word is the single most expensive step in drawing a page, and
|
|
the same words recur constantly, so the bitmap PIL would produce is cached
|
|
and blitted directly. This reproduces what ImageDraw.text() does internally
|
|
(getmask2 followed by draw_bitmap) minus the per-call setup.
|
|
|
|
Returns:
|
|
True if the word was drawn. False means the caller must fall back to
|
|
ImageDraw.text().
|
|
"""
|
|
global _glyph_fast_path_available
|
|
|
|
if not _glyph_fast_path_available:
|
|
return False
|
|
|
|
draw = self._draw
|
|
font = style.font
|
|
|
|
# Bitmap and other non-FreeType fonts do not expose getmask2's anchor and
|
|
# sub-pixel arguments; let PIL handle them.
|
|
if not isinstance(font, ImageFont.FreeTypeFont):
|
|
return False
|
|
|
|
try:
|
|
ink, _ = draw._getink(style.colour)
|
|
if ink is None:
|
|
return False
|
|
|
|
# floor() rather than modf() so the fraction is always in [0, 1),
|
|
# keeping bucket indices non-negative for negative coordinates.
|
|
x = float(self._origin[0])
|
|
y = float(self._origin[1])
|
|
x_whole = math.floor(x)
|
|
y_whole = math.floor(y)
|
|
|
|
steps = _glyph_subpixel_steps
|
|
x_bucket = int((x - x_whole) * steps)
|
|
y_bucket = int((y - y_whole) * steps)
|
|
|
|
mode = draw.fontmode
|
|
key = (font, self._text, mode, ink, x_bucket, y_bucket)
|
|
|
|
entry = _glyph_cache.get(key)
|
|
if entry is None:
|
|
entry = font.getmask2(
|
|
self._text, mode, anchor="ls", ink=ink,
|
|
start=(x_bucket / steps, y_bucket / steps))
|
|
_glyph_cache.put(key, entry)
|
|
|
|
mask, offset = entry
|
|
draw.draw.draw_bitmap((x_whole + offset[0], y_whole + offset[1]), mask, ink)
|
|
return True
|
|
|
|
except AttributeError:
|
|
# A PIL build without the internals this path relies on. Stop trying.
|
|
logger.warning(
|
|
"Glyph cache unavailable for this Pillow build; falling back to "
|
|
"ImageDraw.text() for all text rendering.", exc_info=True)
|
|
_glyph_fast_path_available = False
|
|
return False
|
|
except (TypeError, ValueError):
|
|
# This particular colour/mode combination is not supported by the fast
|
|
# path (e.g. an ink PIL cannot resolve). Others may still be.
|
|
return False
|
|
|
|
|
|
class Line(Box):
|
|
"""
|
|
A line of text consisting of Text objects with consistent spacing.
|
|
Each Text represents a word or word fragment that can be rendered.
|
|
"""
|
|
|
|
def __init__(self,
|
|
spacing: Tuple[int,
|
|
int],
|
|
origin,
|
|
size,
|
|
draw: ImageDraw.Draw,
|
|
font: Optional[Font] = None,
|
|
callback=None,
|
|
sheet=None,
|
|
mode=None,
|
|
halign=Alignment.CENTER,
|
|
valign=Alignment.CENTER,
|
|
previous=None,
|
|
min_word_length_for_brute_force: int = 8,
|
|
min_chars_before_hyphen: int = 2,
|
|
min_chars_after_hyphen: int = 2):
|
|
"""
|
|
Initialize a new line.
|
|
|
|
Args:
|
|
spacing: A tuple of (min_spacing, max_spacing) between words
|
|
origin: The top-left position of the line
|
|
size: The width and height of the line
|
|
font: The default font to use for text in this line
|
|
callback: Optional callback function
|
|
sheet: Optional image sheet
|
|
mode: Optional image mode
|
|
halign: Horizontal alignment of text within the line
|
|
valign: Vertical alignment of text within the line
|
|
previous: Reference to the previous line
|
|
min_word_length_for_brute_force: Minimum word length to attempt brute force hyphenation (default: 8)
|
|
min_chars_before_hyphen: Minimum characters before hyphen in any split (default: 2)
|
|
min_chars_after_hyphen: Minimum characters after hyphen in any split (default: 2)
|
|
"""
|
|
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
|
self._text_objects: List['Text'] = [] # Store Text objects directly
|
|
# Prefix sums of the widths in _text_objects, kept in step by _push_text /
|
|
# _pop_text. Element 0 is the empty sum. See _push_text for the rationale.
|
|
self._width_prefix: List[float] = [0.0]
|
|
self._spacing = spacing # (min_spacing, max_spacing)
|
|
self._font = font if font else Font() # Use default font if none provided
|
|
self._current_width = 0 # Track the current width used
|
|
self._words: List['Word'] = []
|
|
self._previous = previous
|
|
self._next = None
|
|
ascent, descent = self._font.font.getmetrics()
|
|
# Store baseline as offset from line origin (top), not absolute position
|
|
self._baseline = ascent
|
|
self._draw = draw
|
|
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
|
self._position_render = 0
|
|
|
|
# The font's own space advance. Ragged alignments use this as their
|
|
# constant word gap rather than stretching to fill the measure.
|
|
self._natural_spacing = _space_advance(self._font.font)
|
|
|
|
# Hyphenation configuration parameters
|
|
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
|
self._min_chars_before_hyphen = min_chars_before_hyphen
|
|
self._min_chars_after_hyphen = min_chars_after_hyphen
|
|
|
|
# Create the appropriate alignment handler
|
|
self._alignment_handler = self._create_alignment_handler(halign)
|
|
|
|
# Set on the final line of a paragraph. Justification stretches a line to
|
|
# fill the column, which is wrong for the last line - a three-word tail
|
|
# would be spread across the full measure. The last line takes its
|
|
# natural width instead, as in every other typesetting system.
|
|
self._is_paragraph_end = False
|
|
|
|
@property
|
|
def is_paragraph_end(self) -> bool:
|
|
"""Whether this is the final line of its paragraph"""
|
|
return self._is_paragraph_end
|
|
|
|
@is_paragraph_end.setter
|
|
def is_paragraph_end(self, value: bool):
|
|
self._is_paragraph_end = value
|
|
|
|
@property
|
|
def render_alignment_handler(self) -> AlignmentHandler:
|
|
"""
|
|
The handler used to position text when rendering.
|
|
|
|
This differs from the fitting handler only for the last line of a
|
|
justified paragraph, which is rendered flush left.
|
|
"""
|
|
if self._is_paragraph_end and isinstance(
|
|
self._alignment_handler, JustifyAlignmentHandler):
|
|
return LeftAlignmentHandler()
|
|
return self._alignment_handler
|
|
|
|
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
|
|
"""
|
|
Create the appropriate alignment handler based on the alignment type.
|
|
|
|
Args:
|
|
alignment: The alignment type
|
|
|
|
Returns:
|
|
The appropriate alignment handler instance
|
|
"""
|
|
if alignment == Alignment.LEFT:
|
|
return LeftAlignmentHandler()
|
|
elif alignment == Alignment.JUSTIFY:
|
|
return JustifyAlignmentHandler()
|
|
else: # CENTER or RIGHT
|
|
return CenterRightAlignmentHandler(alignment)
|
|
|
|
@property
|
|
def text_objects(self) -> List[Text]:
|
|
"""Get the list of Text objects in this line"""
|
|
return self._text_objects
|
|
|
|
def set_next(self, line: Line):
|
|
"""Set the next line in sequence"""
|
|
self._next = line
|
|
|
|
@property
|
|
def _content_width(self) -> float:
|
|
"""Summed width of the line's current contents."""
|
|
return self._width_prefix[-1]
|
|
|
|
def _push_text(self, text: 'Text'):
|
|
"""
|
|
Append a Text to the line, keeping the running width sum in step.
|
|
|
|
Fitting a word is a trial: the candidate is pushed, measured, and popped
|
|
again if it did not fit, so the line's contents churn far more often than
|
|
they grow. Tracking the sum here rather than re-adding every width on each
|
|
measurement is what keeps filling a line linear in its word count.
|
|
|
|
The sum is kept as a prefix list rather than as one accumulator that is
|
|
added to and subtracted from. Widths are floats, so `(total + w) - w` need
|
|
not give back `total` exactly, and a drift of one ulp is enough to flip an
|
|
overflow decision on a line that ends flush. Truncating a prefix list
|
|
restores the earlier total bit for bit, and each entry is built by the same
|
|
left-to-right addition sum() would perform.
|
|
"""
|
|
self._text_objects.append(text)
|
|
self._width_prefix.append(self._width_prefix[-1] + text.width)
|
|
|
|
def _pop_text(self) -> 'Text':
|
|
"""Remove the last Text from the line, keeping the width sum in step."""
|
|
text = self._text_objects.pop()
|
|
self._width_prefix.pop()
|
|
return text
|
|
|
|
def _measure(self, handler: Optional[AlignmentHandler] = None
|
|
) -> Tuple[int, int, bool]:
|
|
"""Ask an alignment handler to place the line's current contents."""
|
|
if handler is None:
|
|
handler = self._alignment_handler
|
|
return handler.calculate_spacing_and_position(
|
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
self._natural_spacing, self._content_width)
|
|
|
|
def add_word(self,
|
|
word: 'Word',
|
|
part: Optional[Text] = None) -> Tuple[bool,
|
|
Optional['Text']]:
|
|
"""
|
|
Add a word to this line using intelligent word fitting strategies.
|
|
|
|
Args:
|
|
word: The word to add to the line
|
|
part: Optional pretext from a previous hyphenated word
|
|
|
|
Returns:
|
|
Tuple of (success, overflow_text):
|
|
- success: True if word/part was added, False if it couldn't fit
|
|
- overflow_text: Remaining text if word was hyphenated, None otherwise
|
|
"""
|
|
# First, add any pretext from previous hyphenation
|
|
if part is not None:
|
|
self._push_text(part)
|
|
self._words.append(word)
|
|
part.add_line(self)
|
|
|
|
# Try to add the full word - create LinkText for LinkedWord, regular Text
|
|
# otherwise
|
|
if isinstance(word, LinkedWord):
|
|
# Import here to avoid circular dependency
|
|
from .functional import LinkText
|
|
# Create a LinkText which includes the link functionality
|
|
# LinkText constructor needs: (link, text, font, draw, source, line)
|
|
# But LinkedWord itself contains the link properties
|
|
# We'll create a Link object from the LinkedWord properties
|
|
link = Link(
|
|
location=word.location,
|
|
link_type=word.link_type,
|
|
callback=word.link_callback,
|
|
params=word.params,
|
|
title=word.link_title
|
|
)
|
|
text = LinkText(
|
|
link,
|
|
word.text,
|
|
word.style,
|
|
self._draw,
|
|
source=word,
|
|
line=self)
|
|
else:
|
|
text = Text.from_word(word, self._draw)
|
|
self._push_text(text)
|
|
spacing, position, overflow = self._measure()
|
|
|
|
if not overflow:
|
|
# Word fits! Add it completely
|
|
self._words.append(word)
|
|
word.add_concete(text)
|
|
text.add_line(self)
|
|
self._position_render = position
|
|
self._spacing_render = spacing
|
|
return True, None
|
|
|
|
# Word doesn't fit, remove it and try hyphenation
|
|
self._pop_text()
|
|
|
|
# Step 1: Try pyphen hyphenation
|
|
pyphen_splits = word.possible_hyphenation()
|
|
valid_splits = []
|
|
|
|
if pyphen_splits:
|
|
# Create Text objects for each possible split and check if they fit
|
|
for pair in pyphen_splits:
|
|
first_part_text = pair[0] + "-"
|
|
second_part_text = pair[1]
|
|
|
|
# Validate minimum character requirements
|
|
if len(pair[0]) < self._min_chars_before_hyphen:
|
|
continue
|
|
if len(pair[1]) < self._min_chars_after_hyphen:
|
|
continue
|
|
|
|
# Create Text objects
|
|
first_text = Text(
|
|
first_part_text,
|
|
word.style,
|
|
self._draw,
|
|
line=self,
|
|
source=word)
|
|
second_text = Text(
|
|
second_part_text,
|
|
word.style,
|
|
self._draw,
|
|
line=self,
|
|
source=word)
|
|
|
|
# Check if first part fits
|
|
self._push_text(first_text)
|
|
spacing, position, overflow = self._measure()
|
|
self._pop_text()
|
|
|
|
if not overflow:
|
|
# This split fits! Add it to valid options
|
|
valid_splits.append((first_text, second_text, spacing, position))
|
|
|
|
# Step 2: If we have valid pyphen splits, choose the best one
|
|
if valid_splits:
|
|
# Select the split with the best (minimum) spacing
|
|
best_split = min(valid_splits, key=lambda x: x[2])
|
|
first_text, second_text, spacing, position = best_split
|
|
|
|
# Apply the split
|
|
self._push_text(first_text)
|
|
first_text.line = self
|
|
word.add_concete((first_text, second_text))
|
|
self._spacing_render = spacing
|
|
self._position_render = position
|
|
self._words.append(word)
|
|
return True, second_text
|
|
|
|
# Step 3: Try brute force hyphenation (only for long words)
|
|
if len(word.text) >= self._min_word_length_for_brute_force:
|
|
# Calculate available space for the word
|
|
word_length = self._content_width
|
|
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
|
|
remaining = self._size[0] - word_length - spacing_length
|
|
|
|
if remaining > 0:
|
|
# Create a hyphenated version to measure
|
|
test_text = Text(word.text + "-", word.style, self._draw)
|
|
|
|
if test_text.width > 0:
|
|
# Calculate what fraction of the hyphenated word fits
|
|
fraction = remaining / test_text.width
|
|
|
|
# Convert fraction to character position
|
|
# We need at least min_chars_before_hyphen and leave at least
|
|
# min_chars_after_hyphen
|
|
max_split_pos = len(word.text) - self._min_chars_after_hyphen
|
|
min_split_pos = self._min_chars_before_hyphen
|
|
|
|
# Calculate ideal split position based on available space
|
|
ideal_split = int(fraction * len(word.text))
|
|
split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
|
|
|
|
# Ensure we meet minimum requirements
|
|
if (split_pos >= self._min_chars_before_hyphen and
|
|
len(word.text) - split_pos >= self._min_chars_after_hyphen):
|
|
|
|
# Create the split
|
|
first_part_text = word.text[:split_pos] + "-"
|
|
second_part_text = word.text[split_pos:]
|
|
|
|
first_text = Text(
|
|
first_part_text,
|
|
word.style,
|
|
self._draw,
|
|
line=self,
|
|
source=word)
|
|
second_text = Text(
|
|
second_part_text,
|
|
word.style,
|
|
self._draw,
|
|
line=self,
|
|
source=word)
|
|
|
|
# Verify the first part actually fits
|
|
self._push_text(first_text)
|
|
spacing, position, overflow = self._measure()
|
|
|
|
if not overflow:
|
|
# Brute force split works!
|
|
first_text.line = self
|
|
second_text.line = self
|
|
word.add_concete((first_text, second_text))
|
|
self._spacing_render = spacing
|
|
self._position_render = position
|
|
self._words.append(word)
|
|
return True, second_text
|
|
else:
|
|
# Doesn't fit, remove it
|
|
self._pop_text()
|
|
|
|
# Step 4: Word cannot be hyphenated or split, move to next line
|
|
return False, None
|
|
|
|
def render(self):
|
|
"""
|
|
Render the line with all its text objects using the alignment handler system.
|
|
|
|
Returns:
|
|
A PIL Image containing the rendered line
|
|
"""
|
|
# Recalculate spacing and position for current text objects to ensure
|
|
# accuracy. Word fitting used the paragraph's alignment; rendering uses
|
|
# render_alignment_handler, which differs only for the last line of a
|
|
# justified paragraph.
|
|
handler = self.render_alignment_handler
|
|
if len(self._text_objects) > 0:
|
|
spacing, position, overflow = self._measure(handler)
|
|
self._spacing_render = spacing
|
|
self._position_render = position
|
|
|
|
y_cursor = self._origin[1] + self._baseline
|
|
|
|
# Start x_cursor at line origin plus any alignment offset
|
|
x_cursor = self._origin[0] + self._position_render
|
|
|
|
# Everything the loop needs that does not vary per word is resolved once.
|
|
# Only justified lines carry per-gap spacings; every other alignment uses
|
|
# the single spacing figured above.
|
|
texts = self._text_objects
|
|
last = len(texts) - 1
|
|
draw = self._draw
|
|
default_spacing = self._spacing_render
|
|
gaps = handler._gap_spacings if isinstance(handler, JustifyAlignmentHandler) else ()
|
|
gap_count = len(gaps)
|
|
|
|
for i, text in enumerate(texts):
|
|
# Update text draw context to current draw context
|
|
text._draw = draw
|
|
text.set_origin(np.array([x_cursor, y_cursor]))
|
|
|
|
# Determine next text object for continuous decoration
|
|
next_text = texts[i + 1] if i < last else None
|
|
|
|
# Get the spacing for this specific gap (variable for justified text)
|
|
current_spacing = gaps[i] if i < gap_count else default_spacing
|
|
|
|
# Render with next text information for continuous underline/strikethrough
|
|
text.render(next_text, current_spacing)
|
|
# Add text width, then spacing only if there are more words
|
|
x_cursor += text.width
|
|
if i < last:
|
|
x_cursor += current_spacing
|
|
|
|
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
|
"""
|
|
Find which Text object contains the given point.
|
|
Uses Queriable.in_object() mixin for hit-testing.
|
|
|
|
Args:
|
|
point: (x, y) coordinates to query
|
|
|
|
Returns:
|
|
QueryResult from the text object at that point, or None
|
|
"""
|
|
point_array = np.array(point)
|
|
|
|
# Check each text object in this line
|
|
for text_obj in self._text_objects:
|
|
# Use Queriable mixin's in_object() for hit-testing
|
|
if isinstance(text_obj, Queriable) and text_obj.in_object(point_array):
|
|
# Extract metadata based on text type
|
|
origin = text_obj._origin
|
|
size = text_obj.size
|
|
|
|
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
|
|
# Bounds should be (x, visual_top, width, height) for proper
|
|
# highlighting
|
|
visual_top = int(origin[1] - text_obj._ascent)
|
|
bounds = (
|
|
int(origin[0]),
|
|
visual_top,
|
|
int(size[0]) if hasattr(size, '__getitem__') else 0,
|
|
int(size[1]) if hasattr(size, '__getitem__') else 0
|
|
)
|
|
|
|
# Import here to avoid circular dependency
|
|
from .functional import LinkText, ButtonText
|
|
|
|
if isinstance(text_obj, LinkText):
|
|
result = QueryResult(
|
|
object=text_obj,
|
|
object_type="link",
|
|
bounds=bounds,
|
|
text=text_obj._text,
|
|
is_interactive=True,
|
|
link_target=text_obj._link.location if hasattr(
|
|
text_obj,
|
|
'_link') else None)
|
|
elif isinstance(text_obj, ButtonText):
|
|
result = QueryResult(
|
|
object=text_obj,
|
|
object_type="button",
|
|
bounds=bounds,
|
|
text=text_obj._text,
|
|
is_interactive=True,
|
|
callback=text_obj._callback if hasattr(
|
|
text_obj,
|
|
'_callback') else None)
|
|
else:
|
|
result = QueryResult(
|
|
object=text_obj,
|
|
object_type="text",
|
|
bounds=bounds,
|
|
text=text_obj._text if hasattr(text_obj, '_text') else None
|
|
)
|
|
|
|
result.parent_line = self
|
|
return result
|
|
|
|
return None
|