use std::collections::BTreeMap;
use crate::ast::node::Node;
use crate::ast::style::Style;
use crate::ast::value::{PropertyValue, dim_to_px};
use crate::color::{apca_lc, parse_rgb};
use crate::diagnostics::Diagnostic;
use crate::tokens::{ResolvedToken, ResolvedValue};
use super::nodes::node_bbox;
const BACKDROP_OPAQUE_ALPHA: u8 = 128;
fn resolve_fill_rgba(
fill: &Option<PropertyValue>,
style: &Option<String>,
style_map: &BTreeMap<&str, &Style>,
resolved_tokens: &BTreeMap<String, ResolvedToken>,
) -> Option<(u8, u8, u8, u8)> {
let style_fill = || {
style_map
.get(style.as_deref()?)
.and_then(|s| s.properties.get("fill"))
};
let pv = fill.as_ref().or_else(style_fill)?;
let PropertyValue::TokenRef(id) = pv else {
return None;
};
let rt = resolved_tokens.get(id.as_str())?;
let ResolvedValue::Color(hex) = &rt.value else {
return None;
};
let (r, g, b) = parse_rgb(hex)?;
let alpha = hex
.strip_prefix('#')
.filter(|h| h.len() == 8)
.and_then(|h| u8::from_str_radix(&h[6..8], 16).ok())
.unwrap_or(255);
Some((r, g, b, alpha))
}
fn backdrop_rgb(
text_bbox: (f64, f64, f64, f64),
preceding_siblings: &[Node],
page_w: f64,
page_h: f64,
style_map: &BTreeMap<&str, &Style>,
resolved_tokens: &BTreeMap<String, ResolvedToken>,
) -> Option<(u8, u8, u8)> {
let (tx, ty, tw, th) = text_bbox;
let no_fill: Option<PropertyValue> = None;
for sibling in preceding_siblings.iter().rev() {
let (fill, style) = match sibling {
Node::Rect(r) => (&r.fill, &r.style),
Node::Ellipse(e) => (&e.fill, &e.style),
Node::Frame(f) => (&no_fill, &f.style),
Node::Line(_)
| Node::Text(_)
| Node::Code(_)
| Node::Group(_)
| Node::Image(_)
| Node::Polygon(_)
| Node::Polyline(_)
| Node::Instance(_)
| Node::Field(_)
| Node::Footnote(_)
| Node::Toc(_)
| Node::Table(_)
| Node::Shape(_)
| Node::Connector(_)
| Node::Pattern(_)
| Node::Chart(_)
| Node::Unknown(_) => continue,
};
let Some((r, g, b, a)) = resolve_fill_rgba(fill, style, style_map, resolved_tokens) else {
continue;
};
if a < BACKDROP_OPAQUE_ALPHA {
continue;
}
let Some((bx, by, bw, bh)) = node_bbox(sibling, page_w, page_h) else {
continue;
};
if bx <= tx && by <= ty && bx + bw >= tx + tw && by + bh >= ty + th {
return Some((r, g, b));
}
}
None
}
pub(super) fn check_text_contrast(
node: &Node,
page_bg_rgb: Option<(u8, u8, u8)>,
preceding_siblings: &[Node],
page_size: (f64, f64),
resolved_tokens: &BTreeMap<String, ResolvedToken>,
style_map: &BTreeMap<&str, &Style>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (page_w, page_h) = page_size;
match node {
Node::Text(t) => {
let style_prop = |key: &str| -> Option<&PropertyValue> {
style_map
.get(t.style.as_deref()?)
.and_then(|s| s.properties.get(key))
};
let text_rgb = match t.fill.as_ref().or_else(|| style_prop("fill")) {
Some(PropertyValue::TokenRef(id)) => {
resolved_tokens.get(id.as_str()).and_then(|rt| {
if let ResolvedValue::Color(hex) = &rt.value {
parse_rgb(hex)
} else {
None
}
})
}
Some(PropertyValue::Literal(_))
| Some(PropertyValue::Dimension(_))
| Some(PropertyValue::DataRef(_))
| None => None,
};
let Some(fg_rgb) = text_rgb else {
return;
};
let hint_rgb = match t.contrast_bg.as_ref() {
Some(PropertyValue::TokenRef(id)) => {
resolved_tokens.get(id.as_str()).and_then(|rt| {
if let ResolvedValue::Color(hex) = &rt.value {
parse_rgb(hex)
} else {
None
}
})
}
Some(PropertyValue::Literal(_))
| Some(PropertyValue::Dimension(_))
| Some(PropertyValue::DataRef(_))
| None => None,
};
let backdrop = node_bbox(node, page_w, page_h).and_then(|tbbox| {
backdrop_rgb(
tbbox,
preceding_siblings,
page_w,
page_h,
style_map,
resolved_tokens,
)
});
let bg_source = if hint_rgb.is_some() {
"contrast-bg hint"
} else if backdrop.is_some() {
"backdrop"
} else {
"page background"
};
let Some(bg_rgb) = hint_rgb.or(backdrop).or(page_bg_rgb) else {
return;
};
let size_px: f64 = t
.font_size
.as_ref()
.or_else(|| style_prop("font-size"))
.and_then(|pv| {
if let PropertyValue::TokenRef(id) = pv {
resolved_tokens.get(id.as_str()).and_then(|rt| {
if let ResolvedValue::Dimension(dim) = &rt.value {
dim_to_px(dim.value, &dim.unit)
} else {
None
}
})
} else {
None
}
})
.unwrap_or(16.0);
let weight: u32 = t
.font_weight
.as_ref()
.or_else(|| style_prop("font-weight"))
.and_then(|pv| {
if let PropertyValue::TokenRef(id) = pv {
resolved_tokens.get(id.as_str()).and_then(|rt| {
if let ResolvedValue::FontWeight(w) = &rt.value {
Some(*w)
} else {
None
}
})
} else {
None
}
})
.unwrap_or(400);
let is_large = size_px >= 24.0 || (size_px >= 18.66 && weight >= 700);
let threshold = if is_large { 45.0_f64 } else { 60.0_f64 };
let lc = apca_lc(fg_rgb, bg_rgb).abs();
if lc < threshold {
diagnostics.push(Diagnostic::warning(
"contrast.low",
format!(
"text '{}': APCA contrast Lc {:.1} of fill on {} \
is below the WCAG 3 minimum (Lc {:.0})",
t.id, lc, bg_source, threshold
),
t.source_span,
Some(t.id.clone()),
));
}
}
Node::Group(g) => {
for (i, child) in g.children.iter().enumerate() {
check_text_contrast(
child,
page_bg_rgb,
&g.children[..i],
(page_w, page_h),
resolved_tokens,
style_map,
diagnostics,
);
}
}
Node::Frame(f) => {
for (i, child) in f.children.iter().enumerate() {
check_text_contrast(
child,
page_bg_rgb,
&f.children[..i],
(page_w, page_h),
resolved_tokens,
style_map,
diagnostics,
);
}
}
Node::Table(t) => {
let header_rows = t.header_rows.unwrap_or(0);
let resolve_fill = |pv: &Option<PropertyValue>| -> Option<(u8, u8, u8)> {
let (r, g, b, a) = resolve_fill_rgba(pv, &None, style_map, resolved_tokens)?;
if a >= BACKDROP_OPAQUE_ALPHA {
Some((r, g, b))
} else {
None
}
};
for (row_idx, row) in t.rows.iter().enumerate() {
let is_header = (row_idx as u32) < header_rows;
for cell in &row.cells {
let cell_bg: Option<(u8, u8, u8)> = if let Some(rgb) = resolve_fill(&cell.fill)
{
Some(rgb)
} else if is_header {
resolve_fill(&t.header_fill)
.or_else(|| resolve_fill(&t.fill))
.or(page_bg_rgb)
} else {
resolve_fill(&t.fill).or(page_bg_rgb)
};
for (i, child) in cell.children.iter().enumerate() {
check_text_contrast(
child,
cell_bg,
&cell.children[..i],
(page_w, page_h),
resolved_tokens,
style_map,
diagnostics,
);
}
}
}
}
Node::Rect(_)
| Node::Ellipse(_)
| Node::Line(_)
| Node::Code(_)
| Node::Image(_)
| Node::Polygon(_)
| Node::Polyline(_)
| Node::Instance(_)
| Node::Field(_)
| Node::Footnote(_)
| Node::Toc(_)
| Node::Shape(_)
| Node::Connector(_)
| Node::Pattern(_)
| Node::Chart(_)
| Node::Unknown(_) => {}
}
}