Skip to main content

agg_gui/
text.rs

1//! Text rendering — font loading, shaping, and glyph rasterization.
2//!
3//! # Pipeline
4//!
5//! ```text
6//! Font bytes (TTF/OTF)
7//!   │  ttf-parser  →  glyph outline curves
8//!   │  rustybuzz   →  shaped glyph positions & advances
9//!   │
10//! GlyphPathBuilder  →  AGG PathStorage (Bézier curves)
11//!   │
12//! rasterize_fill_path  →  Framebuffer pixels
13//! ```
14//!
15//! # Coordinate system
16//!
17//! TrueType fonts use Y-up coordinates (positive Y = above baseline).
18//! This matches GfxCtx's first-quadrant convention exactly — no Y-flip
19//! is needed at the glyph boundary.
20//!
21//! The baseline is placed at the Y coordinate passed to `GfxCtx::fill_text`.
22//! Ascenders go to higher Y values (up), descenders to lower Y values (down),
23//! which is correct for Y-up rendering.
24
25mod bezier_flat;
26pub use bezier_flat::{shape_and_flatten_text, shape_and_flatten_text_via_agg};
27
28use std::collections::BTreeSet;
29use std::sync::Arc;
30
31use agg_rust::basics::{
32    is_end_poly, is_move_to, is_stop, VertexSource, PATH_CMD_LINE_TO, PATH_FLAGS_NONE,
33};
34use agg_rust::conv_contour::ConvContour;
35use agg_rust::conv_curve::ConvCurve;
36use agg_rust::conv_transform::ConvTransform;
37use agg_rust::path_storage::PathStorage;
38use agg_rust::trans_affine::TransAffine;
39
40/// Metrics describing a single line of shaped text.
41#[derive(Debug, Clone, Copy, Default)]
42pub struct TextMetrics {
43    /// Advance width of the text run in pixels.
44    pub width: f64,
45    /// Distance from baseline to top of tallest ascender, in pixels (positive).
46    pub ascent: f64,
47    /// Distance from baseline to bottom of deepest descender, in pixels (positive).
48    pub descent: f64,
49    /// Recommended line height (ascender + descender + line gap), in pixels.
50    pub line_height: f64,
51}
52
53impl TextMetrics {
54    /// Baseline Y that visually centers this text run in a Y-up box.
55    pub fn centered_baseline_y(&self, height: f64) -> f64 {
56        (height - (self.ascent - self.descent)) * 0.5
57    }
58}
59
60/// A loaded font, ready for shaping and rasterization.
61///
62/// Constructed from raw TTF/OTF bytes via [`Font::from_bytes`]. The data is
63/// reference-counted so fonts can be cheaply shared and saved across frames.
64///
65/// An optional fallback font can be chained via [`Font::with_fallback`]; when
66/// a glyph is missing from the primary font (glyph_id == 0 after shaping),
67/// the fallback is consulted for both the glyph outline and advance width.
68pub struct Font {
69    pub(crate) data: Arc<Vec<u8>>,
70    index: u32,
71    /// Cached at construction to avoid repeated parsing.
72    units_per_em: u16,
73    ascender: i16,
74    descender: i16,
75    line_gap: i16,
76    /// Optional fallback used when the primary font lacks a glyph.
77    pub(crate) fallback: Option<Arc<Font>>,
78}
79
80impl Font {
81    /// Parse a font from raw TTF/OTF bytes.
82    ///
83    /// Returns `Err` if the data is not a valid font.
84    pub fn from_bytes(data: Vec<u8>) -> Result<Self, &'static str> {
85        let face = ttf_parser::Face::parse(&data, 0).map_err(|_| "failed to parse font")?;
86        Ok(Self {
87            units_per_em: face.units_per_em(),
88            ascender: face.ascender(),
89            descender: face.descender(),
90            line_gap: face.line_gap(),
91            data: Arc::new(data),
92            index: 0,
93            fallback: None,
94        })
95    }
96
97    /// Parse a font from a borrowed byte slice (data is copied).
98    pub fn from_slice(data: &[u8]) -> Result<Self, &'static str> {
99        Self::from_bytes(data.to_vec())
100    }
101
102    /// Chain a fallback font consulted when this font lacks a glyph.
103    ///
104    /// Returns `self` so it can be used as a builder method:
105    /// ```ignore
106    /// let font = Font::from_slice(MAIN_BYTES)?.with_fallback(Arc::new(emoji_font));
107    /// ```
108    pub fn with_fallback(mut self, fallback: Arc<Font>) -> Self {
109        self.fallback = Some(fallback);
110        self
111    }
112
113    pub fn units_per_em(&self) -> u16 {
114        self.units_per_em
115    }
116
117    /// Ascender height in pixels at the given font size.
118    pub fn ascender_px(&self, size: f64) -> f64 {
119        self.ascender as f64 * size / self.units_per_em as f64
120    }
121
122    /// Descender depth in pixels at the given font size (positive value).
123    pub fn descender_px(&self, size: f64) -> f64 {
124        self.descender.unsigned_abs() as f64 * size / self.units_per_em as f64
125    }
126
127    /// Recommended line height in pixels at the given font size.
128    pub fn line_height_px(&self, size: f64) -> f64 {
129        let total = (self.ascender - self.descender + self.line_gap) as f64;
130        total * size / self.units_per_em as f64
131    }
132
133    /// Actual vertical extent of a single glyph in pixels (Y-up), relative
134    /// to the baseline. Returns `(y_min, y_max)` where `y_min` is how far
135    /// the glyph dips below the baseline (negative for descenders, near
136    /// zero for upright glyphs) and `y_max` is how far it rises above.
137    ///
138    /// Use this for *visually* centring an icon glyph in a button —
139    /// `ascender_px`/`descender_px` describe the FONT's worst-case
140    /// extents and are too generous for most icon fonts (Font Awesome
141    /// glyphs sit in a sub-rectangle of the design space, so centring
142    /// by the font metric leaves them noticeably high). Returns `None`
143    /// when the glyph has no outline (e.g. a space) or isn't in the
144    /// font.
145    pub fn glyph_visual_bounds(&self, glyph: char, size: f64) -> Option<(f64, f64)> {
146        self.with_ttf_face(|face| {
147            let gid = face.glyph_index(glyph)?;
148            let bbox = face.glyph_bounding_box(gid)?;
149            let scale = size / self.units_per_em as f64;
150            // ttf_parser reports y_min / y_max in font units relative to
151            // baseline, Y-up — convert directly.
152            Some((bbox.y_min as f64 * scale, bbox.y_max as f64 * scale))
153        })
154    }
155
156    /// Run `f` with a `rustybuzz::Face` borrowed from the internal data.
157    ///
158    /// The face has the same lifetime as the closure invocation, so it cannot
159    /// outlive this call. Use this for shaping + outline extraction.
160    pub(crate) fn with_rb_face<F, R>(&self, f: F) -> R
161    where
162        F: FnOnce(&rustybuzz::Face<'_>) -> R,
163    {
164        let face = rustybuzz::Face::from_slice(&self.data, self.index)
165            .expect("font was validated at construction");
166        f(&face)
167    }
168
169    /// Run `f` with a `ttf_parser::Face` borrowed from the internal data.
170    ///
171    /// Used for glyph index lookups (fallback resolution) without full shaping.
172    pub(crate) fn with_ttf_face<F, R>(&self, f: F) -> R
173    where
174        F: FnOnce(&ttf_parser::Face<'_>) -> R,
175    {
176        let face = ttf_parser::Face::parse(&self.data, self.index)
177            .expect("font was validated at construction");
178        f(&face)
179    }
180
181    /// Enumerate every character this font can actually render.
182    ///
183    /// Walks the `cmap` table's Unicode subtables, keeping only code points
184    /// that resolve to a real (non-`.notdef`) glyph, then recurses into the
185    /// fallback chain so the set reflects everything the whole font stack can
186    /// draw — Font Awesome / emoji glyphs that live in a fallback are
187    /// included, matching how the text renderer resolves them at shape time.
188    ///
189    /// Returns a sorted, de-duplicated `BTreeSet<char>`. This backs the Font
190    /// Book demo's glyph grid and its "supports N characters" count, replacing
191    /// the previous hard-coded glyph list.
192    pub fn characters(&self) -> BTreeSet<char> {
193        let mut set = BTreeSet::new();
194        self.collect_characters(&mut set);
195        set
196    }
197
198    /// Recursion terminates because the fallback chain cannot contain cycles:
199    /// `with_fallback` consumes `self` and the chain is immutable after
200    /// construction, so it is always a finite linked list.
201    fn collect_characters(&self, set: &mut BTreeSet<char>) {
202        self.with_ttf_face(|face| {
203            let Some(cmap) = face.tables().cmap else {
204                return;
205            };
206            for subtable in cmap.subtables {
207                // Only Unicode-compatible subtables map `char`s directly; skip
208                // legacy Mac/symbol encodings so we don't invent code points.
209                if !subtable.is_unicode() {
210                    continue;
211                }
212                subtable.codepoints(|cp| {
213                    if let Some(ch) = char::from_u32(cp) {
214                        // `codepoints` may report code points that still map to
215                        // glyph 0; keep only those with a genuine glyph slot.
216                        if face.glyph_index(ch).is_some() {
217                            set.insert(ch);
218                        }
219                    }
220                });
221            }
222        });
223        if let Some(fallback) = &self.fallback {
224            fallback.collect_characters(set);
225        }
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Glyph outline → AGG PathStorage
231// ---------------------------------------------------------------------------
232
233/// Converts ttf-parser outline callbacks into an AGG `PathStorage`.
234///
235/// TTF fonts are Y-up; GfxCtx is Y-up — no axis flip is needed. Each glyph
236/// is translated to its screen position `(ox, oy)` and scaled by `scale`.
237///
238/// The builder can optionally apply two of the `font_settings` typography
239/// transforms directly at outline-construction time:
240/// - `width_scale` — horizontal scale applied to every glyph vertex,
241///   leaving advances untouched (matches AGG `truetype_lcd.cpp` "Width").
242/// - `italic_shear` — horizontal shear as a fraction of Y: `x += y *
243///   italic_shear`.  Matches the C++ "Faux Italic" which applies
244///   `TransAffine::new_skewing(faux_italic/3, 0)`; the `/3` convention
245///   keeps the slider range comparable.
246pub(crate) struct GlyphPathBuilder {
247    pub path: PathStorage,
248    ox: f64,
249    oy: f64,
250    scale: f64,
251    /// Horizontal-only outline scale.  Default `1.0`.
252    width_scale: f64,
253    /// Italic shear factor (x += y * italic_shear).  Default `0.0`.
254    italic_shear: f64,
255    pub has_outline: bool,
256}
257
258impl GlyphPathBuilder {
259    pub fn new(ox: f64, oy: f64, scale: f64) -> Self {
260        Self {
261            path: PathStorage::new(),
262            ox,
263            oy,
264            scale,
265            width_scale: 1.0,
266            italic_shear: 0.0,
267            has_outline: false,
268        }
269    }
270
271    /// Enable Width + Faux-Italic transforms for this glyph.  `width`
272    /// multiplies every outline X after font-scaling; `italic` shears
273    /// horizontally proportional to the vertex's Y above the baseline
274    /// (positive italic slants top-right, matching the AGG reference).
275    #[allow(dead_code)]
276    pub fn with_style(mut self, width: f64, italic: f64) -> Self {
277        self.width_scale = width;
278        self.italic_shear = italic;
279        self
280    }
281
282    /// Pixel-space X of a font-unit input vertex.
283    ///
284    /// `italic_shear` uses the **unsheared** Y (distance above baseline)
285    /// so the shear stays consistent whether or not hinting has snapped
286    /// the glyph origin — the shear depends on glyph geometry, not on
287    /// where the baseline landed on screen.
288    #[inline]
289    fn x(&self, v: f32, y_raw: f32) -> f64 {
290        let base_x = self.ox + v as f64 * self.scale * self.width_scale;
291        let shear = y_raw as f64 * self.scale * self.italic_shear;
292        base_x + shear
293    }
294    #[inline]
295    fn y(&self, v: f32) -> f64 {
296        self.oy + v as f64 * self.scale
297    }
298}
299
300impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
301    fn move_to(&mut self, x: f32, y: f32) {
302        self.path.move_to(self.x(x, y), self.y(y));
303        self.has_outline = true;
304    }
305    fn line_to(&mut self, x: f32, y: f32) {
306        self.path.line_to(self.x(x, y), self.y(y));
307    }
308    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
309        self.path
310            .curve3(self.x(x1, y1), self.y(y1), self.x(x, y), self.y(y));
311    }
312    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
313        self.path.curve4(
314            self.x(x1, y1),
315            self.y(y1),
316            self.x(x2, y2),
317            self.y(y2),
318            self.x(x, y),
319            self.y(y),
320        );
321    }
322    fn close(&mut self) {
323        self.path.close_polygon(PATH_FLAGS_NONE);
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Shaping helper — shapes text and returns per-glyph paths
329// ---------------------------------------------------------------------------
330
331/// Shape `text` with `font` at `size` pixels, starting at screen position
332/// `(x, y)` (baseline-left, Y-up). Returns one `PathStorage` per glyph that
333/// has an outline (spaces and control chars yield no path).
334///
335/// Walks the fallback font chain via [`shape_glyphs`], so Font Awesome /
336/// emoji glyphs not present in the primary font are still resolved and
337/// rasterized using the font they live in.
338/// Apply the "faux weight" outline offset to a glyph path.
339///
340/// Port of the AGG C++ `truetype_lcd.cpp` technique:
341/// ```text
342///   curves -> scale(1, 100) -> ConvContour(width=w) -> scale(1, 1/100)
343/// ```
344/// The Y-zoom makes the contour offset act primarily horizontally —
345/// vertical stems pick up the full `w` of extra thickness while
346/// horizontal strokes stay thin, which is what you want for bold-like
347/// weight.  Returns a fresh `PathStorage` containing the offset outline
348/// flattened to straight segments (ConvCurve has already subdivided the
349/// Béziers by the time ConvContour sees them).
350///
351/// `weight_px` is the raw contour width — matches the agg-rust
352/// `contour.set_width(-faux_weight * height / 15.0)` convention; pass
353/// the already-sign-flipped, already-scaled value.
354fn apply_faux_weight(path: PathStorage, weight_px: f64) -> PathStorage {
355    if weight_px.abs() < 1e-4 {
356        return path;
357    }
358    let mut src = path;
359    let mut curves = ConvCurve::new(&mut src);
360    let zoom_in = TransAffine::new_scaling(1.0, 100.0);
361    let mut zoomed_in = ConvTransform::new(&mut curves, zoom_in);
362    let mut contour = ConvContour::new(&mut zoomed_in);
363    contour.set_auto_detect_orientation(false);
364    contour.set_width(weight_px);
365    let zoom_out = TransAffine::new_scaling(1.0, 1.0 / 100.0);
366    let mut out = ConvTransform::new(&mut contour, zoom_out);
367
368    // Flatten the VertexSource chain into a fresh PathStorage.  ConvCurve
369    // has converted all Béziers to line-segments by the time we get here,
370    // so the output is only `move_to` / `line_to` / `end_poly` commands.
371    let mut result = PathStorage::new();
372    out.rewind(0);
373    loop {
374        let (mut vx, mut vy) = (0.0_f64, 0.0_f64);
375        let cmd = out.vertex(&mut vx, &mut vy);
376        if is_stop(cmd) {
377            break;
378        }
379        if is_move_to(cmd) {
380            result.move_to(vx, vy);
381        } else if cmd == PATH_CMD_LINE_TO {
382            result.line_to(vx, vy);
383        } else if is_end_poly(cmd) {
384            result.close_polygon(PATH_FLAGS_NONE);
385        }
386    }
387    result
388}
389
390pub(crate) fn shape_text(
391    font: &Font,
392    text: &str,
393    size: f64,
394    x: f64,
395    y: f64,
396) -> (Vec<PathStorage>, f64) {
397    let shaped = shape_glyphs(font, text, size);
398
399    // Pull the current typography-style globals ONCE per call.  The
400    // text render path consults them here so any widget (including the
401    // System window's typography controls / Sample Text tab) that writes
402    // through `font_settings` affects the next paint.
403    //
404    // - `width_scale`  → horizontal outline scale per glyph
405    // - `italic_shear` → faux-italic (0..1 range maps to /3 in the
406    //   outline shear, matching the agg-rust reference)
407    // - `hint_y`       → snap the glyph-origin Y to whole pixels
408    //                    (Y-axis-only hinting, matches `(y+0.5).floor()`)
409    // - `interval_px`  → extra pen advance in pixels per glyph,
410    //                    proportional to em size
411    let width_scale = crate::font_settings::current_width();
412    let italic_shear = crate::font_settings::current_faux_italic() / 3.0;
413    let hint_y = crate::font_settings::hinting_enabled();
414    let interval_em = crate::font_settings::current_interval();
415    let interval_px = interval_em * size;
416    // Faux weight — negative sign matches agg-rust: +faux_weight
417    // thickens (contour width negative expands outward for a CCW
418    // outline), -faux_weight thins.  The `/15.0` denominator reproduces
419    // the reference demo's slider-to-pixels conversion.
420    let faux_weight = crate::font_settings::current_faux_weight();
421    let weight_px = if faux_weight.abs() < 0.05 {
422        0.0 // dead zone near 0, matches reference — avoids zero-width noise
423    } else {
424        -faux_weight * size / 15.0
425    };
426
427    let mut paths = Vec::new();
428    let mut pen_x = x;
429    let mut total_advance = 0.0;
430
431    for g in &shaped {
432        let gx = pen_x + g.x_offset;
433        let gy_unsnapped = y + g.y_offset;
434        // Hinting: snap the glyph origin's Y to the integer pixel
435        // nearest the logical baseline.  Matches the AGG C++
436        // `(y + 0.5).floor()` convention — simple, cheap, preserves
437        // horizontal subpixel positioning.
438        let gy = if hint_y {
439            (gy_unsnapped + 0.5).floor()
440        } else {
441            gy_unsnapped
442        };
443        // glyph_id indexes into whichever font resolved the code point.
444        let render_font = g.fallback_font.as_deref().unwrap_or(font);
445        let scale = size / render_font.units_per_em() as f64;
446
447        let mut builder =
448            GlyphPathBuilder::new(gx, gy, scale).with_style(width_scale, italic_shear);
449        let has_outline = render_font.with_ttf_face(|face| {
450            face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut builder)
451                .is_some()
452        });
453        if has_outline && builder.has_outline {
454            // Apply faux weight (zero-cost pass-through at weight_px == 0).
455            let path = apply_faux_weight(builder.path, weight_px);
456            paths.push(path);
457        }
458
459        // Interval adds a fixed pen-advance delta per glyph, in pixels.
460        // Applied after the font-native advance so kerning (already
461        // baked into x_advance by rustybuzz) is preserved — the extra
462        // spacing just piles on top.
463        let advance = g.x_advance + interval_px;
464        pen_x += advance;
465        total_advance += advance;
466    }
467    (paths, total_advance)
468}
469
470// ---------------------------------------------------------------------------
471// Glyph cache support — shaped glyph info + single-glyph outline extraction
472// ---------------------------------------------------------------------------
473
474/// Position and identity of one shaped glyph, without any rendering.
475///
476/// Returned by [`shape_glyphs`].  All distances are in **pixels** at the
477/// requested font size.
478///
479/// When `fallback_font` is `Some`, the glyph was resolved from the fallback
480/// font rather than the primary.  Callers must use that font for outline
481/// extraction and glyph cache lookups, since `glyph_id` is an index into
482/// the fallback's glyph table, not the primary's.
483#[derive(Clone)]
484pub struct ShapedGlyph {
485    /// Index into the font's glyph table (or fallback's if `fallback_font` is Some).
486    pub glyph_id: u16,
487    /// How far to advance the pen after this glyph.
488    pub x_advance: f64,
489    /// Horizontal offset from the pen position to this glyph's origin.
490    pub x_offset: f64,
491    /// Vertical offset from the baseline to this glyph's origin.
492    pub y_offset: f64,
493    /// Set when this glyph was resolved via the fallback font.
494    /// Use this font instead of the primary for cache lookups and rendering.
495    pub fallback_font: Option<Arc<Font>>,
496}
497
498/// Shape `text` and return per-glyph positioning info, with **no** outline
499/// extraction or tessellation.
500///
501/// Results are cached in a thread-local `HashMap` keyed by
502/// `(font_data_ptr, text, size_bits)`.  The GL `fill_text()` path calls this
503/// on every paint; caching it eliminates the per-frame `rustybuzz::shape()`
504/// cost for static labels and sidebar items.
505///
506/// Use the result together with [`flatten_glyph_at_origin`] and a
507/// [`GlyphCache`] to avoid re-tessellating glyphs every frame.
508pub fn shape_glyphs(font: &Font, text: &str, size: f64) -> Vec<ShapedGlyph> {
509    let font_key = Arc::as_ptr(&font.data) as usize;
510    let size_key = size.to_bits();
511
512    SHAPE_CACHE.with(|cache| {
513        {
514            let c = cache.borrow();
515            if let Some(cached) = c.get(&(font_key, text.to_owned(), size_key)) {
516                return cached.clone();
517            }
518        }
519
520        // Cache miss — shape the text.
521        let scale = size / font.units_per_em() as f64;
522        let glyphs = font.with_rb_face(|face| {
523            let mut buffer = rustybuzz::UnicodeBuffer::new();
524            buffer.push_str(text);
525            let output = rustybuzz::shape(face, &[], buffer);
526            output
527                .glyph_infos()
528                .iter()
529                .zip(output.glyph_positions().iter())
530                .map(|(info, pos)| {
531                    let glyph_id = info.glyph_id as u16;
532                    let x_advance = pos.x_advance as f64 * scale;
533                    let x_offset = pos.x_offset as f64 * scale;
534                    let y_offset = pos.y_offset as f64 * scale;
535
536                    // glyph_id == 0 means the primary font has no glyph for
537                    // this code point.  Walk the fallback chain until a font
538                    // with a matching glyph is found.
539                    if glyph_id == 0 {
540                        let byte_off = info.cluster as usize;
541                        if let Some(ch) = text.get(byte_off..).and_then(|s| s.chars().next()) {
542                            let mut cur_fb = font.fallback.as_ref();
543                            while let Some(fb) = cur_fb {
544                                let fb_id = fb
545                                    .with_ttf_face(|f| f.glyph_index(ch).map(|g| g.0).unwrap_or(0));
546                                if fb_id != 0 {
547                                    let fb_scale = size / fb.units_per_em() as f64;
548                                    let fb_adv = fb.with_ttf_face(|f| {
549                                        f.glyph_hor_advance(ttf_parser::GlyphId(fb_id))
550                                            .map(|a| a as f64 * fb_scale)
551                                            .unwrap_or(0.0)
552                                    });
553                                    return ShapedGlyph {
554                                        glyph_id: fb_id,
555                                        x_advance: fb_adv,
556                                        x_offset,
557                                        y_offset,
558                                        fallback_font: Some(Arc::clone(fb)),
559                                    };
560                                }
561                                cur_fb = fb.fallback.as_ref();
562                            }
563                        }
564                    }
565
566                    ShapedGlyph {
567                        glyph_id,
568                        x_advance,
569                        x_offset,
570                        y_offset,
571                        fallback_font: None,
572                    }
573                })
574                .collect::<Vec<_>>()
575        });
576
577        cache
578            .borrow_mut()
579            .insert((font_key, text.to_owned(), size_key), glyphs.clone());
580        glyphs
581    })
582}
583
584/// Flatten a single glyph's outline using AGG `ConvCurve`, with the glyph
585/// origin at **(0, 0)** in pixel space.
586///
587/// Returns one `Vec<[f32;2]>` per closed contour, ready to pass to
588/// `tessellate_fill`.  Returns `None` for glyphs without an outline (space,
589/// tab, or glyph IDs that reference nothing).
590///
591/// The vertices are in **glyph-local pixels**: the glyph baseline is y=0 and
592/// the leftmost bearing is x=0 (approximately).  To place the glyph on screen
593/// at `(gx, gy)`, translate every vertex by that amount before tessellating or
594/// uploading to the GPU.
595pub fn flatten_glyph_at_origin(
596    font: &Font,
597    glyph_id: u16,
598    size: f64,
599) -> Option<Vec<Vec<[f32; 2]>>> {
600    let scale = size / font.units_per_em() as f64;
601    font.with_rb_face(|face| {
602        let gid = ttf_parser::GlyphId(glyph_id);
603        let mut builder = GlyphPathBuilder::new(0.0, 0.0, scale);
604        let has_outline = face.outline_glyph(gid, &mut builder).is_some();
605        if !has_outline || !builder.has_outline {
606            return None;
607        }
608
609        let mut curves = ConvCurve::new(builder.path);
610        curves.rewind(0);
611
612        let mut contours: Vec<Vec<[f32; 2]>> = Vec::new();
613        let mut current: Vec<[f32; 2]> = Vec::new();
614
615        loop {
616            let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
617            let cmd = curves.vertex(&mut cx, &mut cy);
618            if is_stop(cmd) {
619                break;
620            }
621            if is_move_to(cmd) {
622                if current.len() >= 3 {
623                    contours.push(std::mem::take(&mut current));
624                } else {
625                    current.clear();
626                }
627                current.push([cx as f32, cy as f32]);
628            } else if cmd == PATH_CMD_LINE_TO {
629                current.push([cx as f32, cy as f32]);
630            } else if is_end_poly(cmd) {
631                if current.len() >= 3 {
632                    contours.push(std::mem::take(&mut current));
633                } else {
634                    current.clear();
635                }
636            }
637        }
638        if current.len() >= 3 {
639            contours.push(current);
640        }
641
642        if contours.is_empty() {
643            None
644        } else {
645            Some(contours)
646        }
647    })
648}
649
650/// Measure full text metrics (width, ascent, descent, line_height).
651///
652/// Useful for external rendering backends (e.g. `GlGfxCtx`) that need
653/// text metrics without the `GfxCtx` wrapper.
654pub fn measure_text_metrics(font: &Font, text: &str, size: f64) -> TextMetrics {
655    TextMetrics {
656        width: measure_advance(font, text, size),
657        ascent: font.ascender_px(size),
658        descent: font.descender_px(size),
659        line_height: font.line_height_px(size),
660    }
661}
662
663// ---------------------------------------------------------------------------
664// Global shape/measurement cache — survives across Label instance recreation
665// ---------------------------------------------------------------------------
666//
667// TreeView and other widgets rebuild their Label children every layout() call,
668// so a per-Label cache doesn't help: each new instance starts cold. This
669// thread-local HashMap caches rustybuzz::shape() results for the lifetime of
670// the process, keyed by (font data pointer, text, size bits). The pointer is
671// stable as long as any Arc<Vec<u8>> clone exists (which is always true while
672// the Font is alive).
673
674use std::cell::RefCell;
675use std::collections::HashMap;
676
677thread_local! {
678    /// Caches the full rustybuzz shaping output (per-glyph IDs + advances).
679    /// Used by shape_glyphs() so fill_text() avoids re-shaping every frame.
680    /// Also serves as the measurement cache — measure_advance() reads it too.
681    static SHAPE_CACHE: RefCell<HashMap<(usize, String, u64), Vec<ShapedGlyph>>> =
682        RefCell::new(HashMap::new());
683}
684
685/// Measure text advance width without rasterizing.
686///
687/// Delegates to [`shape_glyphs`] so that fallback-font advances are included
688/// in the measurement.  Results are cached via the shared shape cache.
689///
690/// The measurement matches what `shape_text` will actually pen at paint
691/// time — so `interval` (extra letter-spacing) is added here too.  Width
692/// and italic are ignored: width only affects per-glyph outline scale,
693/// not advances, and italic shears the outline which doesn't change the
694/// horizontal extent of the pen walk.
695pub fn measure_advance(font: &Font, text: &str, size: f64) -> f64 {
696    let shaped = shape_glyphs(font, text, size);
697    let interval_px = crate::font_settings::current_interval() * size;
698    shaped.iter().map(|g| g.x_advance + interval_px).sum()
699}
700
701#[cfg(test)]
702mod tests;