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