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::{is_end_poly, is_move_to, is_stop, PATH_CMD_LINE_TO, PATH_FLAGS_NONE, VertexSource};
31use agg_rust::conv_contour::ConvContour;
32use agg_rust::conv_curve::ConvCurve;
33use agg_rust::conv_transform::ConvTransform;
34use agg_rust::path_storage::PathStorage;
35use agg_rust::trans_affine::TransAffine;
36
37/// Metrics describing a single line of shaped text.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct TextMetrics {
40    /// Advance width of the text run in pixels.
41    pub width: f64,
42    /// Distance from baseline to top of tallest ascender, in pixels (positive).
43    pub ascent: f64,
44    /// Distance from baseline to bottom of deepest descender, in pixels (positive).
45    pub descent: f64,
46    /// Recommended line height (ascender + descender + line gap), in pixels.
47    pub line_height: f64,
48}
49
50/// A loaded font, ready for shaping and rasterization.
51///
52/// Constructed from raw TTF/OTF bytes via [`Font::from_bytes`]. The data is
53/// reference-counted so fonts can be cheaply shared and saved across frames.
54///
55/// An optional fallback font can be chained via [`Font::with_fallback`]; when
56/// a glyph is missing from the primary font (glyph_id == 0 after shaping),
57/// the fallback is consulted for both the glyph outline and advance width.
58pub struct Font {
59    pub(crate) data: Arc<Vec<u8>>,
60    index: u32,
61    /// Cached at construction to avoid repeated parsing.
62    units_per_em: u16,
63    ascender: i16,
64    descender: i16,
65    line_gap: i16,
66    /// Optional fallback used when the primary font lacks a glyph.
67    pub(crate) fallback: Option<Arc<Font>>,
68}
69
70impl Font {
71    /// Parse a font from raw TTF/OTF bytes.
72    ///
73    /// Returns `Err` if the data is not a valid font.
74    pub fn from_bytes(data: Vec<u8>) -> Result<Self, &'static str> {
75        let face = ttf_parser::Face::parse(&data, 0).map_err(|_| "failed to parse font")?;
76        Ok(Self {
77            units_per_em: face.units_per_em(),
78            ascender: face.ascender(),
79            descender: face.descender(),
80            line_gap: face.line_gap(),
81            data: Arc::new(data),
82            index: 0,
83            fallback: None,
84        })
85    }
86
87    /// Parse a font from a borrowed byte slice (data is copied).
88    pub fn from_slice(data: &[u8]) -> Result<Self, &'static str> {
89        Self::from_bytes(data.to_vec())
90    }
91
92    /// Chain a fallback font consulted when this font lacks a glyph.
93    ///
94    /// Returns `self` so it can be used as a builder method:
95    /// ```ignore
96    /// let font = Font::from_slice(MAIN_BYTES)?.with_fallback(Arc::new(emoji_font));
97    /// ```
98    pub fn with_fallback(mut self, fallback: Arc<Font>) -> Self {
99        self.fallback = Some(fallback);
100        self
101    }
102
103    pub fn units_per_em(&self) -> u16 {
104        self.units_per_em
105    }
106
107    /// Ascender height in pixels at the given font size.
108    pub fn ascender_px(&self, size: f64) -> f64 {
109        self.ascender as f64 * size / self.units_per_em as f64
110    }
111
112    /// Descender depth in pixels at the given font size (positive value).
113    pub fn descender_px(&self, size: f64) -> f64 {
114        self.descender.unsigned_abs() as f64 * size / self.units_per_em as f64
115    }
116
117    /// Recommended line height in pixels at the given font size.
118    pub fn line_height_px(&self, size: f64) -> f64 {
119        let total = (self.ascender - self.descender + self.line_gap) as f64;
120        total * size / self.units_per_em as f64
121    }
122
123    /// Run `f` with a `rustybuzz::Face` borrowed from the internal data.
124    ///
125    /// The face has the same lifetime as the closure invocation, so it cannot
126    /// outlive this call. Use this for shaping + outline extraction.
127    pub(crate) fn with_rb_face<F, R>(&self, f: F) -> R
128    where
129        F: FnOnce(&rustybuzz::Face<'_>) -> R,
130    {
131        let face = rustybuzz::Face::from_slice(&self.data, self.index)
132            .expect("font was validated at construction");
133        f(&face)
134    }
135
136    /// Run `f` with a `ttf_parser::Face` borrowed from the internal data.
137    ///
138    /// Used for glyph index lookups (fallback resolution) without full shaping.
139    pub(crate) fn with_ttf_face<F, R>(&self, f: F) -> R
140    where
141        F: FnOnce(&ttf_parser::Face<'_>) -> R,
142    {
143        let face = ttf_parser::Face::parse(&self.data, self.index)
144            .expect("font was validated at construction");
145        f(&face)
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Glyph outline → AGG PathStorage
151// ---------------------------------------------------------------------------
152
153/// Converts ttf-parser outline callbacks into an AGG `PathStorage`.
154///
155/// TTF fonts are Y-up; GfxCtx is Y-up — no axis flip is needed. Each glyph
156/// is translated to its screen position `(ox, oy)` and scaled by `scale`.
157///
158/// The builder can optionally apply two of the `font_settings` typography
159/// transforms directly at outline-construction time:
160/// - `width_scale` — horizontal scale applied to every glyph vertex,
161///   leaving advances untouched (matches AGG `truetype_lcd.cpp` "Width").
162/// - `italic_shear` — horizontal shear as a fraction of Y: `x += y *
163///   italic_shear`.  Matches the C++ "Faux Italic" which applies
164///   `TransAffine::new_skewing(faux_italic/3, 0)`; the `/3` convention
165///   keeps the slider range comparable.
166pub(crate) struct GlyphPathBuilder {
167    pub path: PathStorage,
168    ox: f64,
169    oy: f64,
170    scale: f64,
171    /// Horizontal-only outline scale.  Default `1.0`.
172    width_scale: f64,
173    /// Italic shear factor (x += y * italic_shear).  Default `0.0`.
174    italic_shear: f64,
175    pub has_outline: bool,
176}
177
178impl GlyphPathBuilder {
179    pub fn new(ox: f64, oy: f64, scale: f64) -> Self {
180        Self {
181            path: PathStorage::new(),
182            ox,
183            oy,
184            scale,
185            width_scale: 1.0,
186            italic_shear: 0.0,
187            has_outline: false,
188        }
189    }
190
191    /// Enable Width + Faux-Italic transforms for this glyph.  `width`
192    /// multiplies every outline X after font-scaling; `italic` shears
193    /// horizontally proportional to the vertex's Y above the baseline
194    /// (positive italic slants top-right, matching the AGG reference).
195    #[allow(dead_code)]
196    pub fn with_style(mut self, width: f64, italic: f64) -> Self {
197        self.width_scale  = width;
198        self.italic_shear = italic;
199        self
200    }
201
202    /// Pixel-space X of a font-unit input vertex.
203    ///
204    /// `italic_shear` uses the **unsheared** Y (distance above baseline)
205    /// so the shear stays consistent whether or not hinting has snapped
206    /// the glyph origin — the shear depends on glyph geometry, not on
207    /// where the baseline landed on screen.
208    #[inline]
209    fn x(&self, v: f32, y_raw: f32) -> f64 {
210        let base_x = self.ox + v as f64 * self.scale * self.width_scale;
211        let shear  = y_raw as f64 * self.scale * self.italic_shear;
212        base_x + shear
213    }
214    #[inline]
215    fn y(&self, v: f32) -> f64 { self.oy + v as f64 * self.scale }
216}
217
218impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
219    fn move_to(&mut self, x: f32, y: f32) {
220        self.path.move_to(self.x(x, y), self.y(y));
221        self.has_outline = true;
222    }
223    fn line_to(&mut self, x: f32, y: f32) {
224        self.path.line_to(self.x(x, y), self.y(y));
225    }
226    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
227        self.path.curve3(self.x(x1, y1), self.y(y1), self.x(x, y), self.y(y));
228    }
229    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
230        self.path.curve4(
231            self.x(x1, y1), self.y(y1),
232            self.x(x2, y2), self.y(y2),
233            self.x(x,  y),  self.y(y),
234        );
235    }
236    fn close(&mut self) {
237        self.path.close_polygon(PATH_FLAGS_NONE);
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Shaping helper — shapes text and returns per-glyph paths
243// ---------------------------------------------------------------------------
244
245/// Shape `text` with `font` at `size` pixels, starting at screen position
246/// `(x, y)` (baseline-left, Y-up). Returns one `PathStorage` per glyph that
247/// has an outline (spaces and control chars yield no path).
248///
249/// Walks the fallback font chain via [`shape_glyphs`], so Font Awesome /
250/// emoji glyphs not present in the primary font are still resolved and
251/// rasterized using the font they live in.
252/// Apply the "faux weight" outline offset to a glyph path.
253///
254/// Port of the AGG C++ `truetype_lcd.cpp` technique:
255/// ```text
256///   curves -> scale(1, 100) -> ConvContour(width=w) -> scale(1, 1/100)
257/// ```
258/// The Y-zoom makes the contour offset act primarily horizontally —
259/// vertical stems pick up the full `w` of extra thickness while
260/// horizontal strokes stay thin, which is what you want for bold-like
261/// weight.  Returns a fresh `PathStorage` containing the offset outline
262/// flattened to straight segments (ConvCurve has already subdivided the
263/// Béziers by the time ConvContour sees them).
264///
265/// `weight_px` is the raw contour width — matches the agg-rust
266/// `contour.set_width(-faux_weight * height / 15.0)` convention; pass
267/// the already-sign-flipped, already-scaled value.
268fn apply_faux_weight(path: PathStorage, weight_px: f64) -> PathStorage {
269    if weight_px.abs() < 1e-4 { return path; }
270    let mut src = path;
271    let mut curves    = ConvCurve::new(&mut src);
272    let zoom_in       = TransAffine::new_scaling(1.0, 100.0);
273    let mut zoomed_in = ConvTransform::new(&mut curves, zoom_in);
274    let mut contour   = ConvContour::new(&mut zoomed_in);
275    contour.set_auto_detect_orientation(false);
276    contour.set_width(weight_px);
277    let zoom_out      = TransAffine::new_scaling(1.0, 1.0 / 100.0);
278    let mut out       = ConvTransform::new(&mut contour, zoom_out);
279
280    // Flatten the VertexSource chain into a fresh PathStorage.  ConvCurve
281    // has converted all Béziers to line-segments by the time we get here,
282    // so the output is only `move_to` / `line_to` / `end_poly` commands.
283    let mut result = PathStorage::new();
284    out.rewind(0);
285    loop {
286        let (mut vx, mut vy) = (0.0_f64, 0.0_f64);
287        let cmd = out.vertex(&mut vx, &mut vy);
288        if is_stop(cmd) { break; }
289        if is_move_to(cmd) {
290            result.move_to(vx, vy);
291        } else if cmd == PATH_CMD_LINE_TO {
292            result.line_to(vx, vy);
293        } else if is_end_poly(cmd) {
294            result.close_polygon(PATH_FLAGS_NONE);
295        }
296    }
297    result
298}
299
300pub(crate) fn shape_text(
301    font: &Font,
302    text: &str,
303    size: f64,
304    x: f64,
305    y: f64,
306) -> (Vec<PathStorage>, f64) {
307    let shaped = shape_glyphs(font, text, size);
308
309    // Pull the current typography-style globals ONCE per call.  The
310    // text render path consults them here so any widget (including the
311    // LCD Subpixel demo's sliders) that writes through `font_settings`
312    // affects the next paint.
313    //
314    // - `width_scale`  → horizontal outline scale per glyph
315    // - `italic_shear` → faux-italic (0..1 range maps to /3 in the
316    //   outline shear, matching the agg-rust reference)
317    // - `hint_y`       → snap the glyph-origin Y to whole pixels
318    //                    (Y-axis-only hinting, matches `(y+0.5).floor()`)
319    // - `interval_px`  → extra pen advance in pixels per glyph,
320    //                    proportional to em size
321    let width_scale  = crate::font_settings::current_width();
322    let italic_shear = crate::font_settings::current_faux_italic() / 3.0;
323    let hint_y       = crate::font_settings::hinting_enabled();
324    let interval_em  = crate::font_settings::current_interval();
325    let interval_px  = interval_em * size;
326    // Faux weight — negative sign matches agg-rust: +faux_weight
327    // thickens (contour width negative expands outward for a CCW
328    // outline), -faux_weight thins.  The `/15.0` denominator reproduces
329    // the reference demo's slider-to-pixels conversion.
330    let faux_weight  = crate::font_settings::current_faux_weight();
331    let weight_px    = if faux_weight.abs() < 0.05 {
332        0.0  // dead zone near 0, matches reference — avoids zero-width noise
333    } else {
334        -faux_weight * size / 15.0
335    };
336
337    let mut paths = Vec::new();
338    let mut pen_x = x;
339    let mut total_advance = 0.0;
340
341    for g in &shaped {
342        let gx = pen_x + g.x_offset;
343        let gy_unsnapped = y + g.y_offset;
344        // Hinting: snap the glyph origin's Y to the integer pixel
345        // nearest the logical baseline.  Matches the AGG C++
346        // `(y + 0.5).floor()` convention — simple, cheap, preserves
347        // horizontal subpixel positioning.
348        let gy = if hint_y {
349            (gy_unsnapped + 0.5).floor()
350        } else {
351            gy_unsnapped
352        };
353        // glyph_id indexes into whichever font resolved the code point.
354        let render_font = g.fallback_font.as_deref().unwrap_or(font);
355        let scale = size / render_font.units_per_em() as f64;
356
357        let mut builder = GlyphPathBuilder::new(gx, gy, scale)
358            .with_style(width_scale, italic_shear);
359        let has_outline = render_font.with_ttf_face(|face| {
360            face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut builder)
361                .is_some()
362        });
363        if has_outline && builder.has_outline {
364            // Apply faux weight (zero-cost pass-through at weight_px == 0).
365            let path = apply_faux_weight(builder.path, weight_px);
366            paths.push(path);
367        }
368
369        // Interval adds a fixed pen-advance delta per glyph, in pixels.
370        // Applied after the font-native advance so kerning (already
371        // baked into x_advance by rustybuzz) is preserved — the extra
372        // spacing just piles on top.
373        let advance = g.x_advance + interval_px;
374        pen_x += advance;
375        total_advance += advance;
376    }
377    (paths, total_advance)
378}
379
380// ---------------------------------------------------------------------------
381// Glyph cache support — shaped glyph info + single-glyph outline extraction
382// ---------------------------------------------------------------------------
383
384/// Position and identity of one shaped glyph, without any rendering.
385///
386/// Returned by [`shape_glyphs`].  All distances are in **pixels** at the
387/// requested font size.
388///
389/// When `fallback_font` is `Some`, the glyph was resolved from the fallback
390/// font rather than the primary.  Callers must use that font for outline
391/// extraction and glyph cache lookups, since `glyph_id` is an index into
392/// the fallback's glyph table, not the primary's.
393#[derive(Clone)]
394pub struct ShapedGlyph {
395    /// Index into the font's glyph table (or fallback's if `fallback_font` is Some).
396    pub glyph_id: u16,
397    /// How far to advance the pen after this glyph.
398    pub x_advance: f64,
399    /// Horizontal offset from the pen position to this glyph's origin.
400    pub x_offset: f64,
401    /// Vertical offset from the baseline to this glyph's origin.
402    pub y_offset: f64,
403    /// Set when this glyph was resolved via the fallback font.
404    /// Use this font instead of the primary for cache lookups and rendering.
405    pub fallback_font: Option<Arc<Font>>,
406}
407
408/// Shape `text` and return per-glyph positioning info, with **no** outline
409/// extraction or tessellation.
410///
411/// Results are cached in a thread-local `HashMap` keyed by
412/// `(font_data_ptr, text, size_bits)`.  The GL `fill_text()` path calls this
413/// on every paint; caching it eliminates the per-frame `rustybuzz::shape()`
414/// cost for static labels and sidebar items.
415///
416/// Use the result together with [`flatten_glyph_at_origin`] and a
417/// [`GlyphCache`] to avoid re-tessellating glyphs every frame.
418pub fn shape_glyphs(font: &Font, text: &str, size: f64) -> Vec<ShapedGlyph> {
419    let font_key = Arc::as_ptr(&font.data) as usize;
420    let size_key = size.to_bits();
421
422    SHAPE_CACHE.with(|cache| {
423        {
424            let c = cache.borrow();
425            if let Some(cached) = c.get(&(font_key, text.to_owned(), size_key)) {
426                return cached.clone();
427            }
428        }
429
430        // Cache miss — shape the text.
431        let scale = size / font.units_per_em() as f64;
432        let glyphs = font.with_rb_face(|face| {
433            let mut buffer = rustybuzz::UnicodeBuffer::new();
434            buffer.push_str(text);
435            let output = rustybuzz::shape(face, &[], buffer);
436            output
437                .glyph_infos()
438                .iter()
439                .zip(output.glyph_positions().iter())
440                .map(|(info, pos)| {
441                    let glyph_id  = info.glyph_id as u16;
442                    let x_advance = pos.x_advance as f64 * scale;
443                    let x_offset  = pos.x_offset  as f64 * scale;
444                    let y_offset  = pos.y_offset  as f64 * scale;
445
446                    // glyph_id == 0 means the primary font has no glyph for
447                    // this code point.  Walk the fallback chain until a font
448                    // with a matching glyph is found.
449                    if glyph_id == 0 {
450                        let byte_off = info.cluster as usize;
451                        if let Some(ch) = text.get(byte_off..).and_then(|s| s.chars().next()) {
452                            let mut cur_fb = font.fallback.as_ref();
453                            while let Some(fb) = cur_fb {
454                                let fb_id = fb.with_ttf_face(|f| {
455                                    f.glyph_index(ch).map(|g| g.0).unwrap_or(0)
456                                });
457                                if fb_id != 0 {
458                                    let fb_scale = size / fb.units_per_em() as f64;
459                                    let fb_adv = fb.with_ttf_face(|f| {
460                                        f.glyph_hor_advance(ttf_parser::GlyphId(fb_id))
461                                            .map(|a| a as f64 * fb_scale)
462                                            .unwrap_or(0.0)
463                                    });
464                                    return ShapedGlyph {
465                                        glyph_id: fb_id,
466                                        x_advance: fb_adv,
467                                        x_offset,
468                                        y_offset,
469                                        fallback_font: Some(Arc::clone(fb)),
470                                    };
471                                }
472                                cur_fb = fb.fallback.as_ref();
473                            }
474                        }
475                    }
476
477                    ShapedGlyph { glyph_id, x_advance, x_offset, y_offset,
478                                  fallback_font: None }
479                })
480                .collect::<Vec<_>>()
481        });
482
483        cache.borrow_mut().insert((font_key, text.to_owned(), size_key), glyphs.clone());
484        glyphs
485    })
486}
487
488/// Flatten a single glyph's outline using AGG `ConvCurve`, with the glyph
489/// origin at **(0, 0)** in pixel space.
490///
491/// Returns one `Vec<[f32;2]>` per closed contour, ready to pass to
492/// `tessellate_fill`.  Returns `None` for glyphs without an outline (space,
493/// tab, or glyph IDs that reference nothing).
494///
495/// The vertices are in **glyph-local pixels**: the glyph baseline is y=0 and
496/// the leftmost bearing is x=0 (approximately).  To place the glyph on screen
497/// at `(gx, gy)`, translate every vertex by that amount before tessellating or
498/// uploading to the GPU.
499pub fn flatten_glyph_at_origin(font: &Font, glyph_id: u16, size: f64)
500    -> Option<Vec<Vec<[f32; 2]>>>
501{
502    let scale = size / font.units_per_em() as f64;
503    font.with_rb_face(|face| {
504        let gid = ttf_parser::GlyphId(glyph_id);
505        let mut builder = GlyphPathBuilder::new(0.0, 0.0, scale);
506        let has_outline = face.outline_glyph(gid, &mut builder).is_some();
507        if !has_outline || !builder.has_outline {
508            return None;
509        }
510
511        let mut curves = ConvCurve::new(builder.path);
512        curves.rewind(0);
513
514        let mut contours: Vec<Vec<[f32; 2]>> = Vec::new();
515        let mut current: Vec<[f32; 2]>       = Vec::new();
516
517        loop {
518            let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
519            let cmd = curves.vertex(&mut cx, &mut cy);
520            if is_stop(cmd) { break; }
521            if is_move_to(cmd) {
522                if current.len() >= 3 {
523                    contours.push(std::mem::take(&mut current));
524                } else {
525                    current.clear();
526                }
527                current.push([cx as f32, cy as f32]);
528            } else if cmd == PATH_CMD_LINE_TO {
529                current.push([cx as f32, cy as f32]);
530            } else if is_end_poly(cmd) {
531                if current.len() >= 3 {
532                    contours.push(std::mem::take(&mut current));
533                } else {
534                    current.clear();
535                }
536            }
537        }
538        if current.len() >= 3 {
539            contours.push(current);
540        }
541
542        if contours.is_empty() { None } else { Some(contours) }
543    })
544}
545
546/// Measure full text metrics (width, ascent, descent, line_height).
547///
548/// Useful for external rendering backends (e.g. `GlGfxCtx`) that need
549/// text metrics without the `GfxCtx` wrapper.
550pub fn measure_text_metrics(font: &Font, text: &str, size: f64) -> TextMetrics {
551    TextMetrics {
552        width:       measure_advance(font, text, size),
553        ascent:      font.ascender_px(size),
554        descent:     font.descender_px(size),
555        line_height: font.line_height_px(size),
556    }
557}
558
559// ---------------------------------------------------------------------------
560// Global shape/measurement cache — survives across Label instance recreation
561// ---------------------------------------------------------------------------
562//
563// TreeView and other widgets rebuild their Label children every layout() call,
564// so a per-Label cache doesn't help: each new instance starts cold. This
565// thread-local HashMap caches rustybuzz::shape() results for the lifetime of
566// the process, keyed by (font data pointer, text, size bits). The pointer is
567// stable as long as any Arc<Vec<u8>> clone exists (which is always true while
568// the Font is alive).
569
570use std::cell::RefCell;
571use std::collections::HashMap;
572
573thread_local! {
574    /// Caches the full rustybuzz shaping output (per-glyph IDs + advances).
575    /// Used by shape_glyphs() so fill_text() avoids re-shaping every frame.
576    /// Also serves as the measurement cache — measure_advance() reads it too.
577    static SHAPE_CACHE: RefCell<HashMap<(usize, String, u64), Vec<ShapedGlyph>>> =
578        RefCell::new(HashMap::new());
579}
580
581/// Measure text advance width without rasterizing.
582///
583/// Delegates to [`shape_glyphs`] so that fallback-font advances are included
584/// in the measurement.  Results are cached via the shared shape cache.
585///
586/// The measurement matches what `shape_text` will actually pen at paint
587/// time — so `interval` (extra letter-spacing) is added here too.  Width
588/// and italic are ignored: width only affects per-glyph outline scale,
589/// not advances, and italic shears the outline which doesn't change the
590/// horizontal extent of the pen walk.
591pub fn measure_advance(font: &Font, text: &str, size: f64) -> f64 {
592    let shaped = shape_glyphs(font, text, size);
593    let interval_px = crate::font_settings::current_interval() * size;
594    shaped.iter().map(|g| g.x_advance + interval_px).sum()
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    const FONT_BYTES: &[u8] =
602        include_bytes!("../../demo/assets/CascadiaCode.ttf");
603    const FA_BYTES: &[u8] =
604        include_bytes!("../../demo/assets/fa.ttf");
605
606    fn test_font() -> Arc<Font> {
607        Arc::new(Font::from_slice(FONT_BYTES).expect("font ok"))
608    }
609
610    /// Font-Awesome codepoint U+F109 ("fa-laptop") — used by the demo's
611    /// backend-panel button label.  The primary font (CascadiaCode) does not
612    /// cover the FA range, so the fallback chain must carry it.
613    const FA_LAPTOP: &str = "\u{F109}";
614
615    /// A `shape_text` call for a codepoint absent from the primary font must
616    /// walk the fallback chain and produce the real glyph outline — not the
617    /// primary font's `.notdef` (the tofu box the top screenshot shows).
618    #[test]
619    fn test_shape_text_renders_fa_icon_via_fallback() {
620        let fa = Font::from_slice(FA_BYTES).expect("parse fa.ttf");
621        let font = Arc::new(
622            Font::from_slice(FONT_BYTES).expect("cc")
623                .with_fallback(Arc::new(fa)),
624        );
625
626        // shape_glyphs must agree the glyph was resolved via fallback.
627        let shaped = shape_glyphs(&font, FA_LAPTOP, 16.0);
628        assert_eq!(shaped.len(), 1);
629        assert!(
630            shaped[0].fallback_font.is_some(),
631            "FA codepoint must resolve via fallback font"
632        );
633
634        // shape_text must return a non-empty path for that glyph.
635        let (paths, _adv) = shape_text(&font, FA_LAPTOP, 16.0, 0.0, 0.0);
636        assert_eq!(
637            paths.len(),
638            1,
639            "fallback outline must yield exactly one PathStorage for FA_LAPTOP"
640        );
641    }
642
643    /// The outline returned by `shape_text` for a codepoint missing from the
644    /// primary font must match the fallback font's outline — not the primary
645    /// font's `.notdef`.  Compare flattened bounding boxes.
646    #[test]
647    fn test_shape_text_fa_outline_matches_fallback_font() {
648        use agg_rust::conv_curve::ConvCurve;
649        use agg_rust::basics::{is_stop, VertexSource};
650
651        let fa_arc = Arc::new(Font::from_slice(FA_BYTES).expect("fa"));
652        let font = Arc::new(
653            Font::from_slice(FONT_BYTES).expect("cc")
654                .with_fallback(Arc::clone(&fa_arc)),
655        );
656
657        // Outline via the fallback-aware shape_text.
658        let (mut paths, _) = shape_text(&font, FA_LAPTOP, 48.0, 0.0, 0.0);
659        assert_eq!(paths.len(), 1);
660        let mut curves = ConvCurve::new(&mut paths[0]);
661        curves.rewind(0);
662
663        let (mut xmin, mut xmax) = (f64::INFINITY, f64::NEG_INFINITY);
664        loop {
665            let (mut cx, mut cy) = (0.0, 0.0);
666            let cmd = curves.vertex(&mut cx, &mut cy);
667            if is_stop(cmd) { break; }
668            if cx < xmin { xmin = cx; }
669            if cx > xmax { xmax = cx; }
670            let _ = cy;
671        }
672        let width = xmax - xmin;
673
674        // FA's "laptop" glyph is full-width at 48 px; the CascadiaCode .notdef
675        // (tofu) is closer to advance-width (~24 px).  A width over 32 px at
676        // size 48 proves we took the fallback outline, not .notdef.
677        assert!(
678            width > 32.0,
679            "FA glyph outline width at 48 px was {width:.1} — too narrow, \
680             likely still rendering CascadiaCode .notdef instead of FA fallback"
681        );
682    }
683
684    /// Verify that shape_and_flatten_text produces a sane number of
685    /// contour points at typical UI font sizes.
686    ///
687    /// Before the fix, subdivide_quad tested flatness in font units
688    /// (~2048 upm), producing ~1000 sub-divisions per Bézier segment
689    /// instead of ~4 — this test would time-out or produce millions of
690    /// points under the broken implementation.
691    #[test]
692    fn test_flatten_point_count_is_sane() {
693        let font = test_font();
694        let sizes: &[f64] = &[10.0, 13.0, 14.0, 24.0, 34.0];
695        let texts: &[&str] = &[
696            "Hello",
697            "The quick brown fox",
698            "Caption — 10px  The quick brown fox",
699            "agg-gui",
700            "Aa",
701        ];
702
703        for &size in sizes {
704            for &text in texts {
705                let contours =
706                    shape_and_flatten_text(&font, text, size, 0.0, 0.0, 0.5);
707
708                let total_pts: usize = contours.iter().map(|c| c.len()).sum();
709                let char_count = text.chars().count().max(1);
710                let pts_per_char = total_pts / char_count;
711
712                // A well-formed glyph at any typical size should produce
713                // between 4 and 300 points per character.  Anything above
714                // ~500 means over-subdivision is happening again.
715                assert!(
716                    pts_per_char <= 500,
717                    "size={size} text={text:?}: {pts_per_char} pts/char \
718                     (total {total_pts}) — too many, subdivision loop likely"
719                );
720                assert!(
721                    total_pts > 0 || text.trim().is_empty(),
722                    "size={size} text={text:?}: zero points produced"
723                );
724            }
725        }
726    }
727
728    /// Print raw contour coordinates for a single character.
729    #[test]
730    fn test_dump_single_char_coords() {
731        use crate::gl_renderer::tessellate_fill;
732        let font = test_font();
733        for ch in ['W', 'i', 'd', 'g', 'e', 't', 's'] {
734            let s = ch.to_string();
735            let contours = shape_and_flatten_text(&font, &s, 13.0, 10.0, 50.0, 0.5);
736            let total: usize = contours.iter().map(|c| c.len()).sum();
737            eprintln!("{:?}: {} contours, {} pts", ch, contours.len(), total);
738            // Print bounding box of each contour
739            for (ci, c) in contours.iter().enumerate() {
740                if c.is_empty() { continue; }
741                let xs: Vec<f32> = c.iter().map(|p| p[0]).collect();
742                let ys: Vec<f32> = c.iter().map(|p| p[1]).collect();
743                let xmin = xs.iter().cloned().fold(f32::INFINITY, f32::min);
744                let xmax = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
745                let ymin = ys.iter().cloned().fold(f32::INFINITY, f32::min);
746                let ymax = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
747                eprintln!("  contour {ci}: {}/{} pts  x:[{xmin:.1},{xmax:.1}] y:[{ymin:.1},{ymax:.1}]",
748                    c.len(), c.len());
749            }
750            let result = tessellate_fill(&contours);
751            eprintln!("  tess: {:?}", result.as_ref().map(|(v,i)| (v.len()/2, i.len()/3)));
752        }
753    }
754
755    /// Simulate the text draw calls that happen on the very first WASM
756    /// render frame (Basics tab + window visible) and assert the full
757    /// pipeline (shape → flatten → tessellate) completes in < 200 ms.
758    ///
759    /// This test catches both infinite-subdivision loops and algorithmic
760    /// slowness that would cause a tab-kill dialog in the browser.
761    /// WASM is ~5× slower than native, so 200 ms native ≈ 1 s WASM — fine.
762    #[test]
763    fn test_first_frame_text_pipeline_is_fast() {
764        use crate::gl_renderer::tessellate_fill;
765        use std::time::Instant;
766
767        let font = test_font();
768        let t0 = Instant::now();
769
770        // All fill_text calls expected on the first rendered frame:
771        //   tab bar (TabView), window title + label (Window),
772        //   button labels (Button), text field placeholders (TextField).
773        let calls: &[(&str, f64)] = &[
774            // tab bar labels (13 pt)
775            ("Basics",   13.0),
776            ("Widgets",  13.0),
777            ("Text",     13.0),
778            ("Layout",   13.0),
779            ("Tree",     13.0),
780            // floating window
781            ("3D Demo",                  16.0),
782            ("WebGL2 — rotating cube",   11.0),
783            // Basics tab buttons
784            ("Primary Action",  14.0),
785            ("Secondary",       14.0),
786            ("Destructive",     14.0),
787            // text field placeholders
788            ("Type something\u{2026}",  14.0),
789            ("Another field",           14.0),
790        ];
791
792        let mut total_pts  = 0usize;
793        let mut total_tris = 0usize;
794
795        for &(text, size) in calls {
796            let contours = shape_and_flatten_text(&font, text, size, 10.0, 50.0, 0.5);
797            total_pts += contours.iter().map(|c| c.len()).sum::<usize>();
798
799            if let Some((verts, idx)) = tessellate_fill(&contours) {
800                total_tris += idx.len() / 3;
801                let _ = verts;
802            }
803        }
804
805        let elapsed = t0.elapsed();
806
807        // Sanity: we should have produced some geometry.
808        assert!(total_pts  > 0,  "no contour points produced");
809        assert!(total_tris > 0,  "no triangles tessellated");
810
811        // Performance gate: must finish in under 200 ms natively.
812        assert!(
813            elapsed.as_millis() < 200,
814            "first-frame text pipeline took {}ms (pts={total_pts} tris={total_tris}) — \
815             too slow, would hang browser (WASM is ~5× slower)",
816            elapsed.as_millis()
817        );
818
819        eprintln!(
820            "first-frame text: {total_pts} pts, {total_tris} tris in {}ms",
821            elapsed.as_millis()
822        );
823    }
824
825    /// Verify shape_glyphs returns the right number of glyphs with positive advances.
826    #[test]
827    fn test_shape_glyphs_basic() {
828        let font = test_font();
829        let glyphs = shape_glyphs(&font, "Hi", 14.0);
830        assert_eq!(glyphs.len(), 2, "two glyphs for 'Hi'");
831        assert!(glyphs[0].x_advance > 0.0, "H has positive advance");
832        assert!(glyphs[1].x_advance > 0.0, "i has positive advance");
833    }
834
835    /// flatten_glyph_at_origin must produce coords in glyph-local pixel space
836    /// (roughly 0..size range), not in font units (hundreds–thousands).
837    #[test]
838    fn test_flatten_glyph_at_origin_local_coords() {
839        let font = test_font();
840        let size  = 16.0_f64;
841        let glyphs = shape_glyphs(&font, "H", size);
842        assert!(!glyphs.is_empty());
843        let gid = glyphs[0].glyph_id;
844
845        let contours = flatten_glyph_at_origin(&font, gid, size)
846            .expect("'H' must have an outline");
847        assert!(!contours.is_empty(), "should produce at least one contour");
848
849        for contour in &contours {
850            for &[x, y] in contour {
851                assert!(
852                    x >= -2.0 && x <= size as f32 + 4.0,
853                    "x={x} should be in glyph-local pixels for size={size}"
854                );
855                assert!(
856                    y >= -size as f32 * 0.3 && y <= size as f32 * 1.2,
857                    "y={y} should be in glyph-local pixels for size={size}"
858                );
859            }
860        }
861    }
862
863    /// Space has no outline; flatten_glyph_at_origin should return None.
864    #[test]
865    fn test_flatten_glyph_at_origin_space_returns_none() {
866        let font   = test_font();
867        let glyphs = shape_glyphs(&font, " ", 14.0);
868        assert_eq!(glyphs.len(), 1);
869        let result = flatten_glyph_at_origin(&font, glyphs[0].glyph_id, 14.0);
870        assert!(
871            result.is_none(),
872            "space glyph should have no outline, got {:?}",
873            result.as_ref().map(|c| c.len())
874        );
875    }
876
877    /// Verify that all contour points are in screen-pixel range for the
878    /// given font size (not left in raw font units).
879    #[test]
880    fn test_flatten_output_is_in_screen_space() {
881        let font = test_font();
882        // Place text at (100, 200) at size 16.
883        let contours =
884            shape_and_flatten_text(&font, "Hello", 16.0, 100.0, 200.0, 0.5);
885
886        assert!(!contours.is_empty(), "should produce contours for 'Hello'");
887
888        for (ci, contour) in contours.iter().enumerate() {
889            for &[x, y] in contour {
890                // Screen-space points should be near (100±50, 200±30) at 16pt.
891                // Font-unit coordinates would be in the hundreds–thousands.
892                assert!(
893                    x > 50.0 && x < 300.0,
894                    "contour {ci}: x={x} looks like font units, not screen px"
895                );
896                assert!(
897                    y > 150.0 && y < 280.0,
898                    "contour {ci}: y={y} looks like font units, not screen px"
899                );
900            }
901        }
902    }
903}