diff --git a/ROADMAP.md b/ROADMAP.md index 508f711b5..bb0c088f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,7 +66,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. -- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. +- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index cfd7f74c1..68a5cf152 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family]} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; @@ -120,6 +120,9 @@ mod tests { let r = s.execute("prefs.set", &json!({"scaleStrokes": false})).unwrap(); assert_eq!(r["scaleStrokes"], false); assert!(!s.prefs.scale_strokes); + assert_eq!(r["showFontNamesInEnglish"], false, "native CJK font names by default"); + s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap(); + assert!(s.prefs.show_font_names_in_english); assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); s.execute("file.new", &json!({})).unwrap(); let r = s diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1512ce326..6d39b0158 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,6 +171,9 @@ pub struct Prefs { pub rich_black_output: bool, /// Font menu favourites (family names). pub favorite_fonts: Vec, + /// Preferences › Type › Show Font Names in English: the font menus show CJK families by their + /// English names instead of their native ones. Documents store the English name either way. + pub show_font_names_in_english: bool, /// Preferences › Type › Smart Text Reflow: pages follow the primary text frame's story /// (added while it oversets, empty ones at the end removed). pub smart_text_reflow: bool, @@ -224,6 +227,7 @@ impl Default for Prefs { highlight_substituted_fonts: true, rich_black_output: false, favorite_fonts: Vec::new(), + show_font_names_in_english: false, smart_text_reflow: true, autocorrect: false, autocorrect_list: default_autocorrect(), diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 909906b1d..922cf7615 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -12,6 +12,8 @@ use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; +use crate::group::{FamilyInfo, FontGroup}; + /// The family used when a requested family is unknown (the UI sans). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -117,6 +119,8 @@ pub struct FontFace { bmp: std::sync::OnceLock>, /// The ideographic em box, read on first use. em: std::sync::OnceLock<(f64, f64)>, + /// The font menu group and native family name, read on first use. + group: std::sync::OnceLock<(FontGroup, Option)>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -261,6 +265,12 @@ impl FontFace { pub fn em_box(&self) -> (f64, f64) { *self.em.get_or_init(|| crate::vertical::em_box(self)) } + /// The font menu group the font is for and, for a CJK group, its family name in that language + /// (see [`crate::FamilyInfo`]); read from the font once. + pub fn menu_group(&self) -> (FontGroup, Option<&str>) { + let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default()); + (*g, n.as_deref()) + } /// Glyph id for `c` (0 = .notdef). 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) @@ -291,6 +301,19 @@ struct CatalogEntry { family: String, style: String, path: std::path::PathBuf, + /// The font menu group and native family name, read by the scan. + group: FontGroup, + native: Option, +} + +/// What the scan reads of one face in a font file: its names and font menu group. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug, PartialEq)] +struct ScannedFace { + family: String, + style: String, + group: FontGroup, + native: Option, } /// Process-wide font database: the fonts every document shares (bundled, installed, added), and @@ -483,15 +506,19 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { Some((family, style)) } -/// (family, style) of every face in the font file at `path`, reading only its table directories -/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A -/// variable font is cataloged under its default style; its named instances appear once it loads. +/// The names and font menu group of every face in the font file at `path`, reading only its table +/// directories and the tables that name and classify it (`name`, `OS/2`, `meta`; `cmap` only for a +/// font that declares no language or code page): a scan opens hundreds of font files, many of them +/// megabytes long. A variable font is cataloged under its default style; its named instances +/// appear once it loads. #[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &std::path::Path) -> Vec<(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; const MAX_NAME_TABLE: u32 = 1 << 20; + const MAX_SMALL_TABLE: u32 = 64 << 10; + const MAX_CMAP: u32 = 4 << 20; let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; let mut read_at = |offset: u64, len: usize| -> Option> { let mut buf = vec![0; len]; @@ -514,22 +541,31 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { let dir = read_at(start.into(), 12)?; let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; let records = read_at(u64::from(start) + 12, tables * 16)?; - let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?; - let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); - if len > MAX_NAME_TABLE { - return None; - } - let table = read_at(offset.into(), len as usize)?; - // A one-table font holding just the `name` table, to read it as the font itself would. - let mut font = Vec::with_capacity(28 + table.len()); - font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); - font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); - font.extend_from_slice(b"name"); - for v in [0, 28, len] { - font.extend_from_slice(&u32::to_be_bytes(v)); - } - font.extend_from_slice(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + // Table `tag`, unless missing or over `max` bytes. + let mut table = |tag: &[u8; 4], max: u32| -> Option<([u8; 4], Vec)> { + let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > max { + return None; + } + Some((*tag, read_at(offset.into(), len as usize)?)) + }; + // A font holding just these tables, to read them as the font itself would. + let mut found = vec![table(b"name", MAX_NAME_TABLE)?]; + found.extend(table(b"OS/2", MAX_SMALL_TABLE)); + found.extend(table(b"meta", MAX_SMALL_TABLE)); + let font = crate::group::sfnt(found.clone())?; + let f = skrifa::FontRef::new(&font).ok()?; + let (family, style) = face_names(&f)?; + let (group, native) = match crate::group::declared_group(&f) { + Some(g) => (g, crate::group::classify(&f, &family).1), + None => { + found.extend(table(b"cmap", MAX_CMAP)); + let font = crate::group::sfnt(found)?; + crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) + } + }; + Some(ScannedFace { family, style, group, native }) }) .collect() } @@ -663,6 +699,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s index, bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), + group: std::sync::OnceLock::new(), }) } @@ -869,8 +906,8 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - for (family, style) in file_face_names(&p) { - found.push(CatalogEntry { family, style, path: p.clone() }); + for f in file_face_names(&p) { + found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native }); } } } @@ -1150,6 +1187,26 @@ impl ScopedFonts<'_> { v } + /// The available families as the font menus list them (see [`ScopedFonts::families`]), each + /// with its group and native name, unsorted ([`crate::sort_for_menu`] orders them). + pub fn family_infos(&self) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for f in self.own().iter().chain(self.db.read_faces().iter()) { + if seen.insert(f.family.to_lowercase()) { + let (group, native) = f.menu_group(); + out.push(FamilyInfo { family: f.family.clone(), group, native: native.map(str::to_string) }); + } + } + #[cfg(not(target_arch = "wasm32"))] + for c in self.db.read_catalog().iter() { + if seen.insert(c.family.to_lowercase()) { + out.push(FamilyInfo { family: c.family.clone(), group: c.group, native: c.native.clone() }); + } + } + out + } + /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = self @@ -1214,11 +1271,17 @@ impl ScopedFonts<'_> { .collect() } - /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph); see + /// [`FontDb::outline`]. pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { self.db.outline(face, gid) } + /// See [`FontDb::missing_box`]. + pub fn missing_box(&self, face: &FontFace) -> Option { + self.db.missing_box(face) + } + /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. fn instance(&self, family: &str, style: &str) -> Option> { let open = style.find('{')?; diff --git a/crates/fonts/src/group.rs b/crates/fonts/src/group.rs new file mode 100644 index 000000000..039dceada --- /dev/null +++ b/crates/fonts/src/group.rs @@ -0,0 +1,355 @@ +//! Font menu groups: the script or CJK language a font is for and its family name in that +//! language, read from the font's own data. + +use skrifa::raw::TableProvider; + +/// The font menus' groups, in menu order: Western (and other scripts), then Japanese, Simplified +/// Chinese, Traditional Chinese and Korean. The order is InDesign's font menu as recalled, not +/// observed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FontGroup { + #[default] + Western, + Japanese, + ChineseSimplified, + ChineseTraditional, + Korean, +} + +/// A family as the font menus list it. +#[derive(Clone, Debug, PartialEq)] +pub struct FamilyInfo { + /// The family name documents store (the English or only name). + pub family: String, + pub group: FontGroup, + /// A CJK family's name in its group's language, when the font has one that differs. + pub native: Option, +} + +impl FamilyInfo { + /// The name the menus show: the native name unless `english` (Show Font Names in English). + pub fn label(&self, english: bool) -> &str { + match &self.native { + Some(n) if !english => n, + _ => &self.family, + } + } + + /// Does `query` (lowercase) find this family? Either name matches. + pub fn matches(&self, query: &str) -> bool { + query.is_empty() || self.family.to_lowercase().contains(query) || self.native.as_ref().is_some_and(|n| n.to_lowercase().contains(query)) + } +} + +/// Sort families for a font menu: by group, then by the name shown, ignoring case. +pub fn sort_for_menu(families: &mut [FamilyInfo], english: bool) { + families.sort_by_cached_key(|f| (f.group, f.label(english).to_lowercase())); +} + +/// Language IDs naming a group: Windows (platform 3) and Macintosh (platform 1). +fn group_of_language(platform: u16, language: u16) -> Option { + match (platform, language) { + (3, 0x0411) | (1, 11) => Some(FontGroup::Japanese), + (3, 0x0804 | 0x1004) | (1, 33) => Some(FontGroup::ChineseSimplified), + (3, 0x0404 | 0x0C04 | 0x1404) | (1, 19) => Some(FontGroup::ChineseTraditional), + (3, 0x0412) | (1, 23) => Some(FontGroup::Korean), + _ => None, + } +} + +/// The group of a `meta` script and language tag (`Jpan`, `ja`, `zh-Hant`, `ko-Kore`, …). +fn group_of_tag(tag: &str) -> Option { + let tag = tag.trim().to_ascii_lowercase(); + let parts: Vec<&str> = tag.split(['-', '_']).collect(); + let has = |p: &str| parts.contains(&p); + let first = parts.first().copied().unwrap_or(""); + if has("jpan") || has("hira") || has("kana") || first == "ja" { + Some(FontGroup::Japanese) + } else if has("kore") || has("hang") || first == "ko" { + Some(FontGroup::Korean) + } else if has("hant") || (first == "zh" && (has("tw") || has("hk") || has("mo"))) { + Some(FontGroup::ChineseTraditional) + } else if has("hans") || first == "zh" { + Some(FontGroup::ChineseSimplified) + } else { + None + } +} + +/// A font file of `tables` (tag, bytes): the table directory, sorted by tag, then the tables, each +/// 4-byte aligned. `None` if there are too many or they are too large to address. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn sfnt(mut tables: Vec<([u8; 4], Vec)>) -> Option> { + tables.sort_by_key(|t| t.0); + let n = u16::try_from(tables.len()).ok()?; + let pow = 1u16 << (15 - n.max(1).leading_zeros().min(15)); + let mut out = 0x0001_0000_u32.to_be_bytes().to_vec(); + for v in [n, pow.saturating_mul(16), pow.trailing_zeros() as u16, n.saturating_mul(16).saturating_sub(pow.saturating_mul(16))] { + out.extend_from_slice(&v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend_from_slice(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes()); + out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes()); + offset = offset.checked_add(data.len().next_multiple_of(4))?; + } + for (_, data) in &tables { + out.extend_from_slice(data); + out.resize(out.len().next_multiple_of(4), 0); + } + Some(out) +} + +/// Caps on what a (possibly damaged) font can make classification read. +const MAX_TAGS: usize = 64; +const MAX_RECORDS: usize = 512; + +/// The first group in menu order among `found` (the tie order Japanese, Simplified Chinese, +/// Traditional Chinese, Korean). +fn first(found: impl Iterator) -> Option { + found.min() +} + +/// The CJK group the font's `meta` table names: `dlng` (the languages it is designed for), else +/// `slng` (the ones it supports). +fn meta_group(f: &skrifa::FontRef<'_>) -> Option { + use skrifa::raw::tables::meta::{DLNG, Metadata, SLNG}; + let meta = f.meta().ok()?; + let tags = |want| { + let map = meta.data_maps().iter().find(|m| m.tag() == want)?; + let Ok(Metadata::ScriptLangTags(tags)) = map.data(meta.offset_data()) else { return None }; + first(tags.iter().take(MAX_TAGS).filter_map(|t| group_of_tag(t.ok()?.as_str()))) + }; + tags(DLNG).or_else(|| tags(SLNG)) +} + +/// The CJK group of the languages the font's `name` table names its family in (style names +/// don't count: fonts translate those into many languages). +fn name_group(f: &skrifa::FontRef<'_>) -> Option { + let name = f.name().ok()?; + first( + name.name_record() + .iter() + .take(MAX_RECORDS) + .filter(|r| FAMILY_IDS.contains(&r.name_id().to_u16())) + .filter_map(|r| group_of_language(r.platform_id(), r.language_id())), + ) +} + +/// Name IDs of the family name: typographic family, then family. +const FAMILY_IDS: [u16; 2] = [16, 1]; + +/// From the OS/2 code page ranges: `Some(Some(group))` for the CJK code pages (JIS, Chinese +/// Simplified, Korean Wansung, Chinese Traditional, Korean Johab), `Some(None)` when the font +/// lists code pages but no CJK one, `None` when it lists none. +fn code_page_group(f: &skrifa::FontRef<'_>) -> Option> { + let os2 = f.os2().ok()?; + let (r1, r2) = (os2.ul_code_page_range_1()?, os2.ul_code_page_range_2().unwrap_or(0)); + if r1 == 0 && r2 == 0 { + return None; + } + let bits = [ + (17, FontGroup::Japanese), + (18, FontGroup::ChineseSimplified), + (19, FontGroup::Korean), + (20, FontGroup::ChineseTraditional), + (21, FontGroup::Korean), + ]; + Some(first(bits.into_iter().filter(|(b, _)| r1 & (1 << b) != 0).map(|(_, g)| g))) +} + +/// The group the font declares: its `meta` languages, then its `name` table's languages, then its +/// code pages (the strongest signal first). `None` when it declares nothing at all: no CJK +/// language and no code pages. +pub(crate) fn declared_group(f: &skrifa::FontRef<'_>) -> Option { + if let Some(g) = meta_group(f).or_else(|| name_group(f)) { + return Some(g); + } + code_page_group(f).map(|g| g.unwrap_or(FontGroup::Western)) +} + +/// The group by what the font covers: kana is Japanese, Hangul Korean, ideographs alone +/// Simplified Chinese. +pub(crate) fn covered_group(f: &skrifa::FontRef<'_>) -> FontGroup { + use skrifa::MetadataProvider; + let cmap = f.charmap(); + let any = |cs: &[char]| cs.iter().any(|c| cmap.map(*c).is_some()); + if any(&['あ', 'ア', 'の']) { + FontGroup::Japanese + } else if any(&['가', '한', '이']) { + FontGroup::Korean + } else if any(&['一', '中', '人']) { + FontGroup::ChineseSimplified + } else { + FontGroup::Western + } +} + +/// The font's group and, for a CJK group, its family name in that language (typographic family, +/// else family) when it differs from `family`. +pub(crate) fn classify(f: &skrifa::FontRef<'_>, family: &str) -> (FontGroup, Option) { + let group = declared_group(f).unwrap_or_else(|| covered_group(f)); + (group, native_family(f, group).filter(|n| n != family)) +} + +/// The family name (ID 16, else 1) in `group`'s language, from records whose text decodes. +fn native_family(f: &skrifa::FontRef<'_>, group: FontGroup) -> Option { + if group == FontGroup::Western { + return None; + } + let name = f.name().ok()?; + let records = name.name_record(); + FAMILY_IDS.into_iter().find_map(|id| { + records.iter().take(MAX_RECORDS).find_map(|r| { + if r.name_id().to_u16() != id || !r.is_unicode() || group_of_language(r.platform_id(), r.language_id()) != Some(group) { + return None; + } + let s: String = r.string(name.string_data()).ok()?.chars().take(256).collect(); + let s = s.trim().to_string(); + (!s.is_empty()).then_some(s) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{font_with, with_code_pages, with_meta, with_names, with_table_len}; + + const FAMILY: &str = "DC Test Group"; + + fn group_of(font: &[u8]) -> (FontGroup, Option) { + classify(&skrifa::FontRef::new(font).unwrap(), FAMILY) + } + + /// A font with only Latin letters, its code pages `range1` (and none in range 2). + fn latin(range1: u32) -> Vec { + with_code_pages(font_with(FAMILY, &['a', 'b']).unwrap(), range1, 0).unwrap() + } + + /// Windows English family records plus `extra` records. + fn named(font: &[u8], extra: &[(u16, u16, u16, u16, &str)]) -> Vec { + let mut records = vec![(3, 1, 0x409, 1, FAMILY), (3, 1, 0x409, 2, "Regular")]; + records.extend_from_slice(extra); + with_names(font, &records).unwrap() + } + + #[test] + fn code_pages_give_the_group() { + assert_eq!(group_of(&latin(1)).0, FontGroup::Western, "Latin 1 only"); + for (bit, group) in [ + (17, FontGroup::Japanese), + (18, FontGroup::ChineseSimplified), + (19, FontGroup::Korean), + (20, FontGroup::ChineseTraditional), + (21, FontGroup::Korean), + ] { + assert_eq!(group_of(&latin(1 | 1 << bit)).0, group, "bit {bit}"); + } + // Several: Japanese, Simplified Chinese, Traditional Chinese, Korean. + assert_eq!(group_of(&latin(1 << 17 | 1 << 19)).0, FontGroup::Japanese); + assert_eq!(group_of(&latin(1 << 20 | 1 << 21)).0, FontGroup::ChineseTraditional); + } + + #[test] + fn name_languages_give_the_group_and_the_native_name() { + let jp = named(&latin(1), &[(3, 1, 0x0411, 1, "テスト明朝"), (3, 1, 0x0411, 16, "テスト明朝 Pro")]); + assert_eq!(group_of(&jp), (FontGroup::Japanese, Some("テスト明朝 Pro".into())), "the typographic family first"); + for (lang, group) in [ + (0x0804, FontGroup::ChineseSimplified), + (0x1004, FontGroup::ChineseSimplified), + (0x0404, FontGroup::ChineseTraditional), + (0x0C04, FontGroup::ChineseTraditional), + (0x1404, FontGroup::ChineseTraditional), + (0x0412, FontGroup::Korean), + ] { + assert_eq!(group_of(&named(&latin(1), &[(3, 1, lang, 1, "本地名")])), (group, Some("本地名".into())), "{lang:#x}"); + } + // Macintosh records in a CJK encoding: the language counts, the text doesn't decode. + assert_eq!(group_of(&named(&latin(1), &[(1, 1, 11, 1, "x")])), (FontGroup::Japanese, None)); + assert_eq!(group_of(&named(&latin(1), &[(1, 3, 23, 1, "x")])).0, FontGroup::Korean); + // Records in two CJK languages: the menu order decides. + assert_eq!(group_of(&named(&latin(1), &[(3, 1, 0x0412, 1, "한"), (3, 1, 0x0404, 1, "繁")])).0, FontGroup::ChineseTraditional); + // Translated style names don't make a Western font CJK. + assert_eq!(group_of(&named(&latin(1), &[(3, 1, 0x0411, 2, "標準"), (1, 1, 11, 2, "x")])).0, FontGroup::Western); + // A native name equal to the English one isn't shown twice. + assert_eq!(group_of(&named(&latin(1), &[(3, 1, 0x0411, 1, FAMILY)])), (FontGroup::Japanese, None)); + } + + #[test] + fn meta_beats_names_which_beat_code_pages() { + let all = named(&latin(1 << 17), &[(3, 1, 0x0412, 1, "한글")]); + assert_eq!(group_of(&all).0, FontGroup::Korean, "the name table over the code pages"); + assert_eq!(group_of(&with_meta(&all, Some("zh-Hant"), None).unwrap()).0, FontGroup::ChineseTraditional, "dlng first"); + assert_eq!(group_of(&with_meta(&all, None, Some("Hans")).unwrap()).0, FontGroup::ChineseSimplified, "slng without dlng"); + assert_eq!(group_of(&with_meta(&all, Some("Kore, Jpan"), Some("Hans")).unwrap()).0, FontGroup::Japanese, "ties in menu order"); + // A meta table naming no CJK language leaves it to the rest. + assert_eq!(group_of(&with_meta(&all, Some("Latn"), None).unwrap()).0, FontGroup::Korean); + for (tag, group) in [ + ("ja", FontGroup::Japanese), + ("ja-Jpan", FontGroup::Japanese), + ("zh", FontGroup::ChineseSimplified), + ("zh-Hans", FontGroup::ChineseSimplified), + ("zh-TW", FontGroup::ChineseTraditional), + ("zh-HK", FontGroup::ChineseTraditional), + ("ko", FontGroup::Korean), + ("Hang", FontGroup::Korean), + ] { + assert_eq!(group_of_tag(tag), Some(group), "{tag}"); + } + assert_eq!(group_of_tag("en-Latn"), None); + } + + #[test] + fn coverage_decides_when_the_font_declares_nothing() { + for (chars, group) in [ + (&['a', 'あ', '一'][..], FontGroup::Japanese), + (&['a', '한', '一'][..], FontGroup::Korean), + (&['a', '一'][..], FontGroup::ChineseSimplified), + (&['a'][..], FontGroup::Western), + ] { + let font = with_code_pages(font_with(FAMILY, chars).unwrap(), 0, 0).unwrap(); + assert_eq!(group_of(&font).0, group, "{chars:?}"); + } + // Code pages without a CJK one: Western, whatever the font covers. + assert_eq!(group_of(&with_code_pages(font_with(FAMILY, &['一']).unwrap(), 1, 0).unwrap()).0, FontGroup::Western); + } + + #[test] + fn damaged_tables_classify_without_panicking() { + let base = with_meta(&named(&latin(1 << 18), &[(3, 1, 0x0411, 1, "名前")]), Some("Jpan"), None).unwrap(); + for tag in [b"name", b"OS/2", b"meta", b"cmap"] { + for len in [0, 3, 7, 13, 21, 80] { + let font = with_table_len(base.clone(), tag, len).unwrap(); + if let Ok(f) = skrifa::FontRef::new(&font) { + let _ = classify(&f, FAMILY); + } + } + } + } + + #[test] + fn menus_list_western_fonts_then_each_cjk_language() { + let info = |family: &str, group, native: Option<&str>| FamilyInfo { family: family.into(), group, native: native.map(Into::into) }; + let mut fams = vec![ + info("Yu Mincho", FontGroup::Japanese, Some("游明朝")), + info("Apple SD Gothic Neo", FontGroup::Korean, Some("애플 SD 산돌고딕 Neo")), + info("Songti SC", FontGroup::ChineseSimplified, Some("宋体-简")), + info("zapfino", FontGroup::Western, None), + info("Hiragino Mincho ProN", FontGroup::Japanese, Some("ヒラギノ明朝 ProN")), + info("PMingLiU", FontGroup::ChineseTraditional, Some("新細明體")), + info("Arial", FontGroup::Western, None), + ]; + sort_for_menu(&mut fams, true); + let order: Vec<&str> = fams.iter().map(|f| f.label(true)).collect(); + assert_eq!(order, ["Arial", "zapfino", "Hiragino Mincho ProN", "Yu Mincho", "Songti SC", "PMingLiU", "Apple SD Gothic Neo"]); + sort_for_menu(&mut fams, false); + let order: Vec<&str> = fams.iter().map(|f| f.label(false)).collect(); + assert_eq!(order, ["Arial", "zapfino", "ヒラギノ明朝 ProN", "游明朝", "宋体-简", "新細明體", "애플 SD 산돌고딕 Neo"]); + // Search finds either name. + let yu = fams.iter().find(|f| f.family == "Yu Mincho").unwrap(); + assert!(yu.matches("yu min") && yu.matches("游") && yu.matches("") && !yu.matches("songti")); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index f8cb9e42f..8f901e96d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod fontdb; +mod group; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical; @@ -16,6 +17,7 @@ pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs, }; +pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index 07361c102..e03ad1b6f 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -210,3 +210,54 @@ pub fn with_empty_notdef(mut font: Vec) -> Option> { font.get_mut(loca..loca + width)?.copy_from_slice(&end); Some(font) } + +/// `font` with its `name` table replaced by `records`: (platform, encoding, language, name id, +/// text). Text is written as UTF-16BE on the Unicode and Windows platforms, as its bytes on the +/// Macintosh platform. `None` if `font` can't be read. +pub fn with_names(font: &[u8], records: &[(u16, u16, u16, u16, &str)]) -> Option> { + let strings: Vec> = + records.iter().map(|r| if r.0 == 1 { r.4.as_bytes().to_vec() } else { r.4.encode_utf16().flat_map(u16::to_be_bytes).collect() }).collect(); + let count = u16::try_from(records.len()).ok()?; + let mut name = Vec::new(); + for v in [0u16, count, 6 + 12 * count] { + name.extend_from_slice(&v.to_be_bytes()); + } + let mut at = 0usize; + for (r, s) in records.iter().zip(&strings) { + for v in [r.0, r.1, r.2, r.3, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] { + name.extend_from_slice(&v.to_be_bytes()); + } + at += s.len(); + } + strings.iter().for_each(|s| name.extend_from_slice(s)); + with_tables(font, vec![(*b"name", name)]) +} + +/// `font` with its OS/2 `ulCodePageRange1` and `ulCodePageRange2` set (the font's OS/2 table must +/// be version 1 or later). `None` if it has none. +pub fn with_code_pages(font: Vec, range1: u32, range2: u32) -> Option> { + let font = with_table_u16(font, b"OS/2", 78, (range1 >> 16) as u16)?; + let font = with_table_u16(font, b"OS/2", 80, range1 as u16)?; + let font = with_table_u16(font, b"OS/2", 82, (range2 >> 16) as u16)?; + with_table_u16(font, b"OS/2", 84, range2 as u16) +} + +/// `font` with a `meta` table holding `dlng` and `slng` script and language tag lists (as written, +/// comma-separated), each when given. `None` if `font` can't be read. +pub fn with_meta(font: &[u8], dlng: Option<&str>, slng: Option<&str>) -> Option> { + let maps: Vec<(&[u8; 4], &str)> = [(b"dlng", dlng), (b"slng", slng)].into_iter().filter_map(|(t, v)| Some((t, v?))).collect(); + let count = u32::try_from(maps.len()).ok()?; + let mut meta = Vec::new(); + for v in [1u32, 0, 0, count] { + meta.extend_from_slice(&v.to_be_bytes()); + } + let mut at = 16 + 12 * maps.len(); + for (tag, v) in &maps { + meta.extend_from_slice(*tag); + meta.extend_from_slice(&u32::try_from(at).ok()?.to_be_bytes()); + meta.extend_from_slice(&u32::try_from(v.len()).ok()?.to_be_bytes()); + at += v.len(); + } + maps.iter().for_each(|(_, v)| meta.extend_from_slice(v.as_bytes())); + with_tables(font, vec![(*b"meta", meta)]) +} diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 2c8a7ae37..a9cda7cdc 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -129,8 +129,38 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() { #[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())]); + let names: Vec<(String, String)> = file_face_names(&dir.join("Sysfont-Regular.ttf")).into_iter().map(|f| (f.family, f.style)).collect(); + assert_eq!(names, [(FAMILY.to_string(), "Regular".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()); } + +#[test] +fn font_menus_group_installed_and_loaded_fonts_without_loading_them() { + use crate::testing::{font_with, with_code_pages, with_names}; + use crate::{FamilyInfo, FontGroup}; + let dir = std::env::temp_dir().join(format!("dc-sysfonts-groups-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let names = |family: &str, lang: u16, native: &str| { + let font = font_with(family, &['a']).unwrap(); + with_names(&font, &[(3, 1, 0x409, 1, family), (3, 1, 0x409, 2, "Regular"), (3, 1, lang, 1, native)]).unwrap() + }; + std::fs::write(dir.join("jp.ttf"), names("Groups Mincho", 0x0411, "グループ明朝")).unwrap(); + // Declares nothing (no code pages): its kana make it Japanese, read from the cmap. + std::fs::write(dir.join("kana.otf"), with_code_pages(font_with("Groups Kana", &['a', 'あ']).unwrap(), 0, 0).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + db.add_font(with_code_pages(font_with("Groups Ming", &['a']).unwrap(), 1 << 20, 0).unwrap()); + let infos = db.scoped(0).family_infos(); + let find = |f: &str| infos.iter().find(|i| i.family == f).cloned().unwrap_or_else(|| panic!("{f} in {infos:?}")); + assert_eq!(find("Groups Mincho"), FamilyInfo { family: "Groups Mincho".into(), group: FontGroup::Japanese, native: Some("グループ明朝".into()) }); + assert_eq!(find("Groups Kana").group, FontGroup::Japanese); + assert_eq!(find("Groups Ming").group, FontGroup::ChineseTraditional); + assert_eq!(find(crate::DEFAULT_FAMILY).group, FontGroup::Western); + assert!(!db.is_loaded("Groups Mincho"), "the scan read its tables, not the font"); + // Families are listed once. + assert_eq!(infos.iter().filter(|i| i.family == "Groups Mincho").count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index a28ac9b0a..1bf98426a 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -60,9 +60,11 @@ pub fn text_line(db: &ScopedFonts<'_>, family: &str, style: &str, text: &str, he let (asc, desc) = face.vertical_metrics(); let baseline = h as f64 / 2.0 + (asc - desc.abs()) / 2.0 * k; let mut x = 1.0; + // A preview shows what the font draws: not the box DesignCraft draws for a missing glyph. + let boxed = glyphs.iter().any(|g| g.gid == 0) && db.missing_box(&face).is_some(); for g in &glyphs { let outline = db.outline(&face, g.gid); - if !outline.elements().is_empty() { + if !outline.elements().is_empty() && !(boxed && g.gid == 0) { ctx.set_transform( kurbo::Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * kurbo::Affine::scale_non_uniform(k, -k), ); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a322157c7..36767afbf 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,3 +29,4 @@ log = { workspace = true } [dev-dependencies] # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1449110f0..cd292b1ca 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -518,6 +518,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { "type" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Type Options")).font(semibold(12.0))); check(ui, d, "typographersQuotes", crate::i18n::tr(&app.ui.language, "Use Typographer's Quotes")); + check(ui, d, "showFontNamesInEnglish", crate::i18n::tr(&app.ui.language, "Show Font Names in English")); ui.add_space(6.0); ui.label(crate::rtl::widget( ui, @@ -1513,7 +1514,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "preferences" => { app.run( "prefs.set", - json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "smartTextReflow": d.b("smartTextReflow"), + json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "showFontNamesInEnglish": d.b("showFontNamesInEnglish"), "smartTextReflow": d.b("smartTextReflow"), "autocorrect": d.b("autocorrect"), "showAddedText": d.b("showAddedText"), "showNoteAnchors": d.b("showNoteAnchors"), "recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5), "autocorrectList": d.s("autocorrectText").lines().filter_map(|l| { @@ -1970,14 +1971,13 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } "chars" => { let fonts = crate::panels::fonts(app); + let menu = crate::panels::font_menu(app); egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt("psfam").selected_text(&fam).width(200.0).show_ui(ui, |ui| { - for f in fonts.families() { - if ui.selectable_label(f == fam, &f).clicked() { - d.fields.insert("c.fontFamily".into(), json!(f)); - } + egui::ComboBox::from_id_salt("psfam").selected_text(crate::panels::font_label(app, &menu, &fam)).width(200.0).show_ui(ui, |ui| { + if let Some(f) = crate::panels::font_menu_rows(app, ui, &menu, &fam) { + d.fields.insert("c.fontFamily".into(), json!(f)); } }); ui.end_row(); @@ -2625,8 +2625,9 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace With")).font(semibold(12.0))); let db = crate::panels::fonts(app); - let families = db.families(); - let fam_opts: Vec<(&str, &str)> = families.iter().map(|f| (f.as_str(), f.as_str())).collect(); + let menu = crate::panels::font_menu(app); + let english = app.session.prefs.show_font_names_in_english; + let fam_opts: Vec<(&str, &str)> = menu.iter().map(|f| (f.family.as_str(), f.label(english))).collect(); egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); combo(ui, d, "toFamily", &fam_opts); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c3c5352de..43979eec7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2559,6 +2559,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Interface", ["Interface", "Interface", "Interface", "Interface", "界面"]), ("Advanced Type", ["Advanced Type", "Advanced Type", "Advanced Type", "Advanced Type", "高级文字"]), ("Composition", ["Composition", "Composition", "Composition", "Composition", "排版"]), + ( + "Show Font Names in English", + [ + "Schriftnamen auf Englisch anzeigen", + "Afficher les noms de police en anglais", + "Mostrar nombres de fuentes en inglés", + "フォント名を英語で表示", + "以英文显示字体名称", + ], + ), ("Missing Glyphs", ["Fehlende Glyphen", "Glyphes manquants", "Glifos que faltan", "欠落グリフ", "缺失字形"]), ( "Draw Missing Glyphs from Fallback Fonts", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 9f8f4e3d1..34375752a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -971,6 +971,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Interface", "الواجهة"), ("Advanced Type", "إعدادات النص المتقدمة"), ("Composition", "تنضيد النص"), + ("Show Font Names in English", "إظهار أسماء الخطوط بالإنجليزية"), ("Missing Glyphs", "الحروف الناقصة"), ("Draw Missing Glyphs from Fallback Fonts", "رسم الحروف الناقصة بخطوط بديلة"), ("Units & Increments", "الوحدات والزيادات"), diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 3755e187c..d0fa75efc 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -33,12 +33,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } let (db, scope) = (super::fonts(app), super::font_scope(app)); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("glyph_family").selected_text(&st.family).width(140.0).show_ui(ui, |ui| { - for f in db.families() { - if ui.selectable_label(f == st.family, &f).clicked() { - st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into()); - st.family = f; - } + let menu = super::font_menu(app); + egui::ComboBox::from_id_salt("glyph_family").selected_text(super::font_label(app, &menu, &st.family)).width(140.0).show_ui(ui, |ui| { + if let Some(f) = super::font_menu_rows(app, ui, &menu, &st.family) { + st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into()); + st.family = f; } }); egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b9bffd839..ef19590a2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -108,6 +108,44 @@ pub fn fonts(app: &DesignApp) -> designcraft_fonts::ScopedFonts<'static> { designcraft_fonts::FontDb::global().scoped(font_scope(app)) } +/// The font menus' families in menu order: Western (and other scripts) first, then Japanese, +/// Simplified Chinese, Traditional Chinese and Korean, each alphabetical by the name shown (CJK +/// families by their native names unless Show Font Names in English). +pub fn font_menu(app: &DesignApp) -> Vec { + let mut v = fonts(app).family_infos(); + designcraft_fonts::sort_for_menu(&mut v, app.session.prefs.show_font_names_in_english); + v +} + +/// The name the font menus show for `family` (its native name, see [`font_menu`]). +pub fn font_label(app: &DesignApp, menu: &[designcraft_fonts::FamilyInfo], family: &str) -> String { + let english = app.session.prefs.show_font_names_in_english; + menu.iter().find(|f| f.family == family).map_or(family, |f| f.label(english)).to_string() +} + +/// `menu` as rows of a popup or combo box: the name shown (the English name on hover when they +/// differ), a separator between groups. Returns the family clicked. +pub fn font_menu_rows(app: &DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { + let english = app.session.prefs.show_font_names_in_english; + let mut pick = None; + let mut group = None; + for f in menu { + if group.is_some_and(|g| g != f.group) { + ui.separator(); + } + group = Some(f.group); + let label = f.label(english); + let mut resp = ui.selectable_label(f.family == current, label); + if label != f.family { + resp = resp.on_hover_text(&f.family); + } + if resp.clicked() { + pick = Some(f.family.clone()); + } + } + pick +} + /// The active document's font scope (0 without a document or fonts of its own). pub fn font_scope(app: &DesignApp) -> u32 { app.session.active().map_or(0, |d| d.doc.font_scope) @@ -201,16 +239,22 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egu /// that family. pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) { let (fonts, scope) = (fonts(app), font_scope(app)); - let fams = fonts.families(); + let menu = font_menu(app); + let english = app.session.prefs.show_font_names_in_english; + let shown_current = font_label(app, &menu, current); let mut favs = app.session.prefs.favorite_fonts.clone(); let mut pick = None; let mut favs_changed = false; let state_id = egui::Id::new("font_menu_state"); let (mut query, mut only_favs): (String, bool) = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); let t = crate::theme::Tokens::get(ui.ctx()); - egui::ComboBox::from_id_salt("font_family").selected_text(if current.is_empty() { "—" } else { current }).width(width).height(420.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt("font_family") + .selected_text(if current.is_empty() { "—" } else { &shown_current }) + .width(width) + .height(420.0) + // The search field, Favorites and the stars keep the menu open; picking a font closes it. + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { ui.set_min_width(300.0); ui.horizontal(|ui| { ui.add( @@ -224,13 +268,24 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, }); let q = query.to_lowercase(); let ppp = ui.ctx().pixels_per_point(); - let shown: Vec<&String> = - fams.iter().filter(|f| (q.is_empty() || f.to_lowercase().contains(&q)) && (!only_favs || favs.contains(f))).collect(); - for f in shown { - let (row, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click()); + let shown: Vec<&designcraft_fonts::FamilyInfo> = + menu.iter().filter(|f| f.matches(&q) && (!only_favs || favs.contains(&f.family))).collect(); + let mut group = None; + for info in shown { + // A separator between the Western fonts and each CJK language's. + if group.is_some_and(|g| g != info.group) { + ui.separator(); + } + group = Some(info.group); + let f = &info.family; + let label = info.label(english); + let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click()); if !ui.is_rect_visible(row) { continue; } + if label != f { + resp = resp.on_hover_text(f); + } if f == current { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -249,7 +304,7 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, } favs_changed = true; } - ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, f, egui::FontId::proportional(12.0), t.text); + ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text); // The name in its own face (rendered once per family and scale). let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array())); let tex: Option = ui.data(|d| d.get_temp(key)); @@ -270,10 +325,10 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, ); if resp.clicked() { pick = Some(f.clone()); + ui.close(); } } - }, - ); + }); ui.data_mut(|d| d.insert_temp(state_id, (query, only_favs))); if favs_changed { let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); @@ -316,3 +371,29 @@ pub fn para_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": false})); } } + +#[cfg(test)] +mod font_menu_tests { + use designcraft_fonts::FontGroup; + use designcraft_fonts::testing::{font_with, with_names}; + + use super::*; + + #[test] + fn cjk_fonts_follow_the_western_ones_under_their_native_names() { + const FAMILY: &str = "DC UI Test Mincho"; + let font = font_with(FAMILY, &['a']).unwrap(); + let font = with_names(&font, &[(3, 1, 0x409, 1, FAMILY), (3, 1, 0x409, 2, "Regular"), (3, 1, 0x0411, 1, "UIテスト明朝")]).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let menu = font_menu(&app); + let at = menu.iter().position(|f| f.family == FAMILY).unwrap(); + assert_eq!(menu[at].group, FontGroup::Japanese); + assert!(menu[..at].iter().any(|f| f.family == designcraft_fonts::DEFAULT_FAMILY), "Western fonts first"); + assert!(menu.windows(2).all(|w| w[0].group <= w[1].group), "grouped"); + assert_eq!(font_label(&app, &menu, FAMILY), "UIテスト明朝"); + assert_eq!(font_label(&app, &menu, designcraft_fonts::DEFAULT_FAMILY), designcraft_fonts::DEFAULT_FAMILY); + app.run("prefs.set", json!({"showFontNamesInEnglish": true})).unwrap(); + assert_eq!(font_label(&app, &font_menu(&app), FAMILY), FAMILY); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 648d7a9bc..8644fe796 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -987,7 +987,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { let fw = full_width(ui); // Family dropdown with a search segment on the left. let fonts = super::fonts(app); - let fams = fonts.families(); + let menu = super::font_menu(app); let resp = widgets::dropdown(ui, "", fw); let r = resp.rect; icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(3.0, 3.0), vec2(14.0, 14.0)), "search", t.icon); @@ -995,7 +995,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text( r.min + vec2(30.0, 10.5), egui::Align2::LEFT_CENTER, - if fam.is_empty() { "—" } else { &fam }, + if fam.is_empty() { "—".to_string() } else { super::font_label(app, &menu, &fam) }, egui::FontId::proportional(11.5), t.text, ); @@ -1003,11 +1003,9 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(fw); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for f in &fams { - if ui.selectable_label(*f == fam, f).clicked() { - pick_fam = Some(f.clone()); - ui.close(); - } + if let Some(f) = super::font_menu_rows(app, ui, &menu, &fam) { + pick_fam = Some(f); + ui.close(); } }); }); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index caafdc1d7..47b05ff92 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -108,10 +108,13 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let fam = c["fontFamily"].as_str().unwrap_or("").to_string(); let sty = c["fontStyle"].as_str().unwrap_or("").to_string(); let fonts = panels::fonts(app); - let fams = fonts.families(); - let cur = fams.iter().position(|f| *f == fam); - if let Some(k) = widgets::dropdown_list(ui, if fam.is_empty() { "—" } else { &fam }, 150.0, &fams, cur) { - let f = fams[k].clone(); + let menu = panels::font_menu(app); + let english = app.session.prefs.show_font_names_in_english; + let labels: Vec = menu.iter().map(|f| f.label(english).to_string()).collect(); + let cur = menu.iter().position(|f| f.family == fam); + let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) }; + if let Some(f) = widgets::dropdown_list(ui, &shown, 150.0, &labels, cur).and_then(|k| menu.get(k)) { + let f = f.family.clone(); let styles = fonts.styles(&f); let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); diff --git a/docs/cjk.md b/docs/cjk.md index 2ca3de2e9..05e50845e 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -44,6 +44,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | | Languages | Japanese, Korean, Simplified and Traditional Chinese reach the shaper (`locl`), font fallback, line breaking and typographer's quotes; IDML language names are kept as written and recognised in their common spellings and locale codes ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`, `ja_JP`, `ko-KR`) | | Missing glyphs | as in InDesign: characters the applied font lacks are drawn as its missing-glyph box (screen and PDF) and listed by Preflight; the document setting Draw Missing Glyphs from Fallback Fonts (Preferences › Composition, off in new documents and IDML imports, on in documents saved before it existed) draws them from fallback fonts instead | +| Font menus | Western families first, then Japanese, Simplified Chinese, Traditional Chinese and Korean groups (from the font's `meta` languages, CJK family names, OS/2 code pages, else its coverage); CJK families under their native names unless Preferences › Type › Show Font Names in English; the order is recalled from InDesign, not observed | | CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input, in the font menus for users to apply; fallback by language (with fallback fonts on) (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies every font file that draws text, fallback fonts included (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web | | Everything else below | missing |