diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb8574b0..dcfb4c49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -230,7 +230,7 @@ jobs: - 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 76dfa8f6..08a2f445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ You can find its changes [documented below](#021---2024-07-16). 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 @@ This release has an [MSRV][] of 1.75. [#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 diff --git a/Cargo.lock b/Cargo.lock index 2c2699f5..5c7d5cf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2416,6 +2416,7 @@ dependencies = [ "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 00000000..a87b8c3b Binary files /dev/null and b/examples/assets/noto_color_emoji/NotoColorEmoji-CBTF-Subset.ttf differ diff --git a/examples/assets/noto_color_emoji/README.md b/examples/assets/noto_color_emoji/README.md index c9d0d065..66e6a396 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 842fb8c4..8f235696 100644 --- a/examples/scenes/src/simple_text.rs +++ b/examples/scenes/src/simple_text.rs @@ -14,13 +14,16 @@ use vello::Scene; // 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 @@ impl SimpleText { 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 @@ impl SimpleText { /// 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 @@ impl SimpleText { style: impl Into>, 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, + style: impl Into>, + 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 40db1894..5e895e0f 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -99,10 +99,18 @@ mod impls { 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 83ba98d5..7857c0c4 100644 --- a/vello/Cargo.toml +++ b/vello/Cargo.toml @@ -38,3 +38,5 @@ static_assertions = { workspace = true } 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 92aa3730..6c558ef2 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 @@ impl<'a> DrawGlyphs<'a> { /// 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>, glyphs: impl Iterator) { 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 @@ impl<'a> DrawGlyphs<'a> { 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 @@ impl<'a> DrawGlyphs<'a> { .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(); - - 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, + 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; + }; + + 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 @@ impl<'a> DrawGlyphs<'a> { } } } + +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 00000000..3795f550 --- /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 { + 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 { + 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> { + 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> { + // Return the largest size for an unscaled request + let size = size.ppem().unwrap_or(f32::MAX); + self.iter() + .fold(None, |best: Option>, 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> + '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> { + 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::(16).ok()?; + let height = reader.read_at::(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 { + location: L, + data: D, +} + +impl BdtTables { + fn new(location: L, data: D) -> Self { + Self { location, data } + } +} + +type CbdtTables<'a> = BdtTables, cbdt::Cbdt<'a>>; +type EbdtTables<'a> = BdtTables, 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 { + 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 90c78887..00000000 Binary files a/vello_tests/smoke_snapshots/single_emoji.png and /dev/null differ diff --git a/vello_tests/smoke_snapshots/two_emoji.png b/vello_tests/smoke_snapshots/two_emoji.png new file mode 100644 index 00000000..2ae63c5f Binary files /dev/null and b/vello_tests/smoke_snapshots/two_emoji.png differ diff --git a/vello_tests/snapshots/big_bitmap.png b/vello_tests/snapshots/big_bitmap.png new file mode 100644 index 00000000..f27109e6 --- /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 00000000..40386f9d --- /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 00000000..1a124e05 --- /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 00000000..1a124e05 --- /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 00000000..542f8b15 --- /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 00000000..64a11229 --- /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 00000000..1d8f9b87 --- /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, ¶ms) + .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, ¶ms) + .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, ¶ms) + .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, ¶ms) + .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, ¶ms) + .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, ¶ms) + .unwrap() + .assert_mean_less_than(0.001); +} diff --git a/vello_tests/tests/smoke_snapshots.rs b/vello_tests/tests/smoke_snapshots.rs index 9bac1773..13015815 100644 --- a/vello_tests/tests/smoke_snapshots.rs +++ b/vello_tests/tests/smoke_snapshots.rs @@ -47,10 +47,10 @@ fn filled_circle(use_cpu: bool) { .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 @@ fn single_emoji(use_cpu: bool) { 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, ¶ms) .unwrap() @@ -95,12 +103,12 @@ fn filled_circle_cpu() { #[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 98894ded..97090c81 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};