refactoring the mixin system
All checks were successful
Python CI / test (push) Successful in 6m46s

This commit is contained in:
Duncan Tourolle 2025-11-08 08:08:02 +01:00
parent 49d4e551f8
commit ea93681aaf
13 changed files with 676 additions and 350 deletions

View File

@ -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):

View File

@ -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):

View File

@ -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':
"""

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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
)

View File

@ -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

View 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
View File

View 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")

View 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
View File