Skip to main content

kozan_core/layout/inline/
measurer.rs

1//! Pluggable text measurement — trait + default estimator.
2//!
3//! Chrome equivalents:
4//! - `CachingWordShaper` → cache layer (future)
5//! - `HarfBuzzShaper` → shapes text, produces `ShapeResult` (glyph advances)
6//! - `SimpleFontData::GetFontMetrics()` → font-level metrics from OS/2 + hhea tables
7//! - `FontHeight` → ascent + descent after leading distribution
8//!
9//! # Architecture
10//!
11//! ```text
12//! TextMeasurer (trait)
13//!   ├── measure(text, font_size) → TextMetrics { width }      ← shaper output
14//!   └── font_metrics(font_size)  → FontMetrics { ascent, descent, line_gap }
15//!                                                               ← font tables
16//!
17//! FontHeight { ascent, descent }   ← after CSS line-height + leading split
18//!   = resolve from FontMetrics + ComputedStyle::line_height()
19//! ```
20//!
21//! The `TextMeasurer` owns ALL font-dependent values. CSS `line-height`
22//! resolution uses `FontMetrics::line_spacing()` for the `Normal` case
23//! and pure CSS math for `Number`/`Length` cases.
24
25use style::values::computed::font::LineHeight;
26
27// ============================================================
28// FontMetrics — raw font table metrics (per-font, per-size)
29// ============================================================
30
31/// Raw font metrics from the font's OS/2 and hhea tables.
32///
33/// Chrome equivalent: `FontMetrics` (the platform-level metrics
34/// read from the font file by Skia/FreeType).
35///
36/// These are per-font, per-size values — the same for all text
37/// in the same font at the same size.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct FontMetrics {
40    /// Distance from baseline to top of em-square.
41    /// Chrome: `FontMetrics::FixedAscent()`.
42    pub ascent: f32,
43    /// Distance from baseline to bottom of em-square.
44    /// Chrome: `FontMetrics::FixedDescent()`.
45    pub descent: f32,
46    /// Extra space between lines recommended by the font.
47    /// Chrome: `FontMetrics::LineGap()`.
48    /// Many fonts (e.g., Roboto) set this to 0.
49    pub line_gap: f32,
50}
51
52impl FontMetrics {
53    /// Content height = ascent + descent.
54    #[inline]
55    #[must_use]
56    pub fn height(&self) -> f32 {
57        self.ascent + self.descent
58    }
59
60    /// Line spacing = ascent + descent + lineGap.
61    /// Chrome: `FontMetrics::FixedLineSpacing()`.
62    /// This is what CSS `line-height: normal` resolves to.
63    #[inline]
64    #[must_use]
65    pub fn line_spacing(&self) -> f32 {
66        self.ascent + self.descent + self.line_gap
67    }
68}
69
70// ============================================================
71// FontHeight — ascent + descent after leading distribution
72// ============================================================
73
74/// Line box contribution: ascent + descent with leading distributed.
75///
76/// Chrome equivalent: `FontHeight` (ascent + descent as `LayoutUnit`).
77///
78/// When CSS `line-height` exceeds the font's content height, the extra
79/// space (leading) is split equally above and below. This struct holds
80/// the result of that distribution.
81///
82/// Example: font ascent=16, descent=4, line-height=30.
83/// - font height = 20, leading = 30 - 20 = 10
84/// - half-leading = 5
85/// - `FontHeight` { ascent: 16 + 5 = 21, descent: 4 + 5 = 9 }
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct FontHeight {
88    /// Ascent + half-leading above the baseline.
89    pub ascent: f32,
90    /// Descent + half-leading below the baseline.
91    pub descent: f32,
92}
93
94impl FontHeight {
95    /// Total line height = ascent + descent.
96    #[inline]
97    #[must_use]
98    pub fn height(&self) -> f32 {
99        self.ascent + self.descent
100    }
101
102    /// Compute `FontHeight` from raw font metrics + resolved CSS line-height.
103    ///
104    /// Chrome equivalent: `InlineBoxState::ComputeTextMetrics()` →
105    /// `CalculateLeadingSpace()` → `AddLeading()`.
106    ///
107    /// Leading = `line_height` - `font_height`. Split equally above/below.
108    #[must_use]
109    pub fn from_metrics_and_line_height(font_metrics: &FontMetrics, line_height: f32) -> Self {
110        let font_height = font_metrics.height();
111        let leading = (line_height - font_height).max(0.0);
112        let half_leading = leading / 2.0;
113        FontHeight {
114            ascent: font_metrics.ascent + half_leading,
115            descent: font_metrics.descent + half_leading,
116        }
117    }
118}
119
120// ============================================================
121// TextMetrics — shaper output (per-run)
122// ============================================================
123
124/// Metrics for a specific shaped text run.
125///
126/// Chrome equivalent: `ShapeResult` (contains glyph advances).
127/// Width is the sum of all glyph advances after shaping.
128#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct TextMetrics {
130    /// Total advance width of the text run.
131    pub width: f32,
132}
133
134/// Metrics for text laid out with a width constraint (line wrapping).
135///
136/// Chrome equivalent: the result of `NGInlineLayoutAlgorithm` —
137/// measures text within an available width, breaking into lines.
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub struct WrappedTextMetrics {
140    /// Width of the widest line.
141    pub width: f32,
142    /// Total height (number of lines × line height).
143    pub height: f32,
144}
145
146// ============================================================
147// TextMeasurer trait
148// ============================================================
149
150/// Trait for measuring text and querying font metrics.
151///
152/// Chrome equivalent: `CachingWordShaper` (width via `HarfBuzzShaper`)
153/// + `SimpleFontData::GetFontMetrics()` (font-level metrics).
154///
155/// Passed by reference — no lifetime on consumer structs.
156pub trait TextMeasurer {
157    /// Measure the advance width of a text run (no wrapping).
158    ///
159    /// Chrome: `CachingWordShaper::Width()` → `HarfBuzzShaper::Shape()`
160    /// → sum of glyph advances from `ShapeResult`.
161    fn measure(&self, text: &str, font_size: f32) -> TextMetrics;
162
163    /// Get font metrics (ascent, descent, lineGap) for a given font size.
164    ///
165    /// Chrome: `SimpleFontData::GetFontMetrics()` → reads from the
166    /// font's OS/2 and hhea tables via Skia.
167    fn font_metrics(&self, font_size: f32) -> FontMetrics;
168
169    /// Measure text width using a full `FontQuery` (correct font family + weight).
170    /// Default delegates to `measure()` ignoring family/weight.
171    fn shape_text(&self, text: &str, query: &super::font_system::FontQuery) -> TextMetrics {
172        self.measure(text, query.font_size)
173    }
174
175    /// Get font metrics using a full `FontQuery` (correct font family + weight).
176    /// Default delegates to `font_metrics()` ignoring family/weight.
177    fn query_metrics(&self, query: &super::font_system::FontQuery) -> FontMetrics {
178        self.font_metrics(query.font_size)
179    }
180
181    /// Shape text and extract glyph runs for rendering.
182    ///
183    /// Default: empty (no shaping). `FontSystem` provides real implementation.
184    fn shape_glyphs(
185        &self,
186        _text: &str,
187        _query: &super::font_system::FontQuery,
188        _color: [u8; 4],
189    ) -> Vec<super::font_system::ShapedTextRun> {
190        Vec::new()
191    }
192
193    /// Measure text with a width constraint, performing line wrapping.
194    ///
195    /// Chrome: `NGInlineLayoutAlgorithm` → breaks text into lines
196    /// within the available width, returns the bounding box.
197    ///
198    /// `max_width`: `None` = no constraint (single line),
199    /// `Some(w)` = wrap lines at `w` pixels.
200    ///
201    /// Default implementation uses word-level wrapping with `measure()`.
202    /// `FontSystem` overrides with Parley's real line breaker.
203    fn measure_wrapped(
204        &self,
205        text: &str,
206        font_size: f32,
207        max_width: Option<f32>,
208    ) -> WrappedTextMetrics {
209        let fm = self.font_metrics(font_size);
210        let line_h = fm.ascent + fm.descent;
211        let max_w = max_width.unwrap_or(f32::INFINITY);
212
213        // Split at word boundaries (whitespace + zero-width space).
214        let words: Vec<&str> = text
215            .split(|c: char| c.is_whitespace() || c == '\u{200B}')
216            .filter(|w| !w.is_empty())
217            .collect();
218
219        let mut lines = 1u32;
220        let mut current_line_w: f32 = 0.0;
221        let mut widest: f32 = 0.0;
222
223        for word in &words {
224            let word_w = self.measure(word, font_size).width;
225            if current_line_w > 0.0 && current_line_w + word_w > max_w {
226                widest = widest.max(current_line_w);
227                current_line_w = word_w;
228                lines += 1;
229            } else {
230                current_line_w += word_w;
231            }
232        }
233        widest = widest.max(current_line_w);
234
235        WrappedTextMetrics {
236            width: widest,
237            height: lines as f32 * line_h,
238        }
239    }
240}
241
242// ============================================================
243// CSS line-height resolution
244// ============================================================
245
246/// Resolve CSS `line-height` to a pixel value.
247///
248/// Chrome equivalent: `ComputedStyle::ComputedLineHeight()`.
249///
250/// Stylo's computed `LineHeight` has three variants:
251/// - `Normal` → `FontMetrics::line_spacing()` (ascent + descent + lineGap
252///   from the actual font — NOT a hardcoded multiplier).
253/// - `Number(n)` → `font_size * n` (unitless multiplier).
254/// - `Length(px)` → absolute pixel value (already resolved by Stylo).
255#[must_use]
256pub fn resolve_line_height(
257    line_height: &LineHeight,
258    font_size: f32,
259    font_metrics: &FontMetrics,
260) -> f32 {
261    match line_height {
262        LineHeight::Normal => font_metrics.line_spacing(),
263        LineHeight::Number(n) => font_size * n.0,
264        LineHeight::Length(l) => l.0.px(),
265    }
266}
267
268// ============================================================
269// Tests
270// ============================================================
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    // ---- TextMeasurer tests (real FontSystem) ----
277
278    use crate::layout::inline::FontSystem;
279
280    #[test]
281    fn longer_text_is_wider() {
282        let m = FontSystem::new();
283        let short = m.measure("Hi", 16.0);
284        let long = m.measure("Hello World", 16.0);
285        assert!(long.width > short.width, "longer text should be wider");
286    }
287
288    #[test]
289    fn larger_font_is_wider() {
290        let m = FontSystem::new();
291        let small = m.measure("Hello", 12.0);
292        let big = m.measure("Hello", 24.0);
293        assert!(big.width > small.width, "larger font should be wider");
294    }
295
296    #[test]
297    fn empty_string_zero_width() {
298        let m = FontSystem::new();
299        assert_eq!(m.measure("", 16.0).width, 0.0);
300    }
301
302    #[test]
303    fn nonzero_width_for_text() {
304        let m = FontSystem::new();
305        assert!(m.measure("A", 16.0).width > 0.0);
306    }
307
308    #[test]
309    fn custom_measurer_trait() {
310        struct FixedMeasurer;
311        impl TextMeasurer for FixedMeasurer {
312            fn measure(&self, _text: &str, _font_size: f32) -> TextMetrics {
313                TextMetrics { width: 100.0 }
314            }
315            fn font_metrics(&self, _font_size: f32) -> FontMetrics {
316                FontMetrics {
317                    ascent: 14.0,
318                    descent: 4.0,
319                    line_gap: 2.0,
320                }
321            }
322        }
323        assert_eq!(FixedMeasurer.measure("x", 0.0).width, 100.0);
324        assert_eq!(FixedMeasurer.font_metrics(0.0).line_spacing(), 20.0);
325    }
326
327    // ---- FontMetrics tests ----
328
329    #[test]
330    fn font_metrics_from_real_font() {
331        let m = FontSystem::new();
332        let fm = m.font_metrics(16.0);
333        assert!(fm.ascent > 0.0, "ascent should be positive");
334        assert!(fm.descent > 0.0, "descent should be positive");
335        assert!(fm.ascent > fm.descent, "ascent > descent for Latin fonts");
336        assert!(fm.height() > 0.0, "height should be positive");
337    }
338
339    // ---- FontHeight (leading distribution) tests ----
340
341    #[test]
342    fn font_height_no_leading() {
343        // line-height == font height → no leading.
344        let fm = FontMetrics {
345            ascent: 16.0,
346            descent: 4.0,
347            line_gap: 0.0,
348        };
349        let fh = FontHeight::from_metrics_and_line_height(&fm, 20.0);
350        assert_eq!(fh.ascent, 16.0);
351        assert_eq!(fh.descent, 4.0);
352        assert_eq!(fh.height(), 20.0);
353    }
354
355    #[test]
356    fn font_height_with_leading() {
357        // line-height=30, font_height=20 → leading=10, half=5.
358        let fm = FontMetrics {
359            ascent: 16.0,
360            descent: 4.0,
361            line_gap: 0.0,
362        };
363        let fh = FontHeight::from_metrics_and_line_height(&fm, 30.0);
364        assert_eq!(fh.ascent, 21.0); // 16 + 5
365        assert_eq!(fh.descent, 9.0); // 4 + 5
366        assert_eq!(fh.height(), 30.0);
367    }
368
369    #[test]
370    fn font_height_line_height_smaller_than_font() {
371        // line-height < font_height → leading clamped to 0.
372        let fm = FontMetrics {
373            ascent: 16.0,
374            descent: 4.0,
375            line_gap: 0.0,
376        };
377        let fh = FontHeight::from_metrics_and_line_height(&fm, 15.0);
378        assert_eq!(fh.ascent, 16.0); // no negative leading
379        assert_eq!(fh.descent, 4.0);
380        assert_eq!(fh.height(), 20.0); // font_height, not 15
381    }
382
383    // ---- line-height resolution tests ----
384
385    fn test_font_metrics() -> FontMetrics {
386        // Font with lineGap > 0 to verify Normal uses line_spacing.
387        FontMetrics {
388            ascent: 16.0,
389            descent: 4.0,
390            line_gap: 2.0,
391        }
392    }
393
394    #[test]
395    fn line_height_normal_uses_font_line_spacing() {
396        let fm = test_font_metrics();
397        // Normal → ascent + descent + lineGap = 16 + 4 + 2 = 22
398        assert_eq!(resolve_line_height(&LineHeight::Normal, 20.0, &fm), 22.0);
399    }
400
401    #[test]
402    fn line_height_number_multiplier() {
403        use style::values::generics::NonNegative;
404        let fm = test_font_metrics();
405        assert_eq!(
406            resolve_line_height(&LineHeight::Number(NonNegative(1.5)), 20.0, &fm),
407            30.0
408        );
409        assert_eq!(
410            resolve_line_height(&LineHeight::Number(NonNegative(2.0)), 16.0, &fm),
411            32.0
412        );
413    }
414
415    #[test]
416    fn line_height_px() {
417        use style::values::computed::CSSPixelLength;
418        use style::values::generics::NonNegative;
419        let fm = test_font_metrics();
420        assert_eq!(
421            resolve_line_height(
422                &LineHeight::Length(NonNegative(CSSPixelLength::new(28.0))),
423                20.0,
424                &fm
425            ),
426            28.0
427        );
428    }
429
430    #[test]
431    #[ignore] // TODO: Stylo resolves percent/em at computed-value time; LineHeight::Length is always px
432    fn line_height_percent() {}
433
434    #[test]
435    #[ignore] // TODO: Stylo resolves percent/em at computed-value time; LineHeight::Length is always px
436    fn line_height_em() {}
437
438    #[test]
439    fn line_height_normal_zero_line_gap() {
440        // Roboto and many fonts: lineGap = 0
441        let fm = FontMetrics {
442            ascent: 16.0,
443            descent: 4.0,
444            line_gap: 0.0,
445        };
446        assert_eq!(resolve_line_height(&LineHeight::Normal, 20.0, &fm), 20.0);
447    }
448
449    #[test]
450    fn line_height_normal_large_line_gap() {
451        // Some CJK fonts have significant lineGap.
452        let fm = FontMetrics {
453            ascent: 16.0,
454            descent: 4.0,
455            line_gap: 8.0,
456        };
457        assert_eq!(resolve_line_height(&LineHeight::Normal, 20.0, &fm), 28.0);
458    }
459}