use ttf_parser::{Face, GlyphId, name_id};
#[derive(Debug, Clone)]
pub struct TextMeter {
bytes: Vec<u8>,
name: String,
}
impl TextMeter {
pub fn new(name: impl Into<String>, bytes: Vec<u8>) -> Option<Self> {
{
let face = Face::parse(&bytes, 0).ok()?;
let _ = face.units_per_em();
}
Some(Self {
bytes,
name: name.into(),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn family_name(&self) -> Option<String> {
self.face_name(name_id::TYPOGRAPHIC_FAMILY)
.or_else(|| self.face_name(name_id::FAMILY))
}
pub fn legacy_family_name(&self) -> Option<String> {
self.face_name(name_id::FAMILY)
}
pub fn weight(&self) -> u16 {
self.with_face(|face| match face.tables().os2 {
Some(os2) => os2.weight().to_number().max(1),
None => 400,
})
.unwrap_or(400)
}
fn face_name(&self, wanted: u16) -> Option<String> {
self.with_face(|face| {
face.names()
.into_iter()
.filter(|name| name.name_id == wanted)
.find_map(|name| name.to_string())
})
.flatten()
}
fn with_face<T>(&self, f: impl FnOnce(&Face<'_>) -> T) -> Option<T> {
Face::parse(&self.bytes, 0).ok().map(|face| f(&face))
}
pub fn units_per_em(&self) -> f32 {
self.with_face(|face| f32::from(face.units_per_em()))
.unwrap_or(1000.0)
}
pub fn char_width(&self, ch: char, size: f32) -> f32 {
self.with_face(|face| {
let upem = f32::from(face.units_per_em());
match face.glyph_index(ch) {
None => size * 0.5,
Some(glyph) => match face.glyph_hor_advance(glyph) {
Some(advance) => f32::from(advance) / upem * size,
None => size * 0.5,
},
}
})
.unwrap_or(size * 0.5)
}
pub fn text_width(&self, text: &str, size: f32) -> f32 {
self.with_face(|face| {
let upem = f32::from(face.units_per_em());
let kern = face.tables().kern;
let mut width = 0.0_f32;
let mut previous: Option<GlyphId> = None;
for ch in text.chars() {
let Some(glyph) = face.glyph_index(ch) else {
width += size * 0.5;
previous = None;
continue;
};
if let (Some(prev), Some(kern)) = (previous, kern)
&& let Some(pair) = kern
.subtables
.into_iter()
.filter(|s| s.horizontal && !s.variable)
.find_map(|s| s.glyphs_kerning(prev, glyph))
{
width += f32::from(pair) / upem * size;
}
width += match face.glyph_hor_advance(glyph) {
Some(advance) => f32::from(advance) / upem * size,
None => size * 0.5,
};
previous = Some(glyph);
}
width
})
.unwrap_or_else(|| text.chars().count() as f32 * size * 0.55)
}
pub fn space_width(&self, size: f32) -> f32 {
self.char_width(' ', size)
}
pub fn ascent(&self, size: f32) -> f32 {
self.with_face(|face| f32::from(face.ascender()) / f32::from(face.units_per_em()) * size)
.unwrap_or(size * 0.75)
}
pub fn descent(&self, size: f32) -> f32 {
self.with_face(|face| {
f32::from(face.descender()).abs() / f32::from(face.units_per_em()) * size
})
.unwrap_or(size * 0.25)
}
pub fn line_height(&self, size: f32) -> f32 {
self.with_face(|face| {
let upem = f32::from(face.units_per_em());
let ascender = face.ascender();
let descender = face.descender();
let gap = face.line_gap();
let height = gap
.checked_add(ascender)
.and_then(|v| v.checked_sub(descender))
.unwrap_or(ascender - descender);
f32::from(height) / upem * size
})
.unwrap_or(size * 1.2)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meter() -> TextMeter {
let bytes = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/media/DMSans-Medium.ttf"
))
.expect("the embedded font is in the repo");
TextMeter::new("DM Sans", bytes).expect("the embedded font parses")
}
#[test]
fn rejects_non_font_bytes() {
assert!(TextMeter::new("junk", vec![0, 1, 2, 3]).is_none());
}
#[test]
fn measures_text_monotonically() {
let meter = meter();
let short = meter.text_width("ab", 100.0);
let long = meter.text_width("abcd", 100.0);
assert!(short > 0.0);
assert!(long > short, "{long} should exceed {short}");
}
#[test]
fn scales_linearly_with_size() {
let meter = meter();
let at_50 = meter.text_width("Hello", 50.0);
let at_100 = meter.text_width("Hello", 100.0);
assert!(
(at_100 - at_50 * 2.0).abs() < 0.5,
"expected linear scaling, got {at_50} and {at_100}"
);
}
#[test]
fn space_is_narrower_than_a_letter() {
let meter = meter();
assert!(meter.space_width(100.0) < meter.char_width('m', 100.0));
}
#[test]
fn metrics_are_sane() {
let meter = meter();
assert!(meter.units_per_em() > 0.0);
assert!(meter.ascent(100.0) > 0.0);
assert!(meter.descent(100.0) > 0.0);
assert!(meter.line_height(100.0) > 0.0);
}
#[test]
fn unknown_glyph_does_not_collapse_to_zero() {
let meter = meter();
assert!(meter.char_width('\u{E000}', 100.0) > 0.0);
}
fn embedded(file: &str) -> TextMeter {
let bytes = std::fs::read(format!("{}/media/{file}", env!("CARGO_MANIFEST_DIR")))
.unwrap_or_else(|e| panic!("media/{file} is in the repo: {e}"));
TextMeter::new(file, bytes).expect("the embedded font parses")
}
#[test]
fn reports_the_family_and_weight_of_every_new_face() {
let expected = [
("Inter-Regular.ttf", "Inter", "Inter", 400_u16),
("Inter-Medium.ttf", "Inter", "Inter Medium", 500),
("Inter-SemiBold.ttf", "Inter", "Inter SemiBold", 600),
("Inter-Bold.ttf", "Inter", "Inter", 700),
("Montserrat-Bold.ttf", "Montserrat", "Montserrat", 700),
(
"Montserrat-ExtraBold.ttf",
"Montserrat",
"Montserrat ExtraBold",
800,
),
(
"Montserrat-Black.ttf",
"Montserrat",
"Montserrat Black",
900,
),
];
for (file, family, legacy, weight) in expected {
let meter = embedded(file);
println!(
"{file}: family={:?} legacy={:?} weight={}",
meter.family_name(),
meter.legacy_family_name(),
meter.weight()
);
assert_eq!(meter.family_name().as_deref(), Some(family), "{file}");
assert_eq!(
meter.legacy_family_name().as_deref(),
Some(legacy),
"{file}"
);
assert_eq!(meter.weight(), weight, "{file}");
}
}
#[test]
fn dm_sans_keeps_its_own_name_and_weight() {
let meter = embedded("DMSans-Medium.ttf");
println!(
"DMSans-Medium.ttf: family={:?} legacy={:?} weight={}",
meter.family_name(),
meter.legacy_family_name(),
meter.weight()
);
assert_eq!(meter.family_name().as_deref(), Some("DM Sans"));
assert_eq!(
meter.legacy_family_name().as_deref(),
Some("DM Sans Medium")
);
assert_eq!(meter.weight(), 500);
}
}