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