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}