Skip to main content

plumb_core/rules/baseline/
rhythm.rs

1//! `baseline/rhythm` — flag text elements whose baselines miss the
2//! configured vertical-rhythm grid.
3//!
4//! For each text-bearing element with a `font-size` and a bounding
5//! rect, the rule computes an approximate baseline position and checks
6//! whether it falls on a multiple of `rhythm.base_line_px` (within
7//! `rhythm.tolerance_px`).
8
9use 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
18/// Tags considered text-bearing for the purpose of this rule.
19const 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
56/// Typical Latin cap-height ratio (cap-height / font-size).
57const CAP_HEIGHT_RATIO: f64 = 0.7;
58
59/// Default line-height multiplier when the value is `normal` or missing.
60const DEFAULT_LINE_HEIGHT_RATIO: f64 = 1.2;
61
62/// Flags text elements whose baselines don't align to the rhythm grid.
63#[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            // Cap-height approximation.
108            let cap_height = if cap_fallback > 0 {
109                f64::from(cap_fallback)
110            } else {
111                font_size * CAP_HEIGHT_RATIO
112            };
113
114            // Line-height: parse from computed styles, fall back to 1.2 * font_size.
115            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            // half_leading = (line_height - font_size) / 2
122            let half_leading = (line_height - font_size) / 2.0;
123
124            // Prefer text boxes (per-line fragments) over element rect.
125            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
156/// Collect `(baseline_y, nearest_grid_y, distance)` for each off-grid line.
157fn 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
176/// Build the aggregated violation for a single node.
177fn 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    // Worst = largest distance. Caller guarantees non-empty.
187    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}