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