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) 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,
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) 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) missing_cjk_chars: HashSet<char>,
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
}
}
#[allow(dead_code)]
pub(crate) fn char_width_1000_with_fallback(
&self,
ch: char,
fallback: Option<&FontEntry>,
) -> f32 {
let w = self.char_width_1000(ch);
if w > 0.0 || !self.missing_cjk_chars.contains(&ch) {
return w;
}
fallback
.and_then(|fb| fb.char_widths_1000.as_ref()?.get(&ch).copied())
.unwrap_or(0.0)
}
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
}
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 primary_font_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>>;
fn try_font(
pdf: &mut Pdf,
candidate: &str,
bold: bool,
italic: bool,
font_ref: Ref,
descriptor_ref: Ref,
data_ref: Ref,
alloc: &mut impl FnMut() -> Ref,
embedded_fonts: &EmbeddedFonts,
used_chars: &HashSet<char>,
) -> Option<ResolvedFont> {
let mut embed = |data: &[u8], face_index: u32| {
embed::embed_truetype(
pdf,
font_ref,
descriptor_ref,
data_ref,
candidate,
data,
face_index,
used_chars,
alloc,
)
};
let embedded_key = (candidate.to_lowercase(), bold, italic);
if let Some(metrics) = embedded_fonts.get(&embedded_key).and_then(|d| embed(d, 0)) {
return Some(ResolvedFont {
metrics,
synthetic_bold: false,
font_path: None,
face_index: 0,
});
}
let (path, face_index, exact_match) = 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 && !exact_match,
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)
})
}
fn family_fallback(family: FontFamily) -> Option<&'static str> {
match family {
FontFamily::Roman => Some("Times New Roman"),
FontFamily::Swiss => Some("Arial"),
FontFamily::Modern => Some("Courier New"),
FontFamily::Script | FontFamily::Decorative => Some("Times New Roman"),
_ => None,
}
}
fn known_font_alias(name: &str) -> Option<&'static str> {
match name {
"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)]
pub(crate) enum CjkScript {
Unknown,
SimplifiedChinese,
TraditionalChinese,
Japanese,
Korean,
}
pub(crate) fn classify_cjk_script(primary: &str) -> CjkScript {
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",
];
for h in SC_HINTS {
if primary.contains(h) {
return CjkScript::SimplifiedChinese;
}
}
for h in TC_HINTS {
if primary.contains(h) {
return CjkScript::TraditionalChinese;
}
}
for h in JA_HINTS {
if primary.contains(h) {
return CjkScript::Japanese;
}
}
for h in KO_HINTS {
if primary.contains(h) {
return CjkScript::Korean;
}
}
CjkScript::Unknown
}
pub(crate) fn cjk_fallback_fonts_for_script(script: CjkScript) -> &'static [&'static str] {
#[cfg(target_os = "macos")]
{
match script {
CjkScript::SimplifiedChinese => &[
"Hiragino Sans GB",
"Hiragino Sans GB W3",
"PingFang SC",
"Songti SC",
"Malgun Gothic",
"PMingLiU",
"Arial Unicode MS",
],
CjkScript::TraditionalChinese => &[
"PMingLiU",
"MingLiU",
"Songti TC",
"Malgun Gothic",
"Hiragino Sans GB",
"Arial Unicode MS",
],
CjkScript::Japanese => &[
"Hiragino Kaku Gothic ProN W3",
"Hiragino Sans W3",
"Yu Gothic",
"Malgun Gothic",
"Arial Unicode MS",
"Hiragino Sans GB",
],
CjkScript::Korean => &[
"Malgun Gothic",
"AppleSD Gothic Neo",
"Apple SD Gothic Neo",
"Hiragino Sans GB",
"Arial Unicode MS",
],
CjkScript::Unknown => &[
"Malgun Gothic",
"Hiragino Sans GB",
"Hiragino Sans GB W3",
"PMingLiU",
"MingLiU",
"Songti TC",
"AppleSD Gothic Neo",
"Apple SD Gothic Neo",
"Hiragino Sans W3",
"Hiragino Kaku Gothic ProN W3",
"Arial Unicode MS",
"PingFang SC",
],
}
}
#[cfg(target_os = "linux")]
{
let _ = script;
&[
"Noto Sans CJK SC",
"Noto Sans CJK KR",
"Noto Sans CJK JP",
]
}
#[cfg(target_os = "windows")]
{
let _ = script;
&[
"Malgun Gothic",
"Yu Gothic",
"Microsoft YaHei",
]
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = script;
&[]
}
}
pub(crate) fn register_font(
pdf: &mut Pdf,
font_name: &str,
bold: bool,
italic: bool,
pdf_name: String,
alloc: &mut impl FnMut() -> Ref,
embedded_fonts: &EmbeddedFonts,
used_chars: &HashSet<char>,
font_table: &FontTable,
) -> FontEntry {
let t0 = Instant::now();
let font_ref = alloc();
let descriptor_ref = alloc();
let data_ref = alloc();
let primary = primary_font_name(font_name);
let mut try_candidate = |name: &str| {
try_font(
pdf,
name,
bold,
italic,
font_ref,
descriptor_ref,
data_ref,
alloc,
embedded_fonts,
used_chars,
)
};
let needs_cjk = has_cjk_chars(used_chars);
let table_entry = lookup_font_table(font_table, primary);
let script = classify_cjk_script(primary);
let try_cjk_fallback = |tc: &mut dyn FnMut(&str) -> Option<ResolvedFont>| {
cjk_fallback_fonts_for_script(script).iter().find_map(|cjk_font| {
log::debug!("Trying CJK fallback \"{cjk_font}\" for \"{primary}\"");
let m = tc(cjk_font)?;
log::info!("Font substitution: {primary} → CJK fallback \"{cjk_font}\"");
Some(m)
})
};
let result = table_entry
.and_then(|entry| {
let alt = entry.alt_name.as_ref()?;
let m = try_candidate(alt)?;
log::info!("Font substitution: {primary} → altName \"{alt}\"");
Some(m)
})
.or_else(|| {
font_name
.split(';')
.map(|s| s.trim())
.find_map(|c| try_candidate(c))
})
.or_else(|| {
let alias = known_font_alias(primary)?;
let m = try_candidate(alias)?;
log::info!("Font substitution: {primary} → alias \"{alias}\"");
Some(m)
})
.or_else(|| {
let entry = table_entry?;
if needs_cjk {
if let Some(m) = try_cjk_fallback(&mut try_candidate) {
return Some(m);
}
}
let fallback = family_fallback(entry.family)?;
let m = try_candidate(fallback)?;
log::info!(
"Font substitution: {primary} → family {:?} fallback \"{fallback}\"",
entry.family
);
Some(m)
})
.or_else(|| {
if !needs_cjk {
return None;
}
try_cjk_fallback(&mut try_candidate)
});
let missing_cjk = if needs_cjk {
let covered: HashSet<char> = result
.as_ref()
.map(|r| r.metrics.char_to_gid.keys().copied().collect())
.unwrap_or_default();
used_chars
.iter()
.copied()
.filter(|ch| !covered.contains(ch) && crate::docx::is_east_asian_char(*ch))
.collect()
} else {
HashSet::new()
};
let entry = match result {
Some(r) => FontEntry {
pdf_name,
font_ref,
widths_1000: r.metrics.widths_1000,
line_h_ratio: Some(r.metrics.line_h_ratio),
ascender_ratio: Some(r.metrics.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,
missing_cjk_chars: missing_cjk,
font_path: r.font_path,
face_index: r.face_index,
},
None => {
log::warn!("Font not found: {font_name} bold={bold} italic={italic} — using Helvetica");
pdf.type1_font(font_ref)
.base_font(Name(b"Helvetica"))
.encoding_predefined(Name(b"WinAnsiEncoding"));
FontEntry {
pdf_name,
font_ref,
widths_1000: encoding::helvetica_widths(),
line_h_ratio: None,
ascender_ratio: None,
char_to_gid: None,
char_widths_1000: None,
kern_pairs: None,
synthetic_bold: false,
missing_cjk_chars: missing_cjk,
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::*;
use crate::model::VertAlign;
#[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");
}
#[test]
fn test_primary_font_name_empty() {
assert_eq!(primary_font_name(""), "");
}
fn make_run(font: &str, bold: bool, italic: bool) -> Run {
Run {
text: String::new(),
font_name: font.to_string(),
font_size: 12.0,
bold,
italic,
underline: false,
strikethrough: false,
dstrike: false,
color: None,
highlight: None,
shading: None,
vertical_align: VertAlign::Baseline,
text_shadow: None,
text_glow: None,
text_outline: None,
text_fill: None,
vanish: false,
small_caps: false,
caps: false,
char_style_id: None,
lang: None,
char_spacing: 0.0,
text_scale: 100.0,
east_asia_font_name: None,
is_tab: false,
is_line_break: false,
field_code: None,
hyperlink_url: None,
inline_image: None,
footnote_id: None,
is_footnote_ref_mark: false,
endnote_id: None,
is_endnote_ref_mark: false,
kern_threshold: None,
font_size_from_default: false,
font_name_from_default: false,
}
}
#[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");
}
}