new style handling
All checks were successful
Python CI / test (push) Successful in 5m17s

This commit is contained in:
Duncan Tourolle 2025-06-22 13:42:15 +02:00
parent edac4de5b4
commit ae15fe54e8
11 changed files with 2511 additions and 66 deletions

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:

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='-')

View File

@ -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):

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
)

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)

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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__])