use a11y_dom::{elements, Color, ComputedStyle, Node, NodeId, NodeKind, Rendering, Tier};
use a11y_report::{Finding, Location, Severity};
use crate::registry::{Meta, RenderingRule};
fn at(id: NodeId) -> Location {
Location::node(id.to_string())
}
fn luminanz(c: Color) -> f64 {
fn kanal(v: u8) -> f64 {
let s = f64::from(v) / 255.0;
if s <= 0.03928 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * kanal(c.r) + 0.7152 * kanal(c.g) + 0.0722 * kanal(c.b)
}
fn verhaeltnis(vorn: Color, hinten: Color) -> f64 {
let (a, b) = (luminanz(vorn), luminanz(hinten));
let (hell, dunkel) = if a >= b { (a, b) } else { (b, a) };
(hell + 0.05) / (dunkel + 0.05)
}
fn ueber(vorn: Color, hinten: Color) -> Color {
if vorn.a == 255 {
return vorn;
}
let a = f64::from(vorn.a) / 255.0;
let misch = |v: u8, h: u8| (f64::from(v) * a + f64::from(h) * (1.0 - a)).round() as u8;
Color {
r: misch(vorn.r, hinten.r),
g: misch(vorn.g, hinten.g),
b: misch(vorn.b, hinten.b),
a: 255,
}
}
fn ist_grosser_text(stil: &ComputedStyle) -> bool {
let Some(px) = stil.font_size_px else {
return false;
};
let fett = stil.font_weight.is_some_and(|w| w >= 700);
px >= 24.0 || (fett && px >= 18.66)
}
fn traegt_text<'a, N: Node<'a>>(n: N) -> bool {
n.children()
.any(|k| k.kind() == NodeKind::Text && !k.text().trim().is_empty())
}
fn wird_dargestellt(stil: &ComputedStyle) -> bool {
stil.display.as_deref() != Some("none") && stil.visibility.as_deref() != Some("hidden")
}
fn text_kontrast<D: Rendering>(doc: &D, out: &mut Vec<Finding>) {
for n in elements(doc) {
if !traegt_text(n) {
continue;
}
let Some(stil) = doc.computed_style(n) else {
continue;
};
if !wird_dargestellt(&stil) {
continue;
}
let gross = ist_grosser_text(&stil);
let schwelle = if gross { 3.0 } else { 4.5 };
match (stil.color, stil.background_color) {
(Some(vorn), Some(hinten)) => {
let wert = verhaeltnis(ueber(vorn, hinten), hinten);
if wert + 0.005 < schwelle {
out.push(
Finding::fail(
"contrast/text-insufficient",
format!(
"Der Text erreicht ein Kontrastverhältnis von {wert:.2}:1, \
gefordert sind {schwelle:.1}:1 für {}.",
if gross {
"großen Text"
} else {
"normalen Text"
}
),
)
.with_severity(Severity::High)
.with_wcag(["1.4.3"])
.at(at(n.id())),
);
}
}
_ => {
out.push(
Finding::untested(
"contrast/text-undetermined",
"Der Kontrast ist automatisiert nicht bestimmbar — der Host konnte \
Vorder- oder Hintergrundfarbe nicht auflösen. Von Hand prüfen.",
)
.with_severity(Severity::Medium)
.with_wcag(["1.4.3"])
.at(at(n.id())),
);
}
}
}
}
pub(crate) const METAS: &[Meta] = &[Meta {
ids: &["contrast/text-insufficient", "contrast/text-undetermined"],
tier: Tier::Rendering,
wcag: &["1.4.3"],
severity: Severity::High,
help: "Text braucht gegenüber seinem Hintergrund ein Kontrastverhältnis von \
mindestens 4,5:1, bei großem Text 3:1. Großer Text ist ab 18 pt, bei \
fettem Schnitt ab 14 pt.",
}];
pub(crate) fn rules<D: Rendering>() -> Vec<RenderingRule<D>> {
vec![RenderingRule {
meta: METAS[0],
run: text_kontrast,
}]
}
#[cfg(test)]
mod tests {
use super::*;
const SCHWARZ: Color = Color {
r: 0,
g: 0,
b: 0,
a: 255,
};
const WEISS: Color = Color {
r: 255,
g: 255,
b: 255,
a: 255,
};
#[test]
fn verhaeltnis_schwarz_auf_weiss_ist_21() {
assert!((verhaeltnis(SCHWARZ, WEISS) - 21.0).abs() < 0.01);
}
#[test]
fn verhaeltnis_ist_symmetrisch() {
assert!((verhaeltnis(SCHWARZ, WEISS) - verhaeltnis(WEISS, SCHWARZ)).abs() < 1e-9);
}
#[test]
fn gleiche_farbe_ergibt_eins() {
assert!((verhaeltnis(WEISS, WEISS) - 1.0).abs() < 1e-9);
}
#[test]
fn grenzfall_767676_auf_weiss_besteht_knapp() {
let grau = Color {
r: 0x76,
g: 0x76,
b: 0x76,
a: 255,
};
let v = verhaeltnis(grau, WEISS);
assert!(v >= 4.5, "{v} sollte 4,5 erreichen");
assert!(v < 4.6, "{v} sollte knapp darüber liegen");
}
#[test]
fn halbdurchsichtiges_schwarz_auf_weiss_wird_grau() {
let halb = Color {
r: 0,
g: 0,
b: 0,
a: 128,
};
let gemischt = ueber(halb, WEISS);
assert_eq!(gemischt.a, 255);
assert_eq!(gemischt.r, 127);
}
#[test]
fn grosser_text_ab_24px_oder_fett_ab_18_66px() {
let stil = |px: f32, w: u16| ComputedStyle {
color: None,
background_color: None,
font_size_px: Some(px),
font_weight: Some(w),
display: None,
visibility: None,
};
assert!(ist_grosser_text(&stil(24.0, 400)));
assert!(!ist_grosser_text(&stil(23.0, 400)));
assert!(ist_grosser_text(&stil(19.0, 700)));
assert!(!ist_grosser_text(&stil(19.0, 400)));
}
}