Merge pull request #1395 from zitann2020/i18n/ja-cjk-baseline

i18n: the CJK fallback sits on the Latin baseline (#1309)
This commit is contained in:
BFlat
2026-10-30 01:15:32 +07:00
committed by GitHub
+105
View File
@@ -6,6 +6,10 @@
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use egui::FontTweak;
#[cfg(not(target_arch = "wasm32"))]
use egui::emath::GuiRounding as _;
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
use serde::{Deserialize, Serialize};
@@ -383,6 +387,47 @@ const SCRIPT_PROBES: &[(char, &str)] = &[
('ა', "georgian-system"),
];
/// The size the fallback baseline correction is derived at. The correction is an em fraction, so the
/// value holds at every interface size; any size in the interface's range gives the same answer.
#[cfg(not(target_arch = "wasm32"))]
const BASELINE_METRICS_PX: f32 = 12.0;
/// A face's ascent and row height at `px`, rounded the way epaint rounds them: ascent, descent and
/// line gap separately to 1/32, then `ascent + descent + line_gap` (epaint's descent is negative,
/// this one is positive).
#[cfg(not(target_arch = "wasm32"))]
fn ascent_and_height(m: &effectcraft_text::fonts::VMetrics) -> (f32, f32) {
let ascent = m.ascent.round_ui();
let descent = m.descent.round_ui();
let line_gap = m.line_gap.round_ui();
(ascent, ascent + descent + line_gap)
}
/// The `FontTweak` that puts a fallback face's glyphs on the row font's baseline.
///
/// epaint draws a glyph at `A_face + 0.5·(H_row − H_face)` (`galley_from_rows` in epaint's
/// `text_layout.rs`), where `A` and `H` are the ascent and the row height of the face that draws the
/// glyph and of the row's own font, both from `styled_metrics` in epaint's `font.rs`. Shifting the
/// face by `(A_row − A_face) − 0.5·(H_row − H_face)` cancels the offset; as an em fraction one
/// number serves every size, because `FontTweak` applies `size · y_offset_factor`.
///
/// The same expression also carries `valign · (max_row_height − line_height)`, which cancels here:
/// every glyph of a row takes the section font's row height, so it is the same term for the Latin
/// and the fallback glyph. The remaining `snap_to_pixel` difference is likewise shared and cancels.
/// (#1309)
///
/// The UI families do not share a row font — Proportional uses Inter, Monospace JetBrains Mono —
/// and their factors differ by 0.0026 em, which is 0.03 px at 12 pt: inside the 1/32 that is already
/// rounded away above, so one factor per face is enough. The test
/// `fallback_glyphs_sit_on_the_row_baseline` pins the result.
#[cfg(not(target_arch = "wasm32"))]
fn baseline_tweak(row: &effectcraft_text::fonts::Face, face: &effectcraft_text::fonts::Face) -> FontTweak {
let px = BASELINE_METRICS_PX;
let (a_row, h_row) = ascent_and_height(&row.metrics(px));
let (a_face, h_face) = ascent_and_height(&face.metrics(px));
FontTweak { y_offset_factor: ((a_row - a_face) - 0.5 * (h_row - h_face)) / px, ..Default::default() }
}
/// Register the system fallbacks for scripts the bundled fonts lack in every font family, and
/// return their names in registration order (empty when nothing installed covers a probe, so a
/// machine without those fonts simply keeps the bundled ones). A face that covers several probes
@@ -415,6 +460,7 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
// Han text follows the interface language, not the operating system's locale.
fonts::set_cjk_locale(cjk_locale(language));
let base = fonts::resolve("Inter", "Regular").face;
let base_face = fonts::face(base);
let han = (han_probe(language), "chinese-system");
let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] };
let mut names = Vec::new();
@@ -458,6 +504,7 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
let mut data = FontData::from_owned(font.data().as_bytes().to_vec());
data.index = face.info.index;
let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() };
data.tweak = baseline_tweak(&base_face, &face);
family_fonts.font_data.insert(name.clone(), Arc::new(data));
for family in family_fonts.families.values_mut() {
family.push(name.to_string());
@@ -739,6 +786,64 @@ mod script_font_tests {
assert_ne!((meem.uv_rect.min, meem.uv_rect.max), (isolated.min, isolated.max), "the meem takes its initial form");
});
}
/// A fallback face's glyphs sit on the row font's baseline, in every UI family and at every
/// interface size. (#1309)
///
/// `A` stands in for the row's baseline: epaint exposes none, but a capital without a descender
/// has its ink bottom on the baseline, so its ink bottom *is* the row's baseline. A glyph with a
/// descender (`g`) or a round one with an overshoot (`O`) would quietly stop holding, so keep `A`
/// as the Latin half of this pair.
///
/// Both glyphs of `Aあ` end on the baseline, so their ink bottoms agree up to the difference
/// between the two outlines themselves - the kana reaches a little below the Latin letter in the
/// Japanese faces. Without the correction the kana sits 3 pt high at 12 pt (measured), so the
/// tolerance below separates "the correction is applied" from "it is missing"; it is not a claim
/// about the outlines. Two pixels per point, because epaint truncates a glyph's box height to
/// whole pixels, which would otherwise put up to a point of noise on the measurement.
#[test]
fn fallback_glyphs_sit_on_the_row_baseline() {
if !installed('\u{3042}') {
eprintln!("no Japanese system font installed; skipping the baseline check");
return;
}
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "ja");
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y;
ctx.fonts_mut(|fonts| {
for family in [
egui::FontFamily::Proportional,
egui::FontFamily::Monospace,
egui::FontFamily::Name("medium".into()),
egui::FontFamily::Name("semibold".into()),
] {
for size in [12.0_f32, 15.0, 18.0] {
let font = egui::FontId::new(size, family.clone());
let galley = fonts.layout_no_wrap("A\u{3042}".into(), font, egui::Color32::WHITE);
let row = &galley.rows[0].glyphs;
assert_eq!(row.len(), 2, "A\u{3042} should shape to two glyphs");
let (latin, kana) = (bottom(&row[0]), bottom(&row[1]));
let drift = kana - latin;
// Without the correction the kana sits 3-5 pt *above* the Latin ink, so the sign is
// what tells the two apart. With it, the kana reaches a little *below* the Latin
// one, by however far the face's kana overshoot past the baseline - measured at
// 1.94 pt for Noto Sans CJK JP against Yu Gothic's 0.4 - plus up to half a point of
// whole-point rounding in `pos.y` for each glyph.
assert!(
drift > -0.5,
"the kana sits {drift:.2} pt above the Latin baseline at {size} pt in {family:?} (latin {latin}, kana {kana}); the fallback tweak is missing or wrong"
);
assert!(
drift < 2.5,
"the kana sits {drift:.2} pt below the Latin baseline at {size} pt in {family:?} (latin {latin}, kana {kana}); the fallback tweak is too large"
);
}
}
});
}
}
#[cfg(test)]