From ae15fe54e8de467ce876cb80727d897c7eaaa936 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 22 Jun 2025 13:42:15 +0200 Subject: [PATCH] new style handling --- pyWebLayout/abstract/document.py | 138 ++++--- pyWebLayout/abstract/inline.py | 33 +- pyWebLayout/io/readers/html_extraction.py | 1 + pyWebLayout/style/__init__.py | 8 + pyWebLayout/style/abstract_style.py | 334 +++++++++++++++ pyWebLayout/style/concrete_style.py | 434 +++++++++++++++++++ tests/test_monospace_basic.py | 223 ++++++++++ tests/test_monospace_concepts.py | 343 +++++++++++++++ tests/test_monospace_hyphenation.py | 348 ++++++++++++++++ tests/test_monospace_rendering.py | 483 ++++++++++++++++++++++ tests/test_new_style_system.py | 232 +++++++++++ 11 files changed, 2511 insertions(+), 66 deletions(-) create mode 100644 pyWebLayout/style/abstract_style.py create mode 100644 pyWebLayout/style/concrete_style.py create mode 100644 tests/test_monospace_basic.py create mode 100644 tests/test_monospace_concepts.py create mode 100644 tests/test_monospace_hyphenation.py create mode 100644 tests/test_monospace_rendering.py create mode 100644 tests/test_new_style_system.py diff --git a/pyWebLayout/abstract/document.py b/pyWebLayout/abstract/document.py index 0ff9636..61a14e7 100644 --- a/pyWebLayout/abstract/document.py +++ b/pyWebLayout/abstract/document.py @@ -5,6 +5,8 @@ from .block import Block, BlockType, Heading, HeadingLevel, Paragraph from .functional import Link, Button, Form 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 class MetadataType(Enum): @@ -43,8 +45,27 @@ class Document: self._resources: Dict[str, Any] = {} # External resources like images self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets self._scripts: List[str] = [] # JavaScript code + + # Style management with new abstract/concrete system + self._abstract_style_registry = AbstractStyleRegistry() + self._rendering_context = RenderingContext(default_language=language) + self._style_resolver = StyleResolver(self._rendering_context) + self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver) + + # Set default style + if default_style is None: + # Create a default abstract style + default_style = self._abstract_style_registry.default_style + elif isinstance(default_style, Font): + # Convert Font to AbstractStyle for backward compatibility + default_style = AbstractStyle( + font_family=FontFamily.SERIF, # Default assumption + font_size=default_style.font_size, + color=default_style.colour, + language=default_style.language + ) + style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style) self._default_style = default_style - self._fonts: Dict[str, Font] = {} # Font registry for reusing font objects # Set basic metadata if title: @@ -305,72 +326,75 @@ class Document: return toc - 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: + def get_or_create_style(self, + font_family: FontFamily = FontFamily.SERIF, + font_size: Union[FontSize, int] = FontSize.MEDIUM, + font_weight: FontWeight = FontWeight.NORMAL, + font_style: FontStyle = FontStyle.NORMAL, + text_decoration: TextDecoration = TextDecoration.NONE, + color: Union[str, Tuple[int, int, int]] = "black", + background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None, + language: str = "en-US", + **kwargs) -> Tuple[str, AbstractStyle]: """ - Get or create a font with the specified properties. Reuses existing fonts - when possible to avoid creating duplicate font objects. + Get or create an abstract style 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. + font_family: Semantic font family + font_size: Font size (semantic or numeric) + font_weight: Font weight + font_style: Font style + text_decoration: Text decoration + color: Text color (name or RGB tuple) + background_color: Background color + language: Language code + **kwargs: Additional style properties Returns: - Font object (either existing or newly created) + Tuple of (style_id, AbstractStyle) """ - # 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, + abstract_style = AbstractStyle( + font_family=font_family, font_size=font_size, - colour=colour, - weight=weight, - style=style, - decoration=decoration, - background=background, + font_weight=font_weight, + font_style=font_style, + text_decoration=text_decoration, + color=color, + background_color=background_color, language=language, - min_hyphenation_width=min_hyphenation_width + **kwargs ) - self._fonts[key_str] = new_font - return new_font + return self._abstract_style_registry.get_or_create_style(abstract_style) + + def get_font_for_style(self, abstract_style: AbstractStyle) -> Font: + """ + Get a Font object for an AbstractStyle (for rendering). + + Args: + abstract_style: The abstract style to get a font for + + Returns: + Font object ready for rendering + """ + return self._concrete_style_registry.get_font(abstract_style) + + def update_rendering_context(self, **kwargs): + """ + Update the rendering context (user preferences, device settings, etc.). + + Args: + **kwargs: Context properties to update (base_font_size, font_scale_factor, etc.) + """ + self._style_resolver.update_context(**kwargs) + + def get_style_registry(self) -> AbstractStyleRegistry: + """Get the abstract style registry for this document.""" + return self._abstract_style_registry + + def get_concrete_style_registry(self) -> ConcreteStyleRegistry: + """Get the concrete style registry for this document.""" + return self._concrete_style_registry class Chapter: diff --git a/pyWebLayout/abstract/inline.py b/pyWebLayout/abstract/inline.py index 209b0ee..fdf17b9 100644 --- a/pyWebLayout/abstract/inline.py +++ b/pyWebLayout/abstract/inline.py @@ -1,6 +1,7 @@ from __future__ import annotations from pyWebLayout.core.base import Queriable from pyWebLayout.style import Font +from pyWebLayout.style.abstract_style import AbstractStyle from typing import Tuple, Union, List, Optional, Dict import pyphen @@ -10,21 +11,23 @@ class Word: An abstract representation of a word in a document. Words can be split across lines or pages during rendering. This class manages the logical representation of a word without any rendering specifics. + + Now uses AbstractStyle objects for memory efficiency and proper style management. """ - def __init__(self, text: str, style: Font, background=None, previous: Union[Word, None] = None): + def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union[Word, None] = None): """ Initialize a new Word. Args: text: The text content of the word - style: Font style information for the word + style: AbstractStyle object or Font object (for backward compatibility) background: Optional background color override previous: Reference to the previous word in sequence """ self._text = text self._style = style - self._background = background if background else style.background + self._background = background self._previous = previous self._next = None self._hyphenated_parts = None # Will store hyphenated parts if word is hyphenated @@ -158,9 +161,15 @@ class Word: Returns: bool: True if the word can be hyphenated, False otherwise. """ - # Use the provided language or fall back to style language - lang = language if language else self._style.language - dic = pyphen.Pyphen(lang=lang) + # Get language from style (handling both AbstractStyle and Font objects) + if language is None: + if isinstance(self._style, AbstractStyle): + language = self._style.language + else: + # Font object + language = self._style.language + + dic = pyphen.Pyphen(lang=language) # Check if the word can be hyphenated hyphenated = dic.inserted(self._text, hyphen='-') @@ -176,9 +185,15 @@ class Word: Returns: bool: True if the word was hyphenated, False otherwise. """ - # Use the provided language or fall back to style language - lang = language if language else self._style.language - dic = pyphen.Pyphen(lang=lang) + # Get language from style (handling both AbstractStyle and Font objects) + if language is None: + if isinstance(self._style, AbstractStyle): + language = self._style.language + else: + # Font object + language = self._style.language + + dic = pyphen.Pyphen(lang=language) # Get hyphenated version hyphenated = dic.inserted(self._text, hyphen='-') diff --git a/pyWebLayout/io/readers/html_extraction.py b/pyWebLayout/io/readers/html_extraction.py index fb64e1b..01628bb 100644 --- a/pyWebLayout/io/readers/html_extraction.py +++ b/pyWebLayout/io/readers/html_extraction.py @@ -27,6 +27,7 @@ from pyWebLayout.abstract.block import ( Image, ) from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration +from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize, TextAlign class StyleContext(NamedTuple): diff --git a/pyWebLayout/style/__init__.py b/pyWebLayout/style/__init__.py index 36fe59a..a37bb58 100644 --- a/pyWebLayout/style/__init__.py +++ b/pyWebLayout/style/__init__.py @@ -15,3 +15,11 @@ from pyWebLayout.style.alignment import Alignment from pyWebLayout.style.fonts import ( Font, FontWeight, FontStyle, TextDecoration ) + +# Import new style system +from pyWebLayout.style.abstract_style import ( + AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign +) +from pyWebLayout.style.concrete_style import ( + ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver +) diff --git a/pyWebLayout/style/abstract_style.py b/pyWebLayout/style/abstract_style.py new file mode 100644 index 0000000..9dad8db --- /dev/null +++ b/pyWebLayout/style/abstract_style.py @@ -0,0 +1,334 @@ +""" +Abstract style system for storing document styling intent. + +This module defines styles in terms of semantic meaning rather than concrete +rendering parameters, allowing for flexible interpretation by different +rendering systems and user preferences. +""" + +from typing import Dict, Optional, Tuple, Union +from dataclasses import dataclass +from enum import Enum +from .fonts import FontWeight, FontStyle, TextDecoration + + +class FontFamily(Enum): + """Semantic font family categories""" + SERIF = "serif" + SANS_SERIF = "sans-serif" + MONOSPACE = "monospace" + CURSIVE = "cursive" + FANTASY = "fantasy" + + +class FontSize(Enum): + """Semantic font sizes""" + XX_SMALL = "xx-small" + X_SMALL = "x-small" + SMALL = "small" + MEDIUM = "medium" + LARGE = "large" + X_LARGE = "x-large" + XX_LARGE = "xx-large" + + # Allow numeric values as well + @classmethod + def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]: + """Convert a value to FontSize enum or return numeric value""" + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + try: + return cls(value) + except ValueError: + # Try to parse as number + try: + return int(float(value)) + except ValueError: + return cls.MEDIUM + return cls.MEDIUM + + +class TextAlign(Enum): + """Text alignment options""" + LEFT = "left" + CENTER = "center" + RIGHT = "right" + JUSTIFY = "justify" + + +@dataclass(frozen=True) +class AbstractStyle: + """ + Abstract representation of text styling that captures semantic intent + rather than concrete rendering parameters. + + This allows the same document to be rendered differently based on + user preferences, device capabilities, or accessibility requirements. + + Being frozen=True makes this class hashable and immutable, which is + perfect for use as dictionary keys and preventing accidental modification. + """ + + # Font properties (semantic) + font_family: FontFamily = FontFamily.SERIF + font_size: Union[FontSize, int] = FontSize.MEDIUM + font_weight: FontWeight = FontWeight.NORMAL + font_style: FontStyle = FontStyle.NORMAL + text_decoration: TextDecoration = TextDecoration.NONE + + # Color (as semantic names or RGB) + color: Union[str, Tuple[int, int, int]] = "black" + background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None + + # Text properties + text_align: TextAlign = TextAlign.LEFT + line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc. + letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc. + word_spacing: Optional[Union[str, float]] = None + + # Language and locale + language: str = "en-US" + + # Hierarchy properties + parent_style_id: Optional[str] = None + + def __post_init__(self): + """Validate and normalize values after creation""" + # Normalize font_size if it's a string that could be a number + if isinstance(self.font_size, str): + try: + object.__setattr__(self, 'font_size', int(float(self.font_size))) + except ValueError: + # Keep as is if it's a semantic size name + pass + + def __hash__(self) -> int: + """ + Custom hash implementation to ensure consistent hashing. + + Since this is a frozen dataclass, it should be hashable by default, + but we provide a custom implementation to ensure all fields are + properly considered and to handle the Union types correctly. + """ + # Convert all values to hashable forms + hashable_values = ( + self.font_family, + self.font_size if isinstance(self.font_size, int) else self.font_size, + self.font_weight, + self.font_style, + self.text_decoration, + self.color if isinstance(self.color, (str, tuple)) else str(self.color), + self.background_color, + self.text_align, + self.line_height, + self.letter_spacing, + self.word_spacing, + self.language, + self.parent_style_id + ) + + return hash(hashable_values) + + def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle': + """ + Create a new AbstractStyle by merging this one with another. + The other style's properties take precedence. + + Args: + other: AbstractStyle to merge with this one + + Returns: + New AbstractStyle with merged values + """ + # Get all fields from both styles + current_dict = { + field.name: getattr(self, field.name) + for field in self.__dataclass_fields__.values() + } + + other_dict = { + field.name: getattr(other, field.name) + for field in other.__dataclass_fields__.values() + if getattr(other, field.name) != field.default + } + + # Merge dictionaries (other takes precedence) + merged_dict = current_dict.copy() + merged_dict.update(other_dict) + + return AbstractStyle(**merged_dict) + + def with_modifications(self, **kwargs) -> 'AbstractStyle': + """ + Create a new AbstractStyle with specified modifications. + + Args: + **kwargs: Properties to modify + + Returns: + New AbstractStyle with modifications applied + """ + current_dict = { + field.name: getattr(self, field.name) + for field in self.__dataclass_fields__.values() + } + + current_dict.update(kwargs) + return AbstractStyle(**current_dict) + + +class AbstractStyleRegistry: + """ + Registry for managing abstract document styles. + + This registry stores the semantic styling intent and provides + deduplication and inheritance capabilities using hashable AbstractStyle objects. + """ + + def __init__(self): + """Initialize an empty abstract style registry.""" + self._styles: Dict[str, AbstractStyle] = {} + self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles + self._next_id = 1 + + # Create and register the default style + self._default_style = self._create_default_style() + + def _create_default_style(self) -> AbstractStyle: + """Create the default document style.""" + default_style = AbstractStyle() + style_id = "default" + self._styles[style_id] = default_style + self._style_to_id[default_style] = style_id + return default_style + + @property + def default_style(self) -> AbstractStyle: + """Get the default style for the document.""" + return self._default_style + + def _generate_style_id(self) -> str: + """Generate a unique style ID.""" + style_id = f"abstract_style_{self._next_id}" + self._next_id += 1 + return style_id + + def get_style_id(self, style: AbstractStyle) -> Optional[str]: + """ + Get the ID for a given style if it exists in the registry. + + Args: + style: AbstractStyle to find + + Returns: + Style ID if found, None otherwise + """ + return self._style_to_id.get(style) + + def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str: + """ + Register a style in the registry. + + Args: + style: AbstractStyle to register + style_id: Optional style ID. If None, one will be generated + + Returns: + The style ID + """ + # Check if style already exists + existing_id = self.get_style_id(style) + if existing_id is not None: + return existing_id + + if style_id is None: + style_id = self._generate_style_id() + + self._styles[style_id] = style + self._style_to_id[style] = style_id + return style_id + + def get_or_create_style(self, + style: Optional[AbstractStyle] = None, + parent_id: Optional[str] = None, + **kwargs) -> Tuple[str, AbstractStyle]: + """ + Get an existing style or create a new one. + + Args: + style: AbstractStyle object. If None, created from kwargs + parent_id: Optional parent style ID + **kwargs: Individual style properties (used if style is None) + + Returns: + Tuple of (style_id, AbstractStyle) + """ + # Create style object if not provided + if style is None: + # Filter out None values from kwargs + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + if parent_id: + filtered_kwargs['parent_style_id'] = parent_id + style = AbstractStyle(**filtered_kwargs) + + # Check if we already have this style (using hashable property) + existing_id = self.get_style_id(style) + if existing_id is not None: + return existing_id, style + + # Create new style + style_id = self.register_style(style) + return style_id, style + + def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]: + """Get a style by its ID.""" + return self._styles.get(style_id) + + def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]: + """ + Create a new style derived from a base style. + + Args: + base_style_id: ID of the base style + **modifications: Properties to modify + + Returns: + Tuple of (new_style_id, new_AbstractStyle) + """ + base_style = self.get_style_by_id(base_style_id) + if base_style is None: + raise ValueError(f"Base style '{base_style_id}' not found") + + # Create derived style + derived_style = base_style.with_modifications(**modifications) + return self.get_or_create_style(derived_style) + + def resolve_effective_style(self, style_id: str) -> AbstractStyle: + """ + Resolve the effective style including inheritance. + + Args: + style_id: Style ID to resolve + + Returns: + Effective AbstractStyle with inheritance applied + """ + style = self.get_style_by_id(style_id) + if style is None: + return self._default_style + + if style.parent_style_id is None: + return style + + # Recursively resolve parent styles + parent_style = self.resolve_effective_style(style.parent_style_id) + return parent_style.merge_with(style) + + def get_all_styles(self) -> Dict[str, AbstractStyle]: + """Get all registered styles.""" + return self._styles.copy() + + def get_style_count(self) -> int: + """Get the number of registered styles.""" + return len(self._styles) diff --git a/pyWebLayout/style/concrete_style.py b/pyWebLayout/style/concrete_style.py new file mode 100644 index 0000000..ed0d1f7 --- /dev/null +++ b/pyWebLayout/style/concrete_style.py @@ -0,0 +1,434 @@ +""" +Concrete style system for actual rendering parameters. + +This module converts abstract styles to concrete rendering parameters based on +user preferences, device capabilities, and rendering context. +""" + +from typing import Dict, Optional, Tuple, Union, Any +from dataclasses import dataclass +from .abstract_style import AbstractStyle, FontFamily, FontSize, TextAlign +from .fonts import Font, FontWeight, FontStyle, TextDecoration +import os + + +@dataclass(frozen=True) +class RenderingContext: + """ + Context information for style resolution. + Contains user preferences and device capabilities. + """ + + # User preferences + base_font_size: int = 16 # Base font size in points + font_scale_factor: float = 1.0 # Global font scaling + preferred_serif_font: Optional[str] = None + preferred_sans_serif_font: Optional[str] = None + preferred_monospace_font: Optional[str] = None + + # Device/environment info + dpi: int = 96 # Dots per inch + available_width: Optional[int] = None # Available width in pixels + available_height: Optional[int] = None # Available height in pixels + + # Accessibility preferences + high_contrast: bool = False + large_text: bool = False + reduce_motion: bool = False + + # Language and locale + default_language: str = "en-US" + + +@dataclass(frozen=True) +class ConcreteStyle: + """ + Concrete representation of text styling with actual rendering parameters. + + This contains the resolved font files, pixel sizes, actual colors, etc. + that will be used for rendering. This is also hashable for efficient caching. + """ + + # Concrete font properties + font_path: Optional[str] = None + font_size: int = 16 # Always in points/pixels + color: Tuple[int, int, int] = (0, 0, 0) # Always RGB + background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None + + # Font attributes + weight: FontWeight = FontWeight.NORMAL + style: FontStyle = FontStyle.NORMAL + decoration: TextDecoration = TextDecoration.NONE + + # Layout properties + text_align: TextAlign = TextAlign.LEFT + line_height: float = 1.0 # Multiplier + letter_spacing: float = 0.0 # In pixels + word_spacing: float = 0.0 # In pixels + + # Language and locale + language: str = "en-US" + min_hyphenation_width: int = 64 # In pixels + + # Reference to source abstract style + abstract_style: Optional[AbstractStyle] = None + + def create_font(self) -> Font: + """Create a Font object from this concrete style.""" + return Font( + font_path=self.font_path, + font_size=self.font_size, + colour=self.color, + weight=self.weight, + style=self.style, + decoration=self.decoration, + background=self.background_color, + language=self.language, + min_hyphenation_width=self.min_hyphenation_width + ) + + +class StyleResolver: + """ + Resolves abstract styles to concrete styles based on rendering context. + + This class handles the conversion from semantic styling intent to actual + rendering parameters, applying user preferences and device capabilities. + """ + + def __init__(self, context: RenderingContext): + """ + Initialize the style resolver with a rendering context. + + Args: + context: RenderingContext with user preferences and device info + """ + self.context = context + self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {} + + # Font size mapping for semantic sizes + self._semantic_font_sizes = { + FontSize.XX_SMALL: 0.6, + FontSize.X_SMALL: 0.75, + FontSize.SMALL: 0.89, + FontSize.MEDIUM: 1.0, + FontSize.LARGE: 1.2, + FontSize.X_LARGE: 1.5, + FontSize.XX_LARGE: 2.0, + } + + # Color name mapping + self._color_names = { + "black": (0, 0, 0), + "white": (255, 255, 255), + "red": (255, 0, 0), + "green": (0, 128, 0), + "blue": (0, 0, 255), + "yellow": (255, 255, 0), + "cyan": (0, 255, 255), + "magenta": (255, 0, 255), + "silver": (192, 192, 192), + "gray": (128, 128, 128), + "maroon": (128, 0, 0), + "olive": (128, 128, 0), + "lime": (0, 255, 0), + "aqua": (0, 255, 255), + "teal": (0, 128, 128), + "navy": (0, 0, 128), + "fuchsia": (255, 0, 255), + "purple": (128, 0, 128), + } + + def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle: + """ + Resolve an abstract style to a concrete style. + + Args: + abstract_style: AbstractStyle to resolve + + Returns: + ConcreteStyle with concrete rendering parameters + """ + # Check cache first + if abstract_style in self._concrete_cache: + return self._concrete_cache[abstract_style] + + # Resolve each property + font_path = self._resolve_font_path(abstract_style.font_family) + font_size = self._resolve_font_size(abstract_style.font_size) + color = self._resolve_color(abstract_style.color) + background_color = self._resolve_background_color(abstract_style.background_color) + line_height = self._resolve_line_height(abstract_style.line_height) + letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size) + word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size) + min_hyphenation_width = max(font_size * 4, 32) # At least 32 pixels + + # Create concrete style + concrete_style = ConcreteStyle( + font_path=font_path, + font_size=font_size, + color=color, + background_color=background_color, + weight=abstract_style.font_weight, + style=abstract_style.font_style, + decoration=abstract_style.text_decoration, + text_align=abstract_style.text_align, + line_height=line_height, + letter_spacing=letter_spacing, + word_spacing=word_spacing, + language=abstract_style.language, + min_hyphenation_width=min_hyphenation_width, + abstract_style=abstract_style + ) + + # Cache and return + self._concrete_cache[abstract_style] = concrete_style + return concrete_style + + def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]: + """Resolve font family to actual font file path.""" + if font_family == FontFamily.SERIF: + return self.context.preferred_serif_font + elif font_family == FontFamily.SANS_SERIF: + return self.context.preferred_sans_serif_font + elif font_family == FontFamily.MONOSPACE: + return self.context.preferred_monospace_font + else: + # For cursive and fantasy, fall back to sans-serif + return self.context.preferred_sans_serif_font + + def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int: + """Resolve font size to actual pixel/point size.""" + if isinstance(font_size, int): + # Already a concrete size, apply scaling + base_size = font_size + else: + # Semantic size, convert to multiplier + multiplier = self._semantic_font_sizes.get(font_size, 1.0) + base_size = int(self.context.base_font_size * multiplier) + + # Apply global font scaling + final_size = int(base_size * self.context.font_scale_factor) + + # Apply accessibility adjustments + if self.context.large_text: + final_size = int(final_size * 1.2) + + return max(final_size, 8) # Minimum 8pt font + + def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]: + """Resolve color to RGB tuple.""" + if isinstance(color, tuple): + return color + + if isinstance(color, str): + # Check if it's a named color + if color.lower() in self._color_names: + base_color = self._color_names[color.lower()] + elif color.startswith('#'): + # Parse hex color + try: + hex_color = color[1:] + if len(hex_color) == 3: + # Short hex format #RGB -> #RRGGBB + hex_color = ''.join(c*2 for c in hex_color) + if len(hex_color) == 6: + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + base_color = (r, g, b) + else: + base_color = (0, 0, 0) # Fallback to black + except ValueError: + base_color = (0, 0, 0) # Fallback to black + else: + base_color = (0, 0, 0) # Fallback to black + + # Apply high contrast if needed + if self.context.high_contrast: + # Simple high contrast: make dark colors black, light colors white + r, g, b = base_color + brightness = (r + g + b) / 3 + if brightness < 128: + base_color = (0, 0, 0) # Black + else: + base_color = (255, 255, 255) # White + + return base_color + + return (0, 0, 0) # Fallback to black + + def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]: + """Resolve background color to RGBA tuple or None.""" + if bg_color is None: + return None + + if isinstance(bg_color, tuple): + if len(bg_color) == 3: + # RGB -> RGBA + return bg_color + (255,) + return bg_color + + if isinstance(bg_color, str): + if bg_color.lower() == "transparent": + return None + + # Resolve as RGB then add alpha + rgb = self._resolve_color(bg_color) + return rgb + (255,) + + return None + + def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float: + """Resolve line height to multiplier.""" + if line_height is None or line_height == "normal": + return 1.2 # Default line height + + if isinstance(line_height, (int, float)): + return float(line_height) + + if isinstance(line_height, str): + try: + return float(line_height) + except ValueError: + return 1.2 # Fallback + + return 1.2 + + def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float: + """Resolve letter spacing to pixels.""" + if letter_spacing is None or letter_spacing == "normal": + return 0.0 + + if isinstance(letter_spacing, (int, float)): + return float(letter_spacing) + + if isinstance(letter_spacing, str): + if letter_spacing.endswith("em"): + try: + em_value = float(letter_spacing[:-2]) + return em_value * font_size + except ValueError: + return 0.0 + else: + try: + return float(letter_spacing) + except ValueError: + return 0.0 + + return 0.0 + + def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float: + """Resolve word spacing to pixels.""" + if word_spacing is None or word_spacing == "normal": + return 0.0 + + if isinstance(word_spacing, (int, float)): + return float(word_spacing) + + if isinstance(word_spacing, str): + if word_spacing.endswith("em"): + try: + em_value = float(word_spacing[:-2]) + return em_value * font_size + except ValueError: + return 0.0 + else: + try: + return float(word_spacing) + except ValueError: + return 0.0 + + return 0.0 + + def update_context(self, **kwargs): + """ + Update the rendering context and clear cache. + + Args: + **kwargs: Context properties to update + """ + # Create new context with updates + context_dict = { + field.name: getattr(self.context, field.name) + for field in self.context.__dataclass_fields__.values() + } + context_dict.update(kwargs) + + self.context = RenderingContext(**context_dict) + + # Clear cache since context changed + self._concrete_cache.clear() + + def clear_cache(self): + """Clear the concrete style cache.""" + self._concrete_cache.clear() + + def get_cache_size(self) -> int: + """Get the number of cached concrete styles.""" + return len(self._concrete_cache) + + +class ConcreteStyleRegistry: + """ + Registry for managing concrete styles with efficient caching. + + This registry manages the mapping between abstract and concrete styles, + and provides efficient access to Font objects for rendering. + """ + + def __init__(self, resolver: StyleResolver): + """ + Initialize the concrete style registry. + + Args: + resolver: StyleResolver for converting abstract to concrete styles + """ + self.resolver = resolver + self._font_cache: Dict[ConcreteStyle, Font] = {} + + def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle: + """ + Get a concrete style for an abstract style. + + Args: + abstract_style: AbstractStyle to resolve + + Returns: + ConcreteStyle with rendering parameters + """ + return self.resolver.resolve_style(abstract_style) + + def get_font(self, abstract_style: AbstractStyle) -> Font: + """ + Get a Font object for an abstract style. + + Args: + abstract_style: AbstractStyle to get font for + + Returns: + Font object ready for rendering + """ + concrete_style = self.get_concrete_style(abstract_style) + + # Check font cache + if concrete_style in self._font_cache: + return self._font_cache[concrete_style] + + # Create and cache font + font = concrete_style.create_font() + self._font_cache[concrete_style] = font + + return font + + def clear_caches(self): + """Clear all caches.""" + self.resolver.clear_cache() + self._font_cache.clear() + + def get_cache_stats(self) -> Dict[str, int]: + """Get cache statistics.""" + return { + "concrete_styles": self.resolver.get_cache_size(), + "fonts": len(self._font_cache) + } diff --git a/tests/test_monospace_basic.py b/tests/test_monospace_basic.py new file mode 100644 index 0000000..c5d55d0 --- /dev/null +++ b/tests/test_monospace_basic.py @@ -0,0 +1,223 @@ +""" +Basic mono-space font tests for predictable character width behavior. + +This test focuses on the fundamental property of mono-space fonts: +every character has the same width, making layout calculations predictable. +""" + +import unittest +import os +from PIL import Image + +from pyWebLayout.concrete.text import Text, Line +from pyWebLayout.style.fonts import Font +from pyWebLayout.style.layout import Alignment + + +class TestMonospaceBasics(unittest.TestCase): + """Basic tests for mono-space font behavior.""" + + def setUp(self): + """Set up test with a mono-space font if available.""" + # Try to find DejaVu Sans Mono + mono_paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + "/System/Library/Fonts/Monaco.ttf", + "C:/Windows/Fonts/consola.ttf" + ] + self.mono_font_path = None + for path in mono_paths: + if os.path.exists(path): + self.mono_font_path = path + break + + if self.mono_font_path: + self.font = Font(font_path=self.mono_font_path, font_size=12) + + # Calculate reference character width + ref_char = Text("M", self.font) + self.char_width = ref_char.width + print(f"Using mono-space font: {self.mono_font_path}") + print(f"Character width: {self.char_width}px") + else: + print("No mono-space font found - tests will be skipped") + + def test_character_width_consistency(self): + """Test that all characters have the same width.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Test a variety of characters + test_chars = "AaBbCc123!@#.,:;'\"()[]{}|-_+=<>" + + widths = [] + for char in test_chars: + text = Text(char, self.font) + widths.append(text.width) + print(f"'{char}': {text.width}px") + + # All widths should be nearly identical + min_width = min(widths) + max_width = max(widths) + variance = max_width - min_width + + self.assertLessEqual(variance, 2, + f"Character width variance should be minimal, got {variance}px") + + def test_predictable_string_width(self): + """Test that string width equals character_width * length.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + test_strings = [ + "A", + "AB", + "ABC", + "ABCD", + "Hello", + "Hello World", + "123456789" + ] + + for s in test_strings: + text = Text(s, self.font) + expected_width = len(s) * self.char_width + actual_width = text.width + + # Allow small variance for font rendering + diff = abs(actual_width - expected_width) + max_allowed_diff = len(s) + 2 # Small tolerance + + print(f"'{s}' ({len(s)} chars): expected {expected_width}px, " + f"actual {actual_width}px, diff {diff}px") + + self.assertLessEqual(diff, max_allowed_diff, + f"String '{s}' width should be predictable") + + def test_line_capacity_prediction(self): + """Test that we can predict how many characters fit on a line.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Test with different line widths + test_widths = [100, 200, 300] + + for line_width in test_widths: + # Calculate expected character capacity + expected_chars = line_width // self.char_width + + # Create a line and fill it with single characters + line = Line( + spacing=(1, 1), # Minimal spacing + origin=(0, 0), + size=(line_width, 20), + font=self.font, + halign=Alignment.LEFT + ) + + chars_added = 0 + for i in range(expected_chars + 5): # Try a few extra + result = line.add_word("X", self.font) + if result is not None: # Doesn't fit + break + chars_added += 1 + + print(f"Line width {line_width}px: expected ~{expected_chars} chars, " + f"actual {chars_added} chars") + + # Should be reasonably close to prediction + self.assertGreaterEqual(chars_added, max(1, expected_chars - 2)) + self.assertLessEqual(chars_added, expected_chars + 2) + + def test_word_breaking_with_known_widths(self): + """Test word breaking with known character widths.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Create a line that fits exactly 10 characters + line_width = self.char_width * 10 + line = Line( + spacing=(2, 4), + origin=(0, 0), + size=(line_width, 20), + font=self.font, + halign=Alignment.LEFT + ) + + # Try to add a word that's too long + long_word = "ABCDEFGHIJKLMNOP" # 16 characters + result = line.add_word(long_word, self.font) + + # Word should be broken or rejected + if result is None: + self.fail("16-character word should not fit in 10-character line") + else: + print(f"Long word '{long_word}' result: '{result}'") + + # Check that some text was added + self.assertGreater(len(line.text_objects), 0, + "Some text should be added to the line") + + if line.text_objects: + added_text = line.text_objects[0].text + print(f"Added to line: '{added_text}' ({len(added_text)} chars)") + + # Added text should be shorter than original + self.assertLess(len(added_text), len(long_word), + "Added text should be shorter than original word") + + def test_alignment_visual_differences(self): + """Test that different alignments produce visually different results.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Use a line width that allows for visible alignment differences + line_width = self.char_width * 20 + test_words = ["Hello", "World"] + + alignments = [ + (Alignment.LEFT, "left"), + (Alignment.CENTER, "center"), + (Alignment.RIGHT, "right"), + (Alignment.JUSTIFY, "justify") + ] + + results = {} + + for alignment, name in alignments: + line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(line_width, 20), + font=self.font, + halign=alignment + ) + + # Add test words + for word in test_words: + result = line.add_word(word, self.font) + if result is not None: + break + + # Render the line + line_image = line.render() + results[name] = line_image + + # Save for visual inspection + output_dir = "test_output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + output_path = os.path.join(output_dir, f"mono_align_{name}.png") + line_image.save(output_path) + print(f"Saved {name} alignment test to: {output_path}") + + # All alignments should produce valid images + for name, image in results.items(): + self.assertIsInstance(image, Image.Image) + self.assertEqual(image.size, (line_width, 20)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_monospace_concepts.py b/tests/test_monospace_concepts.py new file mode 100644 index 0000000..fc03a25 --- /dev/null +++ b/tests/test_monospace_concepts.py @@ -0,0 +1,343 @@ +""" +Mono-space font testing concepts and demo. + +This test demonstrates why mono-space fonts are valuable for testing +rendering, line-breaking, and hyphenation, even when using regular fonts. +""" + +import unittest +import os +from PIL import Image + +from pyWebLayout.concrete.text import Text, Line +from pyWebLayout.concrete.page import Page, Container +from pyWebLayout.style.fonts import Font +from pyWebLayout.style.layout import Alignment + + +class TestMonospaceConcepts(unittest.TestCase): + """Demonstrate mono-space testing concepts.""" + + def setUp(self): + """Set up test with available fonts.""" + # Use the project's default font + self.regular_font = Font(font_size=12) + + # Analyze character width variance + test_chars = "iIlLmMwW0O" + self.char_analysis = {} + + for char in test_chars: + text = Text(char, self.regular_font) + self.char_analysis[char] = text.width + + widths = list(self.char_analysis.values()) + self.min_width = min(widths) + self.max_width = max(widths) + self.variance = self.max_width - self.min_width + + print(f"\nFont analysis:") + print(f"Character width range: {self.min_width}-{self.max_width}px") + print(f"Variance: {self.variance}px") + + # Find most uniform character (closest to average) + avg_width = sum(widths) / len(widths) + self.uniform_char = min(self.char_analysis.keys(), + key=lambda c: abs(self.char_analysis[c] - avg_width)) + print(f"Most uniform character: '{self.uniform_char}' ({self.char_analysis[self.uniform_char]}px)") + + def test_character_width_predictability(self): + """Show why predictable character widths matter for testing.""" + print("\n=== Character Width Predictability Demo ===") + + # Compare narrow vs wide characters + narrow_word = "ill" # Narrow characters + wide_word = "WWW" # Wide characters + uniform_word = self.uniform_char * 3 # Uniform characters + + narrow_text = Text(narrow_word, self.regular_font) + wide_text = Text(wide_word, self.regular_font) + uniform_text = Text(uniform_word, self.regular_font) + + print(f"Same length (3 chars), different widths:") + print(f" '{narrow_word}': {narrow_text.width}px") + print(f" '{wide_word}': {wide_text.width}px") + print(f" '{uniform_word}': {uniform_text.width}px") + + # Show the problem this creates for testing + width_ratio = wide_text.width / narrow_text.width + print(f" Width ratio: {width_ratio:.1f}x") + + if width_ratio > 1.5: + print(" → This variance makes line capacity unpredictable!") + + # With mono-space, all would be ~36px (3 chars × 12px each) + theoretical_mono = 3 * 12 + print(f" With mono-space: ~{theoretical_mono}px each") + + def test_line_capacity_challenges(self): + """Show how variable character widths affect line capacity.""" + print("\n=== Line Capacity Prediction Challenges ===") + + line_width = 120 # Fixed width + + # Test with different character types + test_cases = [ + ("narrow", "i" * 20), # 20 narrow chars + ("wide", "W" * 8), # 8 wide chars + ("mixed", "Hello World"), # Mixed realistic text + ("uniform", self.uniform_char * 15) # 15 uniform chars + ] + + print(f"Line width: {line_width}px") + + for name, test_text in test_cases: + text_obj = Text(test_text, self.regular_font) + fits = "YES" if text_obj.width <= line_width else "NO" + + print(f" {name:8}: '{test_text[:15]}...' ({len(test_text)} chars)") + print(f" Width: {text_obj.width}px, Fits: {fits}") + + print("\nWith mono-space fonts:") + char_width = 12 # Theoretical mono-space width + capacity = line_width // char_width + print(f" Predictable capacity: ~{capacity} characters") + print(f" Any {capacity}-character string would fit") + + def test_word_breaking_complexity(self): + """Demonstrate word breaking complexity with variable widths.""" + print("\n=== Word Breaking Complexity Demo ===") + + # Create a narrow line + line_width = 80 + line = Line( + spacing=(2, 4), + origin=(0, 0), + size=(line_width, 20), + font=self.regular_font, + halign=Alignment.LEFT + ) + + # Test different word types + test_words = [ + ("narrow", "illillill"), # 9 narrow chars + ("wide", "WWWWW"), # 5 wide chars + ("mixed", "Hello"), # 5 mixed chars + ] + + print(f"Line width: {line_width}px") + + for word_type, word in test_words: + # Create fresh line for each test + test_line = Line( + spacing=(2, 4), + origin=(0, 0), + size=(line_width, 20), + font=self.regular_font, + halign=Alignment.LEFT + ) + + word_obj = Text(word, self.regular_font) + result = test_line.add_word(word, self.regular_font) + + fits = "YES" if result is None else "NO" + + print(f" {word_type:6}: '{word}' ({len(word)} chars, {word_obj.width}px) → {fits}") + + if result is not None and test_line.text_objects: + added = test_line.text_objects[0].text + print(f" Added: '{added}', Remaining: '{result}'") + + print("\nWith mono-space fonts, word fitting would be predictable:") + char_width = 12 + capacity = line_width // char_width + print(f" Any word ≤ {capacity} characters would fit") + print(f" Any word > {capacity} characters would need breaking") + + def test_alignment_consistency(self): + """Show how alignment behavior varies with character widths.""" + print("\n=== Alignment Consistency Demo ===") + + line_width = 150 + + # Test different alignments with various text + test_texts = [ + "ill ill ill", # Narrow characters + "WWW WWW WWW", # Wide characters + "The cat sat", # Mixed characters + ] + + alignments = [ + (Alignment.LEFT, "LEFT"), + (Alignment.CENTER, "CENTER"), + (Alignment.RIGHT, "RIGHT"), + (Alignment.JUSTIFY, "JUSTIFY") + ] + + results = {} + + for align_enum, align_name in alignments: + print(f"\n{align_name} alignment:") + + for text in test_texts: + line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(line_width, 20), + font=self.regular_font, + halign=align_enum + ) + + # Add words to line + words = text.split() + for word in words: + result = line.add_word(word, self.regular_font) + if result is not None: + break + + # Render and save + line_image = line.render() + + # Calculate text coverage + text_obj = Text(text.replace(" ", ""), self.regular_font) + coverage = text_obj.width / line_width + + print(f" '{text}': {coverage:.1%} line coverage") + + # Save example + output_dir = "test_output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + filename = f"align_{align_name.lower()}_{text.replace(' ', '_')}.png" + output_path = os.path.join(output_dir, filename) + line_image.save(output_path) + + print("\nWith mono-space fonts:") + print(" - Alignment calculations would be simpler") + print(" - Spacing distribution would be more predictable") + print(" - Visual consistency would be higher") + + def test_hyphenation_decision_factors(self): + """Show factors affecting hyphenation decisions.""" + print("\n=== Hyphenation Decision Factors ===") + + # Test word that might benefit from hyphenation + test_word = "development" # 11 characters + word_obj = Text(test_word, self.regular_font) + + print(f"Test word: '{test_word}' ({len(test_word)} chars, {word_obj.width}px)") + + # Test different line widths + test_widths = [60, 80, 100, 120, 140] + + for width in test_widths: + line = Line( + spacing=(2, 4), + origin=(0, 0), + size=(width, 20), + font=self.regular_font, + halign=Alignment.LEFT + ) + + result = line.add_word(test_word, self.regular_font) + + if result is None: + status = "FITS completely" + elif line.text_objects: + added = line.text_objects[0].text + status = f"PARTIAL: '{added}' + '{result}'" + else: + status = "REJECTED completely" + + # Calculate utilization + utilization = word_obj.width / width + + print(f" Width {width:3}px ({utilization:>5.1%} util): {status}") + + print("\nWith mono-space fonts:") + char_width = 12 + word_width_mono = len(test_word) * char_width # 132px + print(f" Word would be exactly {word_width_mono}px") + print(f" Hyphenation decisions would be based on character count") + print(f" Line capacity would be width ÷ {char_width}px per char") + + def test_create_visual_comparison(self): + """Create visual comparison showing the difference.""" + print("\n=== Creating Visual Comparison ===") + + # Create a page showing the problems with variable width fonts + page = Page(size=(600, 400)) + + # Create test content + test_text = "The quick brown fox jumps over lazy dogs with varying character widths." + + # Split into words and create multiple lines with different alignments + words = test_text.split() + + # Create container for demonstration + demo_container = Container( + origin=(0, 0), + size=(580, 380), + direction='vertical', + spacing=5, + padding=(10, 10, 10, 10) + ) + + alignments = [ + (Alignment.LEFT, "Left Aligned"), + (Alignment.CENTER, "Center Aligned"), + (Alignment.RIGHT, "Right Aligned"), + (Alignment.JUSTIFY, "Justified") + ] + + for align_enum, title in alignments: + # Add title + from pyWebLayout.style.fonts import FontWeight + title_text = Text(title + ":", Font(font_size=14, weight=FontWeight.BOLD)) + demo_container.add_child(title_text) + + # Create line with this alignment + line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(560, 20), + font=self.regular_font, + halign=align_enum + ) + + # Add as many words as fit + for word in words[:6]: # Limit to first 6 words + result = line.add_word(word, self.regular_font) + if result is not None: + break + + demo_container.add_child(line) + + # Add demo to page + page.add_child(demo_container) + + # Render and save + page_image = page.render() + + output_dir = "test_output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + output_path = os.path.join(output_dir, "monospace_concepts_demo.png") + page_image.save(output_path) + + print(f"Visual demonstration saved to: {output_path}") + print("This shows why mono-space fonts make testing more predictable!") + + # Validation + self.assertIsInstance(page_image, Image.Image) + self.assertEqual(page_image.size, (600, 400)) + + +if __name__ == '__main__': + # Ensure output directory exists + if not os.path.exists("test_output"): + os.makedirs("test_output") + + unittest.main(verbosity=2) diff --git a/tests/test_monospace_hyphenation.py b/tests/test_monospace_hyphenation.py new file mode 100644 index 0000000..5ab0cf7 --- /dev/null +++ b/tests/test_monospace_hyphenation.py @@ -0,0 +1,348 @@ +""" +Mono-space font hyphenation tests. + +Tests hyphenation behavior with mono-space fonts where character widths +are predictable, making it easier to verify hyphenation logic and +line-breaking decisions. +""" + +import unittest +import os +from PIL import Image + +from pyWebLayout.concrete.text import Text, Line +from pyWebLayout.style.fonts import Font +from pyWebLayout.style.layout import Alignment +from pyWebLayout.abstract.inline import Word + + +class TestMonospaceHyphenation(unittest.TestCase): + """Test hyphenation behavior with mono-space fonts.""" + + def setUp(self): + """Set up test with mono-space font.""" + # Try to find a mono-space font + mono_paths = [ + "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" , + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + ] + + self.mono_font_path = None + for path in mono_paths: + if os.path.exists(path): + self.mono_font_path = path + break + + if self.mono_font_path: + self.font = Font( + font_path=self.mono_font_path, + font_size=14, + min_hyphenation_width=20 + ) + + # Calculate character width + ref_char = Text("M", self.font) + self.char_width = ref_char.width + print(f"Using mono-space font: {os.path.basename(self.mono_font_path)}") + print(f"Character width: {self.char_width}px") + else: + print("No mono-space font found - hyphenation tests will be skipped") + + def test_hyphenation_basic_functionality(self): + """Test basic hyphenation with known words.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Test words that should hyphenate + test_words = [ + "hyphenation", + "development", + "information", + "character", + "beautiful", + "computer" + ] + + for word_text in test_words: + word = Word(word_text, self.font) + + if word.hyphenate(): + parts_count = word.get_hyphenated_part_count() + print(f"\nWord: '{word_text}' -> {parts_count} parts") + + # Collect all parts + parts = [] + for i in range(parts_count): + part = word.get_hyphenated_part(i) + parts.append(part) + print(f" Part {i}: '{part}' ({len(part)} chars)") + + # Verify that parts reconstruct the original word + reconstructed = ''.join(parts).replace('-', '') + self.assertEqual(reconstructed, word_text, + f"Hyphenated parts should reconstruct '{word_text}'") + + # Test that each part has predictable width + for i, part in enumerate(parts): + text_obj = Text(part, self.font) + expected_width = len(part) * self.char_width + actual_width = text_obj.width + + # Allow small variance for hyphen rendering + diff = abs(actual_width - expected_width) + max_diff = 5 # pixels tolerance for hyphen + + self.assertLessEqual(diff, max_diff, + f"Part '{part}' width should be predictable") + else: + print(f"Word '{word_text}' cannot be hyphenated") + + def test_hyphenation_line_fitting(self): + """Test that hyphenation helps words fit on lines.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Create a line that's too narrow for long words + narrow_width = self.char_width * 12 # 12 characters + + line = Line( + spacing=(2, 4), + origin=(0, 0), + size=(narrow_width, 20), + font=self.font, + halign=Alignment.LEFT + ) + + # Test with a word that needs hyphenation + long_word = "hyphenation" # 11 characters - should barely fit or need hyphenation + + result = line.add_word(long_word, self.font) + + print(f"\nTesting word '{long_word}' in {narrow_width}px line:") + print(f"Line capacity: ~{narrow_width // self.char_width} characters") + + if result is None: + # Word fit completely + print("Word fit completely on line") + self.assertGreater(len(line.text_objects), 0, "Line should have text") + + added_text = line.text_objects[0].text + print(f"Added text: '{added_text}'") + + else: + # Word was hyphenated or rejected + print(f"Word result: '{result}'") + + if len(line.text_objects) > 0: + added_text = line.text_objects[0].text + print(f"Added to line: '{added_text}' ({len(added_text)} chars)") + print(f"Remaining: '{result}' ({len(result)} chars)") + + # Added part should be shorter than original + self.assertLess(len(added_text), len(long_word), + "Hyphenated part should be shorter than original") + + # Remaining part should be shorter than original + self.assertLess(len(result), len(long_word), + "Remaining part should be shorter than original") + else: + print("No text was added to line") + + def test_hyphenation_vs_no_hyphenation(self): + """Compare behavior with and without hyphenation enabled.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Create fonts with and without hyphenation + font_with_hyphen = Font( + font_path=self.mono_font_path, + font_size=14, + min_hyphenation_width=20 + ) + + font_no_hyphen = Font( + font_path=self.mono_font_path, + font_size=14, + ) + + # Test with a word that benefits from hyphenation + test_word = "development" # 11 characters + line_width = self.char_width * 8 # 8 characters - too narrow + + # Test with hyphenation enabled + line_with_hyphen = Line( + spacing=(2, 4), + origin=(0, 0), + size=(line_width, 20), + font=font_with_hyphen, + halign=Alignment.LEFT + ) + + result_with_hyphen = line_with_hyphen.add_word(test_word, font_with_hyphen) + + # Test without hyphenation + line_no_hyphen = Line( + spacing=(2, 4), + origin=(0, 0), + size=(line_width, 20), + font=font_no_hyphen, + halign=Alignment.LEFT + ) + + result_no_hyphen = line_no_hyphen.add_word(test_word, font_no_hyphen) + + print(f"\nTesting '{test_word}' in {line_width}px line:") + print(f"With hyphenation: {result_with_hyphen}") + print(f"Without hyphenation: {result_no_hyphen}") + + # With hyphenation, we might get partial content + # Without hyphenation, word should be rejected entirely + if result_with_hyphen is None: + print("Word fit completely with hyphenation") + elif len(line_with_hyphen.text_objects) > 0: + added_with_hyphen = line_with_hyphen.text_objects[0].text + print(f"Added with hyphenation: '{added_with_hyphen}'") + + if result_no_hyphen is None: + print("Word fit completely without hyphenation") + elif len(line_no_hyphen.text_objects) > 0: + added_no_hyphen = line_no_hyphen.text_objects[0].text + print(f"Added without hyphenation: '{added_no_hyphen}'") + + def test_hyphenation_quality_metrics(self): + """Test hyphenation quality with different line widths.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + test_word = "information" # 11 characters + + # Test with different line widths + test_widths = [ + self.char_width * 6, # Very narrow + self.char_width * 8, # Narrow + self.char_width * 10, # Medium + self.char_width * 12, # Wide enough + ] + + print(f"\nTesting hyphenation quality for '{test_word}':") + + for width in test_widths: + capacity = width // self.char_width + + line = Line( + spacing=(2, 4), + origin=(0, 0), + size=(width, 20), + font=self.font, + halign=Alignment.LEFT + ) + + result = line.add_word(test_word, self.font) + + print(f"\nLine width: {width}px (~{capacity} chars)") + + if result is None: + print(" Word fit completely") + if line.text_objects: + added = line.text_objects[0].text + print(f" Added: '{added}'") + else: + print(f" Result: '{result}'") + if line.text_objects: + added = line.text_objects[0].text + print(f" Added: '{added}' ({len(added)} chars)") + print(f" Remaining: '{result}' ({len(result)} chars)") + + # Calculate hyphenation efficiency + chars_used = len(added) - added.count('-') # Don't count hyphens + efficiency = chars_used / len(test_word) + print(f" Efficiency: {efficiency:.2%}") + + def test_multiple_words_with_hyphenation(self): + """Test adding multiple words where hyphenation affects spacing.""" + if not self.mono_font_path: + self.skipTest("No mono-space font available") + + # Create a line that forces interesting hyphenation decisions + line_width = self.char_width * 20 # 20 characters + + line = Line( + spacing=(3, 6), + origin=(0, 0), + size=(line_width, 20), + font=self.font, + halign=Alignment.JUSTIFY + ) + + # Test words that might need hyphenation + test_words = ["The", "development", "of", "hyphenation"] + + print(f"\nAdding words to {line_width}px line (~{line_width // self.char_width} chars):") + + words_added = [] + for word in test_words: + result = line.add_word(word, self.font) + + if result is None: + print(f" '{word}' - fit completely") + words_added.append(word) + else: + print(f" '{word}' - result: '{result}'") + if line.text_objects: + last_added = line.text_objects[-1].text + print(f" Added: '{last_added}'") + words_added.append(last_added) + break + + print(f"Final line contains {len(line.text_objects)} text objects") + + # Render the line to test spacing + line_image = line.render() + + # Save for visual inspection + output_dir = "test_output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + output_path = os.path.join(output_dir, "mono_hyphenation_multiword.png") + line_image.save(output_path) + print(f"Saved multi-word hyphenation test to: {output_path}") + + # Basic validation + self.assertIsInstance(line_image, Image.Image) + self.assertEqual(line_image.size, (line_width, 20)) + + def save_hyphenation_example(self, test_name: str, lines: list): + """Save a visual example of hyphenation behavior.""" + from pyWebLayout.concrete.page import Container + + # Create a container for multiple lines + container = Container( + origin=(0, 0), + size=(400, len(lines) * 25), + direction='vertical', + spacing=5, + padding=(10, 10, 10, 10) + ) + + # Add each line to the container + for i, line in enumerate(lines): + line._origin = (0, i * 25) + container.add_child(line) + + # Render the container + container_image = container.render() + + # Save the image + output_dir = "test_output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + output_path = os.path.join(output_dir, f"mono_hyphen_{test_name}.png") + container_image.save(output_path) + print(f"Saved hyphenation example '{test_name}' to: {output_path}") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_monospace_rendering.py b/tests/test_monospace_rendering.py new file mode 100644 index 0000000..8da03ba --- /dev/null +++ b/tests/test_monospace_rendering.py @@ -0,0 +1,483 @@ +""" +Comprehensive mono-space font tests for rendering, line-breaking, and hyphenation. + +Mono-space fonts provide predictable behavior for testing layout algorithms +since each character has the same width. This makes it easier to verify +correct text flow, line breaking, and hyphenation behavior. +""" + +import unittest +import os +from PIL import Image, ImageFont +import numpy as np + +from pyWebLayout.concrete.text import Text, Line +from pyWebLayout.concrete.page import Page, Container +from pyWebLayout.style.fonts import Font, FontWeight, FontStyle +from pyWebLayout.style.layout import Alignment +from pyWebLayout.abstract.inline import Word + + +class TestMonospaceRendering(unittest.TestCase): + """Test rendering behavior with mono-space fonts.""" + + def setUp(self): + """Set up test fixtures with mono-space font.""" + # Try to find a mono-space font on the system + self.monospace_font_path = self._find_monospace_font() + + # Create mono-space font instances for testing + self.mono_font_12 = Font( + font_path=self.monospace_font_path, + font_size=12, + colour=(0, 0, 0) + ) + + self.mono_font_16 = Font( + font_path=self.monospace_font_path, + font_size=16, + colour=(0, 0, 0) + ) + + # Calculate character width for mono-space font + test_char = Text("X", self.mono_font_12) + self.char_width_12 = test_char.width + + test_char_16 = Text("X", self.mono_font_16) + self.char_width_16 = test_char_16.width + + print(f"Mono-space character width (12pt): {self.char_width_12}px") + print(f"Mono-space character width (16pt): {self.char_width_16}px") + + def _find_monospace_font(self): + """Find a suitable mono-space font on the system.""" + # Common mono-space font paths + possible_fonts = [ + "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" , + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + ] + + for font_path in possible_fonts: + if os.path.exists(font_path): + return font_path + + # If no mono-space font found, return None to use default + print("Warning: No mono-space font found, using default font") + return None + + def test_character_width_consistency(self): + """Test that all characters have the same width in mono-space font.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + # Test various characters to ensure consistent width + test_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?" + + widths = [] + for char in test_chars: + text_obj = Text("A"+char+"A", self.mono_font_12) + widths.append(text_obj.width) + + # All widths should be the same (or very close due to rendering differences) + min_width = min(widths) + max_width = max(widths) + width_variance = max_width - min_width + + print(f"Character width range: {min_width}-{max_width}px (variance: {width_variance}px)") + + # Allow small variance for anti-aliasing effects + self.assertLessEqual(width_variance, 2, "Mono-space characters should have consistent width") + + def test_predictable_text_width(self): + """Test that text width is predictable based on character count.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + test_strings = [ + "A", + "AB", + "ABC", + "ABCD", + "ABCDE", + "ABCDEFGHIJ", + "ABCDEFGHIJKLMNOPQRST" + ] + + for text_str in test_strings: + text_obj = Text(text_str, self.mono_font_12) + expected_width = len(text_str) * self.char_width_12 + actual_width = text_obj.width + + # Allow small variance for rendering differences + width_diff = abs(actual_width - expected_width) + + print(f"Text '{text_str}': expected {expected_width}px, actual {actual_width}px, diff {width_diff}px") + + self.assertLessEqual(width_diff, len(text_str) + 2, + f"Text width should be predictable for '{text_str}'") + + def test_line_capacity_calculation(self): + """Test that we can predict how many characters fit on a line.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + # Create lines of different widths + line_widths = [100, 200, 300, 500, 800] + + for line_width in line_widths: + line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(line_width, 20), + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + # Calculate expected capacity + # Account for spacing between words (minimum 3px) + chars_per_word = 10 # Average word length for estimation + word_width = chars_per_word * self.char_width_12 + + # Estimate how many words can fit + estimated_words = line_width // (word_width + 3) # +3 for minimum spacing + + # Test by adding words until line is full + words_added = 0 + test_word = "A" * chars_per_word # 10-character word + + while True: + result = line.add_word(test_word, self.mono_font_12) + if result is not None: # Word didn't fit + break + words_added += 1 + + # Prevent infinite loop + if words_added > 50: + break + + print(f"Line width {line_width}px: estimated {estimated_words} words, actual {words_added} words") + + # The actual should be reasonably close to estimated + self.assertGreaterEqual(words_added, max(1, estimated_words - 2), + f"Should fit at least {max(1, estimated_words - 2)} words") + self.assertLessEqual(words_added, estimated_words + 2, + f"Should not fit more than {estimated_words + 2} words") + + def test_word_breaking_behavior(self): + """Test word breaking and hyphenation with mono-space fonts.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + # Create a narrow line that forces word breaking + narrow_width = self.char_width_12 * 15 # Space for about 15 characters + + line = Line( + spacing=(2, 6), + origin=(0, 0), + size=(narrow_width, 20), + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + # Test with a long word that should be hyphenated + long_word = "supercalifragilisticexpialidocious" # 34 characters + + result = line.add_word(long_word, self.mono_font_12) + + # The word should be partially added (hyphenated) or rejected + if result is None: + # Word fit completely (shouldn't happen with our narrow line) + self.fail("Long word should not fit completely in narrow line") + else: + # Word was partially added or rejected + remaining_text = result + + # Check that some text was added to the line + self.assertGreater(len(line.text_objects), 0, "Some text should be added to line") + + # Check that remaining text is shorter than original + if remaining_text: + self.assertLess(len(remaining_text), len(long_word), + "Remaining text should be shorter than original") + + print(f"Original word: '{long_word}' ({len(long_word)} chars)") + if line.text_objects: + added_text = line.text_objects[0].text + print(f"Added to line: '{added_text}' ({len(added_text)} chars)") + print(f"Remaining: '{remaining_text}' ({len(remaining_text)} chars)") + + def test_alignment_with_monospace(self): + """Test different alignment modes with mono-space fonts.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + line_width = self.char_width_12 * 20 # 20 characters wide + alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY] + + test_words = ["HELLO", "WORLD", "TEST"] # Known character counts: 5, 5, 4 + + for alignment in alignments: + line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(line_width, 20), + font=self.mono_font_12, + halign=alignment + ) + + # Add all test words + for word in test_words: + result = line.add_word(word, self.mono_font_12) + if result is not None: + break # Word didn't fit + + # Render the line to test alignment + line_image = line.render() + + # Basic validation that line rendered successfully + self.assertIsInstance(line_image, Image.Image) + self.assertEqual(line_image.size, (line_width, 20)) + + print(f"Line with {alignment.name} alignment rendered successfully") + + def test_hyphenation_points(self): + """Test hyphenation at specific points with mono-space fonts.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + # Test words that should hyphenate at predictable points + test_cases = [ + ("hyphenation", ["hy-", "phen-", "ation"]), # Expected breaks + ("computer", ["com-", "put-", "er"]), + ("beautiful", ["beau-", "ti-", "ful"]), + ("information", ["in-", "for-", "ma-", "tion"]) + ] + + for word, expected_parts in test_cases: + # Create Word object for hyphenation testing + word_obj = Word(word, self.mono_font_12) + + if word_obj.hyphenate(): + parts_count = word_obj.get_hyphenated_part_count() + + print(f"Word '{word}' hyphenated into {parts_count} parts:") + + actual_parts = [] + for i in range(parts_count): + part = word_obj.get_hyphenated_part(i) + actual_parts.append(part) + print(f" Part {i}: '{part}'") + + # Verify that parts can be rendered and have expected widths + for part in actual_parts: + text_obj = Text(part, self.mono_font_12) + expected_width = len(part) * self.char_width_12 + + # Allow variance for hyphen and rendering differences + width_diff = abs(text_obj.width - expected_width) + self.assertLessEqual(width_diff, 13, + f"Hyphenated part '{part}' should have predictable width") + else: + print(f"Word '{word}' could not be hyphenated") + + def test_line_overflow_scenarios(self): + """Test various line overflow scenarios with mono-space fonts.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + # Test case 1: Single character that barely fits + char_line = Line( + spacing=(1, 3), + origin=(0, 0), + size=(self.char_width_12 + 2, 20), # Just enough for one character + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + result = char_line.add_word("A", self.mono_font_12) + self.assertIsNone(result, "Single character should fit in character-sized line") + + # Test case 2: Word that's exactly the line width + exact_width = self.char_width_12 * 5 # Exactly 5 characters + exact_line = Line( + spacing=(0, 2), + origin=(0, 0), + size=(exact_width, 20), + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + result = exact_line.add_word("HELLO", self.mono_font_12) # Exactly 5 characters + # This might fit or might not depending on margins - test that it behaves consistently + print(f"Word 'HELLO' in exact-width line: {'fit' if result is None else 'did not fit'}") + + # Test case 3: Multiple short words vs one long word + multi_word_line = Line( + spacing=(3, 6), + origin=(0, 0), + size=(self.char_width_12 * 20, 20), # 20 characters + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + # Add multiple short words + short_words = ["CAT", "DOG", "BIRD", "FISH"] # 3 chars each + words_added = 0 + + for word in short_words: + result = multi_word_line.add_word(word, self.mono_font_12) + if result is not None: + break + words_added += 1 + + print(f"Added {words_added} short words to 20-character line") + + # Should be able to add at least 2 words (3 chars + 3 spacing + 3 chars = 9 chars) + self.assertGreaterEqual(words_added, 2, "Should fit at least 2 short words") + + def test_spacing_calculation_accuracy(self): + """Test that spacing calculations are accurate with mono-space fonts.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + line_width = self.char_width_12 * 30 # 30 characters + + # Test justify alignment which distributes spacing + justify_line = Line( + spacing=(2, 10), + origin=(0, 0), + size=(line_width, 20), + font=self.mono_font_12, + halign=Alignment.JUSTIFY + ) + + # Add words that should allow for even spacing + words = ["WORD", "WORD", "WORD"] # 3 words, 4 characters each = 12 characters + # Remaining space: 30 - 12 = 18 characters for spacing + # 2 spaces between 3 words = 9 characters per space + + for word in words: + result = justify_line.add_word(word, self.mono_font_12) + if result is not None: + break + + # Render and verify + line_image = justify_line.render() + self.assertIsInstance(line_image, Image.Image) + + print(f"Justified line with calculated spacing rendered successfully") + + # Test that text objects are positioned correctly + text_objects = justify_line.text_objects + if len(text_objects) >= 2: + # Calculate actual spacing between words + first_word_end = text_objects[0].width + second_word_start = 0 # This would need to be calculated from positioning + + print(f"Added {len(text_objects)} words to justified line") + + def save_test_output(self, test_name: str, image: Image.Image): + """Save test output image for visual inspection.""" + output_dir = "test_output" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + output_path = os.path.join(output_dir, f"monospace_{test_name}.png") + image.save(output_path) + print(f"Test output saved to: {output_path}") + + def test_complete_paragraph_layout(self): + """Test a complete paragraph layout with mono-space fonts.""" + if self.monospace_font_path is None: + self.skipTest("No mono-space font available") + + # Create a page for paragraph layout + page = Page(size=(800, 600)) + + # Test paragraph with known character counts + test_text = ( + "This is a test paragraph with mono-space font rendering. " + "Each character should have exactly the same width, making " + "line breaking and text flow calculations predictable and " + "testable. We can verify that word wrapping occurs at the " + "expected positions based on character counts and spacing." + ) + + # Create container for the paragraph + paragraph_container = Container( + origin=(0, 0), + size=(400, 200), # Fixed width for predictable wrapping + direction='vertical', + spacing=2, + padding=(10, 10, 10, 10) + ) + + # Split text into words and create lines + words = test_text.split() + current_line = Line( + spacing=(3, 8), + origin=(0, 0), + size=(380, 20), # 400 - 20 for padding + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + lines_created = 0 + words_processed = 0 + + for word in words: + result = current_line.add_word(word, self.mono_font_12) + + if result is not None: + # Word didn't fit, start new line + if len(current_line.text_objects) > 0: + paragraph_container.add_child(current_line) + lines_created += 1 + + # Create new line + current_line = Line( + spacing=(3, 8), + origin=(0, lines_created * 22), # 20 height + 2 spacing + size=(380, 20), + font=self.mono_font_12, + halign=Alignment.LEFT + ) + + # Try to add the word to the new line + result = current_line.add_word(word, self.mono_font_12) + if result is not None: + # Word still doesn't fit, might need hyphenation + print(f"Warning: Word '{word}' doesn't fit even on new line") + else: + words_processed += 1 + else: + words_processed += 1 + + # Add the last line if it has content + if len(current_line.text_objects) > 0: + paragraph_container.add_child(current_line) + lines_created += 1 + + # Add paragraph to page + page.add_child(paragraph_container) + + # Render the complete page + page_image = page.render() + + print(f"Paragraph layout: {words_processed}/{len(words)} words processed, {lines_created} lines created") + + # Save output for visual inspection + self.save_test_output("paragraph_layout", page_image) + + # Basic validation + self.assertGreater(lines_created, 1, "Should create multiple lines") + self.assertGreater(words_processed, len(words) * 0.8, "Should process most words") + + +if __name__ == '__main__': + # Create output directory for test results + if not os.path.exists("test_output"): + os.makedirs("test_output") + + unittest.main(verbosity=2) diff --git a/tests/test_new_style_system.py b/tests/test_new_style_system.py new file mode 100644 index 0000000..9dcbba5 --- /dev/null +++ b/tests/test_new_style_system.py @@ -0,0 +1,232 @@ +""" +Test the new abstract/concrete style system. + +This test demonstrates how the new style system addresses the memory efficiency +concerns by using abstract styles that can be resolved to concrete styles +based on user preferences. +""" + +import pytest +from pyWebLayout.style.abstract_style import ( + AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign +) +from pyWebLayout.style.concrete_style import ( + ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver +) +from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration + + +def test_abstract_style_is_hashable(): + """Test that AbstractStyle objects are hashable and can be used as dict keys.""" + # Create two identical styles + style1 = AbstractStyle( + font_family=FontFamily.SERIF, + font_size=16, + font_weight=FontWeight.BOLD, + color="red" + ) + + style2 = AbstractStyle( + font_family=FontFamily.SERIF, + font_size=16, + font_weight=FontWeight.BOLD, + color="red" + ) + + # They should be equal and have the same hash + assert style1 == style2 + assert hash(style1) == hash(style2) + + # They should work as dictionary keys + style_dict = {style1: "first", style2: "second"} + assert len(style_dict) == 1 # Should be deduplicated + assert style_dict[style1] == "second" # Last value wins + + +def test_abstract_style_registry_deduplication(): + """Test that the registry prevents duplicate styles.""" + registry = AbstractStyleRegistry() + + # Create the same style twice + style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD) + style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD) + + # Register both - should get same ID + id1, _ = registry.get_or_create_style(style1) + id2, _ = registry.get_or_create_style(style2) + + assert id1 == id2 # Same style should get same ID + assert registry.get_style_count() == 2 # Only default + our style + + +def test_style_inheritance(): + """Test that style inheritance works properly.""" + registry = AbstractStyleRegistry() + + # Create base style + base_style = AbstractStyle(font_size=16, color="black") + base_id, _ = registry.get_or_create_style(base_style) + + # Create derived style + derived_id, derived_style = registry.create_derived_style( + base_id, + font_weight=FontWeight.BOLD, + color="red" + ) + + # Resolve effective style + effective = registry.resolve_effective_style(derived_id) + + assert effective.font_size == 16 # Inherited from base + assert effective.font_weight == FontWeight.BOLD # Overridden + assert effective.color == "red" # Overridden + + +def test_style_resolver_user_preferences(): + """Test that user preferences affect concrete style resolution.""" + # Create rendering context with larger fonts + context = RenderingContext( + base_font_size=20, # Larger base size + font_scale_factor=1.5, # Additional scaling + large_text=True # Accessibility preference + ) + + resolver = StyleResolver(context) + + # Create abstract style with medium size + abstract_style = AbstractStyle(font_size=FontSize.MEDIUM) + + # Resolve to concrete style + concrete_style = resolver.resolve_style(abstract_style) + + # Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36 + expected_size = int(20 * 1.0 * 1.5 * 1.2) + assert concrete_style.font_size == expected_size + + +def test_style_resolver_color_resolution(): + """Test color name resolution.""" + context = RenderingContext() + resolver = StyleResolver(context) + + # Test named colors + red_style = AbstractStyle(color="red") + concrete_red = resolver.resolve_style(red_style) + assert concrete_red.color == (255, 0, 0) + + # Test hex colors + hex_style = AbstractStyle(color="#ff0000") + concrete_hex = resolver.resolve_style(hex_style) + assert concrete_hex.color == (255, 0, 0) + + # Test RGB tuple (should pass through) + rgb_style = AbstractStyle(color=(128, 64, 192)) + concrete_rgb = resolver.resolve_style(rgb_style) + assert concrete_rgb.color == (128, 64, 192) + + +def test_concrete_style_caching(): + """Test that concrete styles are cached efficiently.""" + context = RenderingContext() + registry = ConcreteStyleRegistry(StyleResolver(context)) + + # Create abstract style + abstract_style = AbstractStyle(font_size=16, color="blue") + + # Get font twice - should be cached + font1 = registry.get_font(abstract_style) + font2 = registry.get_font(abstract_style) + + # Should be the same object (cached) + assert font1 is font2 + + # Check cache stats + stats = registry.get_cache_stats() + assert stats["concrete_styles"] == 1 + assert stats["fonts"] == 1 + + +def test_global_font_scaling(): + """Test that global font scaling affects all text.""" + # Create two contexts with different scaling + context_normal = RenderingContext(font_scale_factor=1.0) + context_large = RenderingContext(font_scale_factor=2.0) + + resolver_normal = StyleResolver(context_normal) + resolver_large = StyleResolver(context_large) + + # Same abstract style + abstract_style = AbstractStyle(font_size=16) + + # Resolve with different contexts + concrete_normal = resolver_normal.resolve_style(abstract_style) + concrete_large = resolver_large.resolve_style(abstract_style) + + # Large should be 2x the size + assert concrete_large.font_size == concrete_normal.font_size * 2 + + +def test_memory_efficiency(): + """Test that the new system is more memory efficient.""" + registry = AbstractStyleRegistry() + + # Create many "different" styles that are actually the same + styles = [] + for i in range(100): + # All these styles are identical + style = AbstractStyle( + font_size=16, + font_weight=FontWeight.NORMAL, + color="black" + ) + style_id, _ = registry.get_or_create_style(style) + styles.append(style_id) + + # All should reference the same style + assert len(set(styles)) == 1 # All IDs are the same + assert registry.get_style_count() == 2 # Only default + our style + + # This demonstrates that we don't create duplicate styles + + +def test_word_style_reference_concept(): + """Demonstrate how words would reference styles instead of storing fonts.""" + registry = AbstractStyleRegistry() + + # Create paragraph style + para_style = AbstractStyle(font_size=16, color="black") + para_id, _ = registry.get_or_create_style(para_style) + + # Create bold word style + bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD) + bold_id, _ = registry.get_or_create_style(bold_style) + + # Simulate words storing style IDs instead of full Font objects + words_data = [ + {"text": "This", "style_id": para_id}, + {"text": "is", "style_id": para_id}, + {"text": "bold", "style_id": bold_id}, + {"text": "text", "style_id": para_id}, + ] + + # To get the actual font for rendering, we resolve through registry + context = RenderingContext() + concrete_registry = ConcreteStyleRegistry(StyleResolver(context)) + + for word_data in words_data: + abstract_style = registry.get_style_by_id(word_data["style_id"]) + font = concrete_registry.get_font(abstract_style) + + # Now we have the actual Font object for rendering + assert font is not None + assert hasattr(font, 'font_size') + + # Bold word should have bold weight + if word_data["text"] == "bold": + assert font.weight == FontWeight.BOLD + else: + assert font.weight == FontWeight.NORMAL + + +if __name__ == "__main__": + pytest.main([__file__])