Duncan Tourolle edac4de5b4
All checks were successful
Python CI / test (push) Successful in 5m6s
use font registry rather than make each time
2025-06-21 11:38:53 +02:00

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