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

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 

8 

9# Import LinkType for type hints (imported at module level to avoid F821 linting error) 

10from pyWebLayout.abstract.functional import LinkType 

11 

12 

13@lru_cache(maxsize=16) 

14def _hyphen_dict(language: Optional[str]) -> pyphen.Pyphen: 

15 """ 

16 The pyphen dictionary for a language, reused across words. 

17 

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) 

23 

24 

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. 

30 

31 Now uses AbstractStyle objects for memory efficiency and proper style management. 

32 """ 

33 

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. 

43 

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) 

58 

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. 

65 

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. 

69 

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 

75 

76 Returns: 

77 The newly created Word object 

78 

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") 

89 

90 # Inherit background from container if not provided 

91 if background is None and hasattr(container, 'background'): 

92 background = container.background 

93 

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 

107 

108 # Create the new word 

109 word = cls(text, style, background, previous) 

110 

111 # Link the previous word to this new one 

112 if previous: 

113 previous.add_next(word) 

114 

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()) 

121 

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") 

144 

145 return word 

146 

147 def add_concete(self, text: Union[Any, Tuple[Any, Any]]): 

148 self.concrete = text 

149 

150 @property 

151 def text(self) -> str: 

152 """Get the text content of the word""" 

153 return self._text 

154 

155 @property 

156 def style(self) -> Font: 

157 """Get the font style of the word""" 

158 return self._style 

159 

160 @property 

161 def background(self): 

162 """Get the background color of the word""" 

163 return self._background 

164 

165 @property 

166 def previous(self) -> Union['Word', None]: 

167 """Get the previous word in sequence""" 

168 return self._previous 

169 

170 @property 

171 def next(self) -> Union['Word', None]: 

172 """Get the next word in sequence""" 

173 return self._next 

174 

175 def add_next(self, next_word: 'Word'): 

176 """Set the next word in sequence""" 

177 self._next = next_word 

178 

179 def with_style(self, style: Font) -> 'Word': 

180 """ 

181 Return a copy of this word carrying a different font. 

182 

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) 

190 

191 def possible_hyphenation(self, language: str = None) -> bool: 

192 """ 

193 Hyphenate the word and store the parts. 

194 

195 Args: 

196 language: Language code for hyphenation. If None, uses the style's language. 

197 

198 Returns: 

199 bool: True if the word was hyphenated, False otherwise. 

200 """ 

201 

202 return list(_hyphen_dict(self._style.language).iterate(self._text)) 

203 

204 

205... 

206 

207 

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 """ 

213 

214 def __init__(self, style: Font, background=None): 

215 """ 

216 Initialize a new formatted span. 

217 

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] = [] 

225 

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. 

235 

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 

240 

241 Returns: 

242 The newly created FormattedSpan object 

243 

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") 

254 

255 # Inherit background from container if not provided 

256 if background is None and hasattr(container, 'background'): 

257 background = container.background 

258 

259 # Create the new span 

260 span = cls(style, background) 

261 

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") 

268 

269 return span 

270 

271 @property 

272 def style(self) -> Font: 

273 """Get the font style of this span""" 

274 return self._style 

275 

276 @property 

277 def background(self): 

278 """Get the background color of this span""" 

279 return self._background 

280 

281 @property 

282 def words(self) -> List[Word]: 

283 """Get the list of words in this span""" 

284 return self._words 

285 

286 def add_word(self, text: str) -> Word: 

287 """ 

288 Create and add a new word to this span. 

289 

290 Args: 

291 text: The text content of the word 

292 

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 

298 

299 # Create the new word 

300 word = Word(text, self._style, self._background, previous) 

301 

302 # Link the previous word to this new one 

303 if previous: 

304 previous.add_next(word) 

305 

306 # Add the word to our list 

307 self._words.append(word) 

308 

309 return word 

310 

311 

312class LinkedWord(Word): 

313 """ 

314 A Word that is also a Link - combines text content with hyperlink functionality. 

315 

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 """ 

319 

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. 

328 

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) 

342 

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 

349 

350 @property 

351 def location(self) -> str: 

352 """Get the link target location""" 

353 return self._location 

354 

355 @property 

356 def link_type(self): 

357 """Get the type of link""" 

358 return self._link_type 

359 

360 @property 

361 def link_callback(self) -> Optional[Callable]: 

362 """Get the link callback (distinct from word callback)""" 

363 return self._callback 

364 

365 @property 

366 def params(self) -> Dict[str, Any]: 

367 """Get the link parameters""" 

368 return self._params 

369 

370 @property 

371 def link_title(self) -> Optional[str]: 

372 """Get the link title/tooltip""" 

373 return self._title 

374 

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 ) 

387 

388 def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any: 

389 """ 

390 Execute the link action. 

391 

392 Args: 

393 context: Optional context dict (e.g., {'text': word.text}) 

394 

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) 

402 

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 

408 

409 

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. 

415 

416 Uses Hierarchical mixin for parent-child relationship management. 

417 """ 

418 

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 

425 

426 @property 

427 def block_type(self): 

428 """Get the block type for this line break""" 

429 return self._block_type 

430 

431 @classmethod 

432 def create_and_add_to(cls, container) -> 'LineBreak': 

433 """ 

434 Create a new LineBreak and add it to a container. 

435 

436 Args: 

437 container: The container to add the line break to 

438 

439 Returns: 

440 The newly created LineBreak object 

441 """ 

442 # Create the new line break 

443 line_break = cls() 

444 

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 

456 

457 return line_break