Coverage for pyWebLayout/abstract/inline.py: 99%

157 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +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 

6import pyphen 

7 

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

9from pyWebLayout.abstract.functional import LinkType 

10 

11 

12class Word: 

13 """ 

14 An abstract representation of a word in a document. Words can be split across 

15 lines or pages during rendering. This class manages the logical representation 

16 of a word without any rendering specifics. 

17 

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

19 """ 

20 

21 def __init__(self, 

22 text: str, 

23 style: Union[Font, 

24 AbstractStyle], 

25 background=None, 

26 previous: Union['Word', 

27 None] = None): 

28 """ 

29 Initialize a new Word. 

30 

31 Args: 

32 text: The text content of the word 

33 style: AbstractStyle object or Font object (for backward compatibility) 

34 background: Optional background color override 

35 previous: Reference to the previous word in sequence 

36 """ 

37 self._text = text 

38 self._style = style 

39 self._background = background 

40 self._previous = previous 

41 self._next = None 

42 self.concrete = None 

43 if previous: 

44 previous.add_next(self) 

45 

46 @classmethod 

47 def create_and_add_to(cls, text: str, container, style: Optional[Font] = None, 

48 background=None) -> 'Word': 

49 """ 

50 Create a new Word and add it to a container, inheriting style and language 

51 from the container if not explicitly provided. 

52 

53 This method provides a convenient way to create words that automatically 

54 inherit styling from their container (Paragraph, FormattedSpan, etc.) 

55 without copying string values - using object references instead. 

56 

57 Args: 

58 text: The text content of the word 

59 container: The container to add the word to (must have add_word method and style property) 

60 style: Optional Font style override. If None, inherits from container 

61 background: Optional background color override. If None, inherits from container 

62 

63 Returns: 

64 The newly created Word object 

65 

66 Raises: 

67 AttributeError: If the container doesn't have the required add_word method or style property 

68 """ 

69 # Inherit style from container if not provided 

70 if style is None: 

71 if hasattr(container, 'style'): 

72 style = container.style 

73 else: 

74 raise AttributeError( 

75 f"Container {type(container).__name__} must have a 'style' property") 

76 

77 # Inherit background from container if not provided 

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

79 background = container.background 

80 

81 # Determine the previous word for proper linking 

82 previous = None 

83 if hasattr(container, '_words') and container._words: 

84 # Container has a _words list (like FormattedSpan) 

85 previous = container._words[-1] 

86 elif hasattr(container, 'words'): 

87 # Container has a words() method (like Paragraph) 

88 try: 

89 # Get the last word from the iterator 

90 for _, word in container.words(): 

91 previous = word 

92 except (StopIteration, TypeError): 

93 previous = None 

94 

95 # Create the new word 

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

97 

98 # Link the previous word to this new one 

99 if previous: 

100 previous.add_next(word) 

101 

102 # Add the word to the container 

103 if hasattr(container, 'add_word'): 

104 # Check if add_word expects a Word object or text string 

105 import inspect 

106 sig = inspect.signature(container.add_word) 

107 params = list(sig.parameters.keys()) 

108 

109 if len(params) > 0: 

110 # Peek at the parameter name to guess the expected type 

111 param_name = params[0] 

112 if param_name in ['word', 'word_obj', 'word_object']: 

113 # Expects a Word object 

114 container.add_word(word) 

115 else: 

116 # Might expect text string (like FormattedSpan.add_word) 

117 # In this case, we can't use the container's add_word as it would create 

118 # a duplicate Word. We need to add directly to the container's word 

119 # list. 

120 if hasattr(container, '_words'): 

121 container._words.append(word) 

122 else: 

123 # Fallback: try calling with the Word object anyway 

124 container.add_word(word) 

125 else: 

126 # No parameters, shouldn't happen with add_word methods 

127 container.add_word(word) 

128 else: 

129 raise AttributeError( 

130 f"Container {type(container).__name__} must have an 'add_word' method") 

131 

132 return word 

133 

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

135 self.concrete = text 

136 

137 @property 

138 def text(self) -> str: 

139 """Get the text content of the word""" 

140 return self._text 

141 

142 @property 

143 def style(self) -> Font: 

144 """Get the font style of the word""" 

145 return self._style 

146 

147 @property 

148 def background(self): 

149 """Get the background color of the word""" 

150 return self._background 

151 

152 @property 

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

154 """Get the previous word in sequence""" 

155 return self._previous 

156 

157 @property 

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

159 """Get the next word in sequence""" 

160 return self._next 

161 

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

163 """Set the next word in sequence""" 

164 self._next = next_word 

165 

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

167 """ 

168 Hyphenate the word and store the parts. 

169 

170 Args: 

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

172 

173 Returns: 

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

175 """ 

176 

177 dic = pyphen.Pyphen(lang=self._style.language) 

178 return list(dic.iterate(self._text)) 

179 

180 

181... 

182 

183 

184class FormattedSpan: 

185 """ 

186 A run of words with consistent formatting. 

187 This represents a sequence of words that share the same style attributes. 

188 """ 

189 

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

191 """ 

192 Initialize a new formatted span. 

193 

194 Args: 

195 style: Font style information for all words in this span 

196 background: Optional background color override 

197 """ 

198 self._style = style 

199 self._background = background if background else style.background 

200 self._words: List[Word] = [] 

201 

202 @classmethod 

203 def create_and_add_to( 

204 cls, 

205 container, 

206 style: Optional[Font] = None, 

207 background=None) -> 'FormattedSpan': 

208 """ 

209 Create a new FormattedSpan and add it to a container, inheriting style from 

210 the container if not explicitly provided. 

211 

212 Args: 

213 container: The container to add the span to (must have add_span method and style property) 

214 style: Optional Font style override. If None, inherits from container 

215 background: Optional background color override 

216 

217 Returns: 

218 The newly created FormattedSpan object 

219 

220 Raises: 

221 AttributeError: If the container doesn't have the required add_span method or style property 

222 """ 

223 # Inherit style from container if not provided 

224 if style is None: 

225 if hasattr(container, 'style'): 

226 style = container.style 

227 else: 

228 raise AttributeError( 

229 f"Container {type(container).__name__} must have a 'style' property") 

230 

231 # Inherit background from container if not provided 

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

233 background = container.background 

234 

235 # Create the new span 

236 span = cls(style, background) 

237 

238 # Add the span to the container 

239 if hasattr(container, 'add_span'): 

240 container.add_span(span) 

241 else: 

242 raise AttributeError( 

243 f"Container {type(container).__name__} must have an 'add_span' method") 

244 

245 return span 

246 

247 @property 

248 def style(self) -> Font: 

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

250 return self._style 

251 

252 @property 

253 def background(self): 

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

255 return self._background 

256 

257 @property 

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

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

260 return self._words 

261 

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

263 """ 

264 Create and add a new word to this span. 

265 

266 Args: 

267 text: The text content of the word 

268 

269 Returns: 

270 The newly created Word object 

271 """ 

272 # Get the previous word if any 

273 previous = self._words[-1] if self._words else None 

274 

275 # Create the new word 

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

277 

278 # Link the previous word to this new one 

279 if previous: 

280 previous.add_next(word) 

281 

282 # Add the word to our list 

283 self._words.append(word) 

284 

285 return word 

286 

287 

288class LinkedWord(Word): 

289 """ 

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

291 

292 When a word is part of a hyperlink, it becomes clickable and can trigger 

293 navigation or callbacks. Multiple words can share the same link destination. 

294 """ 

295 

296 def __init__(self, text: str, style: Union[Font, 'AbstractStyle'], 

297 location: str, link_type: Optional['LinkType'] = None, 

298 callback: Optional[Callable] = None, 

299 background=None, previous: Optional[Word] = None, 

300 params: Optional[Dict[str, Any]] = None, 

301 title: Optional[str] = None): 

302 """ 

303 Initialize a linked word. 

304 

305 Args: 

306 text: The text content of the word 

307 style: The font style 

308 location: The link target (URL, bookmark, etc.) 

309 link_type: Type of link (INTERNAL, EXTERNAL, etc.) 

310 callback: Optional callback for link activation 

311 background: Optional background color 

312 previous: Previous word in sequence 

313 params: Parameters for the link 

314 title: Tooltip/title for the link 

315 """ 

316 # Initialize Word first 

317 super().__init__(text, style, background, previous) 

318 

319 # Store link properties 

320 self._location = location 

321 self._link_type = link_type or LinkType.EXTERNAL 

322 self._callback = callback 

323 self._params = params or {} 

324 self._title = title 

325 

326 @property 

327 def location(self) -> str: 

328 """Get the link target location""" 

329 return self._location 

330 

331 @property 

332 def link_type(self): 

333 """Get the type of link""" 

334 return self._link_type 

335 

336 @property 

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

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

339 return self._callback 

340 

341 @property 

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

343 """Get the link parameters""" 

344 return self._params 

345 

346 @property 

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

348 """Get the link title/tooltip""" 

349 return self._title 

350 

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

352 """ 

353 Execute the link action. 

354 

355 Args: 

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

357 

358 Returns: 

359 The result of the link execution 

360 """ 

361 # Add word text to context 

362 full_context = {**self._params, 'text': self._text} 

363 if context: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 full_context.update(context) 

365 

366 if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback: 

367 return self._callback(self._location, **full_context) 

368 else: 

369 # For INTERNAL and EXTERNAL links, return the location 

370 return self._location 

371 

372 

373class LineBreak(Hierarchical): 

374 """ 

375 A line break element that forces a new line within text content. 

376 While this is an inline element that can occur within paragraphs, 

377 it has block-like properties for consistency with the abstract model. 

378 

379 Uses Hierarchical mixin for parent-child relationship management. 

380 """ 

381 

382 def __init__(self): 

383 """Initialize a line break element.""" 

384 super().__init__() 

385 # Import here to avoid circular imports 

386 from .block import BlockType 

387 self._block_type = BlockType.LINE_BREAK 

388 

389 @property 

390 def block_type(self): 

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

392 return self._block_type 

393 

394 @classmethod 

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

396 """ 

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

398 

399 Args: 

400 container: The container to add the line break to 

401 

402 Returns: 

403 The newly created LineBreak object 

404 """ 

405 # Create the new line break 

406 line_break = cls() 

407 

408 # Add the line break to the container if it has an appropriate method 

409 if hasattr(container, 'add_line_break'): 

410 container.add_line_break(line_break) 

411 elif hasattr(container, 'add_element'): 

412 container.add_element(line_break) 

413 elif hasattr(container, 'add_word'): 

414 # Some containers might treat line breaks like words 

415 container.add_word(line_break) 

416 else: 

417 # Set parent relationship manually 

418 line_break.parent = container 

419 

420 return line_break