This commit is contained in:
parent
edac4de5b4
commit
ae15fe54e8
@ -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
|
||||
)
|
||||
|
||||
334
pyWebLayout/style/abstract_style.py
Normal file
334
pyWebLayout/style/abstract_style.py
Normal file
@ -0,0 +1,334 @@
|
||||
"""
|
||||
Abstract style system for storing document styling intent.
|
||||
|
||||
This module defines styles in terms of semantic meaning rather than concrete
|
||||
rendering parameters, allowing for flexible interpretation by different
|
||||
rendering systems and user preferences.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from .fonts import FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
class FontFamily(Enum):
|
||||
"""Semantic font family categories"""
|
||||
SERIF = "serif"
|
||||
SANS_SERIF = "sans-serif"
|
||||
MONOSPACE = "monospace"
|
||||
CURSIVE = "cursive"
|
||||
FANTASY = "fantasy"
|
||||
|
||||
|
||||
class FontSize(Enum):
|
||||
"""Semantic font sizes"""
|
||||
XX_SMALL = "xx-small"
|
||||
X_SMALL = "x-small"
|
||||
SMALL = "small"
|
||||
MEDIUM = "medium"
|
||||
LARGE = "large"
|
||||
X_LARGE = "x-large"
|
||||
XX_LARGE = "xx-large"
|
||||
|
||||
# Allow numeric values as well
|
||||
@classmethod
|
||||
def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
|
||||
"""Convert a value to FontSize enum or return numeric value"""
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError:
|
||||
# Try to parse as number
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return cls.MEDIUM
|
||||
return cls.MEDIUM
|
||||
|
||||
|
||||
class TextAlign(Enum):
|
||||
"""Text alignment options"""
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
RIGHT = "right"
|
||||
JUSTIFY = "justify"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AbstractStyle:
|
||||
"""
|
||||
Abstract representation of text styling that captures semantic intent
|
||||
rather than concrete rendering parameters.
|
||||
|
||||
This allows the same document to be rendered differently based on
|
||||
user preferences, device capabilities, or accessibility requirements.
|
||||
|
||||
Being frozen=True makes this class hashable and immutable, which is
|
||||
perfect for use as dictionary keys and preventing accidental modification.
|
||||
"""
|
||||
|
||||
# Font properties (semantic)
|
||||
font_family: FontFamily = FontFamily.SERIF
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM
|
||||
font_weight: FontWeight = FontWeight.NORMAL
|
||||
font_style: FontStyle = FontStyle.NORMAL
|
||||
text_decoration: TextDecoration = TextDecoration.NONE
|
||||
|
||||
# Color (as semantic names or RGB)
|
||||
color: Union[str, Tuple[int, int, int]] = "black"
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
||||
|
||||
# Text properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
|
||||
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
||||
word_spacing: Optional[Union[str, float]] = None
|
||||
|
||||
# Language and locale
|
||||
language: str = "en-US"
|
||||
|
||||
# Hierarchy properties
|
||||
parent_style_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize values after creation"""
|
||||
# Normalize font_size if it's a string that could be a number
|
||||
if isinstance(self.font_size, str):
|
||||
try:
|
||||
object.__setattr__(self, 'font_size', int(float(self.font_size)))
|
||||
except ValueError:
|
||||
# Keep as is if it's a semantic size name
|
||||
pass
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Custom hash implementation to ensure consistent hashing.
|
||||
|
||||
Since this is a frozen dataclass, it should be hashable by default,
|
||||
but we provide a custom implementation to ensure all fields are
|
||||
properly considered and to handle the Union types correctly.
|
||||
"""
|
||||
# Convert all values to hashable forms
|
||||
hashable_values = (
|
||||
self.font_family,
|
||||
self.font_size if isinstance(self.font_size, int) else self.font_size,
|
||||
self.font_weight,
|
||||
self.font_style,
|
||||
self.text_decoration,
|
||||
self.color if isinstance(self.color, (str, tuple)) else str(self.color),
|
||||
self.background_color,
|
||||
self.text_align,
|
||||
self.line_height,
|
||||
self.letter_spacing,
|
||||
self.word_spacing,
|
||||
self.language,
|
||||
self.parent_style_id
|
||||
)
|
||||
|
||||
return hash(hashable_values)
|
||||
|
||||
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
|
||||
"""
|
||||
Create a new AbstractStyle by merging this one with another.
|
||||
The other style's properties take precedence.
|
||||
|
||||
Args:
|
||||
other: AbstractStyle to merge with this one
|
||||
|
||||
Returns:
|
||||
New AbstractStyle with merged values
|
||||
"""
|
||||
# Get all fields from both styles
|
||||
current_dict = {
|
||||
field.name: getattr(self, field.name)
|
||||
for field in self.__dataclass_fields__.values()
|
||||
}
|
||||
|
||||
other_dict = {
|
||||
field.name: getattr(other, field.name)
|
||||
for field in other.__dataclass_fields__.values()
|
||||
if getattr(other, field.name) != field.default
|
||||
}
|
||||
|
||||
# Merge dictionaries (other takes precedence)
|
||||
merged_dict = current_dict.copy()
|
||||
merged_dict.update(other_dict)
|
||||
|
||||
return AbstractStyle(**merged_dict)
|
||||
|
||||
def with_modifications(self, **kwargs) -> 'AbstractStyle':
|
||||
"""
|
||||
Create a new AbstractStyle with specified modifications.
|
||||
|
||||
Args:
|
||||
**kwargs: Properties to modify
|
||||
|
||||
Returns:
|
||||
New AbstractStyle with modifications applied
|
||||
"""
|
||||
current_dict = {
|
||||
field.name: getattr(self, field.name)
|
||||
for field in self.__dataclass_fields__.values()
|
||||
}
|
||||
|
||||
current_dict.update(kwargs)
|
||||
return AbstractStyle(**current_dict)
|
||||
|
||||
|
||||
class AbstractStyleRegistry:
|
||||
"""
|
||||
Registry for managing abstract document styles.
|
||||
|
||||
This registry stores the semantic styling intent and provides
|
||||
deduplication and inheritance capabilities using hashable AbstractStyle objects.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty abstract style registry."""
|
||||
self._styles: Dict[str, AbstractStyle] = {}
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
|
||||
self._next_id = 1
|
||||
|
||||
# Create and register the default style
|
||||
self._default_style = self._create_default_style()
|
||||
|
||||
def _create_default_style(self) -> AbstractStyle:
|
||||
"""Create the default document style."""
|
||||
default_style = AbstractStyle()
|
||||
style_id = "default"
|
||||
self._styles[style_id] = default_style
|
||||
self._style_to_id[default_style] = style_id
|
||||
return default_style
|
||||
|
||||
@property
|
||||
def default_style(self) -> AbstractStyle:
|
||||
"""Get the default style for the document."""
|
||||
return self._default_style
|
||||
|
||||
def _generate_style_id(self) -> str:
|
||||
"""Generate a unique style ID."""
|
||||
style_id = f"abstract_style_{self._next_id}"
|
||||
self._next_id += 1
|
||||
return style_id
|
||||
|
||||
def get_style_id(self, style: AbstractStyle) -> Optional[str]:
|
||||
"""
|
||||
Get the ID for a given style if it exists in the registry.
|
||||
|
||||
Args:
|
||||
style: AbstractStyle to find
|
||||
|
||||
Returns:
|
||||
Style ID if found, None otherwise
|
||||
"""
|
||||
return self._style_to_id.get(style)
|
||||
|
||||
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Register a style in the registry.
|
||||
|
||||
Args:
|
||||
style: AbstractStyle to register
|
||||
style_id: Optional style ID. If None, one will be generated
|
||||
|
||||
Returns:
|
||||
The style ID
|
||||
"""
|
||||
# Check if style already exists
|
||||
existing_id = self.get_style_id(style)
|
||||
if existing_id is not None:
|
||||
return existing_id
|
||||
|
||||
if style_id is None:
|
||||
style_id = self._generate_style_id()
|
||||
|
||||
self._styles[style_id] = style
|
||||
self._style_to_id[style] = style_id
|
||||
return style_id
|
||||
|
||||
def get_or_create_style(self,
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Get an existing style or create a new one.
|
||||
|
||||
Args:
|
||||
style: AbstractStyle object. If None, created from kwargs
|
||||
parent_id: Optional parent style ID
|
||||
**kwargs: Individual style properties (used if style is None)
|
||||
|
||||
Returns:
|
||||
Tuple of (style_id, AbstractStyle)
|
||||
"""
|
||||
# Create style object if not provided
|
||||
if style is None:
|
||||
# Filter out None values from kwargs
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
if parent_id:
|
||||
filtered_kwargs['parent_style_id'] = parent_id
|
||||
style = AbstractStyle(**filtered_kwargs)
|
||||
|
||||
# Check if we already have this style (using hashable property)
|
||||
existing_id = self.get_style_id(style)
|
||||
if existing_id is not None:
|
||||
return existing_id, style
|
||||
|
||||
# Create new style
|
||||
style_id = self.register_style(style)
|
||||
return style_id, style
|
||||
|
||||
def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
|
||||
"""Get a style by its ID."""
|
||||
return self._styles.get(style_id)
|
||||
|
||||
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Create a new style derived from a base style.
|
||||
|
||||
Args:
|
||||
base_style_id: ID of the base style
|
||||
**modifications: Properties to modify
|
||||
|
||||
Returns:
|
||||
Tuple of (new_style_id, new_AbstractStyle)
|
||||
"""
|
||||
base_style = self.get_style_by_id(base_style_id)
|
||||
if base_style is None:
|
||||
raise ValueError(f"Base style '{base_style_id}' not found")
|
||||
|
||||
# Create derived style
|
||||
derived_style = base_style.with_modifications(**modifications)
|
||||
return self.get_or_create_style(derived_style)
|
||||
|
||||
def resolve_effective_style(self, style_id: str) -> AbstractStyle:
|
||||
"""
|
||||
Resolve the effective style including inheritance.
|
||||
|
||||
Args:
|
||||
style_id: Style ID to resolve
|
||||
|
||||
Returns:
|
||||
Effective AbstractStyle with inheritance applied
|
||||
"""
|
||||
style = self.get_style_by_id(style_id)
|
||||
if style is None:
|
||||
return self._default_style
|
||||
|
||||
if style.parent_style_id is None:
|
||||
return style
|
||||
|
||||
# Recursively resolve parent styles
|
||||
parent_style = self.resolve_effective_style(style.parent_style_id)
|
||||
return parent_style.merge_with(style)
|
||||
|
||||
def get_all_styles(self) -> Dict[str, AbstractStyle]:
|
||||
"""Get all registered styles."""
|
||||
return self._styles.copy()
|
||||
|
||||
def get_style_count(self) -> int:
|
||||
"""Get the number of registered styles."""
|
||||
return len(self._styles)
|
||||
434
pyWebLayout/style/concrete_style.py
Normal file
434
pyWebLayout/style/concrete_style.py
Normal 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)
|
||||
}
|
||||
223
tests/test_monospace_basic.py
Normal file
223
tests/test_monospace_basic.py
Normal 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)
|
||||
343
tests/test_monospace_concepts.py
Normal file
343
tests/test_monospace_concepts.py
Normal 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)
|
||||
348
tests/test_monospace_hyphenation.py
Normal file
348
tests/test_monospace_hyphenation.py
Normal 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)
|
||||
483
tests/test_monospace_rendering.py
Normal file
483
tests/test_monospace_rendering.py
Normal 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)
|
||||
232
tests/test_new_style_system.py
Normal file
232
tests/test_new_style_system.py
Normal 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__])
|
||||
Loading…
x
Reference in New Issue
Block a user