Coverage for pyWebLayout/abstract/document.py: 78%

194 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1from __future__ import annotations 

2from typing import List, Dict, Optional, Tuple, Union, Any 

3from enum import Enum 

4from .block import Block, BlockType, Heading, HeadingLevel, Paragraph 

5from ..style import Font, FontWeight, FontStyle, TextDecoration 

6from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize 

7from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver 

8from ..core import FontRegistry, MetadataContainer 

9 

10 

11class MetadataType(Enum): 

12 """Types of metadata that can be associated with a document""" 

13 TITLE = 1 

14 AUTHOR = 2 

15 DESCRIPTION = 3 

16 KEYWORDS = 4 

17 LANGUAGE = 5 

18 PUBLICATION_DATE = 6 

19 MODIFIED_DATE = 7 

20 PUBLISHER = 8 

21 IDENTIFIER = 9 

22 COVER_IMAGE = 10 

23 CUSTOM = 100 

24 

25 

26class Document(FontRegistry, MetadataContainer): 

27 """ 

28 Abstract representation of a complete document like an HTML page or an ebook. 

29 This class manages the logical structure of the document without rendering concerns. 

30 

31 Uses FontRegistry mixin for font caching. 

32 Uses MetadataContainer mixin for metadata management. 

33 """ 

34 

35 def __init__( 

36 self, 

37 title: Optional[str] = None, 

38 language: str = "en-US", 

39 default_style=None): 

40 """ 

41 Initialize a new document. 

42 

43 Args: 

44 title: The document title 

45 language: The document language code 

46 default_style: Optional default style for child blocks 

47 """ 

48 super().__init__() 

49 self._blocks: List[Block] = [] 

50 self._anchors: Dict[str, Block] = {} # Named anchors for navigation 

51 self._resources: Dict[str, Any] = {} # External resources like images 

52 self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets 

53 self._scripts: List[str] = [] # JavaScript code 

54 

55 # Style management with new abstract/concrete system 

56 self._abstract_style_registry = AbstractStyleRegistry() 

57 self._rendering_context = RenderingContext(default_language=language) 

58 self._style_resolver = StyleResolver(self._rendering_context) 

59 self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver) 

60 

61 # Set default style 

62 if default_style is None: 62 ↛ 65line 62 didn't jump to line 65 because the condition on line 62 was always true

63 # Create a default abstract style 

64 default_style = self._abstract_style_registry.default_style 

65 elif isinstance(default_style, Font): 

66 # Convert Font to AbstractStyle for backward compatibility 

67 default_style = AbstractStyle( 

68 font_family=FontFamily.SERIF, # Default assumption 

69 font_size=default_style.font_size, 

70 color=default_style.colour, 

71 language=default_style.language 

72 ) 

73 style_id, default_style = self._abstract_style_registry.get_or_create_style( 

74 default_style) 

75 self._default_style = default_style 

76 

77 # Set basic metadata 

78 if title: 

79 self.set_metadata(MetadataType.TITLE, title) 

80 self.set_metadata(MetadataType.LANGUAGE, language) 

81 

82 @property 

83 def blocks(self) -> List[Block]: 

84 """Get the top-level blocks in this document""" 

85 return self._blocks 

86 

87 @property 

88 def default_style(self): 

89 """Get the default style for this document""" 

90 return self._default_style 

91 

92 @default_style.setter 

93 def default_style(self, style): 

94 """Set the default style for this document""" 

95 self._default_style = style 

96 

97 def add_block(self, block: Block): 

98 """ 

99 Add a block to this document. 

100 

101 Args: 

102 block: The block to add 

103 """ 

104 self._blocks.append(block) 

105 

106 def create_paragraph(self, style=None) -> Paragraph: 

107 """ 

108 Create a new paragraph and add it to this document. 

109 

110 Args: 

111 style: Optional style override. If None, inherits from document 

112 

113 Returns: 

114 The newly created Paragraph object 

115 """ 

116 if style is None: 

117 style = self._default_style 

118 paragraph = Paragraph(style) 

119 self.add_block(paragraph) 

120 return paragraph 

121 

122 def create_heading( 

123 self, 

124 level: HeadingLevel = HeadingLevel.H1, 

125 style=None) -> Heading: 

126 """ 

127 Create a new heading and add it to this document. 

128 

129 Args: 

130 level: The heading level 

131 style: Optional style override. If None, inherits from document 

132 

133 Returns: 

134 The newly created Heading object 

135 """ 

136 if style is None: 

137 style = self._default_style 

138 heading = Heading(level, style) 

139 self.add_block(heading) 

140 return heading 

141 

142 def create_chapter( 

143 self, 

144 title: Optional[str] = None, 

145 level: int = 1, 

146 style=None) -> 'Chapter': 

147 """ 

148 Create a new chapter with inherited style. 

149 

150 Args: 

151 title: The chapter title 

152 level: The chapter level 

153 style: Optional style override. If None, inherits from document 

154 

155 Returns: 

156 The newly created Chapter object 

157 """ 

158 if style is None: 

159 style = self._default_style 

160 return Chapter(title, level, style) 

161 

162 # set_metadata() and get_metadata() are provided by MetadataContainer mixin 

163 

164 def add_anchor(self, name: str, target: Block): 

165 """ 

166 Add a named anchor to this document. 

167 

168 Args: 

169 name: The anchor name 

170 target: The target block 

171 """ 

172 self._anchors[name] = target 

173 

174 def get_anchor(self, name: str) -> Optional[Block]: 

175 """ 

176 Get a named anchor from this document. 

177 

178 Args: 

179 name: The anchor name 

180 

181 Returns: 

182 The target block, or None if not found 

183 """ 

184 return self._anchors.get(name) 

185 

186 def add_resource(self, name: str, resource: Any): 

187 """ 

188 Add a resource to this document. 

189 

190 Args: 

191 name: The resource name 

192 resource: The resource data 

193 """ 

194 self._resources[name] = resource 

195 

196 def get_resource(self, name: str) -> Optional[Any]: 

197 """ 

198 Get a resource from this document. 

199 

200 Args: 

201 name: The resource name 

202 

203 Returns: 

204 The resource data, or None if not found 

205 """ 

206 return self._resources.get(name) 

207 

208 def add_stylesheet(self, stylesheet: Dict[str, Any]): 

209 """ 

210 Add a stylesheet to this document. 

211 

212 Args: 

213 stylesheet: The stylesheet data 

214 """ 

215 self._stylesheets.append(stylesheet) 

216 

217 def add_script(self, script: str): 

218 """ 

219 Add a script to this document. 

220 

221 Args: 

222 script: The script code 

223 """ 

224 self._scripts.append(script) 

225 

226 def get_title(self) -> Optional[str]: 

227 """ 

228 Get the document title. 

229 

230 Returns: 

231 The document title, or None if not set 

232 """ 

233 return self.get_metadata(MetadataType.TITLE) 

234 

235 def set_title(self, title: str): 

236 """ 

237 Set the document title. 

238 

239 Args: 

240 title: The document title 

241 """ 

242 self.set_metadata(MetadataType.TITLE, title) 

243 

244 @property 

245 def title(self) -> Optional[str]: 

246 """ 

247 Get the document title as a property. 

248 

249 Returns: 

250 The document title, or None if not set 

251 """ 

252 return self.get_title() 

253 

254 @title.setter 

255 def title(self, title: str): 

256 """ 

257 Set the document title as a property. 

258 

259 Args: 

260 title: The document title 

261 """ 

262 self.set_title(title) 

263 

264 def find_blocks_by_type(self, block_type: BlockType) -> List[Block]: 

265 """ 

266 Find all blocks of a specific type. 

267 

268 Args: 

269 block_type: The type of blocks to find 

270 

271 Returns: 

272 A list of matching blocks 

273 """ 

274 result = [] 

275 

276 def _find_recursive(blocks: List[Block]): 

277 for block in blocks: 

278 if block.block_type == block_type: 

279 result.append(block) 

280 

281 # Check for child blocks based on block type 

282 if hasattr(block, '_blocks'): 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 _find_recursive(block._blocks) 

284 elif hasattr(block, '_items') and isinstance(block._items, list): 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 _find_recursive(block._items) 

286 

287 _find_recursive(self._blocks) 

288 return result 

289 

290 def find_headings(self) -> List[Heading]: 

291 """ 

292 Find all headings in the document. 

293 

294 Returns: 

295 A list of heading blocks 

296 """ 

297 blocks = self.find_blocks_by_type(BlockType.HEADING) 

298 return [block for block in blocks if isinstance(block, Heading)] 

299 

300 def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]: 

301 """ 

302 Generate a table of contents from headings. 

303 

304 Returns: 

305 A list of tuples containing (level, title, heading_block) 

306 """ 

307 headings = self.find_headings() 

308 

309 toc = [] 

310 for heading in headings: 

311 # Extract text from the heading 

312 title = "" 

313 for _, word in heading.words_iter(): 

314 title += word.text + " " 

315 title = title.strip() 

316 

317 # Add to TOC 

318 level = heading.level.value # Get numeric value from HeadingLevel enum 

319 toc.append((level, title, heading)) 

320 

321 return toc 

322 

323 def get_or_create_style(self, 

324 font_family: FontFamily = FontFamily.SERIF, 

325 font_size: Union[FontSize, int] = FontSize.MEDIUM, 

326 font_weight: FontWeight = FontWeight.NORMAL, 

327 font_style: FontStyle = FontStyle.NORMAL, 

328 text_decoration: TextDecoration = TextDecoration.NONE, 

329 color: Union[str, Tuple[int, int, int]] = "black", 

330 background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None, 

331 language: str = "en-US", 

332 **kwargs) -> Tuple[str, AbstractStyle]: 

333 """ 

334 Get or create an abstract style with the specified properties. 

335 

336 Args: 

337 font_family: Semantic font family 

338 font_size: Font size (semantic or numeric) 

339 font_weight: Font weight 

340 font_style: Font style 

341 text_decoration: Text decoration 

342 color: Text color (name or RGB tuple) 

343 background_color: Background color 

344 language: Language code 

345 **kwargs: Additional style properties 

346 

347 Returns: 

348 Tuple of (style_id, AbstractStyle) 

349 """ 

350 abstract_style = AbstractStyle( 

351 font_family=font_family, 

352 font_size=font_size, 

353 font_weight=font_weight, 

354 font_style=font_style, 

355 text_decoration=text_decoration, 

356 color=color, 

357 background_color=background_color, 

358 language=language, 

359 **kwargs 

360 ) 

361 

362 return self._abstract_style_registry.get_or_create_style(abstract_style) 

363 

364 def get_font_for_style(self, abstract_style: AbstractStyle) -> Font: 

365 """ 

366 Get a Font object for an AbstractStyle (for rendering). 

367 

368 Args: 

369 abstract_style: The abstract style to get a font for 

370 

371 Returns: 

372 Font object ready for rendering 

373 """ 

374 return self._concrete_style_registry.get_font(abstract_style) 

375 

376 def update_rendering_context(self, **kwargs): 

377 """ 

378 Update the rendering context (user preferences, device settings, etc.). 

379 

380 Args: 

381 **kwargs: Context properties to update (base_font_size, font_scale_factor, etc.) 

382 """ 

383 self._style_resolver.update_context(**kwargs) 

384 

385 def get_style_registry(self) -> AbstractStyleRegistry: 

386 """Get the abstract style registry for this document.""" 

387 return self._abstract_style_registry 

388 

389 def get_concrete_style_registry(self) -> ConcreteStyleRegistry: 

390 """Get the concrete style registry for this document.""" 

391 return self._concrete_style_registry 

392 

393 # get_or_create_font() is provided by FontRegistry mixin 

394 

395 

396class Chapter(FontRegistry, MetadataContainer): 

397 """ 

398 Represents a chapter or section in a document. 

399 A chapter contains a sequence of blocks and has metadata. 

400 

401 Uses FontRegistry mixin for font caching with parent delegation. 

402 Uses MetadataContainer mixin for metadata management. 

403 """ 

404 

405 def __init__( 

406 self, 

407 title: Optional[str] = None, 

408 level: int = 1, 

409 style=None, 

410 parent=None): 

411 """ 

412 Initialize a new chapter. 

413 

414 Args: 

415 title: The chapter title 

416 level: The chapter level (1 = top level, 2 = subsection, etc.) 

417 style: Optional default style for child blocks 

418 parent: Parent container (e.g., Document or Book) 

419 """ 

420 super().__init__() 

421 self._title = title 

422 self._level = level 

423 self._blocks: List[Block] = [] 

424 self._style = style 

425 self._parent = parent 

426 

427 @property 

428 def title(self) -> Optional[str]: 

429 """Get the chapter title""" 

430 return self._title 

431 

432 @title.setter 

433 def title(self, title: str): 

434 """Set the chapter title""" 

435 self._title = title 

436 

437 @property 

438 def level(self) -> int: 

439 """Get the chapter level""" 

440 return self._level 

441 

442 @property 

443 def blocks(self) -> List[Block]: 

444 """Get the blocks in this chapter""" 

445 return self._blocks 

446 

447 @property 

448 def style(self): 

449 """Get the default style for this chapter""" 

450 return self._style 

451 

452 @style.setter 

453 def style(self, style): 

454 """Set the default style for this chapter""" 

455 self._style = style 

456 

457 def add_block(self, block: Block): 

458 """ 

459 Add a block to this chapter. 

460 

461 Args: 

462 block: The block to add 

463 """ 

464 self._blocks.append(block) 

465 

466 def create_paragraph(self, style=None) -> Paragraph: 

467 """ 

468 Create a new paragraph and add it to this chapter. 

469 

470 Args: 

471 style: Optional style override. If None, inherits from chapter 

472 

473 Returns: 

474 The newly created Paragraph object 

475 """ 

476 if style is None: 

477 style = self._style 

478 paragraph = Paragraph(style) 

479 self.add_block(paragraph) 

480 return paragraph 

481 

482 def create_heading( 

483 self, 

484 level: HeadingLevel = HeadingLevel.H1, 

485 style=None) -> Heading: 

486 """ 

487 Create a new heading and add it to this chapter. 

488 

489 Args: 

490 level: The heading level 

491 style: Optional style override. If None, inherits from chapter 

492 

493 Returns: 

494 The newly created Heading object 

495 """ 

496 if style is None: 

497 style = self._style 

498 heading = Heading(level, style) 

499 self.add_block(heading) 

500 return heading 

501 

502 # set_metadata() and get_metadata() are provided by MetadataContainer mixin 

503 # get_or_create_font() is provided by FontRegistry mixin 

504 

505 

506class Book(Document): 

507 """ 

508 Abstract representation of an ebook. 

509 A book is a document that contains chapters. 

510 """ 

511 

512 def __init__(self, title: Optional[str] = None, author: Optional[str] = None, 

513 language: str = "en-US", default_style=None): 

514 """ 

515 Initialize a new book. 

516 

517 Args: 

518 title: The book title 

519 author: The book author 

520 language: The book language code 

521 default_style: Optional default style for child chapters and blocks 

522 """ 

523 super().__init__(title, language, default_style) 

524 self._chapters: List[Chapter] = [] 

525 

526 if author: 

527 self.set_metadata(MetadataType.AUTHOR, author) 

528 

529 @property 

530 def chapters(self) -> List[Chapter]: 

531 """Get the chapters in this book""" 

532 return self._chapters 

533 

534 def add_chapter(self, chapter: Chapter): 

535 """ 

536 Add a chapter to this book. 

537 

538 Args: 

539 chapter: The chapter to add 

540 """ 

541 self._chapters.append(chapter) 

542 

543 def create_chapter( 

544 self, 

545 title: Optional[str] = None, 

546 level: int = 1, 

547 style=None) -> Chapter: 

548 """ 

549 Create and add a new chapter with inherited style. 

550 

551 Args: 

552 title: The chapter title 

553 level: The chapter level 

554 style: Optional style override. If None, inherits from book 

555 

556 Returns: 

557 The new chapter 

558 """ 

559 if style is None: 559 ↛ 561line 559 didn't jump to line 561 because the condition on line 559 was always true

560 style = self._default_style 

561 chapter = Chapter(title, level, style) 

562 self.add_chapter(chapter) 

563 return chapter 

564 

565 def get_author(self) -> Optional[str]: 

566 """ 

567 Get the book author. 

568 

569 Returns: 

570 The book author, or None if not set 

571 """ 

572 return self.get_metadata(MetadataType.AUTHOR) 

573 

574 def set_author(self, author: str): 

575 """ 

576 Set the book author. 

577 

578 Args: 

579 author: The book author 

580 """ 

581 self.set_metadata(MetadataType.AUTHOR, author) 

582 

583 def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]: 

584 """ 

585 Generate a table of contents from chapters. 

586 

587 Returns: 

588 A list of tuples containing (level, title, chapter) 

589 """ 

590 toc = [] 

591 for chapter in self._chapters: 

592 if chapter.title: 

593 toc.append((chapter.level, chapter.title, chapter)) 

594 

595 return toc