use std::collections::BTreeMap;
use skrifa::instance::Size;
use skrifa::metrics::GlyphMetrics;
use skrifa::{FontRef, MetadataProvider};
use super::common;
use crate::core::objects::{Dictionary, Object, PdfName, PdfStream};
use crate::error::{PdfError, PdfResult};
pub struct EmbeddedFont {
data: Vec<u8>,
ps_name: String,
units_per_em: u16,
ascent: f32,
descent: f32,
cap_height: f32,
bbox: [f32; 4],
is_truetype: bool,
axes: Vec<(String, f32)>,
}
impl EmbeddedFont {
pub fn from_ttf(data: &[u8]) -> PdfResult<Self> {
Self::from_font_data(data, true)
}
pub fn from_otf(data: &[u8]) -> PdfResult<Self> {
Self::from_font_data(data, false)
}
pub fn from_ttf_with_axes(data: &[u8], axes: &[(&str, f32)]) -> PdfResult<Self> {
let m = common::parse_font_metrics_with_axes(data, axes)?;
Ok(Self {
data: data.to_vec(),
ps_name: m.ps_name,
units_per_em: m.units_per_em,
ascent: m.ascent,
descent: m.descent,
cap_height: m.cap_height,
bbox: m.bbox,
is_truetype: true,
axes: axes.iter().map(|(t, v)| (t.to_string(), *v)).collect(),
})
}
fn from_font_data(data: &[u8], expect_truetype: bool) -> PdfResult<Self> {
let m = common::parse_font_metrics(data)?;
Ok(Self {
data: data.to_vec(),
ps_name: m.ps_name,
units_per_em: m.units_per_em,
ascent: m.ascent,
descent: m.descent,
cap_height: m.cap_height,
bbox: m.bbox,
is_truetype: expect_truetype,
axes: Vec::new(),
})
}
pub fn ps_name(&self) -> &str {
&self.ps_name
}
pub fn units_per_em(&self) -> u16 {
self.units_per_em
}
pub fn ascent(&self) -> f32 {
self.ascent
}
pub fn descent(&self) -> f32 {
self.descent
}
pub fn measure_text(&self, text: &str, size: f64) -> PdfResult<f64> {
let font_ref = FontRef::new(&self.data)
.map_err(|e| PdfError::InvalidFont(format!("Font parse error: {}", e)))?;
let axis_refs: Vec<(&str, f32)> = self.axes.iter().map(|(t, v)| (t.as_str(), *v)).collect();
let location = common::build_location_pub(&font_ref, &axis_refs);
let charmap = font_ref.charmap();
let glyph_metrics = GlyphMetrics::new(&font_ref, Size::unscaled(), &location);
let mut total_width: f64 = 0.0;
for ch in text.chars() {
if let Some(gid) = charmap.map(ch) {
total_width += glyph_metrics.advance_width(gid).unwrap_or(0.0) as f64;
}
}
Ok(total_width * size / self.units_per_em as f64)
}
const MAX_SIMPLE_FONT_GLYPHS: usize = 256;
pub fn subset(&self, chars: &[char]) -> PdfResult<SubsetFont> {
let axis_refs: Vec<(&str, f32)> = self.axes.iter().map(|(t, v)| (t.as_str(), *v)).collect();
let result = common::collect_glyphs_and_subset_with_axes(&self.data, chars, &axis_refs)?;
if result.old_to_new.len() > Self::MAX_SIMPLE_FONT_GLYPHS {
return Err(PdfError::InvalidFont(format!(
"Subset has {} glyphs, exceeding the 256-glyph limit for simple fonts. \
Use CIDFont (fonts::cidfont) for larger glyph sets.",
result.old_to_new.len()
)));
}
Ok(SubsetFont {
data: result.data,
ps_name: format!("{}+Subset", self.ps_name),
units_per_em: self.units_per_em,
ascent: self.ascent,
descent: self.descent,
cap_height: self.cap_height,
bbox: self.bbox,
is_truetype: self.is_truetype,
char_to_gid: result.char_to_gid,
old_to_new: result.old_to_new,
widths: result.widths,
})
}
}
pub struct SubsetFont {
data: Vec<u8>,
ps_name: String,
units_per_em: u16,
ascent: f32,
descent: f32,
cap_height: f32,
bbox: [f32; 4],
is_truetype: bool,
char_to_gid: BTreeMap<char, u16>,
old_to_new: BTreeMap<u16, u16>,
widths: BTreeMap<u16, u16>,
}
impl SubsetFont {
pub fn encode_text(&self, text: &str) -> Vec<u8> {
text.chars()
.filter_map(|ch| {
let old_gid = self.char_to_gid.get(&ch)?;
let new_gid = self.old_to_new.get(old_gid)?;
Some(*new_gid as u8)
})
.collect()
}
pub fn to_font_dictionary(&self, descriptor_ref: Object) -> Dictionary {
let mut dict = Dictionary::new();
dict.insert(PdfName::new("Type"), Object::Name(PdfName::new("Font")));
dict.insert(
PdfName::new("Subtype"),
Object::Name(PdfName::new(if self.is_truetype {
"TrueType"
} else {
"Type1"
})),
);
dict.insert(
PdfName::new("BaseFont"),
Object::Name(PdfName::new(&self.ps_name)),
);
let num_glyphs = self.old_to_new.len();
if num_glyphs > 0 {
let max_new_gid = self.old_to_new.values().copied().max().unwrap_or(0) as usize;
let mut width_array = vec![Object::Integer(0); max_new_gid + 1];
for (&old_gid, &new_gid) in &self.old_to_new {
let w = self.widths.get(&old_gid).copied().unwrap_or(0);
let scaled = (w as f64 * 1000.0 / self.units_per_em as f64).round() as i64;
if (new_gid as usize) < width_array.len() {
width_array[new_gid as usize] = Object::Integer(scaled);
}
}
dict.insert(PdfName::new("FirstChar"), Object::Integer(0));
dict.insert(
PdfName::new("LastChar"),
Object::Integer(max_new_gid as i64),
);
dict.insert(PdfName::new("Widths"), Object::Array(width_array));
}
dict.insert(PdfName::new("FontDescriptor"), descriptor_ref);
dict
}
pub fn to_font_descriptor(&self, font_file_ref: Object) -> Dictionary {
common::build_font_descriptor(common::FontDescriptorParams {
ps_name: &self.ps_name,
units_per_em: self.units_per_em,
ascent: self.ascent,
descent: self.descent,
cap_height: self.cap_height,
bbox: self.bbox,
flags: 32, font_file_key: if self.is_truetype {
"FontFile2"
} else {
"FontFile3"
},
font_file_ref,
})
}
pub fn to_font_stream(&self) -> PdfResult<PdfStream> {
common::build_font_stream(&self.data)
}
pub fn to_unicode_cmap(&self) -> PdfResult<PdfStream> {
common::build_to_unicode_cmap(&self.char_to_gid, &self.old_to_new, 1)
}
pub fn glyph_count(&self) -> usize {
self.old_to_new.len()
}
pub fn data(&self) -> &[u8] {
&self.data
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_font_data() -> Option<Vec<u8>> {
let paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Courier New.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"C:\\Windows\\Fonts\\times.ttf",
"C:\\Windows\\Fonts\\cour.ttf",
];
for path in &paths {
if let Ok(data) = std::fs::read(path) {
if EmbeddedFont::from_ttf(&data).is_ok() {
return Some(data);
}
}
}
None
}
#[test]
fn embed_load_ttf() {
let data = match test_font_data() {
Some(d) => d,
None => return, };
let font = EmbeddedFont::from_ttf(&data).unwrap();
assert!(!font.ps_name().is_empty());
assert!(font.units_per_em() > 0);
}
#[test]
fn embed_font_metrics() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
assert!(font.ascent() > 0.0);
assert!(font.descent() < 0.0);
}
#[test]
fn embed_measure_text() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let width = font.measure_text("Hello", 12.0).unwrap();
assert!(width > 0.0);
let width2 = font.measure_text("Hello World", 12.0).unwrap();
assert!(width2 > width);
}
#[test]
fn embed_subset_font() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['H', 'e', 'l', 'o']).unwrap();
assert!(subset.data().len() < data.len());
assert!(subset.glyph_count() > 0);
}
#[test]
fn embed_font_descriptor() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['A', 'B']).unwrap();
let desc = subset.to_font_descriptor(Object::Null);
assert_eq!(
desc.get(&PdfName::new("Type")).and_then(|o| o.as_name()),
Some("FontDescriptor")
);
assert!(desc.get(&PdfName::new("Ascent")).is_some());
assert!(desc.get(&PdfName::new("Descent")).is_some());
assert!(desc.get(&PdfName::new("CapHeight")).is_some());
assert!(desc.get(&PdfName::new("FontBBox")).is_some());
assert!(desc.get(&PdfName::new("FontFile2")).is_some());
}
#[test]
fn embed_font_dictionary() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['H', 'e', 'l', 'o']).unwrap();
let dict = subset.to_font_dictionary(Object::Null);
assert_eq!(
dict.get(&PdfName::new("Type")).and_then(|o| o.as_name()),
Some("Font")
);
assert_eq!(
dict.get(&PdfName::new("Subtype")).and_then(|o| o.as_name()),
Some("TrueType")
);
assert!(dict.get(&PdfName::new("Widths")).is_some());
assert!(dict.get(&PdfName::new("FirstChar")).is_some());
assert!(dict.get(&PdfName::new("LastChar")).is_some());
}
#[test]
fn embed_font_stream() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['A']).unwrap();
let stream = subset.to_font_stream().unwrap();
assert!(stream.dict.get(&PdfName::new("Length1")).is_some());
assert!(!stream.data.is_empty());
}
#[test]
fn embed_to_unicode_cmap() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['H', 'e', 'l', 'o']).unwrap();
let cmap_stream = subset.to_unicode_cmap().unwrap();
let cmap_data = cmap_stream.decode_data().unwrap();
let cmap_str = String::from_utf8(cmap_data).unwrap();
assert!(cmap_str.contains("beginbfchar"));
assert!(cmap_str.contains("endbfchar"));
assert!(cmap_str.contains("begincmap"));
}
#[test]
fn to_unicode_cmap_non_bmp_uses_surrogate_pairs() {
use std::collections::BTreeMap;
let mut char_to_gid: BTreeMap<char, u16> = BTreeMap::new();
let mut old_to_new: BTreeMap<u16, u16> = BTreeMap::new();
char_to_gid.insert('\u{1F44D}', 1);
old_to_new.insert(1, 1);
char_to_gid.insert('A', 2);
old_to_new.insert(2, 2);
let cmap = common::build_to_unicode_cmap(&char_to_gid, &old_to_new, 1).unwrap();
let text = String::from_utf8(cmap.decode_data().unwrap()).unwrap();
assert!(text.contains("0041"), "A should map to 0041");
assert!(
!text.contains("1F44D"),
"Non-BMP should NOT use raw code point 1F44D"
);
assert!(
text.contains("D83DDC4D"),
"Non-BMP 👍 should use surrogate pair D83D DC4D, got:\n{}",
text
);
}
#[test]
fn encode_text_truncation_detected() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let chars: Vec<char> = (0x0020u32..=0x0200).filter_map(char::from_u32).collect();
if chars.len() <= 256 {
return; }
let result = font.subset(&chars);
assert!(
result.is_err(),
"subset with >255 glyphs should error for simple font, got {} glyphs",
result.as_ref().map(|s| s.glyph_count()).unwrap_or(0)
);
}
#[test]
fn font_dictionary_last_char_within_byte_range() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['A', 'B', 'C']).unwrap();
let dict = subset.to_font_dictionary(Object::Null);
let last_char = dict
.get(&PdfName::new("LastChar"))
.and_then(|o| o.as_i64())
.unwrap();
assert!(
last_char <= 255,
"/LastChar should be <= 255 for simple font, got {}",
last_char
);
}
#[test]
fn subset_empty_chars_produces_empty_subset() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&[]).unwrap();
assert!(
subset.glyph_count() <= 1,
"empty subset should have at most .notdef"
);
let encoded = subset.encode_text("Hello");
assert!(
encoded.is_empty(),
"encoding with empty subset should produce no bytes"
);
}
#[test]
fn subset_unmapped_chars_are_skipped() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['A']).unwrap();
let encoded = subset.encode_text("AB");
assert_eq!(encoded.len(), 1);
}
#[test]
fn measure_empty_text_is_zero() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let width = font.measure_text("", 12.0).unwrap();
assert_eq!(width, 0.0);
}
#[test]
fn subset_duplicate_chars_deduplicates() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset1 = font.subset(&['A', 'A', 'A']).unwrap();
let subset2 = font.subset(&['A']).unwrap();
assert_eq!(subset1.glyph_count(), subset2.glyph_count());
}
#[test]
fn invalid_font_data_returns_error() {
let result = EmbeddedFont::from_ttf(b"not a font");
assert!(result.is_err());
}
#[test]
fn from_otf_with_ttf_data_still_works() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
assert!(!font.ps_name().is_empty());
let subset = font.subset(&['A']).unwrap();
let desc = subset.to_font_descriptor(Object::Null);
assert!(
desc.get(&PdfName::new("FontFile3")).is_some(),
"OTF font should use /FontFile3"
);
assert!(
desc.get(&PdfName::new("FontFile2")).is_none(),
"OTF font should not use /FontFile2"
);
}
#[test]
fn embed_encode_text() {
let data = match test_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['H', 'e', 'l', 'o']).unwrap();
let encoded = subset.encode_text("Hello");
assert_eq!(encoded.len(), 5);
assert_eq!(encoded[2], encoded[3]); }
#[test]
fn variable_font_loads_and_subsets() {
let path = "C:\\Windows\\Fonts\\bahnschrift.ttf";
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => return, };
let font = EmbeddedFont::from_ttf(&data).unwrap();
assert!(!font.ps_name().is_empty());
let subset = font.subset(&['A', 'B', 'C']).unwrap();
assert!(subset.glyph_count() >= 3);
let width_default = font.measure_text("Hello", 12.0).unwrap();
assert!(width_default > 0.0);
}
#[test]
fn variable_font_with_axis_changes_metrics() {
let path = "C:\\Windows\\Fonts\\bahnschrift.ttf";
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => return,
};
let font_light = EmbeddedFont::from_ttf_with_axes(&data, &[("wght", 300.0)]).unwrap();
let font_bold = EmbeddedFont::from_ttf_with_axes(&data, &[("wght", 700.0)]).unwrap();
let w_light = font_light.measure_text("Hello World", 12.0).unwrap();
let w_bold = font_bold.measure_text("Hello World", 12.0).unwrap();
assert!(w_light > 0.0, "Light width should be positive");
assert!(w_bold > 0.0, "Bold width should be positive");
let subset_light = font_light.subset(&['A', 'B']).unwrap();
let subset_bold = font_bold.subset(&['A', 'B']).unwrap();
assert!(subset_light.glyph_count() >= 2);
assert!(subset_bold.glyph_count() >= 2);
}
fn test_cff_font_data() -> Option<Vec<u8>> {
let paths = [
"tests/fonts/SourceCodePro-Regular.otf",
"../tests/fonts/SourceCodePro-Regular.otf",
];
for path in &paths {
if let Ok(data) = std::fs::read(path) {
return Some(data);
}
}
None
}
#[test]
fn cff_font_loads_via_from_otf() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
assert!(!font.ps_name().is_empty(), "CFF font should have PS name");
assert!(font.units_per_em() > 0);
assert!(font.ascent() > 0.0);
assert!(font.descent() < 0.0);
}
#[test]
fn cff_font_subsets_correctly() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['A', 'B', 'C']);
assert!(
subset.is_ok(),
"CFF font subsetting should work: {:?}",
subset.err()
);
let subset = subset.unwrap();
assert!(subset.glyph_count() >= 3);
let stream = subset.to_font_stream().unwrap();
assert!(stream.data.len() < data.len(), "Subset should be smaller");
}
#[test]
fn cff_font_uses_fontfile3_in_descriptor() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['X']).unwrap();
let desc = subset.to_font_descriptor(Object::Null);
assert!(
desc.get(&PdfName::new("FontFile3")).is_some(),
"CFF should use FontFile3"
);
assert!(
desc.get(&PdfName::new("FontFile2")).is_none(),
"CFF should NOT use FontFile2"
);
}
#[test]
fn cff_font_generates_valid_dictionary() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['H', 'e', 'l', 'o']).unwrap();
let dict = subset.to_font_dictionary(Object::Null);
assert!(dict.get(&PdfName::new("Type")).is_some());
assert!(dict.get(&PdfName::new("Subtype")).is_some());
assert!(dict.get(&PdfName::new("BaseFont")).is_some());
assert!(dict.get(&PdfName::new("Widths")).is_some());
assert!(dict.get(&PdfName::new("FirstChar")).is_some());
assert!(dict.get(&PdfName::new("LastChar")).is_some());
}
#[test]
fn cff_font_to_unicode_cmap_valid() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['A', '!', '@']).unwrap();
let cmap = subset.to_unicode_cmap();
assert!(cmap.is_ok(), "CFF CMap should generate: {:?}", cmap.err());
let cmap = cmap.unwrap();
let text = String::from_utf8(cmap.decode_data().unwrap()).unwrap();
assert!(text.contains("begincmap"));
assert!(text.contains("0041")); }
#[test]
fn cff_font_encode_and_measure() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['H', 'e', 'l', 'o']).unwrap();
let encoded = subset.encode_text("Hello");
assert_eq!(encoded.len(), 5, "Hello has 5 chars");
let width = font.measure_text("Hello", 12.0).unwrap();
assert!(width > 0.0, "CFF text should have non-zero width");
}
#[test]
fn cff_font_stream_has_data() {
let data = match test_cff_font_data() {
Some(d) => d,
None => return,
};
let font = EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['A']).unwrap();
let stream = subset.to_font_stream().unwrap();
assert!(!stream.data.is_empty(), "CFF font stream should have data");
assert!(
stream.dict.get(&PdfName::new("Length1")).is_some(),
"Should have Length1"
);
}
}