Bullets: a symbol the text font lacks falls back to a font that has it (#284)

Circled-number lists (circleNumDbPlain) and character bullets whose glyph
isn't in the paragraph's font were drawn as missing-glyph boxes (numbers)
or always from Inter (characters). Both now go through the usual font
fallback, as PowerPoint draws them.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 17:54:25 -04:00 committed by GitHub
parent 2bf62c97e2
commit d7fbbaeab1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 36 additions and 5 deletions

View File

@ -492,9 +492,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
bullet_face(db, &mut st, &ch);
Some((ch, st))
}
Some(Bullet::AutoNum { scheme, start_at }) => {
@ -504,8 +502,10 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
let st = make_style(ctx, &rp, scale, opts.prompt_color);
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
}
@ -513,6 +513,15 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
/// and other symbols missing from the text font), by the usual fallback.
fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
let chars: Vec<char> = label.chars().collect();
if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
}
}
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
/// script, face and shaping style, each shaped with the whole paragraph as context.
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.

View File

@ -602,3 +602,25 @@ fn emoji_take_a_colour_face_when_one_is_installed() {
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
}
}
#[test]
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
// bundled face has ①.
let db = FontDb::global();
if !db.cascade(&['①'], &[], "Regular").covers('①') {
return;
}
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
assert_eq!(bullets.len(), 2);
for b in bullets {
assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family);
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
}
}