use std::collections::HashMap;
const TAG_HEAD: &[u8; 4] = b"head";
const TAG_HHEA: &[u8; 4] = b"hhea";
const TAG_HMTX: &[u8; 4] = b"hmtx";
const TAG_CMAP: &[u8; 4] = b"cmap";
const TAG_OS2: &[u8; 4] = b"OS/2";
#[derive(Debug, Clone)]
pub(super) struct TtfMetrics {
pub units_per_em: u16,
pub ascent: i16,
pub descent: i16,
pub cap_height: i16,
pub bbox: [i16; 4],
char_to_gid: HashMap<char, u16>,
advance_for_glyph: HashMap<u16, u16>,
}
const FALLBACK_WIDTH_1000: f64 = 600.0;
impl TtfMetrics {
pub(super) fn parse(data: &[u8]) -> Self {
let mut metrics = TtfMetrics {
units_per_em: 1000,
ascent: 800,
descent: -200,
cap_height: 700,
bbox: [0, 0, 0, 0],
char_to_gid: HashMap::new(),
advance_for_glyph: HashMap::new(),
};
let Some(tables) = read_table_directory(data) else { return metrics };
if let Some(head) = tables.get(TAG_HEAD).copied() {
if let Some(v) = read_u16(data, head.0 + 18) {
metrics.units_per_em = v.max(1);
}
if let (Some(x0), Some(y0), Some(x1), Some(y1)) =
(read_i16(data, head.0 + 36), read_i16(data, head.0 + 38), read_i16(data, head.0 + 40), read_i16(data, head.0 + 42))
{
metrics.bbox = [x0, y0, x1, y1];
}
}
let mut num_h_metrics = 0u16;
if let Some(hhea) = tables.get(TAG_HHEA).copied() {
if let Some(a) = read_i16(data, hhea.0 + 4) {
metrics.ascent = a;
}
if let Some(d) = read_i16(data, hhea.0 + 6) {
metrics.descent = d;
}
if let Some(n) = read_u16(data, hhea.0 + 34) {
num_h_metrics = n;
}
}
let mut cap_height_resolved = false;
if let Some(os2) = tables.get(TAG_OS2).copied() {
if let Some(version) = read_u16(data, os2.0) {
if version >= 2 {
if let Some(ch) = read_i16(data, os2.0 + 88) {
metrics.cap_height = ch;
cap_height_resolved = true;
}
}
}
}
if !cap_height_resolved {
metrics.cap_height = (0.7 * f64::from(metrics.units_per_em)).round() as i16;
}
if let Some(cmap) = tables.get(TAG_CMAP).copied().and_then(|(o, l)| data.get(o..o + l)) {
if let Some(sub_offset) = find_preferred_format4_subtable(cmap) {
if let Some(sub) = cmap.get(sub_offset..) {
metrics.char_to_gid = build_char_to_gid(sub);
}
}
}
if let Some((hmtx_off, hmtx_len)) = tables.get(TAG_HMTX).copied() {
if let Some(hmtx_bytes) = data.get(hmtx_off..hmtx_off + hmtx_len) {
let mut gids: Vec<u16> = metrics.char_to_gid.values().copied().collect();
gids.push(0);
gids.sort_unstable();
gids.dedup();
for gid in gids {
if let Some(advance) = read_glyph_advance(hmtx_bytes, num_h_metrics, gid) {
metrics.advance_for_glyph.insert(gid, advance);
}
}
}
}
metrics
}
pub(super) fn gid_for_char(&self, ch: char) -> Option<u16> {
self.char_to_gid.get(&ch).copied()
}
pub(super) fn advance_1000_for_gid(&self, gid: u16) -> f64 {
let Some(&advance_units) = self.advance_for_glyph.get(&gid) else { return FALLBACK_WIDTH_1000 };
f64::from(advance_units) * 1000.0 / f64::from(self.units_per_em.max(1))
}
pub(super) fn ascent_1000(&self) -> f64 {
f64::from(self.ascent) * 1000.0 / f64::from(self.units_per_em.max(1))
}
pub(super) fn descent_1000(&self) -> f64 {
f64::from(self.descent) * 1000.0 / f64::from(self.units_per_em.max(1))
}
pub(super) fn cap_height_1000(&self) -> f64 {
f64::from(self.cap_height) * 1000.0 / f64::from(self.units_per_em.max(1))
}
pub(super) fn bbox_1000(&self) -> [f64; 4] {
self.bbox.map(|v| f64::from(v) * 1000.0 / f64::from(self.units_per_em.max(1)))
}
}
fn read_u16(data: &[u8], offset: usize) -> Option<u16> {
data.get(offset..offset + 2).map(|b| u16::from_be_bytes([b[0], b[1]]))
}
fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
read_u16(data, offset).map(|v| v as i16)
}
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
data.get(offset..offset + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_table_directory(data: &[u8]) -> Option<HashMap<[u8; 4], (usize, usize)>> {
let num_tables = read_u16(data, 4)?;
let mut tables = HashMap::with_capacity(num_tables as usize);
for i in 0..num_tables as usize {
let record_off = 12 + i * 16;
let tag = data.get(record_off..record_off + 4)?;
let offset = read_u32(data, record_off + 8)? as usize;
let length = read_u32(data, record_off + 12)? as usize;
let tag: [u8; 4] = tag.try_into().ok()?;
tables.insert(tag, (offset, length));
}
Some(tables)
}
const CMAP_PREFERENCE: [(u16, u16); 4] = [(3, 1), (0, 3), (0, 4), (3, 10)];
fn find_preferred_format4_subtable(cmap: &[u8]) -> Option<usize> {
let num_tables = read_u16(cmap, 2)?;
for &(want_platform, want_encoding) in &CMAP_PREFERENCE {
for i in 0..num_tables as usize {
let record_off = 4 + i * 8;
let platform = read_u16(cmap, record_off)?;
let encoding = read_u16(cmap, record_off + 2)?;
if platform == want_platform && encoding == want_encoding {
let sub_offset = read_u32(cmap, record_off + 4)? as usize;
if read_u16(cmap, sub_offset) == Some(4) {
return Some(sub_offset);
}
}
}
}
None
}
fn build_char_to_gid(sub: &[u8]) -> HashMap<char, u16> {
let mut map = HashMap::new();
let Some(seg_count_x2) = read_u16(sub, 6) else { return map };
let seg_count = seg_count_x2 as usize / 2;
let end_code_off = 14;
let start_code_off = end_code_off + seg_count * 2 + 2; let id_delta_off = start_code_off + seg_count * 2;
let id_range_off_off = id_delta_off + seg_count * 2;
for i in 0..seg_count {
let (Some(end), Some(start), Some(delta), Some(range_offset)) = (
read_u16(sub, end_code_off + i * 2),
read_u16(sub, start_code_off + i * 2),
read_i16(sub, id_delta_off + i * 2),
read_u16(sub, id_range_off_off + i * 2),
) else {
continue;
};
if start > end {
continue;
}
for c in start..=end {
let glyph_id = if range_offset == 0 {
((c as i32 + delta as i32) & 0xFFFF) as u16
} else {
let glyph_index_addr = id_range_off_off + i * 2 + range_offset as usize + 2 * (c - start) as usize;
let Some(raw) = read_u16(sub, glyph_index_addr) else { continue };
if raw == 0 {
continue;
}
((raw as i32 + delta as i32) & 0xFFFF) as u16
};
if glyph_id == 0 {
continue;
}
if let Some(ch) = char::from_u32(c as u32) {
map.entry(ch).or_insert(glyph_id);
}
}
}
map
}
fn read_glyph_advance(hmtx: &[u8], num_h_metrics: u16, glyph_id: u16) -> Option<u16> {
if num_h_metrics == 0 {
return None;
}
let idx = (glyph_id as usize).min(num_h_metrics as usize - 1);
read_u16(hmtx, idx * 4)
}
#[cfg(test)]
mod tests {
use super::*;
const ROBOTO_REGULAR: &[u8] = include_bytes!("../../../uzor-fonts/fonts/Roboto-Regular.ttf");
#[test]
fn parses_sane_units_per_em_and_bbox_from_a_real_font() {
let m = TtfMetrics::parse(ROBOTO_REGULAR);
assert!(m.units_per_em >= 500, "real fonts use a several-hundred-plus unit em square, got {}", m.units_per_em);
let bbox = m.bbox_1000();
assert!(bbox[2] > bbox[0], "bbox xMax must exceed xMin");
assert!(bbox[3] > bbox[1], "bbox yMax must exceed yMin");
}
#[test]
fn resolves_a_glyph_and_a_positive_width_for_ordinary_ascii_letters() {
let m = TtfMetrics::parse(ROBOTO_REGULAR);
for ch in 'A'..='Z' {
let gid = m.gid_for_char(ch);
assert!(gid.is_some(), "{ch:?} must resolve a glyph");
assert!(m.advance_1000_for_gid(gid.unwrap_or(0)) > 0.0, "{ch:?} must have a positive advance width");
}
}
#[test]
fn resolves_cyrillic_glyphs_with_positive_widths() {
let m = TtfMetrics::parse(ROBOTO_REGULAR);
for ch in "Отчёт".chars() {
let gid = m.gid_for_char(ch);
assert!(gid.is_some(), "Cyrillic char {ch:?} must resolve a real glyph in Roboto");
assert!(m.advance_1000_for_gid(gid.unwrap_or(0)) > 0.0, "Cyrillic char {ch:?} must have a positive advance width");
}
}
#[test]
fn resolves_the_em_dash_glyph_used_by_this_crates_own_report_fixtures() {
let m = TtfMetrics::parse(ROBOTO_REGULAR);
assert!(m.gid_for_char('\u{2014}').is_some(), "em-dash must resolve to a real glyph in Roboto");
}
#[test]
fn distinct_letters_resolve_to_distinct_glyph_ids() {
let m = TtfMetrics::parse(ROBOTO_REGULAR);
let gid_a = m.gid_for_char('A');
let gid_b = m.gid_for_char('B');
assert!(gid_a.is_some() && gid_b.is_some());
assert_ne!(gid_a, gid_b);
}
#[test]
fn an_unmapped_codepoint_resolves_to_no_glyph() {
let m = TtfMetrics::parse(ROBOTO_REGULAR);
assert_eq!(m.gid_for_char('\u{F8FF}'), None);
}
#[test]
fn malformed_font_bytes_degrade_to_fallback_constants_without_panicking() {
let m = TtfMetrics::parse(&[0u8; 4]); assert_eq!(m.units_per_em, 1000);
assert_eq!(m.gid_for_char('A'), None);
assert_eq!(m.advance_1000_for_gid(0), FALLBACK_WIDTH_1000);
assert!(m.ascent_1000() > 0.0);
}
#[test]
fn empty_font_bytes_do_not_panic() {
let m = TtfMetrics::parse(&[]);
assert_eq!(m.advance_1000_for_gid(0), FALLBACK_WIDTH_1000);
}
}