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)
|
|| 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
|
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation
|
||||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an
|
||||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
/// 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 {
|
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||||
if cluster.contains(&'\u{FE0E}') {
|
if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||||
let default_emoji =
|
let default_emoji = |c: char| {
|
||||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
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.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{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||||
|| cluster.contains(&'\u{20E3}')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Does `face` draw every character of the cluster?
|
/// 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)) {
|
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||||
return f.clone();
|
return f.clone();
|
||||||
}
|
}
|
||||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
// Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one.
|
||||||
if cluster.contains(&'\u{FE0E}') {
|
// So does a pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) when the colour font
|
||||||
let faces = self.read_faces();
|
// is the first to have it: PowerPoint draws those from a plain symbol font.
|
||||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
let plain = cluster.contains(&'\u{FE0E}')
|
||||||
return f.clone();
|
|| 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 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() };
|
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||||
|
|
|
||||||
|
|
@ -1076,6 +1076,79 @@ impl FontDb {
|
||||||
None
|
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.
|
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn system_color_fallback(&self, c: char) -> bool {
|
fn system_color_fallback(&self, c: char) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -141,10 +141,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn emoji_clusters_are_recognised() {
|
fn emoji_clusters_are_recognised() {
|
||||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
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:?}");
|
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:?}");
|
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -884,7 +884,11 @@ fn shape_cells(
|
||||||
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
|
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
|
||||||
let face = match (sym_face, prev.as_ref()) {
|
let face = match (sym_face, prev.as_ref()) {
|
||||||
(Some(f), _) => f,
|
(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, _) => {
|
(None, _) => {
|
||||||
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
|
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
|
||||||
db.cascade(cluster, chain, st.style_name)
|
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;
|
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:?}");
|
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