Type: wire Show Font Names in English (#204) (#2981)

Preferences > Type > Show Font Names in English (default on) now drives the
font menus (Type options bar, Character panel, style and Glyphs panels). With
it off, a family whose name table carries a non-English family name (e.g.
"MS Gothic" -> "MS ゴシック", "Microsoft YaHei" -> "微软雅黑") is listed by
that native name, sorted by the shown label, and the search matches either
name. Choosing it still applies the English family, so documents and PSD
output never change with the preference.

- text: FontDb::localized_family_names() reads each family's first face
  (typographic family name, else family name) and a pure localized_name()
  picks the first non-English record.
- ui-egui: sync_font_names() publishes the labels to the egui context once a
  frame (cached per font generation); family_picker_in shows them.
- prefs: the row is no longer hidden; scorecard: dead settings 43 -> 42.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
David Mann
2026-10-11 09:21:55 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 7c96e9e120
commit 1234ba4c6f
8 changed files with 236 additions and 13 deletions
-1
View File
@@ -1008,7 +1008,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"plugIns.allowScriptsToConnect",
"plugIns.generatorEnabled",
"type.missingGlyphProtection",
"type.showFontNamesInEnglish",
"type.textEngine",
"type.recentFonts",
"enhancedControls.scrubbySliderAcceleration",
+40
View File
@@ -148,3 +148,43 @@ fn installed_yoon_numeric_faces() {
}
}
}
/// A copy of `source` whose name table spells the family in English and in Japanese.
fn localized_font(source: &[u8], english: &str, japanese: &str) -> Vec<u8> {
let mut bytes = source.to_vec();
let count = u16::from_be_bytes(bytes[4..6].try_into().unwrap()) as usize;
let name_record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..r + 4] == b"name").unwrap();
let ps = english.replace(' ', "");
let names = [(0x409u16, 1u16, english), (0x409, 2, "Regular"), (0x409, 6, ps.as_str()), (0x409, 16, english), (0x411, 1, japanese), (0x411, 16, japanese)];
let mut table = Vec::new();
let mut strings = Vec::new();
for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] {
table.extend(value.to_be_bytes());
}
for (lang, id, value) in names {
let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
for n in [3, 1, lang, id, encoded.len() as u16, strings.len() as u16] {
table.extend(n.to_be_bytes());
}
strings.extend(encoded);
}
table.extend(strings);
let offset = bytes.len() as u32;
bytes[name_record + 8..name_record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[name_record + 12..name_record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
bytes.extend(table);
bytes
}
/// Show Font Names in English off: a font that names its family in Japanese is listed by that
/// name, keyed by the English family the menus and documents keep; English-only fonts are not.
#[test]
fn localized_family_names_map_english_families_to_native_names() {
let mut db = FontDb::new();
assert_eq!(db.register_font_data(localized_font(&INTER_REGULAR, "Gothic Test", "ゴシック テスト")), ["Gothic Test"]);
assert!(db.families().iter().any(|f| f == "Gothic Test"));
let local = db.localized_family_names();
assert_eq!(local.get("Gothic Test").map(String::as_str), Some("ゴシック テスト"));
assert!(!local.contains_key(DEFAULT_FAMILY));
assert!(local.values().all(|v| !v.is_empty()));
}
+65
View File
@@ -263,6 +263,37 @@ impl FontDb {
v
}
/// Each listed family's localized (non-English) name, for font menus with Preferences ▸ Type ▸
/// Show Font Names in English off (Photoshop then lists e.g. "MS Gothic" as "MS ゴシック").
/// Families without one are absent. Read from the same name ID the family name comes from
/// (typographic family, else family), so the pair always names the same family. Documents keep
/// the English family: this is a display label only.
pub fn localized_family_names(&mut self) -> HashMap<String, String> {
let mut out = HashMap::new();
for family in self.families() {
let Some(info) = self.fcx.collection.family_by_name(&family) else {
continue;
};
let Some(face) = info.fonts().first() else {
continue;
};
let Some(blob) = face.load(Some(&mut self.fcx.source_cache)) else {
continue;
};
let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), face.index()) else {
continue;
};
let names =
|id| -> Vec<(String, Option<String>)> { font.localized_strings(id).map(|s| (s.chars().collect(), s.language().map(str::to_string))).collect() };
let typographic = names(StringId::TYPOGRAPHIC_FAMILY_NAME);
let names = if typographic.is_empty() { names(StringId::FAMILY_NAME) } else { typographic };
if let Some(local) = localized_name(&family, &names) {
out.insert(family, local);
}
}
out
}
pub fn has_family(&mut self, name: &str) -> bool {
self.fcx.collection.family_id(name).is_some()
}
@@ -552,6 +583,21 @@ pub(crate) fn system_font_dirs() -> Vec<std::path::PathBuf> {
/// A platform-private family (macOS names its UI faces with a leading '.'): never listed or
/// picked by default.
/// The localized family name among a font's `(name, language)` family-name records: the first
/// one in a language other than English that differs from `english` (the name the font menus
/// store). `None` when the font only names itself in English (most fonts).
pub fn localized_name(english: &str, names: &[(String, Option<String>)]) -> Option<String> {
let is_english = |lang: &str| {
let primary = lang.split(['-', '_']).next().unwrap_or(lang);
primary.eq_ignore_ascii_case("en")
};
names
.iter()
.filter(|(name, lang)| lang.as_deref().is_some_and(|l| !l.is_empty() && !is_english(l)) && !name.trim().is_empty() && name != english)
.map(|(name, _)| name.clone())
.next()
}
pub fn is_hidden_family(name: &str) -> bool {
name.starts_with('.')
}
@@ -623,6 +669,25 @@ mod tests {
assert!(db.register_font_data(super::inflate_font(b"\x00garbage")).is_empty());
}
fn names(v: &[(&str, Option<&str>)]) -> Vec<(String, Option<String>)> {
v.iter().map(|(n, l)| (n.to_string(), l.map(str::to_string))).collect()
}
/// Show Font Names in English off: a CJK font's native family name is its menu label.
#[test]
fn localized_name_picks_the_first_non_english_family_name() {
let ms_gothic = names(&[("MS Gothic", Some("en-US")), ("MS ゴシック", Some("ja-JP"))]);
assert_eq!(super::localized_name("MS Gothic", &ms_gothic).as_deref(), Some("MS ゴシック"));
let yahei = names(&[("Microsoft YaHei", Some("en")), ("微软雅黑", Some("zh-CN")), ("微軟雅黑", Some("zh-TW"))]);
assert_eq!(super::localized_name("Microsoft YaHei", &yahei).as_deref(), Some("微软雅黑"));
// English-only fonts, other English locales, unknown languages and copies of the English
// name give no localized name.
assert_eq!(super::localized_name("Arial", &names(&[("Arial", Some("en-US"))])), None);
assert_eq!(super::localized_name("Arial", &names(&[("Arial", Some("en-GB")), ("Arial", Some("de-DE"))])), None);
assert_eq!(super::localized_name("X", &names(&[("Y", None), (" ", Some("ja-JP"))])), None);
assert_eq!(super::localized_name("X", &[]), None);
}
#[test]
fn missing_font_dirs_are_skipped() {
let mut files = Vec::new();
+1
View File
@@ -1486,6 +1486,7 @@ impl eframe::App for PhotocraftApp {
monitor_status::poll(self, ctx);
// Control requests and persisted preferences can change the language in this frame.
i18n::sync_context(ctx, &self.session.prefs().interface.language);
type_tool::sync_font_names(ctx, self.session.prefs().type_.show_font_names_in_english);
// A window bigger than its display (1440 × 900 on 1366 × 768) runs under the taskbar:
// maximize it into the work area once (#315).
work_area::fit_window(ctx);
+1
View File
@@ -2642,6 +2642,7 @@ mod tests {
assert!(!prefs::is_hidden("type.fillNewTypeLayersWithPlaceholder"));
assert!(!prefs::is_hidden("type.useEscToCommit"));
assert!(!prefs::is_hidden("type.smartQuotes"));
assert!(!prefs::is_hidden("type.showFontNamesInEnglish"));
// Rotate View with Trackpad and the pinch zoom are live; the other Enhanced Controls rows
// stay hidden.
assert!(has_visible_fields(&values, "enhancedControls"));
@@ -401,3 +401,65 @@ fn the_preview_size_preference_resizes_or_removes_the_samples() {
let off = h.get_by_label("Family 00").rect().height();
assert!((off - 28.0).abs() < 0.5, "off keeps the plain row height: {off}");
}
/// Preferences ▸ Type ▸ Show Font Names in English off: the menu lists a family by its localized
/// name, finds it by either name, and picking it still applies the English family.
#[test]
fn localized_font_names_label_the_menu_but_pick_the_family() {
use std::collections::HashMap;
use std::sync::Arc;
let state = State {
current: "Arial".into(),
families: vec!["Arial".into(), "MS Gothic".into(), "Zeta Sans".into()],
popup: egui::Id::NULL,
preview: FontPreview::Medium,
};
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|ui, s: &mut State| {
s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup");
let fams = s.families.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
},
state,
);
// Show Font Names in English on: no localized labels.
crate::type_tool::sync_font_names(&h.ctx, true);
let labels: HashMap<String, String> = [("MS Gothic".to_string(), "MS ゴシック".to_string())].into();
h.ctx.data_mut(|d| d.insert_temp(super::font_labels_id(), Arc::new(labels.clone())));
h.run();
open(&mut h);
assert!(h.query_by_label("MS ゴシック").is_some(), "the localized name labels the family");
assert!(h.query_by_label("MS Gothic").is_none());
assert!(h.query_by_label("Arial").is_some(), "fonts without a localized name keep theirs");
h.get_by_role(Role::TextInput).type_text("gothic");
h.run();
assert!(h.query_by_label("MS ゴシック").is_some(), "the English name still finds it");
assert!(h.query_by_label("Arial").is_none());
h.get_by_label("MS ゴシック").click();
h.run();
assert_eq!(h.state().current, "MS Gothic", "the document keeps the English family");
// Turning the preference back on drops the labels.
crate::type_tool::sync_font_names(&h.ctx, true);
h.run();
open(&mut h);
assert!(h.query_by_label("MS Gothic").is_some());
assert!(h.query_by_label("MS ゴシック").is_none());
}
#[test]
fn font_menu_rows_search_both_names_and_sort_by_label() {
use std::collections::HashMap;
let families: Vec<String> = ["Arial", "Microsoft YaHei", "MS Gothic"].iter().map(|s| s.to_string()).collect();
let labels: HashMap<String, String> =
[("MS Gothic", "MS ゴシック"), ("Microsoft YaHei", "微软雅黑")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let rows = |labels: Option<&HashMap<String, String>>, q: &str| -> Vec<(String, String)> {
super::font_menu_rows(&families, labels, q).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect()
};
let english = rows(None, "");
assert!(english.iter().all(|(f, l)| f == l));
let local = rows(Some(&labels), "");
assert_eq!(local.iter().map(|(_, l)| l.as_str()).collect::<Vec<_>>(), ["Arial", "微软雅黑", "MS ゴシック"]);
assert_eq!(rows(Some(&labels), "yahei"), [("Microsoft YaHei".to_string(), "微软雅黑".to_string())]);
assert_eq!(rows(Some(&labels), "雅黑"), [("Microsoft YaHei".to_string(), "微软雅黑".to_string())]);
assert!(rows(Some(&labels), "nothing").is_empty());
}
+64 -9
View File
@@ -4,6 +4,7 @@
//! typing session is one "Edit Type" history step and automation sees exactly what the user does.
//! Offsets in [`TextEdit`] are character indices (the engine's unit); the layout works in bytes.
use std::collections::HashMap;
use std::sync::{Arc, PoisonError};
use egui::{Color32, Pos2, Stroke};
@@ -883,6 +884,57 @@ pub fn families() -> Arc<Vec<String>> {
v
}
/// Where [`sync_font_names`] leaves the font menus' localized labels for this frame.
fn font_labels_id() -> egui::Id {
egui::Id::new("photocraft-font-menu-localized-names")
}
/// Preferences ▸ Type ▸ Show Font Names in English: when off, font menus list a family that has a
/// localized (native-language) name by that name, as Photoshop does ("MS Gothic" reads
/// "MS ゴシック"). Called once a frame; the menus read the labels from the context.
pub fn sync_font_names(ctx: &egui::Context, show_in_english: bool) {
let labels = (!show_in_english).then(localized_families);
ctx.data_mut(|d| {
if let Some(labels) = labels {
d.insert_temp(font_labels_id(), labels);
} else {
d.remove::<Arc<HashMap<String, String>>>(font_labels_id());
}
});
}
/// The fonts' localized family names, keyed by family. Cached until fonts are registered.
fn localized_families() -> Arc<HashMap<String, String>> {
type Cached = Option<(u64, Arc<HashMap<String, String>>)>;
static LOCALIZED: std::sync::Mutex<Cached> = std::sync::Mutex::new(None);
let key = photocraft_text::fonts::generation();
let mut cached = LOCALIZED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((k, v)) = cached.as_ref()
&& *k == key
{
return v.clone();
}
let v = Arc::new(photocraft_text::shared().lock().map(|mut e| e.fonts.localized_family_names()).unwrap_or_default());
*cached = Some((key, v.clone()));
v
}
/// The font menu's rows: each family with the label it shows (its localized name when `labels`
/// has one, else the family), matching `query` (lowercase) by either name. With localized labels
/// the rows sort by label, so the menu stays alphabetical as shown.
fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String, String>>, query: &str) -> Vec<(&'a String, &'a str)> {
let label = |f: &'a String| labels.and_then(|l| l.get(f)).map_or(f.as_str(), String::as_str);
let mut rows: Vec<(&String, &str)> = families
.iter()
.map(|f| (f, label(f)))
.filter(|(f, l)| query.is_empty() || f.to_lowercase().contains(query) || l.to_lowercase().contains(query))
.collect();
if labels.is_some_and(|l| !l.is_empty()) {
rows.sort_by_cached_key(|(_, l)| l.to_lowercase());
}
rows
}
fn weight_name(w: f32) -> &'static str {
match w.round() as i32 {
..=150 => "Thin",
@@ -979,8 +1031,11 @@ pub(crate) fn family_picker_in(
let search_id = ui.id().with(("font-search", salt));
// The list opens on the press, so a press-drag-release picks a font too (#2735).
let (press, close) = crate::press_menu::PressCombo::before(ui, salt);
let labels: Option<Arc<HashMap<String, String>>> = ui.ctx().data(|d| d.get_temp(font_labels_id()));
let labels = labels.as_deref();
let shown = labels.and_then(|l| l.get(current.as_str())).unwrap_or(current).clone();
let combo = egui::ComboBox::from_id_salt(salt)
.selected_text(current.as_str())
.selected_text(shown)
.width(width)
.height(FONT_MENU_HEIGHT)
.icon(crate::widgets::chevron_icon)
@@ -1013,23 +1068,23 @@ pub(crate) fn family_picker_in(
d.insert_temp(search_id, q.clone());
});
let ql = q.to_lowercase();
let filtered: Vec<&String> = families.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).collect();
let rows = font_menu_rows(families, labels, &ql);
if down || up {
let index = filtered.iter().position(|f| *f == current);
let index = rows.iter().position(|(f, _)| *f == current);
let next = match index {
Some(i) if down => i.saturating_add(1).min(filtered.len().saturating_sub(1)),
Some(i) if down => i.saturating_add(1).min(rows.len().saturating_sub(1)),
Some(i) => i.saturating_sub(1),
None if down => 0,
None => filtered.len().saturating_sub(1),
None => rows.len().saturating_sub(1),
};
if let Some(f) = filtered.get(next) {
if let Some((f, _)) = rows.get(next) {
changed = **f != *current;
current.clone_from(f);
// A served family not fetched yet: start now, as a click does.
photocraft_text::served::request(f);
}
}
let accept = enter && filtered.iter().any(|f| *f == current);
let accept = enter && rows.iter().any(|(f, _)| *f == current);
if accept {
ui.data_mut(|d| d.remove::<String>(search_id));
ui.close();
@@ -1038,8 +1093,8 @@ pub(crate) fn family_picker_in(
let row_h = preview.map_or(28.0, |(_, [_, h])| (h + 4.0).max(28.0));
let mut hovered: Option<String> = None;
for f in filtered {
let mut button = egui::Button::selectable(f == current, f).truncate();
for (f, label) in rows {
let mut button = egui::Button::selectable(f == current, label).truncate();
if let Some((size, [_, h])) = preview {
button =
button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h)));
+3 -3
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 6 | 12 | 9 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 25 | 16 | 2 | |
| [Workspace and UI](#workspace-and-ui) | 8 | 8 | 7 | settings that do nothing: 34 of 153 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 8 | 8 | 7 | settings that do nothing: 33 of 153 (target 0) |
| [Type](#type) | 1 | 4 | 4 | |
| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 34 of 153** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 33 of 153** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|