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
« 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
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
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.
31 Uses FontRegistry mixin for font caching.
32 Uses MetadataContainer mixin for metadata management.
33 """
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.
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
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)
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
77 # Set basic metadata
78 if title:
79 self.set_metadata(MetadataType.TITLE, title)
80 self.set_metadata(MetadataType.LANGUAGE, language)
82 @property
83 def blocks(self) -> List[Block]:
84 """Get the top-level blocks in this document"""
85 return self._blocks
87 @property
88 def default_style(self):
89 """Get the default style for this document"""
90 return self._default_style
92 @default_style.setter
93 def default_style(self, style):
94 """Set the default style for this document"""
95 self._default_style = style
97 def add_block(self, block: Block):
98 """
99 Add a block to this document.
101 Args:
102 block: The block to add
103 """
104 self._blocks.append(block)
106 def create_paragraph(self, style=None) -> Paragraph:
107 """
108 Create a new paragraph and add it to this document.
110 Args:
111 style: Optional style override. If None, inherits from document
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
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.
129 Args:
130 level: The heading level
131 style: Optional style override. If None, inherits from document
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
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.
150 Args:
151 title: The chapter title
152 level: The chapter level
153 style: Optional style override. If None, inherits from document
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)
162 # set_metadata() and get_metadata() are provided by MetadataContainer mixin
164 def add_anchor(self, name: str, target: Block):
165 """
166 Add a named anchor to this document.
168 Args:
169 name: The anchor name
170 target: The target block
171 """
172 self._anchors[name] = target
174 def get_anchor(self, name: str) -> Optional[Block]:
175 """
176 Get a named anchor from this document.
178 Args:
179 name: The anchor name
181 Returns:
182 The target block, or None if not found
183 """
184 return self._anchors.get(name)
186 def add_resource(self, name: str, resource: Any):
187 """
188 Add a resource to this document.
190 Args:
191 name: The resource name
192 resource: The resource data
193 """
194 self._resources[name] = resource
196 def get_resource(self, name: str) -> Optional[Any]:
197 """
198 Get a resource from this document.
200 Args:
201 name: The resource name
203 Returns:
204 The resource data, or None if not found
205 """
206 return self._resources.get(name)
208 def add_stylesheet(self, stylesheet: Dict[str, Any]):
209 """
210 Add a stylesheet to this document.
212 Args:
213 stylesheet: The stylesheet data
214 """
215 self._stylesheets.append(stylesheet)
217 def add_script(self, script: str):
218 """
219 Add a script to this document.
221 Args:
222 script: The script code
223 """
224 self._scripts.append(script)
226 def get_title(self) -> Optional[str]:
227 """
228 Get the document title.
230 Returns:
231 The document title, or None if not set
232 """
233 return self.get_metadata(MetadataType.TITLE)
235 def set_title(self, title: str):
236 """
237 Set the document title.
239 Args:
240 title: The document title
241 """
242 self.set_metadata(MetadataType.TITLE, title)
244 @property
245 def title(self) -> Optional[str]:
246 """
247 Get the document title as a property.
249 Returns:
250 The document title, or None if not set
251 """
252 return self.get_title()
254 @title.setter
255 def title(self, title: str):
256 """
257 Set the document title as a property.
259 Args:
260 title: The document title
261 """
262 self.set_title(title)
264 def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
265 """
266 Find all blocks of a specific type.
268 Args:
269 block_type: The type of blocks to find
271 Returns:
272 A list of matching blocks
273 """
274 result = []
276 def _find_recursive(blocks: List[Block]):
277 for block in blocks:
278 if block.block_type == block_type:
279 result.append(block)
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)
287 _find_recursive(self._blocks)
288 return result
290 def find_headings(self) -> List[Heading]:
291 """
292 Find all headings in the document.
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)]
300 def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
301 """
302 Generate a table of contents from headings.
304 Returns:
305 A list of tuples containing (level, title, heading_block)
306 """
307 headings = self.find_headings()
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()
317 # Add to TOC
318 level = heading.level.value # Get numeric value from HeadingLevel enum
319 toc.append((level, title, heading))
321 return toc
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.
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
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 )
362 return self._abstract_style_registry.get_or_create_style(abstract_style)
364 def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
365 """
366 Get a Font object for an AbstractStyle (for rendering).
368 Args:
369 abstract_style: The abstract style to get a font for
371 Returns:
372 Font object ready for rendering
373 """
374 return self._concrete_style_registry.get_font(abstract_style)
376 def update_rendering_context(self, **kwargs):
377 """
378 Update the rendering context (user preferences, device settings, etc.).
380 Args:
381 **kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
382 """
383 self._style_resolver.update_context(**kwargs)
385 def get_style_registry(self) -> AbstractStyleRegistry:
386 """Get the abstract style registry for this document."""
387 return self._abstract_style_registry
389 def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
390 """Get the concrete style registry for this document."""
391 return self._concrete_style_registry
393 # get_or_create_font() is provided by FontRegistry mixin
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.
401 Uses FontRegistry mixin for font caching with parent delegation.
402 Uses MetadataContainer mixin for metadata management.
403 """
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.
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
427 @property
428 def title(self) -> Optional[str]:
429 """Get the chapter title"""
430 return self._title
432 @title.setter
433 def title(self, title: str):
434 """Set the chapter title"""
435 self._title = title
437 @property
438 def level(self) -> int:
439 """Get the chapter level"""
440 return self._level
442 @property
443 def blocks(self) -> List[Block]:
444 """Get the blocks in this chapter"""
445 return self._blocks
447 @property
448 def style(self):
449 """Get the default style for this chapter"""
450 return self._style
452 @style.setter
453 def style(self, style):
454 """Set the default style for this chapter"""
455 self._style = style
457 def add_block(self, block: Block):
458 """
459 Add a block to this chapter.
461 Args:
462 block: The block to add
463 """
464 self._blocks.append(block)
466 def create_paragraph(self, style=None) -> Paragraph:
467 """
468 Create a new paragraph and add it to this chapter.
470 Args:
471 style: Optional style override. If None, inherits from chapter
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
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.
489 Args:
490 level: The heading level
491 style: Optional style override. If None, inherits from chapter
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
502 # set_metadata() and get_metadata() are provided by MetadataContainer mixin
503 # get_or_create_font() is provided by FontRegistry mixin
506class Book(Document):
507 """
508 Abstract representation of an ebook.
509 A book is a document that contains chapters.
510 """
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.
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] = []
526 if author:
527 self.set_metadata(MetadataType.AUTHOR, author)
529 @property
530 def chapters(self) -> List[Chapter]:
531 """Get the chapters in this book"""
532 return self._chapters
534 def add_chapter(self, chapter: Chapter):
535 """
536 Add a chapter to this book.
538 Args:
539 chapter: The chapter to add
540 """
541 self._chapters.append(chapter)
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.
551 Args:
552 title: The chapter title
553 level: The chapter level
554 style: Optional style override. If None, inherits from book
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
565 def get_author(self) -> Optional[str]:
566 """
567 Get the book author.
569 Returns:
570 The book author, or None if not set
571 """
572 return self.get_metadata(MetadataType.AUTHOR)
574 def set_author(self, author: str):
575 """
576 Set the book author.
578 Args:
579 author: The book author
580 """
581 self.set_metadata(MetadataType.AUTHOR, author)
583 def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
584 """
585 Generate a table of contents from chapters.
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))
595 return toc