Source code for segnomms.config.models.visual

"""Visual and style-related configuration models.

This module contains configuration classes for visual styling, frames,
centerpieces, quiet zones, and pattern-specific styling.
"""

from typing import Any, Dict, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing_extensions import Self

from ..enums import PlacementMode, ReserveMode


class PatternStyleConfig(BaseModel):
    """Pattern-specific styling configuration.

    Allows different shapes and styles for specific QR code patterns
    (finder, timing, alignment, etc.) independent of data modules.

    Attributes:
        enabled: Whether to enable pattern-specific styling
        finder: Custom styling for finder patterns (corners)
        timing: Custom styling for timing patterns (grid lines)
        alignment: Custom styling for alignment patterns
        format: Custom styling for format information modules
        version: Custom styling for version information modules
        data: Custom styling for data modules (can override global setting)
    """

    model_config = ConfigDict(validate_default=True, extra="forbid")

    enabled: bool = Field(default=False, description="Enable pattern-specific styling")

    # Pattern-specific shape overrides
    finder: Optional[str] = Field(default=None, description="Custom shape for finder patterns")
    finder_inner: Optional[str] = Field(default=None, description="Custom shape for finder inner regions")
    timing: Optional[str] = Field(default=None, description="Custom shape for timing patterns")
    alignment: Optional[str] = Field(default=None, description="Custom shape for alignment patterns")
    format: Optional[str] = Field(default=None, description="Custom shape for format information")
    version: Optional[str] = Field(default=None, description="Custom shape for version information")
    data: Optional[str] = Field(default=None, description="Custom shape for data modules")

    # Pattern-specific colors
    finder_color: Optional[str] = Field(default=None, description="Custom color for finder patterns")
    finder_inner_color: Optional[str] = Field(
        default=None, description="Custom color for finder inner regions"
    )
    timing_color: Optional[str] = Field(default=None, description="Custom color for timing patterns")
    alignment_color: Optional[str] = Field(default=None, description="Custom color for alignment patterns")
    format_color: Optional[str] = Field(default=None, description="Custom color for format information")
    version_color: Optional[str] = Field(default=None, description="Custom color for version information")
    data_color: Optional[str] = Field(default=None, description="Custom color for data modules")

    # Pattern-specific sizing/effects
    finder_scale: Optional[float] = Field(
        default=None, ge=0.1, le=2.0, description="Scale factor for finder patterns"
    )
    timing_scale: Optional[float] = Field(
        default=None, ge=0.1, le=2.0, description="Scale factor for timing patterns"
    )
    alignment_scale: Optional[float] = Field(
        default=None, ge=0.1, le=2.0, description="Scale factor for alignment patterns"
    )

    # Advanced pattern effects
    finder_effects: Optional[Dict[str, Any]] = Field(
        default=None, description="Custom effects for finder patterns"
    )
    timing_effects: Optional[Dict[str, Any]] = Field(
        default=None, description="Custom effects for timing patterns"
    )
    alignment_effects: Optional[Dict[str, Any]] = Field(
        default=None, description="Custom effects for alignment patterns"
    )

    @model_validator(mode="after")
    def validate_enabled_patterns(self) -> Self:
        """Validate that at least one pattern override is specified when enabled."""
        if self.enabled:
            pattern_overrides = [
                # Shape overrides
                self.finder,
                self.finder_inner,
                self.timing,
                self.alignment,
                self.format,
                self.version,
                self.data,
                # Color overrides
                self.finder_color,
                self.finder_inner_color,
                self.timing_color,
                self.alignment_color,
                self.format_color,
                self.version_color,
                self.data_color,
                # Scale overrides
                self.finder_scale,
                self.timing_scale,
                self.alignment_scale,
                # Effects overrides
                self.finder_effects,
                self.timing_effects,
                self.alignment_effects,
            ]
            if not any(override is not None for override in pattern_overrides):
                raise ValueError(
                    "At least one pattern override must be specified when pattern styling is enabled"
                )
        return self

    @model_validator(mode="after")
    def validate_pattern_shapes(self) -> Self:
        """Validate that pattern shapes are valid shape types."""
        # List of valid shapes (could be imported from shapes module in production)
        valid_shapes = [
            "square",
            "circle",
            "rounded",
            "dot",
            "diamond",
            "star",
            "hexagon",
            "triangle",
            "squircle",
            "cross",
            "connected",
            "connected-extra-rounded",
            "connected-classy",
            "connected-classy-rounded",
        ]

        # Check each pattern shape
        shape_fields = [
            ("finder", self.finder),
            ("finder_inner", self.finder_inner),
            ("timing", self.timing),
            ("alignment", self.alignment),
            ("format", self.format),
            ("version", self.version),
            ("data", self.data),
        ]

        for field_name, shape_value in shape_fields:
            if shape_value is not None and shape_value not in valid_shapes:
                raise ValueError(
                    f"Invalid shape '{shape_value}' for {field_name}. "
                    f"Valid shapes are: {', '.join(valid_shapes)}"
                )

        return self


[docs] class FrameConfig(BaseModel): """Frame shape configuration for QR codes. Attributes: shape: Frame shape type ('square', 'circle', 'rounded-rect', 'squircle', 'custom') corner_radius: Corner radius for rounded-rect (0.0-1.0) clip_mode: How to apply frame ('clip' for hard edge, 'fade' for gradient) custom_path: SVG path string for custom frame shapes """ model_config = ConfigDict(validate_default=True, extra="forbid") shape: Literal["square", "circle", "rounded-rect", "squircle", "custom"] = "square" corner_radius: float = Field(default=0.0, ge=0.0, le=1.0, description="Corner radius for rounded-rect") clip_mode: Literal["clip", "fade", "scale"] = "clip" custom_path: Optional[str] = Field(default=None, description="SVG path string for custom frame shapes") # Clip mode parameters fade_distance: float = Field( default=10.0, ge=0.0, le=50.0, description="Distance in pixels for fade transition", ) scale_distance: float = Field( default=5.0, ge=0.0, le=25.0, description="Distance in modules for scale transition", )
[docs] @model_validator(mode="after") def validate_custom_path(self) -> Self: """Validate that custom_path is provided for custom frame shapes.""" if self.shape == "custom" and not self.custom_path: raise ValueError("custom_path required for custom frame shape") return self
[docs] class CenterpieceConfig(BaseModel): """Centerpiece reserve area configuration. Attributes: enabled: Whether to reserve center area shape: Shape of reserved area ('rect', 'circle', 'squircle') size: Size relative to QR code (0.0-0.5) offset_x: Horizontal offset (-0.5 to 0.5) - used when placement is 'custom' offset_y: Vertical offset (-0.5 to 0.5) - used when placement is 'custom' margin: Module margin around reserved area mode: Reserve area interaction mode (knockout or imprint) placement: Placement mode for positioning (custom, center, corners, edges) """ model_config = ConfigDict(validate_default=True, extra="forbid") enabled: bool = False shape: Literal["rect", "circle", "squircle"] = "rect" size: float = Field(default=0.0, ge=0.0, le=0.5, description="Size relative to QR code") offset_x: float = Field( default=0.0, ge=-0.5, le=0.5, description="Horizontal offset (custom placement only)", ) offset_y: float = Field( default=0.0, ge=-0.5, le=0.5, description="Vertical offset (custom placement only)", ) margin: int = Field(default=2, ge=0, description="Module margin around reserved area") mode: ReserveMode = Field(default=ReserveMode.KNOCKOUT, description="Reserve area interaction mode") placement: PlacementMode = Field( default=PlacementMode.CENTER, description="Placement mode for positioning" )
[docs] @model_validator(mode="after") def validate_enabled_fields(self) -> Self: """Additional validation when centerpiece is enabled.""" if self.enabled and self.size == 0.0: raise ValueError("centerpiece size must be > 0 when enabled") return self
[docs] class QuietZoneConfig(BaseModel): """Quiet zone styling configuration. Attributes: color: Fill color for quiet zone style: Style type ('solid', 'gradient', 'none') gradient: Gradient definition for style='gradient' """ model_config = ConfigDict(validate_default=True, extra="forbid") color: str = Field(default="#ffffff", description="Fill color for quiet zone") style: Literal["solid", "gradient", "none"] = "solid" gradient: Optional[Dict[str, Any]] = Field( default=None, description="Gradient definition for style='gradient'" )
[docs] @model_validator(mode="after") def validate_gradient_required(self) -> Self: """Validate that gradient is provided for gradient style.""" if self.style == "gradient" and not self.gradient: raise ValueError("gradient definition required for gradient style") return self
class StyleConfig(BaseModel): """General styling configuration. Controls CSS classes, interactive features, tooltips, and other visual styling. Attributes: interactive: Enable interactive hover effects tooltips: Show tooltips on module hover css_classes: Custom CSS classes for different QR elements """ model_config = ConfigDict(validate_default=True, extra="forbid") interactive: bool = Field(default=False, description="Enable interactive hover effects") tooltips: bool = Field(default=False, description="Show tooltips on module hover") css_classes: Optional[Dict[str, str]] = Field( default=None, description="Custom CSS classes for different QR elements" ) @field_validator("css_classes") @classmethod def validate_css_classes(cls, v: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]: """Validate CSS class names.""" if v is not None: # Valid keys with qr_ prefix (new format) prefixed_keys = [ "qr_module", "qr_finder", "qr_finder_inner", "qr_timing", "qr_alignment", "qr_alignment_center", "qr_format", "qr_version", "qr_data", "qr_cluster", ] # Valid keys without prefix (legacy format for backward compatibility) unprefixed_keys = [ "module", "finder", "finder_inner", "timing", "alignment", "alignment_center", "format", "version", "data", "cluster", ] valid_keys = prefixed_keys + unprefixed_keys for key in v.keys(): if key not in valid_keys: raise ValueError(f"Invalid CSS class key '{key}'. Valid keys: {', '.join(valid_keys)}") return v