Source code for glyphx.a11y

"""
GlyphX Accessibility helpers.

Generates plain-English alt text for SVG charts and provides
utilities for injecting ARIA attributes into rendered SVGs.
"""
from __future__ import annotations

import re
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .figure import Figure


# Map class name → human-readable kind
_KIND_NAMES: dict[str, str] = {
    "lineseries":       "line",
    "barseries":        "bar",
    "scatterseries":    "scatter",
    "pieseries":        "pie",
    "donutseries":      "donut",
    "histogramseries":  "histogram",
    "boxplotseries":    "box plot",
    "heatmapseries":    "heatmap",
}


[docs] def generate_alt_text(fig: Figure) -> str: """ Generate a plain-English description of a Figure for screen readers. The description covers chart type, title, axis labels, series count, data ranges, and notable values (min / max). Args: fig: A GlyphX :class:`Figure` instance. Returns: A human-readable string suitable for ``aria-label`` or ``<desc>``. """ parts: list[str] = [] # ── Chart kind ────────────────────────────────────────────────────── kinds: list[str] = [] for s, _ in fig.series: raw = type(s).__name__.lower() kind = _KIND_NAMES.get(raw, raw.replace("series", "")) kinds.append(kind) primary_kind = kinds[0] if kinds else "chart" # ── Title ──────────────────────────────────────────────────────────── if fig.title: parts.append(f"{primary_kind.capitalize()} chart titled \"{fig.title}\".") else: parts.append(f"{primary_kind.capitalize()} chart.") # ── Axis labels ────────────────────────────────────────────────────── if getattr(fig.axes, "xlabel", None): parts.append(f"X axis: {fig.axes.xlabel}.") if getattr(fig.axes, "ylabel", None): parts.append(f"Y axis: {fig.axes.ylabel}.") # ── Series descriptions ─────────────────────────────────────────────── for s, _ in fig.series: x_vals = getattr(s, "x", None) y_vals = getattr(s, "y", None) # Pie / donut special case values = getattr(s, "values", None) labels = getattr(s, "labels", None) if values is not None and labels is not None: total = sum(values) biggest = max(zip(values, labels), key=lambda p: p[0]) parts.append( f"Contains {len(values)} slices. " f"Largest: {biggest[1]} ({biggest[0]:.3g} of {total:.3g})." ) continue if not x_vals or not y_vals: continue lbl = f'Series "{s.label}"' if getattr(s, "label", None) else "Series" # Count n = len(x_vals) parts.append(f"{lbl}: {n} data point{'s' if n != 1 else ''}.") # Range (numeric y only) try: numeric_y = [float(v) for v in y_vals] numeric_x = list(x_vals) mn = min(numeric_y) mx = max(numeric_y) # Use enumerate to find indices safely mn_idx = next(i for i, v in enumerate(numeric_y) if v == mn) mx_idx = next(i for i, v in enumerate(numeric_y) if v == mx) mn_x = numeric_x[mn_idx] if mn_idx < len(numeric_x) else "?" mx_x = numeric_x[mx_idx] if mx_idx < len(numeric_x) else "?" parts.append( f"Ranges from {mn:.3g} (at {mn_x}) " f"to {mx:.3g} (at {mx_x})." ) except (TypeError, ValueError, StopIteration): pass return " ".join(parts) if parts else "Interactive chart."
[docs] def inject_aria(svg: str, title: str, desc: str, chart_id: str) -> str: """ Inject ARIA attributes and landmark elements into a rendered SVG string. Changes made: - Adds ``role="img"`` and ``aria-labelledby`` to the root ``<svg>`` (only if not already present) - Injects ``<title>`` and ``<desc>`` as the first children of the SVG - Adds ``tabindex="0"`` and ``role="graphics-symbol"`` to every ``.glyphx-point`` element for keyboard navigation Args: svg: Raw SVG string from ``Figure.render_svg()``. title: Short label (goes in ``<title>``). desc: Longer description (goes in ``<desc>``). chart_id: Unique chart ID already present on the ``<svg>`` element. Returns: The accessibility-enhanced SVG string. """ from .utils import svg_escape title_id = f"{chart_id}-title" desc_id = f"{chart_id}-desc" # ── 1. Add role + aria-labelledby only if not already present ──────── if 'role=' not in svg: svg = svg.replace( "<svg ", f'<svg role="img" focusable="false" ' f'aria-labelledby="{title_id} {desc_id}" ', 1 ) # ── 2. Inject <title> and <desc> right after the first > ───────────── insert = ( f'<title id="{title_id}">{svg_escape(title)}</title>' f'<desc id="{desc_id}">{svg_escape(desc)}</desc>' ) svg = svg.replace(">", f">{insert}", 1) # ── 3. Add tabindex + role to every interactive point ───────────────── svg = re.sub( r'(class="glyphx-point[^"]*")', r'\1 tabindex="0" role="graphics-symbol"', svg, ) return svg