use lopdf::{Dictionary, Document, FontData, Object, ObjectId};
const FIRST_CHAR: u8 = 32;
const LAST_CHAR: u8 = 255;
const PDF_GLYPH_UNITS: f64 = 1000.0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmbedFontError {
NotAFont,
ZeroUnitsPerEm,
CffOutlinesNotSupported,
TooManyGlyphsForSimpleFont {
glyphs: u16,
},
Document(String),
}
impl core::fmt::Display for EmbedFontError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NotAFont => write!(f, "the data is not a readable TrueType or OpenType font"),
Self::ZeroUnitsPerEm => write!(f, "the font declares unitsPerEm of zero"),
Self::CffOutlinesNotSupported => write!(
f,
"the font has CFF outlines, which need /FontFile3; this path writes \
/FontFile2 and would produce a font viewers cannot load"
),
Self::TooManyGlyphsForSimpleFont { glyphs } => write!(
f,
"the font has {glyphs} glyphs; a simple font addresses at most 256, \
and composite (Type0/CID) fonts are not written by this path"
),
Self::Document(why) => write!(f, "the document could not be modified: {why}"),
}
}
}
impl std::error::Error for EmbedFontError {}
#[derive(Debug, Clone)]
pub struct EmbeddedFont {
pub font_id: ObjectId,
pub resource_name: String,
pub pages_registered: usize,
}
pub fn embed_font(
doc: &mut Document,
font_data: &[u8],
name: &str,
) -> Result<EmbeddedFont, EmbedFontError> {
let face = ttf_parser::Face::parse(font_data, 0).map_err(|_| EmbedFontError::NotAFont)?;
let units_per_em = face.units_per_em();
if units_per_em == 0 {
return Err(EmbedFontError::ZeroUnitsPerEm);
}
if face.tables().cmap.is_none() {
return Err(EmbedFontError::TooManyGlyphsForSimpleFont {
glyphs: face.number_of_glyphs(),
});
}
if face.tables().cff.is_some() || face.tables().cff2.is_some() {
return Err(EmbedFontError::CffOutlinesNotSupported);
}
let widths = widths_for_winansi(&face, units_per_em);
let font_id = doc.add_font(FontData::new(font_data, name.to_string()));
let font_id = font_id.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?;
{
let dict = doc
.get_object_mut(font_id)
.and_then(Object::as_dict_mut)
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?;
dict.set("FirstChar", Object::Integer(i64::from(FIRST_CHAR)));
dict.set("LastChar", Object::Integer(i64::from(LAST_CHAR)));
dict.set(
"Widths",
Object::Array(widths.into_iter().map(Object::Integer).collect()),
);
}
let resource_name = unused_resource_name(doc, name);
let pages_registered = register_on_every_page(doc, &resource_name, font_id)?;
Ok(EmbeddedFont {
font_id,
resource_name,
pages_registered,
})
}
fn widths_for_winansi(face: &ttf_parser::Face<'_>, units_per_em: u16) -> Vec<i64> {
let scale = PDF_GLYPH_UNITS / f64::from(units_per_em);
(FIRST_CHAR..=LAST_CHAR)
.map(|code| {
let advance = face
.glyph_index(winansi_to_char(code))
.and_then(|gid| face.glyph_hor_advance(gid))
.unwrap_or(0);
(f64::from(advance) * scale).round() as i64
})
.collect()
}
fn winansi_to_char(code: u8) -> char {
const C1: [Option<char>; 32] = [
Some('\u{20AC}'),
None,
Some('\u{201A}'),
Some('\u{0192}'),
Some('\u{201E}'),
Some('\u{2026}'),
Some('\u{2020}'),
Some('\u{2021}'),
Some('\u{02C6}'),
Some('\u{2030}'),
Some('\u{0160}'),
Some('\u{2039}'),
Some('\u{0152}'),
None,
Some('\u{017D}'),
None,
None,
Some('\u{2018}'),
Some('\u{2019}'),
Some('\u{201C}'),
Some('\u{201D}'),
Some('\u{2022}'),
Some('\u{2013}'),
Some('\u{2014}'),
Some('\u{02DC}'),
Some('\u{2122}'),
Some('\u{0161}'),
Some('\u{203A}'),
Some('\u{0153}'),
None,
Some('\u{017E}'),
Some('\u{0178}'),
];
if (0x80..=0x9F).contains(&code) {
return C1[usize::from(code - 0x80)].unwrap_or(char::from(code));
}
char::from(code)
}
fn unused_resource_name(doc: &Document, wanted: &str) -> String {
let taken = existing_font_resource_names(doc);
if !taken.contains(&wanted.to_string()) {
return wanted.to_string();
}
(1..)
.map(|n| format!("{wanted}{n}"))
.find(|candidate| !taken.contains(candidate))
.expect("the range is unbounded, so some name is free")
}
fn existing_font_resource_names(doc: &Document) -> Vec<String> {
let mut names = Vec::new();
for (_, page_id) in doc.get_pages() {
let Ok((resources, _)) = doc.get_page_resources(page_id) else {
continue;
};
let Some(font_entry) = resources.and_then(|r| r.get(b"Font").ok()) else {
continue;
};
let Ok((_, resolved)) = doc.dereference(font_entry) else {
continue;
};
let Ok(fonts) = resolved.as_dict() else {
continue;
};
for (key, _) in fonts.iter() {
names.push(String::from_utf8_lossy(key).into_owned());
}
}
names
}
fn register_on_every_page(
doc: &mut Document,
resource_name: &str,
font_id: ObjectId,
) -> Result<usize, EmbedFontError> {
let page_ids: Vec<ObjectId> = doc.get_pages().into_values().collect();
let mut registered = 0;
let mut done: Vec<ObjectId> = Vec::new();
for page_id in page_ids {
let holder = resources_holder(doc, page_id)?;
if holder.shared_id.is_some_and(|id| done.contains(&id)) {
registered += 1;
continue;
}
let resources = match holder.shared_id {
Some(id) => doc
.get_object_mut(id)
.and_then(Object::as_dict_mut)
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?,
None => doc
.get_or_create_resources(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?,
};
if !resources.has(b"Font") {
resources.set("Font", Dictionary::new());
}
let mut fonts = resources
.get_mut(b"Font")
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?;
if let Object::Reference(first) = fonts {
let mut fonts_id = *first;
let mut seen = vec![fonts_id];
while let Ok(Object::Reference(next)) = doc.get_object(fonts_id) {
if seen.contains(next) || seen.len() > MAX_REFERENCE_CHAIN {
return Err(EmbedFontError::Document(format!(
"/Resources/Font on page {page_id:?} is a reference cycle"
)));
}
fonts_id = *next;
seen.push(fonts_id);
}
fonts = doc
.get_object_mut(fonts_id)
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?;
}
Object::as_dict_mut(fonts)
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?
.set(resource_name.to_string(), Object::Reference(font_id));
if let Some(id) = holder.shared_id {
done.push(id);
}
registered += 1;
}
Ok(registered)
}
const MAX_REFERENCE_CHAIN: usize = 32;
struct ResourcesHolder {
shared_id: Option<ObjectId>,
}
fn resources_holder(doc: &Document, page_id: ObjectId) -> Result<ResourcesHolder, EmbedFontError> {
let (own, inherited_ids) = doc
.get_page_resources(page_id)
.map_err(|e| EmbedFontError::Document(format!("{e:?}")))?;
if own.is_some() {
return Ok(ResourcesHolder { shared_id: None });
}
Ok(ResourcesHolder {
shared_id: inherited_ids.first().copied(),
})
}