Coverage for pyWebLayout/abstract/inline.py: 99%
164 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1from __future__ import annotations
2from pyWebLayout.core import Hierarchical
3from pyWebLayout.style import Font
4from pyWebLayout.style.abstract_style import AbstractStyle
5from typing import Tuple, Union, List, Optional, Dict, Any, Callable
6from functools import lru_cache
7import pyphen
9# Import LinkType for type hints (imported at module level to avoid F821 linting error)
10from pyWebLayout.abstract.functional import LinkType
13@lru_cache(maxsize=16)
14def _hyphen_dict(language: Optional[str]) -> pyphen.Pyphen:
15 """
16 The pyphen dictionary for a language, reused across words.
18 Pyphen caches the parsed dictionary file itself, but rebuilding the wrapper
19 per word still costs about 40% of a hyphenation call, and hyphenation is
20 attempted for every word that overflows its line.
21 """
22 return pyphen.Pyphen(lang=language)
25class Word:
26 """
27 An abstract representation of a word in a document. Words can be split across
28 lines or pages during rendering. This class manages the logical representation
29 of a word without any rendering specifics.
31 Now uses AbstractStyle objects for memory efficiency and proper style management.
32 """
34 def __init__(self,
35 text: str,
36 style: Union[Font,
37 AbstractStyle],
38 background=None,
39 previous: Union['Word',
40 None] = None):
41 """
42 Initialize a new Word.
44 Args:
45 text: The text content of the word
46 style: AbstractStyle object or Font object (for backward compatibility)
47 background: Optional background color override
48 previous: Reference to the previous word in sequence
49 """
50 self._text = text
51 self._style = style
52 self._background = background
53 self._previous = previous
54 self._next = None
55 self.concrete = None
56 if previous:
57 previous.add_next(self)
59 @classmethod
60 def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
61 background=None) -> 'Word':
62 """
63 Create a new Word and add it to a container, inheriting style and language
64 from the container if not explicitly provided.
66 This method provides a convenient way to create words that automatically
67 inherit styling from their container (Paragraph, FormattedSpan, etc.)
68 without copying string values - using object references instead.
70 Args:
71 text: The text content of the word
72 container: The container to add the word to (must have add_word method and style property)
73 style: Optional Font style override. If None, inherits from container
74 background: Optional background color override. If None, inherits from container
76 Returns:
77 The newly created Word object
79 Raises:
80 AttributeError: If the container doesn't have the required add_word method or style property
81 """
82 # Inherit style from container if not provided
83 if style is None:
84 if hasattr(container, 'style'):
85 style = container.style
86 else:
87 raise AttributeError(
88 f"Container {type(container).__name__} must have a 'style' property")
90 # Inherit background from container if not provided
91 if background is None and hasattr(container, 'background'):
92 background = container.background
94 # Determine the previous word for proper linking
95 previous = None
96 if hasattr(container, '_words') and container._words:
97 # Container has a _words list (like FormattedSpan)
98 previous = container._words[-1]
99 elif hasattr(container, 'words'):
100 # Container has a words() method (like Paragraph)
101 try:
102 # Get the last word from the iterator
103 for _, word in container.words():
104 previous = word
105 except (StopIteration, TypeError):
106 previous = None
108 # Create the new word
109 word = cls(text, style, background, previous)
111 # Link the previous word to this new one
112 if previous:
113 previous.add_next(word)
115 # Add the word to the container
116 if hasattr(container, 'add_word'):
117 # Check if add_word expects a Word object or text string
118 import inspect
119 sig = inspect.signature(container.add_word)
120 params = list(sig.parameters.keys())
122 if len(params) > 0:
123 # Peek at the parameter name to guess the expected type
124 param_name = params[0]
125 if param_name in ['word', 'word_obj', 'word_object']:
126 # Expects a Word object
127 container.add_word(word)
128 else:
129 # Might expect text string (like FormattedSpan.add_word)
130 # In this case, we can't use the container's add_word as it would create
131 # a duplicate Word. We need to add directly to the container's word
132 # list.
133 if hasattr(container, '_words'):
134 container._words.append(word)
135 else:
136 # Fallback: try calling with the Word object anyway
137 container.add_word(word)
138 else:
139 # No parameters, shouldn't happen with add_word methods
140 container.add_word(word)
141 else:
142 raise AttributeError(
143 f"Container {type(container).__name__} must have an 'add_word' method")
145 return word
147 def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
148 self.concrete = text
150 @property
151 def text(self) -> str:
152 """Get the text content of the word"""
153 return self._text
155 @property
156 def style(self) -> Font:
157 """Get the font style of the word"""
158 return self._style
160 @property
161 def background(self):
162 """Get the background color of the word"""
163 return self._background
165 @property
166 def previous(self) -> Union['Word', None]:
167 """Get the previous word in sequence"""
168 return self._previous
170 @property
171 def next(self) -> Union['Word', None]:
172 """Get the next word in sequence"""
173 return self._next
175 def add_next(self, next_word: 'Word'):
176 """Set the next word in sequence"""
177 self._next = next_word
179 def with_style(self, style: Font) -> 'Word':
180 """
181 Return a copy of this word carrying a different font.
183 Subclasses that hold extra state must override this, or that state is
184 silently dropped when a caller restyles the word. Sequence links
185 (previous/next) are deliberately not copied: the copy belongs to a
186 different word chain, which the new container rebuilds as words are
187 added to it.
188 """
189 return Word(self._text, style, self._background)
191 def possible_hyphenation(self, language: str = None) -> bool:
192 """
193 Hyphenate the word and store the parts.
195 Args:
196 language: Language code for hyphenation. If None, uses the style's language.
198 Returns:
199 bool: True if the word was hyphenated, False otherwise.
200 """
202 return list(_hyphen_dict(self._style.language).iterate(self._text))
205...
208class FormattedSpan:
209 """
210 A run of words with consistent formatting.
211 This represents a sequence of words that share the same style attributes.
212 """
214 def __init__(self, style: Font, background=None):
215 """
216 Initialize a new formatted span.
218 Args:
219 style: Font style information for all words in this span
220 background: Optional background color override
221 """
222 self._style = style
223 self._background = background if background else style.background
224 self._words: List[Word] = []
226 @classmethod
227 def create_and_add_to(
228 cls,
229 container,
230 style: Optional[Font] = None,
231 background=None) -> 'FormattedSpan':
232 """
233 Create a new FormattedSpan and add it to a container, inheriting style from
234 the container if not explicitly provided.
236 Args:
237 container: The container to add the span to (must have add_span method and style property)
238 style: Optional Font style override. If None, inherits from container
239 background: Optional background color override
241 Returns:
242 The newly created FormattedSpan object
244 Raises:
245 AttributeError: If the container doesn't have the required add_span method or style property
246 """
247 # Inherit style from container if not provided
248 if style is None:
249 if hasattr(container, 'style'):
250 style = container.style
251 else:
252 raise AttributeError(
253 f"Container {type(container).__name__} must have a 'style' property")
255 # Inherit background from container if not provided
256 if background is None and hasattr(container, 'background'):
257 background = container.background
259 # Create the new span
260 span = cls(style, background)
262 # Add the span to the container
263 if hasattr(container, 'add_span'):
264 container.add_span(span)
265 else:
266 raise AttributeError(
267 f"Container {type(container).__name__} must have an 'add_span' method")
269 return span
271 @property
272 def style(self) -> Font:
273 """Get the font style of this span"""
274 return self._style
276 @property
277 def background(self):
278 """Get the background color of this span"""
279 return self._background
281 @property
282 def words(self) -> List[Word]:
283 """Get the list of words in this span"""
284 return self._words
286 def add_word(self, text: str) -> Word:
287 """
288 Create and add a new word to this span.
290 Args:
291 text: The text content of the word
293 Returns:
294 The newly created Word object
295 """
296 # Get the previous word if any
297 previous = self._words[-1] if self._words else None
299 # Create the new word
300 word = Word(text, self._style, self._background, previous)
302 # Link the previous word to this new one
303 if previous:
304 previous.add_next(word)
306 # Add the word to our list
307 self._words.append(word)
309 return word
312class LinkedWord(Word):
313 """
314 A Word that is also a Link - combines text content with hyperlink functionality.
316 When a word is part of a hyperlink, it becomes clickable and can trigger
317 navigation or callbacks. Multiple words can share the same link destination.
318 """
320 def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
321 location: str, link_type: Optional['LinkType'] = None,
322 callback: Optional[Callable] = None,
323 background=None, previous: Optional[Word] = None,
324 params: Optional[Dict[str, Any]] = None,
325 title: Optional[str] = None):
326 """
327 Initialize a linked word.
329 Args:
330 text: The text content of the word
331 style: The font style
332 location: The link target (URL, bookmark, etc.)
333 link_type: Type of link (INTERNAL, EXTERNAL, etc.)
334 callback: Optional callback for link activation
335 background: Optional background color
336 previous: Previous word in sequence
337 params: Parameters for the link
338 title: Tooltip/title for the link
339 """
340 # Initialize Word first
341 super().__init__(text, style, background, previous)
343 # Store link properties
344 self._location = location
345 self._link_type = link_type or LinkType.EXTERNAL
346 self._callback = callback
347 self._params = params or {}
348 self._title = title
350 @property
351 def location(self) -> str:
352 """Get the link target location"""
353 return self._location
355 @property
356 def link_type(self):
357 """Get the type of link"""
358 return self._link_type
360 @property
361 def link_callback(self) -> Optional[Callable]:
362 """Get the link callback (distinct from word callback)"""
363 return self._callback
365 @property
366 def params(self) -> Dict[str, Any]:
367 """Get the link parameters"""
368 return self._params
370 @property
371 def link_title(self) -> Optional[str]:
372 """Get the link title/tooltip"""
373 return self._title
375 def with_style(self, style: Font) -> 'LinkedWord':
376 """Return a copy carrying a different font, keeping the link intact."""
377 return LinkedWord(
378 self._text,
379 style,
380 self._location,
381 link_type=self._link_type,
382 callback=self._callback,
383 background=self._background,
384 params=dict(self._params),
385 title=self._title,
386 )
388 def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
389 """
390 Execute the link action.
392 Args:
393 context: Optional context dict (e.g., {'text': word.text})
395 Returns:
396 The result of the link execution
397 """
398 # Add word text to context
399 full_context = {**self._params, 'text': self._text}
400 if context: 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true
401 full_context.update(context)
403 if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
404 return self._callback(self._location, **full_context)
405 else:
406 # For INTERNAL and EXTERNAL links, return the location
407 return self._location
410class LineBreak(Hierarchical):
411 """
412 A line break element that forces a new line within text content.
413 While this is an inline element that can occur within paragraphs,
414 it has block-like properties for consistency with the abstract model.
416 Uses Hierarchical mixin for parent-child relationship management.
417 """
419 def __init__(self):
420 """Initialize a line break element."""
421 super().__init__()
422 # Import here to avoid circular imports
423 from .block import BlockType
424 self._block_type = BlockType.LINE_BREAK
426 @property
427 def block_type(self):
428 """Get the block type for this line break"""
429 return self._block_type
431 @classmethod
432 def create_and_add_to(cls, container) -> 'LineBreak':
433 """
434 Create a new LineBreak and add it to a container.
436 Args:
437 container: The container to add the line break to
439 Returns:
440 The newly created LineBreak object
441 """
442 # Create the new line break
443 line_break = cls()
445 # Add the line break to the container if it has an appropriate method
446 if hasattr(container, 'add_line_break'):
447 container.add_line_break(line_break)
448 elif hasattr(container, 'add_element'):
449 container.add_element(line_break)
450 elif hasattr(container, 'add_word'):
451 # Some containers might treat line breaks like words
452 container.add_word(line_break)
453 else:
454 # Set parent relationship manually
455 line_break.parent = container
457 return line_break