rhythm_gpui/math.rs
1//! Vertical rhythm math, independent of gpui.
2//!
3//! # Model
4//!
5//! A renderer (CSS, gpui, and most UI toolkits) vertically centers a font's
6//! `ascent + descent` box inside the line box, placing the baseline at:
7//!
8//! ```text
9//! baseline_from_top = (line_height - ascent - descent) / 2 + ascent
10//! ```
11//!
12//! Given a line height that is an integer number of rhythm units, the baseline
13//! spacing functions compute padding/margin that lands each baseline exactly on
14//! a rhythm grid line. The cap-anchoring functions deliberately align the
15//! capitals' ink instead and preserve whole-row block height with a paired close.
16//!
17//! [`Rhythm`] is only the grid: unit size, spacing, whole-row heights, and
18//! snapping. Every calculation that also needs a typeface lives on
19//! [`FontRhythm`], and shaped-line geometry on
20//! [`RhythmLineMetrics`](crate::RhythmLineMetrics).
21//!
22//! All quantities are `f32` logical pixels; unit conversion (rems, device
23//! scale) belongs to the integration layer.
24//!
25//! The Plumber/rhythm-sass `baseline-ratio` is recoverable from font metrics:
26//! `ratio = (em + descent - ascent) / (2 * em)`. [`FontRhythm::from_baseline_ratio`]
27//! is provided for compatibility with existing measured values.
28
29/// The vertical rhythm grid: a stack of rows, each `size` logical pixels tall.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct Rhythm {
32 size: f32,
33}
34
35impl Rhythm {
36 /// Create a grid with a finite, positive rhythm-unit size.
37 ///
38 /// # Panics
39 ///
40 /// Panics when `size` is zero, negative, or non-finite.
41 pub const fn new(size: f32) -> Self {
42 assert!(
43 size.is_finite() && size > 0.0,
44 "rhythm unit size must be finite and greater than zero"
45 );
46 Self { size }
47 }
48
49 /// Height of one rhythm unit in logical pixels.
50 #[inline]
51 pub const fn size(&self) -> f32 {
52 self.size
53 }
54
55 /// Axis-neutral length of `n` rhythm units. Use this when the vertical grid
56 /// also supplies horizontal indents, gaps, or padding.
57 #[inline]
58 pub fn spacing(&self, n: i32) -> f32 {
59 self.size * n as f32
60 }
61
62 /// Total height of `n` rhythm units. Equivalent to rhythm-sass `rhythm($n)`;
63 /// apply offsets with plain addition: `grid.height(5) - 1.0`.
64 #[inline]
65 pub fn height(&self, n: i32) -> f32 {
66 self.spacing(n)
67 }
68
69 /// The smallest whole number of rhythm rows covering `height` — the pad
70 /// strategy for content whose height is not rhythm-controlled (images,
71 /// video, embeds): the block grows to the next grid line and the
72 /// remainder, always under one unit, becomes trailing whitespace.
73 ///
74 /// Heights within a few `f32` rounding steps of a whole-row height count
75 /// as exact, absorbing float error from measured sizes without allowing a
76 /// large rhythm unit to hide a visible remainder. Unlike [`snap`], which
77 /// rounds a final value to the nearest step of an arbitrary size, this
78 /// rounds heights outward to whole rhythm rows.
79 ///
80 /// # Panics
81 ///
82 /// Panics when `height` is negative or non-finite.
83 #[inline]
84 pub fn snap_up(&self, height: f32) -> f32 {
85 self.size * self.snap_rows(height, f32::ceil)
86 }
87
88 /// The largest whole number of rhythm rows within `height` — the crop
89 /// strategy: the block shrinks to the previous grid line, cutting less
90 /// than one unit and never scaling the content up. Same exactness
91 /// tolerance as [`Self::snap_up`].
92 ///
93 /// # Panics
94 ///
95 /// Panics when `height` is negative or non-finite.
96 #[inline]
97 pub fn snap_down(&self, height: f32) -> f32 {
98 self.size * self.snap_rows(height, f32::floor)
99 }
100
101 // Deliberately kept separate from `RhythmLineMetrics::minimum_line_rows`,
102 // which applies the same rule in `f64`. The two are calibrated to their
103 // inputs' precision, not merely duplicated: this path divides an `f32`
104 // height, whose own rounding error the tolerance absorbs, so widening it
105 // to `f64` changes results once the tolerance approaches half a unit
106 // (`snap_up_keeps_a_real_remainder_at_large_heights` pins one such case),
107 // and bounding the tolerance instead stops it absorbing the `f32` noise
108 // it exists for. Change one only with a differential sweep over both.
109 fn snap_rows(&self, height: f32, round_away: fn(f32) -> f32) -> f32 {
110 assert!(
111 height.is_finite() && height >= 0.0,
112 "height must be finite and non-negative"
113 );
114 let rows = height / self.size;
115 let nearest = rows.round();
116 let nearest_height = self.size * nearest;
117 // Division and multiplication can each move an exact multiple by a
118 // few ULPs. Scale by the measured height, not by the grid size, so the
119 // tolerance cannot hide a visible remainder on a large grid.
120 let tolerance = height * f32::EPSILON * 8.0;
121 if (height - nearest_height).abs() <= tolerance {
122 nearest
123 } else {
124 round_away(rows)
125 }
126 }
127}
128
129/// Vertical metrics of one text style participating in the rhythm grid.
130///
131/// `ascent` and `descent` are resolved at `font_size` (logical pixels, both
132/// positive). Line height is expressed in whole rhythm units, which is what keeps
133/// consecutive lines of the same style on the grid.
134#[derive(Debug, Clone, Copy, PartialEq)]
135pub struct FontRhythm {
136 font_size: f32,
137 line_rhythms: u32,
138 ascent: f32,
139 descent: f32,
140 cap_height: Option<f32>,
141 x_height: Option<f32>,
142}
143
144impl FontRhythm {
145 /// Build from real font metrics resolved at `font_size`.
146 ///
147 /// # Panics
148 ///
149 /// Panics when `font_size` or `ascent` is not finite and positive, when
150 /// `line_rhythms` is zero, or when `descent` is not finite and non-negative.
151 pub fn from_metrics(font_size: f32, line_rhythms: u32, ascent: f32, descent: f32) -> Self {
152 assert!(
153 font_size.is_finite() && font_size > 0.0,
154 "font_size must be finite and greater than zero"
155 );
156 assert!(line_rhythms > 0, "line_rhythms must be greater than zero");
157 assert!(
158 ascent.is_finite() && ascent > 0.0,
159 "ascent must be finite and greater than zero"
160 );
161 assert!(
162 descent.is_finite() && descent >= 0.0,
163 "descent must be finite and non-negative"
164 );
165 Self {
166 font_size,
167 line_rhythms,
168 ascent,
169 descent,
170 cap_height: None,
171 x_height: None,
172 }
173 }
174
175 /// Build from platform-reported metrics, normalizing conventions.
176 ///
177 /// Some platforms report table metrics in the OpenType sign convention where
178 /// descent is negative below the baseline (gpui on macOS does), so `ascent`
179 /// and `descent` are taken by magnitude. Non-finite or non-positive cap and
180 /// x heights are treated as unavailable and become `None`.
181 ///
182 /// # Panics
183 ///
184 /// Panics under the same conditions as [`Self::from_metrics`] after
185 /// normalization (e.g. a zero `ascent`).
186 pub fn from_platform_metrics(
187 font_size: f32,
188 line_rhythms: u32,
189 ascent: f32,
190 descent: f32,
191 cap_height: f32,
192 x_height: f32,
193 ) -> Self {
194 let mut font = Self::from_metrics(font_size, line_rhythms, ascent.abs(), descent.abs());
195 font.cap_height = usable_metric(cap_height);
196 font.x_height = usable_metric(x_height);
197 font
198 }
199
200 /// Compatibility constructor for a Plumber/rhythm-sass `baseline-ratio`
201 /// (`0 < ratio < 1`), using the em-box approximation the Sass library assumed:
202 /// `ascent = (1 - ratio) * font_size`, `descent = ratio * font_size`.
203 ///
204 /// Prefer [`Self::from_metrics`]: the ratio is itself derived from metrics
205 /// (`(em + descent - ascent) / (2 * em)`) and the em-box model slightly
206 /// misplaces the baseline for fonts whose `ascent + descent != em`.
207 ///
208 /// # Panics
209 ///
210 /// Panics when `baseline_ratio` is not strictly between 0 and 1, when
211 /// `font_size` is not finite and positive, or when `line_rhythms` is zero.
212 pub fn from_baseline_ratio(font_size: f32, line_rhythms: u32, baseline_ratio: f32) -> Self {
213 assert!(
214 baseline_ratio > 0.0 && baseline_ratio < 1.0,
215 "baseline ratio must be strictly between 0 and 1"
216 );
217 Self::from_metrics(
218 font_size,
219 line_rhythms,
220 font_size * (1.0 - baseline_ratio),
221 font_size * baseline_ratio,
222 )
223 }
224
225 /// Font size in logical pixels.
226 #[inline]
227 pub const fn font_size(&self) -> f32 {
228 self.font_size
229 }
230
231 /// Line height in whole rhythm units.
232 #[inline]
233 pub const fn line_rhythms(&self) -> u32 {
234 self.line_rhythms
235 }
236
237 /// Distance from the baseline up to the top of the `ascent` box, positive.
238 #[inline]
239 pub const fn ascent(&self) -> f32 {
240 self.ascent
241 }
242
243 /// Distance from the baseline down to the bottom of the `descent` box, positive.
244 #[inline]
245 pub const fn descent(&self) -> f32 {
246 self.descent
247 }
248
249 /// Height of capital letters above the baseline, if known.
250 #[inline]
251 pub const fn cap_height(&self) -> Option<f32> {
252 self.cap_height
253 }
254
255 /// Height of a lowercase x above the baseline, if known.
256 #[inline]
257 pub const fn x_height(&self) -> Option<f32> {
258 self.x_height
259 }
260
261 /// The Plumber-style baseline ratio implied by these metrics.
262 #[inline]
263 pub fn baseline_ratio(&self) -> f32 {
264 (self.font_size + self.descent - self.ascent) / (2.0 * self.font_size)
265 }
266
267 /// The line height on `grid`: [`line_rhythms`](Self::line_rhythms) whole rhythm units.
268 #[inline]
269 pub fn line_height(&self, grid: Rhythm) -> f32 {
270 self.line_metrics(grid).line_height()
271 }
272
273 /// Extra space split above and below the `ascent + descent` box.
274 #[inline]
275 pub fn half_leading(&self, grid: Rhythm) -> f32 {
276 self.line_metrics(grid).half_leading()
277 }
278
279 /// Distance from the top of the line box down to the baseline.
280 #[inline]
281 pub fn baseline_above(&self, grid: Rhythm) -> f32 {
282 self.line_metrics(grid).baseline_above()
283 }
284
285 /// Distance from the baseline down to the bottom of the line box.
286 #[inline]
287 pub fn baseline_below(&self, grid: Rhythm) -> f32 {
288 self.line_metrics(grid).baseline_below()
289 }
290
291 /// Invisible space between the top of the line box and the cap top: the amount
292 /// a leading-trim (CSS `text-box-trim`) would remove. Subtract it from a top
293 /// spacing (or apply as negative margin) to visually butt capitals against an
294 /// edge or grid line.
295 #[inline]
296 pub fn cap_trim_top(&self, grid: Rhythm) -> Option<f32> {
297 Some(self.baseline_above(grid) - self.cap_height?)
298 }
299
300 /// Like [`Self::cap_trim_top`] but trimming to the x-height.
301 #[inline]
302 pub fn x_trim_top(&self, grid: Rhythm) -> Option<f32> {
303 Some(self.baseline_above(grid) - self.x_height?)
304 }
305
306 /// Spacing from an element's top edge up to the nth grid line above the first
307 /// baseline. Equivalent to rhythm-sass `baseline-top()` / `rhythm-bottom()`.
308 ///
309 /// Applied as `padding-top` (or `margin-top`), it makes the first baseline land
310 /// exactly `n` rhythm units below the grid line at the element's padding edge.
311 ///
312 /// The result is negative when `n × size` is smaller than
313 /// [`baseline_above`](Self::baseline_above); a negative value is meaningful
314 /// as a margin but not as a padding, so pick `n` accordingly.
315 #[inline]
316 pub fn baseline_top(&self, grid: Rhythm, n: i32) -> f32 {
317 grid.height(n) - self.baseline_above(grid)
318 }
319
320 /// Spacing from an element's bottom edge down to the nth grid line below the
321 /// last baseline. Equivalent to rhythm-sass `baseline-bottom()` / `rhythm-top()`.
322 ///
323 /// The result is negative when `n × size` is smaller than
324 /// [`baseline_below`](Self::baseline_below); a negative value is meaningful
325 /// as a margin but not as a padding, so pick `n` accordingly.
326 #[inline]
327 pub fn baseline_bottom(&self, grid: Rhythm, n: i32) -> f32 {
328 grid.height(n) - self.baseline_below(grid)
329 }
330
331 /// Spacing from a block set in this style down to a following block set in
332 /// `below`, measured from the bottom of this line box to the top of the
333 /// below font's line box, such that the two adjacent baselines are exactly
334 /// `n` rhythm units apart. Equivalent to rhythm-sass `baseline-between()`.
335 ///
336 /// The result is negative when `n` rhythm units cannot fit both fonts'
337 /// baseline distances; negative values overlap the blocks when applied.
338 #[inline]
339 pub fn baseline_between(&self, grid: Rhythm, below: &FontRhythm, n: i32) -> f32 {
340 self.baseline_bottom(grid, n) - below.baseline_above(grid)
341 }
342
343 /// Top spacing that lands the **cap ink top** — not the baseline — on the
344 /// nth grid line: `n × size − cap_trim_top`. The grid-woven analog of CSS
345 /// `text-box-trim: trim-start` with `text-box-edge: cap`, for openings
346 /// where the eye measures ink to edge (heroes, cards, page tops).
347 ///
348 /// The block's baselines shift off the grid by `cap_height mod size`;
349 /// close the block with [`Self::cap_bottom`] — not
350 /// [`Self::baseline_bottom`] — so it still occupies a whole number of
351 /// rhythm rows and everything after it stays in rhythm.
352 ///
353 /// `None` when this style has no usable cap height. CJK faces report one
354 /// anyway, so on ideographic text this anchor returns `Some` while
355 /// trimming to the wrong ink: ideographs are not seated on the baseline,
356 /// and the envelope their ink is drawn to is the ideographic character
357 /// face, not a cap height. Anchor those with
358 /// [`RhythmBlockMetrics::ink_anchored`](crate::RhythmBlockMetrics::ink_anchored),
359 /// or with `RhythmIcfAnchor::span` under the `gpui` feature. Worse, the
360 /// reported value need not describe any glyph:
361 /// PingFang SC publishes `sCapHeight` 0.860 em, a copy of its
362 /// `sTypoAscender`, while its Latin `H` actually reaches 0.714 em
363 /// (Hiragino Sans GB, by contrast, reports its true 0.766 em). Treat a
364 /// CJK face's cap and x heights as unverified.
365 ///
366 /// # Examples
367 ///
368 /// ```
369 /// use rhythm_gpui::{FontRhythm, Rhythm};
370 ///
371 /// let grid = Rhythm::new(8.0);
372 /// // Georgia-like 28px heading on a 5-unit (40px) line.
373 /// let heading = FontRhythm::from_platform_metrics(28.0, 5, 25.68, -6.14, 19.40, 13.48);
374 ///
375 /// // Open: the capitals' ink starts exactly on the 3rd grid line…
376 /// let pt = heading.cap_top(grid, 3).unwrap();
377 /// assert!((pt + heading.cap_trim_top(grid).unwrap() - grid.height(3)).abs() < 1e-3);
378 ///
379 /// // …and the paired closer returns the trim, so the block spans whole rows.
380 /// let pb = heading.cap_bottom(grid, 0).unwrap();
381 /// let block = pt + heading.line_height(grid) + pb;
382 /// assert!((block - 64.0).abs() < 1e-3); // 8 whole rows: what follows stays in rhythm
383 /// ```
384 #[inline]
385 pub fn cap_top(&self, grid: Rhythm, n: i32) -> Option<f32> {
386 Some(grid.height(n) - self.cap_trim_top(grid)?)
387 }
388
389 /// Bottom spacing pairing [`Self::cap_top`]: `n × size + cap_trim_top`,
390 /// returning at the bottom exactly what `cap_top` trimmed at the top so
391 /// the block occupies a whole number of rhythm rows — for any number of
392 /// wrapped lines, since lines advance by whole rows. Equivalently, the
393 /// bottom edge lands `n + line_rhythms` units below the last line's cap
394 /// top.
395 ///
396 /// `None` when this style has no usable cap height.
397 #[inline]
398 pub fn cap_bottom(&self, grid: Rhythm, n: i32) -> Option<f32> {
399 Some(grid.height(n) + self.cap_trim_top(grid)?)
400 }
401
402 /// This style's line placement on `grid` as a
403 /// [`RhythmLineMetrics`](crate::RhythmLineMetrics) — the same value a
404 /// shaped line produces, so a custom renderer can place single-style
405 /// text (and empty lines) through one code path.
406 #[inline]
407 pub fn line_metrics(&self, grid: Rhythm) -> crate::RhythmLineMetrics {
408 crate::RhythmLineMetrics::new(self.ascent, self.descent, self.line_rhythms, grid)
409 }
410
411 /// Solve a drop cap sunk `lines` lines deep into text set in `self`.
412 ///
413 /// `cap` carries the cap face's metrics resolved at any font size (metrics
414 /// scale linearly, so the probe size is irrelevant); its `line_rhythms` is
415 /// ignored. The solved font size makes the cap face's capital span from the
416 /// first line's cap top down to the `lines`-th baseline, and
417 /// [`DropCapRhythm::top`] anchors the baseline there. A face without a
418 /// usable cap height falls back to the classic 0.7 em approximation, which
419 /// can only misplace the visual top — the baseline anchor stays exact.
420 ///
421 /// Drop caps are a Latin convention; CJK paragraphs conventionally open
422 /// with a first-line indent instead, so there is deliberately no
423 /// ideographic dual of this solver. A CJK face passed as `cap` is solved
424 /// from its reported (Latin-glyph) cap height like any other face.
425 ///
426 /// # Examples
427 ///
428 /// ```
429 /// use rhythm_gpui::{FontRhythm, Rhythm};
430 ///
431 /// let grid = Rhythm::new(8.0);
432 /// // Georgia-like metrics at 16px on a 3-unit (24px) line.
433 /// let body = FontRhythm::from_platform_metrics(16.0, 3, 14.67, -3.51, 11.09, 7.70);
434 /// let cap = body.drop_cap(grid, &body, 3);
435 ///
436 /// // The capital spans two body lines plus the body cap height…
437 /// let span = 2.0 * body.line_height(grid) + body.cap_height().unwrap();
438 /// assert!((cap.metrics().cap_height().unwrap() - span).abs() < 1e-3);
439 /// // …and `top` lands its baseline exactly on the third body baseline.
440 /// let target = body.baseline_above(grid) + 2.0 * body.line_height(grid);
441 /// assert!((cap.top() + cap.metrics().baseline_above(grid) - target).abs() < 1e-3);
442 /// ```
443 ///
444 /// # Panics
445 ///
446 /// Panics when `lines` is zero or `lines × line_rhythms` overflows `u32`.
447 pub fn drop_cap(&self, grid: Rhythm, cap: &FontRhythm, lines: u32) -> DropCapRhythm {
448 assert!(lines > 0, "a drop cap must sink at least one line");
449 let line_rhythms = self
450 .line_rhythms
451 .checked_mul(lines)
452 .expect("drop cap line rhythms overflow u32");
453 let body_cap = self.cap_height.unwrap_or(0.7 * self.font_size);
454 let cap_height = cap.cap_height;
455 let cap_ratio = cap_height.unwrap_or(0.7 * cap.font_size) / cap.font_size;
456 let span = (lines - 1) as f32 * self.line_height(grid) + body_cap;
457 let font_size = span / cap_ratio;
458 let scale = font_size / cap.font_size;
459 let mut metrics = FontRhythm::from_metrics(
460 font_size,
461 line_rhythms,
462 cap.ascent * scale,
463 cap.descent * scale,
464 );
465 metrics.cap_height = cap_height.and_then(|h| usable_metric(h * scale));
466 metrics.x_height = cap.x_height.and_then(|h| usable_metric(h * scale));
467 let target = self.baseline_above(grid) + (lines - 1) as f32 * self.line_height(grid);
468 DropCapRhythm {
469 top: target - metrics.baseline_above(grid),
470 metrics,
471 }
472 }
473}
474
475/// A drop cap solved by [`FontRhythm::drop_cap`]: the cap face's metrics at the
476/// solved size plus the offset anchoring its baseline.
477#[derive(Debug, Clone, Copy, PartialEq)]
478pub struct DropCapRhythm {
479 metrics: FontRhythm,
480 top: f32,
481}
482
483impl DropCapRhythm {
484 /// Cap-face metrics at the solved size. The line box spans the sunk lines
485 /// exactly: `line_rhythms` is `lines ×` the body's `line_rhythms`.
486 #[inline]
487 pub const fn metrics(&self) -> &FontRhythm {
488 &self.metrics
489 }
490
491 /// Offset from the paragraph's top edge down to the cap's line box top,
492 /// negative when the cap must sit above it. Apply it visually — in a flex
493 /// row as a relative inset, not a margin: cap-heavy faces (cap height
494 /// exceeding `ascent − descent`, e.g. Merriweather) yield a positive
495 /// offset, and as a margin it would grow the row's cross size and push
496 /// everything below off the grid.
497 #[inline]
498 pub const fn top(&self) -> f32 {
499 self.top
500 }
501}
502
503fn usable_metric(height: f32) -> Option<f32> {
504 (height.is_finite() && height > 0.0).then_some(height)
505}
506
507/// Round `value` to the nearest multiple of `step` (e.g. `1.0 / scale_factor` to
508/// snap a spacing to whole device pixels). The core functions never round, so
509/// baseline-anchored results stay exact; snap only final applied values.
510///
511/// # Panics
512///
513/// Panics when `step` is zero, negative, or non-finite.
514pub fn snap(value: f32, step: f32) -> f32 {
515 assert!(
516 step.is_finite() && step > 0.0,
517 "snap step must be finite and greater than zero"
518 );
519 (value / step).round() * step
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 const GRID: Rhythm = Rhythm::new(8.0);
527 const NOTO_SERIF_RATIO: f32 = 0.112061;
528
529 // Mirrors __tests__/_font-maps.scss from rhythm-sass.
530 fn font_map_3() -> FontRhythm {
531 FontRhythm::from_baseline_ratio(17.0, 3, NOTO_SERIF_RATIO)
532 }
533
534 fn font_map_3_with_cap(cap_height: f32) -> FontRhythm {
535 let base = font_map_3();
536 FontRhythm::from_platform_metrics(
537 base.font_size(),
538 base.line_rhythms(),
539 base.ascent(),
540 base.descent(),
541 cap_height,
542 base.x_height().unwrap_or(0.0),
543 )
544 }
545
546 fn font_map_5() -> FontRhythm {
547 FontRhythm::from_baseline_ratio(12.0, 2, NOTO_SERIF_RATIO)
548 }
549
550 #[test]
551 fn rhythm_spacing_and_height() {
552 assert_eq!(GRID.spacing(5), 40.0);
553 assert_eq!(GRID.spacing(0), 0.0);
554 assert_eq!(GRID.spacing(-1), -8.0);
555 assert_eq!(GRID.height(5), 40.0);
556 assert_eq!(GRID.height(5), GRID.spacing(5));
557 assert_eq!(GRID.height(5) - 1.0, 39.0); // offsets are plain addition
558 assert_eq!(GRID.height(0), 0.0);
559 assert_eq!(GRID.height(-1), -8.0);
560 }
561
562 // rhythm-sass rounded baseline offsets to whole CSS px before subtracting
563 // (see calc-baseline-offset in _lib.scss). The math here stays exact, so the
564 // Sass parity tests apply the same rounding explicitly.
565 fn sass_baseline_bottom(font: &FontRhythm, n: i32) -> f32 {
566 GRID.height(n) - font.baseline_below(GRID).round()
567 }
568
569 fn sass_baseline_top(font: &FontRhythm, n: i32) -> f32 {
570 GRID.height(n) - font.baseline_above(GRID).round()
571 }
572
573 #[test]
574 fn sass_parity_rhythm_top_and_bottom() {
575 // Expected values from __tests__/rhythm.test.scss.
576 assert_eq!(sass_baseline_bottom(&font_map_3(), 3), 19.0); // rhythm-top
577 assert_eq!(sass_baseline_bottom(&font_map_3(), 0), -5.0);
578 assert_eq!(sass_baseline_bottom(&font_map_3(), -1), -13.0);
579 assert_eq!(sass_baseline_top(&font_map_3(), 3), 5.0); // rhythm-bottom
580 assert_eq!(sass_baseline_top(&font_map_3(), 0), -19.0);
581 assert_eq!(sass_baseline_top(&font_map_3(), -1), -27.0);
582 }
583
584 #[test]
585 fn sass_parity_baseline_between() {
586 let above = font_map_3();
587 let below = font_map_5();
588 let result = sass_baseline_bottom(&above, 3) - below.baseline_above(GRID).round();
589 assert_eq!(result, 6.0);
590 }
591
592 #[test]
593 fn exact_functions_land_baseline_on_grid() {
594 let font = font_map_3();
595 // padding_top + renderer's baseline placement = exact grid multiple
596 let padding_top = font.baseline_top(GRID, 3);
597 assert!((padding_top + font.baseline_above(GRID) - GRID.height(3)).abs() < 1e-4);
598
599 let padding_bottom = font.baseline_bottom(GRID, 3);
600 assert!((padding_bottom + font.baseline_below(GRID) - GRID.height(3)).abs() < 1e-4);
601
602 // Two stacked blocks: distance between adjacent baselines is n grid units.
603 let below = font_map_5();
604 let gap = font.baseline_between(GRID, &below, 3);
605 let baseline_distance = font.baseline_below(GRID) + gap + below.baseline_above(GRID);
606 assert!((baseline_distance - GRID.height(3)).abs() < 1e-4);
607 }
608
609 #[test]
610 fn baseline_ratio_roundtrip() {
611 let font = font_map_3();
612 assert!((font.baseline_ratio() - NOTO_SERIF_RATIO).abs() < 1e-6);
613 }
614
615 #[test]
616 fn baseline_ratio_from_real_metrics_matches_plumber() {
617 // Noto Serif: units_per_em 1000, hhea ascent 1069, descent 293.
618 // Plumber's published ratio for Noto Serif is 0.112061.
619 let em = 1000.0;
620 let font = FontRhythm::from_metrics(em, 1, 1069.0, 293.0);
621 assert!((font.baseline_ratio() - 0.112).abs() < 1e-3);
622 }
623
624 #[test]
625 fn platform_metrics_normalize_opentype_signs() {
626 // gpui's FontMetrics reports table descent as negative on macOS
627 // (OpenType sign convention); magnitudes must come out positive.
628 let font = FontRhythm::from_platform_metrics(16.0, 3, 14.75, -3.25, 11.2, 8.1);
629 assert_eq!(font.ascent(), 14.75);
630 assert_eq!(font.descent(), 3.25);
631 assert_eq!(font.cap_height(), Some(11.2));
632 assert_eq!(font.x_height(), Some(8.1));
633 }
634
635 #[test]
636 fn platform_metrics_filter_unusable_optional_heights() {
637 let font = FontRhythm::from_platform_metrics(16.0, 3, 14.75, 3.25, 0.0, f32::NAN);
638 assert_eq!(font.cap_height(), None);
639 assert_eq!(font.x_height(), None);
640
641 let font = FontRhythm::from_platform_metrics(16.0, 3, 14.75, 3.25, -1.0, 7.5);
642 assert_eq!(font.cap_height(), None);
643 assert_eq!(font.x_height(), Some(7.5));
644 }
645
646 /// Pins CHANGELOG.md's 0.2 migration recipe: the documented em-box
647 /// composition must stay bit-identical to `from_baseline_ratio`.
648 #[test]
649 fn changelog_ratio_recipe_matches_from_baseline_ratio() {
650 let composed = FontRhythm::from_platform_metrics(
651 17.0,
652 3,
653 17.0 * (1.0 - NOTO_SERIF_RATIO),
654 17.0 * NOTO_SERIF_RATIO,
655 11.9,
656 8.5,
657 );
658 let plain = font_map_3();
659
660 assert_eq!(composed.font_size(), plain.font_size());
661 assert_eq!(composed.line_rhythms(), plain.line_rhythms());
662 assert_eq!(composed.ascent(), plain.ascent());
663 assert_eq!(composed.descent(), plain.descent());
664 assert_eq!(composed.baseline_ratio(), plain.baseline_ratio());
665 // …while carrying the heights a bare ratio cannot express.
666 assert_eq!(composed.cap_height(), Some(11.9));
667 assert_eq!(composed.x_height(), Some(8.5));
668 assert_eq!(plain.cap_height(), None);
669 }
670
671 #[test]
672 #[should_panic(expected = "line_rhythms must be greater than zero")]
673 fn zero_line_rhythms_are_rejected() {
674 let _ = FontRhythm::from_metrics(16.0, 0, 14.75, 3.25);
675 }
676
677 #[test]
678 #[should_panic(expected = "baseline ratio must be strictly between 0 and 1")]
679 fn out_of_range_baseline_ratio_is_rejected() {
680 let _ = FontRhythm::from_baseline_ratio(16.0, 3, 1.5);
681 }
682
683 #[test]
684 fn cap_trim() {
685 let font = font_map_3_with_cap(0.7 * 17.0);
686 let trim = font.cap_trim_top(GRID).unwrap();
687 assert!((trim - (font.baseline_above(GRID) - 11.9)).abs() < 1e-4);
688 assert_eq!(font_map_3().cap_trim_top(GRID), None);
689 }
690
691 #[test]
692 fn snap_to_device_pixels() {
693 assert_eq!(snap(5.4, 0.5), 5.5); // 2x display
694 assert_eq!(snap(5.4, 1.0), 5.0);
695 assert_eq!(snap(-5.4, 0.5), -5.5);
696 }
697
698 #[test]
699 fn snap_heights_to_whole_rows() {
700 assert_eq!(GRID.snap_up(450.0), 456.0); // 800px wide at 16:9
701 assert_eq!(GRID.snap_down(450.0), 448.0);
702 assert_eq!(GRID.snap_up(0.0), 0.0);
703 assert_eq!(GRID.snap_down(7.9), 0.0); // under one row floors to zero
704 // Exact multiples pass through both directions.
705 assert_eq!(GRID.snap_up(448.0), 448.0);
706 assert_eq!(GRID.snap_down(448.0), 448.0);
707 }
708
709 #[test]
710 fn snap_heights_absorb_float_error_near_whole_rows() {
711 assert_eq!(GRID.snap_up(448.0002), 448.0);
712 assert_eq!(GRID.snap_down(447.9998), 448.0);
713 }
714
715 #[test]
716 fn snap_up_keeps_a_real_remainder_at_large_heights() {
717 // Guards the `f32` calibration of `snap_rows`: at this magnitude the
718 // proportional tolerance (~4.25) approaches half the 8.5 unit, and
719 // computing the same rule in `f64` instead swallows the genuine 4px
720 // remainder and returns the row below. 4460404.5 / 8.5 is
721 // 524753.47..., so snapping up must reach row 524754.
722 let grid = Rhythm::new(8.5);
723 assert_eq!(grid.snap_up(4_460_404.5), 8.5 * 524_754.0);
724 assert_eq!(grid.snap_down(4_460_404.5), 8.5 * 524_753.0);
725 }
726
727 #[test]
728 fn snap_tolerance_does_not_scale_with_the_grid_size() {
729 let large_grid = Rhythm::new(1_000_000.0);
730 assert_eq!(large_grid.snap_up(50.0), 1_000_000.0);
731 assert_eq!(large_grid.snap_down(50.0), 0.0);
732 }
733
734 #[test]
735 #[should_panic(expected = "height must be finite and non-negative")]
736 fn snap_heights_reject_negative_values() {
737 let _ = GRID.snap_up(-1.0);
738 }
739
740 // Georgia on macOS: upem 2048, hhea 1878/-449, cap 1419, x 986.
741 fn georgia_16() -> FontRhythm {
742 FontRhythm::from_platform_metrics(
743 16.0,
744 3,
745 16.0 * 1878.0 / 2048.0,
746 -16.0 * 449.0 / 2048.0,
747 16.0 * 1419.0 / 2048.0,
748 16.0 * 986.0 / 2048.0,
749 )
750 }
751
752 #[test]
753 fn drop_cap_baseline_lands_on_the_sunk_line() {
754 let body = georgia_16();
755 let cap = body.drop_cap(GRID, &body, 3);
756 assert_eq!(cap.metrics().line_rhythms(), 9);
757 // The capital spans two body lines plus the body cap height…
758 let span = 2.0 * body.line_height(GRID) + body.cap_height().unwrap();
759 assert!((cap.metrics().cap_height().unwrap() - span).abs() < 1e-3);
760 // …and its baseline lands exactly on the third body baseline.
761 let target = body.baseline_above(GRID) + 2.0 * body.line_height(GRID);
762 assert!((cap.top() + cap.metrics().baseline_above(GRID) - target).abs() < 1e-3);
763 }
764
765 #[test]
766 fn drop_cap_sizes_by_the_cap_faces_own_metrics() {
767 let body = georgia_16();
768 // A display face with taller capitals: cap height 0.8 em.
769 let display = FontRhythm::from_platform_metrics(12.0, 1, 11.0, -3.0, 9.6, 6.0);
770 let cap = body.drop_cap(GRID, &display, 3);
771 let span = 2.0 * body.line_height(GRID) + body.cap_height().unwrap();
772 // Solved with the cap face's own ratio, not the body's.
773 assert!((cap.metrics().font_size() - span / 0.8).abs() < 1e-3);
774 assert!((cap.metrics().cap_height().unwrap() - span).abs() < 1e-3);
775 }
776
777 #[test]
778 fn cap_heavy_faces_need_a_positive_top_offset() {
779 // Merriweather: upem 2000, hhea 1968/-546, cap 1486. Cap height
780 // (0.743 em) exceeds ascent − descent (0.711 em), which flips the
781 // anchor offset positive — the reason the offset must be applied as a
782 // relative inset, not a margin (a positive margin grows the flex row's
783 // cross size and pushes everything below off the grid).
784 let body = FontRhythm::from_platform_metrics(
785 16.0,
786 3,
787 16.0 * 1968.0 / 2000.0,
788 -16.0 * 546.0 / 2000.0,
789 16.0 * 1486.0 / 2000.0,
790 16.0 * 1111.0 / 2000.0,
791 );
792 let cap = body.drop_cap(GRID, &body, 3);
793 assert!(
794 cap.top() > 1.0 && cap.top() < 1.1,
795 "expected ≈ +1.03, got {}",
796 cap.top()
797 );
798 let target = body.baseline_above(GRID) + 2.0 * body.line_height(GRID);
799 assert!((cap.top() + cap.metrics().baseline_above(GRID) - target).abs() < 1e-3);
800 }
801
802 #[test]
803 fn drop_cap_falls_back_to_the_em_approximation() {
804 let body = font_map_3(); // baseline-ratio construction: no cap height
805 let cap = body.drop_cap(GRID, &body, 2);
806 let expected_size = (body.line_height(GRID) + 0.7 * body.font_size()) / 0.7;
807 assert!((cap.metrics().font_size() - expected_size).abs() < 1e-3);
808 // The fallback is not fabricated into the solved metrics.
809 assert_eq!(cap.metrics().cap_height(), None);
810 // The baseline anchor holds regardless.
811 let target = body.baseline_above(GRID) + body.line_height(GRID);
812 assert!((cap.top() + cap.metrics().baseline_above(GRID) - target).abs() < 1e-3);
813 }
814
815 #[test]
816 #[should_panic(expected = "at least one line")]
817 fn drop_cap_rejects_zero_lines() {
818 let _ = font_map_3().drop_cap(GRID, &font_map_3(), 0);
819 }
820
821 #[test]
822 fn cap_pair_opens_on_ink_and_closes_on_whole_rows() {
823 // Georgia-like 28px heading on a 5-unit line.
824 let heading = FontRhythm::from_platform_metrics(
825 28.0,
826 5,
827 28.0 * 1878.0 / 2048.0,
828 -28.0 * 449.0 / 2048.0,
829 28.0 * 1419.0 / 2048.0,
830 28.0 * 986.0 / 2048.0,
831 );
832 let pt = heading.cap_top(GRID, 3).unwrap();
833 // The capitals' ink starts exactly on the 3rd grid line…
834 assert!((pt + heading.cap_trim_top(GRID).unwrap() - GRID.height(3)).abs() < 1e-3);
835 // …and the paired closer makes the block a whole number of rows.
836 let pb = heading.cap_bottom(GRID, 0).unwrap();
837 let block = pt + heading.line_height(GRID) + pb;
838 assert!((block - 64.0).abs() < 1e-3);
839 // Closing with baseline_bottom instead would leave the fractional
840 // cap-height phase inside the block and push everything below it
841 // off the grid.
842 let mixed = pt + heading.line_height(GRID) + heading.baseline_bottom(GRID, 1);
843 let rows = mixed / GRID.size();
844 assert!((rows - rows.round()).abs() > 0.01);
845 }
846
847 #[test]
848 fn cap_anchors_need_a_usable_cap_height() {
849 let no_cap = font_map_3(); // baseline-ratio construction: no cap height
850 assert_eq!(no_cap.cap_top(GRID, 3), None);
851 assert_eq!(no_cap.cap_bottom(GRID, 1), None);
852 }
853
854 // PingFang SC Regular, read from the font file: upem 1000, hhea
855 // 1060/-340, BASE icfb -102 / icft +822, OS/2 sCapHeight 860 and sxHeight
856 // 600. Those last two are placeholders, not measurements — sCapHeight
857 // simply repeats sTypoAscender, while the face's real H reaches 0.714 em
858 // and its x 0.517 em. The fixtures keep the reported values because they
859 // are what a text system hands back.
860 const PINGFANG_ICFT: f32 = 0.822;
861
862 fn pingfang(size: f32, rows: u32) -> FontRhythm {
863 FontRhythm::from_platform_metrics(
864 size,
865 rows,
866 1.060 * size,
867 -0.340 * size,
868 0.860 * size,
869 0.600 * size,
870 )
871 }
872
873 /// The math layer needs no ideographic anchor of its own:
874 /// [`RhythmBlockMetrics::ink_anchored`] takes the anchored ink height, so
875 /// passing a character-face ascent lands ideographic ink and still spans
876 /// whole rows.
877 #[test]
878 fn ideographic_ink_anchors_through_the_generic_block_metrics() {
879 let font = pingfang(16.0, 3);
880 let icf = PINGFANG_ICFT * 16.0;
881 let line = font.line_metrics(GRID);
882 let block = crate::RhythmBlockMetrics::ink_anchored(line, icf, 3, 0);
883
884 // The character face's top edge starts exactly on the 3rd grid line…
885 let trim = font.baseline_above(GRID) - icf;
886 assert!((block.opening() + trim - GRID.height(3)).abs() < 1e-3);
887 // …and the paired closer makes the block a whole number of rows.
888 assert!((block.height(1) - 48.0).abs() < 1e-3);
889 assert_eq!(block.rows(1), 6);
890
891 // Closing with a baseline anchor instead would leave the fractional
892 // character-face phase inside the block.
893 let mixed = block.opening() + font.line_height(GRID) + font.baseline_bottom(GRID, 1);
894 let rows = mixed / GRID.size();
895 assert!((rows - rows.round()).abs() > 0.01);
896 }
897
898 #[test]
899 fn a_cjk_faces_cap_height_is_the_wrong_ink() {
900 // Two hazards at once. The reported cap height anchors Latin ink that
901 // ideographs do not share, and PingFang's reported value (0.860 em,
902 // a copy of sTypoAscender) describes no glyph at all — its real H is
903 // 0.714 em. Meanwhile the em box top, 0.86 em, sits above every
904 // ideograph's ink (字 reaches +0.825 em), so anchoring the box leaves
905 // a visible gap where the character face does not.
906 let font = pingfang(16.0, 3);
907 let icf = PINGFANG_ICFT * 16.0;
908 let ink_top = 0.825 * 16.0;
909
910 let cap_anchor = font.cap_top(GRID, 3).unwrap();
911 let face_anchor =
912 crate::RhythmBlockMetrics::ink_anchored(font.line_metrics(GRID), icf, 3, 0).opening();
913 assert!(
914 face_anchor < cap_anchor,
915 "the character face sits above the reported cap height"
916 );
917
918 // The face anchor puts real ink within a fraction of a pixel of the
919 // grid line; an em-box anchor is a visible distance short of it.
920 assert!((icf - ink_top).abs() < 0.06);
921 assert!(0.86 * 16.0 - ink_top > 0.5);
922 }
923
924 /// Pins the worked example in README.md's CJK section.
925 #[test]
926 fn readme_heading_flush_to_a_card_edge() {
927 // PingFang SC 24px on a 5-unit (40px) line, 8px grid.
928 let heading = pingfang(24.0, 5);
929 let icf = PINGFANG_ICFT * 24.0;
930 let trim = heading.baseline_above(GRID) - icf;
931 assert!((trim - 8.912).abs() < 1e-3, "invisible space, got {trim}");
932
933 // The naive padding overshoots by that whole invisible band.
934 assert!((16.0 + trim - 24.912).abs() < 1e-3);
935
936 // The anchored pair lands the ink and still spans whole rows.
937 let block = crate::RhythmBlockMetrics::ink_anchored(heading.line_metrics(GRID), icf, 2, 0);
938 assert!((block.opening() - 7.088).abs() < 1e-3);
939 assert!((block.opening() + trim - 16.0).abs() < 1e-3);
940 assert!((block.height(1) - 56.0).abs() < 1e-3);
941 assert_eq!(block.rows(1), 7);
942
943 // The cap anchor misses by PingFang's reported cap height.
944 assert!((heading.cap_top(GRID, 2).unwrap() + trim - 16.912).abs() < 1e-3);
945 }
946}