mod appendix;
mod batch;
mod batch_report;
mod cover;
pub(crate) mod design;
mod detail_modules;
mod diagnosis;
mod findings;
mod helpers;
mod single_report;
mod wcag_coverage;
pub use self::batch_report::{generate_batch_pdf, generate_batch_typ};
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, extract_domain};
use self::single_report::{
render_appendix_full, render_management_page, render_module_sections, render_part_divider,
render_root_cause_analysis, render_tech_details, render_timeframe_roadmap,
};
use crate::audit::{normalize, AuditReport};
use crate::cli::ReportLevel;
use crate::i18n::I18n;
use crate::output::builder::build_view_model;
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, built_report) = build_single_report(report, config)?;
Ok(engine.render_pdf(&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, built_report) = build_single_report(report, config)?;
Ok(engine.render_typ(&built_report)?)
})();
cleanup_screenshot_temps(report);
typ
}
fn build_single_report(
report: &AuditReport,
config: &ReportConfig,
) -> anyhow::Result<(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("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 = i18n.locale() == "en";
let overall_score = vm.summary.overall_score;
let band_phrase = self::cover::cover_band_phrase(overall_score, en);
let domain = extract_domain(&vm.cover.domain);
let module_gauges: Vec<CoverModuleGauge> = vm
.modules
.dashboard
.iter()
.map(|m| CoverModuleGauge::new(m.name.clone(), m.score))
.collect();
let (score_lbl, find_lbl, mod_lbl, crit_lbl, no_crit) = if en {
(
"OVERALL SCORE",
"FINDINGS",
"MODULES AT A GLANCE",
"Critical",
"0 Critical",
)
} else {
(
"GESAMTSCORE",
"BEFUNDE",
"MODULE IM ÜBERBLICK",
"kritisch",
"0 kritisch",
)
};
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 mut cover = CoverPage::new(&vm.cover.title, &domain, overall_score, &vm.cover.grade)
.with_brand(&vm.cover.brand)
.with_subtitle(&vm.executive.cover_kicker)
.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",
"Distribution of identified template and component issues and prioritized roadmaps.",
"Audience",
"Developers and IT managers. Read this part to understand systemic issues and the remediation roadmap.",
)
} else {
(
"Befunde nach Ursache",
"Verteilung der erkannten Template- und Komponentenfehler sowie der priorisierte Maßnahmenplan.",
"Zielgruppe",
"Entwickler und IT-Verantwortliche. Dieser Teil zeigt die systemischen Ursachen und den Ablaufplan.",
)
};
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, &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 = i18n.locale() == "en";
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",
("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(),
_ => flag.message.clone(),
}
}
fn cleanup_screenshot_temps(report: &AuditReport) {
if let Some(ref _shots) = report.page_screenshots {
let ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("ams-desktop-{}.png", ts)));
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("ams-mobile-{}.png", ts)));
}
}
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, &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;