From ea93681aaf4bcb591ea10660d5858517e5787e34 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 8 Nov 2025 08:08:02 +0100 Subject: [PATCH] refactoring the mixin system --- pyWebLayout/abstract/block.py | 129 +------- pyWebLayout/abstract/document.py | 229 ++------------ pyWebLayout/abstract/inline.py | 21 +- pyWebLayout/abstract/interactive_image.py | 8 +- pyWebLayout/concrete/box.py | 24 +- pyWebLayout/concrete/table.py | 8 + pyWebLayout/core/__init__.py | 4 +- pyWebLayout/core/base.py | 361 +++++++++++++++++++++- tests/abstract/test_document_mixins.py | 58 ++++ tests/mixins/__init__.py | 0 tests/mixins/font_registry_tests.py | 116 +++++++ tests/mixins/metadata_tests.py | 68 ++++ tests/style/__init__.py | 0 13 files changed, 676 insertions(+), 350 deletions(-) create mode 100644 tests/abstract/test_document_mixins.py create mode 100644 tests/mixins/__init__.py create mode 100644 tests/mixins/font_registry_tests.py create mode 100644 tests/mixins/metadata_tests.py create mode 100644 tests/style/__init__.py diff --git a/pyWebLayout/abstract/block.py b/pyWebLayout/abstract/block.py index 98536e3..e972a9f 100644 --- a/pyWebLayout/abstract/block.py +++ b/pyWebLayout/abstract/block.py @@ -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,55 +27,48 @@ 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): """ Initialize a block element. - + 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): """ Initialize an empty paragraph - + 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': @@ -108,17 +102,7 @@ class Paragraph(Block): 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. @@ -199,87 +183,8 @@ 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): diff --git a/pyWebLayout/abstract/document.py b/pyWebLayout/abstract/document.py index a510566..ff52e2c 100644 --- a/pyWebLayout/abstract/document.py +++ b/pyWebLayout/abstract/document.py @@ -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,28 +25,30 @@ 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): """ Initialize a new document. - + Args: title: The document title 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() @@ -145,29 +148,9 @@ class Document: if style is None: style = self._default_style return Chapter(title, level, style) - - def set_metadata(self, meta_type: MetadataType, value: Any): - """ - Set a metadata value. - - Args: - meta_type: The type of metadata - value: The metadata value - """ - self._metadata[meta_type] = value - - def get_metadata(self, meta_type: MetadataType) -> Optional[Any]: - """ - Get a metadata value. - - Args: - meta_type: The type of metadata - - Returns: - The metadata value, or None if not set - """ - return self._metadata.get(meta_type) - + + # set_metadata() and get_metadata() are provided by MetadataContainer mixin + def add_anchor(self, name: str, target: Block): """ Add a named anchor to this document. @@ -396,101 +379,35 @@ class Document: def get_concrete_style_registry(self) -> ConcreteStyleRegistry: """Get the concrete style registry for this document.""" return self._concrete_style_registry - - def get_or_create_font(self, - font_path: Optional[str] = None, - font_size: int = 16, - colour: Tuple[int, int, int] = (0, 0, 0), - weight: FontWeight = FontWeight.NORMAL, - style: FontStyle = FontStyle.NORMAL, - decoration: TextDecoration = TextDecoration.NONE, - background: Optional[Tuple[int, int, int, int]] = None, - language: str = "en_EN", - min_hyphenation_width: Optional[int] = None) -> Font: - """ - Get or create a font with the specified properties. - - Args: - font_path: Path to the font file (.ttf, .otf). If None, uses default font. - font_size: Size of the font in points. - colour: RGB color tuple for the text. - weight: Font weight (normal or bold). - style: Font style (normal or italic). - decoration: Text decoration (none, underline, or strikethrough). - background: RGBA background color for the text. If None, transparent background. - language: Language code for hyphenation and text processing. - min_hyphenation_width: Minimum width in pixels required for hyphenation. - - Returns: - Font object (either existing or newly created) - """ - # Initialize font registry if it doesn't exist - if not hasattr(self, '_fonts'): - self._fonts: Dict[str, Font] = {} - - # Create a unique key for this font configuration - bg_tuple = background if background else (255, 255, 255, 0) - min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4 - - font_key = ( - font_path, - font_size, - colour, - weight.value if isinstance(weight, FontWeight) else weight, - style.value if isinstance(style, FontStyle) else style, - decoration.value if isinstance(decoration, TextDecoration) else decoration, - bg_tuple, - language, - min_hyph_width - ) - - # Convert tuple to string for dictionary key - key_str = str(font_key) - - # Check if we already have this font - if key_str in self._fonts: - return self._fonts[key_str] - - # Create new font and store it - new_font = Font( - font_path=font_path, - font_size=font_size, - colour=colour, - weight=weight, - style=style, - decoration=decoration, - background=background, - language=language, - min_hyphenation_width=min_hyphenation_width - ) - - self._fonts[key_str] = new_font - return new_font + + # 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): """ Initialize a new chapter. - + Args: title: The chapter title level: The chapter level (1 = top level, 2 = subsection, etc.) style: Optional default style for child blocks parent: Parent container (e.g., Document or Book) """ + 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]: @@ -563,109 +480,9 @@ class Chapter: heading = Heading(level, style) self.add_block(heading) return heading - - def set_metadata(self, key: str, value: Any): - """ - Set a metadata value. - - Args: - key: The metadata key - value: The metadata value - """ - self._metadata[key] = value - - def get_metadata(self, key: str) -> Optional[Any]: - """ - Get a metadata value. - - Args: - key: The metadata key - - Returns: - The metadata value, or None if not set - """ - return self._metadata.get(key) - - def get_or_create_font(self, - font_path: Optional[str] = None, - font_size: int = 16, - colour: Tuple[int, int, int] = (0, 0, 0), - weight: FontWeight = FontWeight.NORMAL, - style: FontStyle = FontStyle.NORMAL, - decoration: TextDecoration = TextDecoration.NONE, - background: Optional[Tuple[int, int, int, int]] = None, - language: str = "en_EN", - min_hyphenation_width: Optional[int] = None) -> Font: - """ - Get or create a font with the specified properties. Cascades to parent if available. - - Args: - font_path: Path to the font file (.ttf, .otf). If None, uses default font. - font_size: Size of the font in points. - colour: RGB color tuple for the text. - weight: Font weight (normal or bold). - style: Font style (normal or italic). - decoration: Text decoration (none, underline, or strikethrough). - background: RGBA background color for the text. If None, transparent background. - language: Language code for hyphenation and text processing. - min_hyphenation_width: Minimum width in pixels required for hyphenation. - - Returns: - Font object (either existing or newly created) - """ - # If we have a parent with font management, delegate to parent - if self._parent and hasattr(self._parent, 'get_or_create_font'): - return self._parent.get_or_create_font( - font_path=font_path, - font_size=font_size, - colour=colour, - weight=weight, - style=style, - decoration=decoration, - background=background, - language=language, - min_hyphenation_width=min_hyphenation_width - ) - - # Otherwise manage our own fonts - # Create a unique key for this font configuration - bg_tuple = background if background else (255, 255, 255, 0) - min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4 - - font_key = ( - font_path, - font_size, - colour, - weight.value if isinstance(weight, FontWeight) else weight, - style.value if isinstance(style, FontStyle) else style, - decoration.value if isinstance(decoration, TextDecoration) else decoration, - bg_tuple, - language, - min_hyph_width - ) - - # Convert tuple to string for dictionary key - key_str = str(font_key) - - # Check if we already have this font - if key_str in self._fonts: - return self._fonts[key_str] - - # Create new font and store it - new_font = Font( - font_path=font_path, - font_size=font_size, - colour=colour, - weight=weight, - style=style, - decoration=decoration, - background=background, - language=language, - min_hyphenation_width=min_hyphenation_width - ) - - self._fonts[key_str] = new_font - return new_font + + # set_metadata() and get_metadata() are provided by MetadataContainer mixin + # get_or_create_font() is provided by FontRegistry mixin class Book(Document): diff --git a/pyWebLayout/abstract/inline.py b/pyWebLayout/abstract/inline.py index 9e66a87..f2fdc5d 100644 --- a/pyWebLayout/abstract/inline.py +++ b/pyWebLayout/abstract/inline.py @@ -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': """ diff --git a/pyWebLayout/abstract/interactive_image.py b/pyWebLayout/abstract/interactive_image.py index 76605b5..453ac0b 100644 --- a/pyWebLayout/abstract/interactive_image.py +++ b/pyWebLayout/abstract/interactive_image.py @@ -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 diff --git a/pyWebLayout/concrete/box.py b/pyWebLayout/concrete/box.py index a872c60..88cb74f 100644 --- a/pyWebLayout/concrete/box.py +++ b/pyWebLayout/concrete/box.py @@ -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 - - def in_shape(self, point): + # 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) diff --git a/pyWebLayout/concrete/table.py b/pyWebLayout/concrete/table.py index c47739d..596d3d1 100644 --- a/pyWebLayout/concrete/table.py +++ b/pyWebLayout/concrete/table.py @@ -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: diff --git a/pyWebLayout/core/__init__.py b/pyWebLayout/core/__init__.py index 08cd89d..2421de8 100644 --- a/pyWebLayout/core/__init__.py +++ b/pyWebLayout/core/__init__.py @@ -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 ) diff --git a/pyWebLayout/core/base.py b/pyWebLayout/core/base.py index 8ae3928..c4da9d9 100644 --- a/pyWebLayout/core/base.py +++ b/pyWebLayout/core/base.py @@ -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 diff --git a/tests/abstract/test_document_mixins.py b/tests/abstract/test_document_mixins.py new file mode 100644 index 0000000..90a0623 --- /dev/null +++ b/tests/abstract/test_document_mixins.py @@ -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() diff --git a/tests/mixins/__init__.py b/tests/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mixins/font_registry_tests.py b/tests/mixins/font_registry_tests.py new file mode 100644 index 0000000..2ed4b8c --- /dev/null +++ b/tests/mixins/font_registry_tests.py @@ -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") diff --git a/tests/mixins/metadata_tests.py b/tests/mixins/metadata_tests.py new file mode 100644 index 0000000..0eecfb5 --- /dev/null +++ b/tests/mixins/metadata_tests.py @@ -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")) diff --git a/tests/style/__init__.py b/tests/style/__init__.py new file mode 100644 index 0000000..e69de29