Files
pyWebLayout/pyWebLayout/concrete/text.py
T
dtourolle 1262be6a38 fix(text): constant word space for ragged alignment, exact justification (S13)
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.
2026-08-06 22:18:04 +02:00

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