Fonts: emoji presentation as PowerPoint draws it; keycaps and text pictographs in a symbol font

Observed on PowerPoint's export of pictographs in Aptos, Arial and Segoe UI:
- characters that default to emoji presentation draw in colour without a selector: ⭐ ✅ ☔ ⌚ ⏩
  and the other BMP Emoji_Presentation characters (DeckCraft only knew the supplementary-plane
  ones and drew ⭐ ✅ as text);
- text-presentation pictographs (⚠ ♥ ✔ ☎ ⬆ ➡ ▶ ⤴) draw as text even where the colour font is the
  first installed font that has them: they now come from a plain symbol font, symbol families
  first, never the colour one; and a symbol after an emoji no longer inherits its colour face;
- keycap sequences ("1️⃣", "#⃣") draw as text, an outlined key, not the colour keycap.

w14-xml s7 (pictographs) 9.79 -> 6.16, w09-scripts s1 (emoji) 11.56 -> 10.27.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 05:57:48 -04:00
parent b8a665f0ca
commit 232501a9f3
5 changed files with 172 additions and 17 deletions

View File

@ -24,20 +24,72 @@ fn ignorable(c: char) -> bool {
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
}
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation
/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an
/// emoji presentation selector (U+FE0F), a zero-width joiner between pictographs, a skin tone
/// modifier or regional indicators (flags)? Keycaps ("1️⃣") are not: PowerPoint draws them as text
/// (an outlined key), as it draws ♥ ✔ ☎ ⬆ ▶ without a selector.
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
if cluster.contains(&'\u{FE0E}') {
if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') {
return false;
}
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
let default_emoji =
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
let default_emoji = |c: char| {
matches!(
c as u32,
0x1F300..=0x1F64F
| 0x1F680..=0x1F6FF
| 0x1F900..=0x1F9FF
| 0x1FA70..=0x1FAFF
| 0x1F1E6..=0x1F1FF
| 0x1F004
| 0x1F0CF
| 0x1F18E
| 0x1F191..=0x1F19A
| 0x1F201
| 0x1F21A
| 0x1F22F
| 0x1F232..=0x1F236
| 0x1F238..=0x1F23A
| 0x1F250..=0x1F251
| 0x231A..=0x231B
| 0x23E9..=0x23EC
| 0x23F0
| 0x23F3
| 0x25FD..=0x25FE
| 0x2614..=0x2615
| 0x2648..=0x2653
| 0x267F
| 0x2693
| 0x26A1
| 0x26AA..=0x26AB
| 0x26BD..=0x26BE
| 0x26C4..=0x26C5
| 0x26CE
| 0x26D4
| 0x26EA
| 0x26F2..=0x26F3
| 0x26F5
| 0x26FA
| 0x26FD
| 0x2705
| 0x270A..=0x270B
| 0x2728
| 0x274C
| 0x274E
| 0x2753..=0x2755
| 0x2757
| 0x2795..=0x2797
| 0x27B0
| 0x27BF
| 0x2B1B..=0x2B1C
| 0x2B50
| 0x2B55
)
};
cluster.iter().any(|&c| default_emoji(c))
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c)))
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|| cluster.contains(&'\u{20E3}')
}
/// Does `face` draw every character of the cluster?
@ -86,12 +138,17 @@ impl FontDb {
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
return f.clone();
}
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
if cluster.contains(&'\u{FE0E}') {
let faces = self.read_faces();
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
// Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one.
// So does a pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) when the colour font
// is the first to have it: PowerPoint draws those from a plain symbol font.
let plain = cluster.contains(&'\u{FE0E}')
|| cluster.contains(&'\u{20E3}')
|| (!is_emoji_cluster(cluster) && cluster.iter().any(|c| matches!(*c as u32, 0x2190..=0x2BFF | 0x1F000..=0x1FAFF)));
if plain
&& let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
&& let Some(f) = self.plain_face_for(key, cluster)
{
return f;
}
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };

View File

@ -1076,6 +1076,79 @@ impl FontDb {
None
}
/// A loaded plain (not colour) face drawing all of `cluster`, loading an installed plain font
/// that covers `key` if none is loaded yet. Misses are remembered.
pub(crate) fn plain_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
// Symbol fonts first (PowerPoint draws ⚠ ♥ ⬆ from the system's symbol font), then the
// usual fallback families in order, then any other.
let rank = |f: &FontFace| {
if f.family.contains("Symbol") {
return 0;
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(i) = SYSTEM_FALLBACKS.iter().position(|s| s.eq_ignore_ascii_case(&f.family)) {
return i + 1;
}
1000
};
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| !f.is_color() && crate::covers_cluster(f, cluster)).collect();
v.sort_by_key(|f| (rank(f), f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
#[cfg(not(target_arch = "wasm32"))]
{
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
}
}
}
if let Some(f) = pick(self) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_plain_fallback(key) {
return pick(self);
}
let _ = key;
None
}
/// Load an installed font without colour tables covering `c`. True if one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn system_plain_fallback(&self, c: char) -> bool {
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
if c.is_control() || c.is_whitespace() {
return false;
}
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
return false;
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
!has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
});
if hit && self.add_font(data) > 0 {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
false
}
/// Load an installed colour font covering `c`. True if one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn system_color_fallback(&self, c: char) -> bool {

View File

@ -141,10 +141,15 @@ mod tests {
#[test]
fn emoji_clusters_are_recognised() {
let c = |s: &str| s.chars().collect::<Vec<char>>();
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
// As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even
// without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and
// keycaps always as text.
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"]
{
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
}
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"]
{
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
}
}

View File

@ -884,7 +884,11 @@ fn shape_cells(
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
let face = match (sym_face, prev.as_ref()) {
(Some(f), _) => f,
(None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
(None, Some(p))
if neutral && !p.is_color() && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) =>
{
p.clone()
}
(None, _) => {
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
db.cascade(cluster, chain, st.style_name)

View File

@ -1118,3 +1118,19 @@ fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() {
let e = &s.lines[0].edges;
assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}");
}
#[test]
fn symbols_after_an_emoji_keep_a_text_face() {
// "✅ ⚠ ™": PowerPoint draws ✅ (emoji presentation) in colour and the symbols after it as text.
let db = FontDb::global();
if !db.cascade(&['✅'], &[], "Regular").is_color() {
return;
}
let (p, sh) = bidi_setup(vec![Run::new("✅ ⚠ ™")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
for r in &l.runs {
for (_, c) in &r.cells {
assert_eq!(r.face.is_color(), *c == '✅', "{c:?} drawn with {}", r.face.family);
}
}
}