Merge pull request #6079 from dotCooCoo/fix/issue-5816

Fix #5816: CJK and other fallback faces in the interface sit on the Latin baseline
This commit is contained in:
BFlat
2026-11-08 13:37:32 +08:00
committed by GitHub
5 changed files with 203 additions and 13 deletions
+10
View File
@@ -834,6 +834,16 @@ pub fn logical_order(visual: &str) -> Option<(Vec<usize>, bool)> {
Some((unicode_bidi::BidiInfo::reorder_visual(&levels), para.level.is_rtl()))
}
/// Line metrics of the font at `index` of `data`, at its default axis settings, in font units:
/// (units per em, ascent, descent (negative below the baseline), line gap). egui builds its text
/// rows from these values. `None` if the font can't be read.
pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> {
use skrifa::MetadataProvider;
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+28
View File
@@ -1007,6 +1007,34 @@ fn remapped_source_sans(family: &str, postscript: &str, mapping: &[(char, char)]
Some(builder.build())
}
/// The family of [`tall_ideograph_font`].
pub const TALL_IDEOGRAPH_FAMILY: &str = "Tall Ideograph Sans";
/// A face that maps the ideograph 编 to the bundled Source Sans 3 `H` outline, which stands on the
/// baseline, with a taller ascent (1,058 units per 1,000) and descent (262) than Source Sans 3 in
/// its `hhea` and `OS/2` tables and no line gap: the vertical proportions of a CJK system face.
pub fn tall_ideograph_font() -> Option<Vec<u8>> {
const ASCENT: i16 = 1058;
const DESCENT: i16 = 262;
let mut data = remapped_source_sans(TALL_IDEOGRAPH_FAMILY, "TallIdeographSans-Regular", &[('编', 'H')])?;
let tables: Vec<(Tag, usize)> = {
let font = skrifa::FontRef::new(&data).ok()?;
font.table_directory.table_records().iter().map(|r| (Tag::new(&r.tag().to_be_bytes()), r.offset() as usize)).collect()
};
let (ascent, descent, gap) = (ASCENT.to_be_bytes(), (-DESCENT).to_be_bytes(), [0, 0]);
for (tag, at) in tables {
if tag == Tag::new(b"hhea") {
// ascender, descender and lineGap at bytes 4 to 10.
data.get_mut(at + 4..at + 10)?.copy_from_slice(&[ascent, descent, gap].concat());
} else if tag == Tag::new(b"OS/2") {
// sTypoAscender, sTypoDescender, sTypoLineGap, usWinAscent and usWinDescent at bytes 68 to 78.
let win = [ASCENT.unsigned_abs().to_be_bytes(), DESCENT.unsigned_abs().to_be_bytes()];
data.get_mut(at + 68..at + 78)?.copy_from_slice(&[ascent, descent, gap, win[0], win[1]].concat());
}
}
Some(data)
}
/// The family of [`soft_hyphen_outline_font`].
pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans";
+18 -5
View File
@@ -566,16 +566,29 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
/// The Japanese, Georgian and Devanagari craft-fonts faces (when built with `CRAFT_FONTS_DIR`) as fallbacks at
/// the end of every family, after the app's own fonts and before the installed fonts `ui_fonts`
/// adds: BIZ UDPGothic first (bold first in the semibold family), then the Mincho faces, then Noto
/// Sans Georgian.
/// Sans Georgian. Each face sits on the baseline of the first face of the family it is in (see
/// `ui_fonts::baseline_copies`).
fn add_craft_fonts(fonts: &mut FontDefinitions) {
// The name of each face's copy in each family.
let mut copies: std::collections::HashMap<(FontFamily, String), String> = std::collections::HashMap::new();
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva")) {
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
let name = craft_font_name(f);
for (copy, data, families) in crate::ui_fonts::baseline_copies(fonts, &name, &FontData::from_static(f.bytes)) {
for family in families {
copies.insert((family, name.clone()), copy.clone());
}
fonts.font_data.insert(copy, Arc::new(data));
}
}
for (family, stack) in fonts.families.iter_mut() {
let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into());
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name));
stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(craft_font_name));
stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(craft_font_name));
let copy = |f: &vectorcraft_text::CraftFont| {
let name = craft_font_name(f);
copies.get(&(family.clone(), name.clone())).cloned().unwrap_or(name)
};
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(copy));
stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(copy));
stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(copy));
}
}
+146 -8
View File
@@ -4,16 +4,17 @@
//! which `theme::install_fonts` adds after the UI fonts, so Japanese never gets here; nothing else
//! is bundled for them: the first time a frame paints a character the UI fonts
//! don't have, an installed font covering it is looked for (in the background on native) and added
//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs
//! no font loading or memory. On the web, which has no system fonts, such characters stay
//! missing-glyph boxes (Japanese too, unless built with craft-fonts).
//! to egui's families as their last fallback, on the baseline of each family's first face, from
//! the next frame on. Text the UI fonts cover costs no font loading or memory. On the web, which
//! has no system fonts, such characters stay missing-glyph boxes (Japanese too, unless built with
//! craft-fonts).
use std::collections::HashSet;
use std::sync::Arc;
use egui::epaint::Shape;
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
use egui::{FontData, FontFamily, FontId, LayerId};
use egui::{FontData, FontDefinitions, FontFamily, FontId, FontTweak, LayerId};
use vectorcraft_text::{FontDb, FontFace};
#[derive(Default)]
@@ -109,9 +110,9 @@ impl UiFonts {
missing
}
/// Add `faces` to every egui family, after the fonts it has.
/// Add `faces` to every egui family, after the fonts it has, on the baseline of the family's
/// first face.
fn install(&mut self, ctx: &egui::Context, faces: &[Arc<FontFace>]) {
let families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for face in faces {
let name = format!("{} {} #{}", face.family, face.style, face.face_index());
if !self.added.insert(name.clone()) {
@@ -121,8 +122,10 @@ impl UiFonts {
// face it loads for the session) instead of copied: one handle is leaked per font added.
let face: &'static FontFace = Box::leak(Box::new(face.clone()));
let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) };
let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect();
ctx.add_font(FontInsert { name, data, families });
for (name, data, families) in ctx.fonts(|f| baseline_copies(f.definitions(), &name, &data)) {
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
ctx.add_font(FontInsert { name, data, families });
}
}
if !faces.is_empty() {
ctx.request_repaint();
@@ -138,6 +141,53 @@ impl UiFonts {
}
}
/// The copies of the fallback face `data` that `defs`' families take, one for each baseline they
/// need: each copy is `data` with the `y_offset_factor` that puts its glyphs on the baseline of the
/// first face of the families listed with it. Families whose first faces need the same shift share
/// a copy. The first copy (the Proportional family's, when `defs` has it) is named `name`; each
/// other copy adds the name of its families' first face.
pub(crate) fn baseline_copies(defs: &FontDefinitions, name: &str, data: &FontData) -> Vec<(String, FontData, Vec<FontFamily>)> {
let mut copies: Vec<(String, FontData, Vec<FontFamily>)> = vec![];
for (family, stack) in &defs.families {
let head = stack.first().and_then(|head| Some((head.as_str(), defs.font_data.get(head)?)));
let shift = head.and_then(|(_, data)| row_metrics(data)).map_or(0.0, |row| baseline_shift(row, (*data.font).as_ref(), data.index));
if let Some((_, _, families)) = copies.iter_mut().find(|(_, copy, _)| copy.tweak.y_offset_factor.to_bits() == shift.to_bits()) {
families.push(family.clone());
continue;
}
let copy_name = if copies.is_empty() { name.to_owned() } else { format!("{name} / {}", head.map_or("", |(head, _)| head)) };
let tweak = FontTweak { y_offset_factor: shift, ..data.tweak.clone() };
copies.push((copy_name, FontData { tweak, ..data.clone() }, vec![family.clone()]));
}
copies
}
/// The ascent, row height and vertical offset of a family's first face `data`, in ems of the font
/// size, with that face's `FontTweak` scale applied, as egui lays out its rows. `None` when the
/// face can't be read.
fn row_metrics(data: &FontData) -> Option<(f32, f32, f32)> {
let (ascent, height) = em_metrics((*data.font).as_ref(), data.index)?;
let scale = data.tweak.scale;
Some((ascent * scale, height * scale, data.tweak.y_offset_factor * scale))
}
/// The ascent and row height (ascent, descent and line gap) of the face at `index` of `data`, in ems.
fn em_metrics(data: &[u8], index: u32) -> Option<(f32, f32)> {
let (upem, ascent, descent, gap) = vectorcraft_text::line_metrics(data, index)?;
Some((ascent / upem, (ascent - descent + gap) / upem))
}
/// The `y_offset_factor` that puts the glyphs of the face at `index` of `face` on the baseline of
/// the row's first face, whose ascent, row height and offset in ems are `row`. egui draws a
/// fallback glyph's baseline at its face's ascent plus half the difference between the two row
/// heights, so a face with a taller ascent or descent sits off the baseline by the shift computed
/// here, in ems of the font size, limited to half an em either way.
fn baseline_shift(row: (f32, f32, f32), face: &[u8], index: u32) -> f32 {
let (row_ascent, row_height, row_offset) = row;
let shift = em_metrics(face, index).map_or(0.0, |(ascent, height)| row_ascent + row_offset - (ascent + 0.5 * (row_height - height)));
if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 }
}
/// Call `f` with the text of `shape` and the shapes it holds.
fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) {
match shape {
@@ -242,6 +292,94 @@ mod tests {
assert!(has_glyph(&ctx, 'L'));
}
/// An installed face with a taller ascent and descent than the UI faces (a CJK system face's
/// proportions) draws on the baseline of each family's first face, the monospace families'
/// included: `H` and the test face's 编 (the same `H` outline, which stands on the baseline) end
/// at the same height.
#[test]
fn fallback_faces_sit_on_the_ui_baseline() {
use vectorcraft_text::test_fonts::{TALL_IDEOGRAPH_FAMILY, tall_ideograph_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(tall_ideograph_font().expect("the test face builds"));
let face = db.face(TALL_IDEOGRAPH_FAMILY, "Regular").expect("the test face loads");
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
crate::theme::install_fonts(&ctx);
let mut fonts = UiFonts::default();
frame(&ctx, &mut fonts, "H");
assert!(!has_glyph(&ctx, '编'), "no UI face has 编");
fonts.install(&ctx, &[face]);
frame(&ctx, &mut fonts, "H");
assert!(has_glyph(&ctx, '编'), "the test face draws 编");
let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y;
let families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for family in families {
for size in [13.0_f32, 24.0, 40.0] {
let galley = ctx.fonts_mut(|f| f.layout_no_wrap("H编".into(), FontId::new(size, family.clone()), egui::Color32::WHITE));
let row = &galley.rows[0].glyphs;
assert_eq!(row.len(), 2, "H编 lays out as two glyphs");
// Half a point: one device pixel at 2 pixels per point, for each glyph's rounding.
let drift = bottom(&row[1]) - bottom(&row[0]);
assert!(drift.abs() <= 0.5, "{family:?} at {size} pt: 编 ends {drift:.2} pt below H (negative: above)");
}
}
}
/// The bundled craft-fonts faces draw on the baseline of each family's first face: a glyph of
/// each ends as far below `H`'s baseline as it does below its own face's baseline when that face
/// heads a family.
#[test]
fn craft_fonts_sit_on_the_baseline_of_each_family() {
use vectorcraft_text::CraftFont;
use vectorcraft_text::craft_fonts::{georgian_ui_fonts, japanese_ui_fonts};
// The face each family draws a script in, with a character of it: (regular face, semibold
// family's face, character).
let first = |faces: Vec<&'static CraftFont>| faces.first().copied();
let deva = vectorcraft_text::CRAFT_FONTS.iter().find(|f| f.scripts.contains(&"Deva"));
let probes: Vec<(&CraftFont, &CraftFont, char)> = [
first(japanese_ui_fonts(false)).zip(first(japanese_ui_fonts(true))).map(|(regular, bold)| (regular, bold, '編')),
first(georgian_ui_fonts()).map(|f| (f, f, 'ა')),
deva.map(|f| (f, f, 'क')),
]
.into_iter()
.flatten()
.collect();
if probes.is_empty() {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let mut defs = crate::theme::interface_fonts();
let families: Vec<FontFamily> = defs.families.keys().cloned().collect();
let probe_name = |f: &CraftFont| format!("probe {}", crate::theme::craft_font_name(f));
let probe = |f: &CraftFont| FontFamily::Name(probe_name(f).into());
for &(regular, bold, _) in &probes {
for f in [regular, bold] {
defs.font_data.insert(probe_name(f), Arc::new(FontData::from_static(f.bytes)));
defs.families.insert(probe(f), vec![probe_name(f)]);
}
}
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
ctx.set_fonts(defs);
frame(&ctx, &mut UiFonts::default(), "H");
let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y;
let glyphs = |text: &str, font: FontId| ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), font, egui::Color32::WHITE)).rows[0].glyphs.clone();
for family in families {
let semibold = family == FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into());
for &(regular, bold, c) in &probes {
let face = if semibold { bold } else { regular };
for size in [13.0_f32, 24.0, 40.0] {
let own = glyphs(&c.to_string(), FontId::new(size, probe(face)));
let row = glyphs(&format!("H{c}"), FontId::new(size, family.clone()));
assert_eq!((own.len(), row.len()), (1, 2), "{c} lays out as one glyph");
// Half a point: one device pixel at 2 pixels per point, for each glyph's rounding.
let drift = (bottom(&row[1]) - row[0].pos.y) - (bottom(&own[0]) - own[0].pos.y);
assert!(drift.abs() <= 0.5, "{family:?} at {size} pt: {c} ({}) ends {drift:.2} pt below its baseline", face.family);
}
}
}
}
#[test]
fn cjk_names_get_an_installed_fallback_font() {
let text = "Logo 标志 ロゴ 로고";
+1
View File
@@ -1041,6 +1041,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected.
The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again.
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`).
Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment.
Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them.
A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved.