new style handling
Python CI / test (push) Successful in 5m17s

This commit is contained in:
2025-06-22 13:42:15 +02:00
parent edac4de5b4
commit ae15fe54e8
11 changed files with 2511 additions and 66 deletions
+81 -57
View File
@@ -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:
+24 -9
View File
@@ -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='-')
@@ -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):
+8
View File
@@ -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
)
+334
View File
@@ -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)
+434
View File
@@ -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)
}