Merge upstream/main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-10-31 09:04:38 +01:00
co-authored by Claude Opus 5.5
74 changed files with 1144 additions and 446 deletions
+1
View File
@@ -13,6 +13,7 @@ mod pixels;
mod raster;
mod reading;
mod structure;
mod system_font;
pub mod text;
pub mod transition;
+48 -1
View File
@@ -78,15 +78,36 @@ pub struct RenderConfig {
/// The page clears to transparent instead of white (or the reading background): for a page
/// holding only what is laid over another raster, such as a comment while it is dragged.
pub transparent: bool,
/// Draw a CID font of an Adobe CJK collection that the PDF does not embed with one face already
/// installed on this machine (see `system_font`). Off by default and on only for on-screen
/// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
pub system_fonts: bool,
}
impl RenderConfig {
/// This configuration for a raster that is only shown on screen (the viewer, Combine
/// thumbnails, figure previews): installed CJK faces may draw fonts the PDF doesn't embed.
/// Never use it for anything that can be saved, printed, recognised or returned to an agent
/// (AGENTS.md §1.2, §1.4).
#[must_use]
pub fn on_screen(self) -> Self {
Self { system_fonts: true, ..self }
}
/// Build interpreter settings with layer and comment preferences and a font resolver that
/// tries bundled Japanese faces, then opt-in installed CJK faces, then standard substitutes.
/// Passwords belong to parser construction; request-specific preview overrides are applied
/// by `render_page`, which also preserves the caller's warning and cancellation hooks.
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
let system_fonts = self.system_fonts;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
hide_comments: self.hide_comments,
font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| standard(query))),
font_resolver: Arc::new(move |query| {
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
}),
..InterpreterSettings::default()
}
}
@@ -119,6 +140,14 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
Some((Arc::new(face.bytes), 0))
}
/// An installed face for a CID font of an Adobe CJK collection that the PDF doesn't embed and
/// craft-fonts has no face for (Traditional and Simplified Chinese, Korean, and Japanese in a
/// build without craft-fonts). `None` for every other font.
fn system_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
let FontQuery::Fallback(f) = query else { return None };
crate::system_font::fallback(f)
}
#[derive(Debug, PartialEq)]
enum JapaneseFace {
Mincho,
@@ -182,6 +211,7 @@ pub struct RenderRequest {
/// The tag bit of a print-preview raster. Such requests always render the document's true
/// colours, ignoring `RenderConfig::reading_colors`: a preview's job is fidelity to the
/// printout, and the print itself — imposed from the document — is never recoloured.
/// Installed-font fallback is also disabled, matching the output's font policy.
pub const PRINT_TAG: u64 = 1 << 60;
/// The reading colours a request renders with: print previews ([`PRINT_TAG`]) ignore the
@@ -319,6 +349,23 @@ fn render_page<'a>(
config: &RenderConfig,
req: RenderRequest,
) -> Result<Output, (String, bool)> {
// Print previews share the viewer pool, but must use the output font policy. Keep the
// caller's cancellation/warning hooks, layers and annotation settings. A fresh cache is
// essential: hayro keys cached fonts by PDF dictionary, not by the font resolver, so the
// viewer cache could supply an installed face even after changing the resolver (and a
// cached missing font from print preview could blank the viewer on switching back).
let print_settings;
let print_cache;
let (settings, cache) = if config.system_fonts && req.tag & PRINT_TAG != 0 {
print_settings = InterpreterSettings {
font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver,
..settings.clone()
};
print_cache = RenderCache::new();
(&print_settings, &print_cache)
} else {
(settings, cache)
};
let reading = request_reading(config, &req);
if req.kind == RequestKind::Text {
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
+140
View File
@@ -7822,6 +7822,146 @@ fn test_clip_only_text_raster_preserves_clipping_without_painting() {
assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page");
}
#[cfg(target_os = "windows")]
mod gbk_glyphs;
/// Chinese and Korean CID fonts that aren't embedded (Adobe-CNS1 `MSung-Light`, Adobe-GB1
/// `STSong-Light`, Adobe-Korea1 `HYGoThic-Medium`; Acrobat also writes Japanese `KozGoPr6N`
/// text boxes without the font) drew nothing, because craft-fonts has only Japanese faces.
/// They now draw in a face already installed on the machine, read at runtime. A machine
/// without such a face draws nothing, as before, so each check is skipped there.
#[test]
fn non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face() {
use hayro::hayro_interpret::font::FallbackFontQuery;
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
// (base font, collection, ordering, encoding, encoded text, descendant subtype)
let cases = [
("MSung-Light", CidFamily::AdobeCNS1, "CNS1", "UniCNS-UCS2-H", "7E419AD45B57", "CIDFontType0"), // 繁體字
("STSong-Light", CidFamily::AdobeGB1, "GB1", "UniGB-UCS2-H", "72698D448DF7", "CIDFontType0"), // 物资跷
("HYGoThic-Medium", CidFamily::AdobeKorea1, "Korea1", "UniKS-UCS2-H", "D55CAE00", "CIDFontType0"), // 한글
// The real Windows book uses non-embedded TrueType CID fonts and a legacy GBK CMap,
// without ToUnicode. These bytes encode 中文; they are not Unicode code points.
("SimSun", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("SimHei", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("KaiTi", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("FangSong", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("ABCDEF+FZSSK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("ABCDEF+FZHTK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("ABCDEF+FZKTK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
("ABCDEF+FZFSK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
];
for (base_font, family, ordering, encoding, code, subtype) in cases {
let query = FontQuery::Fallback(FallbackFontQuery {
post_script_name: Some(base_font.into()),
character_collection: Some(CharacterCollection { family, supplement: 2 }),
..FallbackFontQuery::default()
});
if super::system_fallback(&query).is_none() {
eprintln!("no installed face for {base_font} (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
continue;
}
let content = format!("BT /F1 40 Tf 5 15 Td <{code}> Tj ET");
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] >> endobj
6 0 obj << /Type /Font /Subtype /{subtype} /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering ({ordering}) /Supplement 2 >>
/FontDescriptor 7 0 R /DW 1000 >> endobj
7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let pdf = Arc::new(pdf.into_bytes());
let ink = |config: RenderConfig| {
let mut r = PageRenderer::new(pdf.clone(), config);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count()
};
// Two to four glyphs at 40 pt cover several hundred dark pixels; a blank run has none.
let shown = ink(RenderConfig { system_fonts: true, ..RenderConfig::default() });
assert!(shown > 300, "{base_font}: the text is drawn ({shown} dark pixels)");
// Off by default: exports, printing and OCR render without an installed font, so
// nothing drawn with one can be saved (AGENTS.md §1.2, §1.4).
let saved = ink(RenderConfig::default());
assert!(saved < 50, "{base_font}: not drawn without system_fonts ({saved} dark pixels)");
if encoding == "GBK-EUC-H" {
#[cfg(target_os = "windows")]
gbk_glyphs::assert_expected_glyphs(&pdf, base_font);
let mut renderer = PageRenderer::new(pdf, RenderConfig { system_fonts: true, ..Default::default() });
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
assert!(text.error.is_none(), "{base_font}: {:?}", text.error);
assert_eq!(text.text.unwrap().plain_text().trim(), "中文", "the fallback preserves GBK text mapping");
}
}
}
/// Print preview must match output even after the same worker cached an installed face.
/// Switching back to the viewer must not retain the print resolver's missing-font result.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn print_preview_excludes_installed_fonts_without_poisoning_viewer_cache() {
use hayro::hayro_interpret::font::FallbackFontQuery;
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
let query = FontQuery::Fallback(FallbackFontQuery {
post_script_name: Some("SimSun".into()),
character_collection: Some(CharacterCollection { family: CidFamily::AdobeGB1, supplement: 2 }),
..Default::default()
});
if super::system_fallback(&query).is_none() {
eprintln!("no installed GB1 face (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
return;
}
let content = "BT /F1 40 Tf 5 15 Td <D6D0CEC4> Tj ET";
let bytes = Arc::new(format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /SimSun /Encoding /GBK-EUC-H /DescendantFonts [6 0 R] >> endobj\n\
6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /SimSun /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj\n\
7 0 obj << /Type /FontDescriptor /FontName /SimSun /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF", content.len()
).into_bytes());
let config = RenderConfig::default().on_screen();
let mut inline = PageRenderer::new(bytes.clone(), config.clone());
let mut output = PageRenderer::new(bytes.clone(), RenderConfig::default());
let pool = RenderPool::new(bytes, 1, config);
for tile in [None, Some(Tile { x: 0, y: 0, w: 120, h: 60 })] {
// Cover both directions with a warm worker cache, including other caller tag bits.
for tag in [PRINT_TAG | 1, 2, PRINT_TAG | 3, 4] {
let req = RenderRequest { page: 0, scale: 1.0, tile, tag, ..Default::default() };
let truth = output.render(req);
assert!(truth.error.is_none(), "{:?}", truth.error);
pool.set_queue(vec![req]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let worker = loop {
if let Some(page) = pool.try_recv() {
break page;
}
assert!(std::time::Instant::now() < deadline, "print-preview worker timed out");
std::thread::sleep(std::time::Duration::from_millis(5));
};
for page in [inline.render(req), worker] {
assert!(page.error.is_none(), "{:?}", page.error);
if tag & PRINT_TAG != 0 {
assert_eq!(page.rgba, truth.rgba, "print preview must match output, tile={tile:?}");
} else {
let ink = page.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
assert!(ink > 300, "viewer still draws installed Chinese glyphs: {ink}");
}
}
}
}
}
/// A malformed token in a content stream used to end the stream there, so the renderer, text
/// extraction, Find and search-based redaction lost the rest of the page while the edit
/// reader, MuPDF and pdftotext read it (#3565, #3568). Each token below is now stepped over.
@@ -0,0 +1,60 @@
//! A same-machine pixel oracle, independent of GBK decoding and installed-face selection.
//! All font reads and reference rasters stay in memory; nothing is embedded or published.
use super::*;
use hayro::hayro_interpret::font::FontData;
use skrifa::MetadataProvider as _;
use skrifa::string::StringId;
/// Compare the real GBK PDF path with explicit Unicode and a known installed face. Unlike an
/// ink count or extracted text, this fails if drawing chooses another glyph or font family.
pub(super) fn assert_expected_glyphs(pdf: &Arc<Vec<u8>>, base_font: &str) {
let (expected, file) = match base_font {
"SimSun" | "ABCDEF+FZSSK--GBK1-0" => ("SimSun", "simsun.ttc"),
"SimHei" | "ABCDEF+FZHTK--GBK1-0" => ("SimHei", "simhei.ttf"),
"KaiTi" | "ABCDEF+FZKTK--GBK1-0" => ("KaiTi", "simkai.ttf"),
"FangSong" | "ABCDEF+FZFSK--GBK1-0" => ("FangSong", "simfang.ttf"),
other => panic!("no independent GBK oracle for {other}"),
};
// Do not use the production candidate list, resolver or face-selection helpers here.
let directory = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).unwrap_or_else(|| r"C:\Windows".into());
let path = std::path::PathBuf::from(directory).join("Fonts").join(file);
if !path.is_file() {
eprintln!("{base_font}: exact {expected} face unavailable; pixel oracle skipped");
return;
}
let bytes = Arc::new(std::fs::read(path).expect("read installed reference font"));
let index = (0..32)
.find(|&index| {
skrifa::FontRef::from_index(&bytes, index)
.is_ok_and(|font| font.localized_strings(StringId::POSTSCRIPT_NAME).any(|name| name.chars().collect::<String>() == expected))
})
.expect("reference file contains the expected PostScript face");
let face = skrifa::FontRef::from_index(&bytes, index).unwrap();
for character in ['\u{4e2d}', '\u{6587}'] {
assert!(face.charmap().map(character).is_some_and(|id| id.to_u32() != 0), "{expected} must contain {character}");
}
// Explicit ToUnicode maps arbitrary reference codes to U+4E2D and U+6587, bypassing GBK
// and Adobe-GB1 CID lookup. The fixed resolver below bypasses family classification too.
let cmap = "begincmap /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange \
2 beginbfchar <0001> <4E2D> <0002> <6587> endbfchar endcmap";
let source = std::str::from_utf8(pdf).unwrap();
assert!(source.contains("/Encoding /GBK-EUC-H") && source.contains("<D6D0CEC4>"));
let reference = source
.replace("/Encoding /GBK-EUC-H", "/Encoding /Identity-H /ToUnicode 8 0 R")
.replace("<D6D0CEC4>", "<00010002>")
.replace("trailer <<", &format!("8 0 obj << /Length {} >> stream\n{cmap}\nendstream endobj\ntrailer <<", cmap.len()));
let parsed = Pdf::new(Arc::new(reference.into_bytes())).unwrap();
let data: FontData = bytes;
let settings = InterpreterSettings { font_resolver: Arc::new(move |_| Some((data.clone(), index))), ..Default::default() };
let reference = hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, ..Default::default() });
let mut renderer = PageRenderer::new(pdf.clone(), RenderConfig::default().on_screen());
let actual = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(actual.error.is_none(), "{base_font}: {:?}", actual.error);
let expected_pixels = reference.data_as_u8_slice();
assert_eq!(actual.rgba.len(), expected_pixels.len());
assert!(expected_pixels.as_chunks::<4>().0.iter().filter(|pixel| pixel[0] < 128).count() > 300, "reference must draw two visible glyphs");
assert!(actual.rgba.as_ref() == expected_pixels, "{base_font}: GBK raster must draw the exact {expected} glyphs for U+4E2D U+6587");
eprintln!("{base_font}: exact {expected} Unicode-reference pixels matched");
}
+519
View File
@@ -0,0 +1,519 @@
//! 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, used for the viewer only (`RenderConfig::system_fonts`, off
//! for exports, printing and OCR). 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::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, 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;
/// Downloaded font assets looked at per kind on macOS.
const MAX_ASSETS: usize = 512;
/// The writing system a character collection is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Script {
Japanese,
SimplifiedChinese,
TraditionalChinese,
Korean,
}
impl Script {
/// Map the four supported Adobe CJK collections to their writing systems. Identity and
/// custom collections provide no regional hint and cannot select an installed face here.
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],
}
/// Keep named Windows body, heading, calligraphic and FangSong faces distinct. A cache keyed only
/// by script and weight would make the first Chinese font on a page stand in for all of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum ChineseFamily {
Default,
Song,
Hei,
Kai,
FangSong,
}
impl ChineseFamily {
/// The family a font name asks for: the Windows and Founder (方正) names that Chinese PDFs,
/// CNKI papers in particular, use without embedding the font. FangSong is tested first, as
/// its names contain "song" too. Founder PostScript names are matched by prefix (`FZSSK--GBK1-0`
/// is 方正书宋, `FZXBSK--GBK1-0` 方正小标宋, `FZHTK--GBK1-0` 方正黑体, `FZKTK--GBK1-0` 方正楷体,
/// `FZFSK--GBK1-0` 方正仿宋).
fn of(name: &str) -> Self {
// PDF subset names prepend six uppercase ASCII letters and '+'. Match the actual
// PostScript name so Founder prefixes work for subsets too.
let name = match name.split_once('+') {
Some((tag, family)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => family,
_ => name,
};
let name = name.to_ascii_lowercase();
let has = |hints: &[&str]| hints.iter().any(|hint| name.contains(hint));
let starts = |prefixes: &[&str]| prefixes.iter().any(|p| name.starts_with(p));
if has(&["fangsong", "simfang", "仿宋"]) || starts(&["fzfs"]) {
Self::FangSong
} else if has(&["simsun", "stsong", "songti", "song", "宋"]) || starts(&["fzss", "fzbs", "fzxbs", "fzcs"]) {
Self::Song
} else if has(&["simhei", "stheiti", "stxihei", "heiti", "黑"]) || starts(&["fzht", "fzhei", "fzlth", "fzdh"]) {
Self::Hei
} else if has(&["kaiti", "stkaiti", "kai", "楷"]) || starts(&["fzkt", "fzkai"]) {
Self::Kai
} else {
Self::Default
}
}
}
/// 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;
let family = ChineseFamily::of(query.post_script_name.as_deref().unwrap_or_default());
/// A face file and the index of the chosen face in it, or `None` when nothing fits.
type Face = Option<(Arc<Vec<u8>>, u32)>;
type Faces = HashMap<(Script, bool, ChineseFamily), Face>;
static CACHE: Mutex<Option<Faces>> = 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, family)).or_insert_with(|| {
candidates(script, family, bold).iter().find_map(|c| {
read(c, script.probe(), bold).inspect(|(_, index)| {
log::info!("CJK font fallback for {script:?}/{family:?}: {} (face {index})", c.path.display());
})
})
});
found.as_ref().map(|(bytes, index)| (Arc::clone(bytes) as FontData, *index))
}
/// Read a candidate through the bounded file cache and select a face that maps `probe`,
/// preferring the requested region and weight. Unreadable files and unsuitable faces return
/// `None`, allowing the caller to try the next candidate without changing the PDF.
fn read(candidate: &Candidate, probe: char, bold: bool) -> Option<(Arc<Vec<u8>>, u32)> {
let bytes = file_bytes(&candidate.path)?;
let index = pick_face(&bytes, probe, candidate.region, bold)?;
Some((bytes, index))
}
/// The bytes of an installed font file, read once for the whole process and shared: a collection
/// such as `PingFang.ttc` is tens of megabytes and is the candidate of several scripts and both
/// weights, so the per-script cache keeps only a handle and a face index. A file that cannot be
/// used is remembered too, so it is not tried again for every script or family.
fn file_bytes(path: &Path) -> Option<Arc<Vec<u8>>> {
/// The bytes of each file tried, or `None` for one that cannot be used.
type Files = HashMap<PathBuf, Option<Arc<Vec<u8>>>>;
static FILES: Mutex<Option<Files>> = Mutex::new(None);
let mut files = FILES.lock().unwrap_or_else(PoisonError::into_inner);
files.get_or_insert_with(HashMap::new).entry(path.to_path_buf()).or_insert_with(|| read_file(path, MAX_BYTES).map(Arc::new)).clone()
}
/// A regular file of at most `max` bytes, or `None`: a font path is untrusted input.
fn read_file(path: &Path, max: u64) -> Option<Vec<u8>> {
// Reject special files before opening them (in particular a FIFO can block on open).
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > max {
return None;
}
let file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
if !meta.is_file() || meta.len() > max {
return None;
}
// Bound the actual read too: the file can grow after the metadata check.
let mut bytes = Vec::new();
file.take(max.saturating_add(1)).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= max).then_some(bytes)
}
/// 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)
}
/// List installed-font paths in preference order, with regional face-name hints for collections.
/// Windows Simplified Chinese first tries the named document family; its generic list also
/// prefers a bold face for bold requests. macOS includes bounded downloaded-font locations.
/// Only fixed platform filenames are used: a PDF font name never becomes a filesystem path.
fn candidates(script: Script, family: ChineseFamily, bold: bool) -> 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 => match family {
ChineseFamily::Song => &["simsun.ttc", "msyh.ttc", "msjh.ttc"],
ChineseFamily::Hei => &["simhei.ttf", "msyh.ttc", "simsun.ttc", "msjh.ttc"],
ChineseFamily::Kai => &["simkai.ttf", "simsun.ttc", "msyh.ttc", "msjh.ttc"],
ChineseFamily::FangSong => &["simfang.ttf", "simsun.ttc", "msyh.ttc", "msjh.ttc"],
ChineseFamily::Default if bold => &["msyhbd.ttc", "msyh.ttc", "simsun.ttc", "msjh.ttc"],
ChineseFamily::Default => &["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"],
};
// The system folder first, then the downloaded font assets: on recent macOS a face such as
// PingFang lives in `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<id>.asset/AssetData`.
let mut dirs = vec![PathBuf::from("/System/Library/Fonts")];
dirs.extend(macos_font_asset_dirs().iter().cloned());
names.iter().flat_map(|f| dirs.iter().map(move |d| d.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()
}
/// The `AssetData` folders of the downloaded font assets of macOS, found once. The ids in their
/// names change with each asset version, so they are listed rather than named; at most
/// [`MAX_ASSETS`] are looked at.
fn macos_font_asset_dirs() -> &'static [PathBuf] {
static DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new();
DIRS.get_or_init(|| {
let root = Path::new("/System/Library/AssetsV2");
let Ok(kinds) = std::fs::read_dir(root) else { return Vec::new() };
let mut dirs = Vec::new();
for kind in kinds.flatten().filter(|k| k.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font")) {
let Ok(assets) = std::fs::read_dir(kind.path()) else { continue };
for asset in assets.flatten().take(MAX_ASSETS) {
let data = asset.path().join("AssetData");
if data.is_dir() {
dirs.push(data);
}
}
}
dirs
})
}
/// Locate the Windows Fonts directory using `WINDIR`, then `SystemRoot`, then `C:\Windows`.
/// This only constructs the path; candidate reads still enforce regular-file and size limits.
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];
/// Standard, localized and subset-prefixed names retain their Chinese family hint;
/// unrelated Latin names and generic YaHei requests use the default candidate list.
#[test]
fn chinese_family_hints_include_subset_and_localized_names() {
for name in ["SimSun", "ABCDEF+SimSun", "STSong-Light", "宋体"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Song, "{name}");
}
for name in ["SimHei", "STHeiti", "黑体"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Hei, "{name}");
}
for name in ["KaiTi", "STKaiti", "楷体"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Kai, "{name}");
}
assert_eq!(ChineseFamily::of("Helvetica"), ChineseFamily::Default);
assert_eq!(ChineseFamily::of("MicrosoftYaHei"), ChineseFamily::Default);
}
/// FangSong names contain "song" and must not become Song; Founder (方正) names in CNKI papers
/// pick the matching installed family instead of YaHei for everything (#3587).
#[test]
fn fangsong_and_founder_names_pick_their_family() {
for name in ["FangSong", "FangSong_GB2312", "STFangsong", "SimFang", "仿宋", "FZFSK--GBK1-0", "ABCDEF+FZFSK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::FangSong, "{name}");
}
for name in ["FZSSK--GBK1-0", "FZBSK--GBK1-0", "FZXBSK--GBK1-0", "FZShuSong-Z01", "STZhongsong", "NSimSun", "ABCDEF+FZSSK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Song, "{name}");
}
for name in ["FZHTK--GBK1-0", "FZLTHJW--GB1-0", "STXihei", "ABCDEF+FZHTK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Hei, "{name}");
}
for name in ["FZKTK--GBK1-0", "KaiTi_GB2312", "ABCDEF+FZKTK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Kai, "{name}");
}
}
/// Every family has a Windows list that ends in faces any Chinese Windows has, so a missing
/// named face still draws the text.
#[cfg(windows)]
#[test]
fn every_windows_chinese_family_falls_back_to_common_faces() {
for family in [ChineseFamily::Default, ChineseFamily::Song, ChineseFamily::Hei, ChineseFamily::Kai, ChineseFamily::FangSong] {
let list = candidates(Script::SimplifiedChinese, family, false);
assert!(list.iter().any(|c| c.path.ends_with("msyh.ttc")), "{family:?}");
}
let fangsong = candidates(Script::SimplifiedChinese, ChineseFamily::FangSong, false);
assert!(fangsong.first().is_some_and(|c| c.path.ends_with("simfang.ttf")));
}
/// Invalid font bytes, missing paths and directories produce no candidate face rather
/// than preventing the resolver from trying a later installed font.
#[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());
}
/// The files are bounded by `max`: a larger one is not read.
#[test]
fn a_file_over_the_cap_is_not_read() {
let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
assert!(read_file(inter, MAX_BYTES).is_some());
assert!(read_file(inter, 1024).is_none());
}
/// Two faces drawn from one installed file (two scripts, or regular and bold) hold the same
/// bytes, not a copy each; a file that can't be used is remembered as unusable.
#[test]
fn one_installed_file_is_read_once_and_shared() {
let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
let first = file_bytes(inter).expect("readable");
let second = file_bytes(inter).expect("readable");
assert!(Arc::ptr_eq(&first, &second), "one allocation for the file");
let missing = Path::new("definitely/not/here.ttc");
assert!(file_bytes(missing).is_none() && file_bytes(missing).is_none());
}
/// A Latin-only face fails every CJK probe even though its Latin glyph remains readable,
/// distinguishing unsupported script coverage from an unparseable font file.
#[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));
}
/// Platform candidates are absolute TrueType paths and exclude the font families barred
/// by the asset policy, independently of whether those files are installed locally.
#[test]
fn candidates_are_absolute_font_files_without_adobe_designs() {
for script in SCRIPTS {
let list = candidates(script, ChineseFamily::Default, false);
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", "adobe", "kozuka", "minion"].iter().any(|b| name.contains(b)),
"{name}"
);
}
}
}
/// The four regional Adobe collections select their corresponding scripts; Identity
/// carries no regional information and must not acquire an installed fallback by guess.
#[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);
}
/// A real Windows GBK book names these faces. Replacing both with YaHei hides the
/// distinction between serif body text and sans headings even when the exact face exists.
#[cfg(windows)]
#[test]
fn windows_preserves_installed_chinese_document_families() {
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
for (name, expected, file) in [
("SimSun", "SimSun", "simsun.ttc"),
("SimHei", "SimHei", "simhei.ttf"),
("KaiTi", "KaiTi", "simkai.ttf"),
("FangSong", "FangSong", "simfang.ttf"),
("ABCDEF+FZSSK--GBK1-0", "SimSun", "simsun.ttc"),
("ABCDEF+FZHTK--GBK1-0", "SimHei", "simhei.ttf"),
("ABCDEF+FZKTK--GBK1-0", "KaiTi", "simkai.ttf"),
("ABCDEF+FZFSK--GBK1-0", "FangSong", "simfang.ttf"),
] {
if !windows_fonts().join(file).is_file() {
continue;
}
let query = FallbackFontQuery {
post_script_name: Some(name.into()),
character_collection: Some(CharacterCollection { family: CidFamily::AdobeGB1, supplement: 4 }),
..FallbackFontQuery::default()
};
let (bytes, index) = fallback(&query).expect("installed Chinese font");
let font = skrifa::FontRef::from_index(bytes.as_ref().as_ref(), index).unwrap();
let names: Vec<String> = font.localized_strings(StringId::POSTSCRIPT_NAME).map(|n| n.chars().collect()).collect();
assert!(names.iter().any(|n| n.eq_ignore_ascii_case(expected)), "{name} resolved to {names:?}, expected {expected}");
}
}
/// 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() {
// Found where this macOS keeps it: the system folder, or the downloaded font assets.
let path = candidates(Script::TraditionalChinese, ChineseFamily::Default, false)
.into_iter()
.map(|c| c.path)
.find(|p| p.file_name().is_some_and(|n| n == "PingFang.ttc") && p.is_file());
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
eprintln!("PingFang is not installed here: skipped");
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:?}");
}
}
/// A face that this macOS keeps only in its downloaded font assets (PingFang, on recent
/// versions) is among the candidates. The assets are searched here on their own, not by the
/// code under test; where there are none, nothing is checked.
#[cfg(target_os = "macos")]
#[test]
fn macos_candidates_include_a_pingfang_that_lives_in_the_font_assets() {
/// Independently locate an installed asset by filename within the test's depth limit,
/// providing an oracle for the production candidate list without using its discovery.
fn find(dir: &Path, name: &str, depth: u32) -> Option<PathBuf> {
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|n| n == name) && path.is_file() {
return Some(path);
}
if depth > 0
&& path.is_dir()
&& !path.is_symlink()
&& let Some(found) = find(&path, name, depth - 1)
{
return Some(found);
}
}
None
}
let Some(asset) = find(Path::new("/System/Library/AssetsV2"), "PingFang.ttc", 3) else {
eprintln!("no PingFang in the font assets here: skipped");
return;
};
for script in [Script::SimplifiedChinese, Script::TraditionalChinese] {
assert!(
candidates(script, ChineseFamily::Default, false).iter().any(|c| c.path == asset),
"{script:?}: {} is not a candidate",
asset.display()
);
}
}
}