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:
parent
b8a665f0ca
commit
232501a9f3
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue