From 0edb87e3da96fbb40591eeaa20fd80a347acf440 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:48:57 +0200 Subject: [PATCH] draw Symbol and Wingdings bullets as Unicode when the font is missing (#29) (#37) --- crates/fonts/src/word.rs | 41 ++++++++++++++++++++++++++++++++++++++ crates/layout/src/para.rs | 15 ++++++++++++-- crates/layout/src/tests.rs | 20 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 1b259be72..55630dc4f 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -87,6 +87,34 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { r } +/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores +/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used +/// when that font isn't installed: a substitute draws something else, or nothing, at the +/// private-use code. `None` for other fonts and for codes not in the table. +pub fn symbol_font_char(family: &str, c: char) -> Option { + let code = u32::from(c).checked_sub(0xF000)?; + let u = match (family.to_ascii_lowercase().as_str(), code) { + // Adobe Symbol encoding. + ("symbol", 0xA7) => '\u{2663}', + ("symbol", 0xA8) => '\u{2666}', + ("symbol", 0xA9) => '\u{2665}', + ("symbol", 0xAA) => '\u{2660}', + ("symbol", 0xAE) => '\u{2192}', + ("symbol", 0xB7) => '\u{2022}', + ("symbol", 0xD7) => '\u{22C5}', + ("symbol", 0xE0) => '\u{25CA}', + // Wingdings characters common as list bullets. + ("wingdings", 0x6C) => '\u{25CF}', + ("wingdings", 0x6E) => '\u{25A0}', + ("wingdings", 0x76) => '\u{2756}', + ("wingdings", 0xA7) => '\u{25AA}', + ("wingdings", 0xD8) => '\u{27A2}', + ("wingdings", 0xFC) => '\u{2714}', + _ => return None, + }; + Some(u) +} + /// Line metrics for a face in font units: (ascent, descent), both positive, the way word /// processors measure single line spacing (OS/2 usWinAscent/usWinDescent, falling back to the /// hhea values plus line gap). @@ -131,6 +159,19 @@ mod tests { assert!(a > 0.0 && d >= 0.0); } + #[test] + fn symbol_font_chars_map_to_unicode() { + assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}')); + assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}')); + assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}')); + assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}')); + // Same code, other font; unknown codes; ordinary characters. + assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None); + assert_eq!(symbol_font_char("Symbol", '\u{F041}'), None); + assert_eq!(symbol_font_char("Symbol", 'a'), None); + assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None); + } + #[test] fn synthesizes_missing_styles() { // JetBrains Mono ships only Regular here. diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 479858d8b..a734d2e33 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -499,9 +499,20 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let label = env.label.as_ref().map(|(text, lvl)| { let lc = p.mark.clone().overlaid(&lvl.chr); let rc = resolve(&lc); + // A Symbol/Wingdings bullet (U+F0B7…) without that font installed, or in a face that has + // no glyph at the private-use code: draw the standard character instead, since a + // substitute has another glyph, or none, there. + let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); + let text: String = text + .chars() + .map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) { + Some(u) if r.substituted || !r.face.covers(c) => u, + _ => c, + }) + .collect(); let before_c = b.clusters.len(); let before_g = b.glyphs.len(); - b.shape(text, 0, &rc, None); + b.shape(&text, 0, &rc, None); let cl: Vec = b.clusters.drain(before_c..).collect(); let mut glyphs = Vec::new(); let mut x = 0.0; @@ -514,7 +525,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { x += c.adv; } b.glyphs.truncate(before_g); - Label { text: text.clone(), style, glyphs, x: 0.0, width: x } + Label { text, style, glyphs, x: 0.0, width: x } }); let mut pl = ParaLayout { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cc35e6487..650772b12 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -120,6 +120,26 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn symbol_font_bullet_without_the_font_draws_a_bullet() { + // Word's default bullet: U+F0B7 in Symbol. + let mut d = Document::from_text("one"); + let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.chr.font = Some("Symbol".into()); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let l = lay(&d); + let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap(); + // Where a real Symbol font with the private-use code is installed (Windows), the code stays. + let symbol = wordcraft_fonts::word::resolve("Symbol", false, false); + let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" }; + assert_eq!(label.text, expected); + assert_eq!(label.glyphs.len(), 1); +} + #[test] fn tables_lay_out_cells() { let mut d = Document::from_text("before\nafter");