From 0b522ceb862c1ba7ba515f64054643c8073c593f Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 18:14:26 +0800 Subject: [PATCH] fix(text): size TrueType fallback ideographs by the em box, like a big font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a text style's SHX / stroke font lacks a character, the glyph comes from a system TrueType font through `ttf_glyph::fallback_glyph`. That substitute was normalised to the Latin cap height (9 units = text height), which is right for Latin, Greek or Cyrillic stand-ins but wrong for CJK: an ideograph fills the whole em square, and 1 em is 1.3–1.5 cap heights in every common CJK font (DFKai-SB has no OS/2 cap height at all, so it fell to the 0.7 em guess). Every substituted Chinese character therefore came out ~40 % taller and wider than the big font (`chineset.shx`, `hztxt.shx`, …) the drawing was authored with, and whole lines of notes ran past their frames. A big font draws an ideograph one text height tall and one text height wide, so size full-width / ideographic fallback glyphs by their em box instead. Other scripts keep the cap-height normalisation. Real TrueType styles (`Face::Ttf`, shaped runs) are unchanged. Adds a unit test that a fallback `中` advances by about one text height and its ink fits the text height (skipped on machines without a CJK font). Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/text/ttf_glyph.rs | 80 ++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 2c76b291c..0024034b2 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -176,6 +176,42 @@ pub fn glyph(family: &str, ch: char) -> Option> { built } +/// Font-unit → 9-unit-em-box factor: the whole em square maps onto the text +/// height. This is how an SHX big font (`chineset.shx`, `hztxt.shx`, …) +/// sizes its ideographs — a CJK glyph is as tall as the text height and one +/// text height wide — so a TrueType glyph standing in for a missing big-font +/// glyph must use the same box, not the Latin cap height. Cap-height scaling +/// makes ideographs ~1.3–1.5× too big (1 em ≈ 1.3–1.5 cap heights in CJK +/// fonts), so every line of substituted Chinese text ran past its frame. +fn em_scale(face: &ttf_parser::Face) -> f32 { + CAP_UNITS / face.units_per_em().max(1) as f32 +} + +/// Ideographic / full-width characters: the ones a big font would supply and +/// that sit on an em box rather than the Latin cap height. +pub(crate) fn is_full_width(ch: char) -> bool { + matches!( + ch as u32, + 0x1100..=0x11FF // Hangul Jamo + | 0x2E80..=0x2FDF // CJK / Kangxi radicals + | 0x2FF0..=0x303F // ideographic description, CJK symbols & punctuation + | 0x3040..=0x30FF // Hiragana, Katakana + | 0x3100..=0x312F // Bopomofo + | 0x3130..=0x318F // Hangul compatibility Jamo + | 0x3190..=0x31FF // Kanbun, Bopomofo ext., CJK strokes, Katakana ext. + | 0x3200..=0x33FF // enclosed CJK, CJK compatibility + | 0x3400..=0x4DBF // CJK ext. A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA960..=0xA97F // Hangul Jamo ext. A + | 0xAC00..=0xD7FF // Hangul syllables, Jamo ext. B + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF01..=0xFF60 // full-width ASCII variants + | 0xFFE0..=0xFFE6 // full-width symbols + | 0x20000..=0x3FFFF // CJK ext. B–H + ) +} + /// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes /// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em. fn cap_scale(face: &ttf_parser::Face) -> f32 { @@ -379,7 +415,16 @@ fn build_fallback(ch: char) -> Option> { let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); let font = fs.get_font(g.font_id, g.font_weight)?; let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; - let k = cap_scale(&face); + // A fallback glyph stands in for a stroke / SHX font that lacks + // the character. For an ideograph that font would have been a + // big font, whose glyphs fill the text height — so size the + // substitute by its em box. Everything else keeps the cap-height + // normalisation that lines it up with the Latin stroke glyphs. + let k = if is_full_width(ch) { + em_scale(&face) + } else { + cap_scale(&face) + }; let gid = ttf_parser::GlyphId(g.glyph_id); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let mut fl = OutlineFlattener::new(k); @@ -609,6 +654,39 @@ mod tests { assert!(!run.glyphs.is_empty()); } + #[test] + fn fallback_ideograph_fills_the_text_height_like_a_big_font() { + // An SHX big font draws an ideograph one text height tall and one + // text height wide. The TrueType stand-in must match that box (9 + // units), not the ~12–14 units the Latin cap-height normalisation + // gives a CJK em square — that overrun pushed every substituted + // Chinese line past its frame. Tolerated when the machine has no + // CJK font at all. + let Some(g) = fallback_glyph('中') else { + eprintln!("no CJK system font; skipping"); + return; + }; + assert!( + (g.advance - CAP_UNITS).abs() < 0.6, + "ideograph advance must be about one text height: got {}", + g.advance + ); + let ink_h = g + .strokes + .iter() + .flatten() + .map(|p| p[1]) + .fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y))); + assert!( + ink_h.1 - ink_h.0 <= CAP_UNITS * 1.05, + "ideograph ink must fit the text height: {:?}", + ink_h + ); + // Latin fallback keeps the cap-height convention (shares a baseline + // with the stroke glyphs around it). + assert!(!is_full_width('A') && is_full_width('中') && is_full_width(',')); + } + #[test] fn shape_run_positions_glyphs() { let fams = sysfont::families();