Font list: installed Chinese fonts are listed, by their Chinese names in a Chinese interface (#505) (#506)

The font database read only the English family name of each font, so a
Chinese interface listed SimSun, Microsoft YaHei and WenQuanYi Zen Hei
by their English names and never as 宋体, 微软雅黑 or 文泉驿正黑, and a
document naming a font by its Chinese name fell back to another font.

- fonts: keep each face's localized family names (name IDs 16 and 1,
  every face of a collection, read during the scan without loading the
  file); FontDb::families_in(lang) lists them in place of the English
  names for a matching interface language (zh-hans, zh-hant, ja, ko);
  face, has_family and styles accept either name.
- ui: the ribbon and mini toolbar font boxes show the interface
  language's names and store the English name.
- East Asian font: choosing a Chinese, Japanese or Korean font also sets
  it as the East Asian font (w:eastAsia), read and written in DOCX;
  layout draws CJK text in a run's installed East Asian font.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-12 00:27:53 +02:00 committed by GitHub
parent 91b960ed74
commit ee04e2b7f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 344 additions and 36 deletions

View File

@ -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<String>,
/// 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<String>,
/// 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<f32>,
@ -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);
}

View File

@ -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<String>,
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),

View File

@ -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)),

View File

@ -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));

View File

@ -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),

View File

@ -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());
}
})
}

View File

@ -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;

View File

@ -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<LocalName>,
/// 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::<Vec<_>>();
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<LocalName>,
path: std::path::PathBuf,
}
@ -327,6 +372,35 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
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<LocalName> {
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<LocalName> = 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::<String>().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<String>)> {
@ -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<Found> {
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::<Vec<_>>())
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<LocalName>,
/// 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<Found> {
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<Found> = Vec::new();
for ni in f.named_instances().iter() {
@ -486,13 +563,15 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec<Found> {
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<String> {
let local: HashMap<String, String> = self.localized_names(lang).into_iter().map(|(e, l)| (e.to_lowercase(), l)).collect();
let mut v: Vec<String> = 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<String> {
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<String> {
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<String> {
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)

View File

@ -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;

View File

@ -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<u8> {
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<u8>)> =
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<u8>)> = 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}");
}
}

View File

@ -493,6 +493,12 @@ pub fn visual_order(levels: &[u8]) -> Vec<usize> {
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<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u16 {
@ -562,6 +568,15 @@ impl<'a> Builder<'a> {
let rc_cs: Option<Arc<ResolvedChar>> = 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),

View File

@ -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();

View File

@ -15,7 +15,11 @@ use crate::theme::{Tokens, regular};
#[derive(Default)]
pub struct Previews {
tex: HashMap<String, TextureHandle>,
families: Option<Vec<String>>,
/// The font list, for the interface language it was made in.
families: Option<(&'static str, Vec<String>)>,
/// Lowercase English family → its name in that language (simsun → 宋体), and back.
local_names: HashMap<String, String>,
english_names: HashMap<String, String>,
styles_hash: (u64, u64),
/// Previews rendered this frame (rendering is spread over frames).
budget: std::cell::Cell<u32>,
@ -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<String> {
if self.families.is_none() {
let mut f: Vec<String> = 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<String> = 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.

View File

@ -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

View File

@ -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 |