auditmysite 1.4.0

Resource-efficient WCAG 2.1 AA (+ select 2.2 criteria) Accessibility Checker in Rust
Documentation
//! WCAG 1.1.1 - SVG Accessibility Rules
//!
//! Dedicated rules for SVG elements exposed with role="img" in the accessibility tree.
//! Focuses on SVG-specific patterns distinct from general media checks.
//!
//! ## `svg-img-alt` reachability (#563)
//!
//! Real Chrome exposes a bare `<svg>` (no explicit `role` attribute) — and an
//! `<svg role="img">` — as AX role **`"image"`**, indistinguishable from a
//! native `<img>` (confirmed live). `check_svg_img_name`'s `"img"` match below
//! and `text_alternatives.rs`'s general image loop therefore necessarily
//! overlap for that case; both existed before this fix and neither is new
//! false-positive risk, so it's left as-is (`image-alt` catches it, same
//! observable behavior as today) rather than attempting an unverified DOM
//! cross-reference to reassign it to `svg-img-alt`. `role="SVGRoot"`/
//! `"SvgRoot"` (this file's other historical match arms) have never been
//! confirmed to occur in real Chrome output and are believed dead, but are
//! left in place as a harmless no-op fallback.
//!
//! `role="graphics-document"` and `role="graphics-symbol"` are different:
//! confirmed live to compute to their own distinct AX roles, never `"image"`
//! or `"img"` — unambiguous, so `check_svg_rules` owns them outright (moved
//! here from `text_alternatives.rs::check_svg_elements`, which mis-filed them
//! under the more generic `image-alt` axe_id despite the unique role making
//! precise attribution possible with no double-report risk).

use crate::accessibility::{AXNode, AXTree};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};

/// Rule metadata for SVG accessibility (1.1.1)
pub const RULE_META: RuleMetadata = RuleMetadata {
    id: "1.1.1",
    name: "Non-text Content - SVG",
    level: WcagLevel::A,
    severity: Severity::High,
    description: "SVG images must have meaningful accessible names; decorative SVGs must be hidden from assistive technology",
    help_url: "https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html",
    axe_id: "svg-img-alt",
    tags: &["wcag2a", "wcag111", "cat.images"],
};

/// Run all SVG-specific WCAG checks
///
/// This rule focuses on SVG elements that appear as role="img" in the AX tree,
/// which is distinct from the broader media_rules checks.
pub fn check_svg_rules(tree: &AXTree) -> WcagResults {
    let mut results = WcagResults::new();

    for node in tree.iter() {
        if node.ignored {
            continue;
        }

        let role = match node.role.as_deref() {
            Some(r) => r,
            None => continue,
        };

        // SVGs most commonly appear as role="img" or "SVGRoot"/"SvgRoot" in the AX tree
        let is_svg = role == "img"
            || role == "SVGRoot"
            || role == "SvgRoot"
            || role == "graphics-document"
            || role == "graphics-symbol"
            // Some browsers expose SVG with an explicit SVG-related name source
            || node
                .name_source
                .map(|ns| {
                    // If it's an img with no name_source hint, it might be SVG
                    matches!(
                        ns,
                        crate::accessibility::NameSource::Attribute
                            | crate::accessibility::NameSource::RelatedElement
                    )
                })
                .unwrap_or(false);

        if !is_svg {
            continue;
        }

        results.nodes_checked += 1;

        match role {
            "SVGRoot" | "SvgRoot" => {
                // SVG root nodes that are focusable or visible need an accessible name
                check_svg_root_has_name(node, &mut results);
            }
            "img" => {
                // role=img on an SVG element: check name and whitespace-only names
                check_svg_img_name(node, &mut results);
            }
            "graphics-document" | "graphics-symbol" => {
                // Explicit ARIA graphics roles — unique to authored SVGs, never
                // shared with a native <img> (#563), so owning this outright
                // carries no double-report risk.
                check_svg_graphics_role_has_name(node, &mut results);
            }
            _ => {}
        }
    }

    results
}

/// SVG root elements that are not hidden should have a title or aria-label
fn check_svg_root_has_name(node: &AXNode, results: &mut WcagResults) {
    if !node.has_name() {
        let violation = Violation::new(
            RULE_META.id,
            RULE_META.name,
            RULE_META.level,
            Severity::Medium,
            "SVG root element has no accessible name — consider adding a <title> or aria-hidden=\"true\" if decorative",
            &node.node_id,
        )
        .with_role(node.role.clone())
        .with_fix(
            "Add a <title> as the first child of the <svg>, use aria-label, or hide decorative SVGs with aria-hidden=\"true\"",
        )
        .with_help_url(RULE_META.help_url)
            .with_rule_id(RULE_META.axe_id);

        results.add_violation(violation);
    } else {
        results.passes += 1;
    }
}

/// SVG elements with an explicit `role="graphics-document"`/`"graphics-symbol"`
/// need an accessible name — moved from `text_alternatives.rs::check_svg_elements`
/// (#563), same message/severity, now filed under `svg-img-alt`.
fn check_svg_graphics_role_has_name(node: &AXNode, results: &mut WcagResults) {
    if !node.has_name() {
        let violation = Violation::new(
            RULE_META.id,
            RULE_META.name,
            RULE_META.level,
            Severity::High,
            "SVG graphic is missing alternative text",
            &node.node_id,
        )
        .with_role(node.role.clone())
        .with_fix("Add <title> element inside SVG, or aria-label on the SVG element")
        .with_help_url(RULE_META.help_url)
        .with_rule_id(RULE_META.axe_id);

        results.add_violation(violation);
    } else {
        results.passes += 1;
    }
}

/// SVG images exposed as role="img" need non-empty, non-whitespace accessible names
fn check_svg_img_name(node: &AXNode, results: &mut WcagResults) {
    match node.name.as_deref() {
        None => {
            // No name at all
            let violation = Violation::new(
                RULE_META.id,
                RULE_META.name,
                RULE_META.level,
                Severity::High,
                "SVG image is missing an accessible name",
                &node.node_id,
            )
            .with_role(node.role.clone())
            .with_fix(
                "Add a <title> element as the first child of the SVG, or use aria-label/aria-labelledby",
            )
            .with_help_url(RULE_META.help_url)
            .with_rule_id(RULE_META.axe_id);

            results.add_violation(violation);
        }
        Some(name) if name.trim().is_empty() => {
            // Whitespace-only name
            let violation = Violation::new(
                RULE_META.id,
                RULE_META.name,
                RULE_META.level,
                Severity::Medium,
                "SVG image has empty accessible name",
                &node.node_id,
            )
            .with_role(node.role.clone())
            .with_name(node.name.clone())
            .with_fix(
                "Replace the empty accessible name with a meaningful description, or mark the SVG as decorative with role=\"presentation\" and aria-hidden=\"true\"",
            )
            .with_help_url(RULE_META.help_url)
            .with_rule_id(RULE_META.axe_id);

            results.add_violation(violation);
        }
        Some(_) => {
            // Valid name present — check for non-interactivity suggesting it could be decorative
            // This is informational only (Low severity)
            let is_interactive = node.is_interactive() || node.is_focusable();
            let _ = is_interactive; // informational, not flagged
            results.passes += 1;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::accessibility::{AXNode, AXTree};

    fn make_node(id: &str, role: &str, name: Option<&str>) -> AXNode {
        AXNode {
            node_id: id.to_string(),
            ignored: false,
            ignored_reasons: vec![],
            role: Some(role.to_string()),
            name: name.map(String::from),
            name_source: None,
            description: None,
            value: None,
            properties: vec![],
            child_ids: vec![],
            parent_id: None,
            backend_dom_node_id: None,
        }
    }

    #[test]
    fn test_svg_img_without_name_flagged() {
        let nodes = vec![make_node("1", "img", None)];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results
            .violations
            .iter()
            .any(|v| v.message.contains("missing an accessible name")));
    }

    #[test]
    fn test_svg_img_with_whitespace_name_flagged() {
        let nodes = vec![make_node("1", "img", Some("   "))];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results
            .violations
            .iter()
            .any(|v| v.message.contains("empty accessible name")));
    }

    #[test]
    fn test_svg_img_with_name_passes() {
        let nodes = vec![make_node("1", "img", Some("Company logo"))];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results.violations.is_empty());
        assert_eq!(results.passes, 1);
    }

    #[test]
    fn test_svg_root_without_name_flagged() {
        let nodes = vec![make_node("1", "SVGRoot", None)];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results
            .violations
            .iter()
            .any(|v| v.message.contains("no accessible name")));
    }

    #[test]
    fn test_svg_root_with_name_passes() {
        let nodes = vec![make_node("1", "SVGRoot", Some("Chart: Monthly Sales"))];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results.violations.is_empty());
    }

    #[test]
    fn test_svg_graphics_document_without_name_flagged() {
        let nodes = vec![make_node("1", "graphics-document", None)];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results
            .violations
            .iter()
            .any(|v| v.rule_id.as_deref() == Some("svg-img-alt")
                && v.message.contains("missing alternative text")));
    }

    #[test]
    fn test_svg_graphics_symbol_without_name_flagged() {
        let nodes = vec![make_node("1", "graphics-symbol", None)];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results
            .violations
            .iter()
            .any(|v| v.rule_id.as_deref() == Some("svg-img-alt")
                && v.message.contains("missing alternative text")));
    }

    #[test]
    fn test_svg_graphics_document_with_name_passes() {
        let nodes = vec![make_node("1", "graphics-document", Some("Sales chart"))];
        let tree = AXTree::from_nodes(nodes);
        let results = check_svg_rules(&tree);
        assert!(results.violations.is_empty());
        assert_eq!(results.passes, 1);
    }
}