1498 lines
47 KiB
Python
1498 lines
47 KiB
Python
from typing import List, Iterator, Tuple, Dict, Optional, Union, Any
|
|
from enum import Enum
|
|
import os
|
|
import tempfile
|
|
import urllib.request
|
|
import urllib.parse
|
|
from PIL import Image as PILImage
|
|
from .inline import Word, FormattedSpan
|
|
from ..style import Font, FontWeight, FontStyle, TextDecoration
|
|
|
|
|
|
class BlockType(Enum):
|
|
"""Enumeration of different block types for classification purposes"""
|
|
PARAGRAPH = 1
|
|
HEADING = 2
|
|
QUOTE = 3
|
|
CODE_BLOCK = 4
|
|
LIST = 5
|
|
LIST_ITEM = 6
|
|
TABLE = 7
|
|
TABLE_ROW = 8
|
|
TABLE_CELL = 9
|
|
HORIZONTAL_RULE = 10
|
|
LINE_BREAK = 11
|
|
IMAGE = 12
|
|
|
|
|
|
class Block:
|
|
"""
|
|
Base class for all block-level elements.
|
|
Block elements typically represent visual blocks of content that stack vertically.
|
|
"""
|
|
|
|
def __init__(self, block_type: BlockType):
|
|
"""
|
|
Initialize a block element.
|
|
|
|
Args:
|
|
block_type: The type of block this element represents
|
|
"""
|
|
self._block_type = block_type
|
|
self._parent = None
|
|
|
|
@property
|
|
def block_type(self) -> BlockType:
|
|
"""Get the type of this block element"""
|
|
return self._block_type
|
|
|
|
@property
|
|
def parent(self):
|
|
"""Get the parent block containing this block, if any"""
|
|
return self._parent
|
|
|
|
@parent.setter
|
|
def parent(self, parent):
|
|
"""Set the parent block"""
|
|
self._parent = parent
|
|
|
|
|
|
class Paragraph(Block):
|
|
"""
|
|
A paragraph is a block-level element that contains a sequence of words.
|
|
"""
|
|
|
|
def __init__(self, style=None):
|
|
"""
|
|
Initialize an empty paragraph
|
|
|
|
Args:
|
|
style: Optional default style for words in this paragraph
|
|
"""
|
|
super().__init__(BlockType.PARAGRAPH)
|
|
self._words: List[Word] = []
|
|
self._spans: List[FormattedSpan] = []
|
|
self._style = style
|
|
self._fonts: Dict[str, Font] = {} # Local font registry
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, style=None) -> 'Paragraph':
|
|
"""
|
|
Create a new Paragraph and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the paragraph to (must have add_block method and style property)
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created Paragraph object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
elif style is None and hasattr(container, 'default_style'):
|
|
style = container.default_style
|
|
|
|
# Create the new paragraph
|
|
paragraph = cls(style)
|
|
|
|
# Add the paragraph to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(paragraph)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return paragraph
|
|
|
|
@property
|
|
def style(self):
|
|
"""Get the default style for this paragraph"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style):
|
|
"""Set the default style for this paragraph"""
|
|
self._style = style
|
|
|
|
def add_word(self, word: Word):
|
|
"""
|
|
Add a word to this paragraph.
|
|
|
|
Args:
|
|
word: The Word object to add
|
|
"""
|
|
self._words.append(word)
|
|
|
|
def create_word(self, text: str, style=None, background=None) -> Word:
|
|
"""
|
|
Create a new word and add it to this paragraph, inheriting paragraph's style if not specified.
|
|
|
|
This is a convenience method that uses Word.create_and_add_to() to create words
|
|
that automatically inherit styling from this paragraph.
|
|
|
|
Args:
|
|
text: The text content of the word
|
|
style: Optional Font style override. If None, attempts to inherit from paragraph
|
|
background: Optional background color override
|
|
|
|
Returns:
|
|
The newly created Word object
|
|
"""
|
|
return Word.create_and_add_to(text, self, style, background)
|
|
|
|
def add_span(self, span: FormattedSpan):
|
|
"""
|
|
Add a formatted span to this paragraph.
|
|
|
|
Args:
|
|
span: The FormattedSpan object to add
|
|
"""
|
|
self._spans.append(span)
|
|
|
|
def create_span(self, style=None, background=None) -> FormattedSpan:
|
|
"""
|
|
Create a new formatted span with inherited style.
|
|
|
|
Args:
|
|
style: Optional Font style override. If None, inherits from paragraph
|
|
background: Optional background color override
|
|
|
|
Returns:
|
|
The newly created FormattedSpan object
|
|
"""
|
|
return FormattedSpan.create_and_add_to(self, style, background)
|
|
|
|
def words(self) -> Iterator[Tuple[int, Word]]:
|
|
"""
|
|
Iterate over the words in this paragraph.
|
|
|
|
Yields:
|
|
Tuples of (index, word) for each word in the paragraph
|
|
"""
|
|
for i, word in enumerate(self._words):
|
|
yield i, word
|
|
|
|
def spans(self) -> Iterator[FormattedSpan]:
|
|
"""
|
|
Iterate over the formatted spans in this paragraph.
|
|
|
|
Yields:
|
|
Each FormattedSpan in the paragraph
|
|
"""
|
|
for span in self._spans:
|
|
yield span
|
|
|
|
@property
|
|
def word_count(self) -> int:
|
|
"""Get the number of words in this paragraph"""
|
|
return len(self._words)
|
|
|
|
def __len__(self):
|
|
return self.word_count
|
|
|
|
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 HeadingLevel(Enum):
|
|
"""Enumeration representing HTML heading levels (h1-h6)"""
|
|
H1 = 1
|
|
H2 = 2
|
|
H3 = 3
|
|
H4 = 4
|
|
H5 = 5
|
|
H6 = 6
|
|
|
|
|
|
class Heading(Paragraph):
|
|
"""
|
|
A heading element (h1, h2, h3, etc.) that contains text with a specific heading level.
|
|
Headings inherit from Paragraph as they contain words but have additional properties.
|
|
"""
|
|
|
|
def __init__(self, level: HeadingLevel = HeadingLevel.H1, style=None):
|
|
"""
|
|
Initialize a heading element.
|
|
|
|
Args:
|
|
level: The heading level (h1-h6)
|
|
style: Optional default style for words in this heading
|
|
"""
|
|
super().__init__(style)
|
|
self._block_type = BlockType.HEADING
|
|
self._level = level
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, level: HeadingLevel = HeadingLevel.H1, style=None) -> 'Heading':
|
|
"""
|
|
Create a new Heading and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the heading to (must have add_block method and style property)
|
|
level: The heading level (h1-h6)
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created Heading object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
elif style is None and hasattr(container, 'default_style'):
|
|
style = container.default_style
|
|
|
|
# Create the new heading
|
|
heading = cls(level, style)
|
|
|
|
# Add the heading to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(heading)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return heading
|
|
|
|
@property
|
|
def level(self) -> HeadingLevel:
|
|
"""Get the heading level"""
|
|
return self._level
|
|
|
|
@level.setter
|
|
def level(self, level: HeadingLevel):
|
|
"""Set the heading level"""
|
|
self._level = level
|
|
|
|
|
|
class Quote(Block):
|
|
"""
|
|
A blockquote element that can contain other block elements.
|
|
"""
|
|
|
|
def __init__(self, style=None):
|
|
"""
|
|
Initialize an empty blockquote
|
|
|
|
Args:
|
|
style: Optional default style for child blocks
|
|
"""
|
|
super().__init__(BlockType.QUOTE)
|
|
self._blocks: List[Block] = []
|
|
self._style = style
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, style=None) -> 'Quote':
|
|
"""
|
|
Create a new Quote and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the quote to (must have add_block method and style property)
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created Quote object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
elif style is None and hasattr(container, 'default_style'):
|
|
style = container.default_style
|
|
|
|
# Create the new quote
|
|
quote = cls(style)
|
|
|
|
# Add the quote to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(quote)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return quote
|
|
|
|
@property
|
|
def style(self):
|
|
"""Get the default style for this quote"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style):
|
|
"""Set the default style for this quote"""
|
|
self._style = style
|
|
|
|
def add_block(self, block: Block):
|
|
"""
|
|
Add a block element to this quote.
|
|
|
|
Args:
|
|
block: The Block object to add
|
|
"""
|
|
self._blocks.append(block)
|
|
block.parent = self
|
|
|
|
def create_paragraph(self, style=None) -> Paragraph:
|
|
"""
|
|
Create a new paragraph and add it to this quote.
|
|
|
|
Args:
|
|
style: Optional style override. If None, inherits from quote
|
|
|
|
Returns:
|
|
The newly created Paragraph object
|
|
"""
|
|
return Paragraph.create_and_add_to(self, style)
|
|
|
|
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
|
"""
|
|
Create a new heading and add it to this quote.
|
|
|
|
Args:
|
|
level: The heading level
|
|
style: Optional style override. If None, inherits from quote
|
|
|
|
Returns:
|
|
The newly created Heading object
|
|
"""
|
|
return Heading.create_and_add_to(self, level, style)
|
|
|
|
def blocks(self) -> Iterator[Block]:
|
|
"""
|
|
Iterate over the blocks in this quote.
|
|
|
|
Yields:
|
|
Each Block in the quote
|
|
"""
|
|
for block in self._blocks:
|
|
yield block
|
|
|
|
|
|
class CodeBlock(Block):
|
|
"""
|
|
A code block element containing pre-formatted text with syntax highlighting.
|
|
"""
|
|
|
|
def __init__(self, language: str = ""):
|
|
"""
|
|
Initialize a code block.
|
|
|
|
Args:
|
|
language: The programming language for syntax highlighting
|
|
"""
|
|
super().__init__(BlockType.CODE_BLOCK)
|
|
self._language = language
|
|
self._lines: List[str] = []
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, language: str = "") -> 'CodeBlock':
|
|
"""
|
|
Create a new CodeBlock and add it to a container.
|
|
|
|
Args:
|
|
container: The container to add the code block to (must have add_block method)
|
|
language: The programming language for syntax highlighting
|
|
|
|
Returns:
|
|
The newly created CodeBlock object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Create the new code block
|
|
code_block = cls(language)
|
|
|
|
# Add the code block to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(code_block)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return code_block
|
|
|
|
@property
|
|
def language(self) -> str:
|
|
"""Get the programming language"""
|
|
return self._language
|
|
|
|
@language.setter
|
|
def language(self, language: str):
|
|
"""Set the programming language"""
|
|
self._language = language
|
|
|
|
def add_line(self, line: str):
|
|
"""
|
|
Add a line of code to this code block.
|
|
|
|
Args:
|
|
line: The line of code to add
|
|
"""
|
|
self._lines.append(line)
|
|
|
|
def lines(self) -> Iterator[Tuple[int, str]]:
|
|
"""
|
|
Iterate over the lines in this code block.
|
|
|
|
Yields:
|
|
Tuples of (line_number, line_text) for each line
|
|
"""
|
|
for i, line in enumerate(self._lines):
|
|
yield i, line
|
|
|
|
@property
|
|
def line_count(self) -> int:
|
|
"""Get the number of lines in this code block"""
|
|
return len(self._lines)
|
|
|
|
|
|
class ListStyle(Enum):
|
|
"""Enumeration of list styles"""
|
|
UNORDERED = 1 # <ul>
|
|
ORDERED = 2 # <ol>
|
|
DEFINITION = 3 # <dl>
|
|
|
|
|
|
class HList(Block):
|
|
"""
|
|
An HTML list element (ul, ol, dl).
|
|
"""
|
|
|
|
def __init__(self, style: ListStyle = ListStyle.UNORDERED, default_style=None):
|
|
"""
|
|
Initialize a list.
|
|
|
|
Args:
|
|
style: The style of list (unordered, ordered, definition)
|
|
default_style: Optional default style for child items
|
|
"""
|
|
super().__init__(BlockType.LIST)
|
|
self._style = style
|
|
self._items: List[ListItem] = []
|
|
self._default_style = default_style
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, style: ListStyle = ListStyle.UNORDERED, default_style=None) -> 'HList':
|
|
"""
|
|
Create a new HList and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the list to (must have add_block method)
|
|
style: The style of list (unordered, ordered, definition)
|
|
default_style: Optional default style for child items. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created HList object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if default_style is None and hasattr(container, 'style'):
|
|
default_style = container.style
|
|
elif default_style is None and hasattr(container, 'default_style'):
|
|
default_style = container.default_style
|
|
|
|
# Create the new list
|
|
hlist = cls(style, default_style)
|
|
|
|
# Add the list to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(hlist)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return hlist
|
|
|
|
@property
|
|
def style(self) -> ListStyle:
|
|
"""Get the list style"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style: ListStyle):
|
|
"""Set the list style"""
|
|
self._style = style
|
|
|
|
@property
|
|
def default_style(self):
|
|
"""Get the default style for list items"""
|
|
return self._default_style
|
|
|
|
@default_style.setter
|
|
def default_style(self, style):
|
|
"""Set the default style for list items"""
|
|
self._default_style = style
|
|
|
|
def add_item(self, item: 'ListItem'):
|
|
"""
|
|
Add an item to this list.
|
|
|
|
Args:
|
|
item: The ListItem to add
|
|
"""
|
|
self._items.append(item)
|
|
item.parent = self
|
|
|
|
def create_item(self, term: Optional[str] = None, style=None) -> 'ListItem':
|
|
"""
|
|
Create a new list item and add it to this list.
|
|
|
|
Args:
|
|
term: Optional term for definition lists
|
|
style: Optional style override. If None, inherits from list
|
|
|
|
Returns:
|
|
The newly created ListItem object
|
|
"""
|
|
return ListItem.create_and_add_to(self, term, style)
|
|
|
|
def items(self) -> Iterator['ListItem']:
|
|
"""
|
|
Iterate over the items in this list.
|
|
|
|
Yields:
|
|
Each ListItem in the list
|
|
"""
|
|
for item in self._items:
|
|
yield item
|
|
|
|
@property
|
|
def item_count(self) -> int:
|
|
"""Get the number of items in this list"""
|
|
return len(self._items)
|
|
|
|
|
|
class ListItem(Block):
|
|
"""
|
|
A list item element that can contain other block elements.
|
|
"""
|
|
|
|
def __init__(self, term: Optional[str] = None, style=None):
|
|
"""
|
|
Initialize a list item.
|
|
|
|
Args:
|
|
term: Optional term for definition lists (dt element)
|
|
style: Optional default style for child blocks
|
|
"""
|
|
super().__init__(BlockType.LIST_ITEM)
|
|
self._blocks: List[Block] = []
|
|
self._term = term
|
|
self._style = style
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, term: Optional[str] = None, style=None) -> 'ListItem':
|
|
"""
|
|
Create a new ListItem and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the list item to (must have add_item method)
|
|
term: Optional term for definition lists (dt element)
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created ListItem object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_item method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'default_style'):
|
|
style = container.default_style
|
|
elif style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
|
|
# Create the new list item
|
|
item = cls(term, style)
|
|
|
|
# Add the list item to the container
|
|
if hasattr(container, 'add_item'):
|
|
container.add_item(item)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_item' method")
|
|
|
|
return item
|
|
|
|
@property
|
|
def term(self) -> Optional[str]:
|
|
"""Get the definition term (for definition lists)"""
|
|
return self._term
|
|
|
|
@term.setter
|
|
def term(self, term: str):
|
|
"""Set the definition term"""
|
|
self._term = term
|
|
|
|
@property
|
|
def style(self):
|
|
"""Get the default style for this list item"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style):
|
|
"""Set the default style for this list item"""
|
|
self._style = style
|
|
|
|
def add_block(self, block: Block):
|
|
"""
|
|
Add a block element to this list item.
|
|
|
|
Args:
|
|
block: The Block object to add
|
|
"""
|
|
self._blocks.append(block)
|
|
block.parent = self
|
|
|
|
def create_paragraph(self, style=None) -> Paragraph:
|
|
"""
|
|
Create a new paragraph and add it to this list item.
|
|
|
|
Args:
|
|
style: Optional style override. If None, inherits from list item
|
|
|
|
Returns:
|
|
The newly created Paragraph object
|
|
"""
|
|
return Paragraph.create_and_add_to(self, style)
|
|
|
|
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
|
"""
|
|
Create a new heading and add it to this list item.
|
|
|
|
Args:
|
|
level: The heading level
|
|
style: Optional style override. If None, inherits from list item
|
|
|
|
Returns:
|
|
The newly created Heading object
|
|
"""
|
|
return Heading.create_and_add_to(self, level, style)
|
|
|
|
def blocks(self) -> Iterator[Block]:
|
|
"""
|
|
Iterate over the blocks in this list item.
|
|
|
|
Yields:
|
|
Each Block in the list item
|
|
"""
|
|
for block in self._blocks:
|
|
yield block
|
|
|
|
|
|
class TableCell(Block):
|
|
"""
|
|
A table cell element that can contain other block elements.
|
|
"""
|
|
|
|
def __init__(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None):
|
|
"""
|
|
Initialize a table cell.
|
|
|
|
Args:
|
|
is_header: Whether this cell is a header cell (th) or data cell (td)
|
|
colspan: Number of columns this cell spans
|
|
rowspan: Number of rows this cell spans
|
|
style: Optional default style for child blocks
|
|
"""
|
|
super().__init__(BlockType.TABLE_CELL)
|
|
self._is_header = is_header
|
|
self._colspan = colspan
|
|
self._rowspan = rowspan
|
|
self._blocks: List[Block] = []
|
|
self._style = style
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, is_header: bool = False, colspan: int = 1,
|
|
rowspan: int = 1, style=None) -> 'TableCell':
|
|
"""
|
|
Create a new TableCell and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the cell to (must have add_cell method)
|
|
is_header: Whether this cell is a header cell (th) or data cell (td)
|
|
colspan: Number of columns this cell spans
|
|
rowspan: Number of rows this cell spans
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created TableCell object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_cell method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
|
|
# Create the new table cell
|
|
cell = cls(is_header, colspan, rowspan, style)
|
|
|
|
# Add the cell to the container
|
|
if hasattr(container, 'add_cell'):
|
|
container.add_cell(cell)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_cell' method")
|
|
|
|
return cell
|
|
|
|
@property
|
|
def is_header(self) -> bool:
|
|
"""Check if this is a header cell"""
|
|
return self._is_header
|
|
|
|
@is_header.setter
|
|
def is_header(self, is_header: bool):
|
|
"""Set whether this is a header cell"""
|
|
self._is_header = is_header
|
|
|
|
@property
|
|
def colspan(self) -> int:
|
|
"""Get the column span"""
|
|
return self._colspan
|
|
|
|
@colspan.setter
|
|
def colspan(self, colspan: int):
|
|
"""Set the column span"""
|
|
self._colspan = max(1, colspan) # Ensure minimum of 1
|
|
|
|
@property
|
|
def rowspan(self) -> int:
|
|
"""Get the row span"""
|
|
return self._rowspan
|
|
|
|
@rowspan.setter
|
|
def rowspan(self, rowspan: int):
|
|
"""Set the row span"""
|
|
self._rowspan = max(1, rowspan) # Ensure minimum of 1
|
|
|
|
@property
|
|
def style(self):
|
|
"""Get the default style for this table cell"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style):
|
|
"""Set the default style for this table cell"""
|
|
self._style = style
|
|
|
|
def add_block(self, block: Block):
|
|
"""
|
|
Add a block element to this cell.
|
|
|
|
Args:
|
|
block: The Block object to add
|
|
"""
|
|
self._blocks.append(block)
|
|
block.parent = self
|
|
|
|
def create_paragraph(self, style=None) -> Paragraph:
|
|
"""
|
|
Create a new paragraph and add it to this table cell.
|
|
|
|
Args:
|
|
style: Optional style override. If None, inherits from cell
|
|
|
|
Returns:
|
|
The newly created Paragraph object
|
|
"""
|
|
return Paragraph.create_and_add_to(self, style)
|
|
|
|
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
|
"""
|
|
Create a new heading and add it to this table cell.
|
|
|
|
Args:
|
|
level: The heading level
|
|
style: Optional style override. If None, inherits from cell
|
|
|
|
Returns:
|
|
The newly created Heading object
|
|
"""
|
|
return Heading.create_and_add_to(self, level, style)
|
|
|
|
def blocks(self) -> Iterator[Block]:
|
|
"""
|
|
Iterate over the blocks in this cell.
|
|
|
|
Yields:
|
|
Each Block in the cell
|
|
"""
|
|
for block in self._blocks:
|
|
yield block
|
|
|
|
|
|
class TableRow(Block):
|
|
"""
|
|
A table row element containing table cells.
|
|
"""
|
|
|
|
def __init__(self, style=None):
|
|
"""
|
|
Initialize an empty table row
|
|
|
|
Args:
|
|
style: Optional default style for child cells
|
|
"""
|
|
super().__init__(BlockType.TABLE_ROW)
|
|
self._cells: List[TableCell] = []
|
|
self._style = style
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, section: str = "body", style=None) -> 'TableRow':
|
|
"""
|
|
Create a new TableRow and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the row to (must have add_row method)
|
|
section: The section to add the row to ("header", "body", or "footer")
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created TableRow object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_row method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
|
|
# Create the new table row
|
|
row = cls(style)
|
|
|
|
# Add the row to the container
|
|
if hasattr(container, 'add_row'):
|
|
container.add_row(row, section)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_row' method")
|
|
|
|
return row
|
|
|
|
@property
|
|
def style(self):
|
|
"""Get the default style for this table row"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style):
|
|
"""Set the default style for this table row"""
|
|
self._style = style
|
|
|
|
def add_cell(self, cell: TableCell):
|
|
"""
|
|
Add a cell to this row.
|
|
|
|
Args:
|
|
cell: The TableCell to add
|
|
"""
|
|
self._cells.append(cell)
|
|
cell.parent = self
|
|
|
|
def create_cell(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None) -> TableCell:
|
|
"""
|
|
Create a new table cell and add it to this row.
|
|
|
|
Args:
|
|
is_header: Whether this cell is a header cell
|
|
colspan: Number of columns this cell spans
|
|
rowspan: Number of rows this cell spans
|
|
style: Optional style override. If None, inherits from row
|
|
|
|
Returns:
|
|
The newly created TableCell object
|
|
"""
|
|
return TableCell.create_and_add_to(self, is_header, colspan, rowspan, style)
|
|
|
|
def cells(self) -> Iterator[TableCell]:
|
|
"""
|
|
Iterate over the cells in this row.
|
|
|
|
Yields:
|
|
Each TableCell in the row
|
|
"""
|
|
for cell in self._cells:
|
|
yield cell
|
|
|
|
@property
|
|
def cell_count(self) -> int:
|
|
"""Get the number of cells in this row"""
|
|
return len(self._cells)
|
|
|
|
|
|
class Table(Block):
|
|
"""
|
|
A table element containing rows and cells.
|
|
"""
|
|
|
|
def __init__(self, caption: Optional[str] = None, style=None):
|
|
"""
|
|
Initialize a table.
|
|
|
|
Args:
|
|
caption: Optional caption for the table
|
|
style: Optional default style for child rows
|
|
"""
|
|
super().__init__(BlockType.TABLE)
|
|
self._caption = caption
|
|
self._rows: List[TableRow] = []
|
|
self._header_rows: List[TableRow] = []
|
|
self._footer_rows: List[TableRow] = []
|
|
self._style = style
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, caption: Optional[str] = None, style=None) -> 'Table':
|
|
"""
|
|
Create a new Table and add it to a container, inheriting style from
|
|
the container if not explicitly provided.
|
|
|
|
Args:
|
|
container: The container to add the table to (must have add_block method)
|
|
caption: Optional caption for the table
|
|
style: Optional style override. If None, inherits from container
|
|
|
|
Returns:
|
|
The newly created Table object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Inherit style from container if not provided
|
|
if style is None and hasattr(container, 'style'):
|
|
style = container.style
|
|
elif style is None and hasattr(container, 'default_style'):
|
|
style = container.default_style
|
|
|
|
# Create the new table
|
|
table = cls(caption, style)
|
|
|
|
# Add the table to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(table)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return table
|
|
|
|
@property
|
|
def caption(self) -> Optional[str]:
|
|
"""Get the table caption"""
|
|
return self._caption
|
|
|
|
@caption.setter
|
|
def caption(self, caption: Optional[str]):
|
|
"""Set the table caption"""
|
|
self._caption = caption
|
|
|
|
@property
|
|
def style(self):
|
|
"""Get the default style for this table"""
|
|
return self._style
|
|
|
|
@style.setter
|
|
def style(self, style):
|
|
"""Set the default style for this table"""
|
|
self._style = style
|
|
|
|
def add_row(self, row: TableRow, section: str = "body"):
|
|
"""
|
|
Add a row to this table.
|
|
|
|
Args:
|
|
row: The TableRow to add
|
|
section: The section to add the row to ("header", "body", or "footer")
|
|
"""
|
|
row.parent = self
|
|
|
|
if section.lower() == "header":
|
|
self._header_rows.append(row)
|
|
elif section.lower() == "footer":
|
|
self._footer_rows.append(row)
|
|
else: # Default to body
|
|
self._rows.append(row)
|
|
|
|
def create_row(self, section: str = "body", style=None) -> TableRow:
|
|
"""
|
|
Create a new table row and add it to this table.
|
|
|
|
Args:
|
|
section: The section to add the row to ("header", "body", or "footer")
|
|
style: Optional style override. If None, inherits from table
|
|
|
|
Returns:
|
|
The newly created TableRow object
|
|
"""
|
|
return TableRow.create_and_add_to(self, section, style)
|
|
|
|
def header_rows(self) -> Iterator[TableRow]:
|
|
"""
|
|
Iterate over the header rows in this table.
|
|
|
|
Yields:
|
|
Each TableRow in the header section
|
|
"""
|
|
for row in self._header_rows:
|
|
yield row
|
|
|
|
def body_rows(self) -> Iterator[TableRow]:
|
|
"""
|
|
Iterate over the body rows in this table.
|
|
|
|
Yields:
|
|
Each TableRow in the body section
|
|
"""
|
|
for row in self._rows:
|
|
yield row
|
|
|
|
def footer_rows(self) -> Iterator[TableRow]:
|
|
"""
|
|
Iterate over the footer rows in this table.
|
|
|
|
Yields:
|
|
Each TableRow in the footer section
|
|
"""
|
|
for row in self._footer_rows:
|
|
yield row
|
|
|
|
def all_rows(self) -> Iterator[Tuple[str, TableRow]]:
|
|
"""
|
|
Iterate over all rows in this table with their section labels.
|
|
|
|
Yields:
|
|
Tuples of (section, row) for each row in the table
|
|
"""
|
|
for row in self._header_rows:
|
|
yield ("header", row)
|
|
for row in self._rows:
|
|
yield ("body", row)
|
|
for row in self._footer_rows:
|
|
yield ("footer", row)
|
|
|
|
@property
|
|
def row_count(self) -> Dict[str, int]:
|
|
"""Get the row counts by section"""
|
|
return {
|
|
"header": len(self._header_rows),
|
|
"body": len(self._rows),
|
|
"footer": len(self._footer_rows),
|
|
"total": len(self._header_rows) + len(self._rows) + len(self._footer_rows)
|
|
}
|
|
|
|
|
|
class Image(Block):
|
|
"""
|
|
An image element with source, dimensions, and alternative text.
|
|
"""
|
|
|
|
def __init__(self, source: str = "", alt_text: str = "", width: Optional[int] = None, height: Optional[int] = None):
|
|
"""
|
|
Initialize an image element.
|
|
|
|
Args:
|
|
source: The image source URL or path
|
|
alt_text: Alternative text for accessibility
|
|
width: Optional image width in pixels
|
|
height: Optional image height in pixels
|
|
"""
|
|
super().__init__(BlockType.IMAGE)
|
|
self._source = source
|
|
self._alt_text = alt_text
|
|
self._width = width
|
|
self._height = height
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container, source: str = "", alt_text: str = "",
|
|
width: Optional[int] = None, height: Optional[int] = None) -> 'Image':
|
|
"""
|
|
Create a new Image and add it to a container.
|
|
|
|
Args:
|
|
container: The container to add the image to (must have add_block method)
|
|
source: The image source URL or path
|
|
alt_text: Alternative text for accessibility
|
|
width: Optional image width in pixels
|
|
height: Optional image height in pixels
|
|
|
|
Returns:
|
|
The newly created Image object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Create the new image
|
|
image = cls(source, alt_text, width, height)
|
|
|
|
# Add the image to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(image)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return image
|
|
|
|
@property
|
|
def source(self) -> str:
|
|
"""Get the image source"""
|
|
return self._source
|
|
|
|
@source.setter
|
|
def source(self, source: str):
|
|
"""Set the image source"""
|
|
self._source = source
|
|
|
|
@property
|
|
def alt_text(self) -> str:
|
|
"""Get the alternative text"""
|
|
return self._alt_text
|
|
|
|
@alt_text.setter
|
|
def alt_text(self, alt_text: str):
|
|
"""Set the alternative text"""
|
|
self._alt_text = alt_text
|
|
|
|
@property
|
|
def width(self) -> Optional[int]:
|
|
"""Get the image width"""
|
|
return self._width
|
|
|
|
@width.setter
|
|
def width(self, width: Optional[int]):
|
|
"""Set the image width"""
|
|
self._width = width
|
|
|
|
@property
|
|
def height(self) -> Optional[int]:
|
|
"""Get the image height"""
|
|
return self._height
|
|
|
|
@height.setter
|
|
def height(self, height: Optional[int]):
|
|
"""Set the image height"""
|
|
self._height = height
|
|
|
|
def get_dimensions(self) -> Tuple[Optional[int], Optional[int]]:
|
|
"""
|
|
Get the image dimensions as a tuple.
|
|
|
|
Returns:
|
|
Tuple of (width, height)
|
|
"""
|
|
return (self._width, self._height)
|
|
|
|
def get_aspect_ratio(self) -> Optional[float]:
|
|
"""
|
|
Calculate the aspect ratio of the image.
|
|
|
|
Returns:
|
|
The aspect ratio (width/height) or None if either dimension is missing
|
|
"""
|
|
if self._width is not None and self._height is not None and self._height > 0:
|
|
return self._width / self._height
|
|
return None
|
|
|
|
def calculate_scaled_dimensions(self, max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> Tuple[Optional[int], Optional[int]]:
|
|
"""
|
|
Calculate scaled dimensions that fit within the given constraints.
|
|
|
|
Args:
|
|
max_width: Maximum allowed width
|
|
max_height: Maximum allowed height
|
|
|
|
Returns:
|
|
Tuple of (scaled_width, scaled_height)
|
|
"""
|
|
if self._width is None or self._height is None:
|
|
return (self._width, self._height)
|
|
|
|
width, height = self._width, self._height
|
|
|
|
# Scale down if needed
|
|
if max_width is not None and width > max_width:
|
|
height = int(height * max_width / width)
|
|
width = max_width
|
|
|
|
if max_height is not None and height > max_height:
|
|
width = int(width * max_height / height)
|
|
height = max_height
|
|
|
|
return (width, height)
|
|
|
|
def _is_url(self, source: str) -> bool:
|
|
"""
|
|
Check if the source is a URL.
|
|
|
|
Args:
|
|
source: The source string to check
|
|
|
|
Returns:
|
|
True if the source appears to be a URL, False otherwise
|
|
"""
|
|
parsed = urllib.parse.urlparse(source)
|
|
return bool(parsed.scheme and parsed.netloc)
|
|
|
|
def _download_to_temp(self, url: str) -> str:
|
|
"""
|
|
Download an image from a URL to a temporary file.
|
|
|
|
Args:
|
|
url: The URL to download from
|
|
|
|
Returns:
|
|
Path to the temporary file
|
|
|
|
Raises:
|
|
urllib.error.URLError: If the download fails
|
|
"""
|
|
# Create a temporary file
|
|
temp_fd, temp_path = tempfile.mkstemp(suffix='.tmp')
|
|
|
|
try:
|
|
# Download the image
|
|
with urllib.request.urlopen(url) as response:
|
|
# Write the response data to the temporary file
|
|
with os.fdopen(temp_fd, 'wb') as temp_file:
|
|
temp_file.write(response.read())
|
|
|
|
return temp_path
|
|
except:
|
|
# Clean up the temporary file if download fails
|
|
try:
|
|
os.close(temp_fd)
|
|
except:
|
|
pass
|
|
try:
|
|
os.unlink(temp_path)
|
|
except:
|
|
pass
|
|
raise
|
|
|
|
def load_image_data(self, auto_update_dimensions: bool = True) -> Tuple[Optional[str], Optional[PILImage.Image]]:
|
|
"""
|
|
Load image data using PIL, handling both local files and URLs.
|
|
|
|
Args:
|
|
auto_update_dimensions: If True, automatically update width and height from the loaded image
|
|
|
|
Returns:
|
|
Tuple of (file_path, PIL_Image_object). For URLs, file_path is the temporary file path.
|
|
Returns (None, None) if loading fails.
|
|
"""
|
|
if not self._source:
|
|
return None, None
|
|
|
|
file_path = None
|
|
temp_file = None
|
|
|
|
try:
|
|
if self._is_url(self._source):
|
|
# Download to temporary file
|
|
temp_file = self._download_to_temp(self._source)
|
|
file_path = temp_file
|
|
else:
|
|
# Use local file path
|
|
file_path = self._source
|
|
|
|
# Open with PIL
|
|
with PILImage.open(file_path) as img:
|
|
# Load the image data
|
|
img.load()
|
|
|
|
# Update dimensions if requested
|
|
if auto_update_dimensions:
|
|
self._width, self._height = img.size
|
|
|
|
# Return a copy to avoid issues with the context manager
|
|
return file_path, img.copy()
|
|
|
|
except Exception as e:
|
|
# Clean up temporary file on error
|
|
if temp_file and os.path.exists(temp_file):
|
|
try:
|
|
os.unlink(temp_file)
|
|
except:
|
|
pass
|
|
return None, None
|
|
|
|
def get_image_info(self) -> Dict[str, Any]:
|
|
"""
|
|
Get detailed information about the image using PIL.
|
|
|
|
Returns:
|
|
Dictionary containing image information including format, mode, size, etc.
|
|
Returns empty dict if image cannot be loaded.
|
|
"""
|
|
file_path, img = self.load_image_data(auto_update_dimensions=False)
|
|
|
|
if img is None:
|
|
return {}
|
|
|
|
# Try to determine format from the image, file extension, or source
|
|
img_format = img.format
|
|
if img_format is None:
|
|
# Try to determine format from file extension
|
|
format_map = {
|
|
'.jpg': 'JPEG',
|
|
'.jpeg': 'JPEG',
|
|
'.png': 'PNG',
|
|
'.gif': 'GIF',
|
|
'.bmp': 'BMP',
|
|
'.tiff': 'TIFF',
|
|
'.tif': 'TIFF'
|
|
}
|
|
|
|
# First try the actual file path if available
|
|
if file_path:
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
img_format = format_map.get(ext)
|
|
|
|
# If still no format and we have a URL source, try the original URL
|
|
if img_format is None and self._is_url(self._source):
|
|
ext = os.path.splitext(urllib.parse.urlparse(self._source).path)[1].lower()
|
|
img_format = format_map.get(ext)
|
|
|
|
info = {
|
|
'format': img_format,
|
|
'mode': img.mode,
|
|
'size': img.size,
|
|
'width': img.width,
|
|
'height': img.height,
|
|
}
|
|
|
|
# Add additional info if available
|
|
if hasattr(img, 'info'):
|
|
info['info'] = img.info
|
|
|
|
# Clean up temporary file if it was created
|
|
if file_path and self._is_url(self._source):
|
|
try:
|
|
os.unlink(file_path)
|
|
except:
|
|
pass
|
|
|
|
return info
|
|
|
|
|
|
class HorizontalRule(Block):
|
|
"""
|
|
A horizontal rule element (hr tag).
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize a horizontal rule element."""
|
|
super().__init__(BlockType.HORIZONTAL_RULE)
|
|
|
|
@classmethod
|
|
def create_and_add_to(cls, container) -> 'HorizontalRule':
|
|
"""
|
|
Create a new HorizontalRule and add it to a container.
|
|
|
|
Args:
|
|
container: The container to add the horizontal rule to (must have add_block method)
|
|
|
|
Returns:
|
|
The newly created HorizontalRule object
|
|
|
|
Raises:
|
|
AttributeError: If the container doesn't have the required add_block method
|
|
"""
|
|
# Create the new horizontal rule
|
|
hr = cls()
|
|
|
|
# Add the horizontal rule to the container
|
|
if hasattr(container, 'add_block'):
|
|
container.add_block(hr)
|
|
else:
|
|
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
|
|
|
return hr
|