pyWebLayout/pyWebLayout/style/abstract_style.py
Duncan Tourolle ae15fe54e8
All checks were successful
Python CI / test (push) Successful in 5m17s
new style handling
2025-06-22 13:42:15 +02:00

335 lines
11 KiB
Python

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