use std::fmt;
use crate::appearance::font_data;
use crate::{Result, RevenantError};
pub struct Font {
pub name: &'static str,
pub units_per_em: u32,
pub ascent: i32,
pub descent: i32,
pub cap_height: i32,
pub bbox: (i32, i32, i32, i32),
pub stem_v: i32,
pub italic_angle: i32,
pub default_width: u32,
pub cmap: &'static [(u32, u16)],
pub widths: &'static [(u16, u16)],
pub cid_widths_str: &'static str,
pub tounicode_cmap: &'static str,
pub ttf: &'static [u8],
}
impl fmt::Debug for Font {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Font")
.field("name", &self.name)
.field("units_per_em", &self.units_per_em)
.field("glyphs", &self.cmap.len())
.field("ttf_bytes", &self.ttf.len())
.finish_non_exhaustive()
}
}
impl Font {
#[must_use]
pub fn glyph_id(&self, codepoint: u32) -> Option<u16> {
self.cmap
.binary_search_by_key(&codepoint, |&(cp, _)| cp)
.ok()
.map(|i| self.cmap[i].1)
}
#[must_use]
pub fn advance(&self, glyph_id: u16) -> u32 {
match self.widths.binary_search_by_key(&glyph_id, |&(g, _)| g) {
Ok(i) => u32::from(self.widths[i].1),
Err(_) => self.default_width,
}
}
#[must_use]
pub fn text_width(&self, text: &str, font_size: f64) -> f64 {
let mut total = 0.0f64;
for ch in text.chars() {
let gid = self.glyph_id(u32::from(ch)).unwrap_or(0);
total += f64::from(self.advance(gid));
}
total * font_size / f64::from(self.units_per_em)
}
#[must_use]
pub fn encode_text_hex(&self, text: &str) -> String {
use std::fmt::Write as _;
let question = self.glyph_id(u32::from('?')).unwrap_or(0);
let mut out = String::with_capacity(text.len() * 4);
for ch in text.chars() {
let gid = self.glyph_id(u32::from(ch)).unwrap_or(question);
let _ = write!(out, "{gid:04X}");
}
out
}
#[must_use]
pub fn pdf_escape(&self, text: &str) -> String {
format!("<{}>", self.encode_text_hex(text))
}
#[must_use]
pub fn wrap_lines(&self, text: &str, font_size: f64, max_width: f64) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() {
word.to_owned()
} else {
format!("{current} {word}")
};
if self.text_width(&candidate, font_size) <= max_width {
current = candidate;
} else {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
word.clone_into(&mut current);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
}
pub const AVAILABLE_FONTS: [&str; 3] = ["noto-sans", "ghea-mariam", "ghea-grapalat"];
pub const DEFAULT_FONT: &str = "noto-sans";
pub fn get_font(name: Option<&str>) -> Result<&'static Font> {
let key = name.unwrap_or(DEFAULT_FONT);
match key {
"noto-sans" => Ok(&font_data::noto_sans::FONT),
"ghea-mariam" => Ok(&font_data::ghea_mariam::FONT),
"ghea-grapalat" => Ok(&font_data::ghea_grapalat::FONT),
_ => Err(RevenantError::Pdf(format!(
"Unknown font {key:?}. Available: {}",
AVAILABLE_FONTS.join(", ")
))),
}
}
#[must_use]
pub fn get_default_font() -> &'static Font {
&font_data::noto_sans::FONT
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_resolves_and_defaults() {
assert_eq!(get_font(None).unwrap().name, "NotoSans");
assert_eq!(get_font(Some("noto-sans")).unwrap().name, "NotoSans");
assert_eq!(get_font(Some("ghea-mariam")).unwrap().name, "GHEAMariam");
assert_eq!(
get_font(Some("ghea-grapalat")).unwrap().name,
"GHEAGrapalat"
);
assert_eq!(get_default_font().name, "NotoSans");
}
#[test]
fn rejects_unknown_font() {
let err = get_font(Some("comic-sans")).unwrap_err();
assert!(err.to_string().contains("Unknown font"), "{err}");
assert!(err.to_string().contains("noto-sans"), "{err}");
}
#[test]
fn cmap_and_widths_are_sorted() {
for key in AVAILABLE_FONTS {
let font = get_font(Some(key)).unwrap();
assert!(
font.cmap.windows(2).all(|w| w[0].0 < w[1].0),
"{key} cmap not sorted"
);
assert!(
font.widths.windows(2).all(|w| w[0].0 < w[1].0),
"{key} widths not sorted"
);
}
}
#[test]
fn glyph_lookup_matches_generated_cmap() {
let font = get_default_font();
assert_eq!(font.glyph_id(u32::from('A')), Some(34));
assert_eq!(font.encode_text_hex("A"), "0022");
assert_eq!(font.pdf_escape("A"), "<0022>");
}
#[test]
fn unknown_glyph_encodes_as_question() {
let font = get_default_font();
let question_gid = font.glyph_id(u32::from('?')).unwrap();
let encoded = font.encode_text_hex("\u{1F600}");
assert_eq!(encoded, format!("{question_gid:04X}"));
}
#[test]
fn text_width_is_additive_and_scales() {
let font = get_default_font();
assert!(font.text_width("", 10.0).abs() < 1e-12);
let a = font.text_width("A", 10.0);
let b = font.text_width("B", 10.0);
let ab = font.text_width("AB", 10.0);
assert!((ab - (a + b)).abs() < 1e-9);
assert!(
(font.text_width("Signature", 20.0) - 2.0 * font.text_width("Signature", 10.0)).abs()
< 1e-9
);
}
#[test]
fn measurements_match_python_reference() {
let noto = get_font(Some("noto-sans")).unwrap();
assert!((noto.text_width("Signature", 12.0) - 54.684).abs() < 1e-9);
assert!((noto.text_width("Test 2026 UTC+4", 10.0) - 78.31).abs() < 1e-9);
assert_eq!(noto.pdf_escape("Sig"), "<0034004A0048>");
let ghea = get_font(Some("ghea-mariam")).unwrap();
assert!((ghea.text_width("Ստորագրություն", 10.0) - 78.99).abs() < 1e-9);
assert_eq!(ghea.pdf_escape("Ա"), "<00C0>");
}
#[test]
fn wrap_lines_greedy() {
let font = get_default_font();
assert_eq!(
font.wrap_lines("one two three", 10.0, 1000.0),
vec!["one two three"]
);
let narrow = font.wrap_lines("one two three", 10.0, 1.0);
assert_eq!(narrow, vec!["one", "two", "three"]);
assert_eq!(font.wrap_lines(" a b ", 10.0, 1000.0), vec!["a b"]);
}
}