Support bitmap fonts (#641)

Resolve bitmap Emoji fonts at scene construction time (rather than the
ideal resolve time for glyph caching) for expedience.

Current status: Layout details aren't entirely correct

---------

Co-authored-by: Kaur Kuut <strom@nevermore.ee>
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cb8574b..dcfb4c4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -230,7 +230,7 @@
 
       - name: cargo test
         # TODO: Maybe use --release; the CPU shaders are extremely slow when unoptimised
-        run: cargo nextest run --workspace --locked --all-features
+        run: cargo nextest run --workspace --locked --all-features --no-fail-fast
         env:
           VELLO_CI_GPU_SUPPORT: ${{ matrix.gpu }}
           # We are experimenting with git lfs, and we don't expect to run out of bandwidth.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76dfa8f..08a2f44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,10 @@
 
 This release has an [MSRV][] of 1.75.
 
+### Highlights
+
+- Support for most Emoji ([#615][], [#641][] by [@DJMcNab])
+
 ### Added
 
 - Support blends more than four layers deep ([#657][] by [@DJMcNab][])
@@ -117,10 +121,12 @@
 [#575]: https://github.com/linebender/vello/pull/575
 [#589]: https://github.com/linebender/vello/pull/589
 [#612]: https://github.com/linebender/vello/pull/612
+[#615]: https://github.com/linebender/vello/pull/615
 [#619]: https://github.com/linebender/vello/pull/619
 [#630]: https://github.com/linebender/vello/pull/630
 [#631]: https://github.com/linebender/vello/pull/631
 [#635]: https://github.com/linebender/vello/pull/635
+[#641]: https://github.com/linebender/vello/pull/641
 [#657]: https://github.com/linebender/vello/pull/657
 
 <!-- Note that this still comparing against 0.2.0, because 0.2.1 is a cherry-picked patch -->
diff --git a/Cargo.lock b/Cargo.lock
index 2c2699f..5c7d5cf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2416,6 +2416,7 @@
  "futures-intrusive",
  "log",
  "peniko",
+ "png",
  "raw-window-handle",
  "skrifa",
  "static_assertions",
diff --git a/examples/assets/noto_color_emoji/NotoColorEmoji-CBTF-Subset.ttf b/examples/assets/noto_color_emoji/NotoColorEmoji-CBTF-Subset.ttf
new file mode 100644
index 0000000..a87b8c3
--- /dev/null
+++ b/examples/assets/noto_color_emoji/NotoColorEmoji-CBTF-Subset.ttf
Binary files differ
diff --git a/examples/assets/noto_color_emoji/README.md b/examples/assets/noto_color_emoji/README.md
index c9d0d06..66e6a39 100644
--- a/examples/assets/noto_color_emoji/README.md
+++ b/examples/assets/noto_color_emoji/README.md
@@ -2,10 +2,12 @@
 
 This folder contains a small subset of [Noto Color Emoji](https://fonts.google.com/noto/specimen/Noto+Color+Emoji), licensed under the [OFL version 1.1](LICENSE).
 We do not include the full set of Emoji, because including the entire Emoji set would increase the repository size too much.
-Note that Vello *does* support any COLR emoji (but not Emoji in other formats at the moment).
 Included emoji are:
 
 - ✅ Check Mark - \u{2705}/`:white_check_mark:`
 - 👀 Eyes - \u{1f440}/`:eyes:`
 - 🎉 Party Popper - \u{1f389}/`:party_popper:`
 - 🤠 Face with Cowboy Hat - \u{1f920}/`cowboy_hat_face`
+
+These are in the COLR format in `NotoColorEmoji-Subset` and in the CBTF format in `NotoColorEmoji-CBTF-Subset`.
+This covers all ways that Emoji are commonly packaged, and both are supported by Vello.
diff --git a/examples/scenes/src/simple_text.rs b/examples/scenes/src/simple_text.rs
index 842fb8c..8f23569 100644
--- a/examples/scenes/src/simple_text.rs
+++ b/examples/scenes/src/simple_text.rs
@@ -14,13 +14,16 @@
 // On Windows, can set this to "c:\\Windows\\Fonts\\seguiemj.ttf" to get color emoji
 const ROBOTO_FONT: &[u8] = include_bytes!("../../assets/roboto/Roboto-Regular.ttf");
 const INCONSOLATA_FONT: &[u8] = include_bytes!("../../assets/inconsolata/Inconsolata.ttf");
-const NOTO_EMOJI_SUBSET: &[u8] =
+const NOTO_EMOJI_CBTF_SUBSET: &[u8] =
+    include_bytes!("../../assets/noto_color_emoji/NotoColorEmoji-CBTF-Subset.ttf");
+const NOTO_EMOJI_COLR_SUBSET: &[u8] =
     include_bytes!("../../assets/noto_color_emoji/NotoColorEmoji-Subset.ttf");
 
 pub struct SimpleText {
     roboto: Font,
     inconsolata: Font,
-    noto_emoji_subset: Font,
+    noto_emoji_colr_subset: Font,
+    noto_emoji_cbtf_subset: Font,
 }
 
 impl SimpleText {
@@ -29,7 +32,8 @@
         Self {
             roboto: Font::new(Blob::new(Arc::new(ROBOTO_FONT)), 0),
             inconsolata: Font::new(Blob::new(Arc::new(INCONSOLATA_FONT)), 0),
-            noto_emoji_subset: Font::new(Blob::new(Arc::new(NOTO_EMOJI_SUBSET)), 0),
+            noto_emoji_colr_subset: Font::new(Blob::new(Arc::new(NOTO_EMOJI_COLR_SUBSET)), 0),
+            noto_emoji_cbtf_subset: Font::new(Blob::new(Arc::new(NOTO_EMOJI_CBTF_SUBSET)), 0),
         }
     }
 
@@ -42,7 +46,7 @@
     /// Note that Vello does support COLR emoji, but does not currently support
     /// any other forms of emoji.
     #[allow(clippy::too_many_arguments)]
-    pub fn add_emoji_run<'a>(
+    pub fn add_colr_emoji_run<'a>(
         &mut self,
         scene: &mut Scene,
         size: f32,
@@ -51,7 +55,39 @@
         style: impl Into<StyleRef<'a>>,
         text: &str,
     ) {
-        let font = self.noto_emoji_subset.clone();
+        let font = self.noto_emoji_colr_subset.clone();
+        self.add_var_run(
+            scene,
+            Some(&font),
+            size,
+            &[],
+            // This should be unused
+            &Brush::Solid(Color::WHITE),
+            transform,
+            glyph_transform,
+            style,
+            text,
+        );
+    }
+
+    /// Add a text run which supports some emoji.
+    ///
+    /// The supported Emoji are ✅, 👀, 🎉, and 🤠.
+    /// This subset is chosen to demonstrate the emoji support, whilst
+    /// not significantly increasing repository size.
+    ///
+    /// This will use a CBTF font, which Vello supports.
+    #[allow(clippy::too_many_arguments)]
+    pub fn add_bitmap_emoji_run<'a>(
+        &mut self,
+        scene: &mut Scene,
+        size: f32,
+        transform: Affine,
+        glyph_transform: Option<Affine>,
+        style: impl Into<StyleRef<'a>>,
+        text: &str,
+    ) {
+        let font = self.noto_emoji_cbtf_subset.clone();
         self.add_var_run(
             scene,
             Some(&font),
diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs
index 40db189..5e895e0 100644
--- a/examples/scenes/src/test_scenes.rs
+++ b/examples/scenes/src/test_scenes.rs
@@ -99,10 +99,18 @@
     pub(super) fn emoji(scene: &mut Scene, params: &mut SceneParams) {
         let text_size = 120. + 20. * (params.time * 2.).sin() as f32;
         let s = "🎉🤠✅";
-        params.text.add_emoji_run(
+        params.text.add_colr_emoji_run(
             scene,
             text_size,
-            Affine::translate(Vec2::new(100., 400.)),
+            Affine::translate(Vec2::new(100., 250.)),
+            None,
+            Fill::NonZero,
+            s,
+        );
+        params.text.add_bitmap_emoji_run(
+            scene,
+            text_size,
+            Affine::translate(Vec2::new(100., 500.)),
             None,
             Fill::NonZero,
             s,
diff --git a/vello/Cargo.toml b/vello/Cargo.toml
index 83ba98d..7857c0c 100644
--- a/vello/Cargo.toml
+++ b/vello/Cargo.toml
@@ -38,3 +38,5 @@
 futures-intrusive = { workspace = true }
 wgpu-profiler = { workspace = true, optional = true }
 thiserror = { workspace = true }
+# TODO: Add feature for built-in bitmap emoji support?
+png = { version = "0.17.13" }
diff --git a/vello/src/scene.rs b/vello/src/scene.rs
index 92aa373..6c558ef 100644
--- a/vello/src/scene.rs
+++ b/vello/src/scene.rs
@@ -1,15 +1,21 @@
 // Copyright 2022 the Vello Authors
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
+mod bitmap;
+
+use std::sync::Arc;
+
 use peniko::{
     kurbo::{Affine, BezPath, Point, Rect, Shape, Stroke, Vec2},
-    BlendMode, Brush, BrushRef, Color, ColorStop, ColorStops, ColorStopsSource, Compose, Extend,
-    Fill, Font, Gradient, Image, Mix, StyleRef,
+    BlendMode, Blob, Brush, BrushRef, Color, ColorStop, ColorStops, ColorStopsSource, Compose,
+    Extend, Fill, Font, Gradient, Image, Mix, StyleRef,
 };
+use png::{BitDepth, ColorType, Transformations};
 use skrifa::{
-    color::ColorPainter,
+    color::{ColorGlyph, ColorPainter},
     instance::{LocationRef, NormalizedCoord},
     outline::{DrawSettings, OutlinePen},
+    prelude::Size,
     raw::{tables::cpal::Cpal, TableProvider},
     GlyphId, MetadataProvider, OutlineGlyphCollection,
 };
@@ -354,17 +360,18 @@
 
     /// Encodes a fill or stroke for the given sequence of glyphs and consumes the builder.
     ///
-    /// The `style` parameter accepts either `Fill` or `&Stroke` types.
+    /// The `style` parameter accepts either `Fill` or `Stroke` types.
     ///
-    /// If the font has COLR support, it will try to draw each glyph using that table first,
-    /// falling back to non-COLR rendering. `style` is ignored for COLR glyphs.
+    /// This supports emoji fonts in COLR and bitmap formats.
+    /// `style` is ignored for these fonts.
     ///
     /// For these glyphs, the given [brush](Self::brush) is used as the "foreground colour", and should
     /// be [`Solid`](Brush::Solid) for maximum compatibility.
     pub fn draw(mut self, style: impl Into<StyleRef<'a>>, glyphs: impl Iterator<Item = Glyph>) {
         let font_index = self.run.font.index;
         let font = skrifa::FontRef::from_index(self.run.font.data.as_ref(), font_index).unwrap();
-        if font.colr().is_ok() && font.cpal().is_ok() {
+        let bitmaps = bitmap::BitmapStrikes::new(&font);
+        if font.colr().is_ok() && font.cpal().is_ok() || !bitmaps.is_empty() {
             self.try_draw_colr(style.into(), glyphs);
         } else {
             // Shortcut path - no need to test each glyph for a colr outline
@@ -410,11 +417,13 @@
         let font = skrifa::FontRef::from_index(blob.as_ref(), font_index).unwrap();
         let upem: f32 = font.head().map(|h| h.units_per_em()).unwrap().into();
         let run_transform = self.run.transform.to_kurbo();
-        let scale = Affine::scale_non_uniform(
+        let colr_scale = Affine::scale_non_uniform(
             (self.run.font_size / upem).into(),
             (-self.run.font_size / upem).into(),
         );
+
         let colour_collection = font.color_glyphs();
+        let bitmaps = bitmap::BitmapStrikes::new(&font);
         let mut final_glyph = None;
         let mut outline_count = 0;
         // We copy out of the variable font coords here because we need to call an exclusive self method
@@ -423,48 +432,175 @@
         .to_vec();
         let location = LocationRef::new(coords);
         loop {
+            let ppem = self.run.font_size;
             let outline_glyphs = (&mut glyphs).take_while(|glyph| {
-                match colour_collection.get(GlyphId::new(glyph.id.try_into().unwrap())) {
+                let glyph_id = GlyphId::new(glyph.id.try_into().unwrap());
+                match colour_collection.get(glyph_id) {
                     Some(color) => {
-                        final_glyph = Some((color, *glyph));
+                        final_glyph = Some((EmojiLikeGlyph::Colr(color), *glyph));
                         false
                     }
-                    None => true,
+                    None => match bitmaps.glyph_for_size(Size::new(ppem), glyph_id) {
+                        Some(bitmap) => {
+                            final_glyph = Some((EmojiLikeGlyph::Bitmap(bitmap), *glyph));
+                            false
+                        }
+                        None => true,
+                    },
                 }
             });
             self.run.glyphs.start = self.run.glyphs.end;
             self.run.stream_offsets = self.scene.encoding.stream_offsets();
             outline_count += self.draw_outline_glyphs(clone_style_ref(&style), outline_glyphs);
 
-            let Some((color, glyph)) = final_glyph.take() else {
+            let Some((emoji, glyph)) = final_glyph.take() else {
                 // All of the remaining glyphs were outline glyphs
                 break;
             };
 
-            let transform = run_transform
-                * Affine::translate(Vec2::new(glyph.x.into(), glyph.y.into()))
-                * scale
-                * self
-                    .run
-                    .glyph_transform
-                    .unwrap_or(Transform::IDENTITY)
-                    .to_kurbo();
+            match emoji {
+                // TODO: This really needs to be moved to resolve time to get proper caching, etc.
+                EmojiLikeGlyph::Bitmap(bitmap) => {
+                    let image = match bitmap.data {
+                        bitmap::BitmapData::Bgra(data) => {
+                            if bitmap.width * bitmap.height * 4 != data.len().try_into().unwrap() {
+                                // TODO: Error once?
+                                log::error!("Invalid font");
+                                continue;
+                            }
+                            let data: Box<[u8]> = data
+                                .chunks_exact(4)
+                                .flat_map(|bytes| {
+                                    let [b, g, r, a] = bytes.try_into().unwrap();
+                                    [r, g, b, a]
+                                })
+                                .collect();
+                            Image::new(
+                                // TODO: The design of the Blob type forces the double boxing
+                                Blob::new(Arc::new(data)),
+                                peniko::Format::Rgba8,
+                                bitmap.width,
+                                bitmap.height,
+                            )
+                        }
+                        bitmap::BitmapData::Png(data) => {
+                            let mut decoder = png::Decoder::new(data);
+                            decoder.set_transformations(
+                                Transformations::ALPHA | Transformations::STRIP_16,
+                            );
+                            let Ok(mut reader) = decoder.read_info() else {
+                                log::error!("Invalid PNG in font");
+                                continue;
+                            };
 
-            color
-                .paint(
-                    location,
-                    &mut DrawColorGlyphs {
-                        scene: self.scene,
-                        cpal: &font.cpal().unwrap(),
-                        outlines: &font.outline_glyphs(),
-                        transform_stack: vec![Transform::from_kurbo(&transform)],
-                        clip_box: DEFAULT_CLIP_RECT,
-                        clip_depth: 0,
+                            if reader.output_color_type() != (ColorType::Rgba, BitDepth::Eight) {
+                                log::error!("Unsupported `output_color_type`");
+                                continue;
+                            }
+                            let mut buf = vec![0; reader.output_buffer_size()].into_boxed_slice();
+
+                            let info = reader.next_frame(&mut buf).unwrap();
+                            if info.width != bitmap.width || info.height != bitmap.height {
+                                log::error!("Unexpected width and height");
+                                continue;
+                            }
+                            Image::new(
+                                // TODO: The design of the Blob type forces the double boxing
+                                Blob::new(Arc::new(buf)),
+                                peniko::Format::Rgba8,
+                                bitmap.width,
+                                bitmap.height,
+                            )
+                        }
+                        bitmap::BitmapData::Mask(mask) => {
+                            // TODO: Is this code worth having?
+                            let Some(masks) = bitmap_masks(mask.bpp) else {
+                                // TODO: Error once?
+                                log::warn!("Invalid bpp in bitmap glyph");
+                                continue;
+                            };
+
+                            if !mask.is_packed {
+                                // TODO: Error once?
+                                log::warn!("Unpacked mask data in font not yet supported");
+                                // TODO: How do we get the font name here?
+                                continue;
+                            }
+                            let alphas = mask.data.iter().flat_map(|it| {
+                                masks
+                                    .iter()
+                                    .map(move |mask| (it & mask.mask) >> mask.right_shift)
+                            });
+                            let data: Box<[u8]> = alphas
+                                .flat_map(|alpha| [u8::MAX, u8::MAX, u8::MAX, alpha])
+                                .collect();
+
+                            Image::new(
+                                // TODO: The design of the Blob type forces the double boxing
+                                Blob::new(Arc::new(data)),
+                                peniko::Format::Rgba8,
+                                bitmap.width,
+                                bitmap.height,
+                            )
+                        }
+                    };
+                    // Split into multiple statements because rustfmt breaks
+                    let transform =
+                        run_transform.then_translate(Vec2::new(glyph.x.into(), glyph.y.into()));
+
+                    // Logic copied from Skia without examination or careful understanding:
+                    // https://github.com/google/skia/blob/61ac357e8e3338b90fb84983100d90768230797f/src/ports/SkTypeface_fontations.cpp#L664
+
+                    let image_scale_factor = self.run.font_size / bitmap.ppem_y;
+                    let font_units_to_size = self.run.font_size / upem;
+                    let transform = transform
+                        .pre_translate(Vec2 {
+                            x: (-bitmap.bearing_x * font_units_to_size).into(),
+                            y: (bitmap.bearing_y * font_units_to_size).into(),
+                        })
+                        // Unclear why this isn't non-uniform
+                        .pre_scale(image_scale_factor.into())
+                        .pre_translate(Vec2 {
+                            x: (-bitmap.inner_bearing_x).into(),
+                            y: (-bitmap.inner_bearing_y).into(),
+                        });
+                    let mut transform = match bitmap.placement_origin {
+                        bitmap::Origin::TopLeft => transform,
+                        bitmap::Origin::BottomLeft => transform.pre_translate(Vec2 {
+                            x: 0.,
+                            y: f64::from(image.height),
+                        }),
+                    };
+                    if let Some(glyph_transform) = self.run.glyph_transform {
+                        transform *= glyph_transform.to_kurbo();
+                    }
+                    self.scene.draw_image(&image, transform);
+                }
+                EmojiLikeGlyph::Colr(colr) => {
+                    let transform = run_transform
+                        * Affine::translate(Vec2::new(glyph.x.into(), glyph.y.into()))
+                        * colr_scale
+                        * self
+                            .run
+                            .glyph_transform
+                            .unwrap_or(Transform::IDENTITY)
+                            .to_kurbo();
+                    colr.paint(
                         location,
-                        foreground_brush: self.brush.clone(),
-                    },
-                )
-                .unwrap();
+                        &mut DrawColorGlyphs {
+                            scene: self.scene,
+                            cpal: &font.cpal().unwrap(),
+                            outlines: &font.outline_glyphs(),
+                            transform_stack: vec![Transform::from_kurbo(&transform)],
+                            clip_box: DEFAULT_CLIP_RECT,
+                            clip_depth: 0,
+                            location,
+                            foreground_brush: self.brush.clone(),
+                        },
+                    )
+                    .unwrap();
+                }
+            }
         }
         if outline_count == 0 {
             // If we didn't draw any outline glyphs, the encoded variable font parameters were never used
@@ -477,6 +613,64 @@
         }
     }
 }
+
+struct BitmapMask {
+    mask: u8,
+    right_shift: u8,
+}
+
+fn bitmap_masks(bpp: u8) -> Option<&'static [BitmapMask]> {
+    const fn m(mask: u8, right_shift: u8) -> BitmapMask {
+        BitmapMask { mask, right_shift }
+    }
+    const fn byte(value: u8) -> BitmapMask {
+        BitmapMask {
+            mask: 1 << value,
+            right_shift: value,
+        }
+    }
+    match bpp {
+        1 => {
+            const BPP_1_MASK: &[BitmapMask] = &[
+                byte(0),
+                byte(1),
+                byte(2),
+                byte(3),
+                byte(4),
+                byte(5),
+                byte(6),
+                byte(7),
+            ];
+            Some(BPP_1_MASK)
+        }
+
+        2 => {
+            const BPP_2_MASK: &[BitmapMask] = {
+                &[
+                    m(0b0000_0011, 0),
+                    m(0b0000_1100, 2),
+                    m(0b0011_0000, 4),
+                    m(0b1100_0000, 6),
+                ]
+            };
+            Some(BPP_2_MASK)
+        }
+        4 => {
+            const BPP_4_MASK: &[BitmapMask] = &[m(0b0000_1111, 0), m(0b1111_0000, 4)];
+            Some(BPP_4_MASK)
+        }
+        8 => {
+            const BPP_8_MASK: &[BitmapMask] = &[m(u8::MAX, 0)];
+            Some(BPP_8_MASK)
+        }
+        _ => None,
+    }
+}
+
+enum EmojiLikeGlyph<'a> {
+    Bitmap(bitmap::BitmapGlyph<'a>),
+    Colr(ColorGlyph<'a>),
+}
 const BOUND: f64 = 100_000.;
 // Hack: If we don't have a clip box, we guess a rectangle we hope is big enough
 const DEFAULT_CLIP_RECT: Rect = Rect::new(-BOUND, -BOUND, BOUND, BOUND);
diff --git a/vello/src/scene/bitmap.rs b/vello/src/scene/bitmap.rs
new file mode 100644
index 0000000..3795f55
--- /dev/null
+++ b/vello/src/scene/bitmap.rs
@@ -0,0 +1,366 @@
+// Copyright 2024 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+// Based on https://github.com/googlefonts/fontations/blob/cbdf8b485e955e3acee40df1344e33908805ed31/skrifa/src/bitmap.rs
+#![allow(warnings)]
+
+//! Bitmap strikes and glyphs.
+use skrifa::{
+    instance::{LocationRef, Size},
+    metrics::GlyphMetrics,
+    raw::{
+        tables::{bitmap, cbdt, cblc, ebdt, eblc, sbix},
+        types::{GlyphId, Tag},
+        FontData, TableProvider,
+    },
+    MetadataProvider,
+};
+
+/// Set of strikes, each containing embedded bitmaps of a single size.
+#[derive(Clone)]
+pub struct BitmapStrikes<'a>(StrikesKind<'a>);
+
+impl<'a> BitmapStrikes<'a> {
+    /// Creates a new `BitmapStrikes` for the given font.
+    ///
+    /// This will prefer `sbix`, `CBDT`, and `CBLC` formats in that order.
+    ///
+    /// To select a specific format, use [`with_format`](Self::with_format).
+    pub fn new(font: &impl TableProvider<'a>) -> Self {
+        for format in [BitmapFormat::Sbix, BitmapFormat::Cbdt, BitmapFormat::Ebdt] {
+            if let Some(strikes) = Self::with_format(font, format) {
+                return strikes;
+            }
+        }
+        Self(StrikesKind::None)
+    }
+
+    /// Creates a new `BitmapStrikes` for the given font and format.
+    ///
+    /// Returns `None` if the requested format is not available.
+    pub fn with_format(font: &impl TableProvider<'a>, format: BitmapFormat) -> Option<Self> {
+        let kind = match format {
+            BitmapFormat::Sbix => StrikesKind::Sbix(
+                font.sbix().ok()?,
+                font.glyph_metrics(Size::unscaled(), LocationRef::default()),
+            ),
+            BitmapFormat::Cbdt => {
+                StrikesKind::Cbdt(CbdtTables::new(font.cblc().ok()?, font.cbdt().ok()?))
+            }
+            BitmapFormat::Ebdt => {
+                StrikesKind::Ebdt(EbdtTables::new(font.eblc().ok()?, font.ebdt().ok()?))
+            }
+        };
+        Some(Self(kind))
+    }
+
+    /// Returns the format representing the underlying table for this set of
+    /// strikes.
+    pub fn format(&self) -> Option<BitmapFormat> {
+        match &self.0 {
+            StrikesKind::None => None,
+            StrikesKind::Sbix(..) => Some(BitmapFormat::Sbix),
+            StrikesKind::Cbdt(..) => Some(BitmapFormat::Cbdt),
+            StrikesKind::Ebdt(..) => Some(BitmapFormat::Ebdt),
+        }
+    }
+
+    /// Returns the number of available strikes.
+    pub fn len(&self) -> usize {
+        match &self.0 {
+            StrikesKind::None => 0,
+            StrikesKind::Sbix(sbix, _) => sbix.strikes().len(),
+            StrikesKind::Cbdt(cbdt) => cbdt.location.bitmap_sizes().len(),
+            StrikesKind::Ebdt(ebdt) => ebdt.location.bitmap_sizes().len(),
+        }
+    }
+
+    /// Returns true if there are no available strikes.
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// Returns the strike at the given index.
+    pub fn get(&self, index: usize) -> Option<BitmapStrike<'a>> {
+        let kind = match &self.0 {
+            StrikesKind::None => return None,
+            StrikesKind::Sbix(sbix, metrics) => {
+                StrikeKind::Sbix(sbix.strikes().get(index).ok()?, metrics.clone())
+            }
+            StrikesKind::Cbdt(tables) => StrikeKind::Cbdt(
+                tables.location.bitmap_sizes().get(index).copied()?,
+                tables.clone(),
+            ),
+            StrikesKind::Ebdt(tables) => StrikeKind::Ebdt(
+                tables.location.bitmap_sizes().get(index).copied()?,
+                tables.clone(),
+            ),
+        };
+        Some(BitmapStrike(kind))
+    }
+
+    /// Returns the best matching glyph for the given size and glyph
+    /// identifier.
+    ///
+    /// In this case, "best" means a glyph of the exact size, nearest larger
+    /// size, or nearest smaller size, in that order.
+    pub fn glyph_for_size(&self, size: Size, glyph_id: GlyphId) -> Option<BitmapGlyph<'a>> {
+        // Return the largest size for an unscaled request
+        let size = size.ppem().unwrap_or(f32::MAX);
+        self.iter()
+            .fold(None, |best: Option<BitmapGlyph<'a>>, entry| {
+                let entry_size = entry.ppem();
+                if let Some(best) = best {
+                    let best_size = best.ppem_y;
+                    if (entry_size >= size && entry_size < best_size)
+                        || (best_size < size && entry_size > best_size)
+                    {
+                        entry.get(glyph_id).or(Some(best))
+                    } else {
+                        Some(best)
+                    }
+                } else {
+                    entry.get(glyph_id)
+                }
+            })
+    }
+
+    /// Returns an iterator over all available strikes.
+    pub fn iter(&self) -> impl Iterator<Item = BitmapStrike<'a>> + 'a + Clone {
+        let this = self.clone();
+        (0..this.len()).filter_map(move |ix| this.get(ix))
+    }
+}
+
+#[derive(Clone)]
+enum StrikesKind<'a> {
+    None,
+    Sbix(sbix::Sbix<'a>, GlyphMetrics<'a>),
+    Cbdt(CbdtTables<'a>),
+    Ebdt(EbdtTables<'a>),
+}
+
+/// Set of embedded bitmap glyphs of a specific size.
+#[derive(Clone)]
+pub struct BitmapStrike<'a>(StrikeKind<'a>);
+
+impl<'a> BitmapStrike<'a> {
+    /// Returns the pixels-per-em (size) of this strike.
+    pub fn ppem(&self) -> f32 {
+        match &self.0 {
+            StrikeKind::Sbix(sbix, _) => sbix.ppem() as f32,
+            StrikeKind::Cbdt(size, _) => size.ppem_y() as f32,
+            StrikeKind::Ebdt(size, _) => size.ppem_y() as f32,
+        }
+    }
+
+    /// Returns a bitmap glyph for the given identifier, if available.
+    pub fn get(&self, glyph_id: GlyphId) -> Option<BitmapGlyph<'a>> {
+        match &self.0 {
+            StrikeKind::Sbix(sbix, metrics) => {
+                let glyph = sbix.glyph_data(glyph_id).ok()??;
+                if glyph.graphic_type() != Tag::new(b"PNG ") {
+                    return None;
+                }
+                let glyf_bb = metrics.bounds(glyph_id).unwrap_or_default();
+                let lsb = metrics.left_side_bearing(glyph_id).unwrap_or_default();
+                let ppem = sbix.ppem() as f32;
+                let png_data = glyph.data();
+                // PNG format:
+                // 8 byte header, IHDR chunk (4 byte length, 4 byte chunk type), width, height
+                let reader = FontData::new(png_data);
+                let width = reader.read_at::<u32>(16).ok()?;
+                let height = reader.read_at::<u32>(20).ok()?;
+                Some(BitmapGlyph {
+                    data: BitmapData::Png(glyph.data()),
+                    bearing_x: lsb,
+                    bearing_y: glyf_bb.y_min as f32,
+                    inner_bearing_x: glyph.origin_offset_x() as f32,
+                    inner_bearing_y: glyph.origin_offset_y() as f32,
+                    ppem_x: ppem,
+                    ppem_y: ppem,
+                    width,
+                    height,
+                    advance: metrics.advance_width(glyph_id).unwrap_or_default(),
+                    placement_origin: Origin::BottomLeft,
+                })
+            }
+            StrikeKind::Cbdt(size, tables) => {
+                let location = size
+                    .location(tables.location.offset_data(), glyph_id)
+                    .ok()?;
+                let data = tables.data.data(&location).ok()?;
+                BitmapGlyph::from_bdt(&size, &data)
+            }
+            StrikeKind::Ebdt(size, tables) => {
+                let location = size
+                    .location(tables.location.offset_data(), glyph_id)
+                    .ok()?;
+                let data = tables.data.data(&location).ok()?;
+                BitmapGlyph::from_bdt(&size, &data)
+            }
+        }
+    }
+}
+
+#[derive(Clone)]
+enum StrikeKind<'a> {
+    Sbix(sbix::Strike<'a>, GlyphMetrics<'a>),
+    Cbdt(bitmap::BitmapSize, CbdtTables<'a>),
+    Ebdt(bitmap::BitmapSize, EbdtTables<'a>),
+}
+
+#[derive(Clone)]
+struct BdtTables<L, D> {
+    location: L,
+    data: D,
+}
+
+impl<L, D> BdtTables<L, D> {
+    fn new(location: L, data: D) -> Self {
+        Self { location, data }
+    }
+}
+
+type CbdtTables<'a> = BdtTables<cblc::Cblc<'a>, cbdt::Cbdt<'a>>;
+type EbdtTables<'a> = BdtTables<eblc::Eblc<'a>, ebdt::Ebdt<'a>>;
+
+/// An embedded bitmap glyph.
+#[derive(Clone)]
+pub struct BitmapGlyph<'a> {
+    pub data: BitmapData<'a>,
+    pub bearing_x: f32,
+    pub bearing_y: f32,
+    pub inner_bearing_x: f32,
+    pub inner_bearing_y: f32,
+    pub ppem_x: f32,
+    pub ppem_y: f32,
+    pub advance: f32,
+    pub width: u32,
+    pub height: u32,
+    pub placement_origin: Origin,
+}
+
+impl<'a> BitmapGlyph<'a> {
+    fn from_bdt(
+        bitmap_size: &bitmap::BitmapSize,
+        bitmap_data: &bitmap::BitmapData<'a>,
+    ) -> Option<Self> {
+        let metrics = BdtMetrics::new(&bitmap_data);
+        let (ppem_x, ppem_y) = (bitmap_size.ppem_x() as f32, bitmap_size.ppem_y() as f32);
+        let bpp = bitmap_size.bit_depth();
+        let data = match bpp {
+            32 => {
+                match &bitmap_data.content {
+                    bitmap::BitmapContent::Data(bitmap::BitmapDataFormat::Png, bytes) => {
+                        BitmapData::Png(bytes)
+                    }
+                    // 32-bit formats are always byte aligned
+                    bitmap::BitmapContent::Data(bitmap::BitmapDataFormat::ByteAligned, bytes) => {
+                        BitmapData::Bgra(bytes)
+                    }
+                    _ => return None,
+                }
+            }
+            1 | 2 | 4 | 8 => {
+                let (data, is_packed) = match &bitmap_data.content {
+                    bitmap::BitmapContent::Data(bitmap::BitmapDataFormat::ByteAligned, bytes) => {
+                        (bytes, false)
+                    }
+                    bitmap::BitmapContent::Data(bitmap::BitmapDataFormat::BitAligned, bytes) => {
+                        (bytes, true)
+                    }
+                    _ => return None,
+                };
+                BitmapData::Mask(MaskData {
+                    bpp,
+                    is_packed,
+                    data,
+                })
+            }
+            // All other bit depth values are invalid
+            _ => return None,
+        };
+        Some(Self {
+            data,
+            bearing_x: 0.0,
+            bearing_y: 0.0,
+            inner_bearing_x: metrics.inner_bearing_x,
+            inner_bearing_y: metrics.inner_bearing_y,
+            ppem_x,
+            ppem_y,
+            width: metrics.width,
+            height: metrics.height,
+            advance: metrics.advance,
+            placement_origin: Origin::TopLeft,
+        })
+    }
+}
+
+struct BdtMetrics {
+    inner_bearing_x: f32,
+    inner_bearing_y: f32,
+    advance: f32,
+    width: u32,
+    height: u32,
+}
+
+impl BdtMetrics {
+    fn new(data: &bitmap::BitmapData) -> Self {
+        match data.metrics {
+            bitmap::BitmapMetrics::Small(metrics) => Self {
+                inner_bearing_x: metrics.bearing_x() as f32,
+                inner_bearing_y: metrics.bearing_y() as f32,
+                advance: metrics.advance() as f32,
+                width: metrics.width() as u32,
+                height: metrics.height() as u32,
+            },
+            bitmap::BitmapMetrics::Big(metrics) => Self {
+                inner_bearing_x: metrics.hori_bearing_x() as f32,
+                inner_bearing_y: metrics.hori_bearing_y() as f32,
+                advance: metrics.hori_advance() as f32,
+                width: metrics.width() as u32,
+                height: metrics.height() as u32,
+            },
+        }
+    }
+}
+
+/// Determines the origin point for drawing a bitmap glyph.
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub enum Origin {
+    TopLeft,
+    BottomLeft,
+}
+
+/// Data content of a bitmap.
+#[derive(Clone)]
+pub enum BitmapData<'a> {
+    /// Uncompressed 32-bit color bitmap data, pre-multiplied in BGRA order
+    /// and encoded in the sRGB color space.
+    Bgra(&'a [u8]),
+    /// Compressed PNG bitmap data.
+    Png(&'a [u8]),
+    /// Data representing a single channel alpha mask.
+    Mask(MaskData<'a>),
+}
+
+/// A single channel alpha mask.
+#[derive(Clone)]
+pub struct MaskData<'a> {
+    /// Number of bits-per-pixel. Always 1, 2, 4 or 8.
+    pub bpp: u8,
+    /// True if each row of the data is bit-aligned. Otherwise, each row
+    /// is padded to the next byte.
+    pub is_packed: bool,
+    /// Raw bitmap data.
+    pub data: &'a [u8],
+}
+
+/// The format (or table) containing the data backing a set of bitmap strikes.
+#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
+pub enum BitmapFormat {
+    Sbix,
+    Cbdt,
+    Ebdt,
+}
diff --git a/vello_tests/smoke_snapshots/single_emoji.png b/vello_tests/smoke_snapshots/single_emoji.png
deleted file mode 100644
index 90c7888..0000000
--- a/vello_tests/smoke_snapshots/single_emoji.png
+++ /dev/null
Binary files differ
diff --git a/vello_tests/smoke_snapshots/two_emoji.png b/vello_tests/smoke_snapshots/two_emoji.png
new file mode 100644
index 0000000..2ae63c5
--- /dev/null
+++ b/vello_tests/smoke_snapshots/two_emoji.png
Binary files differ
diff --git a/vello_tests/snapshots/big_bitmap.png b/vello_tests/snapshots/big_bitmap.png
new file mode 100644
index 0000000..f27109e
--- /dev/null
+++ b/vello_tests/snapshots/big_bitmap.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:261a21526b5f4f95751422baadea7b19dc4eb3a49be48fba21e3c0bbf3d24a9d
+size 14683
diff --git a/vello_tests/snapshots/big_colr.png b/vello_tests/snapshots/big_colr.png
new file mode 100644
index 0000000..40386f9
--- /dev/null
+++ b/vello_tests/snapshots/big_colr.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f10d642ad14b33815998273a8a44fb6ddb1d7b8e161b550d498200a584f8370f
+size 15853
diff --git a/vello_tests/snapshots/bitmap_undef.png b/vello_tests/snapshots/bitmap_undef.png
new file mode 100644
index 0000000..1a124e0
--- /dev/null
+++ b/vello_tests/snapshots/bitmap_undef.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f88a18a4b17eb2f3e4e8057a7ebc47930f2d4a94e8fc0bb397264bac93b29725
+size 289
diff --git a/vello_tests/snapshots/colr_undef.png b/vello_tests/snapshots/colr_undef.png
new file mode 100644
index 0000000..1a124e0
--- /dev/null
+++ b/vello_tests/snapshots/colr_undef.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f88a18a4b17eb2f3e4e8057a7ebc47930f2d4a94e8fc0bb397264bac93b29725
+size 289
diff --git a/vello_tests/snapshots/little_bitmap.png b/vello_tests/snapshots/little_bitmap.png
new file mode 100644
index 0000000..542f8b1
--- /dev/null
+++ b/vello_tests/snapshots/little_bitmap.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dcee2e5d10e4a95a3566de5d4d97d8b3ad02e749567c0faf6d67259932309df0
+size 1562
diff --git a/vello_tests/snapshots/little_colr.png b/vello_tests/snapshots/little_colr.png
new file mode 100644
index 0000000..64a1122
--- /dev/null
+++ b/vello_tests/snapshots/little_colr.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4aabea4b8cbf754fdf29c9d454f0880bfb9b0d380818d2ddb1d9128aa80fddb9
+size 2039
diff --git a/vello_tests/tests/emoji.rs b/vello_tests/tests/emoji.rs
new file mode 100644
index 0000000..1d8f9b8
--- /dev/null
+++ b/vello_tests/tests/emoji.rs
@@ -0,0 +1,134 @@
+// Copyright 2024 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Snapshot tests for Emoji [`scenes`].
+
+use scenes::SimpleText;
+use vello::{kurbo::Affine, peniko::Fill, Scene};
+use vello_tests::{snapshot_test_sync, TestParams};
+
+fn encode_noto_colr(text: &str, font_size: f32) -> Scene {
+    let mut scene = Scene::new();
+    let mut simple_text = SimpleText::new();
+    simple_text.add_colr_emoji_run(
+        &mut scene,
+        font_size,
+        Affine::translate((0., f64::from(font_size))),
+        None,
+        Fill::EvenOdd,
+        text,
+    );
+    scene
+}
+
+fn encode_noto_bitmap(text: &str, font_size: f32) -> Scene {
+    let mut scene = Scene::new();
+    let mut simple_text = SimpleText::new();
+    simple_text.add_bitmap_emoji_run(
+        &mut scene,
+        font_size,
+        Affine::translate((0., f64::from(font_size))),
+        None,
+        Fill::EvenOdd,
+        text,
+    );
+    scene
+}
+
+/// The Emoji supported by our font subset.
+const TEXT: &str = "✅👀🎉🤠";
+
+#[test]
+#[cfg_attr(skip_gpu_tests, ignore)]
+fn big_colr() {
+    let font_size = 48.;
+    let scene = encode_noto_colr(TEXT, font_size);
+    let params = TestParams::new(
+        "big_colr",
+        (font_size * 10.) as _,
+        // Noto Emoji seem to be about 25% bigger than the actual font_size suggests
+        (font_size * 1.25).ceil() as _,
+    );
+    snapshot_test_sync(scene, &params)
+        .unwrap()
+        .assert_mean_less_than(0.001);
+}
+
+#[test]
+#[cfg_attr(skip_gpu_tests, ignore)]
+fn little_colr() {
+    let font_size = 10.;
+    let scene = encode_noto_colr(TEXT, font_size);
+    let params = TestParams::new(
+        "little_colr",
+        (font_size * 10.) as _,
+        (font_size * 1.25).ceil() as _,
+    );
+    snapshot_test_sync(scene, &params)
+        .unwrap()
+        .assert_mean_less_than(0.002);
+}
+
+#[test]
+#[cfg_attr(skip_gpu_tests, ignore)]
+fn colr_undef() {
+    let font_size = 10.;
+    // This emoji isn't in the subset we have made
+    let scene = encode_noto_colr("🤷", font_size);
+    let params = TestParams::new(
+        "colr_undef",
+        (font_size * 10.) as _,
+        (font_size * 1.25).ceil() as _,
+    );
+    // TODO: Work out why the undef glyph is nothing - is it an issue with our font subset or with our renderer?
+    snapshot_test_sync(scene, &params)
+        .unwrap()
+        .assert_mean_less_than(0.001);
+}
+
+#[test]
+#[cfg_attr(skip_gpu_tests, ignore)]
+fn big_bitmap() {
+    let font_size = 48.;
+    let scene = encode_noto_bitmap(TEXT, font_size);
+    let params = TestParams::new(
+        "big_bitmap",
+        (font_size * 10.) as _,
+        (font_size * 1.25).ceil() as _,
+    );
+    snapshot_test_sync(scene, &params)
+        .unwrap()
+        .assert_mean_less_than(0.001);
+}
+
+#[test]
+#[cfg_attr(skip_gpu_tests, ignore)]
+fn little_bitmap() {
+    let font_size = 10.;
+    let scene = encode_noto_bitmap(TEXT, font_size);
+    let params = TestParams::new(
+        "little_bitmap",
+        (font_size * 10.) as _,
+        (font_size * 1.25).ceil() as _,
+    );
+    snapshot_test_sync(scene, &params)
+        .unwrap()
+        .assert_mean_less_than(0.001);
+}
+
+#[test]
+#[cfg_attr(skip_gpu_tests, ignore)]
+fn bitmap_undef() {
+    let font_size = 10.;
+    // This emoji isn't in the subset we have made
+    let scene = encode_noto_bitmap("🤷", font_size);
+    let params = TestParams::new(
+        "bitmap_undef",
+        (font_size * 10.) as _,
+        (font_size * 1.25).ceil() as _,
+    );
+    // TODO: Work out why the undef glyph is nothing - is it an issue with our font subset or with our renderer?
+    snapshot_test_sync(scene, &params)
+        .unwrap()
+        .assert_mean_less_than(0.001);
+}
diff --git a/vello_tests/tests/smoke_snapshots.rs b/vello_tests/tests/smoke_snapshots.rs
index 9bac177..1301581 100644
--- a/vello_tests/tests/smoke_snapshots.rs
+++ b/vello_tests/tests/smoke_snapshots.rs
@@ -47,10 +47,10 @@
         .assert_mean_less_than(0.01);
 }
 
-fn single_emoji(use_cpu: bool) {
+fn two_emoji(use_cpu: bool) {
     let mut scene = Scene::new();
     let mut text = SimpleText::new();
-    text.add_emoji_run(
+    text.add_colr_emoji_run(
         &mut scene,
         24.,
         Affine::translate((0., 24.)),
@@ -58,9 +58,17 @@
         Fill::NonZero,
         "🤠",
     );
+    text.add_bitmap_emoji_run(
+        &mut scene,
+        24.,
+        Affine::translate((30., 24.)),
+        None,
+        Fill::NonZero,
+        "🤠",
+    );
     let params = TestParams {
         use_cpu,
-        ..TestParams::new("single_emoji", 30, 30)
+        ..TestParams::new("two_emoji", 60, 30)
     };
     smoke_snapshot_test_sync(scene, &params)
         .unwrap()
@@ -95,12 +103,12 @@
 
 #[test]
 #[cfg_attr(skip_gpu_tests, ignore)]
-fn single_emoji_gpu() {
-    single_emoji(false);
+fn two_emoji_gpu() {
+    two_emoji(false);
 }
 
 #[test]
 #[cfg_attr(skip_gpu_tests, ignore)]
-fn single_emoji_cpu() {
-    single_emoji(true);
+fn two_emoji_cpu() {
+    two_emoji(true);
 }
diff --git a/vello_tests/tests/snapshots.rs b/vello_tests/tests/snapshot_test_scenes.rs
similarity index 96%
rename from vello_tests/tests/snapshots.rs
rename to vello_tests/tests/snapshot_test_scenes.rs
index 98894de..97090c8 100644
--- a/vello_tests/tests/snapshots.rs
+++ b/vello_tests/tests/snapshot_test_scenes.rs
@@ -1,8 +1,7 @@
 // Copyright 2024 the Vello Authors
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
-// Copyright 2024 the Vello Authors
-// SPDX-License-Identifier: Apache-2.0 OR MIT
+//! Snapshot tests using the test scenes from [`scenes`].
 
 use scenes::{test_scenes, ExampleScene};
 use vello_tests::{encode_test_scene, snapshot_test_sync, TestParams};