Files
pyWebLayout/pyWebLayout/abstract/inline.py
T
dtourolleandClaude Opus 5 0bb34a4a32
Python CI / test (3.10) (push) Successful in 1m4s
Python CI / test (3.11) (push) Successful in 1m8s
Python CI / test (3.12) (push) Successful in 1m5s
Python CI / test (3.13) (push) Successful in 1m24s
perf(layout): cut page layout time by ~40%
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>
2026-08-08 14:27:20 +02:00

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