From c0dccff14a8dfbd4f8718cb091cc81acc3dc4dac Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Tue, 22 Sep 2026 09:39:43 +0300 Subject: [PATCH] fix(text): never accept a fallback glyph that has no outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cosmic-text's fallback search can land on a face that claims the character and outlines to nothing: macOS's system font does that for every glyph, because ttf-parser reads no contours out of it. The code took the first non-.notdef glyph id and stopped, so the character drew nothing while believing it drew something — invisible text, and no warning either. An empty outline is now treated as no answer: the search moves on to the next face, and when cosmic-text's own list has nothing that draws the character, the installed families are walked for one that does. That list is the platform's own handful of names (on macOS `.SF NS`, `Menlo`, `Apple Color Emoji`, `Geneva`, `Arial Unicode MS`) and covers neither Cyrillic nor Greek, however many fonts the machine has that do. `cargo test --lib a_fallback_glyph_is_never_empty`, and the Turkish letters in `lff::tests::fonts_parse_and_resolve` render on macOS now instead of failing. --- src/scene/text/ttf_glyph.rs | 165 ++++++++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 46 deletions(-) diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 0024034b2..8e324b037 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -366,6 +366,125 @@ pub fn clear_fallback_cache() { crate::scene::text::sdf_atlas::reset_font_entries(); } +#[cfg(test)] +mod fallback_tests { + use super::*; + + /// A fallback that comes back with an id but no outline draws nothing while + /// believing it drew something: the stroke fonts hand every letter they + /// lack to this path, so on macOS a Cyrillic drawing came out blank. + #[test] + fn a_fallback_glyph_is_never_empty() { + for ch in ['Ğ', 'ş', 'б', 'Я', 'Ω', '中', 'A'] { + let Some(glyph) = fallback_glyph(ch) else { + continue; // no font on this host covers it at all + }; + assert!( + !glyph.strokes.is_empty() || !glyph.fill_tris.is_empty(), + "'{ch}' resolved to a glyph with no outline" + ); + } + } +} + +/// Last-resort glyph for a character missing from a stroke (LFF) font: whatever +/// this machine can draw it with, normalized to 9-unit cap height, or `None` +/// when nothing installed has the character. Cached per character. +/// +/// The result is a filled-outline glyph, so it visually differs from the +/// surrounding single-stroke text — accepted as the price of covering scripts +/// no stroke font provides. +#[cfg(not(target_arch = "wasm32"))] +fn build_fallback(ch: char) -> Option> { + outline_from_fallback_face(ch).or_else(|| installed_family_glyph(ch)) +} + +/// Outline `ch` from a parsed face, in the 9-unit text space. +/// +/// 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. +#[cfg(not(target_arch = "wasm32"))] +fn outline_char(face: &ttf_parser::Face, ch: char) -> Option { + let gid = face.glyph_index(ch)?; + let k = if is_full_width(ch) { + em_scale(face) + } else { + cap_scale(face) + }; + let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; + let mut fl = OutlineFlattener::new(k); + face.outline_glyph(gid, &mut fl); + fl.flush(); + let fill_tris = triangulate_contours(&fl.contours); + Some(Glyph { + strokes: fl.contours, + advance, + fill_tris, + }) +} + +/// Outline `ch` from the face cosmic-text picks for it: it knows the platform's +/// fallback order and shapes its way through ligatures and joining. +/// +/// A face can answer with a glyph id and no outline at all — macOS's system font +/// does, for every character, because ttf-parser reads no contours out of it — +/// so an empty outline is not an answer and the caller has to keep looking. +#[cfg(not(target_arch = "wasm32"))] +fn outline_from_fallback_face(ch: char) -> Option> { + use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; + let mut fs = font_system().lock().unwrap(); + // Default family → cosmic's own fallback search chooses a covering font. + let attrs = Attrs::new(); + let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS)); + buf.set_size(&mut fs, None, None); + let s = ch.to_string(); + buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None); + buf.shape_until_scroll(&mut fs, false); + + for run in buf.layout_runs() { + for g in run.glyphs.iter() { + if g.glyph_id == 0 { + continue; // .notdef — this font doesn't really cover it + } + 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 Some(glyph) = outline_char(&face, ch) else { + continue; + }; + if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() { + continue; + } + return Some(Arc::new(glyph)); + } + } + None +} + +/// The first installed family that actually draws `ch` — the net under +/// cosmic-text, whose fallback list is the platform's own handful of names (on +/// macOS: `.SF NS`, `Menlo`, `Apple Color Emoji`, `Geneva`, `Arial Unicode MS`) +/// and covers neither Cyrillic nor Greek, however many fonts the machine has +/// that do. +#[cfg(not(target_arch = "wasm32"))] +fn installed_family_glyph(ch: char) -> Option> { + crate::scene::text::sysfont::families() + .iter() + .find_map(|family| { + let glyph = sysfont::with_face_data(family, |data, index| { + let face = ttf_parser::Face::parse(data, index).ok()?; + outline_char(&face, ch) + }) + .flatten()?; + // A face that yields no outline (the system font, a space) is not a + // font that draws this character. + (!glyph.strokes.is_empty() || !glyph.fill_tris.is_empty()) + .then(|| Arc::new(glyph)) + }) +} + /// Web: outline the glyph from the lazily-fetched per-script Noto subset that /// covers it. Returns `None` while that font is still loading (the char renders /// once it arrives and the fallback cache is cleared). (#141) @@ -395,52 +514,6 @@ fn build_fallback(ch: char) -> Option> { })) } -#[cfg(not(target_arch = "wasm32"))] -fn build_fallback(ch: char) -> Option> { - use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; - let mut fs = font_system().lock().unwrap(); - // Default family → cosmic's own fallback search chooses a covering font. - let attrs = Attrs::new(); - let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS)); - buf.set_size(&mut fs, None, None); - let s = ch.to_string(); - buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None); - buf.shape_until_scroll(&mut fs, false); - - for run in buf.layout_runs() { - for g in run.glyphs.iter() { - if g.glyph_id == 0 { - continue; // .notdef — this font doesn't really cover it - } - 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()?; - // 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); - face.outline_glyph(gid, &mut fl); - fl.flush(); - let fill_tris = triangulate_contours(&fl.contours); - return Some(Arc::new(Glyph { - strokes: fl.contours, - advance, - fill_tris, - })); - } - } - None -} - #[cfg(target_arch = "wasm32")] fn build_shaped(_family: &str, text: &str) -> Option { use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping};