From ba56742fa9ad75264d9e8f237651f857eba2a266 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:48:51 -0400 Subject: [PATCH] Emoji: drawn in colour, with families, skin tones and flags joined (#278) * Emoji: colour glyphs, and emoji take the colour emoji font Emoji were drawn as black outlines from a symbol font, with ZWJ sequences (families, flags) falling apart into their parts. Now: - an emoji cluster (emoji presentation, U+FE0F, ZWJ between pictographs, skin tones, regional indicators, keycaps) takes an installed colour font (COLR, CBDT, sbix or SVG tables) before the document's fonts, so its sequences ligate as in PowerPoint; U+FE0E keeps a plain face; - COLR v0 and v1 glyphs are painted in their CPAL colours (solid fills, linear, radial and sweep gradients, transforms, composite modes), with palette index 0xFFFF in the text colour. A tiny COLRv0 test font (two squares, CC0, regenerated by make_colr_font.py) backs the render test. Co-Authored-By: Claude Opus 5.5 (1M context) * Render: lock skrifa for colour glyphs Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ATTRIBUTION.md | 2 + Cargo.lock | 1 + crates/fonts/src/fallback.rs | 35 +++ crates/fonts/src/fontdb.rs | 70 ++++++ crates/fonts/src/lib.rs | 21 +- crates/render/Cargo.toml | 1 + crates/render/src/colr.rs | 212 ++++++++++++++++++ crates/render/src/lib.rs | 8 +- crates/render/src/tests.rs | 26 +++ crates/render/tests/fixtures/colr-squares.ttf | Bin 0 -> 892 bytes .../render/tests/fixtures/make_colr_font.py | 51 +++++ crates/text/src/lib.rs | 4 +- crates/text/src/tests.rs | 19 ++ 13 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/colr.rs create mode 100644 crates/render/tests/fixtures/colr-squares.ttf create mode 100644 crates/render/tests/fixtures/make_colr_font.py diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c8137dfb..b0d7747e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | +| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | +| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font | ## Bundled through dependencies diff --git a/Cargo.lock b/Cargo.lock index 8518da8f..65028e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1108,6 +1108,7 @@ dependencies = [ "image", "kurbo", "log", + "skrifa 0.47.0", "vello_common 0.2.0", "vello_cpu 0.2.0", ] diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index 99eb5baf..f4e1100c 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -24,6 +24,22 @@ 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? +pub fn is_emoji_cluster(cluster: &[char]) -> bool { + if cluster.contains(&'\u{FE0E}') { + 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); + 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{200D}') && cluster.iter().any(|&c| pictographic(c))) + || cluster.contains(&'\u{20E3}') +} + /// Does `face` draw every character of the cluster? pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool { cluster.iter().all(|c| ignorable(*c) || face.covers(*c)) @@ -55,9 +71,28 @@ impl FontDb { /// bundled face for its script, else an installed face for the script, else any loaded /// face that draws it, else `chain[0]` (or the default face if `chain` is empty). pub fn cascade(&self, cluster: &[char], chain: &[Arc], style: &str) -> Arc { + // Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the + // document's fonts, as PowerPoint draws them, even when a text font has a plain glyph. + if is_emoji_cluster(cluster) { + if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) { + return f.clone(); + } + if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) + && let Some(f) = self.color_face_for(key, cluster) + { + return f; + } + } 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(); + } + } 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 script = cluster.iter().find_map(|c| script_of(*c)); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8b2a1ec8..d9c9ece7 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -91,6 +91,8 @@ pub struct FontFace { pub instance: Option, /// Basic Multilingual Plane coverage bitset, built on first use. bmp: std::sync::OnceLock>, + /// Colour glyph tables present (`is_color`), looked up on first use. + color: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -158,6 +160,11 @@ impl FontFace { pub fn skrifa(&self) -> Option> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji + /// fonts have)? + pub fn is_color(&self) -> bool { + *self.color.get_or_init(|| has_color_tables(self.data(), self.index)) + } pub fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -258,6 +265,13 @@ struct SysFallback { misses: std::collections::HashSet, } +/// Does face `index` of `data` carry colour glyph tables? +fn has_color_tables(data: &[u8], index: u32) -> bool { + use skrifa::raw::types::Tag; + let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false }; + [b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some()) +} + /// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. #[cfg(not(target_arch = "wasm32"))] const SYSTEM_FALLBACKS: &[&str] = &[ @@ -496,6 +510,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords bytes, index, bmp: std::sync::OnceLock::new(), + color: std::sync::OnceLock::new(), }) } @@ -902,6 +917,61 @@ impl FontDb { false } + /// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers + /// `key` if none is loaded yet. Misses are remembered. + pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option> { + let pick = |db: &FontDb| { + let faces = db.read_faces(); + let mut v: Vec<&Arc> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect(); + v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); + v.first().map(|f| (*f).clone()) + }; + if let Some(f) = pick(self) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_color_fallback(key) { + return pick(self); + } + let _ = key; + None + } + + /// 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 { + static MISSES: Mutex>> = Mutex::new(None); + if c.is_control() || c.is_whitespace() { + return false; + } + { + let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if !sys.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 = 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() > 64 << 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 + } + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 74ce9211..8caff942 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -13,7 +13,7 @@ mod fontdb; pub mod script; mod shape; -pub use fallback::{bundled_families, covers_cluster, ligature_carets}; +pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs}; pub use harfrust::Feature; use harfrust::Tag; @@ -136,6 +136,25 @@ mod tests { FontDb::global().face(DEFAULT_FAMILY, "Regular") } + #[test] + fn emoji_clusters_are_recognised() { + let c = |s: &str| s.chars().collect::>(); + for yes in ["😀", "đŸ‘đŸœ", "❀\u{FE0F}", "👹\u{200D}đŸ‘©\u{200D}👧", "đŸ‡ŻđŸ‡”", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] { + assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); + } + for no in ["a", "1", "❀", "â˜ș\u{FE0E}", "😀\u{FE0E}", "歗", "→"] { + assert!(!is_emoji_cluster(&c(no)), "{no:?}"); + } + } + + #[test] + fn colour_fonts_are_recognised() { + let db = FontDb::with_font_dirs(Vec::new()); + assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); + assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); + assert!(!fontdb::last_resort_face().is_color()); + } + #[test] fn last_resort_face_parses() { let f = fontdb::last_resort_face(); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 6eecf625..ccb98ea1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,6 +19,7 @@ vello_common = { workspace = true } image = { workspace = true } log = { workspace = true } kurbo = { workspace = true } +skrifa = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] vello_cpu = { workspace = true, features = ["multithreading"] } diff --git a/crates/render/src/colr.rs b/crates/render/src/colr.rs new file mode 100644 index 00000000..97294aa8 --- /dev/null +++ b/crates/render/src/colr.rs @@ -0,0 +1,212 @@ +//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw +//! them: layers of glyph outlines filled with solid colours or gradients. + +use deckcraft_color::Rgba; +use deckcraft_fonts::FontFace; +use kurbo::{Affine, BezPath, Point, Rect}; +use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform}; +use skrifa::raw::types::BoundingBox; +use skrifa::{GlyphId, MetadataProvider}; +use vello_cpu::{RenderContext, peniko}; + +/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts). +const MAX_DEPTH: usize = 64; + +/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale` +/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all. +/// False when the glyph has no colour version, so the caller draws the plain outline. +pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool { + let Some(font) = face.skrifa() else { return false }; + let glyphs = font.color_glyphs(); + let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false }; + let palette: Vec = font + .color_palettes() + .get(0) + .map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect()) + .unwrap_or_default(); + // Font units are y up; the slide is y down. + let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0); + let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() }; + p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None); + let ok = glyph.paint(face.location(), &mut p).is_ok(); + // A malformed glyph may leave layers open: close them all. + while let Some(open) = p.opened.pop() { + if open { + p.ctx.pop_layer(); + } + } + p.ctx.pop_layer(); + ok +} + +struct Painter<'a> { + ctx: &'a mut RenderContext, + face: &'a FontFace, + base: Affine, + /// Transforms pushed by the glyph (font units), innermost last. + stack: Vec, + palette: Vec, + fg: Rgba, + /// Clips and composite groups pushed by the glyph, innermost last: true when a layer was + /// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too). + opened: Vec, +} + +fn affine(t: &Transform) -> Affine { + Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64]) +} + +fn pt(p: skrifa::raw::types::Point) -> Point { + Point::new(p.x as f64, p.y as f64) +} + +fn extend(e: Extend) -> peniko::Extend { + match e { + Extend::Repeat => peniko::Extend::Repeat, + Extend::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Pad, + } +} + +fn blend(m: CompositeMode) -> peniko::BlendMode { + use peniko::{Compose, Mix}; + let (mix, compose) = match m { + CompositeMode::Clear => (Mix::Normal, Compose::Clear), + CompositeMode::Src => (Mix::Normal, Compose::Copy), + CompositeMode::Dest => (Mix::Normal, Compose::Dest), + CompositeMode::DestOver => (Mix::Normal, Compose::DestOver), + CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn), + CompositeMode::DestIn => (Mix::Normal, Compose::DestIn), + CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut), + CompositeMode::DestOut => (Mix::Normal, Compose::DestOut), + CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop), + CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop), + CompositeMode::Xor => (Mix::Normal, Compose::Xor), + CompositeMode::Plus => (Mix::Normal, Compose::Plus), + CompositeMode::Screen => (Mix::Screen, Compose::SrcOver), + CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver), + CompositeMode::Darken => (Mix::Darken, Compose::SrcOver), + CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver), + CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver), + CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver), + CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver), + CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver), + CompositeMode::Difference => (Mix::Difference, Compose::SrcOver), + CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver), + CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver), + CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver), + CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver), + CompositeMode::HslColor => (Mix::Color, Compose::SrcOver), + CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver), + _ => (Mix::Normal, Compose::SrcOver), + }; + peniko::BlendMode::new(mix, compose) +} + +impl Painter<'_> { + fn current(&self) -> Affine { + self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY) + } + + fn color(&self, index: u16, alpha: f32) -> peniko::Color { + let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) }; + let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8; + peniko::Color::from_rgba8(c.r, c.g, c.b, a) + } + + fn stops(&self, stops: &[ColorStop]) -> Vec { + stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect() + } + + /// The glyph outline in font units, y up. + fn outline(&self, gid: GlyphId) -> BezPath { + let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32()); + Affine::scale_non_uniform(1.0, -1.0) * (*p).clone() + } + + fn push_clip(&mut self, path: &BezPath) { + let open = self.opened.len() < MAX_DEPTH; + if open { + self.ctx.set_transform(self.current()); + self.ctx.push_clip_layer(path); + } + self.opened.push(open); + } + + fn pop(&mut self) { + if self.opened.pop() == Some(true) { + self.ctx.pop_layer(); + } + } + + /// Fill the open clip area (a large box in the current transform) with `paint`. + fn cover(&mut self, paint: impl Into) { + self.ctx.set_transform(self.current()); + self.ctx.set_paint(paint); + self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5)); + } +} + +impl ColorPainter for Painter<'_> { + fn push_transform(&mut self, t: Transform) { + let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t); + self.stack.push(next); + } + + fn pop_transform(&mut self) { + if self.stack.len() > 1 { + self.stack.pop(); + } + } + + fn push_clip_glyph(&mut self, gid: GlyphId) { + let path = self.outline(gid); + self.push_clip(&path); + } + + fn push_clip_box(&mut self, b: BoundingBox) { + let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64); + self.push_clip(&kurbo::Shape::to_path(&r, 0.1)); + } + + fn pop_clip(&mut self) { + self.pop(); + } + + fn fill(&mut self, brush: Brush<'_>) { + match brush { + Brush::Solid { palette_index, alpha } => { + let c = self.color(palette_index, alpha); + self.cover(c); + } + Brush::LinearGradient { p0, p1, color_stops, extend: e } => { + let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e)); + self.cover(g); + } + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => { + let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0)) + .with_stops(self.stops(color_stops).as_slice()) + .with_extend(extend(e)); + self.cover(g); + } + Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { + let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians()) + .with_stops(self.stops(color_stops).as_slice()) + .with_extend(extend(e)); + self.cover(g); + } + } + } + + fn push_layer(&mut self, mode: CompositeMode) { + let open = self.opened.len() < MAX_DEPTH; + if open { + self.ctx.push_blend_layer(blend(mode)); + } + self.opened.push(open); + } + + fn pop_layer(&mut self) { + self.pop(); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 29f60c00..33bec34b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod chart; +mod colr; mod images; mod morph_path; mod morph_text; @@ -1147,11 +1148,16 @@ pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { return; } + let k = run.size / run.face.upem.max(1.0); + // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still + // strokes the plain glyph below. + if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { + return; + } let path = glyph(&run.face, gid); if path.elements().is_empty() { return; } - let k = run.size / run.face.upem.max(1.0); let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; ctx.set_transform(at * skew * Affine::scale(k)); if alpha > 0.0 && run.color.a > 0 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 936dce3f..15f70167 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -632,3 +632,29 @@ fn a_background_is_laid_over_white() { let g = img.pixel(20, 20); assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}"); } + +#[test] +fn colour_glyphs_paint_their_layers() { + // A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue + // square in its middle, U+E001 a square in the text colour. + deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec()); + let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("\u{E000} \u{E001}"); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(72.0); + props.font = Some("DeckCraft Colr Squares".into()); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0)))); + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count(); + let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30); + let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230); + let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30); + // 72 pt squares: the blue one is 40 % of the side (16 % of the area). + assert!(red > 2500 && blue > 500, "red {red} blue {blue}"); + let share = blue as f64 / (red + blue) as f64; + assert!((share - 0.16).abs() < 0.03, "blue share {share}"); + // The text-colour glyph is as big as the other square. + assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue); +} diff --git a/crates/render/tests/fixtures/colr-squares.ttf b/crates/render/tests/fixtures/colr-squares.ttf new file mode 100644 index 0000000000000000000000000000000000000000..546942b00940464791059c92ce437d70b50b0382 GIT binary patch literal 892 zcmb7CL2DC16#iyslPHwbN-aeY)>6bnz_y2aFk)pZs1z#65722cNlIp$bdx}P>mR7} z57dJwcoa__3SN5Ci~k^sNa(RBco5fbCP`WGAU^iZyzhH&-+MD}AOK8Y4-zZk`dSG& z+y-Qk?6vUG-8Cf1MdB*)%zF98(mmX~1c>{@Pb!gWzdH>MiQf_z>t?t1{P+cJ&nK=n zw5l$A&R(befx6rvk^3l57;pXaji|dL2ecqwamkxjMd2Y9h`(7Jshu{?Axms?1Y0W7 zFZbWvV*Up4e!JD_&P{%KO8kR(74|IDWBvZw z@@%!4(jr{$EPS`KVA|P|bG7|W zv$nmbVV9oGm?XqOpF>PO1)hr{F_H<)VjfFG?zHY!m~V0F>XfyA&i+3y3Zmd0c}KP{ z!92tVR)4iw#mDlf1A&=wRo-Ndh$ryR(_6rmk?kRm2dlc$3}aR624Ty@L3z8UV%;g} zde5ks1J|gai4ZXqYUuK!LbPCr1C+6i9{HG29hA7%iH&1e{qG5qObK5dr0|3kzR2Wa wUEMG}EjPECa$~ce=|svHM%XNQ>ScCqZO`q(y@CwS p.clone(), _ => { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f8011417..cd78b903 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -583,3 +583,22 @@ fn closing_punctuation_hangs_past_the_end_with_hanging_punct() { let lines = wrapped(text, w, |p| p.hanging_punct = Some(false)); assert_eq!(lines[0], "癜ăȘă—ă§ç¶šăăŸ", "{lines:?}"); } + +#[test] +fn emoji_take_a_colour_face_when_one_is_installed() { + // PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font + // even when the run's font has a plain glyph for some of them. Skipped without an installed + // colour emoji font. + let db = FontDb::global(); + let probe = db.cascade(&['😀'], &[], "Regular"); + if !probe.is_color() { + return; + } + for s in ["😀", "đŸ‘đŸœ", "❀\u{FE0F}", "👹\u{200D}đŸ‘©\u{200D}👧"] { + let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left); + let l = lay(&p, &sh, 2000.0, 300.0); + let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap(); + assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family); + assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face"); + } +}