@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user