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>
458 lines
15 KiB
Python
458 lines
15 KiB
Python
from __future__ import annotations
|
|
from pyWebLayout.core import Hierarchical
|
|
from pyWebLayout.style import Font
|
|
from pyWebLayout.style.abstract_style import AbstractStyle
|
|
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
|
from functools import lru_cache
|
|
import pyphen
|
|
|
|
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
|
from pyWebLayout.abstract.functional import LinkType
|
|
|
|
|
|
@lru_cache(maxsize=16)
|
|
def _hyphen_dict(language: Optional[str]) -> pyphen.Pyphen:
|
|
"""
|
|
The pyphen dictionary for a language, reused across words.
|
|
|
|
Pyphen caches the parsed dictionary file itself, but rebuilding the wrapper
|
|
per word still costs about 40% of a hyphenation call, and hyphenation is
|
|
attempted for every word that overflows its line.
|
|
"""
|
|
return pyphen.Pyphen(lang=language)
|
|
|
|
|
|
class Word:
|
|
"""
|
|
An abstract representation of a word in a document. Words can be split across
|
|
lines or pages during rendering. This class manages the logical representation
|
|
of a word without any rendering specifics.
|
|
|
|
Now uses AbstractStyle objects for memory efficiency and proper style management.
|
|
"""
|
|
|
|
def __init__(self,
|
|
text: str,
|
|
style: Union[Font,
|
|
AbstractStyle],
|
|
background=None,
|
|
previous: Union['Word',
|
|
None] = None):
|
|
"""
|
|
Initialize a new Word.
|
|
|
|
Args:
|
|
text: The text content of the word
|
|
style: AbstractStyle object or Font object (for backward compatibility)
|
|
background: Optional background color override
|
|
previous: Reference to the previous word in sequence
|
|
"""
|
|
self._text = text
|
|
self._style = style
|
|
self._background = background
|
|
self._previous = previous
|
|
self._next = None
|
|
self.concrete = None
|
|
if previous:
|
|
previous.add_next(self)
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
|
|
background=None) -> 'Word':
|
|
"""
|
|
Create a new Word and add it to a container, inheriting style and language
|
|
from the container if not explicitly provided.
|
|
|
|
This method provides a convenient way to create words that automatically
|
|
inherit styling from their container (Paragraph, FormattedSpan, etc.)
|
|
without copying string values - using object references instead.
|
|
|
|
Args:
|
|
text: The text content of the word
|
|
container: The container to add the word to (must have add_word method and style property)
|
|
style: Optional Font style override. If None, inherits from container
|
|
background: Optional background color override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created Word object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_word method or style property
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None:
|
|
if hasattr(container, 'style'):
|
|
style = container.style
|
|
else:
|
|
raise AttributeError(
|
|
f"Container {type(container).__name__} must have a 'style' property")
|
|
|
|
# Inherit background from container if not provided
|
|
if background is None and hasattr(container, 'background'):
|
|
background = container.background
|
|
|
|
# Determine the previous word for proper linking
|
|
previous = None
|
|
if hasattr(container, '_words') and container._words:
|
|
# Container has a _words list (like FormattedSpan)
|
|
previous = container._words[-1]
|
|
elif hasattr(container, 'words'):
|
|
# Container has a words() method (like Paragraph)
|
|
try:
|
|
# Get the last word from the iterator
|
|
for _, word in container.words():
|
|
previous = word
|
|
except (StopIteration, TypeError):
|
|
previous = None
|
|
|
|
# Create the new word
|
|
word = cls(text, style, background, previous)
|
|
|
|
# Link the previous word to this new one
|
|
if previous:
|
|
previous.add_next(word)
|
|
|
|
# Add the word to the container
|
|
if hasattr(container, 'add_word'):
|
|
# Check if add_word expects a Word object or text string
|
|
import inspect
|
|
sig = inspect.signature(container.add_word)
|
|
params = list(sig.parameters.keys())
|
|
|
|
if len(params) > 0:
|
|
# Peek at the parameter name to guess the expected type
|
|
param_name = params[0]
|
|
if param_name in ['word', 'word_obj', 'word_object']:
|
|
# Expects a Word object
|
|
container.add_word(word)
|
|
else:
|
|
# Might expect text string (like FormattedSpan.add_word)
|
|
# In this case, we can't use the container's add_word as it would create
|
|
# a duplicate Word. We need to add directly to the container's word
|
|
# list.
|
|
if hasattr(container, '_words'):
|
|
container._words.append(word)
|
|
else:
|
|
# Fallback: try calling with the Word object anyway
|
|
container.add_word(word)
|
|
else:
|
|
# No parameters, shouldn't happen with add_word methods
|
|
container.add_word(word)
|
|
else:
|
|
raise AttributeError(
|
|
f"Container {type(container).__name__} must have an 'add_word' method")
|
|
|
|
return word
|
|
|
|
def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
|
|
self.concrete = text
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
"""Get the text content of the word"""
|
|
return self._text
|
|
|
|
@property
|
|
def style(self) -> Font:
|
|
"""Get the font style of the word"""
|
|
return self._style
|
|
|
|
@property
|
|
def background(self):
|
|
"""Get the background color of the word"""
|
|
return self._background
|
|
|
|
@property
|
|
def previous(self) -> Union['Word', None]:
|
|
"""Get the previous word in sequence"""
|
|
return self._previous
|
|
|
|
@property
|
|
def next(self) -> Union['Word', None]:
|
|
"""Get the next word in sequence"""
|
|
return self._next
|
|
|
|
def add_next(self, next_word: 'Word'):
|
|
"""Set the next word in sequence"""
|
|
self._next = next_word
|
|
|
|
def with_style(self, style: Font) -> 'Word':
|
|
"""
|
|
Return a copy of this word carrying a different font.
|
|
|
|
Subclasses that hold extra state must override this, or that state is
|
|
silently dropped when a caller restyles the word. Sequence links
|
|
(previous/next) are deliberately not copied: the copy belongs to a
|
|
different word chain, which the new container rebuilds as words are
|
|
added to it.
|
|
"""
|
|
return Word(self._text, style, self._background)
|
|
|
|
def possible_hyphenation(self, language: str = None) -> bool:
|
|
"""
|
|
Hyphenate the word and store the parts.
|
|
|
|
Args:
|
|
language: Language code for hyphenation. If None, uses the style's language.
|
|
|
|
Returns:
|
|
bool: True if the word was hyphenated, False otherwise.
|
|
"""
|
|
|
|
return list(_hyphen_dict(self._style.language).iterate(self._text))
|
|
|
|
|
|
...
|
|
|
|
|
|
class FormattedSpan:
|
|
"""
|
|
A run of words with consistent formatting.
|
|
This represents a sequence of words that share the same style attributes.
|
|
"""
|
|
|
|
def __init__(self, style: Font, background=None):
|
|
"""
|
|
Initialize a new formatted span.
|
|
|
|
Args:
|
|
style: Font style information for all words in this span
|
|
background: Optional background color override
|
|
"""
|
|
self._style = style
|
|
self._background = background if background else style.background
|
|
self._words: List[Word] = []
|
|
|
|
@classmethod
|
|
def create_and_add_to(
|
|
cls,
|
|
container,
|
|
style: Optional[Font] = None,
|
|
background=None) -> 'FormattedSpan':
|
|
"""
|
|
Create a new FormattedSpan and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the span to (must have add_span method and style property)
|
|
style: Optional Font style override. If None, inherits from container
|
|
background: Optional background color override
|
|
|
|
Returns:
|
|
The newly created FormattedSpan object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_span method or style property
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None:
|
|
if hasattr(container, 'style'):
|
|
style = container.style
|
|
else:
|
|
raise AttributeError(
|
|
f"Container {type(container).__name__} must have a 'style' property")
|
|
|
|
# Inherit background from container if not provided
|
|
if background is None and hasattr(container, 'background'):
|
|
background = container.background
|
|
|
|
# Create the new span
|
|
span = cls(style, background)
|
|
|
|
# Add the span to the container
|
|
if hasattr(container, 'add_span'):
|
|
container.add_span(span)
|
|
else:
|
|
raise AttributeError(
|
|
f"Container {type(container).__name__} must have an 'add_span' method")
|
|
|
|
return span
|
|
|
|
@property
|
|
def style(self) -> Font:
|
|
"""Get the font style of this span"""
|
|
return self._style
|
|
|
|
@property
|
|
def background(self):
|
|
"""Get the background color of this span"""
|
|
return self._background
|
|
|
|
@property
|
|
def words(self) -> List[Word]:
|
|
"""Get the list of words in this span"""
|
|
return self._words
|
|
|
|
def add_word(self, text: str) -> Word:
|
|
"""
|
|
Create and add a new word to this span.
|
|
|
|
Args:
|
|
text: The text content of the word
|
|
|
|
Returns:
|
|
The newly created Word object
|
|
"""
|
|
# Get the previous word if any
|
|
previous = self._words[-1] if self._words else None
|
|
|
|
# Create the new word
|
|
word = Word(text, self._style, self._background, previous)
|
|
|
|
# Link the previous word to this new one
|
|
if previous:
|
|
previous.add_next(word)
|
|
|
|
# Add the word to our list
|
|
self._words.append(word)
|
|
|
|
return word
|
|
|
|
|
|
class LinkedWord(Word):
|
|
"""
|
|
A Word that is also a Link - combines text content with hyperlink functionality.
|
|
|
|
When a word is part of a hyperlink, it becomes clickable and can trigger
|
|
navigation or callbacks. Multiple words can share the same link destination.
|
|
"""
|
|
|
|
def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
|
|
location: str, link_type: Optional['LinkType'] = None,
|
|
callback: Optional[Callable] = None,
|
|
background=None, previous: Optional[Word] = None,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
title: Optional[str] = None):
|
|
"""
|
|
Initialize a linked word.
|
|
|
|
Args:
|
|
text: The text content of the word
|
|
style: The font style
|
|
location: The link target (URL, bookmark, etc.)
|
|
link_type: Type of link (INTERNAL, EXTERNAL, etc.)
|
|
callback: Optional callback for link activation
|
|
background: Optional background color
|
|
previous: Previous word in sequence
|
|
params: Parameters for the link
|
|
title: Tooltip/title for the link
|
|
"""
|
|
# Initialize Word first
|
|
super().__init__(text, style, background, previous)
|
|
|
|
# Store link properties
|
|
self._location = location
|
|
self._link_type = link_type or LinkType.EXTERNAL
|
|
self._callback = callback
|
|
self._params = params or {}
|
|
self._title = title
|
|
|
|
@property
|
|
def location(self) -> str:
|
|
"""Get the link target location"""
|
|
return self._location
|
|
|
|
@property
|
|
def link_type(self):
|
|
"""Get the type of link"""
|
|
return self._link_type
|
|
|
|
@property
|
|
def link_callback(self) -> Optional[Callable]:
|
|
"""Get the link callback (distinct from word callback)"""
|
|
return self._callback
|
|
|
|
@property
|
|
def params(self) -> Dict[str, Any]:
|
|
"""Get the link parameters"""
|
|
return self._params
|
|
|
|
@property
|
|
def link_title(self) -> Optional[str]:
|
|
"""Get the link title/tooltip"""
|
|
return self._title
|
|
|
|
def with_style(self, style: Font) -> 'LinkedWord':
|
|
"""Return a copy carrying a different font, keeping the link intact."""
|
|
return LinkedWord(
|
|
self._text,
|
|
style,
|
|
self._location,
|
|
link_type=self._link_type,
|
|
callback=self._callback,
|
|
background=self._background,
|
|
params=dict(self._params),
|
|
title=self._title,
|
|
)
|
|
|
|
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
|
|
"""
|
|
Execute the link action.
|
|
|
|
Args:
|
|
context: Optional context dict (e.g., {'text': word.text})
|
|
|
|
Returns:
|
|
The result of the link execution
|
|
"""
|
|
# Add word text to context
|
|
full_context = {**self._params, 'text': self._text}
|
|
if context:
|
|
full_context.update(context)
|
|
|
|
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
|
|
return self._callback(self._location, **full_context)
|
|
else:
|
|
# For INTERNAL and EXTERNAL links, return the location
|
|
return self._location
|
|
|
|
|
|
class LineBreak(Hierarchical):
|
|
"""
|
|
A line break element that forces a new line within text content.
|
|
While this is an inline element that can occur within paragraphs,
|
|
it has block-like properties for consistency with the abstract model.
|
|
|
|
Uses Hierarchical mixin for parent-child relationship management.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize a line break element."""
|
|
super().__init__()
|
|
# Import here to avoid circular imports
|
|
from .block import BlockType
|
|
self._block_type = BlockType.LINE_BREAK
|
|
|
|
@property
|
|
def block_type(self):
|
|
"""Get the block type for this line break"""
|
|
return self._block_type
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container) -> 'LineBreak':
|
|
"""
|
|
Create a new LineBreak and add it to a container.
|
|
|
|
Args:
|
|
container: The container to add the line break to
|
|
|
|
Returns:
|
|
The newly created LineBreak object
|
|
"""
|
|
# Create the new line break
|
|
line_break = cls()
|
|
|
|
# Add the line break to the container if it has an appropriate method
|
|
if hasattr(container, 'add_line_break'):
|
|
container.add_line_break(line_break)
|
|
elif hasattr(container, 'add_element'):
|
|
container.add_element(line_break)
|
|
elif hasattr(container, 'add_word'):
|
|
# Some containers might treat line breaks like words
|
|
container.add_word(line_break)
|
|
else:
|
|
# Set parent relationship manually
|
|
line_break.parent = container
|
|
|
|
return line_break
|