use super::*;
pub(in crate::output::pdf) fn render_a11y_journey_findings(
mut builder: renderreport::engine::ReportBuilder,
findings: &[crate::audit::normalized::InteractiveFinding],
journey: Option<&crate::audit::normalized::AccessibilityJourney>,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
builder = builder.add_component(PageBreak::new()).add_component(
Section::new(if en {
"Keyboard Accessibility Journey"
} else {
"Tastatur-Accessibility-Journey"
})
.with_level(2),
);
let mut deduped: Vec<(&crate::audit::normalized::InteractiveFinding, usize)> = Vec::new();
for f in findings {
if let Some(entry) = deduped.iter_mut().find(|(g, _)| {
g.journey == f.journey
&& g.category == f.category
&& g.message == f.message
&& g.severity == f.severity
}) {
entry.1 += 1;
} else {
deduped.push((f, 1));
}
}
let critical_count = deduped
.iter()
.filter(|(f, _)| f.severity == crate::taxonomy::Severity::Critical)
.count();
let high_count = deduped
.iter()
.filter(|(f, _)| f.severity == crate::taxonomy::Severity::High)
.count();
let overview = if critical_count > 0 {
if en {
format!(
"{} critical and {} high issues detected during keyboard and state-transition tests.",
critical_count, high_count
)
} else {
format!(
"{} kritische und {} hohe Befunde aus Tastatur- und Zustandswechsel-Tests.",
critical_count, high_count
)
}
} else if high_count > 0 {
if en {
format!("{} high-severity interactive issues found.", high_count)
} else {
format!(
"{} interaktive Befunde mit hoher Schwere gefunden.",
high_count
)
}
} else if en {
format!(
"{} minor interactive {} — no critical or high barriers detected.",
deduped.len(),
if deduped.len() == 1 {
"issue"
} else {
"issues"
}
)
} else {
format!(
"{} {} interaktive{} Befund{} — keine kritischen oder hohen Barrieren erkannt.",
deduped.len(),
if deduped.len() == 1 {
"kleinerer"
} else {
"kleinere"
},
if deduped.len() == 1 { "r" } else { "" },
if deduped.len() == 1 { "" } else { "e" }
)
};
builder = builder.add_component(if critical_count > 0 {
Callout::warning(&overview)
} else {
Callout::info(&overview)
});
let shown = deduped.len().min(10);
for (finding, count) in &deduped[..shown] {
let sev = map_severity(&finding.severity);
let (message, fix_suggestion) =
crate::audit::normalized::interactive_finding_text(finding.kind, &finding.values, en);
let body = if let Some(fix) = fix_suggestion {
format!("{} — {}", message, fix)
} else {
message
};
let label = journey_category_label(&finding.category, i18n);
let title = if *count > 1 {
format!("{label} (×{count})")
} else {
label
};
builder = builder.add_component(Finding::new(&title, sev, &body));
}
if deduped.len() > 10 {
let more = if en {
format!(
"{} additional interactive findings in the JSON report.",
deduped.len() - 10
)
} else {
format!(
"{} weitere interaktive Befunde im JSON-Report.",
deduped.len() - 10
)
};
builder = builder.add_component(Callout::info(&more));
}
if let Some(journey_data) = journey {
if !journey_data.traces.is_empty() {
let mut kv = KeyValueList::new().with_title(if en {
"Tested Journey Sequences"
} else {
"Geprüfte Journey-Sequenzen"
});
for trace in &journey_data.traces {
let step_count = trace.steps.len();
let summary = format!("{} {}", step_count, if en { "steps" } else { "Schritte" });
kv = kv.add(journey_label(&trace.journey, en), summary);
}
builder = builder.add_component(kv);
}
}
let disclaimer = if en {
"These tests check whether browser, DOM, focus, and accessibility tree provide a robust foundation for screen reader use. They do not simulate the exact output of NVDA, JAWS, or VoiceOver."
} else {
"Diese Tests prüfen, ob Browser, DOM, Fokus und Accessibility Tree eine robuste Grundlage für Screenreader-Nutzung liefern. Sie simulieren nicht den exakten Output von NVDA, JAWS oder VoiceOver."
};
builder = builder.add_component(Callout::info(disclaimer));
builder
}
pub(in crate::output::pdf) fn render_screen_reader_section(
mut builder: renderreport::engine::ReportBuilder,
sr: &crate::screen_reader::SrAuditReport,
patterns: Option<&crate::patterns::PatternAnalysis>,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
use crate::screen_reader::BfsgVerdict;
let en = i18n.locale() == "en";
builder = builder.add_component(PageBreak::new()).add_component(
Section::new(if en {
"Screen Reader Reading Order"
} else {
"Screenreader-Lesereihenfolge"
})
.with_level(2),
);
let intro = if en {
"How the page is announced to screen reader users: reading order, landmark and heading quality, and accessible names. These are structural checks on the accessibility tree, not a simulation of NVDA, JAWS or VoiceOver."
} else {
"Wie die Seite Screenreader-Nutzern angekündigt wird: Lesereihenfolge, Landmark- und Heading-Qualität sowie zugängliche Namen. Strukturelle Prüfungen am Accessibility Tree, keine Simulation von NVDA, JAWS oder VoiceOver."
};
builder = builder.add_component(
Label::new(intro)
.with_size("10.5pt")
.with_color(crate::output::pdf::design::tokens::NEUTRAL),
);
let s = &sr.summary;
builder = builder.add_component(
MetricStrip::new(vec![
MetricStripItem::new(
if en {
"Heading quality"
} else {
"Heading-Qualität"
},
format!("{} / 100", s.heading_quality_score),
)
.with_accent(crate::output::pdf::design::score_color(
s.heading_quality_score as u8,
)),
MetricStripItem::new(
if en {
"Landmark quality"
} else {
"Landmark-Qualität"
},
format!("{} / 100", s.landmark_quality_score),
)
.with_accent(crate::output::pdf::design::score_color(
s.landmark_quality_score as u8,
)),
MetricStripItem::new(
if en {
"Name quality"
} else {
"Namens-Qualität"
},
format!("{} / 100", s.name_quality_score),
)
.with_accent(crate::output::pdf::design::score_color(
s.name_quality_score as u8,
)),
MetricStripItem::new(
if en {
"Announced nodes"
} else {
"Angekündigte Knoten"
},
s.total_announced_nodes.to_string(),
),
MetricStripItem::new(
if en { "Tab stops" } else { "Tab-Stopps" },
s.tab_stops.to_string(),
),
])
.compact(),
);
builder = builder.add_component(
Label::new(if en {
"The three quality scores use a 0–100 scale (higher is better) and summarize the detected heading hierarchy, landmark structure, and accessible names. Announced nodes and tab stops are simple counts, not quality scores."
} else {
"Die drei Qualitätswerte nutzen eine Skala von 0–100 (höher ist besser) und verdichten erkannte Überschriftenhierarchie, Landmark-Struktur und zugängliche Namen. Angekündigte Knoten und Tab-Stopps sind reine Anzahlen, keine Qualitätswerte."
})
.with_size("9pt")
.with_color(crate::output::pdf::design::tokens::NEUTRAL),
);
let bfsg_note = match sr.bfsg_compliance.verdict {
BfsgVerdict::Compliant => Callout::info(if en {
"BFSG-relevant technical criteria: no violations detected within the automated scope. This is not a legal conformance assessment."
} else {
"BFSG-relevante technische Kriterien: im automatisierten Prüfumfang keine Verstöße erkannt. Dies ist keine rechtliche Konformitätsprüfung."
}),
BfsgVerdict::NonCompliant => Callout::warning(if en {
"BFSG-relevant technical criteria: violations detected within the automated scope (see issues below). This is not a legal conformance assessment."
} else {
"BFSG-relevante technische Kriterien: im automatisierten Prüfumfang Verstöße festgestellt (siehe Befunde unten). Dies ist keine rechtliche Konformitätsprüfung."
}),
BfsgVerdict::NotEvaluated => Callout::info(if en {
"BFSG conformance was not evaluated for this page."
} else {
"BFSG-Konformität wurde für diese Seite nicht bewertet."
}),
};
builder = builder.add_component(bfsg_note);
let items: Vec<crate::screen_reader::ReadingItem> =
sr.reading_sequence.iter().map(|a| a.item.clone()).collect();
let has_disclosure_menu_pattern =
patterns.is_some_and(|patterns| patterns.has_recognized("DisclosureMenu"));
let localized_issues = crate::screen_reader::analyze_reading_sequence(
&items,
&sr.navigation_views,
i18n.locale(),
i18n.locale() == "en",
has_disclosure_menu_pattern,
);
if !localized_issues.is_empty() {
let mut deduped: Vec<(&crate::screen_reader::SrAuditIssue, usize)> = Vec::new();
for issue in &localized_issues {
if let Some(entry) = deduped
.iter_mut()
.find(|(g, _)| g.message == issue.message && g.severity == issue.severity)
{
entry.1 += 1;
} else {
deduped.push((issue, 1));
}
}
let sev_label = |sev: &str| -> &'static str {
match (sev, en) {
("high", true) => "High",
("high", false) => "Hoch",
("medium", true) => "Medium",
("medium", false) => "Mittel",
(_, true) => "Low",
(_, false) => "Niedrig",
}
};
let mut table = AuditTable::new(vec![
TableColumn::new(i18n.t("label-issue")).with_width("75%"),
TableColumn::new(i18n.t("label-severity")).with_width("25%"),
])
.with_title(if en {
"Screen reader findings"
} else {
"Screenreader-Befunde"
});
for (issue, count) in deduped {
let mut msg = if count > 1 {
format!("{} ({}×)", issue.message, count)
} else {
issue.message.clone()
};
if let Some(instruction) = issue
.wcag_criterion
.as_deref()
.and_then(|c| manual_recheck_instruction(c, en))
{
msg.push(' ');
msg.push_str(instruction);
}
table = table.add_row(vec![msg, sev_label(&issue.severity).to_string()]);
}
builder = builder.add_component(table);
}
builder
}
pub(super) fn journey_label(journey: &str, en: bool) -> String {
let (base, index) = match journey.rsplit_once('_') {
Some((base, tail)) if tail.chars().all(|c| c.is_ascii_digit()) && !tail.is_empty() => (
base,
tail.parse::<usize>()
.ok()
.map(|index| index.saturating_add(1)),
),
_ => (journey, None),
};
let label = match base {
"tab_walk" => Some(if en { "Tab walk" } else { "Tab-Durchlauf" }),
"spa_navigation" => Some(if en {
"SPA navigation"
} else {
"SPA-Navigation"
}),
"skip_link" => Some(if en { "Skip link" } else { "Sprunglink" }),
"disclosure" => Some(if en {
"Disclosure menu"
} else {
"Aufklapp-Menü"
}),
"menu" => Some(if en { "Menu" } else { "Menü" }),
"modal" => Some(if en { "Modal dialog" } else { "Dialog" }),
"tabs" => Some(if en { "Tab panel" } else { "Tab-Panel" }),
"form_error" => Some(if en { "Form error" } else { "Formularfehler" }),
"add_to_cart" => Some(if en {
"Add to cart"
} else {
"In den Warenkorb"
}),
"quantity_stepper" => Some(if en {
"Quantity stepper"
} else {
"Mengenauswahl"
}),
_ => None,
};
match (label, index) {
(Some(label), Some(index)) => format!("{label} {index}"),
(Some(label), None) => label.to_string(),
(None, _) => journey.to_string(),
}
}