335 lines
11 KiB
Python
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)
|