mod appendix;
mod batch_report;
mod bik_guide;
mod cover;
pub(crate) mod design;
mod detail_modules;
mod diagnosis;
mod en301549;
mod findings;
mod helpers;
mod problem_profile;
mod root_causes;
mod sanitize;
mod score_drivers;
mod single_report;
mod wcag_coverage;
pub use self::batch_report::{generate_batch_pdf, generate_batch_typ};
use crate::output::localized::is_english;
use renderreport::components::advanced::{
ChecklistPanel, ChecklistRow, PageBreak, TableOfContents,
};
use renderreport::components::text::Label;
use renderreport::components::{CoverModuleGauge, CoverPage};
use renderreport::prelude::*;
use self::appendix::{cover_logo_asset, register_cover_logo_asset};
use self::diagnosis::{business_relevance, format_word_count, output_scope_callout};
use self::helpers::create_engine;
use self::root_causes::{render_root_cause_analysis, render_timeframe_roadmap};
use self::single_report::{
render_appendix_full, render_management_page, render_module_sections, render_part_divider,
render_tech_details,
};
use crate::audit::{normalize, AuditReport};
use crate::cli::ReportLevel;
use crate::i18n::I18n;
use crate::output::builder::{build_view_model, extract_domain};
use crate::output::report_model::*;
const WORDMARK_ASSET: &str = "/auditmysite-wordmark.svg";
const CUSTOM_COVER_LOGO_ASSET: &str = "/cover-logo-custom";
const PAGE_DESKTOP_SCREENSHOT_ASSET: &str = "/auditmysite-desktop-preview.png";
const PAGE_MOBILE_SCREENSHOT_ASSET: &str = "/auditmysite-mobile-preview.png";
pub fn generate_pdf(report: &AuditReport, config: &ReportConfig) -> anyhow::Result<Vec<u8>> {
let pdf_bytes: anyhow::Result<Vec<u8>> = (|| {
let (engine, mut built_report) = build_single_report(report, config)?;
self::sanitize::render_pdf_sanitized(&engine, &mut built_report)
})();
cleanup_screenshot_temps(report);
pdf_bytes
}
pub fn generate_typ(report: &AuditReport, config: &ReportConfig) -> anyhow::Result<String> {
let typ: anyhow::Result<String> = (|| {
let (engine, mut built_report) = build_single_report(report, config)?;
self::sanitize::sanitize_request(&mut built_report, &Default::default());
Ok(engine.render_typ(&built_report)?)
})();
cleanup_screenshot_temps(report);
typ
}
fn build_single_report(
report: &AuditReport,
config: &ReportConfig,
) -> anyhow::Result<(
std::sync::Arc<renderreport::Engine>,
renderreport::RenderRequest,
)> {
let engine = create_engine();
let normalized = normalize(report);
let vm = build_view_model(&normalized, config);
let i18n = I18n::new(&config.locale)?;
let mut builder = engine
.report("wcag-audit")
.metadata("date", &vm.meta.date)
.metadata("version", &vm.meta.version)
.metadata("author", &vm.meta.author)
.metadata("score", &vm.meta.score_label)
.metadata("lang", i18n.locale())
.metadata("footer_prefix", "Audit:")
.metadata("footer_link_url", "")
.metadata(
"footer_tagline",
"A technical auditing platform by casoon.de",
);
builder = register_page_screenshot_assets(builder, report)?;
let en = is_english(&i18n);
let headline_score = vm.summary.score;
let band_phrase = self::cover::cover_band_phrase(
headline_score,
vm.summary.ci_verdict,
vm.summary.run_is_partial,
en,
);
let domain = extract_domain(&vm.cover.domain);
let module_gauges: Vec<CoverModuleGauge> = vm
.modules
.dashboard
.iter()
.map(|m| {
let name = self::helpers::module_name_with_taxonomy_suffix(
&m.name,
&m.measurement_type,
&i18n,
);
CoverModuleGauge::new(name, m.score)
})
.collect();
let (score_lbl, find_lbl, mod_lbl, crit_lbl, no_crit) = if en {
(
"ACCESSIBILITY · AUTOMATED SCOPE",
"WCAG OCCURRENCES",
"MODULES · 0–100 WITHIN THE AUTOMATED SCOPE · HIGHER IS BETTER",
"critical/high",
"0 critical/high",
)
} else {
(
"BARRIEREFREIHEIT · AUTOMATISIERTER PRÜFUMFANG",
"WCAG-VORKOMMEN",
"MODULE · 0–100 IM AUTOMATISIERTEN PRÜFUMFANG · HÖHER IST BESSER",
"kritisch/hoch",
"0 kritisch/hoch",
)
};
let cover_logo_asset = self::appendix::cover_logo_asset(config);
let custom_logo = if cover_logo_asset == CUSTOM_COVER_LOGO_ASSET {
builder = self::appendix::register_cover_logo_asset(builder, config, cover_logo_asset);
Some(cover_logo_asset)
} else {
None
};
let cover_subtitle = format!(
"{} · {}",
vm.executive.cover_kicker,
self::single_report::audit_scope_line(en)
);
let mut cover = CoverPage::new(&vm.cover.title, &domain, headline_score, &vm.cover.grade)
.with_brand(&vm.cover.brand)
.with_subtitle(&cover_subtitle)
.with_date(&vm.cover.date)
.with_band_phrase(&band_phrase)
.with_issues(vm.cover.total_issues, vm.cover.critical_issues)
.with_module_gauges(module_gauges)
.with_labels(score_lbl, find_lbl, mod_lbl, crit_lbl, no_crit);
if let Some(logo) = custom_logo {
cover = cover.with_logo(logo);
}
builder = builder.add_component(cover);
builder = render_management_page(builder, &vm, &i18n);
if vm.meta.report_level != ReportLevel::Executive {
builder = builder.add_component(PageBreak::new());
let toc_title = if en { "Contents" } else { "Inhalt" };
builder = builder.add_component(TableOfContents::new().with_title(toc_title).with_depth(2));
let (p1_title, p1_intro, p1_audience_title, p1_audience_body) = if en {
(
"Findings by Root Cause",
"Which underlying causes explain the most findings, and the prioritized plan to fix them.",
"Audience",
"Site owners, decision-makers, and developers. This part explains what is driving the findings and what to do next.",
)
} else {
(
"Befunde nach Ursache",
"Welche Ursachen die meisten Befunde erklären – und der priorisierte Plan, sie zu beheben.",
"Zielgruppe",
"Inhaber, Entscheider und Entwickler. Dieser Teil erklärt, was die Befunde verursacht und was als Nächstes zu tun ist.",
)
};
builder = render_part_divider(
builder,
1,
p1_title,
p1_intro,
p1_audience_title,
p1_audience_body,
&i18n,
);
builder = render_root_cause_analysis(builder, &vm, &i18n);
builder = render_timeframe_roadmap(builder, &vm, &i18n);
builder = render_audit_confidence_notes(builder, &normalized.normalized.audit_flags, &i18n);
}
if vm.meta.report_level == ReportLevel::Executive {
builder = builder
.add_component(
Callout::info(&vm.methodology.limitations)
.with_title(i18n.t("callout-limitations-title")),
)
.add_component(
Callout::warning(&vm.methodology.disclaimer)
.with_title(i18n.t("callout-note-title")),
);
let built_report = builder.build();
return Ok((engine, built_report));
}
let (p2_title, p2_intro, p2_audience_title, p2_audience_body) = if en {
(
"Technical Details for Developers",
"Detailed WCAG violations, HTML evidence, and code examples grouped by systemic components vs. local instances.",
"Audience",
"Developers and QA teams. Read this part to implement fixes for all detected violations.",
)
} else {
(
"Technische Details für Entwickler",
"Detaillierte WCAG-Verstöße, HTML-Evidenzen und Code-Beispiele, gruppiert nach systemischen Komponenten vs. Einzelfällen.",
"Zielgruppe",
"Entwickler und QA-Teams. Dieser Teil liefert konkrete Code-Befunde zur Behebung aller Barrieren.",
)
};
builder = render_part_divider(
builder,
2,
p2_title,
p2_intro,
p2_audience_title,
p2_audience_body,
&i18n,
);
builder = render_tech_details(builder, &vm, report, &i18n);
let (p3_title, p3_intro, p3_audience_title, p3_audience_body) = if en {
(
"Quality Analyses",
"Search engine optimization, load speed, mobile layout, and security header metrics.",
"Audience",
"Marketing, SEO specialists, and product owners. Read this part to optimize discoverability and performance.",
)
} else {
(
"Qualitäts-Analysen",
"Suchmaschinenoptimierung, Ladezeiten, mobiles Layout und Sicherheits-Metriken.",
"Zielgruppe",
"Marketing, SEO-Spezialisten und Product Owner. Dieser Teil ergänzt die Barrierefreiheit um Qualitäts-Indikatoren.",
)
};
builder = render_part_divider(
builder,
3,
p3_title,
p3_intro,
p3_audience_title,
p3_audience_body,
&i18n,
);
builder = render_module_sections(builder, &vm, report, &i18n);
builder = render_appendix_full(
builder,
&vm,
report,
&normalized.normalized.findings,
config,
&i18n,
);
let built_report = builder.build();
Ok((engine, built_report))
}
fn render_audit_confidence_notes(
mut builder: renderreport::engine::ReportBuilder,
audit_flags: &[crate::audit::normalized::AuditFlag],
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = is_english(i18n);
let base = if en {
"Automated findings are reliable for detectable patterns, but context-dependent WCAG criteria, semantic content quality and user journeys still require manual verification. Indicator modules such as AI visibility, content visibility and UX are best-effort signals, not guarantees."
} else {
"Automatisierte Befunde sind belastbar für erkennbare Muster, aber kontextabhängige WCAG-Kriterien, semantische Inhaltsqualität und Nutzerwege benötigen weiterhin manuelle Prüfung. Indikator-Module wie KI-Sichtbarkeit, Content Visibility und UX sind Hinweiswerte, keine Garantien."
};
let title = if en {
"Notes on report confidence"
} else {
"Hinweise zur Aussagekraft"
};
let text = format!("{}: {}", title, base);
builder = builder.add_component(Label::new(&text).with_size("10.5pt").with_color("#475569"));
if !audit_flags.is_empty() {
let mut rows = Vec::new();
for flag in audit_flags {
rows.push(
ChecklistRow::new(
audit_flag_title(&flag.kind, en),
audit_flag_customer_text(flag, en),
)
.with_status("warn"),
);
}
builder = builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Audit caveats"
} else {
"Audit-Hinweise"
}));
}
builder
}
fn audit_flag_title(kind: &str, en: bool) -> &'static str {
match (kind, en) {
("consent_banner", true) => "Consent banner",
("consent_banner", false) => "Consent-Banner",
("bypass_blocks_untested", true) => "Skip link verification",
("bypass_blocks_untested", false) => "Skip-Link-Prüfung",
("conflicting_signal", true) => "Conflicting signal",
("conflicting_signal", false) => "Widersprüchliches Signal",
("viewport_gap", true) => "Desktop/mobile difference",
("viewport_gap", false) => "Desktop-/Mobile-Unterschied",
("incomplete_audit", true) => "Limited test coverage",
("incomplete_audit", false) => "Eingeschränkte Prüfabdeckung",
("consent_wall_artifact", true) => "Consent wall artifact",
("consent_wall_artifact", false) => "Consent-Wall-Artefakt",
(_, true) => "Audit note",
(_, false) => "Audit-Hinweis",
}
}
fn audit_flag_customer_text(flag: &crate::audit::normalized::AuditFlag, en: bool) -> String {
match (flag.kind.as_str(), en) {
("consent_banner", true) => "A consent banner was detected. Parts of the page may have been hidden or not fully measurable without consent.".to_string(),
("consent_banner", false) => "Ein Consent-Banner wurde erkannt. Teile der Seite können ohne Zustimmung verborgen oder nicht vollständig messbar gewesen sein.".to_string(),
("bypass_blocks_untested", true) => "A skip link exists, but the usage journey indicates that keyboard focus may not reach the intended target.".to_string(),
("bypass_blocks_untested", false) => "Ein Skip-Link ist vorhanden, der Nutzungstest deutet aber darauf hin, dass der Tastaturfokus sein Ziel nicht zuverlässig erreicht.".to_string(),
("conflicting_signal", true) => "Two checks report different signals. Treat this result as a review point rather than a final conclusion.".to_string(),
("conflicting_signal", false) => "Zwei Prüfungen melden unterschiedliche Signale. Dieses Ergebnis sollte als Prüfhilfe statt als endgültige Aussage gelesen werden.".to_string(),
("viewport_gap", true) => "Desktop and mobile results differ strongly. The page experience should be checked separately for both viewports.".to_string(),
("viewport_gap", false) => "Desktop- und Mobile-Ergebnis unterscheiden sich deutlich. Die Nutzung sollte für beide Ansichten getrennt geprüft werden.".to_string(),
("incomplete_audit", true) => "Individual measurements hit a stability/retry budget and are excluded from the reported scores, which describe only the successfully measured scope.".to_string(),
("incomplete_audit", false) => "Einzelne Messungen haben ein Stabilitäts-/Wiederholungsbudget erreicht und sind daher nicht in den ausgewiesenen Scores enthalten; diese beschreiben den erfolgreich gemessenen Umfang.".to_string(),
_ => flag.message.clone(),
}
}
fn cleanup_screenshot_temps(report: &AuditReport) {
let ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
if let Some(ref _shots) = report.page_screenshots {
remove_temp_file(&std::env::temp_dir().join(format!("ams-desktop-{}.png", ts)));
remove_temp_file(&std::env::temp_dir().join(format!("ams-mobile-{}.png", ts)));
}
for n in 0..crate::accessibility::MAX_ELEMENT_CROPS {
remove_temp_file(&std::env::temp_dir().join(format!("ams-evidence-{}-{}.png", ts, n)));
}
}
fn remove_temp_file(path: &std::path::Path) {
if let Err(e) = remove_file_if_exists(path) {
tracing::debug!("Failed to remove temporary file {}: {}", path.display(), e);
}
}
fn remove_file_if_exists(path: &std::path::Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
const MOBILE_PREVIEW_CROP_RATIO: f64 = 0.686;
fn crop_mobile_preview(png_bytes: &[u8]) -> Vec<u8> {
let Ok(img) = image::load_from_memory(png_bytes) else {
return png_bytes.to_vec();
};
let (width, height) = (img.width(), img.height());
let target_height = ((width as f64) / MOBILE_PREVIEW_CROP_RATIO).round() as u32;
if target_height >= height {
return png_bytes.to_vec();
}
let cropped = img.crop_imm(0, 0, width, target_height);
let mut out = Vec::new();
if cropped
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.is_err()
{
return png_bytes.to_vec();
}
out
}
fn register_page_screenshot_assets(
mut builder: renderreport::engine::ReportBuilder,
report: &AuditReport,
) -> anyhow::Result<renderreport::engine::ReportBuilder> {
let Some(ref shots) = report.page_screenshots else {
return Ok(builder);
};
let ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
let desktop_path = std::env::temp_dir().join(format!("ams-desktop-{}.png", ts));
let mobile_path = std::env::temp_dir().join(format!("ams-mobile-{}.png", ts));
std::fs::write(&desktop_path, &shots.desktop)?;
std::fs::write(&mobile_path, crop_mobile_preview(&shots.mobile))?;
builder = builder
.asset(PAGE_DESKTOP_SCREENSHOT_ASSET, desktop_path)
.asset(PAGE_MOBILE_SCREENSHOT_ASSET, mobile_path);
Ok(builder)
}
#[cfg(all(test, feature = "pdf_test"))]
#[path = "tests.rs"]
mod tests;