use std::sync::OnceLock;
mod data;
const FALLBACK_ADVANCE_UNITS: u16 = data::UNITS_PER_EM;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FontStyle {
Regular,
#[allow(dead_code)]
Italic,
Bold,
#[allow(dead_code)]
BoldItalic,
}
impl FontStyle {
#[allow(dead_code)]
pub(crate) fn from_flags(bold: bool, italic: bool) -> Self {
match (bold, italic) {
(false, false) => FontStyle::Regular,
(false, true) => FontStyle::Italic,
(true, false) => FontStyle::Bold,
(true, true) => FontStyle::BoldItalic,
}
}
}
#[derive(Clone, Copy, Debug)]
struct VMetrics {
ascent: i16,
descent: i16,
line_gap: i16,
cap_height: i16,
x_height: i16,
}
fn vmetrics(style: FontStyle) -> VMetrics {
match style {
FontStyle::Regular => data::VMETRICS_REGULAR,
FontStyle::Italic => data::VMETRICS_ITALIC,
FontStyle::Bold => data::VMETRICS_BOLD,
FontStyle::BoldItalic => data::VMETRICS_BOLD_ITALIC,
}
}
fn scale_units(units: impl Into<i32>, font_size: f64) -> f64 {
f64::from(units.into()) / f64::from(data::UNITS_PER_EM) * font_size
}
pub(crate) fn line_height(font_size: f64, style: FontStyle) -> f64 {
let m = vmetrics(style);
scale_units(
i32::from(m.ascent) + i32::from(m.descent) + i32::from(m.line_gap),
font_size,
)
}
pub(crate) fn ascent(font_size: f64, style: FontStyle) -> f64 {
scale_units(vmetrics(style).ascent, font_size)
}
pub(crate) fn descent(font_size: f64, style: FontStyle) -> f64 {
scale_units(vmetrics(style).descent, font_size)
}
pub(crate) fn text_height(font_size: f64, style: FontStyle) -> f64 {
let m = vmetrics(style);
scale_units(i32::from(m.ascent) + i32::from(m.descent), font_size)
}
#[allow(dead_code)]
pub(crate) fn cap_height(font_size: f64, style: FontStyle) -> f64 {
scale_units(vmetrics(style).cap_height, font_size)
}
#[allow(dead_code)]
pub(crate) fn x_height(font_size: f64, style: FontStyle) -> f64 {
scale_units(vmetrics(style).x_height, font_size)
}
pub(crate) fn center_offset(font_size: f64, style: FontStyle) -> f64 {
scale_units(vmetrics(style).cap_height, font_size) / 2.0
}
pub(crate) fn measure_text_width(text: &str, font_size: f64, style: FontStyle) -> f64 {
let table = advance_table(style);
let total_units: u64 = text
.chars()
.map(|c| {
let cp = c as u32;
let units = if cp <= 0xFFFF { table[cp as usize] } else { 0 };
u64::from(if units == 0 {
FALLBACK_ADVANCE_UNITS
} else {
units
})
})
.sum();
total_units as f64 / f64::from(data::UNITS_PER_EM) * font_size
}
pub(crate) fn widest_text_width<'a>(
labels: impl IntoIterator<Item = &'a str>,
font_size: f64,
style: FontStyle,
) -> f64 {
labels
.into_iter()
.map(|s| measure_text_width(s, font_size, style))
.fold(0.0, f64::max)
}
pub(crate) fn mean_char_width(font_size: f64) -> f64 {
font_size * data::MEAN_ADVANCE_EM
}
fn advance_table(style: FontStyle) -> &'static [u16; 0x10000] {
static REGULAR: OnceLock<Box<[u16; 0x10000]>> = OnceLock::new();
static ITALIC: OnceLock<Box<[u16; 0x10000]>> = OnceLock::new();
static BOLD: OnceLock<Box<[u16; 0x10000]>> = OnceLock::new();
static BOLD_ITALIC: OnceLock<Box<[u16; 0x10000]>> = OnceLock::new();
match style {
FontStyle::Regular => REGULAR.get_or_init(|| decode_rle(data::ADVANCE_RLE_REGULAR)),
FontStyle::Italic => ITALIC.get_or_init(|| decode_rle(data::ADVANCE_RLE_ITALIC)),
FontStyle::Bold => BOLD.get_or_init(|| decode_rle(data::ADVANCE_RLE_BOLD)),
FontStyle::BoldItalic => {
BOLD_ITALIC.get_or_init(|| decode_rle(data::ADVANCE_RLE_BOLD_ITALIC))
}
}
}
fn decode_rle(rle: &[(u16, u16)]) -> Box<[u16; 0x10000]> {
let mut values: Vec<u16> = Vec::with_capacity(0x10000);
for &(advance, len) in rle {
values.resize(values.len() + len as usize, advance);
}
values
.into_boxed_slice()
.try_into()
.expect("advance RLE must cover exactly the 65536 BMP codepoints")
}
#[cfg(test)]
mod tests {
use super::*;
const SIZE: f64 = 12.0;
#[test]
fn empty_string_has_zero_width() {
assert_eq!(measure_text_width("", SIZE, FontStyle::Regular), 0.0);
}
#[test]
fn wide_glyphs_measure_wider_than_narrow_ones() {
let wide = measure_text_width("MMMM", SIZE, FontStyle::Regular);
let narrow = measure_text_width("iiii", SIZE, FontStyle::Regular);
assert!(
wide > narrow,
"M should be wider than i ({wide} vs {narrow})"
);
}
#[test]
fn digits_are_tabular() {
let a = measure_text_width("100000", SIZE, FontStyle::Regular);
let b = measure_text_width("888888", SIZE, FontStyle::Regular);
assert_eq!(a, b);
}
#[test]
fn bold_is_wider_than_regular() {
let bold = measure_text_width("Frequency", SIZE, FontStyle::Bold);
let regular = measure_text_width("Frequency", SIZE, FontStyle::Regular);
assert!(bold > regular, "bold should be wider ({bold} vs {regular})");
}
#[test]
fn width_scales_linearly_with_font_size() {
let small = measure_text_width("Sample", 10.0, FontStyle::Regular);
let big = measure_text_width("Sample", 20.0, FontStyle::Regular);
assert!((big - 2.0 * small).abs() < 1e-9);
}
#[test]
fn widest_picks_the_widest_not_the_longest() {
let labels = ["iiiiiiii", "WWW", "ab"];
let widest = widest_text_width(labels, SIZE, FontStyle::Regular);
let www = measure_text_width("WWW", SIZE, FontStyle::Regular);
assert_eq!(widest, www);
}
#[test]
fn widest_of_empty_is_zero() {
let none: [&str; 0] = [];
assert_eq!(widest_text_width(none, SIZE, FontStyle::Regular), 0.0);
}
#[test]
fn non_ascii_measured_by_glyph_not_byte_length() {
let sup = measure_text_width("\u{00B2}", SIZE, FontStyle::Regular);
let two = measure_text_width("2", SIZE, FontStyle::Regular);
assert!(
sup > 0.0 && sup < two,
"superscript should be narrow ({sup} vs {two})"
);
}
#[test]
fn unknown_glyph_falls_back_to_one_em() {
let emoji = measure_text_width("\u{1F600}", SIZE, FontStyle::Regular);
assert!((emoji - SIZE).abs() < 1e-9);
}
#[test]
fn committed_table_matches_font() {
let cases = [
(FontStyle::Regular, "DejaVuSans.ttf", None),
(FontStyle::Italic, "DejaVuSans-Oblique.ttf", None),
(FontStyle::Bold, "DejaVuSans-Bold.ttf", None),
(
FontStyle::BoldItalic,
"DejaVuSans-BoldOblique.ttf",
Some("DejaVuSans-Bold.ttf"),
),
];
for (style, primary, fallback) in cases {
let dir = format!("{}/assets/fonts", env!("CARGO_MANIFEST_DIR"));
let primary_path = format!("{dir}/{primary}");
let path = match (std::path::Path::new(&primary_path).exists(), fallback) {
(true, _) => primary_path,
(false, Some(fb)) => format!("{dir}/{fb}"),
(false, None) => primary_path,
};
let bytes = std::fs::read(&path).expect("read bundled font");
let face = ttf_parser::Face::parse(&bytes, 0).expect("parse font");
assert_eq!(u16::from(face.units_per_em()), data::UNITS_PER_EM);
let table = advance_table(style);
for cp in 0u32..=0xFFFF {
let expected = char::from_u32(cp)
.and_then(|c| face.glyph_index(c))
.and_then(|g| face.glyph_hor_advance(g))
.unwrap_or(0);
assert_eq!(
table[cp as usize], expected,
"{style:?} mismatch at U+{cp:04X}"
);
}
}
}
#[test]
fn committed_vmetrics_match_font() {
let cases = [
(FontStyle::Regular, "DejaVuSans.ttf", None),
(FontStyle::Italic, "DejaVuSans-Oblique.ttf", None),
(FontStyle::Bold, "DejaVuSans-Bold.ttf", None),
(
FontStyle::BoldItalic,
"DejaVuSans-BoldOblique.ttf",
Some("DejaVuSans-Bold.ttf"),
),
];
for (style, primary, fallback) in cases {
let dir = format!("{}/assets/fonts", env!("CARGO_MANIFEST_DIR"));
let primary_path = format!("{dir}/{primary}");
let path = match (std::path::Path::new(&primary_path).exists(), fallback) {
(true, _) => primary_path,
(false, Some(fb)) => format!("{dir}/{fb}"),
(false, None) => primary_path,
};
let bytes = std::fs::read(&path).expect("read bundled font");
let face = ttf_parser::Face::parse(&bytes, 0).expect("parse font");
let glyph_top = |ch: char| {
face.glyph_index(ch)
.and_then(|g| face.glyph_bounding_box(g))
.map(|bb| bb.y_max)
};
let cap = glyph_top('H')
.or_else(|| face.capital_height())
.unwrap_or(face.ascender());
let x = glyph_top('x')
.or_else(|| face.x_height())
.unwrap_or((f32::from(cap) * 0.72) as i16);
let m = vmetrics(style);
assert_eq!(m.ascent, face.ascender(), "{style:?} ascent");
assert_eq!(m.descent, -face.descender(), "{style:?} descent");
assert_eq!(m.line_gap, face.line_gap(), "{style:?} line_gap");
assert_eq!(m.cap_height, cap, "{style:?} cap_height");
assert_eq!(m.x_height, x, "{style:?} x_height");
}
}
#[test]
fn line_height_covers_ascent_and_descent() {
let lh = line_height(SIZE, FontStyle::Regular);
let box_h = ascent(SIZE, FontStyle::Regular) + descent(SIZE, FontStyle::Regular);
assert!(
lh >= box_h - 1e-9,
"line_height {lh} < ascent+descent {box_h}"
);
}
#[test]
fn line_height_exceeds_cap_height() {
assert!(line_height(SIZE, FontStyle::Regular) > cap_height(SIZE, FontStyle::Regular));
}
#[test]
fn center_offset_is_half_cap_height() {
let co = center_offset(SIZE, FontStyle::Regular);
assert!((co - cap_height(SIZE, FontStyle::Regular) / 2.0).abs() < 1e-9);
}
#[test]
fn x_height_below_cap_below_ascent() {
let s = FontStyle::Regular;
assert!(x_height(SIZE, s) < cap_height(SIZE, s));
assert!(cap_height(SIZE, s) < ascent(SIZE, s));
}
#[test]
fn vertical_metrics_scale_linearly_with_size() {
let a = line_height(10.0, FontStyle::Regular);
let b = line_height(20.0, FontStyle::Regular);
assert!((b - 2.0 * a).abs() < 1e-9);
}
}
#[cfg(test)]
mod probe_scratch2 {
use super::*;
#[test]
fn probe_digit_widths() {
for d in "0123456789".chars() {
println!(
"{d}: {:.3}",
measure_text_width(&d.to_string(), 12.0, FontStyle::Regular)
);
}
}
}