Source code for segnomms.a11y.accessibility

"""
Accessibility enhancement utilities for SegnoMMS.

This module provides comprehensive accessibility features including:
- Stable ID generation with configurable prefixes
- ARIA attribute management
- Screen reader optimization
- Keyboard navigation support
- Accessibility validation
"""

import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Optional, Set

from pydantic import BaseModel, ConfigDict, Field, field_validator


[docs] class ARIARole(str, Enum): """Standard ARIA roles for QR code elements.""" IMG = "img" GRAPHICS_DOCUMENT = "graphics-document" GRAPHICS_OBJECT = "graphics-object" APPLICATION = "application" GROUP = "group" PRESENTATION = "presentation"
class AccessibilityLevel(str, Enum): """WCAG accessibility compliance levels.""" A = "A" AA = "AA" AAA = "AAA" @dataclass class ElementAccessibility: """Accessibility information for a single SVG element.""" element_id: str aria_role: Optional[str] = None aria_label: Optional[str] = None aria_describedby: Optional[str] = None aria_labelledby: Optional[str] = None title: Optional[str] = None tabindex: Optional[int] = None def to_attributes(self) -> Dict[str, str]: """Convert to SVG element attributes.""" attrs = {"id": self.element_id} if self.aria_role: attrs["role"] = self.aria_role if self.aria_label: attrs["aria-label"] = self.aria_label if self.aria_describedby: attrs["aria-describedby"] = self.aria_describedby if self.aria_labelledby: attrs["aria-labelledby"] = self.aria_labelledby if self.title: attrs["title"] = self.title if self.tabindex is not None: attrs["tabindex"] = str(self.tabindex) return attrs
[docs] class AccessibilityConfig(BaseModel): """Comprehensive accessibility configuration.""" model_config = ConfigDict(validate_default=True, extra="forbid") # Enable/disable accessibility features enabled: bool = Field(default=True, description="Enable accessibility features") # ID generation id_prefix: str = Field(default="qr", description="Prefix for generated IDs") use_stable_ids: bool = Field(default=True, description="Generate stable, predictable IDs") include_coordinates: bool = Field(default=False, description="Include row/col coordinates in IDs") # ARIA support enable_aria: bool = Field(default=True, description="Enable ARIA attributes") root_role: ARIARole = Field(default=ARIARole.IMG, description="ARIA role for root SVG element") module_role: Optional[ARIARole] = Field(default=None, description="ARIA role for individual modules") # Labels and descriptions root_label: str = Field(default="QR Code", description="ARIA label for root element") root_description: Optional[str] = Field(default=None, description="Description of QR code content") include_module_labels: bool = Field(default=False, description="Add labels to individual modules") include_pattern_labels: bool = Field( default=True, description="Add labels to QR patterns (finder, timing, etc.)" ) # Screen reader optimization optimize_for_screen_readers: bool = Field( default=True, description="Optimize structure for screen readers" ) group_similar_elements: bool = Field( default=True, description="Group similar elements for better navigation" ) add_structural_markup: bool = Field(default=True, description="Add structural markup for complex layouts") # Interactive features enable_keyboard_navigation: bool = Field(default=False, description="Enable keyboard navigation") focus_visible_elements: List[str] = Field( default_factory=lambda: ["root"], description="Elements that should be focusable", ) # Compliance level target_compliance: AccessibilityLevel = Field( default=AccessibilityLevel.AA, description="Target WCAG compliance level" ) # Custom attributes custom_attributes: Dict[str, str] = Field( default_factory=dict, description="Custom accessibility attributes" )
[docs] @field_validator("id_prefix") @classmethod def validate_id_prefix(cls, v: str) -> str: """Validate ID prefix follows HTML standards.""" if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", v): raise ValueError( "ID prefix must start with a letter and contain only " "letters, numbers, underscores, and hyphens" ) return v
[docs] @field_validator("focus_visible_elements") @classmethod def validate_focus_elements(cls, v: List[str]) -> List[str]: """Validate focus element names.""" valid_elements = { "root", "frame", "centerpiece", "modules", "finder", "timing", "alignment", "data", } for element in v: if element not in valid_elements: raise ValueError(f"Invalid focus element: {element}. Must be one of {valid_elements}") return v
class IDGenerator: """Generates stable, predictable IDs for QR code elements.""" def __init__(self, config: AccessibilityConfig): self.config = config self.used_ids: Set[str] = set() self.element_counter = 0 def generate_root_id(self) -> str: """Generate ID for the root SVG element.""" base_id = f"{self.config.id_prefix}-root" return self._ensure_unique(base_id) def generate_module_id(self, row: int, col: int, module_type: str = "module") -> str: """Generate ID for a QR module.""" if self.config.include_coordinates: base_id = f"{self.config.id_prefix}-{module_type}-{row}-{col}" else: self.element_counter += 1 base_id = f"{self.config.id_prefix}-{module_type}-{self.element_counter}" return self._ensure_unique(base_id) def generate_pattern_id(self, pattern_type: str, index: Optional[int] = None) -> str: """Generate ID for a QR pattern (finder, timing, etc.).""" if index is not None: base_id = f"{self.config.id_prefix}-{pattern_type}-{index}" else: base_id = f"{self.config.id_prefix}-{pattern_type}" return self._ensure_unique(base_id) def generate_group_id(self, group_type: str) -> str: """Generate ID for a group element.""" base_id = f"{self.config.id_prefix}-{group_type}-group" return self._ensure_unique(base_id) def generate_frame_id(self) -> str: """Generate ID for frame element.""" base_id = f"{self.config.id_prefix}-frame" return self._ensure_unique(base_id) def generate_centerpiece_id(self) -> str: """Generate ID for centerpiece element.""" base_id = f"{self.config.id_prefix}-centerpiece" return self._ensure_unique(base_id) def generate_layer_id(self, layer_type: str) -> str: """Generate ID for a layer element.""" base_id = f"{self.config.id_prefix}-layer-{layer_type}" return self._ensure_unique(base_id) def generate_title_id(self) -> str: """Generate ID for title element.""" base_id = f"{self.config.id_prefix}-title" return self._ensure_unique(base_id) def generate_desc_id(self) -> str: """Generate ID for description element.""" base_id = f"{self.config.id_prefix}-desc" return self._ensure_unique(base_id) def _ensure_unique(self, base_id: str) -> str: """Ensure ID is unique by adding suffix if needed.""" if base_id not in self.used_ids: self.used_ids.add(base_id) return base_id counter = 1 while f"{base_id}-{counter}" in self.used_ids: counter += 1 unique_id = f"{base_id}-{counter}" self.used_ids.add(unique_id) return unique_id
[docs] class AccessibilityEnhancer: """Enhances SVG elements with comprehensive accessibility features."""
[docs] def __init__(self, config: AccessibilityConfig): self.config = config self.id_generator = IDGenerator(config) self.element_registry: Dict[str, ElementAccessibility] = {}
[docs] def enhance_root_element( self, svg_element: ET.Element, title: Optional[str] = None, description: Optional[str] = None, ) -> ElementAccessibility: """Enhance the root SVG element with accessibility features.""" if not self.config.enabled: # Even when disabled, add basic title for minimal accessibility if title: import xml.etree.ElementTree as ET title_element = ET.SubElement(svg_element, "title") title_element.text = title # Use proper ID generation even when disabled element_id = self.id_generator.generate_root_id() return ElementAccessibility(element_id=element_id) # Generate stable ID element_id = self.id_generator.generate_root_id() # Create accessibility info accessibility = ElementAccessibility( element_id=element_id, aria_role=self.config.root_role.value if self.config.enable_aria else None, aria_label=title or self.config.root_label, title=title or self.config.root_label, ) # Add description if provided if description or self.config.root_description: desc_id = f"{element_id}-desc" accessibility.aria_describedby = desc_id # Add keyboard navigation if enabled if self.config.enable_keyboard_navigation and "root" in self.config.focus_visible_elements: accessibility.tabindex = 0 # Apply attributes self._apply_accessibility_attributes(svg_element, accessibility) # Create child elements for title and description (SVG best practice) import xml.etree.ElementTree as ET if title or self.config.root_label: title_text = title or self.config.root_label title_id = f"{self.config.id_prefix}-title" title_element = ET.SubElement(svg_element, "title", id=title_id) title_element.text = title_text accessibility.aria_labelledby = title_id if description or self.config.root_description: desc_text = description or self.config.root_description desc_id = f"{self.config.id_prefix}-desc" desc_element = ET.SubElement(svg_element, "desc", id=desc_id) desc_element.text = desc_text accessibility.aria_describedby = desc_id # Register element self.element_registry[element_id] = accessibility return accessibility
[docs] def enhance_module_element( self, element: Any, row: int, col: int, module_type: str = "data" ) -> ElementAccessibility: """Enhance a QR module element with accessibility features.""" if not self.config.enabled: return ElementAccessibility(element_id=f"module-{row}-{col}") # Generate ID element_id = self.id_generator.generate_module_id(row, col, module_type) # Create accessibility info accessibility = ElementAccessibility( element_id=element_id, aria_role=(self.config.module_role.value if self.config.module_role else None), ) # Add label if enabled if self.config.include_module_labels: accessibility.aria_label = f"{module_type} module at row {row}, column {col}" # Add title for hover information if module_type != "data": # Add titles for special patterns accessibility.title = f"{module_type.title()} pattern module" # Apply attributes self._apply_accessibility_attributes(element, accessibility) # Add data attributes for debugging and testing element.set("data-module-type", module_type) element.set("data-x", str(row)) element.set("data-y", str(col)) # Register element self.element_registry[element_id] = accessibility return accessibility
[docs] def enhance_pattern_group( self, group_element: Any, pattern_type: str, index: int = 0 ) -> ElementAccessibility: """Enhance a pattern group (finder, timing, etc.) with accessibility.""" if not self.config.enabled: return ElementAccessibility(element_id=f"{pattern_type}-{index}") # Generate ID element_id = self.id_generator.generate_pattern_id(pattern_type, index) # Create accessibility info accessibility = ElementAccessibility( element_id=element_id, aria_role=ARIARole.GROUP.value if self.config.enable_aria else None, ) # Add pattern-specific labels if self.config.include_pattern_labels: labels = { "finder": (f"Finder pattern {index + 1}" if index is not None else "Finder pattern"), "timing": "Timing pattern", "alignment": (f"Alignment pattern {index + 1}" if index is not None else "Alignment pattern"), "format": "Format information", "version": "Version information", "data": "Data modules", "quiet": "Quiet zone", } accessibility.aria_label = labels.get(pattern_type, f"{pattern_type} pattern") # Apply attributes self._apply_accessibility_attributes(group_element, accessibility) # Register element self.element_registry[element_id] = accessibility return accessibility
[docs] def create_description_element(self, parent_element: Any, description: str, ref_id: str) -> str: """Create a description element and return its ID.""" import xml.etree.ElementTree as ET desc_id = f"{ref_id}-desc" desc_element = ET.SubElement(parent_element, "desc", id=desc_id) desc_element.text = description return desc_id
[docs] def generate_accessibility_report(self) -> Dict[str, Any]: """Generate a report of accessibility features applied.""" report = { "enabled": self.config.enabled, "compliance_target": self.config.target_compliance.value, "total_elements": len(self.element_registry), "elements_with_aria": sum(1 for elem in self.element_registry.values() if elem.aria_role), "elements_with_labels": sum(1 for elem in self.element_registry.values() if elem.aria_label), "focusable_elements": sum( 1 for elem in self.element_registry.values() if elem.tabindex is not None ), "features": { "stable_ids": self.config.use_stable_ids, "aria_support": self.config.enable_aria, "keyboard_navigation": self.config.enable_keyboard_navigation, "screen_reader_optimization": self.config.optimize_for_screen_readers, "pattern_labels": self.config.include_pattern_labels, "module_labels": self.config.include_module_labels, }, "id_statistics": { "prefix": self.config.id_prefix, "total_generated": len(self.id_generator.used_ids), "include_coordinates": self.config.include_coordinates, }, } return report
[docs] def validate_accessibility(self, svg_root: Optional[Any] = None) -> List[str]: """Validate accessibility implementation and return issues.""" issues = [] if not self.config.enabled: return ["Accessibility features are disabled"] # If svg_root is provided, validate directly from the element if svg_root is not None: return self._validate_svg_element(svg_root) # Check for root element root_elements = [elem for elem in self.element_registry.values() if "root" in elem.element_id] if not root_elements: issues.append("No root element found with accessibility attributes") # Check ARIA compliance if self.config.enable_aria: elements_without_role = [ elem for elem in self.element_registry.values() if not elem.aria_role and "root" in elem.element_id ] if elements_without_role: issues.append("Root element missing ARIA role") # Check keyboard navigation if self.config.enable_keyboard_navigation: focusable = [elem for elem in self.element_registry.values() if elem.tabindex is not None] if not focusable: issues.append("Keyboard navigation enabled but no focusable elements found") # Check for proper labeling if self.config.target_compliance in [ AccessibilityLevel.AA, AccessibilityLevel.AAA, ]: unlabeled_interactive = [ elem for elem in self.element_registry.values() if elem.tabindex is not None and not elem.aria_label ] if unlabeled_interactive: issues.append("Interactive elements missing ARIA labels") return issues
def _validate_svg_element(self, svg_root: Any) -> List[str]: """Validate accessibility of an SVG element directly.""" issues = [] # Check for title element title_elem = svg_root.find("title") if title_elem is None: issues.append("Missing title element for accessibility") # Check for description element (optional but good for comprehensive # accessibility) # desc_elem = svg_root.find("desc") # Not currently used # Check for ARIA attributes if enabled if self.config.enable_aria: if not svg_root.get("role"): issues.append("Missing ARIA role attribute") if not svg_root.get("aria-label") and title_elem is None: issues.append("Missing ARIA label or title element") # Check for proper ID structure if self.config.use_stable_ids and not svg_root.get("id"): issues.append("Missing ID attribute for stable identification") return issues def _apply_accessibility_attributes(self, element: Any, accessibility: ElementAccessibility) -> None: """Apply accessibility attributes to an SVG element.""" attributes = accessibility.to_attributes() # Add custom attributes attributes.update(self.config.custom_attributes) # Apply all attributes for key, value in attributes.items(): if value is not None: element.set(key, str(value))
def create_accessibility_config(**kwargs: Any) -> AccessibilityConfig: """Factory function to create accessibility configuration with common presets.""" return AccessibilityConfig(**kwargs) def create_basic_accessibility() -> AccessibilityConfig: """Create basic accessibility configuration (WCAG AA compliance).""" return AccessibilityConfig( enabled=True, target_compliance=AccessibilityLevel.AA, enable_aria=True, include_pattern_labels=True, optimize_for_screen_readers=True, ) def create_enhanced_accessibility() -> AccessibilityConfig: """Create enhanced accessibility configuration (WCAG AAA compliance).""" return AccessibilityConfig( enabled=True, target_compliance=AccessibilityLevel.AAA, enable_aria=True, include_pattern_labels=True, include_module_labels=True, optimize_for_screen_readers=True, enable_keyboard_navigation=True, focus_visible_elements=["root", "finder", "frame", "centerpiece", "timing"], add_structural_markup=True, ) def create_minimal_accessibility() -> AccessibilityConfig: """Create minimal accessibility configuration.""" return AccessibilityConfig( enabled=True, target_compliance=AccessibilityLevel.A, enable_aria=False, include_pattern_labels=False, optimize_for_screen_readers=False, use_stable_ids=False, )