Repository: zng-ui/zng (language: rust)

Issue:
Implement SVG emoji
Some fonts define emojis using SVGs.

This will take a while, first we need to implement SVG rendering.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cba1a762..3de509ca5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,14 @@
 # Unreleased
 
+* Implement support for raster and svg emojis.
+* Add `FontFace::ttf` to quickly access the full parsed TTF data.
+* Add `has_raster_images` and `has_svg_images` method to `FontFace` and `ShapedText`.
 * Add software render in macOS.
 * Fix software render in Wayland.
 
 # 0.12.3
 
-* Fix close button icon in Wayland instances without any close icon available.
+* Fix close button icon in Wayland instances without any close icon available
 * Add `IMAGES.image_task` to load async image sources.
 * Implement support for SVG images.
     - Add `zng-ext-svg`.
diff --git a/crates/zng-app/src/render.rs b/crates/zng-app/src/render.rs
index 9e4f388bb..0173e1ea6 100644
--- a/crates/zng-app/src/render.rs
+++ b/crates/zng-app/src/render.rs
@@ -1915,6 +1915,16 @@ impl FrameBuilder {
         self.debug_dot_overlays.extend(nested.debug_dot_overlays);
     }
 
+    /// External render requests for this frame.
+    pub fn render_widgets(&self) -> &Arc<RenderUpdates> {
+        &self.render_widgets
+    }
+
+    /// External render update requests for this frame.
+    pub fn render_update_widgets(&self) -> &Arc<RenderUpdates> {
+        &self.render_update_widgets
+    }
+
     /// Finalizes the build.
     pub fn finalize(self, info_tree: &WidgetInfoTree) -> BuiltFrame {
         info_tree.root().bounds_info().set_rendered(
diff --git a/crates/zng-app/src/widget/node/match_node.rs b/crates/zng-app/src/widget/node/match_node.rs
index 7f1b12f29..56f356a3e 100644
--- a/crates/zng-app/src/widget/node/match_node.rs
+++ b/crates/zng-app/src/widget/node/match_node.rs
@@ -762,7 +762,7 @@ pub fn match_widget<W: UiNode>(child: W, closure: impl FnMut(&mut MatchWidgetChi
                     .unwrap_or(false)
                 {
                     // this is likely an error, but a child widget could have requested info again
-                    tracing::warn!(target: "match_widget-pending", "pending info build after info delegated");
+                    tracing::warn!(target: "match_widget-pending", "pending info build after info delegated in {:?}", WIDGET.id());
                 }
             }
         }
@@ -803,7 +803,7 @@ pub fn match_widget<W: UiNode>(child: W, closure: impl FnMut(&mut MatchWidgetChi
                 if size != PxSize::zero() {
                     // this is an error because the child will be measured if the return size is zero,
                     // flagging delegated ensure consistent behavior.
-                    tracing::error!("measure changed size without flagging delegated");
+                    tracing::error!("measure changed size without flagging delegated in {:?}", WIDGET.id());
                     return size;
                 }
 
@@ -823,7 +823,7 @@ pub fn match_widget<W: UiNode>(child: W, closure: impl FnMut(&mut MatchWidgetChi
                 if size != PxSize::zero() {
                     // this is an error because the child will be layout if the return size is zero,
                     // flagging delegated ensure consistent behavior.
-                    tracing::error!("layout changed size without flagging delegated");
+                    tracing::error!("layout changed size without flagging delegated in {:?}", WIDGET.id());
                     return size;
                 }
 
@@ -840,7 +840,7 @@ pub fn match_widget<W: UiNode>(child: W, closure: impl FnMut(&mut MatchWidgetChi
                     .unwrap_or(false)
                 {
                     // this is likely an error, but a child widget could have requested layout again,
-                    tracing::warn!(target: "match_widget-pending", "pending layout after layout delegated");
+                    tracing::warn!(target: "match_widget-pending", "pending layout after layout delegated in {:?}", WIDGET.id());
                 }
                 size
             }
@@ -865,7 +865,7 @@ pub fn match_widget<W: UiNode>(child: W, closure: impl FnMut(&mut MatchWidgetChi
                     .unwrap_or(false)
                 {
                     // this is likely an error, but a child widget could have requested render again,
-                    tracing::warn!(target: "match_widget-pending", "pending render after render delegated");
+                    tracing::warn!(target: "match_widget-pending", "pending render after render delegated in {:?}", WIDGET.id());
                 }
             }
         }
@@ -889,7 +889,7 @@ pub fn match_widget<W: UiNode>(child: W, closure: impl FnMut(&mut MatchWidgetChi
                     .unwrap_or(false)
                 {
                     // this is likely an error, but a child widget could have requested render_update again,
-                    tracing::warn!(target: "match_widget-pending", "pending render_update after render_update delegated");
+                    tracing::warn!(target: "match_widget-pending", "pending render_update after render_update delegated in {:?}", WIDGET.id());
                 }
             }
         }
diff --git a/crates/zng-ext-font/Cargo.toml b/crates/zng-ext-font/Cargo.toml
index 35517fd75..5557eea08 100644
--- a/crates/zng-ext-font/Cargo.toml
+++ b/crates/zng-ext-font/Cargo.toml
@@ -17,6 +17,12 @@ keywords = ["gui", "ui", "user-interface", "zng"]
 # See the `hyphenation` for more details.
 hyphenation_embed_all = ["hyphenation/embed_all"]
 
+# Enable support for SVG image glyphs.
+#
+# This feature signals that the `IMAGES` service supports SVG. The app must ensure that this is so by
+# using `zng-ext-svg` or another SVG extension.
+svg = []
+
 [dependencies]
 zng-clone-move = { path = "../zng-clone-move", version = "0.2.5" }
 zng-app-context = { path = "../zng-app-context", version = "0.5.9" }
@@ -28,6 +34,7 @@ zng-var = { path = "../zng-var", version = "0.5.6" }
 zng-task = { path = "../zng-task", version = "0.4.6" }
 zng-layout = { path = "../zng-layout", version = "0.2.27" }
 zng-color = { path = "../zng-color", version = "0.3.12" }
+zng-ext-image = { path = "../zng-ext-image", version = "0.2.34" }
 
 serde = "1.0"
 unicase = "2.7"
diff --git a/crates/zng-ext-font/src/lib.rs b/crates/zng-ext-font/src/lib.rs
index 3de1aca0d..ce8cb8637 100644
--- a/crates/zng-ext-font/src/lib.rs
+++ b/crates/zng-ext-font/src/lib.rs
@@ -871,17 +871,25 @@ struct LoadedFontFace {
     display_name: FontName,
     family_name: FontName,
     postscript_name: Option<Txt>,
-    is_monospace: bool,
     style: FontStyle,
     weight: FontWeight,
     stretch: FontStretch,
     metrics: FontFaceMetrics,
     color_palettes: ColorPalettes,
     color_glyphs: ColorGlyphs,
-    has_ligatures: bool,
     lig_carets: LigatureCaretList,
+    flags: FontFaceFlags,
     m: Mutex<FontFaceMut>,
 }
+bitflags! {
+    #[derive(Debug, Clone, Copy)]
+    struct FontFaceFlags: u8 {
+        const IS_MONOSPACE =      0b0000_0001;
+        const HAS_LIGATURES =     0b0000_0010;
+        const HAS_RASTER_IMAGES = 0b0000_0100;
+        const HAS_SVG_IMAGES =    0b0000_1000;
+    }
+}
 struct FontFaceMut {
     instances: HashMap<FontInstanceKey, Font>,
     render_ids: Vec<RenderFontFace>,
@@ -895,7 +903,7 @@ impl fmt::Debug for FontFace {
             .field("display_name", &self.0.display_name)
             .field("family_name", &self.0.family_name)
             .field("postscript_name", &self.0.postscript_name)
-            .field("is_monospace", &self.0.is_monospace)
+            .field("flags", &self.0.flags)
             .field("style", &self.0.style)
             .field("weight", &self.0.weight)
             .field("stretch", &self.0.stretch)
@@ -923,7 +931,7 @@ impl FontFace {
             display_name: FontName::from("<empty>"),
             family_name: FontName::from("<empty>"),
             postscript_name: None,
-            is_monospace: true,
+            flags: FontFaceFlags::IS_MONOSPACE,
             style: FontStyle::Normal,
             weight: FontWeight::NORMAL,
             stretch: FontStretch::NORMAL,
@@ -942,7 +950,6 @@ impl FontFace {
             },
             color_palettes: ColorPalettes::empty(),
             color_glyphs: ColorGlyphs::empty(),
-            has_ligatures: false,
             lig_carets: LigatureCaretList::empty(),
             m: Mutex::new(FontFaceMut {
                 instances: HashMap::default(),
@@ -985,7 +992,6 @@ impl FontFace {
                         style: other_font.0.style,
                         weight: other_font.0.weight,
                         stretch: other_font.0.stretch,
-                        is_monospace: other_font.0.is_monospace,
                         metrics: other_font.0.metrics.clone(),
                         m: Mutex::new(FontFaceMut {
                             instances: Default::default(),
@@ -994,8 +1000,8 @@ impl FontFace {
                         }),
                         color_palettes: other_font.0.color_palettes.clone(),
                         color_glyphs: other_font.0.color_glyphs.clone(),
-                        has_ligatures: other_font.0.has_ligatures,
                         lig_carets: other_font.0.lig_carets.clone(),
+                        flags: other_font.0.flags,
                     }))),
                     None => Err(FontLoadingError::NoSuchFontInCollection),
                 };
@@ -1031,6 +1037,18 @@ impl FontFace {
             LigatureCaretList::load(ttf_face.raw_face())?
         };
 
+        // all tables used by `ttf_parser::Face::glyph_raster_image`
+        let has_raster_images = {
+            let t = ttf_face.tables();
+            t.sbix.is_some() || t.bdat.is_some() || t.ebdt.is_some() || t.cbdt.is_some()
+        };
+
+        let mut flags = FontFaceFlags::empty();
+        flags.set(FontFaceFlags::IS_MONOSPACE, ttf_face.is_monospaced());
+        flags.set(FontFaceFlags::HAS_LIGATURES, has_ligatures);
+        flags.set(FontFaceFlags::HAS_RASTER_IMAGES, has_raster_images);
+        flags.set(FontFaceFlags::HAS_SVG_IMAGES, ttf_face.tables().svg.is_some());
+
         Ok(FontFace(Arc::new(LoadedFontFace {
             face_index,
             display_name: custom_font.name.clone(),
@@ -1039,11 +1057,9 @@ impl FontFace {
             style: custom_font.style,
             weight: custom_font.weight,
             stretch: custom_font.stretch,
-            is_monospace: ttf_face.is_monospaced(),
             metrics: ttf_face.into(),
             color_palettes,
             color_glyphs,
-            has_ligatures,
             lig_carets,
             m: Mutex::new(FontFaceMut {
                 instances: Default::default(),
@@ -1051,6 +1067,7 @@ impl FontFace {
                 unregistered: Default::default(),
             }),
             data: bytes,
+            flags,
         })))
     }
 
@@ -1125,6 +1142,18 @@ impl FontFace {
             return Err(FontLoadingError::UnknownFormat);
         }
 
+        // all tables used by `ttf_parser::Face::glyph_raster_image`
+        let has_raster_images = {
+            let t = ttf_face.tables();
+            t.sbix.is_some() || t.bdat.is_some() || t.ebdt.is_some() || t.cbdt.is_some()
+        };
+
+        let mut flags = FontFaceFlags::empty();
+        flags.set(FontFaceFlags::IS_MONOSPACE, ttf_face.is_monospaced());
+        flags.set(FontFaceFlags::HAS_LIGATURES, has_ligatures);
+        flags.set(FontFaceFlags::HAS_RASTER_IMAGES, has_raster_images);
+        flags.set(FontFaceFlags::HAS_SVG_IMAGES, ttf_face.tables().svg.is_some());
+
         Ok(FontFace(Arc::new(LoadedFontFace {
             face_index,
             family_name,
@@ -1133,11 +1162,9 @@ impl FontFace {
             style: ttf_face.style().into(),
             weight: ttf_face.weight().into(),
             stretch: ttf_face.width().into(),
-            is_monospace: ttf_face.is_monospaced(),
             metrics: ttf_face.into(),
             color_palettes,
             color_glyphs,
-            has_ligatures,
             lig_carets,
             m: Mutex::new(FontFaceMut {
                 instances: Default::default(),
@@ -1145,6 +1172,7 @@ impl FontFace {
                 unregistered: Default::default(),
             }),
             data: bytes,
+            flags,
         })))
     }
 
@@ -1177,7 +1205,7 @@ impl FontFace {
 
     /// Loads the harfbuzz face.
     ///
-    /// Loads from the cached [`bytes`].
+    /// Loads from in memory [`bytes`].
     ///
     /// Returns `None` if [`is_empty`].
     ///
@@ -1191,10 +1219,30 @@ impl FontFace {
         }
     }
 
+    /// Loads the full TTF face.
+    ///
+    /// Loads from in memory [`bytes`].
+    ///
+    /// Returns `None` if [`is_empty`].
+    ///
+    /// [`is_empty`]: Self::is_empty
+    /// [`bytes`]: Self::bytes
+    pub fn ttf(&self) -> Option<ttf_parser::Face> {
+        if self.is_empty() {
+            None
+        } else {
+            Some(ttf_parser::Face::parse(&self.0.data.0, self.0.face_index).unwrap())
+        }
+    }
+
     /// Reference the font file bytes.
     pub fn bytes(&self) -> &FontDataRef {
         &self.0.data
     }
+    /// Index of the font face in the [font file](Self::bytes).
+    pub fn index(&self) -> u32 {
+        self.0.face_index
+    }
 
     /// Font full name.
     pub fn display_name(&self) -> &FontName {
@@ -1211,11 +1259,6 @@ impl FontFace {
         self.0.postscript_name.as_deref()
     }
 
-    /// Index of the font face in the [font file](Self::bytes).
-    pub fn index(&self) -> u32 {
-        self.0.face_index
-    }
-
     /// Font style.
     pub fn style(&self) -> FontStyle {
         self.0.style
@@ -1233,7 +1276,7 @@ impl FontFace {
 
     /// Font is monospace (fixed-width).
     pub fn is_monospace(&self) -> bool {
-        self.0.is_monospace
+        self.0.flags.contains(FontFaceFlags::IS_MONOSPACE)
     }
 
     /// Font metrics in font units.
@@ -1303,7 +1346,7 @@ impl FontFace {
 
     /// If the font provides glyph substitutions.
     pub fn has_ligatures(&self) -> bool {
-        self.0.has_ligatures
+        self.0.flags.contains(FontFaceFlags::HAS_LIGATURES)
     }
 
     /// If this font provides custom positioned carets for some or all ligature glyphs.
@@ -1313,6 +1356,16 @@ impl FontFace {
     pub fn has_ligature_caret_offsets(&self) -> bool {
         !self.0.lig_carets.is_empty()
     }
+
+    /// If this font has bitmap images associated with some glyphs.
+    pub fn has_raster_images(&self) -> bool {
+        self.0.flags.contains(FontFaceFlags::HAS_RASTER_IMAGES)
+    }
+
+    /// If this font has SVG images associated with some glyphs.
+    pub fn has_svg_images(&self) -> bool {
+        self.0.flags.contains(FontFaceFlags::HAS_SVG_IMAGES)
+    }
 }
 
 /// A sized font face.
@@ -1424,6 +1477,8 @@ impl Font {
     }
 
     /// Font size.
+    ///
+    /// This is also the *pixels-per-em* value.
     pub fn size(&self) -> Px {
         self.0.size
     }
diff --git a/crates/zng-ext-font/src/shaping.rs b/crates/zng-ext-font/src/shaping.rs
index f002f468d..7f33353d8 100644
--- a/crates/zng-ext-font/src/shaping.rs
+++ b/crates/zng-ext-font/src/shaping.rs
@@ -2,15 +2,18 @@ use std::{
     cmp, fmt,
     hash::{BuildHasher, Hash},
     mem, ops,
+    sync::Arc,
 };
 
 use zng_app::widget::info::InlineSegmentInfo;
+use zng_ext_image::{ImageDataFormat, ImageSource, ImageVar, IMAGES};
 use zng_ext_l10n::{lang, Lang};
 use zng_layout::{
     context::{InlineConstraintsLayout, InlineConstraintsMeasure, InlineSegmentPos, LayoutDirection, TextSegmentKind},
     unit::{euclid, Align, Factor2d, FactorUnits, Px, PxBox, PxConstraints2d, PxPoint, PxRect, PxSize},
 };
 use zng_txt::Txt;
+use zng_var::{AnyVar, Var as _};
 use zng_view_api::font::{GlyphIndex, GlyphInstance};
 
 use crate::{
@@ -288,6 +291,19 @@ impl FontRangeVec {
     }
 }
 
+#[derive(Clone)]
+struct GlyphImage(ImageVar);
+impl PartialEq for GlyphImage {
+    fn eq(&self, other: &Self) -> bool {
+        self.0.var_ptr() == other.0.var_ptr()
+    }
+}
+impl fmt::Debug for GlyphImage {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "GlyphImage(_)")
+    }
+}
+
 /// Output of [text layout].
 ///
 /// [text layout]: Font::shape_text
@@ -301,6 +317,8 @@ pub struct ShapedText {
     segments: GlyphSegmentVec,
     lines: LineRangeVec,
     fonts: FontRangeVec,
+    // sorted map of `glyphs` index -> image.
+    images: Vec<(u32, GlyphImage)>,
 
     line_height: Px,
     line_spacing: Px,
@@ -353,6 +371,25 @@ pub enum ShapedColoredGlyphs<'a> {
     },
 }
 
+/// Represents normal and image glyphs in [`ShapedText::image_glyphs`].
+pub enum ShapedImageGlyphs<'a> {
+    /// Sequence of not image glyphs.
+    Normal(&'a [GlyphInstance]),
+    /// Image glyph.
+    Image {
+        /// Origin and size of the image in the shaped text.
+        ///
+        /// The size is empty is the image has not loaded yet.
+        rect: euclid::Rect<f32, Px>,
+        /// The glyph that is replaced by `img`.
+        ///
+        /// Must be used as fallback if the `img` cannot be rendered.
+        base_glyph: GlyphIndex,
+        /// The image.
+        img: &'a ImageVar,
+    },
+}
+
 impl ShapedText {
     /// New empty text.
     pub fn new(font: &Font) -> Self {
@@ -388,6 +425,11 @@ impl ShapedText {
         self.has_colored_glyphs
     }
 
+    /// If the shaped text has any Emoji glyph associated with a pixel image.
+    pub fn has_images(&self) -> bool {
+        !self.images.is_empty()
+    }
+
     /// Glyphs by font and palette color.
     pub fn colored_glyphs(&self) -> impl Iterator<Item = (&Font, ShapedColoredGlyphs)> {
         ColoredGlyphsIter {
@@ -404,6 +446,27 @@ impl ShapedText {
         }
     }
 
+    /// Glyphs by font and associated image.
+    pub fn image_glyphs(&self) -> impl Iterator<Item = (&Font, ShapedImageGlyphs)> {
+        ImageGlyphsIter {
+            glyphs: self.glyphs(),
+            glyphs_i: 0,
+            images: &self.images,
+            maybe_img: None,
+        }
+    }
+
+    /// Glyphs in a range by font and associated image.
+    pub fn image_glyphs_slice(&self, range: impl ops::RangeBounds<usize>) -> impl Iterator<Item = (&Font, ShapedImageGlyphs)> {
+        let range = IndexRange::from_bounds(range);
+        ImageGlyphsIter {
+            glyphs_i: range.start() as _,
+            glyphs: self.glyphs_slice_impl(range),
+            images: &self.images,
+            maybe_img: None,
+        }
+    }
+
     /// Glyphs by font in the range.
     fn glyphs_range(&self, range: IndexRange) -> impl Iterator<Item = (&Font, &[GlyphInstance])> {
         self.fonts.iter_glyphs_clip(range).map(|(f, r)| (f, &self.glyphs[r.iter()]))
@@ -1007,6 +1070,7 @@ impl ShapedText {
                 font: self.fonts.font(0).clone(),
                 end: 0,
             }]),
+            images: vec![],
             orig_line_height: self.orig_line_height,
             orig_line_spacing: self.orig_line_spacing,
             orig_first_line: PxSize::zero(),
@@ -1676,6 +1740,71 @@ impl ShapedText {
     }
 }
 
+struct ImageGlyphsIter<'a, G>
+where
+    G: Iterator<Item = (&'a Font, &'a [GlyphInstance])> + 'a,
+{
+    glyphs: G,
+    glyphs_i: u32,
+    images: &'a [(u32, GlyphImage)],
+    maybe_img: Option<(&'a Font, &'a [GlyphInstance])>,
+}
+impl<'a, G> Iterator for ImageGlyphsIter<'a, G>
+where
+    G: Iterator<Item = (&'a Font, &'a [GlyphInstance])> + 'a,
+{
+    type Item = (&'a Font, ShapedImageGlyphs<'a>);
+
+    fn next(&mut self) -> Option<Self::Item> {
+        loop {
+            if let Some((font, glyphs)) = &mut self.maybe_img {
+                while self.images.first().map(|(i, _)| *i < self.glyphs_i).unwrap_or(false) {
+                    self.images = &self.images[1..];
+                }
+                if let Some((i, img)) = self.images.first() {
+                    if *i == self.glyphs_i {
+                        self.glyphs_i += 1;
+                        let mut size = img.0.with(|i| i.size()).cast::<f32>();
+                        let scale = font.size().0 as f32 / size.width.max(size.height);
+                        size *= scale;
+                        let r = (
+                            *font,
+                            ShapedImageGlyphs::Image {
+                                rect: euclid::Rect::new(glyphs[0].point - euclid::vec2(0.0, size.height), size),
+                                base_glyph: glyphs[0].index,
+                                img: &img.0,
+                            },
+                        );
+                        *glyphs = &glyphs[1..];
+                        if glyphs.is_empty() {
+                            self.maybe_img = None;
+                        }
+                        return Some(r);
+                    } else {
+                        let normal = &glyphs[..glyphs.len().min(*i as _)];
+                        self.glyphs_i += normal.len() as u32;
+
+                        *glyphs = &glyphs[normal.len()..];
+                        let r = (*font, ShapedImageGlyphs::Normal(normal));
+
+                        if glyphs.is_empty() {
+                            self.maybe_img = None;
+                        }
+                        return Some(r);
+                    }
+                } else {
+                    let r = (*font, ShapedImageGlyphs::Normal(glyphs));
+                    return Some(r);
+                }
+            } else if let Some(seq) = self.glyphs.next() {
+                self.maybe_img = Some(seq);
+            } else {
+                return None;
+            }
+        }
+    }
+}
+
 struct ColoredGlyphsIter<'a, G>
 where
     G: Iterator<Item = (&'a Font, &'a [GlyphInstance])> + 'a,
@@ -1874,6 +2003,7 @@ impl ShapedTextBuilder {
                 mid_size: PxSize::zero(),
                 last_line: PxRect::zero(),
                 has_colored_glyphs: false,
+                images: vec![],
             },
 
             line_height: 0.0,
@@ -2174,8 +2304,26 @@ impl ShapedTextBuilder {
                     self.push_text_seg(seg, info);
                 }
 
-                if matches!(info.kind, TextSegmentKind::Emoji) && !font.face().color_glyphs().is_empty() {
-                    self.out.has_colored_glyphs = true;
+                if matches!(info.kind, TextSegmentKind::Emoji) {
+                    if !font.face().color_glyphs().is_empty() {
+                        self.out.has_colored_glyphs = true;
+                    }
+                    if font.face().has_raster_images() || (cfg!(feature = "svg") && font.face().has_svg_images()) {
+                        if let Some(ttf) = font.face().ttf() {
+                            for (i, g) in shaped_seg.glyphs.iter().enumerate() {
+                                let id = ttf_parser::GlyphId(g.index as _);
+                                let ppm = font.size().0 as u16;
+                                let glyphs_i = self.out.glyphs.len() - shaped_seg.glyphs.len() + i;
+                                if let Some(img) = ttf.glyph_raster_image(id, ppm) {
+                                    self.push_glyph_raster(glyphs_i as _, img);
+                                } else if cfg!(feature = "svg") {
+                                    if let Some(img) = ttf.glyph_svg_image(id) {
+                                        self.push_glyph_svg(glyphs_i as _, img);
+                                    }
+                                }
+                            }
+                        }
+                    }
                 }
 
                 self.push_font(font);
@@ -2228,6 +2376,161 @@ impl ShapedTextBuilder {
         }
     }
 
+    fn push_glyph_raster(&mut self, glyphs_i: u32, img: ttf_parser::RasterGlyphImage) {
+        use ttf_parser::RasterImageFormat;
+        let size = PxSize::new(Px(img.width as _), Px(img.height as _));
+        let bgra_fmt = ImageDataFormat::Bgra8 { size, ppi: None };
+        let bgra_len = img.width as usize * img.height as usize * 4;
+        let (data, fmt) = match img.format {
+            RasterImageFormat::PNG => (img.data.to_vec(), ImageDataFormat::from("png")),
+            RasterImageFormat::BitmapMono => {
+                // row aligned 1-bitmap
+                let mut bgra = Vec::with_capacity(bgra_len);
+                let bytes_per_row = (img.width as usize + 7) / 8;
+                for y in 0..img.height as usize {
+                    let row_start = y * bytes_per_row;
+                    for x in 0..img.width as usize {
+                        let byte_index = row_start + x / 8;
+                        let bit_index = 7 - (x % 8);
+                        let bit = (img.data[byte_index] >> bit_index) & 1;
+                        let color = if bit == 1 { [0, 0, 0, 255] } else { [255, 255, 255, 255] };
+                        bgra.extend_from_slice(&color);
+                    }
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapMonoPacked => {
+                // packed 1-bitmap
+                let mut bgra = Vec::with_capacity(bgra_len);
+                for &c8 in img.data {
+                    for bit in 0..8 {
+                        let color = if (c8 >> (7 - bit)) & 1 == 1 {
+                            [0, 0, 0, 255]
+                        } else {
+                            [255, 255, 255, 255]
+                        };
+                        bgra.extend_from_slice(&color);
+                        if bgra.len() == bgra_len {
+                            break;
+                        }
+                    }
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapGray2 => {
+                // row aligned 2-bitmap
+                let mut bgra = Vec::with_capacity(bgra_len);
+                let bytes_per_row = (img.width as usize + 3) / 4;
+                for y in 0..img.height as usize {
+                    let row_start = y * bytes_per_row;
+                    for x in 0..img.width as usize {
+                        let byte_index = row_start + x / 4;
+                        let shift = (3 - (x % 4)) * 2;
+                        let gray = (img.data[byte_index] >> shift) & 0b11;
+                        let color = match gray {
+                            0b00 => [0, 0, 0, 255],       // Black
+                            0b01 => [85, 85, 85, 255],    // Dark gray
+                            0b10 => [170, 170, 170, 255], // Light gray
+                            0b11 => [255, 255, 255, 255], // White
+                            _ => unreachable!(),
+                        };
+                        bgra.extend_from_slice(&color);
+                    }
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapGray2Packed => {
+                // packed 2-bitmap
+                let mut bgra = Vec::with_capacity(bgra_len);
+                for &c4 in img.data {
+                    for i in 0..4 {
+                        let gray = (c4 >> (7 - i * 2)) & 0b11;
+                        let color = match gray {
+                            0b00 => [0, 0, 0, 255],       // Black
+                            0b01 => [85, 85, 85, 255],    // Dark gray
+                            0b10 => [170, 170, 170, 255], // Light gray
+                            0b11 => [255, 255, 255, 255], // White
+                            _ => unreachable!(),
+                        };
+                        bgra.extend_from_slice(&color);
+                        if bgra.len() == bgra_len {
+                            break;
+                        }
+                    }
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapGray4 => {
+                // row aligned 4-bitmap
+                let mut bgra = Vec::with_capacity(bgra_len);
+                let bytes_per_row = (img.width as usize + 1) / 2;
+                for y in 0..img.height as usize {
+                    let row_start = y * bytes_per_row;
+                    for x in 0..img.width as usize {
+                        let byte_index = row_start + x / 2;
+                        let shift = if x % 2 == 0 { 4 } else { 0 };
+                        let gray = (img.data[byte_index] >> shift) & 0b1111;
+                        let g = gray * 17;
+                        bgra.extend_from_slice(&[g, g, g, 255]);
+                    }
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapGray4Packed => {
+                let mut bgra = Vec::with_capacity(bgra_len);
+                for &c2 in img.data {
+                    for i in 0..2 {
+                        let gray = (c2 >> (7 - i * 4)) & 0b1111;
+                        let g = gray * 17;
+                        bgra.extend_from_slice(&[g, g, g, 255]);
+                        if bgra.len() == bgra_len {
+                            break;
+                        }
+                    }
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapGray8 => {
+                let mut bgra = Vec::with_capacity(bgra_len);
+                for &c in img.data {
+                    bgra.extend_from_slice(&[c, c, c, 255]);
+                }
+                (bgra, bgra_fmt)
+            }
+            RasterImageFormat::BitmapPremulBgra32 => {
+                let mut bgra = img.data.to_vec();
+                for c in bgra.chunks_exact_mut(4) {
+                    let (b, g, r, a) = (c[0], c[1], c[2], c[3]);
+                    let unp = if a == 255 {
+                        [b, g, r]
+                    } else {
+                        [
+                            (b as u32 * 255 / a as u32) as u8,
+                            (g as u32 * 255 / a as u32) as u8,
+                            (r as u32 * 255 / a as u32) as u8,
+                        ]
+                    };
+                    c.copy_from_slice(&unp);
+                }
+                (bgra, bgra_fmt)
+            }
+        };
+        self.push_glyph_img(glyphs_i, ImageSource::from_data(Arc::new(data), fmt));
+    }
+
+    fn push_glyph_svg(&mut self, glyphs_i: u32, img: ttf_parser::svg::SvgDocument) {
+        self.push_glyph_img(
+            glyphs_i,
+            ImageSource::from_data(Arc::new(img.data.to_vec()), ImageDataFormat::from("svg")),
+        );
+    }
+
+    fn push_glyph_img(&mut self, glyphs_i: u32, source: ImageSource) {
+        let img = IMAGES.cache(source);
+
+        self.out.images.push((glyphs_i, GlyphImage(img)));
+    }
+
     fn push_last_line(&mut self, text: &SegmentedText) {
         let directions = self.finish_current_line_bidi(text);
         self.out.lines.0.push(LineRange {
diff --git a/crates/zng-wgt-text/src/node/render.rs b/crates/zng-wgt-text/src/node/render.rs
index 051c55cb9..37b066151 100644
--- a/crates/zng-wgt-text/src/node/render.rs
+++ b/crates/zng-wgt-text/src/node/render.rs
@@ -1,7 +1,7 @@
 use std::borrow::Cow;
 
 use zng_app::{
-    render::{FontSynthesis, FrameValueKey},
+    render::{FontSynthesis, FrameValueKey, ReferenceFrameId},
     widget::{
         border::{LineOrientation, LineStyle},
         node::{match_node, match_node_leaf, UiNode, UiNodeOp},
@@ -9,7 +9,7 @@ use zng_app::{
     },
 };
 use zng_color::Rgba;
-use zng_ext_font::{Font, ShapedColoredGlyphs};
+use zng_ext_font::{Font, ShapedColoredGlyphs, ShapedImageGlyphs};
 use zng_ext_input::focus::FOCUS_CHANGED_EVENT;
 use zng_layout::{
     context::LAYOUT,
@@ -226,6 +226,8 @@ pub fn render_text() -> impl UiNode {
     let mut reuse = None;
     let mut rendered = None;
     let mut color_key = None;
+    let image_spatial_id = SpatialFrameId::new_unique();
+    let mut has_loading_images = false;
 
     match_node_leaf(move |op| match op {
         UiNodeOp::Init => {
@@ -243,6 +245,7 @@ pub fn render_text() -> impl UiNode {
             color_key = None;
             reuse = None;
             rendered = None;
+            has_loading_images = false;
         }
         UiNodeOp::Update { .. } => {
             if FONT_PALETTE_VAR.is_new() || FONT_PALETTE_COLORS_VAR.is_new() {
@@ -292,8 +295,92 @@ pub fn render_text() -> impl UiNode {
             t.render_info.transform = *frame.transform();
             t.render_info.scale_factor = frame.scale_factor();
 
+            if std::mem::take(&mut has_loading_images)
+                && reuse.is_some()
+                && frame.render_widgets().delivery_list().enter_widget(WIDGET.id())
+            {
+                // loading emoji images request render on load
+                reuse = None;
+            }
+
             frame.push_reuse(&mut reuse, |frame| {
-                if t.shaped_text.has_colored_glyphs() || t.overflow_suffix.as_ref().map(|o| o.has_colored_glyphs()).unwrap_or(false) {
+                if t.shaped_text.has_images() {
+                    let mut img_count = 0;
+                    let mut push_img_glyphs = |font: &Font, glyphs, offset: Option<euclid::Vector2D<f32, Px>>| match glyphs {
+                        ShapedImageGlyphs::Normal(glyphs) => {
+                            if let Some(offset) = offset {
+                                let mut glyphs = glyphs.to_vec();
+                                for g in &mut glyphs {
+                                    g.point.x += offset.x;
+                                    g.point.y += offset.y;
+                                }
+                                frame.push_text(clip, &glyphs, font, color_value, r.synthesis, aa);
+                            } else {
+                                frame.push_text(clip, glyphs, font, color_value, r.synthesis, aa);
+                            }
+                        }
+                        ShapedImageGlyphs::Image { rect, img, .. } => {
+                            let is_loading = img.with(|i| {
+                                if i.is_loaded() {
+                                    frame.push_reference_frame(
+                                        ReferenceFrameId::from_unique_child(image_spatial_id, img_count),
+                                        FrameValue::Value(PxTransform::translation(rect.origin.x, rect.origin.y)),
+                                        true,
+                                        true,
+                                        |frame| {
+                                            let size = rect.size.cast::<Px>();
+                                            frame.push_image(
+                                                PxRect::from_size(size),
+                                                size,
+                                                size,
+                                                PxSize::zero(),
+                                                i,
+                                                zng_view_api::ImageRendering::Pixelated,
+                                            );
+                                        },
+                                    );
+                                    img_count = img_count.wrapping_add(1);
+                                }
+                                i.is_loading()
+                            });
+                            if is_loading {
+                                has_loading_images = true;
+                                let id = WIDGET.id();
+                                img.hook(move |args| {
+                                    if args.value().is_loaded() {
+                                        UPDATES.render(id);
+                                    }
+                                    args.value().is_loading()
+                                })
+                                .perm();
+                            }
+                        }
+                    };
+
+                    match (&t.overflow, TEXT_OVERFLOW_VAR.get(), TEXT_EDITABLE_VAR.get()) {
+                        (Some(o), TextOverflow::Truncate(_), false) => {
+                            for glyphs in &o.included_glyphs {
+                                for (font, glyphs) in t.shaped_text.image_glyphs_slice(glyphs.clone()) {
+                                    push_img_glyphs(font, glyphs, None)
+                                }
+                            }
+
+                            if let Some(suf) = &t.overflow_suffix {
+                                let suf_offset = o.suffix_origin.to_vector().cast_unit();
+                                for (font, glyphs) in suf.image_glyphs() {
+                                    push_img_glyphs(font, glyphs, Some(suf_offset))
+                                }
+                            }
+                        }
+                        _ => {
+                            // no overflow truncating
+                            for (font, glyphs) in t.shaped_text.image_glyphs() {
+                                push_img_glyphs(font, glyphs, None)
+                            }
+                        }
+                    }
+                } else if t.shaped_text.has_colored_glyphs() || t.overflow_suffix.as_ref().map(|o| o.has_colored_glyphs()).unwrap_or(false)
+                {
                     let palette_query = FONT_PALETTE_VAR.get();
                     FONT_PALETTE_COLORS_VAR.with(|palette_colors| {
                         let mut push_font_glyphs = |font: &Font, glyphs, offset: Option<euclid::Vector2D<f32, Px>>| {
diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml
index 26a2f9fba..5b65f22b6 100644
--- a/crates/zng/Cargo.toml
+++ b/crates/zng/Cargo.toml
@@ -39,8 +39,8 @@ debug_default = [
     "zng-wgt-window/debug_default",
 ]
 
-# Enable SVG images support.
-svg = ["zng-ext-svg"]
+# Enable SVG images, emoji support.
+svg = ["zng-ext-svg", "zng-ext-font/svg"]
 
 # Use more dynamic dispatch at the node level by enabling `UiNode::cfg_boxed` to box.
 #


Test patch: