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