use std::collections::BTreeMap;
use std::path::PathBuf;
use printpdf::{BuiltinFont, FontId, PdfDocument, PdfFontHandle};
use ttf_parser::Face;
use super::ir::{RunFlags, VariantUsage};
use crate::fonts::{FontConfig, FontSource, default_body_source, find_system_font};
#[derive(Debug, Clone, Copy)]
pub enum FontVariant {
HelveticaRegular,
HelveticaBold,
HelveticaItalic,
HelveticaBoldItalic,
CourierRegular,
CourierBold,
CourierItalic,
CourierBoldItalic,
}
impl FontVariant {
pub fn for_flags(flags: RunFlags) -> Self {
match (flags.monospace, flags.bold, flags.italic) {
(true, true, true) => FontVariant::CourierBoldItalic,
(true, true, false) => FontVariant::CourierBold,
(true, false, true) => FontVariant::CourierItalic,
(true, false, false) => FontVariant::CourierRegular,
(false, true, true) => FontVariant::HelveticaBoldItalic,
(false, true, false) => FontVariant::HelveticaBold,
(false, false, true) => FontVariant::HelveticaItalic,
(false, false, false) => FontVariant::HelveticaRegular,
}
}
pub fn builtin(self) -> BuiltinFont {
match self {
FontVariant::HelveticaRegular => BuiltinFont::Helvetica,
FontVariant::HelveticaBold => BuiltinFont::HelveticaBold,
FontVariant::HelveticaItalic => BuiltinFont::HelveticaOblique,
FontVariant::HelveticaBoldItalic => BuiltinFont::HelveticaBoldOblique,
FontVariant::CourierRegular => BuiltinFont::Courier,
FontVariant::CourierBold => BuiltinFont::CourierBold,
FontVariant::CourierItalic => BuiltinFont::CourierOblique,
FontVariant::CourierBoldItalic => BuiltinFont::CourierBoldOblique,
}
}
}
pub struct VariantMetrics {
pub units_per_em: u16,
pub unscaled_widths: Box<[u16; 256]>,
pub fallback_width: u16,
}
impl VariantMetrics {
fn load(variant: FontVariant) -> Self {
let subset = variant.builtin().get_subset_font();
let face = Face::parse(&subset.bytes, 0).expect("built-in subset font must parse");
let units_per_em = face.units_per_em();
let mut widths = Box::new([0u16; 256]);
for (i, slot) in widths.iter_mut().enumerate() {
let ch = char::from_u32(i as u32).unwrap_or('\0');
*slot = face
.glyph_index(ch)
.and_then(|gid| face.glyph_hor_advance(gid))
.unwrap_or(0);
}
backfill_afm_widths(variant, units_per_em, &mut widths);
let (sum, count) = widths.iter().fold((0u64, 0u64), |(s, c), w| {
if *w > 0 {
(s + *w as u64, c + 1)
} else {
(s, c)
}
});
let fallback_width = if count > 0 {
(sum / count) as u16
} else {
500
};
VariantMetrics {
units_per_em,
unscaled_widths: widths,
fallback_width,
}
}
#[allow(dead_code)]
pub fn units_per_em(&self) -> u16 {
self.units_per_em
}
pub fn measure(&self, text: &str, font_size_pt: f32) -> f32 {
let mut unscaled: u64 = 0;
for c in text.chars() {
for_each_builtin_emit_char(c, |emitted| {
let idx = emitted as u32 as usize;
let w = if idx < 256 {
self.unscaled_widths[idx]
} else {
0
};
let w = if w == 0 { self.fallback_width } else { w };
unscaled += w as u64;
});
}
unscaled as f32 * font_size_pt / self.units_per_em as f32
}
}
pub struct FontMetricsCache {
helvetica_regular: VariantMetrics,
helvetica_bold: VariantMetrics,
helvetica_italic: VariantMetrics,
helvetica_bold_italic: VariantMetrics,
courier_regular: VariantMetrics,
courier_bold: VariantMetrics,
courier_italic: VariantMetrics,
courier_bold_italic: VariantMetrics,
}
impl FontMetricsCache {
pub fn new() -> Self {
Self {
helvetica_regular: VariantMetrics::load(FontVariant::HelveticaRegular),
helvetica_bold: VariantMetrics::load(FontVariant::HelveticaBold),
helvetica_italic: VariantMetrics::load(FontVariant::HelveticaItalic),
helvetica_bold_italic: VariantMetrics::load(FontVariant::HelveticaBoldItalic),
courier_regular: VariantMetrics::load(FontVariant::CourierRegular),
courier_bold: VariantMetrics::load(FontVariant::CourierBold),
courier_italic: VariantMetrics::load(FontVariant::CourierItalic),
courier_bold_italic: VariantMetrics::load(FontVariant::CourierBoldItalic),
}
}
pub fn for_variant(&self, v: FontVariant) -> &VariantMetrics {
match v {
FontVariant::HelveticaRegular => &self.helvetica_regular,
FontVariant::HelveticaBold => &self.helvetica_bold,
FontVariant::HelveticaItalic => &self.helvetica_italic,
FontVariant::HelveticaBoldItalic => &self.helvetica_bold_italic,
FontVariant::CourierRegular => &self.courier_regular,
FontVariant::CourierBold => &self.courier_bold,
FontVariant::CourierItalic => &self.courier_italic,
FontVariant::CourierBoldItalic => &self.courier_bold_italic,
}
}
}
pub struct ExternalFont {
pub font_id: FontId,
units_per_em: u16,
advance_by_codepoint: BTreeMap<u32, u16>,
fallback_advance: u16,
}
impl ExternalFont {
pub fn measure(&self, text: &str, font_size_pt: f32) -> f32 {
let mut unscaled: u64 = 0;
for c in text.chars() {
let w = self
.advance_by_codepoint
.get(&(c as u32))
.copied()
.unwrap_or(self.fallback_advance);
unscaled += w as u64;
}
unscaled as f32 * font_size_pt / self.units_per_em as f32
}
pub fn covers(&self, c: char) -> bool {
self.advance_by_codepoint.contains_key(&(c as u32))
}
}
pub struct FontSet {
pub builtin: FontMetricsCache,
pub external_body: ExternalFamily,
pub external_code: ExternalFamily,
pub external_code_inline: ExternalFamily,
pub fallbacks: Vec<ExternalFont>,
}
#[derive(Default)]
pub struct ExternalFamily {
pub regular: Option<ExternalFont>,
pub bold: Option<ExternalFont>,
pub italic: Option<ExternalFont>,
pub bold_italic: Option<ExternalFont>,
}
impl ExternalFamily {
pub fn pick(&self, flags: RunFlags) -> Option<&ExternalFont> {
match (flags.bold, flags.italic) {
(true, true) => self
.bold_italic
.as_ref()
.or(self.bold.as_ref())
.or(self.italic.as_ref())
.or(self.regular.as_ref()),
(true, false) => self.bold.as_ref().or(self.regular.as_ref()),
(false, true) => self.italic.as_ref().or(self.regular.as_ref()),
(false, false) => self.regular.as_ref(),
}
}
pub fn is_loaded(&self) -> bool {
self.regular.is_some()
|| self.bold.is_some()
|| self.italic.is_some()
|| self.bold_italic.is_some()
}
}
pub enum FontResolution<'a> {
Builtin {
handle: PdfFontHandle,
metrics: &'a VariantMetrics,
},
External {
handle: PdfFontHandle,
font: &'a ExternalFont,
},
}
#[derive(Debug, Clone)]
pub struct EmitChunk {
pub handle: PdfFontHandle,
pub needs_transliteration: bool,
pub text: String,
pub width_pt: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FontPick {
Primary,
Fallback(usize),
}
impl FontSet {
pub fn load(
font_config: Option<&FontConfig>,
used_codepoints: &[char],
usage: VariantUsage,
doc: &mut PdfDocument,
) -> Self {
let builtin = FontMetricsCache::new();
let body_variants = BodyVariantNeed {
bold: usage.body_bold || usage.body_bold_italic,
italic: usage.body_italic || usage.body_bold_italic,
bold_italic: usage.body_bold_italic,
};
let code_variants = BodyVariantNeed {
bold: usage.mono_bold
|| usage.mono_bold_italic
|| usage.inline_code_bold
|| usage.inline_code_bold_italic,
italic: usage.mono_italic
|| usage.mono_bold_italic
|| usage.inline_code_italic
|| usage.inline_code_bold_italic,
bold_italic: usage.mono_bold_italic || usage.inline_code_bold_italic,
};
let user_src = font_config.and_then(default_source);
let opted_into_builtin = matches!(&user_src, Some(FontSource::Builtin(_)));
let external_body = load_external_family(user_src, used_codepoints, body_variants, doc)
.or_else(|| {
if opted_into_builtin {
return None;
}
load_external_family(
default_body_source(),
used_codepoints,
body_variants,
doc,
)
})
.unwrap_or_default();
let user_code_src = font_config.and_then(code_source);
let code_src = match user_code_src {
Some(src) => Some(src),
None if external_body.is_loaded() => default_monospace_source(),
None => None,
};
let external_code = load_external_family(code_src, used_codepoints, code_variants, doc)
.unwrap_or_default();
let fallbacks = load_fallbacks(font_config, used_codepoints, doc);
Self {
builtin,
external_body,
external_code,
external_code_inline: ExternalFamily::default(),
fallbacks,
}
}
pub fn load_with_style_fallbacks(
font_config: Option<&FontConfig>,
style_fallback_names: &[String],
code_inline_name: Option<&str>,
used_codepoints: &[char],
usage: VariantUsage,
doc: &mut PdfDocument,
) -> Self {
let mut set = Self::load(font_config, used_codepoints, usage, doc);
if let Some(name) = code_inline_name {
let inline_variants = BodyVariantNeed {
bold: usage.inline_code_bold || usage.inline_code_bold_italic,
italic: usage.inline_code_italic || usage.inline_code_bold_italic,
bold_italic: usage.inline_code_bold_italic,
};
set.external_code_inline = load_external_family(
Some(name_to_external_source(name)),
used_codepoints,
inline_variants,
doc,
)
.unwrap_or_default();
}
for name in style_fallback_names {
let src = name_to_external_source(name);
let Some((_, bytes)) = resolve_regular(src) else {
continue;
};
if let Some(font) = parse_and_register(bytes, "fallback", used_codepoints, doc) {
set.fallbacks.push(font);
}
}
set
}
pub fn resolve(&self, flags: RunFlags) -> FontResolution<'_> {
if flags.inline_code {
if let Some(ext) = self.external_code_inline.pick(flags) {
return FontResolution::External {
handle: PdfFontHandle::External(ext.font_id.clone()),
font: ext,
};
}
}
if flags.monospace {
if let Some(ext) = self.external_code.pick(flags) {
return FontResolution::External {
handle: PdfFontHandle::External(ext.font_id.clone()),
font: ext,
};
}
} else if let Some(ext) = self.external_body.pick(flags) {
return FontResolution::External {
handle: PdfFontHandle::External(ext.font_id.clone()),
font: ext,
};
}
let variant = FontVariant::for_flags(flags);
FontResolution::Builtin {
handle: PdfFontHandle::Builtin(variant.builtin()),
metrics: self.builtin.for_variant(variant),
}
}
pub fn measure(&self, flags: RunFlags, text: &str, size_pt: f32) -> f32 {
if self.fallbacks.is_empty() {
return match self.resolve(flags) {
FontResolution::Builtin { metrics, .. } => metrics.measure(text, size_pt),
FontResolution::External { font, .. } => font.measure(text, size_pt),
};
}
self.split_for_emit(flags, text, size_pt)
.iter()
.map(|c| c.width_pt)
.sum()
}
pub fn needs_transliteration(&self, flags: RunFlags) -> bool {
matches!(self.resolve(flags), FontResolution::Builtin { .. })
}
pub fn split_for_emit(
&self,
flags: RunFlags,
text: &str,
size_pt: f32,
) -> Vec<EmitChunk> {
if text.is_empty() {
return Vec::new();
}
let primary = self.resolve(flags);
if self.fallbacks.is_empty() {
return vec![chunk_from_resolution(&primary, text.to_string(), size_pt)];
}
let mut chunks: Vec<EmitChunk> = Vec::new();
let mut buf = String::new();
let mut current: Option<FontPick> = None;
for c in text.chars() {
let pick = if primary_covers(&primary, c) {
FontPick::Primary
} else if let Some(idx) = self.fallbacks.iter().position(|f| f.covers(c)) {
FontPick::Fallback(idx)
} else {
FontPick::Primary
};
match current {
Some(cur) if cur == pick => buf.push(c),
Some(cur) => {
chunks.push(self.build_chunk(cur, std::mem::take(&mut buf), &primary, size_pt));
buf.push(c);
current = Some(pick);
}
None => {
buf.push(c);
current = Some(pick);
}
}
}
if let Some(cur) = current {
chunks.push(self.build_chunk(cur, buf, &primary, size_pt));
}
chunks
}
fn build_chunk(
&self,
pick: FontPick,
text: String,
primary: &FontResolution<'_>,
size_pt: f32,
) -> EmitChunk {
match pick {
FontPick::Primary => chunk_from_resolution(primary, text, size_pt),
FontPick::Fallback(idx) => {
let font = &self.fallbacks[idx];
let width_pt = font.measure(&text, size_pt);
EmitChunk {
handle: PdfFontHandle::External(font.font_id.clone()),
needs_transliteration: false,
text,
width_pt,
}
}
}
}
}
fn primary_covers(primary: &FontResolution<'_>, c: char) -> bool {
match primary {
FontResolution::External { font, .. } => font.covers(c),
FontResolution::Builtin { .. } => (c as u32) < 0x80,
}
}
fn chunk_from_resolution(
primary: &FontResolution<'_>,
text: String,
size_pt: f32,
) -> EmitChunk {
match primary {
FontResolution::Builtin { handle, metrics } => {
let width_pt = metrics.measure(&text, size_pt);
EmitChunk {
handle: handle.clone(),
needs_transliteration: true,
text,
width_pt,
}
}
FontResolution::External { handle, font } => {
let width_pt = font.measure(&text, size_pt);
EmitChunk {
handle: handle.clone(),
needs_transliteration: false,
text,
width_pt,
}
}
}
}
fn load_fallbacks(
font_config: Option<&FontConfig>,
used_codepoints: &[char],
doc: &mut PdfDocument,
) -> Vec<ExternalFont> {
let mut out = Vec::new();
let Some(cfg) = font_config else {
return out;
};
let mut sources: Vec<FontSource> = Vec::new();
sources.extend(cfg.fallback_font_sources.iter().cloned());
sources.extend(cfg.fallback_fonts.iter().map(|n| name_to_external_source(n)));
for src in sources {
let Some((_, bytes)) = resolve_regular(src) else {
continue;
};
if let Some(font) = parse_and_register(bytes, "fallback", used_codepoints, doc) {
out.push(font);
}
}
out
}
fn default_source(c: &FontConfig) -> Option<FontSource> {
if let Some(src) = c.default_font_source.clone() {
return Some(src);
}
c.default_font.as_deref().map(name_to_external_source)
}
fn code_source(c: &FontConfig) -> Option<FontSource> {
if let Some(src) = c.code_font_source.clone() {
return Some(src);
}
c.code_font.as_deref().map(name_to_external_source)
}
fn default_monospace_source() -> Option<FontSource> {
#[cfg(target_os = "macos")]
const CANDIDATES: &[&str] = &["Menlo", "Monaco", "Courier New"];
#[cfg(target_os = "windows")]
const CANDIDATES: &[&str] = &["Consolas", "Cascadia Code", "Courier New", "Lucida Console"];
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const CANDIDATES: &[&str] = &[
"DejaVu Sans Mono",
"Liberation Mono",
"Noto Sans Mono",
"Ubuntu Mono",
];
for name in CANDIDATES {
if find_system_font(name).is_some() {
return Some(FontSource::System((*name).to_string()));
}
}
None
}
fn name_to_external_source(name: &str) -> FontSource {
if name.contains('/')
|| name.contains('\\')
|| name.ends_with(".ttf")
|| name.ends_with(".otf")
{
return FontSource::File(name.into());
}
FontSource::System(name.to_string())
}
fn resolve_regular(source: FontSource) -> Option<(Option<PathBuf>, Vec<u8>)> {
match source {
FontSource::Builtin(_) => None,
FontSource::Bytes(b) => Some((None, b.to_vec())),
FontSource::File(path) => {
let bytes = read_font_file(&path)?;
Some((Some(path), bytes))
}
FontSource::System(name) => {
let path = find_system_font(&name).or_else(|| {
log::warn!("could not locate system font {:?}", name);
None
})?;
let bytes = read_font_file(&path)?;
Some((Some(path), bytes))
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BodyVariantNeed {
pub bold: bool,
pub italic: bool,
pub bold_italic: bool,
}
fn load_external_family(
source: Option<FontSource>,
used_codepoints: &[char],
need: BodyVariantNeed,
doc: &mut PdfDocument,
) -> Option<ExternalFamily> {
let source = source?;
let (anchor_path, regular_bytes) = resolve_regular(source)?;
let regular = parse_and_register(regular_bytes, "regular", used_codepoints, doc)?;
let mut family = ExternalFamily {
regular: Some(regular),
..ExternalFamily::default()
};
if let Some(path) = anchor_path {
let candidates: &[(VariantKind, &[&str], bool)] = &[
(VariantKind::Bold, &["Bold"], need.bold),
(VariantKind::Italic, &["Italic", "Oblique"], need.italic),
(
VariantKind::BoldItalic,
&["Bold Italic", "BoldItalic", "Bold-Italic", "BoldOblique"],
need.bold_italic,
),
];
for (kind, names, wanted) in candidates {
if !wanted {
continue;
}
if let Some(variant_path) = find_variant_path(&path, names) {
if let Some(bytes) = read_font_file(&variant_path) {
if let Some(parsed) =
parse_and_register(bytes, kind.label(), used_codepoints, doc)
{
match kind {
VariantKind::Bold => family.bold = Some(parsed),
VariantKind::Italic => family.italic = Some(parsed),
VariantKind::BoldItalic => family.bold_italic = Some(parsed),
}
}
}
}
}
}
if family.is_loaded() { Some(family) } else { None }
}
#[derive(Clone, Copy)]
enum VariantKind {
Bold,
Italic,
BoldItalic,
}
impl VariantKind {
fn label(self) -> &'static str {
match self {
VariantKind::Bold => "bold",
VariantKind::Italic => "italic",
VariantKind::BoldItalic => "bold-italic",
}
}
}
fn find_variant_path(anchor: &std::path::Path, variant_names: &[&str]) -> Option<PathBuf> {
let parent = anchor.parent()?;
let stem = anchor.file_stem()?.to_string_lossy().to_string();
for variant in variant_names {
for sep in [" ", "-", ""] {
for ext in ["ttf", "otf"] {
let candidate = parent.join(format!("{}{}{}.{}", stem, sep, variant, ext));
if candidate.exists() {
return Some(candidate);
}
}
}
}
None
}
pub fn for_each_builtin_emit_char(c: char, mut f: impl FnMut(char)) {
match c as u32 {
0x00..=0x7F => f(c),
0x2014 => {
f('-');
f('-');
}
0x2013 => f('-'),
0x2022 => f('*'),
0x2018 | 0x2019 => f('\''),
0x201C | 0x201D => f('"'),
0x2026 => {
f('.');
f('.');
f('.');
}
0x00A0 => f(' '),
0x00A9 => {
f('(');
f('c');
f(')');
}
0x00AE => {
f('(');
f('R');
f(')');
}
0x2122 => {
f('(');
f('T');
f('M');
f(')');
}
_ => f('?'),
}
}
const RENDERER_INJECTED_CHARS: &[char] = &[
'\u{2022}', '[', ']', 'x', ' ', '.',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'(', ')', ':', '-',
];
fn parse_and_register(
bytes: Vec<u8>,
label: &str,
used_codepoints: &[char],
doc: &mut PdfDocument,
) -> Option<ExternalFont> {
let face = match Face::parse(&bytes, 0) {
Ok(f) => f,
Err(e) => {
log::warn!("could not parse {} font face: {}", label, e);
return None;
}
};
let units_per_em = face.units_per_em();
let mut codepoints: Vec<char> = used_codepoints.to_vec();
codepoints.extend_from_slice(RENDERER_INJECTED_CHARS);
codepoints.sort();
codepoints.dedup();
let used_codepoints = &codepoints[..];
let mut codepoint_to_orig_gid: BTreeMap<u32, u16> = BTreeMap::new();
let mut orig_gid_advance: BTreeMap<u16, u16> = BTreeMap::new();
for &ch in used_codepoints {
let cp = ch as u32;
if let Some(gid) = face.glyph_index(ch) {
if let Some(w) = face.glyph_hor_advance(gid) {
codepoint_to_orig_gid.insert(cp, gid.0);
orig_gid_advance.insert(gid.0, w);
}
}
}
let orig_gids: Vec<u16> = orig_gid_advance.keys().copied().collect();
let remapper = subsetter::GlyphRemapper::new_from_glyphs_sorted(&orig_gids);
let (subset_bytes, gid_remap): (Vec<u8>, Box<dyn Fn(u16) -> u16>) =
match subsetter::subset(&bytes, 0, &remapper) {
Ok(b) => (
b,
Box::new(move |old| remapper.get(old).unwrap_or(0)),
),
Err(e) => {
log::warn!(
"could not subset {} font: {:?}; embedding full font instead",
label,
e
);
(bytes.clone(), Box::new(|old| old))
}
};
let mut advance_by_codepoint: BTreeMap<u32, u16> = BTreeMap::new();
let mut codepoint_to_glyph: BTreeMap<u32, u16> = BTreeMap::new();
let mut glyph_widths: BTreeMap<u16, u16> = BTreeMap::new();
let mut sum: u64 = 0;
let mut count: u64 = 0;
for (cp, orig_gid) in &codepoint_to_orig_gid {
let new_gid = gid_remap(*orig_gid);
let w = orig_gid_advance.get(orig_gid).copied().unwrap_or(0);
codepoint_to_glyph.insert(*cp, new_gid);
glyph_widths.insert(new_gid, w);
advance_by_codepoint.insert(*cp, w);
if w > 0 {
sum += w as u64;
count += 1;
}
}
let fallback_advance = if count > 0 {
(sum / count) as u16
} else {
units_per_em / 2
};
let ascent = normalize_to_1000_em(face.ascender(), units_per_em);
let descent = normalize_to_1000_em(face.descender(), units_per_em);
let parsed = printpdf::ParsedFont::with_glyph_data(
subset_bytes,
0,
None,
codepoint_to_glyph,
glyph_widths,
units_per_em,
printpdf::FontMetrics { ascent, descent },
);
let font_id = doc.add_font(&parsed);
Some(ExternalFont {
font_id,
units_per_em,
advance_by_codepoint,
fallback_advance,
})
}
fn normalize_to_1000_em(value: i16, units_per_em: u16) -> i16 {
let upem = i32::from(units_per_em).max(1);
let scaled = i32::from(value) * 1000 / upem;
scaled.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
}
fn read_font_file(path: &std::path::Path) -> Option<Vec<u8>> {
std::fs::read(path)
.map_err(|e| log::warn!("could not read font {:?}: {}", path, e))
.ok()
}
fn backfill_afm_widths(variant: FontVariant, units_per_em: u16, widths: &mut [u16; 256]) {
let space_per_1000 = match variant {
FontVariant::HelveticaRegular
| FontVariant::HelveticaBold
| FontVariant::HelveticaItalic
| FontVariant::HelveticaBoldItalic => 278u32,
FontVariant::CourierRegular
| FontVariant::CourierBold
| FontVariant::CourierItalic
| FontVariant::CourierBoldItalic => 600u32,
};
if widths[b' ' as usize] == 0 {
let scaled = space_per_1000 * units_per_em as u32 / 1000;
widths[b' ' as usize] = scaled.min(u16::MAX as u32) as u16;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_helvetica_width_monotonic_in_text_length() {
let cache = FontMetricsCache::new();
let m = cache.for_variant(FontVariant::HelveticaRegular);
let a = m.measure("a", 12.0);
let aaa = m.measure("aaa", 12.0);
assert!(aaa > a * 2.5);
assert!(a > 1.0 && a < 12.0, "unexpected width: {}", a);
}
#[test]
fn courier_is_monospace() {
let cache = FontMetricsCache::new();
let m = cache.for_variant(FontVariant::CourierRegular);
let i = m.measure("i", 12.0);
let w = m.measure("w", 12.0);
assert!((i - w).abs() < 0.5, "courier should be monospace");
}
#[test]
fn normalize_to_1000_em_is_font_agnostic() {
assert_eq!(normalize_to_1000_em(1878, 2048), 916);
assert_eq!(normalize_to_1000_em(-449, 2048), -219);
assert_eq!(normalize_to_1000_em(800, 1000), 800);
assert_eq!(normalize_to_1000_em(-200, 1000), -200);
assert_eq!(normalize_to_1000_em(819, 1024), 799);
assert_eq!(normalize_to_1000_em(3000, 4096), 732);
assert_eq!(normalize_to_1000_em(1000, 0), i16::MAX);
assert_eq!(normalize_to_1000_em(i16::MAX, 1), i16::MAX);
assert_eq!(normalize_to_1000_em(i16::MIN, 1), i16::MIN);
}
#[test]
fn default_monospace_source_resolves_on_supported_oses() {
let src = default_monospace_source();
if cfg!(any(target_os = "macos", target_os = "windows")) {
assert!(
src.is_some(),
"expected a system monospace fallback on macOS/Windows"
);
}
}
#[test]
fn split_with_no_fallbacks_returns_single_chunk() {
let mut doc = PdfDocument::new("test");
let set = FontSet::load(None, &[], VariantUsage::default(), &mut doc);
let chunks = set.split_for_emit(RunFlags::default(), "Hello", 12.0);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].text, "Hello");
let on_external_path = set.external_body.is_loaded();
assert_eq!(chunks[0].needs_transliteration, !on_external_path);
assert!(chunks[0].width_pt > 0.0);
}
#[test]
fn split_empty_text_returns_empty() {
let mut doc = PdfDocument::new("test");
let set = FontSet::load(None, &[], VariantUsage::default(), &mut doc);
let chunks = set.split_for_emit(RunFlags::default(), "", 12.0);
assert!(chunks.is_empty());
}
#[test]
fn measure_equals_sum_of_per_chunk_widths() {
let mut doc = PdfDocument::new("test");
let set = FontSet::load(None, &[], VariantUsage::default(), &mut doc);
let cases = ["", "Hello", "Hello world", "ABCDE 12345 !?.,"];
for text in cases {
let direct = set.measure(RunFlags::default(), text, 10.0);
let summed: f32 = set
.split_for_emit(RunFlags::default(), text, 10.0)
.iter()
.map(|c| c.width_pt)
.sum();
assert!(
(direct - summed).abs() < 1e-3,
"measure({:?}) {} != sum-of-chunks {}",
text,
direct,
summed
);
}
}
#[test]
fn builtin_measure_matches_transliterated_emit() {
let cache = FontMetricsCache::new();
let m = cache.for_variant(FontVariant::HelveticaRegular);
let pairs = [
("\u{2022}", "*"),
("\u{2014}", "--"),
("\u{2013}", "-"),
("\u{2026}", "..."),
("\u{2018}", "'"),
("\u{2019}", "'"),
("\u{201C}", "\""),
("\u{201D}", "\""),
("\u{00A0}", " "),
("\u{00A9}", "(c)"),
("\u{00AE}", "(R)"),
("\u{2122}", "(TM)"),
("a \u{2022} b \u{2014} c \u{2026}", "a * b -- c ..."),
];
for (src, emitted) in pairs {
let measured = m.measure(src, 12.0);
let actual = m.measure(emitted, 12.0);
assert!(
(measured - actual).abs() < 1e-3,
"measure({:?}) = {} but emit writes {:?} with width {}",
src,
measured,
emitted,
actual,
);
}
let unknown = m.measure("\u{4E2D}", 12.0);
let q = m.measure("?", 12.0);
assert!(
(unknown - q).abs() < 1e-3,
"uncurated codepoint should price as '?': {} vs {}",
unknown,
q,
);
}
#[test]
fn missing_fallback_source_does_not_panic() {
let cfg = FontConfig {
default_font: None,
code_font: None,
default_font_source: None,
code_font_source: None,
fallback_fonts: vec!["This_Font_Definitely_Does_Not_Exist_12345".to_string()],
fallback_font_sources: Vec::new(),
enable_subsetting: true,
};
let mut doc = PdfDocument::new("test");
let set = FontSet::load(Some(&cfg), &['日' as char], VariantUsage::default(), &mut doc);
assert!(set.fallbacks.is_empty());
let chunks = set.split_for_emit(RunFlags::default(), "日", 12.0);
assert_eq!(chunks.len(), 1);
let on_external_path = set.external_body.is_loaded();
assert_eq!(chunks[0].needs_transliteration, !on_external_path);
}
#[test]
fn for_flags_routes_correctly() {
assert!(matches!(
FontVariant::for_flags(RunFlags::default()),
FontVariant::HelveticaRegular
));
assert!(matches!(
FontVariant::for_flags(RunFlags::default().with_bold()),
FontVariant::HelveticaBold
));
assert!(matches!(
FontVariant::for_flags(RunFlags::default().with_monospace().with_bold()),
FontVariant::CourierBold
));
}
}