Compare commits

...

2 Commits

Author SHA1 Message Date
Brandon Thomas da5332b5df Lists: draw an installed symbol font's bullet from its own glyph
A bare-code Wingdings bullet drawn as U+25AA came from a fallback face whose taller ascent made
every bulleted line about 1 pt taller than Word's. Where the symbol font is installed, its glyph at
the private-use code is drawn instead, with the font's own metrics.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 06:37:15 -04:00
Brandon Thomas 29ed409282 Lists: Symbol and Wingdings bullets stored as bare codes
Found in Microsoft's own specification documents compared with Word for
Windows.

Before: a bullet whose list level uses Wingdings and stores the
character as its bare code (U+00A7, "§") rather than the private-use
form (U+F0A7) drew a section sign; Word draws Wingdings' small square.

After: the Symbol/Wingdings table also maps codes U+0020-U+00FF, so the
bullet draws the standard character (here U+25AA) wherever the font's
own glyph isn't available.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 06:16:34 -04:00
5 changed files with 55 additions and 7 deletions

View File

@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 2026-10-12 | List bullets in Symbol and Wingdings stored as bare codes (Microsoft's spec documents use `§` in Wingdings) draw Word's square or dot, not `§` |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|

View File

@ -208,11 +208,16 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
}
/// 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.
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet), or as
/// the bare code U+0020–U+00FF (a list level's `§` in Wingdings is its small square bullet, as Word
/// draws it). Used when that font isn't installed: a substitute draws something else, or nothing,
/// there. `None` for other fonts and for codes not in the table.
pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
let code = u32::from(c).checked_sub(0xF000)?;
let code = match u32::from(c) {
u @ 0xF020..=0xF0FF => u - 0xF000,
u @ 0x20..=0xFF => u,
_ => return None,
};
let u = match (family.to_ascii_lowercase().as_str(), code) {
// Adobe Symbol encoding.
("symbol", 0xA7) => '\u{2663}',
@ -311,6 +316,12 @@ mod tests {
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}'));
// The same code stored bare, as Microsoft's own specification documents do.
assert_eq!(symbol_font_char("Wingdings", '\u{A7}'), Some('\u{25AA}'));
assert_eq!(symbol_font_char("Symbol", '\u{B7}'), Some('\u{2022}'));
assert_eq!(symbol_font_char("Calibri", '\u{A7}'), None, "only symbol fonts");
assert_eq!(symbol_font_char("Wingdings", 'A'), None, "codes outside the table stay");
assert_eq!(symbol_font_char("Wingdings", '\u{100}'), None);
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);

View File

@ -1245,9 +1245,15 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
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,
.map(|c| {
// A bare code (`§` = 0xA7) in a symbol font that is installed: its glyph sits at the
// private-use code (U+F0A7); drawn from that font, the line keeps Word's metrics.
let private = (u32::from(c) < 0x100).then(|| char::from_u32(0xF000 + u32::from(c))).flatten();
match (private, wordcraft_fonts::word::symbol_font_char(&rc.font, c)) {
(Some(pc), Some(_)) if !r.substituted && !r.face.covers(c) && r.face.covers(pc) => pc,
(_, Some(u)) if r.substituted || !r.face.covers(c) => u,
_ => c,
}
})
.collect();
let before_c = b.clusters.len();

View File

@ -3948,3 +3948,32 @@ fn footer_page_total_needs_one_body_pass() {
p.insert_object(n, field(), &Default::default()).unwrap();
assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3));
}
/// A Wingdings bullet stored as its bare code (`§` = 0xA7, as Microsoft's specification documents
/// store it) draws Wingdings' small square, as in Word, not a section sign.
#[test]
fn bare_code_wingdings_bullet_draws_its_square() {
let mut d = Document::from_text("one");
let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{A7}'));
for a in &mut d.numbering.abstracts {
if let Some(l) = a.levels.first_mut() {
l.chr.font = Some("Wingdings".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 Wingdings is installed (Windows) its own glyph at the private-use code is drawn,
// which keeps Word's line height; elsewhere the standard square.
let wd = wordcraft_fonts::word::resolve("Wingdings", false, false);
let expected = if wd.substituted {
"\u{25AA}"
} else if wd.face.covers('\u{A7}') {
"\u{A7}"
} else if wd.face.covers('\u{F0A7}') {
"\u{F0A7}"
} else {
"\u{25AA}"
};
assert_eq!(label.text, expected);
}

View File

@ -75,6 +75,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Symbol and Wingdings list bullets stored as bare codes (U+0020–U+00FF) draw their standard characters, as Word does |
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |