mod cache;
mod discovery;
mod embed;
mod encoding;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Instant;
use pdf_writer::{Name, Pdf, Ref};
use crate::model::{FontFamily, FontTable, Run};
pub(crate) use encoding::{encode_as_gids, to_winansi_bytes};
pub(crate) struct FontMetrics {
pub(crate) widths_1000: Vec<f32>,
pub(crate) line_h_ratio: f32,
pub(crate) ascender_ratio: f32,
pub(crate) grid_line_ratio: Option<f32>,
pub(crate) plain_line_h_ratio: f32,
pub(crate) plain_ascender_ratio: f32,
pub(crate) grid_baseline_shift: f32,
pub(crate) superscript_ratio: Option<f32>,
pub(crate) subscript_ratio: Option<f32>,
pub(crate) underline: Option<(f32, f32)>,
pub(crate) strikeout: Option<(f32, f32)>,
pub(crate) east_asian: bool,
pub(crate) char_to_gid: HashMap<char, u16>,
pub(crate) char_widths_1000: HashMap<char, f32>,
pub(crate) kern_pairs: HashMap<(u16, u16), f32>,
}
struct ResolvedFont {
metrics: FontMetrics,
synthetic_bold: bool,
synthetic_italic: bool,
font_path: Option<PathBuf>,
face_index: u32,
}
pub(crate) struct FontEntry {
pub(crate) pdf_name: String,
pub(crate) font_ref: Ref,
pub(crate) widths_1000: Vec<f32>,
pub(crate) line_h_ratio: Option<f32>,
pub(crate) ascender_ratio: Option<f32>,
pub(crate) grid_line_ratio: Option<f32>,
pub(crate) plain_line_h_ratio: Option<f32>,
pub(crate) plain_ascender_ratio: Option<f32>,
pub(crate) grid_baseline_shift: Option<f32>,
pub(crate) superscript_ratio: Option<f32>,
pub(crate) subscript_ratio: Option<f32>,
pub(crate) underline: Option<(f32, f32)>,
pub(crate) strikeout: Option<(f32, f32)>,
pub(crate) east_asian: bool,
pub(crate) char_to_gid: Option<HashMap<char, u16>>,
pub(crate) char_widths_1000: Option<HashMap<char, f32>>,
pub(crate) kern_pairs: Option<HashMap<(u16, u16), f32>>,
pub(crate) synthetic_bold: bool,
pub(crate) synthetic_italic: bool,
pub(crate) is_substituted: bool,
pub(crate) missing_cjk_chars: HashSet<char>,
pub(crate) drew_notdef: std::cell::Cell<bool>,
pub(crate) font_path: Option<PathBuf>,
pub(crate) face_index: u32,
}
impl FontEntry {
pub(crate) fn char_width_1000(&self, ch: char) -> f32 {
if let Some(w) = self.char_widths_1000.as_ref().and_then(|m| m.get(&ch)) {
return *w;
}
let byte = encoding::char_to_winansi(ch);
if byte >= 32 {
self.widths_1000[(byte - 32) as usize]
} else {
0.0
}
}
pub(crate) fn encode(&self, text: &str) -> Vec<u8> {
match &self.char_to_gid {
Some(map) => encoding::encode_as_gids_noting(text, map, &self.drew_notdef),
None => encoding::to_winansi_bytes(text),
}
}
pub(crate) fn word_width(&self, word: &str, font_size: f32, kern: bool) -> f32 {
if !kern || self.kern_pairs.is_none() {
return word
.chars()
.map(|ch| self.char_width_1000(ch) * font_size / 1000.0)
.sum();
}
let scale = font_size / 1000.0;
let mut prev: Option<char> = None;
let mut w = 0.0;
for ch in word.chars() {
if let Some(p) = prev {
w += self.kern_1000(p, ch) * scale;
}
w += self.char_width_1000(ch) * scale;
prev = Some(ch);
}
w
}
pub(crate) fn kern_1000(&self, left: char, right: char) -> f32 {
let (Some(pairs), Some(c2g)) = (&self.kern_pairs, &self.char_to_gid) else {
return 0.0;
};
c2g.get(&left)
.zip(c2g.get(&right))
.and_then(|(&l, &r)| pairs.get(&(l, r)))
.copied()
.unwrap_or(0.0)
}
pub(crate) fn space_width(&self, font_size: f32) -> f32 {
self.char_width_1000(' ') * font_size / 1000.0
}
pub(crate) fn has_char(&self, ch: char) -> bool {
self.char_to_gid
.as_ref()
.is_none_or(|m| m.contains_key(&ch))
}
}
pub(crate) fn primary_font_name(name: &str) -> &str {
first_css_name(name.split(';').next().unwrap_or(name))
}
fn first_css_name(name: &str) -> &str {
name.split(',').next().unwrap_or(name).trim()
}
pub(crate) fn font_key_buf<'a>(run: &Run, buf: &'a mut String) -> &'a str {
buf.clear();
buf.push_str(primary_font_name(&run.font_name));
match (run.bold, run.italic) {
(true, true) => buf.push_str("/BI"),
(true, false) => buf.push_str("/B"),
(false, true) => buf.push_str("/I"),
(false, false) => {}
}
buf.as_str()
}
pub(crate) fn font_key(run: &Run) -> String {
let mut buf = String::new();
font_key_buf(run, &mut buf);
buf
}
pub(crate) type EmbeddedFonts = HashMap<(String, bool, bool), Vec<u8>>;
pub(crate) struct FontContext<'a> {
pub(crate) embedded_fonts: &'a EmbeddedFonts,
pub(crate) font_table: &'a FontTable,
pub(crate) used_chars: &'a HashSet<char>,
pub(crate) word_text: bool,
}
#[derive(Clone, Copy)]
struct FontRefs {
font: Ref,
descriptor: Ref,
data: Ref,
}
fn try_font(
pdf: &mut Pdf,
candidate: &str,
bold: bool,
italic: bool,
refs: FontRefs,
alloc: &mut impl FnMut() -> Ref,
ctx: &FontContext,
) -> Option<ResolvedFont> {
let mut embed = |data: &[u8], face_index: u32| {
embed::embed_truetype(
pdf,
refs,
candidate,
data,
face_index,
ctx.used_chars,
alloc,
)
};
let embedded_key = (candidate.to_lowercase(), bold, italic);
if let Some(metrics) = ctx
.embedded_fonts
.get(&embedded_key)
.and_then(|d| embed(d, 0))
{
return Some(ResolvedFont {
metrics,
synthetic_bold: false,
synthetic_italic: false,
font_path: None,
face_index: 0,
});
}
let (path, face_index, face_bold, face_italic) =
discovery::find_font_file(candidate, bold, italic)?;
let data = std::fs::read(&path).ok()?;
let metrics = embed(&data, face_index)?;
Some(ResolvedFont {
metrics,
synthetic_bold: bold && !face_bold,
synthetic_italic: italic && !face_italic,
font_path: Some(path),
face_index,
})
}
fn lookup_font_table<'a>(
font_table: &'a FontTable,
name: &str,
) -> Option<&'a crate::model::FontTableEntry> {
font_table.get(name).or_else(|| {
let lower = name.to_lowercase();
font_table
.iter()
.find(|(k, _)| k.to_lowercase() == lower)
.map(|(_, v)| v)
})
}
const LAST_RESORT_FONTS: &[&str] = &[
"Arial",
"Liberation Sans",
"Arimo",
"Helvetica",
"DejaVu Sans",
];
fn family_fallback(family: Option<FontFamily>) -> &'static str {
match family {
Some(FontFamily::Roman) | None => "Cambria",
Some(_) => "Calibri",
}
}
fn face_is_script_design(path: &std::path::Path, face_index: u32) -> bool {
discovery::probe_face(path, face_index, |face| {
face.raw_face()
.table(ttf_parser::Tag::from_bytes(b"OS/2"))
.is_some_and(|os2| os2.get(30) == Some(&10) || os2.get(32) == Some(&3))
})
.unwrap_or(false)
}
fn word_substitute(name: &str) -> Option<&'static str> {
match name.to_ascii_lowercase().as_str() {
"times" => Some("Times New Roman"),
"courier" => Some("Courier New"),
"helvetica" => Some("Arial"),
_ => None,
}
}
fn family_of_face_name(name: &str) -> Option<&str> {
["Bold Italic", "Bold", "Italic", "Regular"]
.iter()
.find_map(|style| name.strip_suffix(style)?.strip_suffix(' '))
}
fn known_font_alias(name: &str) -> Option<&'static str> {
match name {
"Liberation Sans" => Some("Arial"),
"Liberation Serif" => Some("Times New Roman"),
"Carlito" => Some("Calibri"),
"Palatino Linotype" => Some("Palatino"),
"標楷體" | "DFKai-SB" => Some("BiauKai"),
_ => None,
}
}
fn has_cjk_chars(chars: &HashSet<char>) -> bool {
chars.iter().any(|&c| crate::docx::is_east_asian_char(c))
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum CjkScript {
Unknown,
SimplifiedChinese,
TraditionalChinese,
Japanese,
Korean,
}
fn script_of_chars(chars: impl Iterator<Item = char>) -> CjkScript {
let mut script = CjkScript::Unknown;
for c in chars {
match c as u32 {
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF => return CjkScript::Korean,
0x3040..=0x30FF | 0x31F0..=0x31FF => script = CjkScript::Japanese,
_ => {}
}
}
script
}
fn classify_cjk_script(
primary: &str,
charset: Option<u8>,
used_chars: &HashSet<char>,
) -> CjkScript {
match charset {
Some(0x80) => return CjkScript::Japanese,
Some(0x81) | Some(0x82) => return CjkScript::Korean,
Some(0x86) => return CjkScript::SimplifiedChinese,
Some(0x88) => return CjkScript::TraditionalChinese,
_ => {}
}
const SC_HINTS: &[&str] = &[
"宋体",
"仿宋",
"黑体",
"楷体",
"华文",
"微软雅黑",
"方正",
"SimSun",
"SimHei",
"FangSong",
"KaiTi",
"Microsoft YaHei",
"STSong",
"STFangsong",
"STKaiti",
"STHeiti",
"STZhongsong",
];
const TC_HINTS: &[&str] = &[
"細明體",
"新細明體",
"標楷體",
"微軟正黑體",
"華康",
"PMingLiU",
"MingLiU",
"DFKai-SB",
"Microsoft JhengHei",
];
const JA_HINTS: &[&str] = &[
"明朝",
"ゴシック",
"メイリオ",
"游明朝",
"游ゴシック",
"MS明朝",
"MS ゴシック",
"MS P明朝",
"MS Pゴシック",
"MS Mincho",
"MS Gothic",
"MS PMincho",
"MS PGothic",
"Meiryo",
"Yu Mincho",
"Yu Gothic",
"Hiragino",
];
const KO_HINTS: &[&str] = &[
"바탕",
"돋움",
"굴림",
"궁서",
"맑은 고딕",
"나눔",
"Batang",
"Dotum",
"Gulim",
"Gungsuh",
"Malgun Gothic",
"Nanum",
];
let by_name = [
(SC_HINTS, CjkScript::SimplifiedChinese),
(TC_HINTS, CjkScript::TraditionalChinese),
(JA_HINTS, CjkScript::Japanese),
(KO_HINTS, CjkScript::Korean),
]
.into_iter()
.find(|(hints, _)| hints.iter().any(|h| primary.contains(h)));
if let Some((_, script)) = by_name {
return script;
}
match script_of_chars(primary.chars()) {
CjkScript::Unknown => script_of_chars(used_chars.iter().copied()),
s => s,
}
}
fn cjk_fallback_fonts(script: CjkScript, serif: bool) -> &'static [&'static str] {
use CjkScript::*;
match (script, serif) {
(Korean, true) => &[
"Batang",
"Malgun Gothic",
"Gulim",
"AppleMyungjo",
"Apple SD Gothic Neo",
"Noto Serif CJK KR",
"Noto Sans CJK KR",
"Arial Unicode MS",
],
(Korean, false) => &[
"Malgun Gothic",
"Gulim",
"Batang",
"Apple SD Gothic Neo",
"AppleGothic",
"Noto Sans CJK KR",
"Arial Unicode MS",
],
(Japanese, true) => &[
"MS Mincho",
"Yu Mincho",
"MS Gothic",
"Yu Gothic",
"Meiryo",
"Hiragino Mincho ProN W3",
"Hiragino Kaku Gothic ProN W3",
"Noto Serif CJK JP",
"Noto Sans CJK JP",
"Arial Unicode MS",
],
(Japanese, false) => &[
"MS Gothic",
"Yu Gothic",
"Meiryo",
"MS Mincho",
"Yu Mincho",
"Hiragino Kaku Gothic ProN W3",
"Hiragino Sans W3",
"Noto Sans CJK JP",
"Arial Unicode MS",
],
(SimplifiedChinese, true) => &[
"SimSun",
"Microsoft YaHei",
"Songti SC",
"PingFang SC",
"Hiragino Sans GB W3",
"Noto Serif CJK SC",
"Noto Sans CJK SC",
"Arial Unicode MS",
],
(SimplifiedChinese, false) => &[
"Microsoft YaHei",
"SimSun",
"PingFang SC",
"Hiragino Sans GB W3",
"Songti SC",
"Noto Sans CJK SC",
"Arial Unicode MS",
],
(TraditionalChinese, true) => &[
"PMingLiU",
"MingLiU",
"Microsoft JhengHei",
"Songti TC",
"PingFang TC",
"Noto Serif CJK TC",
"Noto Sans CJK TC",
"Arial Unicode MS",
],
(TraditionalChinese, false) => &[
"Microsoft YaHei",
"Microsoft JhengHei",
"PMingLiU",
"MingLiU",
"PingFang TC",
"Songti TC",
"Noto Sans CJK TC",
"Arial Unicode MS",
],
(Unknown, true) => &[
"MS Mincho",
"SimSun",
"PMingLiU",
"Batang",
"Songti SC",
"Hiragino Mincho ProN W3",
"Noto Serif CJK SC",
"Noto Sans CJK SC",
"Arial Unicode MS",
],
(Unknown, false) => &[
"Microsoft YaHei",
"MS Gothic",
"Malgun Gothic",
"PMingLiU",
"PingFang SC",
"Hiragino Sans GB W3",
"Noto Sans CJK SC",
"Arial Unicode MS",
],
}
}
fn glyph_coverage(name: &str, chars: &HashSet<char>) -> usize {
let Some((path, face_index, ..)) = discovery::find_font_file(name, false, false) else {
return 0;
};
discovery::probe_face(&path, face_index, |face| {
chars
.iter()
.filter(|&&c| face.glyph_index(c).is_some())
.count()
})
.unwrap_or(0)
}
pub(crate) fn cjk_rescue_fonts(missing: &HashSet<char>) -> String {
let script = script_of_chars(missing.iter().copied());
let candidates = cjk_fallback_fonts(script, script == CjkScript::Unknown);
let mut best = (0usize, 0usize);
for (i, name) in candidates.iter().enumerate() {
let coverage = glyph_coverage(name, missing);
if coverage > best.0 {
best = (coverage, i);
}
if coverage == missing.len() {
break;
}
}
let lead = candidates[best.1];
std::iter::once(lead)
.chain(candidates.iter().copied().filter(|n| *n != lead))
.collect::<Vec<_>>()
.join(";")
}
pub(crate) fn register_font(
pdf: &mut Pdf,
font_name: &str,
bold: bool,
italic: bool,
pdf_name: String,
alloc: &mut impl FnMut() -> Ref,
ctx: &FontContext,
) -> FontEntry {
let t0 = Instant::now();
let refs = FontRefs {
font: alloc(),
descriptor: alloc(),
data: alloc(),
};
let primary = primary_font_name(font_name);
let mut try_candidate = |name: &str| try_font(pdf, name, bold, italic, refs, alloc, ctx);
let table_entry = lookup_font_table(ctx.font_table, font_name.trim());
let script = classify_cjk_script(primary, table_entry.and_then(|e| e.charset), ctx.used_chars);
let needs_cjk = script != CjkScript::Unknown || has_cjk_chars(ctx.used_chars);
let serif = table_entry.is_some_and(|e| e.family == FontFamily::Roman);
let substituted = std::cell::Cell::new(false);
let try_list =
|label: &str, names: &[&str], tc: &mut dyn FnMut(&str) -> Option<ResolvedFont>| {
names.iter().find_map(|&name| {
log::debug!("Trying {label} \"{name}\" for \"{primary}\"");
let m = tc(name)?;
log::info!("Font substitution: {primary} → {label} \"{name}\"");
substituted.set(true);
Some(m)
})
};
let cjk_fonts = cjk_fallback_fonts(script, serif);
let has_alt = table_entry.is_some_and(|e| e.alt_name.is_some());
let result = font_name
.split(';')
.map(first_css_name)
.map(|s| word_substitute(s).unwrap_or(s))
.filter(|c| !(has_alt && discovery::is_mac_only_family(c)))
.find_map(&mut try_candidate)
.or_else(|| {
let entry = table_entry?;
let alt = entry.alt_name.as_ref()?;
if alt.contains("Math") || alt == "SignPainter-HouseScript" {
return None;
}
let m = try_candidate(alt)?;
if entry.family != crate::model::FontFamily::Script
&& m.font_path
.as_deref()
.is_some_and(|p| face_is_script_design(p, m.face_index))
{
log::info!("Rejecting script-classified altName \"{alt}\" for {primary}");
return None;
}
log::info!("Font substitution: {primary} → altName \"{alt}\"");
Some(m)
})
.or_else(|| try_candidate(family_of_face_name(primary)?))
.or_else(|| {
let alias = known_font_alias(primary)?;
let m = try_candidate(alias)?;
log::info!("Font substitution: {primary} → alias \"{alias}\"");
Some(m)
})
.or_else(|| {
if !needs_cjk {
return None;
}
try_list("CJK fallback", cjk_fonts, &mut try_candidate)
})
.or_else(|| {
if table_entry.is_none() && !ctx.word_text {
return None;
}
let family = table_entry.map(|e| e.family);
let fallback = family_fallback(family);
let m = try_candidate(fallback)?;
log::info!("Font substitution: {primary} → family {family:?} fallback \"{fallback}\"");
substituted.set(true);
Some(m)
})
.or_else(|| try_list("last resort", LAST_RESORT_FONTS, &mut try_candidate));
let missing_cjk = if needs_cjk {
let covered = result.as_ref().map(|r| &r.metrics.char_to_gid);
ctx.used_chars
.iter()
.copied()
.filter(|ch| {
crate::docx::is_east_asian_char(*ch)
&& !covered.is_some_and(|map| map.contains_key(ch))
})
.collect()
} else {
HashSet::new()
};
let entry = match result {
Some(r) => FontEntry {
pdf_name,
font_ref: refs.font,
widths_1000: r.metrics.widths_1000,
line_h_ratio: Some(r.metrics.line_h_ratio),
ascender_ratio: Some(r.metrics.ascender_ratio),
grid_line_ratio: r.metrics.grid_line_ratio,
plain_line_h_ratio: Some(r.metrics.plain_line_h_ratio),
grid_baseline_shift: Some(r.metrics.grid_baseline_shift),
superscript_ratio: r.metrics.superscript_ratio,
subscript_ratio: r.metrics.subscript_ratio,
underline: r.metrics.underline,
strikeout: r.metrics.strikeout,
east_asian: r.metrics.east_asian,
plain_ascender_ratio: Some(r.metrics.plain_ascender_ratio),
char_to_gid: Some(r.metrics.char_to_gid),
char_widths_1000: Some(r.metrics.char_widths_1000),
kern_pairs: if r.metrics.kern_pairs.is_empty() {
None
} else {
Some(r.metrics.kern_pairs)
},
synthetic_bold: r.synthetic_bold,
synthetic_italic: r.synthetic_italic,
is_substituted: substituted.get(),
missing_cjk_chars: missing_cjk,
drew_notdef: Default::default(),
font_path: r.font_path,
face_index: r.face_index,
},
None => {
let base_font: &[u8] = match (bold, italic) {
(true, true) => b"Helvetica-BoldOblique",
(true, false) => b"Helvetica-Bold",
(false, true) => b"Helvetica-Oblique",
(false, false) => b"Helvetica",
};
log::warn!(
"Font not found: {font_name} bold={bold} italic={italic} — using {}",
String::from_utf8_lossy(base_font)
);
pdf.type1_font(refs.font)
.base_font(Name(base_font))
.encoding_predefined(Name(b"WinAnsiEncoding"));
FontEntry {
pdf_name,
font_ref: refs.font,
widths_1000: encoding::helvetica_widths(),
line_h_ratio: None,
ascender_ratio: None,
grid_line_ratio: None,
plain_line_h_ratio: None,
grid_baseline_shift: None,
superscript_ratio: None,
subscript_ratio: None,
underline: None,
strikeout: None,
east_asian: false,
plain_ascender_ratio: None,
char_to_gid: None,
char_widths_1000: None,
kern_pairs: None,
synthetic_bold: false,
synthetic_italic: false,
is_substituted: true,
missing_cjk_chars: missing_cjk,
drew_notdef: Default::default(),
font_path: None,
face_index: 0,
}
}
};
log::debug!(
"register_font: {font_name} bold={bold} italic={italic} → {:.1}ms",
t0.elapsed().as_secs_f64() * 1000.0,
);
entry
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cjk_script_from_charset_then_name_then_text() {
let none = HashSet::new();
assert_eq!(
classify_cjk_script("Whatever", Some(0x80), &none),
CjkScript::Japanese
);
assert_eq!(
classify_cjk_script("HY헤드라인M", Some(0x81), &none),
CjkScript::Korean
);
assert_eq!(
classify_cjk_script("X", Some(0x86), &none),
CjkScript::SimplifiedChinese
);
assert_eq!(
classify_cjk_script("X", Some(0x88), &none),
CjkScript::TraditionalChinese
);
assert_eq!(
classify_cjk_script("HY헤드라인M", None, &none),
CjkScript::Korean
);
let kana: HashSet<char> = "表タイトル".chars().collect();
assert_eq!(
classify_cjk_script("Mystery", None, &kana),
CjkScript::Japanese
);
let han: HashSet<char> = "発表".chars().collect();
assert_eq!(
classify_cjk_script("Mystery", None, &han),
CjkScript::Unknown
);
}
#[test]
fn cjk_fallback_picks_word_face_by_family() {
assert_eq!(cjk_fallback_fonts(CjkScript::Korean, true)[0], "Batang");
assert_eq!(
cjk_fallback_fonts(CjkScript::Korean, false)[0],
"Malgun Gothic"
);
assert_eq!(
cjk_fallback_fonts(CjkScript::Japanese, true)[0],
"MS Mincho"
);
assert_eq!(cjk_fallback_fonts(CjkScript::Unknown, true)[0], "MS Mincho");
}
#[test]
fn missing_fonts_resolve_as_word_does() {
let resolve = |run: &str, entry: Option<(&str, FontFamily, Option<&str>)>| {
let table: FontTable = entry
.map(|(name, family, alt)| {
let alt_name = alt.map(Into::into);
let e = crate::model::FontTableEntry {
alt_name,
family,
charset: None,
};
(name.to_string(), e)
})
.into_iter()
.collect();
let mut next = 0;
let mut alloc = || {
next += 1;
Ref::new(next)
};
let chars: HashSet<char> = "Ab".chars().collect();
let ctx = FontContext {
embedded_fonts: &EmbeddedFonts::new(),
font_table: &table,
used_chars: &chars,
word_text: true,
};
let entry = register_font(
&mut Pdf::new(),
run,
false,
false,
"F1".into(),
&mut alloc,
&ctx,
);
let path = entry.font_path.expect("resolved to a file");
path.file_name().unwrap().to_string_lossy().to_lowercase()
};
use FontFamily::*;
let aptos = Some("Aptos");
assert_eq!(
resolve("Calibri", Some(("Calibri", Swiss, aptos))),
"calibri.ttf"
);
assert_eq!(
resolve("Zqx Dalt", Some(("Zqx Dalt", Swiss, aptos))),
"aptos.ttf"
);
assert_eq!(
resolve("Zqx Broman", Some(("Zqx Broman", Roman, None))),
"cambria.ttc"
);
assert_eq!(
resolve("Zqx Bauto", Some(("Zqx Bauto", Auto, Some("sans-serif")))),
"calibri.ttf"
);
assert_eq!(resolve("Zqx Alpha", None), "cambria.ttc");
assert_eq!(
resolve("Zqx Elist;sans-serif", Some(("Zqx Elist", Auto, None))),
"cambria.ttc"
);
assert_eq!(
resolve("Helvetica", Some(("Helvetica", Swiss, aptos))),
"arial.ttf"
);
assert_eq!(resolve("Liberation Sans", None), "arial.ttf");
assert_eq!(
resolve("Arial Bold", Some(("Arial Bold", Roman, None))),
"arial.ttf"
);
assert_eq!(
resolve(
"Arial Bold",
Some(("Arial Bold", Auto, Some("Courier New")))
),
"courier new.ttf"
);
}
#[test]
fn test_primary_font_name_simple() {
assert_eq!(primary_font_name("Arial"), "Arial");
assert_eq!(primary_font_name("Times New Roman"), "Times New Roman");
}
#[test]
fn test_primary_font_name_with_fallback() {
assert_eq!(primary_font_name("Arial; Helvetica"), "Arial");
assert_eq!(primary_font_name("Calibri; sans-serif"), "Calibri");
}
#[test]
fn test_primary_font_name_with_whitespace() {
assert_eq!(primary_font_name(" Arial ; Helvetica"), "Arial");
assert_eq!(primary_font_name("Verdana, Geneva, sans-serif"), "Verdana");
assert_eq!(primary_font_name("\"Calibri\", Arial"), "\"Calibri\"");
}
#[test]
fn test_primary_font_name_empty() {
assert_eq!(primary_font_name(""), "");
}
fn make_run(font: &str, bold: bool, italic: bool) -> Run {
Run {
font_name: font.to_string(),
font_size: 12.0,
bold,
italic,
text_scale: 100.0,
..Run::default()
}
}
#[test]
fn test_font_key_regular() {
let run = make_run("Arial", false, false);
let mut buf = String::new();
assert_eq!(font_key_buf(&run, &mut buf), "Arial");
}
#[test]
fn test_font_key_bold() {
let run = make_run("Arial", true, false);
let mut buf = String::new();
assert_eq!(font_key_buf(&run, &mut buf), "Arial/B");
}
#[test]
fn test_font_key_italic() {
let run = make_run("Arial", false, true);
let mut buf = String::new();
assert_eq!(font_key_buf(&run, &mut buf), "Arial/I");
}
#[test]
fn test_font_key_bold_italic() {
let run = make_run("Arial", true, true);
let mut buf = String::new();
assert_eq!(font_key_buf(&run, &mut buf), "Arial/BI");
}
#[test]
fn test_font_key_with_fallback_font() {
let run = make_run("Calibri; sans-serif", false, false);
let mut buf = String::new();
assert_eq!(font_key_buf(&run, &mut buf), "Calibri");
}
}