From 0ad4d4f06c15e6313429640725066a23b96225e3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 23:44:26 -0800 Subject: [PATCH 1/2] Fix #5816: CJK fallback faces in the interface sit on the UI font's baseline An installed face added for characters the interface fonts lack (Microsoft YaHei for Simplified Chinese on Windows, for example) now gets a FontTweak y_offset_factor computed from its ascent, descent and line gap against the first face of the Proportional family, so its glyphs share that face's baseline instead of centering the difference in row height. The bundled faces and Latin text do not move. This follows designcraft#394 (baseline_tweak from line metrics) and effectcraft#1395. vectorcraft-text gains line_metrics, and a test face with a CJK system face's vertical proportions checks the baseline at 13, 24 and 40 pt in the interface families. --- crates/text/src/lib.rs | 10 +++++ crates/text/src/test_fonts.rs | 28 +++++++++++++ crates/ui-egui/src/ui_fonts.rs | 75 ++++++++++++++++++++++++++++++---- docs/target-app-parity.md | 1 + 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 8b1c4a63d..1ea6e5a94 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -834,6 +834,16 @@ pub fn logical_order(visual: &str) -> Option<(Vec, 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)] diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 1f74e2213..2f591b6e0 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -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> { + 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"; diff --git a/crates/ui-egui/src/ui_fonts.rs b/crates/ui-egui/src/ui_fonts.rs index bcb6c909e..2265181f0 100644 --- a/crates/ui-egui/src/ui_fonts.rs +++ b/crates/ui-egui/src/ui_fonts.rs @@ -4,16 +4,16 @@ //! 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 UI face's baseline, 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 +109,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 UI face's baseline. fn install(&mut self, ctx: &egui::Context, faces: &[Arc]) { - let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + let (families, row): (Vec, _) = ctx.fonts(|f| (f.definitions().families.keys().cloned().collect(), row_metrics(f.definitions()))); for face in faces { let name = format!("{} {} #{}", face.family, face.style, face.face_index()); if !self.added.insert(name.clone()) { @@ -120,7 +120,8 @@ impl UiFonts { // The face's file data, shared with the process-wide font database (which keeps every // 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 tweak = row.map_or_else(FontTweak::default, |row| baseline_tweak(row, face.file_data(), face.face_index())); + let data = FontData { index: face.face_index(), tweak, ..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 }); } @@ -138,6 +139,33 @@ impl UiFonts { } } +/// The ascent and row height of the Proportional family's first face, in ems of the font size, +/// with that face's `FontTweak` scale applied, as egui lays out its rows. `None` when the family is +/// empty or its face can't be read. +fn row_metrics(defs: &FontDefinitions) -> Option<(f32, f32)> { + let name = defs.families.get(&FontFamily::Proportional)?.first()?; + let data = defs.font_data.get(name)?; + let (ascent, height) = em_metrics((*data.font).as_ref(), data.index)?; + Some((ascent * data.tweak.scale, height * data.tweak.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 tweak that puts a fallback face's glyphs on the baseline of the row's first face, whose +/// ascent and row height 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. One shift +/// serves every family: it is measured against the Proportional family's first face. +fn baseline_tweak(row: (f32, f32), face: &[u8], index: u32) -> FontTweak { + let (row_ascent, row_height) = row; + let shift = em_metrics(face, index).map_or(0.0, |(ascent, height)| row_ascent - (ascent + 0.5 * (row_height - height))); + FontTweak { y_offset_factor: if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 }, ..Default::default() } +} + /// 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 +270,39 @@ mod tests { assert!(has_glyph(&ctx, 'L')); } + /// An installed face with a taller ascent and descent than the UI face (a CJK system face's + /// proportions) draws on the UI face's baseline: `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 = + [FontFamily::Proportional, FontFamily::Name(crate::theme::FONT_UI.into()), FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into())]; + 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)"); + } + } + } + #[test] fn cjk_names_get_an_installed_fallback_font() { let text = "Logo 标志 ロゴ 로고"; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e41d57eb..2a26cf085 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 an installed font that covers them (Microsoft YaHei for Simplified Chinese on Windows, for example), sits on the interface font's baseline beside Latin text (#5816; `crates/ui-egui/src/ui_fonts.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. From 5db4cfe9bcbb54ff07a6a1a73c5a7f52f3cf713b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 00:40:35 -0800 Subject: [PATCH 2/2] Fix #5816: bundled fallback faces and monospace text share the Latin baseline The bundled craft-fonts Japanese, Georgian and Devanagari faces now get the same baseline shift as installed fallback faces, so every glyph in an interface label sits on the baseline of its family's first face. Each fallback face's shift is measured against the first face of the family it is added to (Inter for the proportional families, JetBrains Mono for `mono`, egui's face for `Monospace`). Families whose first faces need the same shift share one copy of the face; the others get a copy named after their first face. Tests: craft_fonts_sit_on_the_baseline_of_each_family checks each bundled face in every family against its own baseline, and fallback_faces_sit_on_the_ui_baseline now checks every family, monospace ones included. --- crates/ui-egui/src/theme.rs | 23 ++++-- crates/ui-egui/src/ui_fonts.rs | 137 +++++++++++++++++++++++++-------- docs/target-app-parity.md | 2 +- 3 files changed, 126 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 53281dc69..55fb3fbc6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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)); } } diff --git a/crates/ui-egui/src/ui_fonts.rs b/crates/ui-egui/src/ui_fonts.rs index 2265181f0..ac3f260e9 100644 --- a/crates/ui-egui/src/ui_fonts.rs +++ b/crates/ui-egui/src/ui_fonts.rs @@ -4,9 +4,10 @@ //! 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, on the UI face's baseline, 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; @@ -109,9 +110,9 @@ impl UiFonts { missing } - /// Add `faces` to every egui family, after the fonts it has, on the UI face's baseline. + /// 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]) { - let (families, row): (Vec, _) = ctx.fonts(|f| (f.definitions().families.keys().cloned().collect(), row_metrics(f.definitions()))); for face in faces { let name = format!("{} {} #{}", face.family, face.style, face.face_index()); if !self.added.insert(name.clone()) { @@ -120,10 +121,11 @@ impl UiFonts { // The face's file data, shared with the process-wide font database (which keeps every // 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 tweak = row.map_or_else(FontTweak::default, |row| baseline_tweak(row, face.file_data(), face.face_index())); - let data = FontData { index: face.face_index(), tweak, ..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 }); + let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }; + 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(); @@ -139,14 +141,34 @@ impl UiFonts { } } -/// The ascent and row height of the Proportional family's first face, in ems of the font size, -/// with that face's `FontTweak` scale applied, as egui lays out its rows. `None` when the family is -/// empty or its face can't be read. -fn row_metrics(defs: &FontDefinitions) -> Option<(f32, f32)> { - let name = defs.families.get(&FontFamily::Proportional)?.first()?; - let data = defs.font_data.get(name)?; +/// 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)> { + let mut copies: Vec<(String, FontData, Vec)> = 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)?; - Some((ascent * data.tweak.scale, height * data.tweak.scale)) + 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. @@ -155,15 +177,15 @@ fn em_metrics(data: &[u8], index: u32) -> Option<(f32, f32)> { Some((ascent / upem, (ascent - descent + gap) / upem)) } -/// The tweak that puts a fallback face's glyphs on the baseline of the row's first face, whose -/// ascent and row height 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. One shift -/// serves every family: it is measured against the Proportional family's first face. -fn baseline_tweak(row: (f32, f32), face: &[u8], index: u32) -> FontTweak { - let (row_ascent, row_height) = row; - let shift = em_metrics(face, index).map_or(0.0, |(ascent, height)| row_ascent - (ascent + 0.5 * (row_height - height))); - FontTweak { y_offset_factor: if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 }, ..Default::default() } +/// 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. @@ -270,9 +292,10 @@ mod tests { assert!(has_glyph(&ctx, 'L')); } - /// An installed face with a taller ascent and descent than the UI face (a CJK system face's - /// proportions) draws on the UI face's baseline: `H` and the test face's 编 (the same `H` - /// outline, which stands on the baseline) end at the same height. + /// 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}; @@ -289,8 +312,7 @@ mod tests { 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 = - [FontFamily::Proportional, FontFamily::Name(crate::theme::FONT_UI.into()), FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into())]; + let families: Vec = 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)); @@ -303,6 +325,61 @@ mod tests { } } + /// 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 = 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 标志 ロゴ 로고"; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2a26cf085..a368c794c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1041,7 +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 an installed font that covers them (Microsoft YaHei for Simplified Chinese on Windows, for example), sits on the interface font's baseline beside Latin text (#5816; `crates/ui-egui/src/ui_fonts.rs`). + 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.