plumb_core/rules/baseline/
rhythm.rs1use indexmap::IndexMap;
10use serde_json::Value as JsonValue;
11
12use crate::config::Config;
13use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
14use crate::rules::Rule;
15use crate::rules::util::parse_px;
16use crate::snapshot::SnapshotCtx;
17
18const TEXT_TAGS: &[&str] = &[
20 "p",
21 "span",
22 "h1",
23 "h2",
24 "h3",
25 "h4",
26 "h5",
27 "h6",
28 "a",
29 "li",
30 "td",
31 "th",
32 "label",
33 "button",
34 "input",
35 "textarea",
36 "select",
37 "summary",
38 "dt",
39 "dd",
40 "figcaption",
41 "blockquote",
42 "cite",
43 "code",
44 "pre",
45 "em",
46 "strong",
47 "small",
48 "b",
49 "i",
50 "u",
51 "mark",
52 "time",
53 "abbr",
54];
55
56const CAP_HEIGHT_RATIO: f64 = 0.7;
58
59const DEFAULT_LINE_HEIGHT_RATIO: f64 = 1.2;
61
62#[derive(Debug, Clone, Copy)]
64pub struct Rhythm;
65
66impl Rule for Rhythm {
67 fn id(&self) -> &'static str {
68 "baseline/rhythm"
69 }
70
71 fn default_severity(&self) -> Severity {
72 Severity::Warning
73 }
74
75 fn summary(&self) -> &'static str {
76 "Flags text elements whose baselines miss the vertical-rhythm grid."
77 }
78
79 fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
80 let base_line = config.rhythm.base_line_px;
81 if base_line == 0 {
82 return;
83 }
84 let base_line_f = f64::from(base_line);
85 let tolerance_f = f64::from(config.rhythm.tolerance_px);
86 let cap_fallback = config.rhythm.cap_height_fallback_px;
87
88 for node in ctx.nodes() {
89 if !TEXT_TAGS.contains(&node.tag.as_str()) {
90 continue;
91 }
92
93 let Some(rect) = ctx.rect_for(node.dom_order) else {
94 continue;
95 };
96
97 let Some(font_size_raw) = node.computed_styles.get("font-size") else {
98 continue;
99 };
100 let Some(font_size) = parse_px(font_size_raw) else {
101 continue;
102 };
103 if font_size <= 0.0 {
104 continue;
105 }
106
107 let cap_height = if cap_fallback > 0 {
109 f64::from(cap_fallback)
110 } else {
111 font_size * CAP_HEIGHT_RATIO
112 };
113
114 let line_height = node
116 .computed_styles
117 .get("line-height")
118 .and_then(|v| parse_px(v))
119 .unwrap_or(font_size * DEFAULT_LINE_HEIGHT_RATIO);
120
121 let half_leading = (line_height - font_size) / 2.0;
123
124 let text_boxes = ctx.text_boxes_for(node.dom_order);
126 let y_origins: Vec<f64> = if text_boxes.is_empty() {
127 vec![f64::from(rect.y)]
128 } else {
129 text_boxes.iter().map(|tb| f64::from(tb.bounds.y)).collect()
130 };
131
132 let off_grid_lines = collect_off_grid(
133 &y_origins,
134 half_leading,
135 cap_height,
136 base_line_f,
137 tolerance_f,
138 );
139 if off_grid_lines.is_empty() {
140 continue;
141 }
142
143 sink.push(build_violation(
144 *self,
145 node,
146 ctx,
147 rect,
148 base_line,
149 &y_origins,
150 &off_grid_lines,
151 ));
152 }
153 }
154}
155
156fn collect_off_grid(
158 y_origins: &[f64],
159 half_leading: f64,
160 cap_height: f64,
161 base_line_f: f64,
162 tolerance_f: f64,
163) -> Vec<(f64, f64, f64)> {
164 let mut result = Vec::new();
165 for &y_origin in y_origins {
166 let baseline_y = y_origin + half_leading + cap_height;
167 let nearest_grid_y = (baseline_y / base_line_f).round() * base_line_f;
168 let distance = (baseline_y - nearest_grid_y).abs();
169 if distance > tolerance_f {
170 result.push((baseline_y, nearest_grid_y, distance));
171 }
172 }
173 result
174}
175
176fn build_violation(
178 rule: Rhythm,
179 node: &crate::snapshot::SnapshotNode,
180 ctx: &SnapshotCtx<'_>,
181 rect: crate::report::Rect,
182 base_line: u32,
183 y_origins: &[f64],
184 off_grid_lines: &[(f64, f64, f64)],
185) -> Violation {
186 let &(baseline_y, nearest_grid_y, distance) = off_grid_lines
188 .iter()
189 .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))
190 .unwrap_or(&off_grid_lines[0]);
191
192 let total_lines = y_origins.len();
193 let off_count = off_grid_lines.len();
194
195 let message = if off_count > 1 {
196 format!(
197 "`{selector}` has {off_count}/{total_lines} lines off the {base_line}px rhythm grid (worst: {distance:.1}px at {baseline_y:.1}px).",
198 selector = node.selector,
199 )
200 } else {
201 format!(
202 "`{selector}` baseline at {baseline_y:.1}px is {distance:.1}px off the {base_line}px rhythm grid.",
203 selector = node.selector,
204 )
205 };
206
207 let mut metadata = IndexMap::new();
208 metadata.insert("baseline_y".to_owned(), JsonValue::from(baseline_y));
209 metadata.insert("nearest_grid_y".to_owned(), JsonValue::from(nearest_grid_y));
210 metadata.insert(
211 "distance_px".to_owned(),
212 JsonValue::from((distance * 100.0).round() / 100.0),
213 );
214 metadata.insert(
215 "off_grid_lines".to_owned(),
216 JsonValue::Array(
217 off_grid_lines
218 .iter()
219 .map(|&(by, ngy, d)| {
220 serde_json::json!({
221 "baseline_y": by,
222 "nearest_grid_y": ngy,
223 "distance_px": (d * 100.0).round() / 100.0,
224 })
225 })
226 .collect(),
227 ),
228 );
229
230 Violation {
231 rule_id: rule.id().to_owned(),
232 severity: rule.default_severity(),
233 message,
234 selector: node.selector.clone(),
235 viewport: ctx.snapshot().viewport.clone(),
236 rect: Some(rect),
237 dom_order: node.dom_order,
238 fix: Some(Fix {
239 kind: FixKind::Description {
240 text: format!(
241 "Adjust line-height or margin-top so the baseline aligns to the nearest {base_line}px grid line ({nearest_grid_y:.0}px).",
242 ),
243 },
244 description: format!(
245 "Shift baseline from {baseline_y:.1}px to {nearest_grid_y:.0}px to restore vertical rhythm.",
246 ),
247 confidence: Confidence::Low,
248 }),
249 doc_url: "https://plumb.aramhammoudeh.com/rules/baseline-rhythm".to_owned(),
250 metadata,
251 }
252}