mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Release builds have no Simplified Chinese face: craft-fonts' only Hans face is Noto Sans CJK SC, which build.rs skips (AGENTS.md §1.1). The Chinese UI therefore fell through to BIZ UDPGothic, which lacks 226 of the 771 Han characters in zh-hans.tsv (欢, 导, 签, 编, 创 ...) and 12 in zh-hant.tsv. In a Chinese interface built without a Hans face, the desktop app now loads one installed Chinese face (Microsoft YaHei/JhengHei, PingFang, Hiragino Sans GB, STHeiti, WenQuanYi, Droid Sans Fallback; never Noto CJK or Source Han) and places it before the Japanese faces so a line keeps one face and one baseline. Other languages keep the existing Arabic-capable fallback, last in every family. Still one installed font, read at runtime, never embedded; PDFCRAFT_SYSTEM_FONTS=0 still turns it off. Claude-Session: https://claude.ai/code/session_01Rsrc3tE4vB8UrMazUAkSkU Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d47c615a4c
commit
6f96c40eaa
5 files changed
+246
-48
No files matched your search
+10
-10
@@ -613,8 +613,8 @@ pub struct PdfCraftApp {
|
||||
ctx: Option<egui::Context>,
|
||||
styled: bool,
|
||||
fonts_ready: bool,
|
||||
/// The installed fonts put the Simplified Chinese faces first (see `theme::font_definitions_for`).
|
||||
fonts_hans: bool,
|
||||
/// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`).
|
||||
fonts_cjk: theme::CjkPreference,
|
||||
/// The window title last sent to the platform.
|
||||
pub window_title: String,
|
||||
/// The UI control channel, when enabled (`--control`; off by default).
|
||||
@@ -855,7 +855,7 @@ impl PdfCraftApp {
|
||||
ctx: None,
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
fonts_hans: false,
|
||||
fonts_cjk: theme::CjkPreference::Default,
|
||||
control: None,
|
||||
bookmark_rename: None,
|
||||
last_opened_url: None,
|
||||
@@ -2042,20 +2042,20 @@ impl eframe::App for PdfCraftApp {
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
let lang = i18n::Lang::from_pref(&self.language);
|
||||
i18n::set_current(lang);
|
||||
// Simplified Chinese wants its own faces before the Japanese ones (one baseline per line).
|
||||
let hans = lang.code() == "zh-hans";
|
||||
// Chinese wants its own faces before the Japanese ones (one baseline per line).
|
||||
let cjk = theme::CjkPreference::for_language(lang.code());
|
||||
if !self.styled {
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
theme::install_fonts_for(ctx, hans);
|
||||
self.fonts_hans = hans;
|
||||
theme::install_fonts_for(ctx, cjk);
|
||||
self.fonts_cjk = cjk;
|
||||
theme::apply(ctx, self.theme);
|
||||
self.styled = true;
|
||||
} else {
|
||||
self.fonts_ready = true;
|
||||
if hans != self.fonts_hans {
|
||||
if cjk != self.fonts_cjk {
|
||||
// Same family names as before, so named fonts stay valid while the new set loads.
|
||||
theme::install_fonts_for(ctx, hans);
|
||||
self.fonts_hans = hans;
|
||||
theme::install_fonts_for(ctx, cjk);
|
||||
self.fonts_cjk = cjk;
|
||||
}
|
||||
}
|
||||
self.sync_theme(ctx);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one
|
||||
//! only draws characters none of them has, such as an Arabic file name in a build without
|
||||
//! craft-fonts. It is read at runtime and never embedded or shipped (AGENTS.md §1.4), and
|
||||
//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off (published screenshots do).
|
||||
//! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). It is
|
||||
//! read at runtime and never embedded or shipped (AGENTS.md §1.4), and `PDFCRAFT_SYSTEM_FONTS=0`
|
||||
//! turns it off (published screenshots do).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -12,36 +13,73 @@ use egui::FontData;
|
||||
|
||||
/// Larger files are not read: a font path is still untrusted input.
|
||||
const MAX_BYTES: u64 = 32 << 20;
|
||||
/// Chinese system faces are big collections (PingFang, STHeiti), so they get a higher cap.
|
||||
const MAX_HAN_BYTES: u64 = 96 << 20;
|
||||
/// Faces tried in a collection (`.ttc`).
|
||||
const MAX_FACES: u32 = 16;
|
||||
/// Arabic letter alef: the face must have it to be worth loading.
|
||||
const PROBE: char = '\u{0627}';
|
||||
/// 欢 (U+6B22): Simplified only, absent from the Japanese craft-fonts faces.
|
||||
const HANS_PROBE: char = '\u{6B22}';
|
||||
/// 說 (U+8AAA): Traditional only, absent from the Japanese craft-fonts faces.
|
||||
const HANT_PROBE: char = '\u{8AAA}';
|
||||
|
||||
/// The installed fallback face, read once. `None` when it is turned off or no candidate fits.
|
||||
pub fn fallback() -> Option<Arc<FontData>> {
|
||||
static CACHE: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
|
||||
CACHE.get_or_init(load).clone()
|
||||
/// What the installed face is wanted for, from the interface language.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Need {
|
||||
/// Scripts no embedded face has (Arabic in a build without craft-fonts).
|
||||
#[default]
|
||||
Other,
|
||||
/// Simplified Chinese interface text.
|
||||
Hans,
|
||||
/// Traditional Chinese interface text.
|
||||
Hant,
|
||||
}
|
||||
|
||||
fn load() -> Option<Arc<FontData>> {
|
||||
/// The installed fallback face for `need`, read once per need. `None` when it is turned off or no
|
||||
/// candidate fits.
|
||||
pub fn fallback(need: Need) -> Option<Arc<FontData>> {
|
||||
static OTHER: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
|
||||
static HANS: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
|
||||
static HANT: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
|
||||
let cache = match need {
|
||||
Need::Other => &OTHER,
|
||||
Need::Hans => &HANS,
|
||||
Need::Hant => &HANT,
|
||||
};
|
||||
cache.get_or_init(|| load(need)).clone()
|
||||
}
|
||||
|
||||
fn load(need: Need) -> Option<Arc<FontData>> {
|
||||
if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
|
||||
return None;
|
||||
}
|
||||
candidates().iter().find_map(|path| read(path))
|
||||
let (probe, max) = match need {
|
||||
Need::Other => (PROBE, MAX_BYTES),
|
||||
Need::Hans => (HANS_PROBE, MAX_HAN_BYTES),
|
||||
Need::Hant => (HANT_PROBE, MAX_HAN_BYTES),
|
||||
};
|
||||
candidates(need).iter().find_map(|path| read(path, probe, max))
|
||||
}
|
||||
|
||||
/// Well-known locations of faces with broad script coverage, best first.
|
||||
fn candidates() -> Vec<PathBuf> {
|
||||
fn candidates(need: Need) -> Vec<PathBuf> {
|
||||
match need {
|
||||
Need::Other => other_candidates(),
|
||||
Need::Hans | Need::Hant => han_candidates(need == Need::Hant),
|
||||
}
|
||||
}
|
||||
|
||||
fn other_candidates() -> Vec<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
|
||||
["segoeui.ttf", "tahoma.ttf", "arial.ttf"].iter().map(|f| dir.join("Fonts").join(f)).collect()
|
||||
["segoeui.ttf", "tahoma.ttf", "arial.ttf"].iter().map(|f| windows_fonts().join(f)).collect()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
["/System/Library/Fonts/SFArabic.ttf", "/System/Library/Fonts/GeezaPro.ttc", "/System/Library/Fonts/Supplemental/Arial.ttf"]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
} else {
|
||||
let files = [
|
||||
unix_fonts(&[
|
||||
"truetype/noto/NotoSansArabic-Regular.ttf",
|
||||
"noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto/NotoSansArabic-Regular.ttf",
|
||||
@@ -49,21 +87,61 @@ fn candidates() -> Vec<PathBuf> {
|
||||
"TTF/DejaVuSans.ttf",
|
||||
"dejavu/DejaVuSans.ttf",
|
||||
"dejavu-sans-fonts/DejaVuSans.ttf",
|
||||
];
|
||||
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Option<Arc<FontData>> {
|
||||
/// Chinese sans faces the operating system ships, the UI language's own region first. Noto CJK
|
||||
/// and Source Han are left out on purpose (AGENTS.md §1.1).
|
||||
fn han_candidates(traditional: bool) -> Vec<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
// Microsoft YaHei / JhengHei, then the older SimSun / MingLiU collections.
|
||||
let files: &[&str] =
|
||||
if traditional { &["msjh.ttc", "msyh.ttc", "mingliu.ttc", "simsun.ttc"] } else { &["msyh.ttc", "msjh.ttc", "simsun.ttc", "mingliu.ttc"] };
|
||||
files.iter().map(|f| windows_fonts().join(f)).collect()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let files: &[&str] = if traditional {
|
||||
&["PingFang.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc", "Hiragino Sans GB.ttc"]
|
||||
} else {
|
||||
&["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc"]
|
||||
};
|
||||
files.iter().map(|f| Path::new("/System/Library/Fonts").join(f)).collect()
|
||||
} else {
|
||||
unix_fonts(&[
|
||||
"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",
|
||||
"droid/DroidSansFallbackFull.ttf",
|
||||
"truetype/arphic/uming.ttc",
|
||||
"arphic-uming/uming.ttc",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fn unix_fonts(files: &'static [&'static str]) -> Vec<PathBuf> {
|
||||
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect()
|
||||
}
|
||||
|
||||
fn read(path: &Path, probe: char, max: u64) -> Option<Arc<FontData>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_BYTES {
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let index = face_with(&bytes, PROBE)?;
|
||||
let index = face_with(&bytes, probe)?;
|
||||
let mut data = FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
log::info!("interface font fallback: {}", path.display());
|
||||
log::info!("interface font fallback: {} (face {index})", path.display());
|
||||
Some(Arc::new(data))
|
||||
}
|
||||
|
||||
@@ -83,9 +161,9 @@ mod tests {
|
||||
assert_eq!(face_with(b"", PROBE), None);
|
||||
assert_eq!(face_with(b"not a font at all", PROBE), None);
|
||||
assert_eq!(face_with(&[0u8; 4096], PROBE), None);
|
||||
assert!(read(Path::new("definitely/not/here.ttf")).is_none());
|
||||
assert!(read(Path::new("definitely/not/here.ttf"), PROBE, MAX_BYTES).is_none());
|
||||
// A directory is not a font.
|
||||
assert!(read(&std::env::temp_dir()).is_none());
|
||||
assert!(read(&std::env::temp_dir(), PROBE, MAX_BYTES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -93,13 +171,34 @@ mod tests {
|
||||
// Inter is Latin, Greek and Cyrillic only.
|
||||
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
assert_eq!(face_with(inter, PROBE), None);
|
||||
assert_eq!(face_with(inter, HANS_PROBE), None);
|
||||
assert_eq!(face_with(inter, HANT_PROBE), None);
|
||||
assert_eq!(face_with(inter, 'A'), Some(0));
|
||||
}
|
||||
|
||||
#[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(inter, 'A', MAX_BYTES).is_some());
|
||||
assert!(read(inter, 'A', 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_are_absolute_font_files() {
|
||||
let list = candidates();
|
||||
assert!(!list.is_empty());
|
||||
assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc")));
|
||||
for need in [Need::Other, Need::Hans, Need::Hant] {
|
||||
let list = candidates(need);
|
||||
assert!(!list.is_empty(), "{need:?}");
|
||||
assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc")), "{need:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_candidates_skip_adobe_designs() {
|
||||
for need in [Need::Hans, Need::Hant] {
|
||||
for path in candidates(need) {
|
||||
let name = path.to_string_lossy().to_lowercase();
|
||||
assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-12
@@ -134,28 +134,71 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
ctx.set_fonts(font_definitions());
|
||||
}
|
||||
|
||||
/// The interface language's claim on the CJK fallback faces.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CjkPreference {
|
||||
/// Japanese faces first (every non-Chinese language).
|
||||
#[default]
|
||||
Default,
|
||||
SimplifiedChinese,
|
||||
TraditionalChinese,
|
||||
}
|
||||
|
||||
impl CjkPreference {
|
||||
/// The preference for a UI language code (`zh-hans`, `zh-hant`, anything else).
|
||||
pub fn for_language(code: &str) -> Self {
|
||||
match code {
|
||||
"zh-hans" => Self::SimplifiedChinese,
|
||||
"zh-hant" => Self::TraditionalChinese,
|
||||
_ => Self::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the interface fonts with the CJK fallback order for the UI language (Chinese
|
||||
/// first for Simplified Chinese, Japanese first otherwise). Call it when the language
|
||||
/// changes; the new faces take effect next frame.
|
||||
pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) {
|
||||
ctx.set_fonts(installed_font_definitions(prefer_hans));
|
||||
/// first for Chinese, Japanese first otherwise). Call it when the language changes; the new
|
||||
/// faces take effect next frame.
|
||||
pub fn install_fonts_for(ctx: &egui::Context, cjk: CjkPreference) {
|
||||
ctx.set_fonts(installed_font_definitions(cjk));
|
||||
}
|
||||
|
||||
/// The name of the installed face [`installed_font_definitions`] may add after the embedded ones.
|
||||
pub const SYSTEM_FALLBACK: &str = "system-fallback";
|
||||
|
||||
/// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face
|
||||
/// already installed on this machine as the last fallback of every family. It only draws
|
||||
/// characters no embedded face has (an Arabic file name in a build without craft-fonts);
|
||||
/// already installed on this machine. It only draws characters no embedded face has.
|
||||
///
|
||||
/// - In a Chinese interface built without a `Hans` craft-fonts face (every release so far,
|
||||
/// #826), it is a Chinese system face (Microsoft YaHei, PingFang, …), placed before the
|
||||
/// Japanese craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …),
|
||||
/// and one line drawn from two faces with different vertical metrics would not sit on one
|
||||
/// baseline.
|
||||
/// - Otherwise it is the last fallback of every family (an Arabic file name in a build without
|
||||
/// craft-fonts).
|
||||
///
|
||||
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves it out.
|
||||
pub fn installed_font_definitions(prefer_hans: bool) -> FontDefinitions {
|
||||
pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
|
||||
#[cfg_attr(target_arch = "wasm32", expect(unused_mut))]
|
||||
let mut fonts = font_definitions_for(prefer_hans);
|
||||
let mut fonts = font_definitions_for(cjk == CjkPreference::SimplifiedChinese);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(data) = crate::system_fonts::fallback() {
|
||||
fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data);
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push(SYSTEM_FALLBACK.to_owned());
|
||||
{
|
||||
use crate::system_fonts::{Need, fallback};
|
||||
let need = match cjk {
|
||||
_ if !pdfcraft_fonts::ui_chinese_fonts().is_empty() => Need::Other,
|
||||
CjkPreference::SimplifiedChinese => Need::Hans,
|
||||
CjkPreference::TraditionalChinese => Need::Hant,
|
||||
CjkPreference::Default => Need::Other,
|
||||
};
|
||||
if let Some(data) = fallback(need) {
|
||||
fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data);
|
||||
let japanese: Vec<String> = pdfcraft_fonts::ui_japanese_fonts().iter().map(|f| f.name()).collect();
|
||||
for stack in fonts.families.values_mut() {
|
||||
let at = match need {
|
||||
Need::Other => None,
|
||||
Need::Hans | Need::Hant => stack.iter().position(|n| japanese.contains(n)),
|
||||
};
|
||||
stack.insert(at.unwrap_or(stack.len()), SYSTEM_FALLBACK.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
fonts
|
||||
|
||||
@@ -129,7 +129,7 @@ fn telugu_ui_text_uses_craft_fonts() {
|
||||
#[test]
|
||||
fn system_fallback_fills_missing_scripts() {
|
||||
assert!(!theme::font_definitions().font_data.contains_key(theme::SYSTEM_FALLBACK));
|
||||
let defs = theme::installed_font_definitions(false);
|
||||
let defs = theme::installed_font_definitions(theme::CjkPreference::Default);
|
||||
if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) {
|
||||
eprintln!("skipping system_fallback_fills_missing_scripts: no installed fallback font (or PDFCRAFT_SYSTEM_FONTS=0)");
|
||||
assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == theme::SYSTEM_FALLBACK)));
|
||||
@@ -147,6 +147,62 @@ fn system_fallback_fills_missing_scripts() {
|
||||
assert!(layout_widths(&mut fonts, ARABIC).iter().all(|w| w.is_finite() && *w > 0.0));
|
||||
}
|
||||
|
||||
/// The Han characters of a catalog's translations.
|
||||
fn catalog_han(catalog: &str) -> String {
|
||||
let mut han: Vec<char> = catalog
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.filter_map(|l| l.split('\t').nth(2))
|
||||
.flat_map(str::chars)
|
||||
.filter(|c| matches!(*c, '\u{3400}'..='\u{9fff}' | '\u{f900}'..='\u{faff}'))
|
||||
.collect();
|
||||
han.sort_unstable();
|
||||
han.dedup();
|
||||
han.into_iter().collect()
|
||||
}
|
||||
|
||||
/// In a Chinese interface built without a `Hans` face (every release so far, #826), a Chinese
|
||||
/// face installed on this machine goes before the Japanese faces, which lack hundreds of the
|
||||
/// catalog's characters, and draws every Han character of both Chinese catalogs.
|
||||
#[test]
|
||||
fn chinese_ui_uses_an_installed_chinese_face() {
|
||||
if !pdfcraft_fonts::ui_chinese_fonts().is_empty() {
|
||||
eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: a Hans face is bundled");
|
||||
return;
|
||||
}
|
||||
for (cjk, catalog) in [
|
||||
(theme::CjkPreference::SimplifiedChinese, include_str!("../src/i18n/zh-hans.tsv")),
|
||||
(theme::CjkPreference::TraditionalChinese, include_str!("../src/i18n/zh-hant.tsv")),
|
||||
] {
|
||||
let defs = theme::installed_font_definitions(cjk);
|
||||
if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) {
|
||||
eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: no installed Chinese face (or PDFCRAFT_SYSTEM_FONTS=0)");
|
||||
return;
|
||||
}
|
||||
for (family, stack) in &defs.families {
|
||||
let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family");
|
||||
assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}");
|
||||
let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font");
|
||||
assert!(own < system, "{family:?}: {stack:?}");
|
||||
if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) {
|
||||
assert!(system < first_ja, "{family:?}: {stack:?}");
|
||||
}
|
||||
}
|
||||
let han = catalog_han(catalog);
|
||||
assert!(han.contains('欢') || han.contains('說'), "{cjk:?}");
|
||||
let mut fonts = Fonts::new(TextOptions::default(), defs);
|
||||
for id in families() {
|
||||
let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect();
|
||||
assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}");
|
||||
}
|
||||
}
|
||||
// Other languages keep the installed face last, as before.
|
||||
let defs = theme::installed_font_definitions(theme::CjkPreference::Default);
|
||||
for stack in defs.families.values() {
|
||||
assert!(stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).is_none_or(|i| i + 1 == stack.len()), "{stack:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without craft-fonts the interface fonts still install and lay out any text (Japanese falls
|
||||
/// back to egui's replacement glyph) without panicking; Latin text is unaffected.
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`
|
||||
|
||||
The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
|
||||
|
||||
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
|
||||
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
|
||||
|
||||
The Brazilian Portuguese catalog has 2,000 entries: the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), the labels added since, and a review of the existing wording. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`).
|
||||
|
||||
@@ -37,7 +37,7 @@ What Arabic does not have yet:
|
||||
|
||||
These need bidirectional support in the text layout itself, not in the catalog.
|
||||
|
||||
Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` never embeds: `AGENTS.md` §1.1 bars it, so Chinese still has no face of its own.
|
||||
Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` never embeds: `AGENTS.md` §1.1 bars it, so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above).
|
||||
|
||||
File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly.
|
||||
|
||||
|
||||
Reference in new issue
Block a user