From fd2f8146b4222da9ba240db9714fb5433e7db0cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:06:30 -0400 Subject: [PATCH] Bullets: an installed symbol font draws its own glyph (#293) Bullets in Wingdings, Wingdings 2/3, Symbol and other symbol fonts were always swapped for a look-alike from one Wingdings table, so a Wingdings 2 star (0xEA) became a round dot. When the deck's symbol font is installed, its glyph is drawn (symbol fonts map character codes at U+F000 + code), as PowerPoint does; otherwise Wingdings 2 and 3 codes get their own look-alikes. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++++ crates/text/src/tests.rs | 25 +++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 93c52e4..226d2bd 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -493,6 +493,17 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, // Symbol fonts (Wingdings) aren't available: map common private-use bullets. let ch = map_symbol_bullet(char, props.bullet_font.as_deref()); bullet_face(db, &mut st, &ch); + + // A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found + // at U+F000 + the character code as such fonts map them; otherwise common symbol + // bullets are mapped to Unicode look-alikes. + let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) { + Some(c) => c.to_string(), + None => map_symbol_bullet(char, props.bullet_font.as_deref()), + }; + if !st.face.covers(ch.chars().next().unwrap_or('•')) { + st.face = db.face("Inter", "Regular"); + } Some((ch, st)) } Some(Bullet::AutoNum { scheme, start_at }) => { @@ -715,11 +726,59 @@ fn shape_item( } } +/// Is `family` a symbol font (characters at U+F000 + code)? +fn is_symbol_font(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett") +} + +/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a +/// symbol font that `face` is (not a substitute) and it has the glyph. +fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option { + let font = font?; + if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) { + return None; + } + let first = c.chars().next()?; + let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?; + if face.covers(pua) { + Some(pua) + } else if face.covers(first) { + Some(first) + } else { + None + } +} + fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) { let code = (first as u32) & 0xff; + // Wingdings 2 and 3 put different shapes at the same codes. + if f.starts_with("wingdings 2") { + let m = match code { + 0x97 | 0x98 => '●', + 0x9e..=0xa1 => '■', + 0xa2..=0xa5 => '◆', + 0xea..=0xf3 => '★', + 0xb7..=0xbf => '○', + 0x50 => '✓', + _ => '•', + }; + return m.to_string(); + } + if f.starts_with("wingdings 3") { + let m = match code { + 0x7d | 0x75 => '►', + 0x7c | 0x74 => '◄', + 0x70 => '▲', + 0x71 => '▼', + 0xc6 | 0xc7 => '➔', + _ => '▶', + }; + return m.to_string(); + } let m = match code { 0xa7 => '▪', 0xd8 => '➢', diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1b9a58c..c55f274 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -624,3 +624,28 @@ fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() { assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box"); } } + +#[test] +fn symbol_font_bullets_map_by_font() { + // Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain + // Wingdings keeps its own table (0xA7 is a small square). + let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA + assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★"); + assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►"); + assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪"); + assert_eq!(map_symbol_bullet("•", Some("Arial")), "•"); + assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial")); +} + +#[test] +fn an_installed_symbol_font_draws_its_own_bullet() { + // Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a + // look-alike. Skipped without Wingdings 2. + let db = FontDb::global(); + if !db.has_family("Wingdings 2") { + return; + } + let face = db.face("Wingdings 2", "Regular"); + assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}')); + assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None); +}