LeftAlignmentHandler spread each line's residual space across its word gaps,
clamped to max_spacing. A line whose residual divided to under max_spacing was
stretched flush, one that exceeded it was not, so left-aligned text was
justified sometimes, by a different amount per line - which reads as a wobbling
right edge rather than as ragged-right. Centre/right did the same, and computed
their start position from a different spacing than the one they returned, so
centred lines were not centred.
Ragged alignments now use a constant word space - the font's own space advance,
clamped to the style's bounds - and report overflow instead of tightening, so
line breaking decides what fits rather than rendering squeezing it.
Justification kept two further defects:
- the final line of a paragraph was stretched across the measure, so a
three-word tail was spread edge to edge. Line now carries is_paragraph_end,
set on the line holding the last word, and renders flush left. A paragraph
continued on the next page is not marked, so it stays justified.
- gaps were floored per gap with a truncated remainder, discarding the
fractional part of both. Lines stopped one or two pixels short, differently
each time. Distributing by cumulative rounding makes the gaps sum to the
residual exactly; advance ends now land identically on every line.
Alignment is configurable rather than hardcoded: PageStyle.default_alignment,
defaulting to JUSTIFY for body text. text_align on abstract and concrete styles
defaults to None meaning "unspecified", so HTML without text-align inherits the
page default while explicit CSS still wins. Headings are never justified.
1076 lines
43 KiB
Python
1076 lines
43 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
|
|
|
|
# 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()
|
|
|
|
|
|
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
|
|
) -> 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.
|
|
|
|
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
|
|
) -> 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])
|
|
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
|
|
) -> 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])
|
|
|
|
# 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):
|
|
# Store variable spacing for each gap to distribute remainder pixels
|
|
self._gap_spacings: List[int] = []
|
|
|
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
|
available_width: int, min_spacing: int,
|
|
max_spacing: int,
|
|
natural_spacing: Optional[int] = 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])
|
|
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_spacings = [min_spacing] * num_gaps
|
|
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_spacings = []
|
|
placed = 0
|
|
for i in range(1, num_gaps + 1):
|
|
cumulative = int(round(total * i / num_gaps))
|
|
self._gap_spacings.append(cumulative - placed)
|
|
placed = cumulative
|
|
|
|
return self._gap_spacings[0], 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
|
|
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.
|
|
try:
|
|
self._natural_spacing = int(round(self._font.font.getlength(" ")))
|
|
except (AttributeError, TypeError, ValueError):
|
|
self._natural_spacing = None
|
|
|
|
# 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
|
|
|
|
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._text_objects.append(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._text_objects.append(text)
|
|
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
self._natural_spacing)
|
|
|
|
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._text_objects.pop()
|
|
|
|
# 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._text_objects.append(first_text)
|
|
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
self._natural_spacing)
|
|
_ = self._text_objects.pop()
|
|
|
|
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._text_objects.append(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 = sum([text.width for text in self._text_objects])
|
|
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._text_objects.append(first_text)
|
|
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
self._natural_spacing)
|
|
|
|
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._text_objects.pop()
|
|
|
|
# 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 = handler.calculate_spacing_and_position(
|
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
|
self._natural_spacing)
|
|
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
|
|
for i, text in enumerate(self._text_objects):
|
|
# Update text draw context to current draw context
|
|
text._draw = self._draw
|
|
text.set_origin(np.array([x_cursor, y_cursor]))
|
|
|
|
# Determine next text object for continuous decoration
|
|
next_text = self._text_objects[i + 1] if i + \
|
|
1 < len(self._text_objects) else None
|
|
|
|
# Get the spacing for this specific gap (variable for justified text)
|
|
if isinstance(handler, JustifyAlignmentHandler) and \
|
|
hasattr(handler, '_gap_spacings') and \
|
|
i < len(handler._gap_spacings):
|
|
current_spacing = handler._gap_spacings[i]
|
|
else:
|
|
current_spacing = self._spacing_render
|
|
|
|
# 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 < len(self._text_objects) - 1:
|
|
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
|