Duncan Tourolle 718027f3c8
Some checks failed
Python CI / test (push) Failing after 5m26s
Fix tests
2025-09-12 21:23:56 +02:00

757 lines
25 KiB
Python

from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Union, Any
from enum import Enum
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
from .functional import Link, Button, Form
from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
class MetadataType(Enum):
"""Types of metadata that can be associated with a document"""
TITLE = 1
AUTHOR = 2
DESCRIPTION = 3
KEYWORDS = 4
LANGUAGE = 5
PUBLICATION_DATE = 6
MODIFIED_DATE = 7
PUBLISHER = 8
IDENTIFIER = 9
COVER_IMAGE = 10
CUSTOM = 100
class Document:
"""
Abstract representation of a complete document like an HTML page or an ebook.
This class manages the logical structure of the document without rendering concerns.
"""
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
"""
Initialize a new document.
Args:
title: The document title
language: The document language code
default_style: Optional default style for child blocks
"""
self._blocks: List[Block] = []
self._metadata: Dict[MetadataType, Any] = {}
self._anchors: Dict[str, Block] = {} # Named anchors for navigation
self._resources: Dict[str, Any] = {} # External resources like images
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
self._scripts: List[str] = [] # JavaScript code
self._fonts: Dict[str, Font] = {} # Font registry for backward compatibility
# Style management with new abstract/concrete system
self._abstract_style_registry = AbstractStyleRegistry()
self._rendering_context = RenderingContext(default_language=language)
self._style_resolver = StyleResolver(self._rendering_context)
self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver)
# Set default style
if default_style is None:
# Create a default abstract style
default_style = self._abstract_style_registry.default_style
elif isinstance(default_style, Font):
# Convert Font to AbstractStyle for backward compatibility
default_style = AbstractStyle(
font_family=FontFamily.SERIF, # Default assumption
font_size=default_style.font_size,
color=default_style.colour,
language=default_style.language
)
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
self._default_style = default_style
# Set basic metadata
if title:
self.set_metadata(MetadataType.TITLE, title)
self.set_metadata(MetadataType.LANGUAGE, language)
@property
def blocks(self) -> List[Block]:
"""Get the top-level blocks in this document"""
return self._blocks
@property
def default_style(self):
"""Get the default style for this document"""
return self._default_style
@default_style.setter
def default_style(self, style):
"""Set the default style for this document"""
self._default_style = style
def add_block(self, block: Block):
"""
Add a block to this document.
Args:
block: The block to add
"""
self._blocks.append(block)
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this document.
Args:
style: Optional style override. If None, inherits from document
Returns:
The newly created Paragraph object
"""
if style is None:
style = self._default_style
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
"""
Create a new heading and add it to this document.
Args:
level: The heading level
style: Optional style override. If None, inherits from document
Returns:
The newly created Heading object
"""
if style is None:
style = self._default_style
heading = Heading(level, style)
self.add_block(heading)
return heading
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
"""
Create a new chapter with inherited style.
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from document
Returns:
The newly created Chapter object
"""
if style is None:
style = self._default_style
return Chapter(title, level, style)
def set_metadata(self, meta_type: MetadataType, value: Any):
"""
Set a metadata value.
Args:
meta_type: The type of metadata
value: The metadata value
"""
self._metadata[meta_type] = value
def get_metadata(self, meta_type: MetadataType) -> Optional[Any]:
"""
Get a metadata value.
Args:
meta_type: The type of metadata
Returns:
The metadata value, or None if not set
"""
return self._metadata.get(meta_type)
def add_anchor(self, name: str, target: Block):
"""
Add a named anchor to this document.
Args:
name: The anchor name
target: The target block
"""
self._anchors[name] = target
def get_anchor(self, name: str) -> Optional[Block]:
"""
Get a named anchor from this document.
Args:
name: The anchor name
Returns:
The target block, or None if not found
"""
return self._anchors.get(name)
def add_resource(self, name: str, resource: Any):
"""
Add a resource to this document.
Args:
name: The resource name
resource: The resource data
"""
self._resources[name] = resource
def get_resource(self, name: str) -> Optional[Any]:
"""
Get a resource from this document.
Args:
name: The resource name
Returns:
The resource data, or None if not found
"""
return self._resources.get(name)
def add_stylesheet(self, stylesheet: Dict[str, Any]):
"""
Add a stylesheet to this document.
Args:
stylesheet: The stylesheet data
"""
self._stylesheets.append(stylesheet)
def add_script(self, script: str):
"""
Add a script to this document.
Args:
script: The script code
"""
self._scripts.append(script)
def get_title(self) -> Optional[str]:
"""
Get the document title.
Returns:
The document title, or None if not set
"""
return self.get_metadata(MetadataType.TITLE)
def set_title(self, title: str):
"""
Set the document title.
Args:
title: The document title
"""
self.set_metadata(MetadataType.TITLE, title)
@property
def title(self) -> Optional[str]:
"""
Get the document title as a property.
Returns:
The document title, or None if not set
"""
return self.get_title()
@title.setter
def title(self, title: str):
"""
Set the document title as a property.
Args:
title: The document title
"""
self.set_title(title)
def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
"""
Find all blocks of a specific type.
Args:
block_type: The type of blocks to find
Returns:
A list of matching blocks
"""
result = []
def _find_recursive(blocks: List[Block]):
for block in blocks:
if block.block_type == block_type:
result.append(block)
# Check for child blocks based on block type
if hasattr(block, '_blocks'):
_find_recursive(block._blocks)
elif hasattr(block, '_items') and isinstance(block._items, list):
_find_recursive(block._items)
_find_recursive(self._blocks)
return result
def find_headings(self) -> List[Heading]:
"""
Find all headings in the document.
Returns:
A list of heading blocks
"""
blocks = self.find_blocks_by_type(BlockType.HEADING)
return [block for block in blocks if isinstance(block, Heading)]
def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
"""
Generate a table of contents from headings.
Returns:
A list of tuples containing (level, title, heading_block)
"""
headings = self.find_headings()
toc = []
for heading in headings:
# Extract text from the heading
title = ""
for _, word in heading.words_iter():
title += word.text + " "
title = title.strip()
# Add to TOC
level = heading.level.value # Get numeric value from HeadingLevel enum
toc.append((level, title, heading))
return toc
def get_or_create_style(self,
font_family: FontFamily = FontFamily.SERIF,
font_size: Union[FontSize, int] = FontSize.MEDIUM,
font_weight: FontWeight = FontWeight.NORMAL,
font_style: FontStyle = FontStyle.NORMAL,
text_decoration: TextDecoration = TextDecoration.NONE,
color: Union[str, Tuple[int, int, int]] = "black",
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
language: str = "en-US",
**kwargs) -> Tuple[str, AbstractStyle]:
"""
Get or create an abstract style with the specified properties.
Args:
font_family: Semantic font family
font_size: Font size (semantic or numeric)
font_weight: Font weight
font_style: Font style
text_decoration: Text decoration
color: Text color (name or RGB tuple)
background_color: Background color
language: Language code
**kwargs: Additional style properties
Returns:
Tuple of (style_id, AbstractStyle)
"""
abstract_style = AbstractStyle(
font_family=font_family,
font_size=font_size,
font_weight=font_weight,
font_style=font_style,
text_decoration=text_decoration,
color=color,
background_color=background_color,
language=language,
**kwargs
)
return self._abstract_style_registry.get_or_create_style(abstract_style)
def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an AbstractStyle (for rendering).
Args:
abstract_style: The abstract style to get a font for
Returns:
Font object ready for rendering
"""
return self._concrete_style_registry.get_font(abstract_style)
def update_rendering_context(self, **kwargs):
"""
Update the rendering context (user preferences, device settings, etc.).
Args:
**kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
"""
self._style_resolver.update_context(**kwargs)
def get_style_registry(self) -> AbstractStyleRegistry:
"""Get the abstract style registry for this document."""
return self._abstract_style_registry
def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
"""Get the concrete style registry for this document."""
return self._concrete_style_registry
def get_or_create_font(self,
font_path: Optional[str] = None,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> Font:
"""
Get or create a font with the specified properties.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
colour: RGB color tuple for the text.
weight: Font weight (normal or bold).
style: Font style (normal or italic).
decoration: Text decoration (none, underline, or strikethrough).
background: RGBA background color for the text. If None, transparent background.
language: Language code for hyphenation and text processing.
min_hyphenation_width: Minimum width in pixels required for hyphenation.
Returns:
Font object (either existing or newly created)
"""
# Initialize font registry if it doesn't exist
if not hasattr(self, '_fonts'):
self._fonts: Dict[str, Font] = {}
# Create a unique key for this font configuration
bg_tuple = background if background else (255, 255, 255, 0)
min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
font_key = (
font_path,
font_size,
colour,
weight.value if isinstance(weight, FontWeight) else weight,
style.value if isinstance(style, FontStyle) else style,
decoration.value if isinstance(decoration, TextDecoration) else decoration,
bg_tuple,
language,
min_hyph_width
)
# Convert tuple to string for dictionary key
key_str = str(font_key)
# Check if we already have this font
if key_str in self._fonts:
return self._fonts[key_str]
# Create new font and store it
new_font = Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
self._fonts[key_str] = new_font
return new_font
class Chapter:
"""
Represents a chapter or section in a document.
A chapter contains a sequence of blocks and has metadata.
"""
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
"""
Initialize a new chapter.
Args:
title: The chapter title
level: The chapter level (1 = top level, 2 = subsection, etc.)
style: Optional default style for child blocks
parent: Parent container (e.g., Document or Book)
"""
self._title = title
self._level = level
self._blocks: List[Block] = []
self._metadata: Dict[str, Any] = {}
self._style = style
self._parent = parent
self._fonts: Dict[str, Font] = {} # Local font registry
@property
def title(self) -> Optional[str]:
"""Get the chapter title"""
return self._title
@title.setter
def title(self, title: str):
"""Set the chapter title"""
self._title = title
@property
def level(self) -> int:
"""Get the chapter level"""
return self._level
@property
def blocks(self) -> List[Block]:
"""Get the blocks in this chapter"""
return self._blocks
@property
def style(self):
"""Get the default style for this chapter"""
return self._style
@style.setter
def style(self, style):
"""Set the default style for this chapter"""
self._style = style
def add_block(self, block: Block):
"""
Add a block to this chapter.
Args:
block: The block to add
"""
self._blocks.append(block)
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this chapter.
Args:
style: Optional style override. If None, inherits from chapter
Returns:
The newly created Paragraph object
"""
if style is None:
style = self._style
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
"""
Create a new heading and add it to this chapter.
Args:
level: The heading level
style: Optional style override. If None, inherits from chapter
Returns:
The newly created Heading object
"""
if style is None:
style = self._style
heading = Heading(level, style)
self.add_block(heading)
return heading
def set_metadata(self, key: str, value: Any):
"""
Set a metadata value.
Args:
key: The metadata key
value: The metadata value
"""
self._metadata[key] = value
def get_metadata(self, key: str) -> Optional[Any]:
"""
Get a metadata value.
Args:
key: The metadata key
Returns:
The metadata value, or None if not set
"""
return self._metadata.get(key)
def get_or_create_font(self,
font_path: Optional[str] = None,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> Font:
"""
Get or create a font with the specified properties. Cascades to parent if available.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
colour: RGB color tuple for the text.
weight: Font weight (normal or bold).
style: Font style (normal or italic).
decoration: Text decoration (none, underline, or strikethrough).
background: RGBA background color for the text. If None, transparent background.
language: Language code for hyphenation and text processing.
min_hyphenation_width: Minimum width in pixels required for hyphenation.
Returns:
Font object (either existing or newly created)
"""
# If we have a parent with font management, delegate to parent
if self._parent and hasattr(self._parent, 'get_or_create_font'):
return self._parent.get_or_create_font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
# Otherwise manage our own fonts
# Create a unique key for this font configuration
bg_tuple = background if background else (255, 255, 255, 0)
min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
font_key = (
font_path,
font_size,
colour,
weight.value if isinstance(weight, FontWeight) else weight,
style.value if isinstance(style, FontStyle) else style,
decoration.value if isinstance(decoration, TextDecoration) else decoration,
bg_tuple,
language,
min_hyph_width
)
# Convert tuple to string for dictionary key
key_str = str(font_key)
# Check if we already have this font
if key_str in self._fonts:
return self._fonts[key_str]
# Create new font and store it
new_font = Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
self._fonts[key_str] = new_font
return new_font
class Book(Document):
"""
Abstract representation of an ebook.
A book is a document that contains chapters.
"""
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
language: str = "en-US", default_style=None):
"""
Initialize a new book.
Args:
title: The book title
author: The book author
language: The book language code
default_style: Optional default style for child chapters and blocks
"""
super().__init__(title, language, default_style)
self._chapters: List[Chapter] = []
if author:
self.set_metadata(MetadataType.AUTHOR, author)
@property
def chapters(self) -> List[Chapter]:
"""Get the chapters in this book"""
return self._chapters
def add_chapter(self, chapter: Chapter):
"""
Add a chapter to this book.
Args:
chapter: The chapter to add
"""
self._chapters.append(chapter)
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
"""
Create and add a new chapter with inherited style.
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from book
Returns:
The new chapter
"""
if style is None:
style = self._default_style
chapter = Chapter(title, level, style)
self.add_chapter(chapter)
return chapter
def get_author(self) -> Optional[str]:
"""
Get the book author.
Returns:
The book author, or None if not set
"""
return self.get_metadata(MetadataType.AUTHOR)
def set_author(self, author: str):
"""
Set the book author.
Args:
author: The book author
"""
self.set_metadata(MetadataType.AUTHOR, author)
def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
"""
Generate a table of contents from chapters.
Returns:
A list of tuples containing (level, title, chapter)
"""
toc = []
for chapter in self._chapters:
if chapter.title:
toc.append((chapter.level, chapter.title, chapter))
return toc