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}