Skip to main content

kozan_core/layout/inline/
font_system.rs

1//! Font system — manages font discovery, caching, and measurement.
2//!
3//! Chrome equivalent: `FontCache` + `SimpleFontData` + `CachingWordShaper`.
4//!
5//! Wraps Parley's `FontContext` (font discovery + matching) and
6//! `LayoutContext` (reusable scratch space for text shaping).
7//!
8//! # Architecture
9//!
10//! ```text
11//! FontSystem (owned by FrameWidget, one per View)
12//!   ├── FontContext     (Parley: font collection, matching, fallback)
13//!   │     └── Fontique  (system font enumeration)
14//!   │     └── Skrifa    (font file reading, metrics from OS/2 + hhea tables)
15//!   │     └── HarfRust  (text shaping, glyph positioning)
16//!   └── LayoutContext   (Parley: reusable scratch buffers)
17//! ```
18//!
19//! # Chrome mapping
20//!
21//! | Chrome | Kozan |
22//! |--------|-------|
23//! | `FontCache` | `FontSystem` (discovery + cache) |
24//! | `SimpleFontData::GetFontMetrics()` | `FontSystem::font_metrics()` |
25//! | `CachingWordShaper::Width()` | `FontSystem::measure_text()` |
26//! | `HarfBuzzShaper::Shape()` | Parley → `HarfRust` (shaping) |
27//! | `FontDescription` | `FontQuery` |
28//!
29//! # Thread safety
30//!
31//! `FontSystem` is `!Send` (matches Chrome: font ops are main-thread only).
32//! Each View/FrameWidget owns its own `FontSystem`.
33
34use std::cell::RefCell;
35use std::sync::Arc;
36
37use parley::fontique::Blob;
38use parley::style::{FontStack, FontStyle as ParleyFontStyle, FontWeight as ParleyFontWeight};
39use parley::{FontContext, Layout, LayoutContext, StyleProperty};
40
41use super::measurer::{FontMetrics, TextMeasurer, TextMetrics};
42
43/// Wrapper for font data that can be created from multiple sources
44/// without unnecessary copies.
45///
46/// Chrome equivalent: the data source for a `FontFace` — can be a
47/// static buffer (bundled font) or dynamically loaded bytes (web font).
48pub struct FontBlob(pub(crate) Blob<u8>);
49
50impl From<&'static [u8]> for FontBlob {
51    /// Zero-copy: wraps the static pointer in an Arc (no data copy).
52    /// Ideal for `include_bytes!()`.
53    fn from(data: &'static [u8]) -> Self {
54        FontBlob(Blob::new(Arc::new(data)))
55    }
56}
57
58impl From<Vec<u8>> for FontBlob {
59    /// One allocation: Vec → Box<[u8]> → Arc.
60    fn from(data: Vec<u8>) -> Self {
61        FontBlob(Blob::from(data))
62    }
63}
64
65impl From<Arc<Vec<u8>>> for FontBlob {
66    /// Already shared — wrap directly.
67    fn from(data: Arc<Vec<u8>>) -> Self {
68        FontBlob(Blob::new(data))
69    }
70}
71
72/// Query for font lookup — the CSS properties that affect font selection.
73///
74/// Chrome equivalent: `FontDescription`.
75#[derive(Debug, Clone)]
76pub struct FontQuery {
77    /// Font size in CSS pixels.
78    pub font_size: f32,
79    /// Font weight (100-900). Chrome: `FontSelectionRequest::weight`.
80    pub font_weight: u16,
81    /// Font style (normal/italic/oblique). Chrome: `FontSelectionRequest::slope`.
82    pub font_style: ParleyFontStyle,
83    /// Font family name (comma-separated CSS string).
84    pub font_family: String,
85    /// CSS `letter-spacing` in px. 0.0 = normal.
86    /// Chrome: `FontDescription::LetterSpacing()`.
87    pub letter_spacing: f32,
88    /// CSS `word-spacing` in px. 0.0 = normal.
89    /// Chrome: `FontDescription::WordSpacing()`.
90    pub word_spacing: f32,
91}
92
93impl FontQuery {
94    /// Create a query from full properties.
95    #[must_use]
96    pub fn new(
97        font_size: f32,
98        font_weight: u16,
99        font_style: ParleyFontStyle,
100        font_family: String,
101    ) -> Self {
102        Self {
103            font_size,
104            font_weight,
105            font_style,
106            font_family,
107            letter_spacing: 0.0,
108            word_spacing: 0.0,
109        }
110    }
111
112    /// Create a query with just font size (default weight + sans-serif).
113    #[must_use]
114    pub fn from_size(font_size: f32) -> Self {
115        Self {
116            font_size,
117            font_weight: 400,
118            font_style: ParleyFontStyle::Normal,
119            font_family: "sans-serif".to_string(),
120            letter_spacing: 0.0,
121            word_spacing: 0.0,
122        }
123    }
124
125    /// Create from Stylo `ComputedValues`.
126    #[must_use]
127    pub fn from_computed(cv: &style::properties::ComputedValues) -> Self {
128        let font = cv.get_font();
129        let fs = font.clone_font_size().computed_size().px();
130        let fw = font.clone_font_weight().value() as u16;
131        let style_val = font.clone_font_style();
132        let font_style = if style_val == style::values::computed::font::FontStyle::ITALIC {
133            ParleyFontStyle::Italic
134        } else if style_val != style::values::computed::font::FontStyle::NORMAL {
135            ParleyFontStyle::Oblique(None)
136        } else {
137            ParleyFontStyle::Normal
138        };
139        // Extract family name from Stylo's FontFamily.
140        use style_traits::ToCss;
141        let family = font.clone_font_family().to_css_string();
142
143        // Letter-spacing & word-spacing from inherited text properties.
144        // Stylo: `Spacing<CSSPixelLength>` — `Value(px)` or `Normal` (= 0).
145        let text = cv.get_inherited_text();
146        let zero = style::values::computed::CSSPixelLength::new(0.0);
147        let letter_spacing = text
148            .clone_letter_spacing()
149            .0
150            .percentage_relative_to(zero)
151            .px();
152        let word_spacing = text.clone_word_spacing().percentage_relative_to(zero).px();
153
154        Self {
155            font_size: fs,
156            font_weight: fw,
157            font_style,
158            font_family: family,
159            letter_spacing,
160            word_spacing,
161        }
162    }
163}
164
165/// The font system — owns font discovery, caching, and measurement.
166///
167/// Chrome equivalent: `FontCache` (singleton per renderer process).
168/// In Kozan: one per View (per-thread, matching Chrome's threading model).
169///
170/// All font metrics and text measurements go through this system.
171/// No hardcoded values — everything comes from real font files.
172///
173/// Uses `RefCell` for interior mutability so the `TextMeasurer` trait
174/// (which takes `&self`) can perform real Parley shaping (which needs `&mut`).
175/// Safe because layout is single-threaded per View.
176pub struct FontSystem {
177    font_cx: RefCell<FontContext>,
178    layout_cx: RefCell<LayoutContext>,
179}
180
181impl FontSystem {
182    /// Create a new font system, discovering system fonts.
183    ///
184    /// Chrome: `FontCache::Create()` → enumerates platform fonts.
185    /// Parley/Fontique scans the system font directories.
186    #[must_use]
187    pub fn new() -> Self {
188        Self {
189            font_cx: RefCell::new(FontContext::new()),
190            layout_cx: RefCell::new(LayoutContext::new()),
191        }
192    }
193
194    /// Register custom font data (TTF/OTF/TTC bytes) into the font collection.
195    ///
196    /// Chrome equivalent: `FontFaceCache::Add()` — registers a `@font-face`
197    /// source so the font becomes available for CSS `font-family` matching.
198    ///
199    /// After registration, the font's family name is automatically discovered
200    /// from the font's `name` table and becomes usable via `font-family` in CSS.
201    ///
202    /// Accepts anything convertible to `Arc<dyn AsRef<[u8]> + Send + Sync>`:
203    /// - `&'static [u8]` — zero-copy for `include_bytes!()` (Arc wraps the pointer, not the data)
204    /// - `Vec<u8>` — for runtime-loaded fonts (one allocation into Arc)
205    /// - `Arc<[u8]>` — if you already have shared font data
206    ///
207    /// Returns the family names that were registered.
208    ///
209    /// # Example
210    ///
211    /// ```ignore
212    /// // Static — zero copy, the bytes live in the binary:
213    /// ctx.register_font(include_bytes!("../assets/Cairo.ttf") as &[u8]);
214    ///
215    /// // Runtime — from a file:
216    /// ctx.register_font(std::fs::read("font.ttf").unwrap());
217    /// ```
218    pub fn register_font(&self, data: impl Into<FontBlob>) -> Vec<String> {
219        let mut font_cx = self.font_cx.borrow_mut();
220        let blob = data.into().0;
221        let registered = font_cx.collection.register_fonts(blob, None);
222        registered
223            .iter()
224            .filter_map(|(fid, _)| font_cx.collection.family_name(*fid).map(|n| n.to_string()))
225            .collect()
226    }
227
228    /// Measure the advance width of a text run.
229    ///
230    /// Chrome: `CachingWordShaper::Width()` → `HarfBuzzShaper::Shape()`.
231    /// Parley: builds a layout, shapes with `HarfRust`, returns width.
232    pub fn shape_text(&self, text: &str, query: &FontQuery) -> TextMetrics {
233        if text.is_empty() {
234            return TextMetrics { width: 0.0 };
235        }
236
237        let mut font_cx = self.font_cx.borrow_mut();
238        let mut layout_cx = self.layout_cx.borrow_mut();
239
240        let font_stack = family_to_stack(&query.font_family);
241        let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
242        builder.push_default(StyleProperty::FontSize(query.font_size));
243        builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(
244            query.font_weight as f32,
245        )));
246        builder.push_default(StyleProperty::FontStyle(query.font_style));
247        builder.push_default(StyleProperty::FontStack(font_stack));
248
249        let mut layout: Layout<[u8; 4]> = builder.build(text);
250        layout.break_all_lines(None);
251
252        TextMetrics {
253            width: layout.width(),
254        }
255    }
256
257    /// Get font metrics (ascent, descent, line-gap) for a given font query.
258    ///
259    /// Chrome: `SimpleFontData::GetFontMetrics()` → reads from font's
260    /// OS/2 and hhea tables via Skia.
261    pub fn query_metrics(&self, query: &FontQuery) -> FontMetrics {
262        let mut font_cx = self.font_cx.borrow_mut();
263        let mut layout_cx = self.layout_cx.borrow_mut();
264
265        let font_stack = family_to_stack(&query.font_family);
266        let reference_text = "x";
267        let mut builder = layout_cx.ranged_builder(&mut font_cx, reference_text, 1.0, true);
268        builder.push_default(StyleProperty::FontSize(query.font_size));
269        builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(
270            query.font_weight as f32,
271        )));
272        builder.push_default(StyleProperty::FontStyle(query.font_style));
273        builder.push_default(StyleProperty::FontStack(font_stack));
274
275        let mut layout: Layout<[u8; 4]> = builder.build(reference_text);
276        layout.break_all_lines(None);
277
278        // Read ascent/descent/leading from the first line's real font metrics.
279        // Parley always produces at least one line for non-empty text.
280        let line = layout
281            .lines()
282            .next()
283            .expect("Parley always produces at least one line for non-empty text");
284        let m = line.metrics();
285        FontMetrics {
286            ascent: m.ascent,
287            descent: m.descent,
288            line_gap: m.leading,
289        }
290    }
291}
292
293impl Default for FontSystem {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299/// Real `TextMeasurer` implementation using Parley font shaping.
300///
301/// Uses `RefCell` interior mutability to satisfy the `&self` trait
302/// while performing real `&mut` Parley operations internally.
303impl TextMeasurer for FontSystem {
304    fn measure(&self, text: &str, font_size: f32) -> TextMetrics {
305        let query = FontQuery::from_size(font_size);
306        FontSystem::shape_text(self, text, &query)
307    }
308
309    fn font_metrics(&self, font_size: f32) -> FontMetrics {
310        let query = FontQuery::from_size(font_size);
311        FontSystem::query_metrics(self, &query)
312    }
313
314    fn shape_text(&self, text: &str, query: &FontQuery) -> TextMetrics {
315        FontSystem::shape_text(self, text, query)
316    }
317
318    fn query_metrics(&self, query: &FontQuery) -> FontMetrics {
319        FontSystem::query_metrics(self, query)
320    }
321
322    fn measure_wrapped(
323        &self,
324        text: &str,
325        font_size: f32,
326        max_width: Option<f32>,
327    ) -> super::measurer::WrappedTextMetrics {
328        if text.is_empty() {
329            let fm = self.query_metrics(&FontQuery::from_size(font_size));
330            return super::measurer::WrappedTextMetrics {
331                width: 0.0,
332                height: fm.ascent + fm.descent,
333            };
334        }
335
336        let mut font_cx = self.font_cx.borrow_mut();
337        let mut layout_cx = self.layout_cx.borrow_mut();
338
339        let query = FontQuery::from_size(font_size);
340        let font_stack = family_to_stack(&query.font_family);
341
342        let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
343        builder.push_default(StyleProperty::FontSize(font_size));
344        builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(400.0)));
345        builder.push_default(StyleProperty::FontStack(font_stack));
346
347        let mut layout: Layout<[u8; 4]> = builder.build(text);
348        // Parley's real line breaker — handles Unicode line break rules,
349        // word boundaries, hyphenation opportunities.
350        layout.break_all_lines(max_width);
351
352        super::measurer::WrappedTextMetrics {
353            width: layout.width(),
354            height: layout.height(),
355        }
356    }
357
358    fn shape_glyphs(&self, text: &str, query: &FontQuery, color: [u8; 4]) -> Vec<ShapedTextRun> {
359        // Delegates to the inherent method on FontSystem.
360        FontSystem::shape_glyphs(self, text, query, color)
361    }
362}
363
364/// A shaped glyph — glyph ID + position, ready for GPU rendering.
365///
366/// Chrome equivalent: entry in `ShapeResult::RunInfo::glyph_data`.
367/// Extracted from Parley's shaping output, owned (no lifetime ties).
368#[derive(Debug, Clone, Copy)]
369pub struct ShapedGlyph {
370    pub id: u32,
371    pub x: f32,
372    pub y: f32,
373}
374
375/// A shaped text run — font + glyphs, ready for the renderer.
376///
377/// Chrome equivalent: `ShapeResult::RunInfo` — one run per font/script change.
378/// Carries the font data (same type as `peniko::Font` — zero conversion to vello).
379#[derive(Debug, Clone)]
380pub struct ShapedTextRun {
381    /// Font file data — `parley::FontData` = `peniko::Font` (same type).
382    pub font: parley::FontData,
383    /// Font size in CSS pixels.
384    pub font_size: f32,
385    /// Pre-positioned glyphs from `HarfRust` shaping.
386    pub glyphs: Vec<ShapedGlyph>,
387    /// Text color as RGBA u8.
388    pub color: [u8; 4],
389    /// X offset of this run within the line.
390    pub offset: f32,
391    /// Baseline Y position.
392    pub baseline: f32,
393    /// Normalized design-space coordinates for variable font axes (e.g., wght, wdth).
394    /// Chrome: `FontVariationSettings` → OpenType `fvar` axis values.
395    /// Without these, vello renders the default instance (Regular/400)
396    /// even if `HarfRust` shaped at the correct weight.
397    pub normalized_coords: Vec<i16>,
398}
399
400impl FontSystem {
401    /// Shape text and extract owned glyph runs for rendering.
402    ///
403    /// Chrome: layout shapes text → stores `ShapeResult` → paint reads it.
404    /// This is the ONLY place text shaping happens. The renderer just
405    /// draws the pre-shaped glyphs — zero font logic in the GPU layer.
406    ///
407    /// Handles automatically via Parley + `HarfRust`:
408    /// - Arabic letter joining (initial/medial/final forms)
409    /// - RTL bidi reordering
410    /// - Ligatures and kerning
411    /// - Font fallback (system fonts via Fontique)
412    /// - CJK, Thai, Devanagari — all complex scripts
413    pub fn shape_glyphs(
414        &self,
415        text: &str,
416        query: &FontQuery,
417        color: [u8; 4],
418    ) -> Vec<ShapedTextRun> {
419        if text.is_empty() {
420            return Vec::new();
421        }
422
423        let mut font_cx = self.font_cx.borrow_mut();
424        let mut layout_cx = self.layout_cx.borrow_mut();
425
426        let font_stack = family_to_stack(&query.font_family);
427        let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
428        builder.push_default(StyleProperty::FontSize(query.font_size));
429        builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(
430            query.font_weight as f32,
431        )));
432        builder.push_default(StyleProperty::FontStyle(query.font_style));
433        builder.push_default(StyleProperty::FontStack(font_stack));
434        builder.push_default(StyleProperty::Brush(color));
435
436        // CSS letter-spacing / word-spacing → Parley shaping.
437        // Chrome: `FontDescription::LetterSpacing()` feeds into HarfBuzz.
438        if query.letter_spacing != 0.0 {
439            builder.push_default(StyleProperty::LetterSpacing(query.letter_spacing));
440        }
441        if query.word_spacing != 0.0 {
442            builder.push_default(StyleProperty::WordSpacing(query.word_spacing));
443        }
444
445        let mut layout: Layout<[u8; 4]> = builder.build(text);
446        layout.break_all_lines(None);
447
448        let mut runs = Vec::new();
449
450        for line in layout.lines() {
451            for item in line.items() {
452                let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else {
453                    continue;
454                };
455
456                let run = glyph_run.run();
457                let font_data = run.font().clone();
458                let run_font_size = run.font_size();
459
460                // Parley's glyph.x is an OFFSET within the glyph cell, NOT
461                // an absolute position. We must accumulate glyph.advance to
462                // compute each glyph's absolute x within the run.
463                // Reference: parley/src/tests/utils/renderer.rs lines 336-339.
464                let mut cursor_x = 0.0f32;
465                let glyphs: Vec<ShapedGlyph> = glyph_run
466                    .glyphs()
467                    .map(|g| {
468                        let shaped = ShapedGlyph {
469                            id: g.id,
470                            x: cursor_x + g.x,
471                            y: g.y,
472                        };
473                        cursor_x += g.advance;
474                        shaped
475                    })
476                    .collect();
477
478                if glyphs.is_empty() {
479                    continue;
480                }
481
482                // Variable font axis values — tells vello which weight/width
483                // instance to render. Without this, vello draws the default
484                // instance (Regular/400) even though HarfRust shaped correctly.
485                // Chrome: reads fvar axis values from ComputedStyle.
486                let normalized_coords: Vec<i16> = run.normalized_coords().to_vec();
487
488                runs.push(ShapedTextRun {
489                    font: font_data,
490                    font_size: run_font_size,
491                    glyphs,
492                    color,
493                    offset: glyph_run.offset(),
494                    baseline: glyph_run.baseline(),
495                    normalized_coords,
496                });
497            }
498        }
499
500        runs
501    }
502}
503
504/// Convert Kozan's `FontFamily` to Parley's `FontStack`.
505///
506/// Chrome: `FontDescription` → Fontique query.
507/// Builds a comma-separated font list string that Parley understands.
508fn family_to_stack(family: &str) -> FontStack<'_> {
509    // Parley's FontStack::Source accepts "font1, font2, generic" format.
510    FontStack::Source(family.into())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn font_system_creates_successfully() {
519        let _fs = FontSystem::new();
520    }
521
522    #[test]
523    fn measure_empty_text() {
524        let fs = FontSystem::new();
525        let query = FontQuery::from_size(16.0);
526        let metrics = fs.shape_text("", &query);
527        assert_eq!(metrics.width, 0.0);
528    }
529
530    #[test]
531    fn measure_text_has_nonzero_width() {
532        let fs = FontSystem::new();
533        let query = FontQuery::from_size(16.0);
534        let metrics = fs.shape_text("Hello", &query);
535        assert!(
536            metrics.width > 0.0,
537            "shaped text should have positive width, got {}",
538            metrics.width
539        );
540    }
541
542    #[test]
543    fn longer_text_is_wider() {
544        let fs = FontSystem::new();
545        let query = FontQuery::from_size(16.0);
546        let short = fs.shape_text("Hi", &query);
547        let long = fs.shape_text("Hello World", &query);
548        assert!(long.width > short.width, "longer text should be wider");
549    }
550
551    #[test]
552    fn larger_font_is_wider() {
553        let fs = FontSystem::new();
554        let small = FontQuery::from_size(12.0);
555        let big = FontQuery::from_size(24.0);
556        let w_small = fs.shape_text("Hello", &small);
557        let w_big = fs.shape_text("Hello", &big);
558        assert!(
559            w_big.width > w_small.width,
560            "larger font should produce wider text"
561        );
562    }
563
564    #[test]
565    fn font_metrics_from_real_font() {
566        let fs = FontSystem::new();
567        let query = FontQuery::from_size(16.0);
568        let metrics = fs.query_metrics(&query);
569
570        // Real font metrics should have positive ascent and descent.
571        assert!(
572            metrics.ascent > 0.0,
573            "ascent should be positive, got {}",
574            metrics.ascent
575        );
576        assert!(
577            metrics.descent > 0.0,
578            "descent should be positive, got {}",
579            metrics.descent
580        );
581        assert!(metrics.line_gap >= 0.0, "line_gap should be non-negative");
582
583        // Ascent should be larger than descent for Latin fonts.
584        assert!(
585            metrics.ascent > metrics.descent,
586            "ascent ({}) should be > descent ({}) for Latin fonts",
587            metrics.ascent,
588            metrics.descent
589        );
590    }
591
592    #[test]
593    fn font_metrics_scale_with_size() {
594        let fs = FontSystem::new();
595        let small = FontQuery::from_size(12.0);
596        let big = FontQuery::from_size(24.0);
597        let m_small = fs.query_metrics(&small);
598        let m_big = fs.query_metrics(&big);
599
600        // Metrics should scale roughly proportionally.
601        assert!(
602            m_big.ascent > m_small.ascent,
603            "bigger font should have larger ascent"
604        );
605        assert!(
606            m_big.descent > m_small.descent,
607            "bigger font should have larger descent"
608        );
609    }
610
611    #[test]
612    fn bold_text_may_differ_in_width() {
613        let fs = FontSystem::new();
614        let family = "sans-serif".to_string();
615        let regular = FontQuery::new(16.0, 400, ParleyFontStyle::Normal, family.clone());
616        let bold = FontQuery::new(16.0, 700, ParleyFontStyle::Normal, family);
617        let w_regular = fs.shape_text("Hello", &regular);
618        let w_bold = fs.shape_text("Hello", &bold);
619
620        // Both should have positive width.
621        assert!(w_regular.width > 0.0);
622        assert!(w_bold.width > 0.0);
623    }
624
625    #[test]
626    fn monospace_characters_equal_width() {
627        let fs = FontSystem::new();
628        let family = "monospace".to_string();
629        let query = FontQuery::new(16.0, 400, ParleyFontStyle::Normal, family);
630        let w_i = fs.shape_text("iiiii", &query);
631        let w_m = fs.shape_text("mmmmm", &query);
632
633        // In a monospace font, all characters should have equal advance.
634        assert!(
635            (w_i.width - w_m.width).abs() < 1.0,
636            "monospace: 'iiiii' ({:.1}) should equal 'mmmmm' ({:.1})",
637            w_i.width,
638            w_m.width
639        );
640    }
641
642    #[test]
643    fn font_query_from_size_defaults() {
644        let query = FontQuery::from_size(20.0);
645        assert_eq!(query.font_size, 20.0);
646        assert_eq!(query.font_weight, 400);
647        assert_eq!(query.font_family, "sans-serif");
648    }
649
650    #[test]
651    fn named_font_family_query() {
652        let family = "Roboto, Arial, sans-serif".to_string();
653        let query = FontQuery::new(16.0, 400, ParleyFontStyle::Normal, family);
654        // Font family is now a plain CSS string.
655        assert!(query.font_family.contains("Roboto"));
656        assert!(query.font_family.contains("sans-serif"));
657    }
658}