collective

Browse / github.com/storytold/wordcraft / patch

Navigation pane: colour emoji in heading labels

Navigation pane: colour emoji in heading labels ## What Heading labels in the Navigation pane draw emoji with the same colour glyphs as the page: an emoji presentation sequence (`⚖️`, `1️⃣`, `👩‍💻`, flags) is rasterised from the system's colour emoji font once and drawn as an image inside the row; everything else stays text. When the platform has no colour emoji font, or the character is text presentation (`❤︎`), the row keeps the interface font's glyph exactly as before. ## Why The outline used to show a box or a black-and-white glyph wherever the page showed a colour emoji, because the interface fonts are monochrome: the same heading lo

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasmelo-mike01 (imported from pull request #4463, open)
Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3
Size
+518 −24 in 8 files
Review
pending
Shared
2026-10-28
Id
9d9b4c55b9d0064c19eda848

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts/src/fontdb.rs crates/fonts/src/lib.rs crates/fonts/src/script.rs crates/layout/src/para.rs crates/render/src/emoji.rs crates/render/src/lib.rs crates/render/src/tests_color_glyphs.rs crates/ui-egui/src/panes.rs

diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs--- a/crates/fonts/src/fontdb.rs+++ b/crates/fonts/src/fontdb.rs@@ -1757,13 +1757,17 @@ impl FontDb {     /// loaded face covers a character. `None` when no single face covers them all, and the caller     /// keeps the per-character fallback.     ///+    /// Default-ignorables (variation selectors, joiners) take no glyph of their own: they neither+    /// choose a face nor disqualify one, so an emoji sequence can be given a colour emoji face+    /// even though that face maps no variation selector.+    ///     /// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces     /// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.     pub fn fallback_for_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {-        if chars.is_empty() {+        let mut set: Vec<char> = chars.iter().copied().filter(|c| !crate::is_default_ignorable(*c)).collect();+        if set.is_empty() {             return None;         }-        let mut set: Vec<char> = chars.to_vec();         set.sort_unstable();         set.dedup();         let key = (set, exclude, bold, italic);@@ -1791,6 +1795,33 @@ impl FontDb {         found     } +    /// A face drawing an emoji presentation sequence (`1️⃣`, `⚖️`, `👩‍💻`) as the one emoji it is:+    /// the first colour emoji family that maps every character of the sequence the shaper draws+    /// (the variation selectors and joiners are the shaper's own), else the run's fallback face.+    /// `None` when no loaded face covers the characters.+    ///+    /// Not cached: the colour families are tried first, a handful of lookups, and the run cache+    /// under them already keeps the search cheap.+    pub fn color_emoji_face(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {+        let need: Vec<char> = chars.iter().copied().filter(|c| !c.is_control() && !c.is_whitespace() && !crate::is_default_ignorable(*c)).collect();+        if need.is_empty() {+            return None;+        }+        let style = match (bold, italic) {+            (true, true) => "Bold Italic",+            (true, false) => "Bold",+            (false, true) => "Italic",+            (false, false) => "Regular",+        };+        for family in crate::COLOR_EMOJI {+            let face = self.face(family, style);+            if face.family.eq_ignore_ascii_case(family) && need.iter().all(|c| face.covers(*c)) {+                return Some(face);+            }+        }+        self.fallback_for_run(&need, exclude, bold, italic)+    }+     /// The face for a run, and the number of loaded faces the search last chose from.     fn search_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> (Option<Arc<FontFace>>, usize) {         #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]@@ -2156,6 +2187,31 @@ mod tests {         assert!(db.run_fallbacks.read().unwrap().map.is_empty());     } +    #[test]+    #[cfg(not(target_arch = "wasm32"))]+    fn an_emoji_sequence_prefers_a_colour_emoji_face() {+        let db = FontDb::global();+        let colour = crate::COLOR_EMOJI.iter().find(|f| db.face(f, "Regular").family.eq_ignore_ascii_case(f)).copied();+        let Some(colour) = colour else {+            eprintln!("skipped: no colour emoji font installed");+            return;+        };+        let primary = db.face(FALLBACK_FAMILY, "Regular");+        // "1️⃣": the keycap is the digit and the enclosing mark; the variation selector takes no+        // glyph of its own, so a face that maps the two characters draws it as one emoji.+        let keycap: Vec<char> = "1\u{FE0F}\u{20E3}".chars().collect();+        let face = db.color_emoji_face(&keycap, primary.id(), false, false).expect("a colour emoji face");+        assert!(face.family.eq_ignore_ascii_case(colour), "{} is not {colour}", face.family);+        assert!(face.covers('1') && face.covers('\u{20E3}'));+        // The variation selector neither chooses a face nor disqualifies one.+        assert_eq!(+            db.fallback_for_run(&['1', '\u{FE0F}'], primary.id(), false, false).map(|f| f.id()),+            db.fallback_for_run(&['1'], primary.id(), false, false).map(|f| f.id())+        );+        // A text-presentation cluster is not an emoji sequence and keeps the ordinary fallback.+        assert!(!crate::emoji_sequence(&"\u{2764}\u{FE0E}".chars().collect::<Vec<_>>()));+    }+     /// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.     const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50"; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs--- a/crates/fonts/src/lib.rs+++ b/crates/fonts/src/lib.rs@@ -21,7 +21,7 @@ pub use fontdb::{ pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath;-pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};+pub use script::{COLOR_EMOJI, emoji_sequence, fallback_rank, is_default_ignorable, is_emoji, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs--- a/crates/fonts/src/script.rs+++ b/crates/fonts/src/script.rs@@ -94,7 +94,32 @@ pub fn script_fallbacks(c: char) -> &'static [&'static str] {  /// The platforms' colour emoji fonts. Emoji prefer them over the monochrome symbol fonts that /// also cover many emoji (Windows' Segoe UI Symbol has ✅, so it drew in black and white).-const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];+pub const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];++/// Characters a shaper draws no glyph for of its own: variation selectors, the joiners, the tags+/// and the other default-ignorables. A face that lacks one is not missing a glyph, so they never+/// choose a face and never disqualify one ([`FontFace::covers`](crate::FontFace::covers)).+pub fn is_default_ignorable(c: char) -> bool {+    matches!(+        u32::from(c),+        0x00AD // soft hyphen+            | 0x034F // combining grapheme joiner+            | 0x061C // Arabic letter mark+            | 0x115F..=0x1160 // Hangul fillers+            | 0x17B4..=0x17B5 // Khmer inherent vowels+            | 0x180B..=0x180F // Mongolian free variation selectors, vowel separator+            | 0x200B..=0x200F // zero-width space, joiners, direction marks+            | 0x202A..=0x202E // bidi embedding and override controls+            | 0x2060..=0x206F // word joiner, invisible operators, bidi isolates+            | 0x3164 // Hangul filler+            | 0xFE00..=0xFE0F // variation selectors 1–16+            | 0xFEFF // zero-width no-break space+            | 0xFFA0 // halfwidth Hangul filler+            | 0x1BCA0..=0x1BCA3 // shorthand format controls+            | 0x1D173..=0x1D17A // musical format controls+            | 0xE0000..=0xE0FFF // tags and variation selectors supplement+    )+}  /// Characters drawn as emoji by default (Unicode `Emoji_Presentation`): the BMP ones listed in /// emoji-data.txt, and the pictograph blocks from U+1F000.@@ -139,6 +164,38 @@ pub fn is_emoji(c: char) -> bool {     ) } +/// Whether these characters, one grapheme cluster, are an emoji **presentation sequence**: the+/// text asks for an emoji drawing of characters that are not emoji by themselves — a variation+/// selector-16 (`⚖️`), a keycap (`1️⃣`), a ZWJ sequence, a flag or a skin-toned emoji. Such a+/// cluster is shaped as one emoji in a colour emoji font, whose ligature draws the whole thing;+/// the text font would draw the base and leave the keycap or the joiner without a glyph.+///+/// `false` for text presentation (`❤︎`, U+FE0E) and for a lone emoji, which+/// [`is_emoji`] already sends to a colour emoji face character by character.+pub fn emoji_sequence(chars: &[char]) -> bool {+    let mut selectors = 0u8;+    let mut emoji = false;+    let mut regional = 0usize;+    for c in chars.iter().copied() {+        emoji |= is_emoji(c);+        match u32::from(c) {+            0xFE0F => selectors |= 1,             // VARIATION SELECTOR-16: emoji presentation+            0x20E3 => selectors |= 2,             // COMBINING ENCLOSING KEYCAP+            0x200D => selectors |= 4,             // ZERO WIDTH JOINER+            0x1F3FB..=0x1F3FF => selectors |= 8,  // emoji modifier (skin tone)+            0xE0020..=0xE007F => selectors |= 16, // tag characters (subdivision flags)+            0x1F1E6..=0x1F1FF => regional += 1,   // regional indicators (flags)+            _ => {}+        }+    }+    selectors & 1 != 0+        || selectors & 2 != 0+        || (selectors & 4 != 0 && emoji)+        || regional >= 2+        || (selectors & 8 != 0 && emoji)+        || (selectors & 16 != 0 && emoji)+}+ /// How well `family` suits `c` as a fallback: 0 for the first preferred family, then the others /// in order; then families named after the script ("Noto Sans Tamil"); then everything else. pub fn fallback_rank(c: char, family: &str) -> usize {@@ -195,4 +252,33 @@ mod tests {             assert!(!is_emoji(c), "{c}");         }     }++    #[test]+    fn emoji_presentation_sequences_are_recognised() {+        let seq = |s: &str| emoji_sequence(&s.chars().collect::<Vec<_>>());+        // Keycaps (with and without the variation selector), emoji presentation of a text-default+        // symbol, a ZWJ sequence, a flag, a skin tone and a tag sequence.+        assert!(seq("1\u{FE0F}\u{20E3}"));+        assert!(seq("1\u{20E3}"));+        assert!(seq("\u{2696}\u{FE0F}"));+        assert!(seq("\u{1F469}\u{200D}\u{1F4BB}"));+        assert!(seq("\u{1F1E7}\u{1F1F7}"));+        assert!(seq("\u{1F44D}\u{1F3FD}"));+        assert!(seq("\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"));+        // Text presentation, plain text and a lone joiner are not sequences.+        assert!(!seq("\u{2764}\u{FE0E}"));+        assert!(!seq("1"));+        assert!(!seq("Tema central"));+        assert!(!seq("\u{1F4D8}"));+        assert!(!seq("\u{200D}"));+    }++    #[test]+    fn default_ignorables_never_choose_a_face() {+        assert!(is_default_ignorable('\u{FE0F}') && is_default_ignorable('\u{FE0E}'));+        assert!(is_default_ignorable('\u{200D}'));+        // The enclosing keycap is drawn by the emoji's ligature: it does need a face.+        assert!(!is_default_ignorable('\u{20E3}'));+        assert!(!is_default_ignorable('1') && !is_default_ignorable('\u{1F4D8}'));+    } }diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs--- a/crates/layout/src/para.rs+++ b/crates/layout/src/para.rs@@ -626,11 +626,17 @@ struct SegKey {     complex: bool,     /// Odd bidi level: shaped right to left.     rtl: bool,+    /// A cluster drawn as one emoji in a colour emoji face (see [`emoji_sequence`]).+    emoji: bool, }  impl SegKey {     fn same(&self, o: &SegKey) -> bool {-        self.face.map(|f| f.id()) == o.face.map(|f| f.id()) && self.small == o.small && self.complex == o.complex && self.rtl == o.rtl+        self.face.map(|f| f.id()) == o.face.map(|f| f.id())+            && self.small == o.small+            && self.complex == o.complex+            && self.rtl == o.rtl+            && self.emoji == o.emoji     } } @@ -981,40 +987,72 @@ impl<'a> Builder<'a> {             }         };         // Split by font coverage (fallback faces), small caps case, script formatting and direction.+        // Emoji presentation sequences ("1️⃣", "⚖️", "👩‍💻") are drawn as the one emoji they are:+        // the whole cluster takes a colour emoji face covering it, where the text face would draw+        // its base and leave the keycap (U+20E3) or the joined emoji without a glyph.+        let clusters = wordcraft_doc::cluster::cluster_starts(text);+        let mut cluster = 0usize;+        let mut cluster_end = 0usize;+        let mut cluster_face: Option<FaceRef> = None;         let mut seg_start = 0;         let mut cur: Option<SegKey> = None;         let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();         for (i, c) in text.char_indices() {             let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1);+            if i >= cluster_end {+                while clusters.get(cluster + 1).is_some_and(|s| *s <= i) {+                    cluster += 1;+                }+                let start = clusters.get(cluster).copied().unwrap_or(i);+                cluster_end = clusters.get(cluster + 1).copied().unwrap_or(text.len());+                // A cluster of plain ASCII cannot be an emoji sequence: no allocation, no scan.+                let cluster_text = text.get(start..cluster_end).unwrap_or("");+                cluster_face = (!cluster_text.is_ascii())+                    .then(|| {+                        let chars: Vec<char> = cluster_text.chars().collect();+                        wordcraft_fonts::emoji_sequence(&chars)+                            .then(|| wordcraft_fonts::FontDb::global().color_emoji_face(&chars, primary.id(), rc.bold, rc.italic))+                            .flatten()+                    })+                    .flatten()+                    .map(|f| FaceRef::of(&f));+            }+            // An emoji cluster keeps its own face and its digits as typed: a numeral mode that+            // rewrote them would break the keycap the shaper draws.+            let emoji = cluster_face.filter(|_| i < cluster_end);+            let prev_emoji = cur.is_some_and(|k| k.emoji);             // A symbol-font character becomes its Unicode symbol (#776).             let c = show(c);             // Displayed form (Word › Numeral): one char in, one char out. Coverage and the             // fallback face are picked for the displayed char, so Hindi digits land in an             // Arabic-capable face and Western digits in a Latin one.-            let dc = self.numeral_char(&context, i, c, rtl);+            let dc = if emoji.is_some() { c } else { self.numeral_char(&context, i, c, rtl) };             let mark = is_mark(c);             let complex = match cur {                 Some(k) if mark => k.complex,                 _ => rc_cs.is_some() && rc.uses_complex(dc),             };             let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };-            let face = match cur {-                Some(k) if mark => k.face,-                _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,-                // The run's one substitute face for a missing Thai font (#1549).-                _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,-                _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,-                // Stay in the fallback face the word started in while it covers the letters.-                Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),-                // The run's unified East Asian face, when one face covers the whole run.-                _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,-                _ => {-                    let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };-                    wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))-                }+            let face = match emoji {+                Some(f) => Some(f),+                None => match cur {+                    Some(k) if mark => k.face,+                    _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,+                    // The run's one substitute face for a missing Thai font (#1549).+                    _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,+                    _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,+                    // Stay in the fallback face the word started in while it covers the letters.+                    Some(SegKey { face: Some(f), .. }) if !prev_emoji && f.covers(dc) => Some(f),+                    // The run's unified East Asian face, when one face covers the whole run.+                    _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,+                    _ => {+                        let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };+                        wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))+                    }+                },             };             let small = rc.small_caps && !rc.caps && c.is_lowercase();-            let k = SegKey { face, small, complex, rtl };+            let k = SegKey { face, small, complex, rtl, emoji: emoji.is_some() };             match cur {                 Some(p) if p.same(&k) => {}                 Some(p) => {@@ -1047,7 +1085,7 @@ impl<'a> Builder<'a> {             // Displayed text: numeral substitution is 1 char → 1 char and a capital may be             // several; `map_starts` holds where each document char's displayed form starts and             // `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel).-            let substituted = expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);+            let substituted = !key.emoji && (expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c));             let mut mapped = String::new();             let mut map_starts: Vec<usize> = Vec::new();             let mut orig_of: Vec<usize> = Vec::new();diff --git a/crates/render/src/emoji.rs b/crates/render/src/emoji.rsnew file mode 100644--- /dev/null+++ b/crates/render/src/emoji.rs@@ -0,0 +1,192 @@+//! Colour emoji for the interface: the same emoji fonts and colour-glyph path the page draws with+//! ([`crate::color_glyph`]), rasterised into a small straight-alpha RGBA image that the UI can show+//! as a texture. The Navigation pane uses it for the emoji in heading labels, which the interface+//! fonts only have monochrome.++use std::sync::Arc;++use vello_cpu::kurbo::{Affine, Rect};+use vello_cpu::{PixmapMut, Resources};++use crate::color_glyph::{self, Drawable};+use crate::context_pool::CONTEXTS;++/// A colour emoji raster: straight (not premultiplied) RGBA, row-major, top-down.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct EmojiImage {+    pub width: u32,+    pub height: u32,+    pub rgba: Vec<u8>,+}++/// The largest side an emoji raster may have, pixels: the UI asks for a line's height, not more.+const MAX_SIDE: u32 = 512;++/// `text` (an emoji or an emoji sequence such as `⚖️` or `👩‍💻`) drawn from a colour emoji font,+/// about `px` pixels tall (the em square), cropped to its ink, in straight-alpha RGBA. `None` when+/// the system has no colour emoji font or the glyphs carry no colour data, so the caller keeps the+/// interface font's monochrome glyph.+pub fn emoji_image(text: &str, px: u32) -> Option<EmojiImage> {+    let px = px.clamp(4, MAX_SIDE) as f64;+    let chars: Vec<char> = text.chars().collect();+    if chars.iter().all(|c| c.is_whitespace() || c.is_control()) {+        return None;+    }+    let face = wordcraft_fonts::FontDb::global().color_emoji_face(&chars, u32::MAX, false, false)?;+    // A monochrome face (the run fallback when no colour emoji family is installed) would draw a+    // flat silhouette: the interface font's own glyph is better than that.+    if !wordcraft_fonts::COLOR_EMOJI.contains(&face.family.as_str()) {+        return None;+    }+    let glyphs = wordcraft_fonts::shape(&face, text, &[], |c| c);+    if glyphs.is_empty() {+        return None;+    }+    let upem = face.upem;+    if !(upem.is_finite() && upem > 0.0) {+        return None;+    }+    let scale = px / upem;++    // Drawables with their pen positions (font units, y down from the baseline), and the union of+    // their boxes: the image is sized from that, then cropped to the ink.+    let mut pen = 0.0f64;+    let mut placed: Vec<(Arc<Drawable>, (f64, f64))> = Vec::new();+    let mut bounds: Option<Rect> = None;+    for g in &glyphs {+        let origin = (pen + g.x_offset as f64, g.y_offset as f64);+        if let Some(d) = color_glyph::drawable(&face, g.gid, px, true) {+            let b = match &*d {+                Drawable::Bitmap { rect, .. } => Rect::new(rect.x0 + origin.0, rect.y0 + origin.1, rect.x1 + origin.0, rect.y1 + origin.1),+                // COLR glyphs have no bitmap box: take the em square, trimmed later by the ink.+                Drawable::Layers(_) => Rect::new(origin.0 - 0.1 * upem, origin.1 - 0.95 * upem, origin.0 + 1.1 * upem, origin.1 + 0.1 * upem),+            };+            bounds = Some(match bounds {+                Some(u) => u.union(b),+                None => b,+            });+            placed.push((d, origin));+        }+        pen += g.x_advance as f64;+    }+    let b = bounds?;+    // A wide sequence (a ZWJ family at 512 px) can need more than MAX_SIDE: shrink the drawing so+    // its ink fits the image instead of being clipped at the right or bottom edge.+    let raw_w = b.width() * scale + 2.0;+    let raw_h = b.height() * scale + 2.0;+    let shrink = (MAX_SIDE as f64 / raw_w).min(MAX_SIDE as f64 / raw_h).min(1.0);+    let scale = scale * shrink;+    let w = (b.width() * scale).ceil() + 2.0;+    let h = (b.height() * scale).ceil() + 2.0;+    if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) {+        return None;+    }+    let (w, h) = ((w as u32).clamp(1, MAX_SIDE), (h as u32).clamp(1, MAX_SIDE));++    let mut ctx = CONTEXTS.take(w as u16, h as u16);+    ctx.set_transform(Affine::IDENTITY);+    // Font units (pen at the origin) → the image, with one pixel of margin.+    let to_image = Affine::translate((1.0 - b.x0 * scale, 1.0 - b.y0 * scale)) * Affine::scale(scale);+    for (d, origin) in &placed {+        color_glyph::draw(&mut ctx, d, to_image * Affine::translate(*origin), upem, vello_cpu::peniko::Color::WHITE, 1.0);+    }+    ctx.flush();+    let mut pixels = vec![0u8; w as usize * h as usize * 4];+    let mut res = Resources::new();+    if let Some(pm) = PixmapMut::new(w as u16, h as u16, &mut pixels) {+        ctx.render(pm, &mut res);+    }+    CONTEXTS.give_back(ctx);++    crop_straight(&pixels, w, h)+}++/// Crop premultiplied RGBA rows to the ink and un-premultiply them.+fn crop_straight(pixels: &[u8], w: u32, h: u32) -> Option<EmojiImage> {+    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);+    for y in 0..h {+        for x in 0..w {+            if pixels[((y * w + x) * 4 + 3) as usize] != 0 {+                x0 = x0.min(x);+                y0 = y0.min(y);+                x1 = x1.max(x);+                y1 = y1.max(y);+            }+        }+    }+    if x0 == u32::MAX {+        return None;+    }+    let (cw, ch) = (x1 - x0 + 1, y1 - y0 + 1);+    let mut rgba = Vec::with_capacity((cw * ch * 4) as usize);+    for y in y0..=y1 {+        for x in x0..=x1 {+            let i = ((y * w + x) * 4) as usize;+            let a = pixels[i + 3] as u32;+            let straight = |v: u8| if a == 0 || a == 255 { v } else { ((v as u32 * 255 + a / 2) / a).min(255) as u8 };+            rgba.extend_from_slice(&[straight(pixels[i]), straight(pixels[i + 1]), straight(pixels[i + 2]), a as u8]);+        }+    }+    Some(EmojiImage { width: cw, height: ch, rgba })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn has_colour_emoji() -> bool {+        wordcraft_fonts::FontDb::global().color_emoji_face(&['📘'], u32::MAX, false, false).is_some()+    }++    /// A colour emoji font, when the system has one, gives a non-empty image with actual colour in+    /// it — what the Navigation pane paints over the interface font's monochrome glyph.+    #[test]+    fn a_colour_emoji_rasterises_in_colour() {+        if !has_colour_emoji() {+            return;+        }+        let img = emoji_image("📘", 32).expect("the blue book rasterises");+        assert!(img.width > 4 && img.height > 4, "cropped to the ink: {img:?}");+        assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);+        let colourful = img.rgba.chunks(4).any(|p| {+            p[3] > 200 && {+                let (hi, lo) = (*p[0..3].iter().max().unwrap(), *p[0..3].iter().min().unwrap());+                hi - lo > 40+            }+        });+        assert!(colourful, "the book is blue, not grey");+    }++    #[test]+    fn several_markers_rasterise() {+        if !has_colour_emoji() {+            return;+        }+        for m in ["📘", "🔄", "⚖️", "📊", "📝"] {+            assert!(emoji_image(m, 20).is_some(), "{m} must rasterise");+        }+    }++    /// Junk input and text-only strings give no image: the caller keeps the monochrome glyph.+    #[test]+    fn text_and_blank_strings_have_no_image() {+        assert!(emoji_image("   ", 16).is_none());+        assert!(emoji_image("", 16).is_none());+        if has_colour_emoji() {+            assert!(emoji_image("conceito", 16).is_none(), "letters are not a colour emoji");+        }+    }++    /// The size asked for is respected (the em square, about), and nonsense sizes are clamped+    /// instead of asking vello for a huge pixmap.+    #[test]+    fn the_requested_size_is_respected() {+        if !has_colour_emoji() {+            return;+        }+        let small = emoji_image("📘", 8).expect("small");+        let big = emoji_image("📘", 96).expect("big");+        assert!(big.height > small.height, "bigger size, taller image: {small:?} {big:?}");+        assert!(emoji_image("📘", 0).is_some(), "clamped, not zero");+    }+}diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -17,6 +17,7 @@ use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_layers}; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg};  mod color_glyph;+mod emoji; pub mod icon_lib;  /// Worker threads for rasterising (0 on the web, where there are no threads).@@ -575,6 +576,8 @@ pub struct RenderOptions {     pub read_paper: Option<Rgb>, } +pub use emoji::{EmojiImage, emoji_image};+ /// Default grey for a dark page's paper. pub const DARK_PAPER: u8 = 0x33; diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs--- a/crates/render/src/tests_color_glyphs.rs+++ b/crates/render/src/tests_color_glyphs.rs@@ -98,3 +98,53 @@ fn emoji_page_with(opts: &RenderOptions, cp: wordcraft_doc::CharProps) -> Option     assert_eq!(boxes.len(), 2, "both emoji use the colour emoji font");     Some((img, boxes)) }++/// A keycap sequence (`1️⃣`) is drawn as the one colour emoji it is: the emoji font's ligature+/// draws digit and enclosing keycap together, where the text font leaves a missing-glyph box+/// after the digit. Skips when no colour emoji font is installed.+#[cfg(not(target_arch = "wasm32"))]+#[test]+fn a_keycap_is_drawn_as_a_colour_emoji() {+    let keycap = "1\u{FE0F}\u{20E3}";+    let chars: Vec<char> = keycap.chars().collect();+    let colour = FontDb::global().color_emoji_face(&chars, u32::MAX, false, false);+    if !colour.is_some_and(|f| COLOR_EMOJI.contains(&f.family.as_str())) {+        eprintln!("skipped: no colour emoji font installed");+        return;+    }+    let mut d = Document::from_text("x");+    d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("{keycap} Tema"), wordcraft_doc::CharProps::default()))];+    let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());+    let page = &l.pages[0];+    let scale = 2.0;+    let img = render_page(&d, page, scale, &RenderOptions::default());+    let mut box_of = None;+    for it in page_display(&d, page, &DisplayOptions::default()) {+        if let Draw::Glyphs { face, size, glyphs, text, .. } = it+            && COLOR_EMOJI.contains(&face.family.as_str())+        {+            // One glyph draws the whole keycap: its ligature, not the digit alone.+            assert_eq!(glyphs.len(), 1, "keycap drawn with {} glyphs in {}", glyphs.len(), face.family);+            assert_eq!(text, keycap, "the colour emoji draws the keycap text");+            let (_, x, y) = glyphs[0];+            let pad = 6.0;+            box_of = Some((+                ((x * scale - pad).max(0.0) as u32),+                (((y - size) * scale - pad).max(0.0) as u32),+                ((x + size) * scale + pad) as u32,+                (y * scale + pad) as u32,+            ));+        }+    }+    let (x0, y0, x1, y1) = box_of.expect("the keycap is drawn from the colour emoji font");+    // The keycap's badge is blue: its blue channel runs well above its red one, where the paper+    // and the black text are grey.+    let blue = (x0..x1)+        .flat_map(|x| (y0..y1).map(move |y| (x, y)))+        .filter(|(x, y)| {+            let p = img.pixel(*x, *y);+            i32::from(p[2]) - i32::from(p[0]) > 20+        })+        .count();+    assert!(blue > 30, "only {blue} blue pixels in the keycap box {x0},{y0}..{x1},{y1}");+}diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs--- a/crates/ui-egui/src/panes.rs+++ b/crates/ui-egui/src/panes.rs@@ -603,6 +603,57 @@ fn nav_headings(doc: &Document) -> Vec<(Path, u8, String)> {     items } +/// The heading's label as atoms: a colour emoji cluster is drawn from the system emoji font as an+/// image (the interface fonts are monochrome, so the outline used to show a box or a black-and-+/// white glyph where the page showed colour), the rest stays text. The texture is rasterised once+/// per (cluster, size) and cached on the context.+fn nav_label_atoms<'a>(ui: &mut Ui, text: &str, level: u8) -> egui::Atoms<'a> {+    let font = if level == 0 { semibold(12.5) } else { regular(12.5) };+    let px = 14;+    let mut atoms: Vec<egui::Atom<'a>> = Vec::new();+    let mut run = String::new();+    let flush = |run: &mut String, atoms: &mut Vec<egui::Atom<'a>>| {+        if !run.is_empty() {+            atoms.push(egui::Atom::from(egui::RichText::new(std::mem::take(run)).font(font.clone())));+        }+    };+    let starts = wordcraft_doc::cluster::cluster_starts(text);+    for (i, start) in starts.iter().copied().enumerate() {+        let end = starts.get(i + 1).copied().unwrap_or(text.len());+        let cluster = text.get(start..end).unwrap_or("");+        match nav_emoji_atom(ui, cluster, px) {+            Some(atom) => {+                flush(&mut run, &mut atoms);+                atoms.push(atom);+            }+            None => run.push_str(cluster),+        }+    }+    flush(&mut run, &mut atoms);+    atoms.push(egui::Atom::grow());+    egui::Atoms::from(atoms)+}++/// A colour emoji image atom for a grapheme cluster, when the cluster is an emoji presentation+/// sequence and a colour emoji font draws it; `None` for text and for sequences without colour.+fn nav_emoji_atom<'a>(ui: &mut Ui, cluster: &str, px: u32) -> Option<egui::Atom<'a>> {+    let chars: Vec<char> = cluster.chars().collect();+    if !wordcraft_fonts::emoji_sequence(&chars) {+        return None;+    }+    let key = egui::Id::unique(("nav_emoji", cluster, px));+    if let Some((tex, size)) = ui.data(|d| d.get_temp::<(egui::TextureHandle, egui::Vec2)>(key)) {+        return Some(egui::Image::new((tex.id(), size)).into());+    }+    let image = wordcraft_render::emoji_image(cluster, px)?;+    let size = egui::vec2(image.width as f32, image.height as f32);+    let pixels = egui::ColorImage::from_rgba_unmultiplied([image.width as usize, image.height as usize], &image.rgba);+    let tex = ui.ctx().load_texture(format!("nav_emoji/{cluster}/{px}"), pixels, egui::TextureOptions::LINEAR);+    let atom = egui::Image::new((tex.id(), size)).into();+    ui.data_mut(|d| d.insert_temp(key, (tex, size)));+    Some(atom)+}+ /// Constrain each heading to the available pane width so long document text cannot /// enlarge the panel. A heading too long for the width wraps onto further lines — a word /// wider than the pane is broken too — and the row grows with it, so the label reads whole@@ -624,13 +675,13 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Op             ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));             toggled = tri.clicked();         }-        let label = egui::RichText::new(text).font(if level == 0 { semibold(12.5) } else { regular(12.5) });+        let atoms = nav_label_atoms(ui, text, level);         let width = ui.available_width();         let r = ui.add(             // The trailing grow atom takes the spare width, so the text starts flush left             // under its parent instead of centred, as in Word (#1199). A minimum height             // keeps short headings on one row; a wrapped one becomes as tall as it needs.-            egui::Button::selectable(selected, (label, egui::Atom::grow())).wrap().min_size(vec2(width, ui.spacing().interact_size.y)),+            egui::Button::selectable(selected, atoms).wrap().min_size(vec2(width, ui.spacing().interact_size.y)),         );         (toggled, r)     })@@ -1330,6 +1381,24 @@ mod tests {         }     } +    /// A heading with a colour emoji shows it as an image atom, because the interface fonts are+    /// monochrome; a plain heading stays text (plus the trailing grow atom). Skips the image+    /// half when the platform has no colour emoji font.+    #[test]+    fn nav_labels_draw_colour_emoji_as_images() {+        let has_colour = wordcraft_fonts::FontDb::global()+            .fallback_for('\u{2696}', u32::MAX)+            .is_some_and(|f| wordcraft_fonts::COLOR_EMOJI.contains(&f.family.as_str()));+        egui::__run_test_ui(|ui| {+            let plain = super::nav_label_atoms(ui, "Base legal", 0);+            assert_eq!(plain.len(), 2, "text + grow: {plain:?}");+            if has_colour {+                let marked = super::nav_label_atoms(ui, "\u{2696}\u{FE0F} Base legal", 0);+                assert_eq!(marked.len(), 3, "image + text + grow: {marked:?}");+            }+        });+    }+     /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's     /// text field keeps Ctrl+H for itself.     #[test]