Browse / github.com/storytold/wordcraft / patch
Emoji sequences (keycaps, VS-16, ZWJ, flags) keep their colour and stay one glyph
Emoji sequences (keycaps, VS-16, ZWJ, flags) keep their colour and stay one glyph ## What Emoji presentation sequences are shaped as one cluster in a colour emoji font: `1️⃣`, `⚖️`, `👩💻`, flags, skin tones, ZWJ families. Variation selectors, keycaps and joiners no longer disqualify a face, and `render::emoji_image` rasterises a colour emoji cluster into an RGBA texture for UI elements. ## Why - Keycaps and VS-16 sequences were split: the base character went to the text font and the keycap/selector had no glyph, so `1️⃣` came out as a box or as `1` plus a missing sign. - Headings with emoji sequences lost their proper shaping and colour, because sequences were treated ch
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- lucasmelo-mike01 (imported from pull request #4442, open)
- Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3- Size
- +447 −22 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4b549344aca56693a6acc991
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
@@ -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"; @@ -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};@@ -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}'));+ } }@@ -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();new file mode 100644@@ -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");+ }+}@@ -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; @@ -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}");+}