This commit is contained in:
parent
49d4e551f8
commit
ea93681aaf
@ -7,6 +7,7 @@ import urllib.parse
|
||||
from PIL import Image as PILImage
|
||||
from .inline import Word, FormattedSpan
|
||||
from ..style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from ..core import Hierarchical, Styleable, FontRegistry
|
||||
|
||||
|
||||
class BlockType(Enum):
|
||||
@ -26,10 +27,12 @@ class BlockType(Enum):
|
||||
PAGE_BREAK = 13
|
||||
|
||||
|
||||
class Block:
|
||||
class Block(Hierarchical):
|
||||
"""
|
||||
Base class for all block-level elements.
|
||||
Block elements typically represent visual blocks of content that stack vertically.
|
||||
|
||||
Uses Hierarchical mixin for parent-child relationship management.
|
||||
"""
|
||||
|
||||
def __init__(self, block_type: BlockType):
|
||||
@ -39,28 +42,21 @@ class Block:
|
||||
Args:
|
||||
block_type: The type of block this element represents
|
||||
"""
|
||||
super().__init__()
|
||||
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):
|
||||
class Paragraph(Styleable, FontRegistry, Block):
|
||||
"""
|
||||
A paragraph is a block-level element that contains a sequence of words.
|
||||
|
||||
Uses Styleable mixin for style property management.
|
||||
Uses FontRegistry mixin for font caching with parent delegation.
|
||||
"""
|
||||
|
||||
def __init__(self, style=None):
|
||||
@ -70,11 +66,9 @@ class Paragraph(Block):
|
||||
Args:
|
||||
style: Optional default style for words in this paragraph
|
||||
"""
|
||||
super().__init__(BlockType.PARAGRAPH)
|
||||
super().__init__(style=style, block_type=BlockType.PARAGRAPH)
|
||||
self._words: List[Word] = []
|
||||
self._spans: List[FormattedSpan] = []
|
||||
self._style : style = style
|
||||
self._fonts: Dict[str, Font] = {} # Local font registry
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, style=None) -> 'Paragraph':
|
||||
@ -109,16 +103,6 @@ class Paragraph(Block):
|
||||
|
||||
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.
|
||||
@ -200,86 +184,7 @@ class Paragraph(Block):
|
||||
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
|
||||
# get_or_create_font() is provided by FontRegistry mixin
|
||||
|
||||
|
||||
class HeadingLevel(Enum):
|
||||
|
||||
@ -7,6 +7,7 @@ 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
|
||||
from ..core import FontRegistry, MetadataContainer
|
||||
|
||||
|
||||
class MetadataType(Enum):
|
||||
@ -24,10 +25,13 @@ class MetadataType(Enum):
|
||||
CUSTOM = 100
|
||||
|
||||
|
||||
class Document:
|
||||
class Document(FontRegistry, MetadataContainer):
|
||||
"""
|
||||
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.
|
||||
|
||||
Uses FontRegistry mixin for font caching.
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
|
||||
@ -39,13 +43,12 @@ class Document:
|
||||
language: The document language code
|
||||
default_style: Optional default style for child blocks
|
||||
"""
|
||||
super().__init__()
|
||||
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()
|
||||
@ -146,27 +149,7 @@ class Document:
|
||||
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)
|
||||
# set_metadata() and get_metadata() are provided by MetadataContainer mixin
|
||||
|
||||
def add_anchor(self, name: str, target: Block):
|
||||
"""
|
||||
@ -397,81 +380,16 @@ class Document:
|
||||
"""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
|
||||
# get_or_create_font() is provided by FontRegistry mixin
|
||||
|
||||
|
||||
class Chapter:
|
||||
class Chapter(FontRegistry, MetadataContainer):
|
||||
"""
|
||||
Represents a chapter or section in a document.
|
||||
A chapter contains a sequence of blocks and has metadata.
|
||||
|
||||
Uses FontRegistry mixin for font caching with parent delegation.
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
|
||||
@ -484,13 +402,12 @@ class Chapter:
|
||||
style: Optional default style for child blocks
|
||||
parent: Parent container (e.g., Document or Book)
|
||||
"""
|
||||
super().__init__()
|
||||
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]:
|
||||
@ -564,108 +481,8 @@ class Chapter:
|
||||
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
|
||||
# set_metadata() and get_metadata() are provided by MetadataContainer mixin
|
||||
# get_or_create_font() is provided by FontRegistry mixin
|
||||
|
||||
|
||||
class Book(Document):
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
from pyWebLayout.core.base import Queriable
|
||||
from pyWebLayout.core import Hierarchical
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
||||
@ -358,35 +359,27 @@ class LinkedWord(Word):
|
||||
return self._location
|
||||
|
||||
|
||||
class LineBreak():
|
||||
class LineBreak(Hierarchical):
|
||||
"""
|
||||
A line break element that forces a new line within text content.
|
||||
While this is an inline element that can occur within paragraphs,
|
||||
it has block-like properties for consistency with the abstract model.
|
||||
|
||||
Uses Hierarchical mixin for parent-child relationship management.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a line break element."""
|
||||
super().__init__()
|
||||
# Import here to avoid circular imports
|
||||
from .block import BlockType
|
||||
self._block_type = BlockType.LINE_BREAK
|
||||
self._parent = None
|
||||
|
||||
@property
|
||||
def block_type(self):
|
||||
"""Get the block type for this line break"""
|
||||
return self._block_type
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
"""Get the parent element containing this line break, if any"""
|
||||
return self._parent
|
||||
|
||||
@parent.setter
|
||||
def parent(self, parent):
|
||||
"""Set the parent element"""
|
||||
self._parent = parent
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container) -> 'LineBreak':
|
||||
"""
|
||||
|
||||
@ -132,11 +132,15 @@ class InteractiveImage(Image, Interactable, Queriable):
|
||||
callback=callback
|
||||
)
|
||||
|
||||
# Add to parent's children
|
||||
if hasattr(parent, 'add_child'):
|
||||
# Add to parent using its add_block method
|
||||
if hasattr(parent, 'add_block'):
|
||||
parent.add_block(img)
|
||||
elif hasattr(parent, 'add_child'):
|
||||
parent.add_child(img)
|
||||
elif hasattr(parent, '_children'):
|
||||
parent._children.append(img)
|
||||
elif hasattr(parent, '_blocks'):
|
||||
parent._blocks.append(img)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
@ -4,13 +4,18 @@ from PIL import Image
|
||||
from typing import Tuple, Union, List, Optional, Dict
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core import Geometric
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
class Box(Renderable, Queriable):
|
||||
class Box(Geometric, Renderable, Queriable):
|
||||
"""
|
||||
A box with geometric properties (origin and size).
|
||||
|
||||
Uses Geometric mixin for origin and size management.
|
||||
"""
|
||||
|
||||
def __init__(self,origin, size, callback = None, sheet : Image = None, mode: bool = None, halign=Alignment.CENTER, valign = Alignment.CENTER):
|
||||
self._origin = np.array(origin)
|
||||
self._size = np.array(size)
|
||||
super().__init__(origin=origin, size=size)
|
||||
self._end = self._origin + self._size
|
||||
self._callback = callback
|
||||
self._sheet : Image = sheet
|
||||
@ -21,16 +26,7 @@ class Box(Renderable, Queriable):
|
||||
self._halign = halign
|
||||
self._valign = valign
|
||||
|
||||
@property
|
||||
def origin(self) -> np.ndarray:
|
||||
"""Get the origin (top-left corner) of the box"""
|
||||
return self._origin
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the size (width, height) of the box"""
|
||||
return self._size
|
||||
# origin and size properties are provided by Geometric mixin
|
||||
|
||||
def in_shape(self, point):
|
||||
|
||||
return np.all((point >= self._origin) & (point < self._end), axis=-1)
|
||||
|
||||
@ -16,6 +16,7 @@ from dataclasses import dataclass
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.concrete.box import Box
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
|
||||
from pyWebLayout.abstract.interactive_image import InteractiveImage
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
|
||||
|
||||
@ -220,6 +221,13 @@ class TableCellRenderer(Box):
|
||||
text_y = y + (new_height - 12) // 2
|
||||
self._draw.text((text_x, text_y), text, fill=(100, 100, 100), font=small_font)
|
||||
|
||||
# Set bounds on InteractiveImage objects for tap detection
|
||||
if isinstance(image_block, InteractiveImage):
|
||||
image_block.set_rendered_bounds(
|
||||
origin=(img_x, y),
|
||||
size=(new_width, new_height)
|
||||
)
|
||||
|
||||
return y + new_height + 5 # Add some spacing after image
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -6,5 +6,7 @@ of the pyWebLayout rendering system.
|
||||
"""
|
||||
|
||||
from pyWebLayout.core.base import (
|
||||
Renderable, Interactable, Layoutable, Queriable
|
||||
Renderable, Interactable, Layoutable, Queriable,
|
||||
Hierarchical, Geometric, Styleable, FontRegistry,
|
||||
MetadataContainer, BlockContainer, ContainerAware
|
||||
)
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
from abc import ABC
|
||||
from typing import Optional, Tuple, List, TYPE_CHECKING
|
||||
from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
class Renderable(ABC):
|
||||
@ -74,3 +75,361 @@ class Queriable(ABC):
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
return np.all((0 <= relative_point) & (relative_point < self.size))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Mixins - Reusable components for common patterns
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class Hierarchical:
|
||||
"""
|
||||
Mixin providing parent-child relationship management.
|
||||
|
||||
Classes using this mixin can track their parent in a document hierarchy.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._parent: Optional[Any] = None
|
||||
|
||||
@property
|
||||
def parent(self) -> Optional[Any]:
|
||||
"""Get the parent object containing this object, if any"""
|
||||
return self._parent
|
||||
|
||||
@parent.setter
|
||||
def parent(self, parent: Any):
|
||||
"""Set the parent object"""
|
||||
self._parent = parent
|
||||
|
||||
|
||||
class Geometric:
|
||||
"""
|
||||
Mixin providing origin and size properties for positioned elements.
|
||||
|
||||
Provides standard geometric properties for elements that have a position
|
||||
and size in 2D space. Uses numpy arrays for efficient calculations.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, origin=None, size=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
|
||||
self._size = np.array(size) if size is not None else np.array([0, 0])
|
||||
|
||||
@property
|
||||
def origin(self) -> np.ndarray:
|
||||
"""Get the origin (top-left corner) of the element"""
|
||||
return self._origin
|
||||
|
||||
@origin.setter
|
||||
def origin(self, origin: np.ndarray):
|
||||
"""Set the origin of the element"""
|
||||
self._origin = np.array(origin)
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the size (width, height) of the element"""
|
||||
return self._size
|
||||
|
||||
@size.setter
|
||||
def size(self, size: np.ndarray):
|
||||
"""Set the size of the element"""
|
||||
self._size = np.array(size)
|
||||
|
||||
def set_origin(self, origin: np.ndarray):
|
||||
"""Set the origin of this element (alternative setter method)"""
|
||||
self._origin = np.array(origin)
|
||||
|
||||
|
||||
class Styleable:
|
||||
"""
|
||||
Mixin providing style property management.
|
||||
|
||||
Classes using this mixin can have a style property that can be
|
||||
inherited from parents or set explicitly.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, style=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._style = style
|
||||
|
||||
@property
|
||||
def style(self) -> Optional[Any]:
|
||||
"""Get the style for this element"""
|
||||
return self._style
|
||||
|
||||
@style.setter
|
||||
def style(self, style: Any):
|
||||
"""Set the style for this element"""
|
||||
self._style = style
|
||||
|
||||
|
||||
class FontRegistry:
|
||||
"""
|
||||
Mixin providing font caching and creation with parent delegation.
|
||||
|
||||
This mixin allows classes to maintain a local font registry and create/reuse
|
||||
Font objects efficiently. It supports parent delegation, where font requests
|
||||
can cascade up to a parent container if one exists.
|
||||
|
||||
Classes using this mixin should also use Hierarchical to support parent delegation.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._fonts: Dict[str, 'Font'] = {}
|
||||
|
||||
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' = None,
|
||||
style: 'FontStyle' = None,
|
||||
decoration: '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.
|
||||
|
||||
This method will first check if a parent object has a get_or_create_font
|
||||
method and delegate to it. Otherwise, it will manage fonts locally.
|
||||
|
||||
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)
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
# Set defaults for enum types
|
||||
if weight is None:
|
||||
weight = FontWeight.NORMAL
|
||||
if style is None:
|
||||
style = FontStyle.NORMAL
|
||||
if decoration is None:
|
||||
decoration = TextDecoration.NONE
|
||||
|
||||
# If we have a parent with font management, delegate to parent
|
||||
if hasattr(self, '_parent') and 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 hasattr(weight, 'value') else weight,
|
||||
style.value if hasattr(style, 'value') else style,
|
||||
decoration.value if hasattr(decoration, 'value') 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 MetadataContainer:
|
||||
"""
|
||||
Mixin providing metadata dictionary management.
|
||||
|
||||
Allows classes to store and retrieve arbitrary metadata as key-value pairs.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._metadata: Dict[Any, Any] = {}
|
||||
|
||||
def set_metadata(self, key: Any, value: Any):
|
||||
"""
|
||||
Set a metadata value.
|
||||
|
||||
Args:
|
||||
key: The metadata key
|
||||
value: The metadata value
|
||||
"""
|
||||
self._metadata[key] = value
|
||||
|
||||
def get_metadata(self, key: Any) -> 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)
|
||||
|
||||
|
||||
class BlockContainer:
|
||||
"""
|
||||
Mixin providing block management methods.
|
||||
|
||||
Provides standard methods for managing block-level children including
|
||||
adding blocks and creating common block types.
|
||||
|
||||
Classes using this mixin should also use Styleable to support style inheritance.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._blocks = []
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
"""Get the list of blocks in this container"""
|
||||
return self._blocks
|
||||
|
||||
def add_block(self, block):
|
||||
"""
|
||||
Add a block to this container.
|
||||
|
||||
Args:
|
||||
block: The block to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
if hasattr(block, 'parent'):
|
||||
block.parent = self
|
||||
|
||||
def create_paragraph(self, style=None):
|
||||
"""
|
||||
Create a new paragraph and add it to this container.
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from container
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
|
||||
if style is None and hasattr(self, '_style'):
|
||||
style = self._style
|
||||
|
||||
paragraph = Paragraph(style)
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level=None, style=None):
|
||||
"""
|
||||
Create a new heading and add it to this container.
|
||||
|
||||
Args:
|
||||
level: The heading level (h1-h6)
|
||||
style: Optional style override. If None, inherits from container
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
from pyWebLayout.abstract.block import Heading, HeadingLevel
|
||||
|
||||
if level is None:
|
||||
level = HeadingLevel.H1
|
||||
|
||||
if style is None and hasattr(self, '_style'):
|
||||
style = self._style
|
||||
|
||||
heading = Heading(level, style)
|
||||
self.add_block(heading)
|
||||
return heading
|
||||
|
||||
|
||||
class ContainerAware:
|
||||
"""
|
||||
Mixin providing support for the create_and_add_to factory pattern.
|
||||
|
||||
This is a base that can be extended to provide the create_and_add_to
|
||||
class method pattern used throughout the abstract module.
|
||||
|
||||
Note: This is a framework for future refactoring. Currently, each class
|
||||
has its own create_and_add_to implementation due to varying constructor
|
||||
signatures. This mixin provides a foundation for standardizing that pattern.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _validate_container(cls, container, required_method='add_block'):
|
||||
"""
|
||||
Validate that a container has the required method.
|
||||
|
||||
Args:
|
||||
container: The container to validate
|
||||
required_method: The method name to check for
|
||||
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required method
|
||||
"""
|
||||
if not hasattr(container, required_method):
|
||||
raise AttributeError(
|
||||
f"Container {type(container).__name__} must have a '{required_method}' method"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _inherit_style(cls, container, style=None):
|
||||
"""
|
||||
Inherit style from container if not explicitly provided.
|
||||
|
||||
Args:
|
||||
container: The container to inherit from
|
||||
style: Optional explicit style
|
||||
|
||||
Returns:
|
||||
The style to use (explicit or inherited)
|
||||
"""
|
||||
if style is not None:
|
||||
return style
|
||||
|
||||
if hasattr(container, 'style'):
|
||||
return container.style
|
||||
elif hasattr(container, 'default_style'):
|
||||
return container.default_style
|
||||
|
||||
return None
|
||||
|
||||
58
tests/abstract/test_document_mixins.py
Normal file
58
tests/abstract/test_document_mixins.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""
|
||||
Simplified unit tests for abstract document elements using test mixins.
|
||||
|
||||
This demonstrates how test mixins can eliminate duplication and simplify tests.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.abstract.document import Document, Chapter
|
||||
from tests.mixins.font_registry_tests import FontRegistryTestMixin, FontRegistryParentDelegationTestMixin
|
||||
from tests.mixins.metadata_tests import MetadataContainerTestMixin
|
||||
|
||||
|
||||
class TestDocumentFontRegistry(FontRegistryTestMixin, unittest.TestCase):
|
||||
"""Test FontRegistry behavior for Document - simplified with mixin."""
|
||||
|
||||
def create_test_object(self):
|
||||
"""Create a Document instance for testing."""
|
||||
return Document("Test Document", "en-US")
|
||||
|
||||
|
||||
class TestDocumentMetadata(MetadataContainerTestMixin, unittest.TestCase):
|
||||
"""Test MetadataContainer behavior for Document - simplified with mixin."""
|
||||
|
||||
def create_test_object(self):
|
||||
"""Create a Document instance for testing."""
|
||||
return Document("Test Document", "en-US")
|
||||
|
||||
|
||||
class TestChapterFontRegistry(FontRegistryTestMixin, unittest.TestCase):
|
||||
"""Test FontRegistry behavior for Chapter - simplified with mixin."""
|
||||
|
||||
def create_test_object(self):
|
||||
"""Create a Chapter instance for testing."""
|
||||
return Chapter("Test Chapter", level=1)
|
||||
|
||||
|
||||
class TestChapterFontRegistryParentDelegation(FontRegistryParentDelegationTestMixin, unittest.TestCase):
|
||||
"""Test FontRegistry parent delegation for Chapter - simplified with mixin."""
|
||||
|
||||
def create_parent(self):
|
||||
"""Create a Document as parent."""
|
||||
return Document("Parent Document", "en-US")
|
||||
|
||||
def create_child(self, parent):
|
||||
"""Create a Chapter with parent reference."""
|
||||
return Chapter("Child Chapter", level=1, parent=parent)
|
||||
|
||||
|
||||
class TestChapterMetadata(MetadataContainerTestMixin, unittest.TestCase):
|
||||
"""Test MetadataContainer behavior for Chapter - simplified with mixin."""
|
||||
|
||||
def create_test_object(self):
|
||||
"""Create a Chapter instance for testing."""
|
||||
return Chapter("Test Chapter", level=1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
0
tests/mixins/__init__.py
Normal file
0
tests/mixins/__init__.py
Normal file
116
tests/mixins/font_registry_tests.py
Normal file
116
tests/mixins/font_registry_tests.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Test mixins for FontRegistry functionality.
|
||||
|
||||
Provides reusable test cases for any class that uses the FontRegistry mixin.
|
||||
"""
|
||||
|
||||
from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
class FontRegistryTestMixin:
|
||||
"""
|
||||
Mixin providing standard tests for FontRegistry behavior.
|
||||
|
||||
Classes using this mixin must implement:
|
||||
- create_test_object() -> object with FontRegistry mixin
|
||||
"""
|
||||
|
||||
def create_test_object(self):
|
||||
"""
|
||||
Create an instance of the object to test.
|
||||
Must be implemented by test class.
|
||||
"""
|
||||
raise NotImplementedError("Test class must implement create_test_object()")
|
||||
|
||||
def test_font_caching(self):
|
||||
"""Test that fonts with identical properties are cached and reused."""
|
||||
obj = self.create_test_object()
|
||||
|
||||
# Create font twice with same properties
|
||||
font1 = obj.get_or_create_font(font_size=14, colour=(255, 0, 0), weight=FontWeight.BOLD)
|
||||
font2 = obj.get_or_create_font(font_size=14, colour=(255, 0, 0), weight=FontWeight.BOLD)
|
||||
|
||||
# Should return the same font object (cached)
|
||||
self.assertIs(font1, font2, "Fonts with identical properties should be cached")
|
||||
|
||||
# Should only have one font in registry
|
||||
self.assertEqual(len(obj._fonts), 1, "Registry should contain exactly one font")
|
||||
|
||||
def test_font_differentiation(self):
|
||||
"""Test that fonts with different properties create separate instances."""
|
||||
obj = self.create_test_object()
|
||||
|
||||
# Create fonts that differ in exactly one property each
|
||||
base_params = {'colour': (0, 0, 0), 'weight': FontWeight.NORMAL}
|
||||
|
||||
font1 = obj.get_or_create_font(font_size=14, **base_params)
|
||||
font2 = obj.get_or_create_font(font_size=16, **base_params) # Different size
|
||||
|
||||
base_params2 = {'font_size': 18, 'weight': FontWeight.NORMAL}
|
||||
font3 = obj.get_or_create_font(colour=(255, 0, 0), **base_params2) # Different color
|
||||
|
||||
base_params3 = {'font_size': 20, 'colour': (100, 100, 100)}
|
||||
font4 = obj.get_or_create_font(weight=FontWeight.BOLD, **base_params3) # Different weight
|
||||
|
||||
# All should be different objects
|
||||
self.assertIsNot(font1, font2, "Fonts with different sizes should be distinct")
|
||||
self.assertIsNot(font1, font3, "Fonts with different colors should be distinct")
|
||||
self.assertIsNot(font1, font4, "Fonts with different weights should be distinct")
|
||||
self.assertIsNot(font2, font3, "Fonts should be distinct")
|
||||
self.assertIsNot(font2, font4, "Fonts should be distinct")
|
||||
self.assertIsNot(font3, font4, "Fonts should be distinct")
|
||||
|
||||
# Should have 4 fonts in registry
|
||||
self.assertEqual(len(obj._fonts), 4, "Should have 4 distinct fonts")
|
||||
|
||||
def test_font_properties(self):
|
||||
"""Test that created fonts have the correct properties."""
|
||||
obj = self.create_test_object()
|
||||
|
||||
font = obj.get_or_create_font(
|
||||
font_size=18,
|
||||
colour=(128, 64, 32),
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC,
|
||||
decoration=TextDecoration.UNDERLINE
|
||||
)
|
||||
|
||||
self.assertEqual(font.font_size, 18)
|
||||
self.assertEqual(font.colour, (128, 64, 32))
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
|
||||
|
||||
|
||||
class FontRegistryParentDelegationTestMixin:
|
||||
"""
|
||||
Test mixin for FontRegistry parent delegation functionality.
|
||||
|
||||
Classes using this mixin must implement:
|
||||
- create_parent() -> parent object with FontRegistry
|
||||
- create_child(parent) -> child object with FontRegistry and parent link
|
||||
"""
|
||||
|
||||
def create_parent(self):
|
||||
"""Create a parent object with FontRegistry. Must be implemented."""
|
||||
raise NotImplementedError("Test class must implement create_parent()")
|
||||
|
||||
def create_child(self, parent):
|
||||
"""Create a child object with parent reference. Must be implemented."""
|
||||
raise NotImplementedError("Test class must implement create_child(parent)")
|
||||
|
||||
def test_parent_delegation(self):
|
||||
"""Test that font creation delegates to parent when available."""
|
||||
parent = self.create_parent()
|
||||
child = self.create_child(parent)
|
||||
|
||||
# Create font through child
|
||||
font = child.get_or_create_font(font_size=16, colour=(200, 100, 50))
|
||||
|
||||
# Font should be in parent's registry, not child's
|
||||
self.assertEqual(len(parent._fonts), 1, "Font should be in parent registry")
|
||||
self.assertEqual(len(child._fonts), 0, "Font should NOT be in child registry")
|
||||
|
||||
# Getting same font through child should return parent's font
|
||||
font2 = child.get_or_create_font(font_size=16, colour=(200, 100, 50))
|
||||
self.assertIs(font, font2, "Child should reuse parent's font")
|
||||
68
tests/mixins/metadata_tests.py
Normal file
68
tests/mixins/metadata_tests.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""
|
||||
Test mixins for MetadataContainer functionality.
|
||||
|
||||
Provides reusable test cases for any class that uses the MetadataContainer mixin.
|
||||
"""
|
||||
|
||||
|
||||
class MetadataContainerTestMixin:
|
||||
"""
|
||||
Mixin providing standard tests for MetadataContainer behavior.
|
||||
|
||||
Classes using this mixin must implement:
|
||||
- create_test_object() -> object with MetadataContainer mixin
|
||||
"""
|
||||
|
||||
def create_test_object(self):
|
||||
"""
|
||||
Create an instance of the object to test.
|
||||
Must be implemented by test class.
|
||||
"""
|
||||
raise NotImplementedError("Test class must implement create_test_object()")
|
||||
|
||||
def test_set_and_get_metadata(self):
|
||||
"""Test basic metadata storage and retrieval."""
|
||||
obj = self.create_test_object()
|
||||
|
||||
# Set various types of metadata
|
||||
obj.set_metadata("string_key", "string_value")
|
||||
obj.set_metadata("int_key", 42)
|
||||
obj.set_metadata("list_key", [1, 2, 3])
|
||||
obj.set_metadata("dict_key", {"nested": "value"})
|
||||
|
||||
# Verify retrieval
|
||||
self.assertEqual(obj.get_metadata("string_key"), "string_value")
|
||||
self.assertEqual(obj.get_metadata("int_key"), 42)
|
||||
self.assertEqual(obj.get_metadata("list_key"), [1, 2, 3])
|
||||
self.assertEqual(obj.get_metadata("dict_key"), {"nested": "value"})
|
||||
|
||||
def test_get_nonexistent_metadata(self):
|
||||
"""Test that getting nonexistent metadata returns None."""
|
||||
obj = self.create_test_object()
|
||||
|
||||
result = obj.get_metadata("nonexistent_key")
|
||||
self.assertIsNone(result, "Nonexistent metadata should return None")
|
||||
|
||||
def test_update_metadata(self):
|
||||
"""Test that metadata values can be updated."""
|
||||
obj = self.create_test_object()
|
||||
|
||||
# Set initial value
|
||||
obj.set_metadata("key", "initial")
|
||||
self.assertEqual(obj.get_metadata("key"), "initial")
|
||||
|
||||
# Update value
|
||||
obj.set_metadata("key", "updated")
|
||||
self.assertEqual(obj.get_metadata("key"), "updated", "Metadata should be updateable")
|
||||
|
||||
def test_metadata_isolation(self):
|
||||
"""Test that metadata is isolated between instances."""
|
||||
obj1 = self.create_test_object()
|
||||
obj2 = self.create_test_object()
|
||||
|
||||
obj1.set_metadata("key", "value1")
|
||||
obj2.set_metadata("key", "value2")
|
||||
|
||||
self.assertEqual(obj1.get_metadata("key"), "value1")
|
||||
self.assertEqual(obj2.get_metadata("key"), "value2")
|
||||
self.assertNotEqual(obj1.get_metadata("key"), obj2.get_metadata("key"))
|
||||
0
tests/style/__init__.py
Normal file
0
tests/style/__init__.py
Normal file
Loading…
x
Reference in New Issue
Block a user