Files
pdfcraft/crates/render/src/system_font.rs
T
PhilipandClaude Sonnet 5.5 69f80819e1 M2: draw non-embedded CJK CID fonts with an installed face
A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that the PDF
doesn't embed drew nothing unless craft-fonts had a Japanese face: text boxes
from Acrobat (Kozuka Gothic) and Pages (SongtiTC) were blank. After craft-fonts,
the renderer now uses one face already installed on the machine, read at
runtime and never bundled. The face of a multi-region collection (PingFang SC
or TC) is chosen by region. Noto CJK and Source Han are not candidates, and
PDFCRAFT_SYSTEM_FONTS=0 turns it off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:10:43 +01:00

249 lines
12 KiB
Rust

//! Installed CJK faces for CID fonts a PDF doesn't embed.
//!
//! Acrobat and other tools often write Chinese, Japanese or Korean text with one of the Adobe
//! collections (Adobe-Japan1, GB1, CNS1, Korea1) and a font name such as `KozGoPr6N-Medium` or
//! `MSung-Light` without embedding the font. hayro maps those CIDs to Unicode itself, but it needs
//! a face to draw them with; without one the text is blank. The craft-fonts Japanese faces come
//! first (see `japanese_fallback` in `raster.rs`); this module is the last resort: one face
//! already installed on this machine. It is read at runtime and never embedded or shipped
//! (AGENTS.md §1.4), Noto CJK and Source Han are left out on purpose (§1.1), and
//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off, as for the interface font.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use hayro::hayro_interpret::font::{FallbackFontQuery, FontData};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use skrifa::MetadataProvider as _;
use skrifa::string::StringId;
/// Larger files are not read: a font path is still untrusted input. CJK system faces are big
/// collections (PingFang is tens of megabytes).
const MAX_BYTES: u64 = 96 << 20;
/// Faces tried in a collection (`.ttc`).
const MAX_FACES: u32 = 32;
/// The writing system a character collection is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Script {
Japanese,
SimplifiedChinese,
TraditionalChinese,
Korean,
}
impl Script {
fn of(family: &CidFamily) -> Option<Self> {
match family {
CidFamily::AdobeJapan1 => Some(Self::Japanese),
CidFamily::AdobeGB1 => Some(Self::SimplifiedChinese),
CidFamily::AdobeCNS1 => Some(Self::TraditionalChinese),
CidFamily::AdobeKorea1 => Some(Self::Korean),
CidFamily::AdobeIdentity | CidFamily::Custom { .. } => None,
}
}
/// A character the face must have to be worth using. Each is common and not shared by the
/// other scripts' faces: 物 (Japanese; also Chinese, so it only rules out Latin-only faces),
/// 欢 (Simplified only), 說 (Traditional only), 한 (Hangul).
fn probe(self) -> char {
match self {
Self::Japanese => '\u{7269}',
Self::SimplifiedChinese => '\u{6B22}',
Self::TraditionalChinese => '\u{8AAA}',
Self::Korean => '\u{D55C}',
}
}
}
/// One installed file and the words (in a face's family name) that pick the face for the right
/// region out of a collection that holds several (PingFang SC, TC and HK share one file).
struct Candidate {
path: PathBuf,
region: &'static [&'static str],
}
/// The installed face to draw a CID font of `query`'s collection with, as `(data, face index)`.
/// `None` when it is turned off, the collection isn't one of the four, or nothing fits.
pub(crate) fn fallback(query: &FallbackFontQuery) -> Option<(FontData, u32)> {
if cfg!(target_arch = "wasm32") || std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
return None;
}
let script = Script::of(&query.character_collection.as_ref()?.family)?;
let bold = query.is_bold || query.font_weight >= 600;
/// A face file and the index of the chosen face in it, or `None` when nothing fits.
type Face = Option<(Arc<Vec<u8>>, u32)>;
static CACHE: Mutex<Option<HashMap<(Script, bool), Face>>> = Mutex::new(None);
let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
let found = cache.get_or_insert_with(HashMap::new).entry((script, bold)).or_insert_with(|| {
candidates(script)
.iter()
.find_map(|c| read(c, script.probe(), bold))
.inspect(|(_, index)| log::info!("CJK font fallback for {script:?}: face {index}"))
});
found.as_ref().map(|(bytes, index)| (Arc::clone(bytes) as FontData, *index))
}
fn read(candidate: &Candidate, probe: char, bold: bool) -> Option<(Arc<Vec<u8>>, u32)> {
let meta = std::fs::metadata(&candidate.path).ok()?;
if !meta.is_file() || meta.len() > MAX_BYTES {
return None;
}
let bytes = std::fs::read(&candidate.path).ok()?;
let index = pick_face(&bytes, probe, candidate.region, bold)?;
Some((Arc::new(bytes), index))
}
/// The best face of the file that parses and maps `probe`: the region's face when the file has
/// one, then the regular (or, for bold text, the semibold or bold) weight, then the first.
fn pick_face(bytes: &[u8], probe: char, region: &[&str], bold: bool) -> Option<u32> {
let mut best: Option<(u32, u32)> = None;
for index in 0..MAX_FACES {
let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { continue };
if font.charmap().map(probe).is_none() {
continue;
}
let names = |id: StringId| font.localized_strings(id).map(|s| s.chars().collect::<String>()).collect::<Vec<_>>();
let family = names(StringId::FAMILY_NAME).join(" ");
let style = names(StringId::SUBFAMILY_NAME).join(" ").to_ascii_lowercase();
let mut score = 1;
if region.iter().any(|r| family.split_whitespace().any(|w| w == *r)) {
score += 4;
}
let weighty = style.contains("bold") || style.contains("semibold") || style.contains("heavy");
let plain = style.is_empty() || style.contains("regular") || style.contains("normal") || style.contains("medium") && !bold;
if (bold && weighty) || (!bold && plain && !weighty) {
score += 2;
}
if best.is_none_or(|(_, s)| score > s) {
best = Some((index, score));
}
}
best.map(|(index, _)| index)
}
fn candidates(script: Script) -> Vec<Candidate> {
let region: &'static [&'static str] = match script {
Script::Japanese => &["JP", "Japanese"],
Script::SimplifiedChinese => &["SC", "GB"],
Script::TraditionalChinese => &["TC", "HK", "CNS"],
Script::Korean => &["KR", "Korean"],
};
let files: Vec<PathBuf> = if cfg!(windows) {
let dir = windows_fonts();
let names: &[&str] = match script {
Script::Japanese => &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"],
Script::SimplifiedChinese => &["msyh.ttc", "simsun.ttc", "msjh.ttc"],
Script::TraditionalChinese => &["msjh.ttc", "mingliu.ttc", "msyh.ttc"],
Script::Korean => &["malgun.ttf", "gulim.ttc"],
};
names.iter().map(|f| dir.join(f)).collect()
} else if cfg!(target_os = "macos") {
let names: &[&str] = match script {
Script::Japanese => &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans GB.ttc", "PingFang.ttc"],
Script::SimplifiedChinese => &["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc"],
Script::TraditionalChinese => &["PingFang.ttc", "STHeiti Light.ttc", "Hiragino Sans GB.ttc"],
Script::Korean => &["AppleSDGothicNeo.ttc", "AppleGothic.ttf", "PingFang.ttc"],
};
names.iter().map(|f| Path::new("/System/Library/Fonts").join(f)).collect()
} else {
let names: &[&str] = match script {
Script::Korean => {
&["truetype/nanum/NanumGothic.ttf", "nanum/NanumGothic.ttf", "truetype/baekmuk/gulim.ttf", "truetype/unfonts-core/UnDotum.ttf"]
}
Script::Japanese => &[
"truetype/fonts-japanese-gothic.ttf",
"ipa-gothic/ipag.ttf",
"truetype/takao-gothic/TakaoGothic.ttf",
"truetype/vlgothic/VL-Gothic-Regular.ttf",
],
Script::SimplifiedChinese | Script::TraditionalChinese => &[
"truetype/wqy/wqy-microhei.ttc",
"wenquanyi/wqy-microhei/wqy-microhei.ttc",
"wqy-microhei/wqy-microhei.ttc",
"truetype/wqy/wqy-zenhei.ttc",
"wenquanyi/wqy-zenhei/wqy-zenhei.ttc",
"wqy-zenhei/wqy-zenhei.ttc",
"truetype/droid/DroidSansFallbackFull.ttf",
"google-droid-sans-fonts/DroidSansFallbackFull.ttf",
"truetype/arphic/uming.ttc",
"arphic-uming/uming.ttc",
],
};
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| names.iter().map(move |f| Path::new(dir).join(f))).collect()
};
files.into_iter().map(|path| Candidate { path, region }).collect()
}
fn windows_fonts() -> PathBuf {
let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
dir.join("Fonts")
}
#[cfg(test)]
mod tests {
use super::*;
const SCRIPTS: [Script; 4] = [Script::Japanese, Script::SimplifiedChinese, Script::TraditionalChinese, Script::Korean];
#[test]
fn broken_and_missing_files_are_skipped() {
assert_eq!(pick_face(b"", '\u{7269}', &[], false), None);
assert_eq!(pick_face(b"not a font at all", '\u{7269}', &[], false), None);
assert_eq!(pick_face(&[0u8; 4096], '\u{7269}', &[], false), None);
let missing = Candidate { path: PathBuf::from("definitely/not/here.ttc"), region: &[] };
assert!(read(&missing, '\u{7269}', false).is_none());
// A directory is not a font.
let dir = Candidate { path: std::env::temp_dir(), region: &[] };
assert!(read(&dir, '\u{7269}', false).is_none());
}
#[test]
fn a_latin_face_is_rejected_for_every_script() {
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
for script in SCRIPTS {
assert_eq!(pick_face(inter, script.probe(), &[], false), None, "{script:?}");
}
assert_eq!(pick_face(inter, 'A', &[], false), Some(0));
}
#[test]
fn candidates_are_absolute_font_files_without_adobe_designs() {
for script in SCRIPTS {
let list = candidates(script);
assert!(!list.is_empty(), "{script:?}");
for c in list {
assert!(c.path.is_absolute(), "{}", c.path.display());
assert!(c.path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{}", c.path.display());
let name = c.path.to_string_lossy().to_lowercase();
assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}");
}
}
}
#[test]
fn only_the_four_adobe_collections_have_a_script() {
assert_eq!(Script::of(&CidFamily::AdobeCNS1), Some(Script::TraditionalChinese));
assert_eq!(Script::of(&CidFamily::AdobeGB1), Some(Script::SimplifiedChinese));
assert_eq!(Script::of(&CidFamily::AdobeKorea1), Some(Script::Korean));
assert_eq!(Script::of(&CidFamily::AdobeJapan1), Some(Script::Japanese));
assert_eq!(Script::of(&CidFamily::AdobeIdentity), None);
}
/// On a Mac the face for Traditional Chinese is the TC one of PingFang, not the first of the
/// collection (which is another region's), and it maps a character only Traditional has.
#[cfg(target_os = "macos")]
#[test]
fn macos_picks_the_regional_pingfang_face() {
let path = Path::new("/System/Library/Fonts/PingFang.ttc");
let Ok(bytes) = std::fs::read(path) else { return };
for (script, region) in [(Script::TraditionalChinese, "TC"), (Script::SimplifiedChinese, "SC")] {
let index = pick_face(&bytes, script.probe(), &[region], false).expect("PingFang covers it");
let font = skrifa::FontRef::from_index(&bytes, index).unwrap();
let family: String = font.localized_strings(StringId::FAMILY_NAME).next().unwrap().chars().collect();
assert!(family.contains(region), "{script:?} got {family:?}");
}
}
}