mod bezier_flat;
pub use bezier_flat::{shape_and_flatten_text, shape_and_flatten_text_via_agg};
use std::sync::Arc;
use agg_rust::basics::{is_end_poly, is_move_to, is_stop, PATH_CMD_LINE_TO, PATH_FLAGS_NONE, VertexSource};
use agg_rust::conv_contour::ConvContour;
use agg_rust::conv_curve::ConvCurve;
use agg_rust::conv_transform::ConvTransform;
use agg_rust::path_storage::PathStorage;
use agg_rust::trans_affine::TransAffine;
#[derive(Debug, Clone, Copy, Default)]
pub struct TextMetrics {
pub width: f64,
pub ascent: f64,
pub descent: f64,
pub line_height: f64,
}
pub struct Font {
pub(crate) data: Arc<Vec<u8>>,
index: u32,
units_per_em: u16,
ascender: i16,
descender: i16,
line_gap: i16,
pub(crate) fallback: Option<Arc<Font>>,
}
impl Font {
pub fn from_bytes(data: Vec<u8>) -> Result<Self, &'static str> {
let face = ttf_parser::Face::parse(&data, 0).map_err(|_| "failed to parse font")?;
Ok(Self {
units_per_em: face.units_per_em(),
ascender: face.ascender(),
descender: face.descender(),
line_gap: face.line_gap(),
data: Arc::new(data),
index: 0,
fallback: None,
})
}
pub fn from_slice(data: &[u8]) -> Result<Self, &'static str> {
Self::from_bytes(data.to_vec())
}
pub fn with_fallback(mut self, fallback: Arc<Font>) -> Self {
self.fallback = Some(fallback);
self
}
pub fn units_per_em(&self) -> u16 {
self.units_per_em
}
pub fn ascender_px(&self, size: f64) -> f64 {
self.ascender as f64 * size / self.units_per_em as f64
}
pub fn descender_px(&self, size: f64) -> f64 {
self.descender.unsigned_abs() as f64 * size / self.units_per_em as f64
}
pub fn line_height_px(&self, size: f64) -> f64 {
let total = (self.ascender - self.descender + self.line_gap) as f64;
total * size / self.units_per_em as f64
}
pub(crate) fn with_rb_face<F, R>(&self, f: F) -> R
where
F: FnOnce(&rustybuzz::Face<'_>) -> R,
{
let face = rustybuzz::Face::from_slice(&self.data, self.index)
.expect("font was validated at construction");
f(&face)
}
pub(crate) fn with_ttf_face<F, R>(&self, f: F) -> R
where
F: FnOnce(&ttf_parser::Face<'_>) -> R,
{
let face = ttf_parser::Face::parse(&self.data, self.index)
.expect("font was validated at construction");
f(&face)
}
}
pub(crate) struct GlyphPathBuilder {
pub path: PathStorage,
ox: f64,
oy: f64,
scale: f64,
width_scale: f64,
italic_shear: f64,
pub has_outline: bool,
}
impl GlyphPathBuilder {
pub fn new(ox: f64, oy: f64, scale: f64) -> Self {
Self {
path: PathStorage::new(),
ox,
oy,
scale,
width_scale: 1.0,
italic_shear: 0.0,
has_outline: false,
}
}
#[allow(dead_code)]
pub fn with_style(mut self, width: f64, italic: f64) -> Self {
self.width_scale = width;
self.italic_shear = italic;
self
}
#[inline]
fn x(&self, v: f32, y_raw: f32) -> f64 {
let base_x = self.ox + v as f64 * self.scale * self.width_scale;
let shear = y_raw as f64 * self.scale * self.italic_shear;
base_x + shear
}
#[inline]
fn y(&self, v: f32) -> f64 { self.oy + v as f64 * self.scale }
}
impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
self.path.move_to(self.x(x, y), self.y(y));
self.has_outline = true;
}
fn line_to(&mut self, x: f32, y: f32) {
self.path.line_to(self.x(x, y), self.y(y));
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.path.curve3(self.x(x1, y1), self.y(y1), self.x(x, y), self.y(y));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.path.curve4(
self.x(x1, y1), self.y(y1),
self.x(x2, y2), self.y(y2),
self.x(x, y), self.y(y),
);
}
fn close(&mut self) {
self.path.close_polygon(PATH_FLAGS_NONE);
}
}
fn apply_faux_weight(path: PathStorage, weight_px: f64) -> PathStorage {
if weight_px.abs() < 1e-4 { return path; }
let mut src = path;
let mut curves = ConvCurve::new(&mut src);
let zoom_in = TransAffine::new_scaling(1.0, 100.0);
let mut zoomed_in = ConvTransform::new(&mut curves, zoom_in);
let mut contour = ConvContour::new(&mut zoomed_in);
contour.set_auto_detect_orientation(false);
contour.set_width(weight_px);
let zoom_out = TransAffine::new_scaling(1.0, 1.0 / 100.0);
let mut out = ConvTransform::new(&mut contour, zoom_out);
let mut result = PathStorage::new();
out.rewind(0);
loop {
let (mut vx, mut vy) = (0.0_f64, 0.0_f64);
let cmd = out.vertex(&mut vx, &mut vy);
if is_stop(cmd) { break; }
if is_move_to(cmd) {
result.move_to(vx, vy);
} else if cmd == PATH_CMD_LINE_TO {
result.line_to(vx, vy);
} else if is_end_poly(cmd) {
result.close_polygon(PATH_FLAGS_NONE);
}
}
result
}
pub(crate) fn shape_text(
font: &Font,
text: &str,
size: f64,
x: f64,
y: f64,
) -> (Vec<PathStorage>, f64) {
let shaped = shape_glyphs(font, text, size);
let width_scale = crate::font_settings::current_width();
let italic_shear = crate::font_settings::current_faux_italic() / 3.0;
let hint_y = crate::font_settings::hinting_enabled();
let interval_em = crate::font_settings::current_interval();
let interval_px = interval_em * size;
let faux_weight = crate::font_settings::current_faux_weight();
let weight_px = if faux_weight.abs() < 0.05 {
0.0 } else {
-faux_weight * size / 15.0
};
let mut paths = Vec::new();
let mut pen_x = x;
let mut total_advance = 0.0;
for g in &shaped {
let gx = pen_x + g.x_offset;
let gy_unsnapped = y + g.y_offset;
let gy = if hint_y {
(gy_unsnapped + 0.5).floor()
} else {
gy_unsnapped
};
let render_font = g.fallback_font.as_deref().unwrap_or(font);
let scale = size / render_font.units_per_em() as f64;
let mut builder = GlyphPathBuilder::new(gx, gy, scale)
.with_style(width_scale, italic_shear);
let has_outline = render_font.with_ttf_face(|face| {
face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut builder)
.is_some()
});
if has_outline && builder.has_outline {
let path = apply_faux_weight(builder.path, weight_px);
paths.push(path);
}
let advance = g.x_advance + interval_px;
pen_x += advance;
total_advance += advance;
}
(paths, total_advance)
}
#[derive(Clone)]
pub struct ShapedGlyph {
pub glyph_id: u16,
pub x_advance: f64,
pub x_offset: f64,
pub y_offset: f64,
pub fallback_font: Option<Arc<Font>>,
}
pub fn shape_glyphs(font: &Font, text: &str, size: f64) -> Vec<ShapedGlyph> {
let font_key = Arc::as_ptr(&font.data) as usize;
let size_key = size.to_bits();
SHAPE_CACHE.with(|cache| {
{
let c = cache.borrow();
if let Some(cached) = c.get(&(font_key, text.to_owned(), size_key)) {
return cached.clone();
}
}
let scale = size / font.units_per_em() as f64;
let glyphs = font.with_rb_face(|face| {
let mut buffer = rustybuzz::UnicodeBuffer::new();
buffer.push_str(text);
let output = rustybuzz::shape(face, &[], buffer);
output
.glyph_infos()
.iter()
.zip(output.glyph_positions().iter())
.map(|(info, pos)| {
let glyph_id = info.glyph_id as u16;
let x_advance = pos.x_advance as f64 * scale;
let x_offset = pos.x_offset as f64 * scale;
let y_offset = pos.y_offset as f64 * scale;
if glyph_id == 0 {
let byte_off = info.cluster as usize;
if let Some(ch) = text.get(byte_off..).and_then(|s| s.chars().next()) {
let mut cur_fb = font.fallback.as_ref();
while let Some(fb) = cur_fb {
let fb_id = fb.with_ttf_face(|f| {
f.glyph_index(ch).map(|g| g.0).unwrap_or(0)
});
if fb_id != 0 {
let fb_scale = size / fb.units_per_em() as f64;
let fb_adv = fb.with_ttf_face(|f| {
f.glyph_hor_advance(ttf_parser::GlyphId(fb_id))
.map(|a| a as f64 * fb_scale)
.unwrap_or(0.0)
});
return ShapedGlyph {
glyph_id: fb_id,
x_advance: fb_adv,
x_offset,
y_offset,
fallback_font: Some(Arc::clone(fb)),
};
}
cur_fb = fb.fallback.as_ref();
}
}
}
ShapedGlyph { glyph_id, x_advance, x_offset, y_offset,
fallback_font: None }
})
.collect::<Vec<_>>()
});
cache.borrow_mut().insert((font_key, text.to_owned(), size_key), glyphs.clone());
glyphs
})
}
pub fn flatten_glyph_at_origin(font: &Font, glyph_id: u16, size: f64)
-> Option<Vec<Vec<[f32; 2]>>>
{
let scale = size / font.units_per_em() as f64;
font.with_rb_face(|face| {
let gid = ttf_parser::GlyphId(glyph_id);
let mut builder = GlyphPathBuilder::new(0.0, 0.0, scale);
let has_outline = face.outline_glyph(gid, &mut builder).is_some();
if !has_outline || !builder.has_outline {
return None;
}
let mut curves = ConvCurve::new(builder.path);
curves.rewind(0);
let mut contours: Vec<Vec<[f32; 2]>> = Vec::new();
let mut current: Vec<[f32; 2]> = Vec::new();
loop {
let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
let cmd = curves.vertex(&mut cx, &mut cy);
if is_stop(cmd) { break; }
if is_move_to(cmd) {
if current.len() >= 3 {
contours.push(std::mem::take(&mut current));
} else {
current.clear();
}
current.push([cx as f32, cy as f32]);
} else if cmd == PATH_CMD_LINE_TO {
current.push([cx as f32, cy as f32]);
} else if is_end_poly(cmd) {
if current.len() >= 3 {
contours.push(std::mem::take(&mut current));
} else {
current.clear();
}
}
}
if current.len() >= 3 {
contours.push(current);
}
if contours.is_empty() { None } else { Some(contours) }
})
}
pub fn measure_text_metrics(font: &Font, text: &str, size: f64) -> TextMetrics {
TextMetrics {
width: measure_advance(font, text, size),
ascent: font.ascender_px(size),
descent: font.descender_px(size),
line_height: font.line_height_px(size),
}
}
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static SHAPE_CACHE: RefCell<HashMap<(usize, String, u64), Vec<ShapedGlyph>>> =
RefCell::new(HashMap::new());
}
pub fn measure_advance(font: &Font, text: &str, size: f64) -> f64 {
let shaped = shape_glyphs(font, text, size);
let interval_px = crate::font_settings::current_interval() * size;
shaped.iter().map(|g| g.x_advance + interval_px).sum()
}
#[cfg(test)]
mod tests {
use super::*;
const FONT_BYTES: &[u8] =
include_bytes!("../../demo/assets/CascadiaCode.ttf");
const FA_BYTES: &[u8] =
include_bytes!("../../demo/assets/fa.ttf");
fn test_font() -> Arc<Font> {
Arc::new(Font::from_slice(FONT_BYTES).expect("font ok"))
}
const FA_LAPTOP: &str = "\u{F109}";
#[test]
fn test_shape_text_renders_fa_icon_via_fallback() {
let fa = Font::from_slice(FA_BYTES).expect("parse fa.ttf");
let font = Arc::new(
Font::from_slice(FONT_BYTES).expect("cc")
.with_fallback(Arc::new(fa)),
);
let shaped = shape_glyphs(&font, FA_LAPTOP, 16.0);
assert_eq!(shaped.len(), 1);
assert!(
shaped[0].fallback_font.is_some(),
"FA codepoint must resolve via fallback font"
);
let (paths, _adv) = shape_text(&font, FA_LAPTOP, 16.0, 0.0, 0.0);
assert_eq!(
paths.len(),
1,
"fallback outline must yield exactly one PathStorage for FA_LAPTOP"
);
}
#[test]
fn test_shape_text_fa_outline_matches_fallback_font() {
use agg_rust::conv_curve::ConvCurve;
use agg_rust::basics::{is_stop, VertexSource};
let fa_arc = Arc::new(Font::from_slice(FA_BYTES).expect("fa"));
let font = Arc::new(
Font::from_slice(FONT_BYTES).expect("cc")
.with_fallback(Arc::clone(&fa_arc)),
);
let (mut paths, _) = shape_text(&font, FA_LAPTOP, 48.0, 0.0, 0.0);
assert_eq!(paths.len(), 1);
let mut curves = ConvCurve::new(&mut paths[0]);
curves.rewind(0);
let (mut xmin, mut xmax) = (f64::INFINITY, f64::NEG_INFINITY);
loop {
let (mut cx, mut cy) = (0.0, 0.0);
let cmd = curves.vertex(&mut cx, &mut cy);
if is_stop(cmd) { break; }
if cx < xmin { xmin = cx; }
if cx > xmax { xmax = cx; }
let _ = cy;
}
let width = xmax - xmin;
assert!(
width > 32.0,
"FA glyph outline width at 48 px was {width:.1} — too narrow, \
likely still rendering CascadiaCode .notdef instead of FA fallback"
);
}
#[test]
fn test_flatten_point_count_is_sane() {
let font = test_font();
let sizes: &[f64] = &[10.0, 13.0, 14.0, 24.0, 34.0];
let texts: &[&str] = &[
"Hello",
"The quick brown fox",
"Caption — 10px The quick brown fox",
"agg-gui",
"Aa",
];
for &size in sizes {
for &text in texts {
let contours =
shape_and_flatten_text(&font, text, size, 0.0, 0.0, 0.5);
let total_pts: usize = contours.iter().map(|c| c.len()).sum();
let char_count = text.chars().count().max(1);
let pts_per_char = total_pts / char_count;
assert!(
pts_per_char <= 500,
"size={size} text={text:?}: {pts_per_char} pts/char \
(total {total_pts}) — too many, subdivision loop likely"
);
assert!(
total_pts > 0 || text.trim().is_empty(),
"size={size} text={text:?}: zero points produced"
);
}
}
}
#[test]
fn test_dump_single_char_coords() {
use crate::gl_renderer::tessellate_fill;
let font = test_font();
for ch in ['W', 'i', 'd', 'g', 'e', 't', 's'] {
let s = ch.to_string();
let contours = shape_and_flatten_text(&font, &s, 13.0, 10.0, 50.0, 0.5);
let total: usize = contours.iter().map(|c| c.len()).sum();
eprintln!("{:?}: {} contours, {} pts", ch, contours.len(), total);
for (ci, c) in contours.iter().enumerate() {
if c.is_empty() { continue; }
let xs: Vec<f32> = c.iter().map(|p| p[0]).collect();
let ys: Vec<f32> = c.iter().map(|p| p[1]).collect();
let xmin = xs.iter().cloned().fold(f32::INFINITY, f32::min);
let xmax = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let ymin = ys.iter().cloned().fold(f32::INFINITY, f32::min);
let ymax = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
eprintln!(" contour {ci}: {}/{} pts x:[{xmin:.1},{xmax:.1}] y:[{ymin:.1},{ymax:.1}]",
c.len(), c.len());
}
let result = tessellate_fill(&contours);
eprintln!(" tess: {:?}", result.as_ref().map(|(v,i)| (v.len()/2, i.len()/3)));
}
}
#[test]
fn test_first_frame_text_pipeline_is_fast() {
use crate::gl_renderer::tessellate_fill;
use std::time::Instant;
let font = test_font();
let t0 = Instant::now();
let calls: &[(&str, f64)] = &[
("Basics", 13.0),
("Widgets", 13.0),
("Text", 13.0),
("Layout", 13.0),
("Tree", 13.0),
("3D Demo", 16.0),
("WebGL2 — rotating cube", 11.0),
("Primary Action", 14.0),
("Secondary", 14.0),
("Destructive", 14.0),
("Type something\u{2026}", 14.0),
("Another field", 14.0),
];
let mut total_pts = 0usize;
let mut total_tris = 0usize;
for &(text, size) in calls {
let contours = shape_and_flatten_text(&font, text, size, 10.0, 50.0, 0.5);
total_pts += contours.iter().map(|c| c.len()).sum::<usize>();
if let Some((verts, idx)) = tessellate_fill(&contours) {
total_tris += idx.len() / 3;
let _ = verts;
}
}
let elapsed = t0.elapsed();
assert!(total_pts > 0, "no contour points produced");
assert!(total_tris > 0, "no triangles tessellated");
assert!(
elapsed.as_millis() < 200,
"first-frame text pipeline took {}ms (pts={total_pts} tris={total_tris}) — \
too slow, would hang browser (WASM is ~5× slower)",
elapsed.as_millis()
);
eprintln!(
"first-frame text: {total_pts} pts, {total_tris} tris in {}ms",
elapsed.as_millis()
);
}
#[test]
fn test_shape_glyphs_basic() {
let font = test_font();
let glyphs = shape_glyphs(&font, "Hi", 14.0);
assert_eq!(glyphs.len(), 2, "two glyphs for 'Hi'");
assert!(glyphs[0].x_advance > 0.0, "H has positive advance");
assert!(glyphs[1].x_advance > 0.0, "i has positive advance");
}
#[test]
fn test_flatten_glyph_at_origin_local_coords() {
let font = test_font();
let size = 16.0_f64;
let glyphs = shape_glyphs(&font, "H", size);
assert!(!glyphs.is_empty());
let gid = glyphs[0].glyph_id;
let contours = flatten_glyph_at_origin(&font, gid, size)
.expect("'H' must have an outline");
assert!(!contours.is_empty(), "should produce at least one contour");
for contour in &contours {
for &[x, y] in contour {
assert!(
x >= -2.0 && x <= size as f32 + 4.0,
"x={x} should be in glyph-local pixels for size={size}"
);
assert!(
y >= -size as f32 * 0.3 && y <= size as f32 * 1.2,
"y={y} should be in glyph-local pixels for size={size}"
);
}
}
}
#[test]
fn test_flatten_glyph_at_origin_space_returns_none() {
let font = test_font();
let glyphs = shape_glyphs(&font, " ", 14.0);
assert_eq!(glyphs.len(), 1);
let result = flatten_glyph_at_origin(&font, glyphs[0].glyph_id, 14.0);
assert!(
result.is_none(),
"space glyph should have no outline, got {:?}",
result.as_ref().map(|c| c.len())
);
}
#[test]
fn test_flatten_output_is_in_screen_space() {
let font = test_font();
let contours =
shape_and_flatten_text(&font, "Hello", 16.0, 100.0, 200.0, 0.5);
assert!(!contours.is_empty(), "should produce contours for 'Hello'");
for (ci, contour) in contours.iter().enumerate() {
for &[x, y] in contour {
assert!(
x > 50.0 && x < 300.0,
"contour {ci}: x={x} looks like font units, not screen px"
);
assert!(
y > 150.0 && y < 280.0,
"contour {ci}: y={y} looks like font units, not screen px"
);
}
}
}
}