use indexmap::IndexMap;
use serde_json::Value as JsonValue;
use crate::config::Config;
use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
use crate::rules::Rule;
use crate::rules::util::parse_px;
use crate::snapshot::SnapshotCtx;
const TEXT_TAGS: &[&str] = &[
"p",
"span",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"a",
"li",
"td",
"th",
"label",
"button",
"input",
"textarea",
"select",
"summary",
"dt",
"dd",
"figcaption",
"blockquote",
"cite",
"code",
"pre",
"em",
"strong",
"small",
"b",
"i",
"u",
"mark",
"time",
"abbr",
];
const CAP_HEIGHT_RATIO: f64 = 0.7;
const DEFAULT_LINE_HEIGHT_RATIO: f64 = 1.2;
#[derive(Debug, Clone, Copy)]
pub struct Rhythm;
impl Rule for Rhythm {
fn id(&self) -> &'static str {
"baseline/rhythm"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn summary(&self) -> &'static str {
"Flags text elements whose baselines miss the vertical-rhythm grid."
}
fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
let base_line = config.rhythm.base_line_px;
if base_line == 0 {
return;
}
let base_line_f = f64::from(base_line);
let tolerance_f = f64::from(config.rhythm.tolerance_px);
let cap_fallback = config.rhythm.cap_height_fallback_px;
for node in ctx.nodes() {
if !TEXT_TAGS.contains(&node.tag.as_str()) {
continue;
}
let Some(rect) = ctx.rect_for(node.dom_order) else {
continue;
};
let Some(font_size_raw) = node.computed_styles.get("font-size") else {
continue;
};
let Some(font_size) = parse_px(font_size_raw) else {
continue;
};
if font_size <= 0.0 {
continue;
}
let cap_height = if cap_fallback > 0 {
f64::from(cap_fallback)
} else {
font_size * CAP_HEIGHT_RATIO
};
let line_height = node
.computed_styles
.get("line-height")
.and_then(|v| parse_px(v))
.unwrap_or(font_size * DEFAULT_LINE_HEIGHT_RATIO);
let half_leading = (line_height - font_size) / 2.0;
let text_boxes = ctx.text_boxes_for(node.dom_order);
let y_origins: Vec<f64> = if text_boxes.is_empty() {
vec![f64::from(rect.y)]
} else {
text_boxes.iter().map(|tb| f64::from(tb.bounds.y)).collect()
};
let off_grid_lines = collect_off_grid(
&y_origins,
half_leading,
cap_height,
base_line_f,
tolerance_f,
);
if off_grid_lines.is_empty() {
continue;
}
sink.push(build_violation(
*self,
node,
ctx,
rect,
base_line,
&y_origins,
&off_grid_lines,
));
}
}
}
fn collect_off_grid(
y_origins: &[f64],
half_leading: f64,
cap_height: f64,
base_line_f: f64,
tolerance_f: f64,
) -> Vec<(f64, f64, f64)> {
let mut result = Vec::new();
for &y_origin in y_origins {
let baseline_y = y_origin + half_leading + cap_height;
let nearest_grid_y = (baseline_y / base_line_f).round() * base_line_f;
let distance = (baseline_y - nearest_grid_y).abs();
if distance > tolerance_f {
result.push((baseline_y, nearest_grid_y, distance));
}
}
result
}
fn build_violation(
rule: Rhythm,
node: &crate::snapshot::SnapshotNode,
ctx: &SnapshotCtx<'_>,
rect: crate::report::Rect,
base_line: u32,
y_origins: &[f64],
off_grid_lines: &[(f64, f64, f64)],
) -> Violation {
let &(baseline_y, nearest_grid_y, distance) = off_grid_lines
.iter()
.max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(&off_grid_lines[0]);
let total_lines = y_origins.len();
let off_count = off_grid_lines.len();
let message = if off_count > 1 {
format!(
"`{selector}` has {off_count}/{total_lines} lines off the {base_line}px rhythm grid (worst: {distance:.1}px at {baseline_y:.1}px).",
selector = node.selector,
)
} else {
format!(
"`{selector}` baseline at {baseline_y:.1}px is {distance:.1}px off the {base_line}px rhythm grid.",
selector = node.selector,
)
};
let mut metadata = IndexMap::new();
metadata.insert("baseline_y".to_owned(), JsonValue::from(baseline_y));
metadata.insert("nearest_grid_y".to_owned(), JsonValue::from(nearest_grid_y));
metadata.insert(
"distance_px".to_owned(),
JsonValue::from((distance * 100.0).round() / 100.0),
);
metadata.insert(
"off_grid_lines".to_owned(),
JsonValue::Array(
off_grid_lines
.iter()
.map(|&(by, ngy, d)| {
serde_json::json!({
"baseline_y": by,
"nearest_grid_y": ngy,
"distance_px": (d * 100.0).round() / 100.0,
})
})
.collect(),
),
);
Violation {
rule_id: rule.id().to_owned(),
severity: rule.default_severity(),
message,
selector: node.selector.clone(),
viewport: ctx.snapshot().viewport.clone(),
rect: Some(rect),
dom_order: node.dom_order,
fix: Some(Fix {
kind: FixKind::Description {
text: format!(
"Adjust line-height or margin-top so the baseline aligns to the nearest {base_line}px grid line ({nearest_grid_y:.0}px).",
),
},
description: format!(
"Shift baseline from {baseline_y:.1}px to {nearest_grid_y:.0}px to restore vertical rhythm.",
),
confidence: Confidence::Low,
}),
doc_url: "https://plumb.aramhammoudeh.com/rules/baseline-rhythm".to_owned(),
metadata,
}
}