From 8cb120d0ec86e02b1ebbd4dd361bdf550c3d1ef3 Mon Sep 17 00:00:00 2001 From: DWAA1660 <80738438+DWAA1660@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:47:38 -0600 Subject: [PATCH] Text: fallback skips a font whose glyph for the character is empty (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Face::has_char counted any non-zero cmap entry as coverage. Some fonts map whole scripts to empty glyphs (a decorative Latin font installed on a Linux machine maps あ, 水, ア and 。 to glyphs with no contours), so once such a face was loaded, fallback_for could pick it and Japanese text rendered as nothing; cjk_falls_back_to_a_system_family failed whenever another test had loaded that face first. has_char (and file_covers, used for faces not yet loaded) now also require the glyph to draw something: an outline with at least one segment, a colour glyph, or a bitmap in any strike. Characters that are blank by nature (whitespace, and the default-ignorable code points such as soft hyphen, joiners and variation selectors) still count as covered by any mapping. Face::glyph is unchanged, so shaping with the chosen face is as before. Test: an_empty_glyph_does_not_cover_its_character builds a minimal TrueType font in code (no font file added to the repo) mapping U+E000 to an empty glyph and U+E001 to a triangle, and checks has_char and fallback_for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW --- crates/text/src/fonts.rs | 129 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 62c753cb..58539c66 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -207,8 +207,12 @@ impl Face { pub fn glyph(&self, c: char) -> Option { self.font()?.charmap().map(c).map(|g| g.to_u32()).filter(|g| *g != 0) } + /// Whether the face can draw `c`: it maps `c` to a glyph that has an outline, a colour glyph + /// or a bitmap. Some fonts map whole scripts to empty glyphs (#285); those don't count, so + /// fallback moves on to a face that draws the character. Characters that are blank by nature + /// (spaces, format characters) count as covered by any mapping. pub fn has_char(&self, c: char) -> bool { - self.glyph(c).is_some() + self.font().is_some_and(|f| covers(&f, c)) } pub fn metrics(&self, px: f32) -> VMetrics { let Some(f) = self.font() else { @@ -586,7 +590,54 @@ fn file_covers(f: &Face, c: char) -> bool { } let FaceData::File(p) = &f.info.data else { return f.has_char(c) }; let Ok(data) = std::fs::read(p) else { return false }; - FontRef::from_index(&data, f.info.index).ok().and_then(|font| font.charmap().map(c)).is_some_and(|g| g.to_u32() != 0) + FontRef::from_index(&data, f.info.index).ok().is_some_and(|font| covers(&font, c)) +} + +/// Characters that legitimately draw nothing: whitespace and the default-ignorable code points +/// (soft hyphen, joiners, bidi controls, variation selectors…). +fn blank_by_nature(c: char) -> bool { + c.is_whitespace() + || matches!(c as u32, + 0x00AD | 0x034F | 0x061C | 0x115F | 0x1160 | 0x17B4 | 0x17B5 | 0x180B..=0x180F | 0x200B..=0x200F + | 0x202A..=0x202E | 0x2060..=0x206F | 0x3164 | 0xFE00..=0xFE0F | 0xFEFF | 0xFFA0 | 0x1BCA0..=0x1BCA3 + | 0x1D173..=0x1D17A | 0xE0000..=0xE0FFF) +} + +/// Whether `font` maps `c` to a glyph it can draw (see [`Face::has_char`]). +fn covers(font: &FontRef, c: char) -> bool { + let Some(gid) = font.charmap().map(c).filter(|g| g.to_u32() != 0) else { return false }; + blank_by_nature(c) || glyph_draws(font, gid) +} + +/// Whether a glyph has an outline, a colour glyph or a bitmap in any strike. +fn glyph_draws(font: &FontRef, gid: skrifa::GlyphId) -> bool { + use skrifa::outline::{DrawSettings, OutlinePen}; + /// Counts the drawing segments of an outline. + struct Segments(usize); + impl OutlinePen for Segments { + fn move_to(&mut self, _: f32, _: f32) {} + fn line_to(&mut self, _: f32, _: f32) { + self.0 += 1; + } + fn quad_to(&mut self, _: f32, _: f32, _: f32, _: f32) { + self.0 += 1; + } + fn curve_to(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32) { + self.0 += 1; + } + fn close(&mut self) {} + } + if let Some(outline) = font.outline_glyphs().get(gid) { + let mut pen = Segments(0); + if outline.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).is_ok() && pen.0 > 0 { + return true; + } + } + if font.color_glyphs().get(gid).is_some() { + return true; + } + let strikes = font.bitmap_strikes(); + (0..strikes.len()).any(|i| strikes.get(i).is_some_and(|s| s.get(gid).is_some())) } /// Results of the expensive part of [`fallback_for`], per 256-character block and preferred face @@ -780,6 +831,80 @@ mod tests { assert_eq!(fallback_for('水', inter), f.id); } + /// A minimal TrueType font built in code (no font file in the repo): U+0020 and U+E000 map to + /// an empty glyph, U+E001 to a triangle. + fn blank_glyph_font() -> Vec { + fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() + } + let delta_space = 1u16.wrapping_sub(0x20); + let cmap4 = { + let mut t = u16s(&[4, 0, 0, 6, 4, 1, 2]); + t.extend(u16s(&[0x20, 0xE001, 0xFFFF, 0, 0x20, 0xE000, 0xFFFF, delta_space, 0x2001, 1, 0, 0, 0])); + let len = t.len() as u16; + t[2..4].copy_from_slice(&len.to_be_bytes()); + t + }; + let mut cmap = u16s(&[0, 1, 3, 1, 0, 12]); + cmap.extend(cmap4); + // glyph 2: one contour of three on-curve points (100,0) (500,0) (300,600) + let mut glyf = u16s(&[1, 100, 0, 500, 600, 2, 0]); + glyf.extend([1u8, 1, 1]); + glyf.extend(u16s(&[100, 400, (-200i16) as u16, 0, 0, 600])); + glyf.push(0); + let loca = u16s(&[0, 0, 0, (glyf.len() / 2) as u16]); + let mut head = u16s(&[1, 0, 1, 0, 0, 0, 0x5F0F, 0x3CF5, 0, 1000]); + head.extend([0u8; 16]); + head.extend(u16s(&[100, 0, 500, 600, 0, 8, 2, 0, 0])); + let mut hhea = u16s(&[1, 0, 800, (-200i16) as u16, 0, 600, 0, 0, 500, 1, 0, 0]); + hhea.extend([0u8; 8]); + hhea.extend(u16s(&[0, 3])); + let hmtx = u16s(&[600, 0, 600, 0, 600, 0]); + let maxp = u16s(&[1, 0, 3, 3, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]); + let strings: Vec> = ["Blank Glyph Test", "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect(); + let mut name = u16s(&[0, 2, 30]); + let mut off = 0; + for (id, st) in strings.iter().enumerate() { + name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off])); + off += st.len() as u16; + } + strings.iter().for_each(|st| name.extend(st)); + let tables: [(&[u8; 4], Vec); 8] = + [(b"cmap", cmap), (b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca), (b"maxp", maxp), (b"name", name)]; + let mut out = u16s(&[1, 0, tables.len() as u16, 128, 3, 0]); + let mut offset = 12 + 16 * tables.len(); + let mut body = vec![]; + for (tag, data) in &tables { + out.extend(*tag); + out.extend([0u8; 4]); + out.extend((offset as u32).to_be_bytes()); + out.extend((data.len() as u32).to_be_bytes()); + let padded = data.len().div_ceil(4) * 4; + body.extend(data); + body.resize(body.len() + padded - data.len(), 0); + offset += padded; + } + out.extend(body); + out + } + + /// A face that maps a character to an empty glyph doesn't cover it, so fallback skips it + /// (#285); a blank space still counts, and a drawn glyph is found as before. + #[test] + fn an_empty_glyph_does_not_cover_its_character() { + let ids = add_font_data(blank_glyph_font()); + let &[id] = ids.as_slice() else { panic!("one face expected: {ids:?}") }; + let f = face(id); + assert_eq!(f.info.family, "Blank Glyph Test"); + assert!(f.glyph('\u{e000}').is_some(), "the cmap maps it"); + assert!(!f.has_char('\u{e000}'), "but its glyph is empty"); + assert!(f.has_char('\u{e001}'), "a drawn glyph covers its character"); + assert!(f.has_char(' '), "a space is blank by nature"); + let inter = resolve("Inter", "Regular").face; + assert_ne!(fallback_for('\u{e000}', inter), id, "fallback never picks the empty glyph"); + assert_eq!(fallback_for('\u{e001}', inter), id, "fallback finds the drawn one"); + } + #[test] fn metrics_and_coverage() { let f = face(resolve("Inter", "Regular").face);