diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 40de99b..510f5e0 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -299,6 +299,10 @@ pub struct CharProps { /// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`. #[serde(skip_serializing_if = "Option::is_none")] pub font_cs: Option, + /// East Asian font (Chinese, Japanese and Korean text; OOXML `w:rFonts/@w:eastAsia`). `None` = + /// inherit, then `font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub font_ea: Option, /// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]). #[serde(skip_serializing_if = "Option::is_none")] pub size_cs: Option, @@ -394,7 +398,7 @@ impl CharProps { pub fn overlay(&mut self, patch: &CharProps) { overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change); + cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change); if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index f2da0cc..6191066 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -52,6 +52,9 @@ pub struct ResolvedChar { pub cs: bool, /// Complex-script font, size, bold and italic (Arabic, Persian, Hebrew… text). pub font_cs: String, + /// East Asian font (Chinese, Japanese and Korean text), when one is set. + #[serde(skip_serializing_if = "Option::is_none")] + pub font_ea: Option, pub size_cs: f32, pub bold_cs: bool, pub italic_cs: bool, @@ -93,6 +96,7 @@ impl ResolvedChar { rtl: c.rtl.unwrap_or(false), cs: c.cs.unwrap_or(false), font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), + font_ea: c.font_ea.clone(), size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0), bold_cs: c.bold_cs.or(c.bold).unwrap_or(false), italic_cs: c.italic_cs.or(c.italic).unwrap_or(false), diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 4200cc5..6e73840 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -64,6 +64,9 @@ impl PropCtx { // The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is // left to inherit: the theme's per-script fonts aren't read. c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string); + // The East Asian font (Chinese, Japanese, Korean text). A `w:eastAsiaTheme` font + // is left to inherit, like `w:cstheme`. + c.font_ea = k.attr("w:eastAsia").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string); } "w:b" => c.bold = Some(on_off(k)), "w:bCs" => c.bold_cs = Some(on_off(k)), diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 62e9e7f..c2a2261 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -29,11 +29,18 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { // The complex-script font is written only when set: without it, Persian/Arabic text keeps // the style's. Complex-script bold, italic and size default to the plain ones (they come in // pairs, see `CharProps::overlay`), so text formatted here looks the same in Word. - match (&c.font, &c.font_cs) { - (Some(f), Some(cs)) => w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f), ("w:cs", cs)]), - (Some(f), None) => w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f)]), - (None, Some(cs)) => w.empty("w:rFonts", &[("w:cs", cs)]), - (None, None) => {} + let mut fonts: Vec<(&str, &str)> = Vec::new(); + if let Some(f) = &c.font { + fonts.extend([("w:ascii", f.as_str()), ("w:hAnsi", f.as_str())]); + } + if let Some(ea) = &c.font_ea { + fonts.push(("w:eastAsia", ea)); + } + if let Some(cs) = &c.font_cs { + fonts.push(("w:cs", cs)); + } + if !fonts.is_empty() { + w.empty("w:rFonts", &fonts); } toggle(w, "w:b", c.bold); toggle(w, "w:bCs", c.bold_cs.or(c.bold)); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 83fe912..701f8bc 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -105,6 +105,7 @@ fn every_char_prop_round_trips() { rtl: Some(false), cs: Some(true), font_cs: Some("B Nazanin".into()), + font_ea: Some("宋体".into()), size_cs: Some(13.0), bold_cs: Some(false), italic_cs: Some(true), diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 4bb3055..1f8172d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -280,10 +280,19 @@ fn font(s: &mut Session, v: &Value) -> CmdResult { if name.is_empty() || name.len() > 128 { return Err(CmdError::Params("bad font name".into())); } - // The font applies to Persian/Arabic text in the selection too (its complex-script font). + // The font applies to Persian/Arabic text in the selection too (its complex-script font). A + // Chinese, Japanese or Korean font is the East Asian text's font as well (`w:eastAsia`), as in + // Word; choosing a Latin font leaves that text's font as it was. + let east_asian = { + let r = wordcraft_fonts::word::resolve(&name, false, false); + !r.substituted && r.face.is_east_asian() + }; apply(s, &|c| { c.font = Some(name.clone()); c.font_cs = Some(name.clone()); + if east_asian { + c.font_ea = Some(name.clone()); + } }) } diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 9970e36..8ad7405 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -404,12 +404,14 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult { para.format(0, len, &|c| { c.font = None; c.font_cs = None; + c.font_ea = None; c.size = None; c.size_cs = None; c.color = None; })?; para.mark.font = None; para.mark.font_cs = None; + para.mark.font_ea = None; para.mark.size = None; para.mark.size_cs = None; para.mark.color = None; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index b437b8a..00dbe20 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -54,6 +54,9 @@ pub struct FontFace { /// Semibold"): the typographic family for Regular, Bold and Italic faces, the family plus the /// other style words for the rest. See [`legacy_family`]. pub legacy_family: String, + /// The family's names in other languages (宋体 for SimSun), from the `name` table. Lookups by + /// family name accept them; the font list shows them in a matching interface language. + pub local_names: Vec, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -212,6 +215,47 @@ impl FontFace { pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) } + /// Is this an East Asian (Chinese, Japanese or Korean) font? Its OS/2 code pages say so + /// (Shift-JIS, GB2312, Wansung, Big5, Johab), or, without them, it maps common ideographs. + /// Word applies such a font to East Asian text too (`w:eastAsia`). + pub fn is_east_asian(&self) -> bool { + use skrifa::raw::TableProvider; + const CJK_CODE_PAGES: u32 = 0b1_1111 << 17; + match self.skrifa().and_then(|f| f.os2().ok()).and_then(|t| t.ul_code_page_range_1()) { + Some(pages) if pages != 0 => pages & CJK_CODE_PAGES != 0, + _ => ['中', '国', '日'].iter().all(|c| self.covers(*c)), + } + } +} + +/// A family name in another language, from a font's `name` table. +#[derive(Clone, Debug, PartialEq)] +pub struct LocalName { + /// BCP 47 language tag, lowercase (`zh-hans`, `zh-tw`, `ja-jp`). + pub lang: String, + /// The name in that language (宋体). + pub name: String, + /// The English family name it stands for (SimSun): the face's typographic or legacy family. + pub english: String, +} + +/// Does a `name` table language tag (`zh-Hans`, `zh-TW`, `ja-JP`) suit the interface language +/// `ui` (`zh-hans`, `zh-hant`, `ja`)? Chinese matches by script: Simplified for mainland China and +/// Singapore, Traditional for Taiwan, Hong Kong and Macao. +pub fn name_language_matches(ui: &str, tag: &str) -> bool { + let parts = |s: &str| s.split(['-', '_']).map(str::to_ascii_lowercase).collect::>(); + let (u, t) = (parts(ui), parts(tag)); + let (Some(up), Some(tp)) = (u.first(), t.first()) else { return false }; + if up.is_empty() || up != tp { + return false; + } + if up != "zh" { + return true; + } + let traditional = |p: &[String]| p.iter().any(|x| matches!(x.as_str(), "hant" | "tw" | "hk" | "mo")); + let simplified = |p: &[String]| p.iter().any(|x| matches!(x.as_str(), "hans" | "cn" | "sg")); + // Plain `zh` is Simplified Chinese, as in the interface languages. + if traditional(&u) { traditional(&t) } else { simplified(&t) } } #[cfg(not(target_arch = "wasm32"))] @@ -220,6 +264,7 @@ struct CatalogEntry { family: String, style: String, legacy: String, + local: Vec, path: std::path::PathBuf, } @@ -327,6 +372,35 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } +/// The family's names in other languages: localized typographic family names (name ID 16) stand +/// for `family`, localized legacy family names (name ID 1) for `id1` (or for `family` when the +/// font has no typographic names). Bounded: a damaged font can list any number of records. +fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec { + const MAX_NAMES: usize = 64; + const MAX_CHARS: usize = 128; + let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some(); + let legacy = if typographic { id1 } else { Some(family) }; + let mut out: Vec = Vec::new(); + for (id, english) in [(StringId::TYPOGRAPHIC_FAMILY_NAME, Some(family)), (StringId::FAMILY_NAME, legacy)] { + let Some(english) = english else { continue }; + for s in f.localized_strings(id) { + let Some(lang) = s.language().map(str::to_ascii_lowercase) else { continue }; + if lang == "en" || lang.starts_with("en-") { + continue; + } + let name: String = s.chars().take(MAX_CHARS).collect::().trim().to_string(); + if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) { + continue; + } + if out.len() >= MAX_NAMES { + return out; + } + out.push(LocalName { lang, name, english: english.to_string() }); + } + } + out +} + /// A face's family and style names (the default instance's style for a variable font), and its /// legacy family name (name ID 1) when it has one. pub(crate) fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option)> { @@ -345,11 +419,12 @@ pub(crate) fn legacy_family(family: &str, style: &str) -> String { if extra.is_empty() { family.to_string() } else { format!("{family} {}", extra.join(" ")) } } -/// (family, style, legacy family) of every face in the font file at `path`, reading only its -/// table directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of -/// them megabytes long. A variable font yields one entry per named instance, as it does loaded. +/// The names of every face in the font file at `path`, reading only its table directories and +/// `name` and `fvar` tables: a scan opens hundreds of font files, many of them megabytes long +/// (CJK collections run to 20 MB and more). A variable font yields one entry per named instance, +/// as it does loaded. #[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &std::path::Path) -> Vec<(String, String, String)> { +fn file_face_names(path: &std::path::Path) -> Vec { use std::io::{Read, Seek, SeekFrom}; /// Caps on what a (possibly damaged) file can make the scan read. const MAX_FACES: u32 = 256; @@ -390,8 +465,7 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String, String)> { tables.push((*b"name", name)); // A font holding just those tables, to read them as the font itself would. let font = mini_font(&tables); - let faces = font_faces(&skrifa::FontRef::new(&font).ok()?, 0); - Some(faces.into_iter().map(|f| (f.family, f.style, f.legacy)).collect::>()) + Some(font_faces(&skrifa::FontRef::new(&font).ok()?, 0)) }) .flatten() .collect() @@ -468,6 +542,8 @@ struct Found { style: String, /// The legacy family name ([`FontFace::legacy_family`]). legacy: String, + /// The family's names in other languages ([`FontFace::local_names`]). + local: Vec, /// Variable fonts: the named instance's axis settings. coords: Vec<([u8; 4], f32)>, } @@ -476,6 +552,7 @@ struct Found { /// (InDesign lists them as styles), otherwise the font itself. fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { let Some((family, style, id1)) = face_names(f) else { return vec![] }; + let local = local_names(f, &family, id1.as_deref()); let axes = f.axes(); let mut named: Vec = Vec::new(); for ni in f.named_instances().iter() { @@ -486,13 +563,15 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); // Name ID 1 names the default instance only. let legacy = legacy_family(&family, &style); - named.push(Found { index, family: family.clone(), style, legacy, coords }); + // Name ID 1 names the default instance only: keep the names of the whole family. + let local = local.iter().filter(|n| n.english.eq_ignore_ascii_case(&family)).cloned().collect(); + named.push(Found { index, family: family.clone(), style, legacy, local, coords }); } if !named.is_empty() { return named; } let legacy = id1.filter(|n| !n.eq_ignore_ascii_case(&family)).unwrap_or_else(|| legacy_family(&family, &style)); - vec![Found { index, family, style, legacy, coords: Vec::new() }] + vec![Found { index, family, style, legacy, local, coords: Vec::new() }] } /// Parse every face in `data` (a font file or collection); a variable font yields one face per @@ -545,6 +624,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy family, style, legacy_family, + local_names: Vec::new(), weight, italic, upem: m.units_per_em.max(1) as f64, @@ -606,8 +686,9 @@ impl FontDb { let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { for f in enumerate_faces(data) { - if let Some(f) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) { - faces.push(Arc::new(f)); + if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) { + face.local_names = f.local; + faces.push(Arc::new(face)); } } } @@ -671,9 +752,65 @@ impl FontDb { v } + /// Every family's name in the interface language `lang` (`zh-hans`, `zh-hant`, `ja`…), as + /// (English name, localized name) pairs: SimSun is 宋体 in Simplified Chinese. Families without + /// a name in that language are left out. + pub fn localized_names(&self, lang: &str) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = Vec::new(); + let mut add = |names: &[LocalName]| { + for n in names.iter().filter(|n| name_language_matches(lang, &n.lang)) { + if !v.iter().any(|(e, _)| e.eq_ignore_ascii_case(&n.english)) { + v.push((n.english.clone(), n.name.clone())); + } + } + }; + for f in self.read_faces().iter() { + add(&f.local_names); + } + #[cfg(not(target_arch = "wasm32"))] + for c in self.read_catalog().iter() { + add(&c.local); + } + v + } + + /// [`FontDb::families`] as the font list shows them in the interface language `lang`: each + /// family that has a name in that language by that name (宋体, 微软雅黑 in Simplified Chinese), + /// the others by their English names. Lookups accept either name. + pub fn families_in(&self, lang: &str) -> Vec { + let local: HashMap = self.localized_names(lang).into_iter().map(|(e, l)| (e.to_lowercase(), l)).collect(); + let mut v: Vec = self.families().into_iter().map(|f| local.get(&f.to_lowercase()).cloned().unwrap_or(f)).collect(); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// The English family a localized family name stands for (宋体 → SimSun), when no face has + /// `family` as its English name. `None` for names no font has. + pub fn english_family(&self, family: &str) -> Option { + let hit = |names: &[LocalName]| names.iter().find(|n| n.name.eq_ignore_ascii_case(family)).map(|n| n.english.clone()); + if let Some(e) = self.read_faces().iter().find_map(|f| hit(&f.local_names)) { + return Some(e); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(e) = self.read_catalog().iter().find_map(|c| hit(&c.local)) { + return Some(e); + } + None + } + /// Style names available for `family` (Regular first, then by weight). For a legacy family - /// name ("Roboto Light"), the styles of the faces it names ("Light", "Light Italic"). + /// name ("Roboto Light"), the styles of the faces it names ("Light", "Light Italic"); for a + /// localized name (宋体), those of the family it names. pub fn styles(&self, family: &str) -> Vec { + let v = self.styles_named(family); + match self.english_family(family) { + Some(en) if v.is_empty() => self.styles_named(&en), + _ => v, + } + } + + fn styles_named(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = Vec::new(); for legacy in [false, true] { let named = |typographic: &str, legacy_name: &str| (if legacy { legacy_name } else { typographic }).eq_ignore_ascii_case(family); @@ -713,8 +850,9 @@ impl FontDb { for bytes in files { let data = Arc::new(bytes); for f in enumerate_faces(&data) { - if let Some(f) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) { - new.push(Arc::new(f)); + if let Some(mut face) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) { + face.local_names = f.local; + new.push(Arc::new(face)); } } } @@ -781,8 +919,8 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - for (family, style, legacy) in file_face_names(&p) { - found.push(CatalogEntry { family, style, legacy, path: p.clone() }); + for f in file_face_names(&p) { + found.push(CatalogEntry { family: f.family, style: f.style, legacy: f.legacy, local: f.local, path: p.clone() }); } } } @@ -834,13 +972,27 @@ impl FontDb { return f; } } + // A localized family name (宋体 → SimSun). + if let Some(en) = self.english_family(family).filter(|en| !en.eq_ignore_ascii_case(family)) { + if let Some(f) = self.find(&en, style) { + return f; + } + #[cfg(not(target_arch = "wasm32"))] + { + self.load_cataloged(&en); + if let Some(f) = self.find(&en, style) { + return f; + } + } + } self.find(FALLBACK_FAMILY, style) .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) .or_else(|| self.read_faces().first().cloned()) .unwrap_or_else(last_resort_face) } - /// Is `family` available (loaded, or installed on the system)? + /// Is `family` available (loaded, or installed on the system)? English and localized family + /// names both count. pub fn has_family(&self, family: &str) -> bool { if self.is_loaded(family) { return true; @@ -849,7 +1001,7 @@ impl FontDb { if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family)) { return true; } - false + self.english_family(family).is_some() } /// Is a face named `family` (its typographic or legacy family) loaded? @@ -892,7 +1044,8 @@ impl FontDb { None => coords.push((tag, v)), } } - let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), base.legacy_family.clone(), coords)?; + let mut f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), base.legacy_family.clone(), coords)?; + f.local_names = base.local_names.clone(); let f = Arc::new(f); self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); Some(f) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 042480b..373aa1c 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -12,7 +12,7 @@ pub mod math; mod ui_fallback; pub mod word; -pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; +pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LocalName, base_style, bundled, name_language_matches, system_font_dirs}; pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index ee6148b..7826504 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -58,6 +58,11 @@ fn font_dir(name: &str) -> PathBuf { dir } +/// (family, style, legacy family) of each face the scan finds in the file at `path`. +fn names(path: &Path) -> Vec<(String, String, String)> { + file_face_names(path).into_iter().map(|f| (f.family, f.style, f.legacy)).collect() +} + fn has(list: &[String], family: &str) -> bool { list.iter().any(|f| f == family) } @@ -171,7 +176,7 @@ fn fallback_lookups_are_remembered_until_fonts_load() { #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); - assert_eq!(file_face_names(&dir.join("Sysfont-Regular.ttf")), [(FAMILY.to_string(), "Regular".to_string(), FAMILY.to_string())]); + assert_eq!(names(&dir.join("Sysfont-Regular.ttf")), [(FAMILY.to_string(), "Regular".to_string(), FAMILY.to_string())]); assert!(file_face_names(&dir.join("damaged.ttf")).is_empty()); assert!(file_face_names(&dir.join("readme.txt")).is_empty()); assert!(file_face_names(&dir.join("missing.ttf")).is_empty()); @@ -184,7 +189,7 @@ fn installed_weights_are_found_by_their_legacy_family_name() { let dir = font_dir("legacy"); std::fs::write(dir.join("Sysfont-Semibold.ttf"), renamed("SourceSans3-Semibold.ttf")).unwrap(); let semibold = format!("{FAMILY} Semibold"); - assert_eq!(file_face_names(&dir.join("Sysfont-Semibold.ttf")), [(FAMILY.to_string(), "Semibold".to_string(), semibold.clone())]); + assert_eq!(names(&dir.join("Sysfont-Semibold.ttf")), [(FAMILY.to_string(), "Semibold".to_string(), semibold.clone())]); let db = || FontDb::with_font_dirs(vec![dir.clone()]); assert!(has(&db().families(), FAMILY) && has(&db().families(), &semibold)); assert!(db().has_family(&semibold.to_uppercase())); @@ -216,7 +221,7 @@ fn the_scan_lists_a_variable_fonts_named_instances_as_loading_it_does() { }; let data = std::fs::read(path).unwrap(); let loaded: Vec<(String, String, String)> = enumerate_faces(&data).into_iter().map(|f| (f.family, f.style, f.legacy)).collect(); - assert_eq!(file_face_names(path), loaded); + assert_eq!(names(path), loaded); if loaded.len() > 1 { // Instances other than Regular, Bold and Italic have names of their own. assert!(loaded.iter().any(|(family, _, legacy)| family != legacy), "{loaded:?}"); @@ -244,3 +249,77 @@ fn fallback_cache_is_dropped_when_a_face_loads_by_its_legacy_family_name() { assert_eq!(cache.faces, db.read_faces().len()); assert!(!cache.map.contains_key(&(thai, latin.id())), "answers from before the load are dropped"); } + +/// A bundled font renamed by a `name` table holding just its family names: (Windows language id, +/// name) pairs, English (0x0409) first, as SimSun's names 宋体 for Simplified Chinese. +fn with_family_names(file: &str, names: &[(u16, &str)]) -> Vec { + let data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let font = skrifa::FontRef::new(&data).unwrap(); + // Records sorted by language, then name ID: family (1) and subfamily (2, English only). + let mut records: Vec<(u16, u16, Vec)> = + names.iter().map(|(lang, n)| (*lang, 1, n.encode_utf16().flat_map(u16::to_be_bytes).collect())).collect(); + records.push((0x0409, 2, "Regular".encode_utf16().flat_map(u16::to_be_bytes).collect())); + records.sort_by_key(|r| (r.0, r.1)); + let mut name = Vec::new(); + for v in [0u16, records.len() as u16, (6 + 12 * records.len()) as u16] { + name.extend_from_slice(&v.to_be_bytes()); + } + let mut offset = 0u16; + for (lang, id, s) in &records { + for v in [3u16, 1, *lang, *id, s.len() as u16, offset] { + name.extend_from_slice(&v.to_be_bytes()); + } + offset += s.len() as u16; + } + for (_, _, s) in &records { + name.extend_from_slice(s); + } + let mut tables: Vec<([u8; 4], Vec)> = font + .table_directory() + .table_records() + .iter() + .filter(|r| r.tag() != skrifa::Tag::new(b"name")) + .map(|r| (r.tag().to_be_bytes(), font.table_data(r.tag()).unwrap().as_bytes().to_vec())) + .collect(); + tables.push((*b"name", name)); + tables.sort_by_key(|t| t.0); + mini_font(&tables) +} + +#[test] +fn chinese_fonts_are_listed_by_their_chinese_names_in_a_chinese_interface() { + // #505: the font list showed fonts only by their English names (SimSun, never 宋体). Here a + // Chinese font is the second face of a collection, as in simsun.ttc and msyh.ttc. + let dir = font_dir("localized"); + let song = with_family_names("SourceSans3-Regular.ttf", &[(0x0409, "Songti Test"), (0x0804, "测试宋体"), (0x0404, "測試宋體")]); + std::fs::write(dir.join("song.ttc"), collection(&[renamed("SourceSans3-Semibold.ttf"), song])).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + // English names in the English list; the Chinese names in place of them in a Chinese one. + assert!(has(&db.families(), "Songti Test") && !has(&db.families(), "测试宋体")); + assert!(has(&db.families_in("en"), "Songti Test")); + let hans = db.families_in("zh-hans"); + assert!(has(&hans, "测试宋体") && !has(&hans, "Songti Test") && has(&hans, FAMILY), "{hans:?}"); + assert!(has(&db.families_in("zh-hant"), "測試宋體")); + assert_eq!(db.localized_names("zh-hans"), [("Songti Test".to_string(), "测试宋体".to_string())]); + // Either name finds the font, before and after it loads. + assert!(db.has_family("测试宋体") && db.has_family("測試宋體")); + assert_eq!(db.styles("测试宋体"), ["Regular"]); + let f = db.face("测试宋体", "Regular"); + assert_eq!((f.family.as_str(), f.index()), ("Songti Test", 1)); + assert_eq!(db.face("測試宋體", "Bold").id(), f.id()); + assert_eq!(db.english_family("测试宋体").as_deref(), Some("Songti Test")); + assert!(db.has_family("测试宋体")); + // Name table languages against interface languages. + for (ui, tag, ok) in [ + ("zh-hans", "zh-Hans", true), + ("zh-hans", "zh-SG", true), + ("zh-hans", "zh-TW", false), + ("zh-hant", "zh-HK", true), + ("zh-hant", "zh-Hans", false), + ("ja", "ja-JP", true), + ("ja", "ko-KR", false), + ("en", "zh-Hans", false), + ] { + assert_eq!(name_language_matches(ui, tag), ok, "{ui} {tag}"); + } +} diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 6914e14..a5df55e 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -493,6 +493,12 @@ pub fn visual_order(levels: &[u8]) -> Vec { order } +/// Text Word sets in the East Asian font: CJK ideographs, kana, bopomofo, CJK punctuation and +/// fullwidth forms, and Hangul. +fn east_asian(c: char) -> bool { + crate::kinsoku::is_east_asian(c) || matches!(u32::from(c), 0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF) +} + impl<'a> Builder<'a> { /// Style index for a resolved char in `face` (caps-scaled `size` override for small caps). fn style(&mut self, rc: &Arc, face_override: Option, small: bool) -> u16 { @@ -562,6 +568,15 @@ impl<'a> Builder<'a> { let rc_cs: Option> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex())); let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face; let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face); + // Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when + // it is installed and has them. + let primary_ea = rc + .font_ea + .as_ref() + .filter(|f| !f.eq_ignore_ascii_case(&rc.font) && text.chars().any(east_asian)) + .map(|f| wordcraft_fonts::word::resolve(f, rc.bold, rc.italic)) + .filter(|r| !r.substituted) + .map(|r| r.face); let use_levels = para_text && !self.levels.is_empty(); // Split by font coverage (fallback faces), small caps case, script formatting and direction. let mut seg_start = 0; @@ -576,6 +591,7 @@ impl<'a> Builder<'a> { let prim = if complex { primary_cs.unwrap_or(primary) } else { primary }; let face = match cur { Some(k) if mark => k.face, + _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea, _ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, // Stay in the fallback face the word started in while it covers the letters. Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f), diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 86a664d..14b8003 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -96,9 +96,11 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { let align = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props).align).unwrap_or_default(); let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let font = app.previews.font_shown(&font); let fams = app.previews.families(); let prev = app.previews.font_preview_fn(); if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { + let f = app.previews.font_stored(&f); let _ = app.run("format.font", json!({"name": f})); } let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index ffe5d81..608ae65 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -15,7 +15,11 @@ use crate::theme::{Tokens, regular}; #[derive(Default)] pub struct Previews { tex: HashMap, - families: Option>, + /// The font list, for the interface language it was made in. + families: Option<(&'static str, Vec)>, + /// Lowercase English family → its name in that language (simsun → 宋体), and back. + local_names: HashMap, + english_names: HashMap, styles_hash: (u64, u64), /// Previews rendered this frame (rendering is spread over frames). budget: std::cell::Cell, @@ -23,9 +27,13 @@ pub struct Previews { } impl Previews { + /// The font list: installed families, each by its name in the interface language when it has + /// one (宋体 for SimSun in Chinese, #505), like Word's. pub fn families(&mut self) -> Vec { - if self.families.is_none() { - let mut f: Vec = wordcraft_fonts::FontDb::global().families().into_iter().filter(|f| !f.starts_with('.')).collect(); + let lang = crate::i18n::current().code(); + if self.families.as_ref().is_none_or(|(l, _)| *l != lang) { + let db = wordcraft_fonts::FontDb::global(); + let mut f: Vec = db.families_in(lang).into_iter().filter(|f| !f.starts_with('.')).collect(); for w in [wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT] { if !f.iter().any(|x| x == w) { f.push(w.to_string()); @@ -33,9 +41,26 @@ impl Previews { } f.sort_by_key(|a| a.to_lowercase()); f.dedup(); - self.families = Some(f); + let names = db.localized_names(lang); + self.local_names = names.iter().map(|(e, l)| (e.to_lowercase(), l.clone())).collect(); + self.english_names = names.into_iter().map(|(e, l)| (l, e)).collect(); + self.families = Some((lang, f)); } - self.families.clone().unwrap_or_default() + self.families.as_ref().map(|(_, f)| f.clone()).unwrap_or_default() + } + + /// The name the font box shows for the document's font `family`: its name in the interface + /// language when the font has one. + pub fn font_shown(&mut self, family: &str) -> String { + self.families(); + self.local_names.get(&family.to_lowercase()).cloned().unwrap_or_else(|| family.to_string()) + } + + /// The family name a font chosen in the font box is stored as: the English name for a font + /// listed by its localized name (宋体 → SimSun), so the document reads the same anywhere. + pub fn font_stored(&mut self, shown: &str) -> String { + self.families(); + self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string()) } /// A closure drawing a font-menu entry in its own face. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2d08502..03e5e5a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -351,9 +351,11 @@ fn home(app: &mut WordApp, ui: &mut Ui) { stack(ui, |ui| { crate::widgets::row(ui, |ui| { let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let font = app.previews.font_shown(&font); let fams = app.previews.families(); let prev = app.previews.font_preview_fn(); if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { + let f = app.previews.font_stored(&f); let _ = app.run("format.font", json!({"name": f})); } let size = st diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d467f28..a06f3fc 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (CJK interface font coverage, #486; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (font list shows fonts by their names in the interface language, #505; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -40,7 +40,7 @@ shared items below. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| | English | en | source (100%) | ✅ | Latin ✅ | yes | **full** | 0 | -| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); no vertical text | no | partial | 3–5 | +| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); font list by Chinese names (宋体, 微软雅黑, #505); no vertical text | no | partial | 3–5 | | Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 | | Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 | | Arabic | ar | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 | @@ -76,6 +76,7 @@ proofing is not included; terminology and checks: [`localization-tr.md`](localiz | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | | 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | | 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted |