UI: the font menu groups fonts by script, CJK fonts by language

Font menus list Western (and other-script) families first, then
Japanese, Simplified Chinese, Traditional Chinese and Korean families,
each group alphabetical by the name shown, with a separator between
groups. The group comes from the font's own data, strongest signal
first: the meta table's dlng (else slng) languages, family names in a
CJK language (Windows and Macintosh language IDs), OS/2 code pages (JIS,
Chinese Simplified, Korean Wansung, Chinese Traditional, Korean Johab);
a font declaring none of these goes by what it covers (kana, Hangul,
ideographs). Ties go Japanese, Simplified, Traditional, Korean. Loaded faces are classified
once; installed fonts by the font scan, which also reads their OS/2 and
meta tables (and cmap only for fonts declaring nothing), capped.

CJK families show their family name in their language, the English
name on hover; Preferences > Type > Show Font Names in English
(prefs.set {showFontNamesInEnglish}, off by default) shows English
names. Search matches either name; documents store the English name.
The Character panel, Control bar, Properties, style dialogs, Find Font
and Glyphs panel lists all follow this order. The main font menu stays
open while searching or starring (it closed on any click inside).

Font previews don't draw DesignCraft's missing-glyph box for fonts
whose .notdef is empty.
This commit is contained in:
aiongg committed 2026-10-07 10:44:46 -04:00
1 parent 1ef187ad02
commit c64e538de5
18 files changed
+668 -63

No files matched your search

+1 -1
View File
@@ -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.
+4 -1
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
+4
View File
@@ -171,6 +171,9 @@ pub struct Prefs {
pub rich_black_output: bool,
/// Font menu favourites (family names).
pub favorite_fonts: Vec<String>,
/// 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(),
+86 -23
View File
@@ -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<Box<[u64]>>,
/// 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<String>)>,
}
/// 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<String>,
}
/// 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<String>,
}
/// 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<ScannedFace> {
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<Vec<u8>> {
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<u8>)> {
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<FamilyInfo> {
let mut out: Vec<FamilyInfo> = 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<String> {
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<BezPath> {
self.db.outline(face, gid)
}
/// See [`FontDb::missing_box`].
pub fn missing_box(&self, face: &FontFace) -> Option<BezPath> {
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<Arc<FontFace>> {
let open = style.find('{')?;
+355
View File
@@ -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<String>,
}
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<FontGroup> {
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<FontGroup> {
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<u8>)>) -> Option<Vec<u8>> {
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<Item = FontGroup>) -> Option<FontGroup> {
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<FontGroup> {
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<FontGroup> {
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<Option<FontGroup>> {
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<FontGroup> {
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<String>) {
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<String> {
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<String>) {
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<u8> {
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<u8> {
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"));
}
}
+2
View File
@@ -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;
+51
View File
@@ -210,3 +210,54 @@ pub fn with_empty_notdef(mut font: Vec<u8>) -> Option<Vec<u8>> {
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<Vec<u8>> {
let strings: Vec<Vec<u8>> =
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<u8>, range1: u32, range2: u32) -> Option<Vec<u8>> {
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<Vec<u8>> {
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)])
}
+31 -1
View File
@@ -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);
}
+3 -1
View File
@@ -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),
);
+1
View File
@@ -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"] }
+9 -8
View File
@@ -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<Value, String> {
"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);
+10
View File
@@ -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",
+1
View File
@@ -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", "الوحدات والزيادات"),
+5 -6
View File
@@ -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| {
+92 -11
View File
@@ -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<designcraft_fonts::FamilyInfo> {
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<String> {
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<egui::TextureHandle> = 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);
}
}
+5 -7
View File
@@ -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();
}
});
});
+7 -4
View File
@@ -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<String> = 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}}));
+1
View File
@@ -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 |