diff --git a/Cargo.lock b/Cargo.lock index 48bd7129..05b10e59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ "figcraft-geom", "harfrust 0.13.3", "log", + "png", "skrifa 0.47.0", "unicode-bidi", "unicode-segmentation", diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b27..a2bfed3c 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -531,21 +531,50 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, NodeKind::Text(t) => { let Some((layout, dy)) = figcraft_text::layout_node(n) else { return }; let gxf = xf * Affine::translate((0.0, dy)); + // Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a + // bitmap the font ships, or COLR layers with a colour each. + let gs = gxf.determinant().abs().sqrt(); + let mut ink: Vec<(usize, Ink)> = Vec::new(); // Group glyphs by style so runs with their own fills paint with them. let mut by_style: HashMap = HashMap::new(); for g in &layout.glyphs { - by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()); + match g.color(gs) { + Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))), + Some(figcraft_text::ColorInk::Layers(layers)) => { + for layer in layers.iter() { + ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba))); + } + } + None => by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()), + } } for d in &layout.decorations { by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied()); } - let mut keys: Vec = by_style.keys().copied().collect(); + // A run can have ink and no outline at all (bitmap-only fonts). + let mut keys: Vec = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect(); keys.sort_unstable(); - // Every run's fills, then what paints over the fills, then the strokes on top. + keys.dedup(); + // Every run's fills (its ink included), then what paints over the fills, then the + // strokes on top. for k in &keys { - let Some(tp) = by_style.get(k).and_then(ts_path) else { continue }; let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills); - fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None); + if let Some(tp) = by_style.get(k).and_then(ts_path) { + fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None); + } + for (_, item) in ink.iter().filter(|(style, _)| style == k) { + match item { + Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)), + Ink::Layer(path, rgba) => { + if let Some(tp) = ts_path(path) { + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]); + paint.anti_alias = true; + pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), None); + } + } + } + } } if let Some(f) = over_fills { f(pix); @@ -687,6 +716,23 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0)))); } +/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a +/// bitmap-strike glyph has no outline to stroke. +enum Ink { + /// An embedded bitmap and the transform from its pixels into layout coordinates. + Bitmap(Arc, Affine), + /// One COLR layer: its outline in layout coordinates and the colour to fill it with. + Layer(BezPath, [u8; 4]), +} + +/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the +/// transform scales it. +fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) { + let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return }; + let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; + pix.draw_pixmap(0, 0, image, &paint, transform, None); +} + fn is_axis_aligned(xf: Affine) -> bool { let c = xf.as_coeffs(); c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5]) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 167cc742..b851e9cd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -217,6 +217,37 @@ fn text_renders_something() { } } +/// The machine's colour emoji face, if it has one (this test skips without: no font ships here). +fn emoji_face() -> Option<(String, String)> { + figcraft_text::scan_system_fonts(); + let mut db = figcraft_text::db().write().unwrap(); + db.emoji_fallbacks().into_iter().find(|f| f.has_color()).map(|f| (f.family.clone(), f.style.clone())) +} + +#[test] +fn colour_emoji_draw_their_pixels() { + let Some((family, style)) = emoji_face() else { return }; + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let mut td = TextData::new("\u{1F600}"); + (td.style.font_family, td.style.font_style) = (family.clone(), style.clone()); + td.style.font_size = 48.0; + d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 64.0).unwrap(); + let pix = render_view(&d, p, &view(100, 80), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); + // The emoji is the only thing in this document that paints saturated colour. + let coloured = pix + .data() + .as_chunks::<4>() + .0 + .iter() + .filter(|c| { + let (r, g, b) = (i16::from(c[0]), i16::from(c[1]), i16::from(c[2])); + c[3] == 255 && (r.max(g).max(b) - r.min(g).min(b)) > 60 + }) + .count(); + assert!(coloured > 200, "{family} drew {coloured} coloured pixels"); +} + #[test] fn export_png_roundtrip() { let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap()); diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 273ff128..84a80858 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -13,6 +13,8 @@ figcraft-geom = { workspace = true } figcraft-doc = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } +# Colour emoji: sbix/CBDT strikes are PNG images the text layer decodes for the renderer. +png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs new file mode 100644 index 00000000..ca927a94 --- /dev/null +++ b/crates/text/src/color.rs @@ -0,0 +1,313 @@ +//! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers. +//! +//! Colour emoji fonts don't draw like other glyphs: the picture itself lives in a bitmap strike +//! (sbix, CBDT/EBDT) or in a stack of coloured layers, not in the glyph's outline — in Apple Color +//! Emoji most emoji are `sbix` bitmaps while the flags are drawn from outlines, and Noto Color +//! Emoji has no outlines at all. This module gives the renderer either a +//! premultiplied RGBA raster at the size it asked for, or the layer glyphs and the palette colours +//! to fill with its own outlines. Anything else (COLR v1's paint graphs, alpha-only strikes) is +//! left to the outline pipeline, which draws whatever outlines the face has, as before. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::PoisonError; + +use figcraft_geom::Affine; +use skrifa::bitmap::{BitmapData, BitmapGlyph, BitmapStrikes, Origin}; +use skrifa::color::ColorPalettes; +use skrifa::instance::Size; +use skrifa::raw::TableProvider; +use skrifa::{FontRef, GlyphId}; + +use crate::fontdb::{Coords, Face}; + +/// Colour rasters kept per face before the cache starts over, in bytes: they are pixels, and a +/// canvas zoomed far in makes each one large. +const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024; +/// Rasters kept per face (misses count too: a face whose strikes cover a few glyphs builds one +/// per glyph and size, and the zoom changes the size). +const MAX_RASTER_ENTRIES: usize = 8192; +/// COLR layer lists kept per face (24 bytes a layer, so a count is enough). +const MAX_LAYERS: usize = 4096; +/// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels). +const MAX_PNG_SIDE: u32 = 4096; +/// Largest colour raster per em (device pixels). Past this the renderer scales one up instead. +pub const MAX_PX_PER_EM: f64 = 512.0; + +/// A colour bitmap glyph: premultiplied RGBA at `size` px per em, and the offset of its top-left +/// corner from the glyph's pen, in px at `size` (y down). +#[derive(Debug)] +pub struct ColorBitmap { + pub width: u32, + pub height: u32, + pub left: f64, + pub top: f64, + pub rgba: Arc<[u8]>, +} + +/// One layer of a COLR v0 glyph: the glyph that draws the layer and the palette colour it fills +/// with (straight RGBA: the renderer paints it). +#[derive(Debug)] +pub struct ColorLayer { + pub gid: u32, + pub rgba: [u8; 4], +} + +/// A glyph's colour ink: what draws instead of its outline, placed in coordinates the caller +/// supplies (a bitmap pixel maps through `place`; COLR layers are drawn like the glyph's outline). +pub enum ColorInk { + Bitmap(Arc, Affine), + Layers(Arc>), +} + +/// Colour rasters cached per face, keyed by glyph, variations and size. +#[derive(Default)] +pub(crate) struct Rasters { + entries: HashMap<(u32, Coords, u64), Option>>, + bytes: usize, +} + +impl Rasters { + fn get(&self, key: &(u32, Coords, u64)) -> Option> { + self.entries.get(key).and_then(Option::clone) + } + + fn known(&self, key: &(u32, Coords, u64)) -> bool { + self.entries.contains_key(key) + } + + fn insert(&mut self, key: (u32, Coords, u64), bitmap: Option>) { + if let Some(b) = &bitmap { + self.bytes += b.rgba.len(); + } + if self.bytes > MAX_RASTER_BYTES || self.entries.len() > MAX_RASTER_ENTRIES { + self.entries.clear(); + self.bytes = bitmap.as_ref().map(|b| b.rgba.len()).unwrap_or(0); + } + self.entries.insert(key, bitmap); + } +} + +impl Face { + /// Whether the face has colour glyphs at all: bitmaps (sbix, CBDT/EBDT) or COLR layers. + pub fn has_color(&self) -> bool { + self.has_bitmaps() || self.has_colr() + } + + /// The face has bitmap strikes (`sbix`/`CBDT`/`EBDT`) + pub fn has_bitmaps(&self) -> bool { + self.bitmap_strikes + } + + /// The face has a `COLR` table. + pub fn has_colr(&self) -> bool { + self.colr + } + + /// The glyph's embedded bitmap, scaled to `size` px per em (premultiplied RGBA), with the offset + /// of its top-left corner from the pen in px at `size` (y down). `None` when the face has no + /// bitmap for the glyph. + pub fn color_bitmap(&self, gid: u32, coords: &Coords, size: f64) -> Option> { + if !self.bitmap_strikes || !size.is_finite() || size <= 0.0 { + return None; + } + let size = size.clamp(1.0, MAX_PX_PER_EM); + let key = (gid, coords.clone(), size.to_bits()); + let mut cache = self.rasters.lock().unwrap_or_else(PoisonError::into_inner); + if cache.known(&key) { + return cache.get(&key); + } + let made = self.read_bitmap(gid, size).map(Arc::new); + cache.insert(key, made.clone()); + made + } + + /// The glyph's COLR v0 layers (empty when the glyph has none: COLR v1 glyphs, or no COLR data). + pub fn color_layers(&self, gid: u32) -> Option>> { + if !self.colr { + return None; + } + let mut cache = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(hit) = cache.get(&gid) { + return hit.clone(); + } + let made = self.read_layers(gid).map(Arc::new); + if cache.len() >= MAX_LAYERS { + cache.clear(); + } + cache.insert(gid, made.clone()); + made + } + + /// Read a strike bitmap and place it the way Skia's fontations port does + /// (`src/ports/fontations/src/bitmap.rs`): the outer bearings are in font units and scale with + /// the em, the inner ones are pixels at the strike's ppem, and an sbix image is measured from + /// its lower-left corner (the spec places the graphic's origin at the glyph box's xMin, yMin). + fn read_bitmap(&self, gid: u32, size: f64) -> Option { + let gid = GlyphId::new(gid); + let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; + let strike = BitmapStrikes::new(&font).glyph_for_size(Size::new(size as f32), gid)?; + let (rgba, width, height) = decode(&strike)?; + if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 { + return None; + } + let (left, top) = placement(&strike, size, self.upem); + // Scale the strike's own pixels to the requested size (the caller blits them 1:1). + let scale = size / f64::from(strike.ppem_y.max(1.0)); + let w = ((f64::from(width) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32; + let h = ((f64::from(height) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32; + let rgba = if (w, h) == (width, height) { rgba } else { scaled(&rgba, width, height, w, h)? }; + Some(ColorBitmap { width: w, height: h, left, top, rgba: rgba.into() }) + } + + /// Read the COLR v0 layers of a glyph (`None` for a glyph the table doesn't cover, which is + /// every glyph of a COLR v1-only font). + fn read_layers(&self, gid: u32) -> Option> { + let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; + let colr = font.colr().ok()?; + let range = colr.v0_base_glyph(GlyphId::new(gid))?; + let palettes = ColorPalettes::new(&font); + let palette = palettes.get(0); + let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]); + let mut out = Vec::new(); + for index in range { + let (layer, color_index) = colr.v0_layer(index)?; + let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]); + out.push(ColorLayer { gid: layer.to_u32(), rgba }); + } + (!out.is_empty()).then_some(out) + } +} + +/// Where a strike's image sits from the pen: the image's top-left corner in px at `size` (y down). +fn placement(glyph: &BitmapGlyph, size: f64, upem: f64) -> (f64, f64) { + let units = size / upem.max(1.0); + let pixels = size / f64::from(glyph.ppem_y.max(1.0)); + let left = f64::from(glyph.inner_bearing_x) * pixels + f64::from(glyph.bearing_x) * units; + let above = match glyph.placement_origin { + // sbix: the origin is the image's lower-left corner, so the whole image hangs above it. + Origin::BottomLeft => f64::from(glyph.inner_bearing_y) + f64::from(glyph.height), + Origin::TopLeft => f64::from(glyph.inner_bearing_y), + }; + (left, -above * pixels - f64::from(glyph.bearing_y) * units) +} + +/// A strike's pixels as premultiplied RGBA (what the renderer's pixmaps hold), with their size. +fn decode(glyph: &BitmapGlyph<'_>) -> Option<(Vec, u32, u32)> { + match &glyph.data { + // The OpenType spec's PNG strikes are plain PNGs (straight alpha), so they are + // premultiplied here for the renderer. + BitmapData::Png(png) => { + let (rgba, width, height) = decode_png(png)?; + Some((premultiplied(rgba), width, height)) + } + // Bit depth 32 without PNG is BGRA with premultiplied channels (the spec says so). + BitmapData::Bgra(bgra) => { + let (width, height) = (glyph.width, glyph.height); + let mut rgba = bgra.get(..width as usize * height as usize * 4)?.to_vec(); + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + } + Some((rgba, width, height)) + } + // Alpha-only strikes would be masks, not colour: the outline pipeline draws those glyphs. + BitmapData::Mask(_) => None, + } +} + +#[cfg(test)] +#[path = "color_tests.rs"] +mod tests; + +/// A PNG strike as straight 8-bit RGBA. +fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { + let mut decoder = png::Decoder::new(std::io::Cursor::new(png)); + // Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA. + decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA); + let mut reader = decoder.read_info().ok()?; + // The header's size is checked before anything is allocated for it: a font can claim any size. + let (w, h) = reader.info().size(); + if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE { + return None; + } + let mut buf = vec![0u8; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buf).ok()?; + let (width, height) = (info.width, info.height); + let data = buf.get(..info.buffer_size())?; + let rgba = match info.color_type { + png::ColorType::Rgba => data.to_vec(), + png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + png::ColorType::Grayscale => data.iter().flat_map(|g| [*g, *g, *g, 255]).collect(), + png::ColorType::GrayscaleAlpha => data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(), + png::ColorType::Indexed => return None, + }; + Some((rgba, width, height)) +} + +/// Straight RGBA → premultiplied RGBA (in place). +fn premultiplied(mut rgba: Vec) -> Vec { + for px in rgba.chunks_exact_mut(4) { + let a = u32::from(px[3]); + for c in &mut px[..3] { + *c = ((u32::from(*c) * a + 127) / 255) as u8; + } + } + rgba +} + +/// Premultiplied RGBA scaled to `dw`×`dh`: a box average when shrinking, bilinear when growing. +fn scaled(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Option> { + if sw == 0 || sh == 0 || dw == 0 || dh == 0 { + return None; + } + let sample = |x: u32, y: u32| -> [u8; 4] { + let i = ((y.min(sh - 1) * sw + x.min(sw - 1)) * 4) as usize; + match src.get(i..i + 4) { + Some(px) => [px[0], px[1], px[2], px[3]], + None => [0, 0, 0, 0], + } + }; + let mut out = vec![0u8; dw as usize * dh as usize * 4]; + if dw >= sw && dh >= sh { + let at = |v: f64, n: u32| ((v + 0.5) * f64::from(n) - 0.5).clamp(0.0, f64::from(n - 1)); + for y in 0..dh { + let sy = at(f64::from(y) / f64::from(dh), sh); + let (y0, fy) = (sy.floor() as u32, sy.fract()); + for x in 0..dw { + let sx = at(f64::from(x) / f64::from(dw), sw); + let (x0, fx) = (sx.floor() as u32, sx.fract()); + let (p00, p10, p01, p11) = (sample(x0, y0), sample(x0 + 1, y0), sample(x0, y0 + 1), sample(x0 + 1, y0 + 1)); + let o = ((y * dw + x) * 4) as usize; + for c in 0..4 { + let top = f64::from(p00[c]) * (1.0 - fx) + f64::from(p10[c]) * fx; + let bottom = f64::from(p01[c]) * (1.0 - fx) + f64::from(p11[c]) * fx; + out[o + c] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8; + } + } + } + return Some(out); + } + let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh)); + for y in 0..dh { + let (y0, y1) = ((f64::from(y) * fy).floor() as u32, ((f64::from(y) + 1.0) * fy).ceil() as u32); + for x in 0..dw { + let (x0, x1) = ((f64::from(x) * fx).floor() as u32, ((f64::from(x) + 1.0) * fx).ceil() as u32); + let mut sum = [0.0f64; 4]; + let mut n = 0.0f64; + for sy in y0..y1.clamp(y0 + 1, sh) { + for sx in x0..x1.clamp(x0 + 1, sw) { + let px = sample(sx, sy); + for c in 0..4 { + sum[c] += f64::from(px[c]); + } + n += 1.0; + } + } + let o = ((y * dw + x) * 4) as usize; + for c in 0..4 { + out[o + c] = (sum[c] / n.max(1.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Some(out) +} diff --git a/crates/text/src/color_tests.rs b/crates/text/src/color_tests.rs new file mode 100644 index 00000000..fa92298b --- /dev/null +++ b/crates/text/src/color_tests.rs @@ -0,0 +1,230 @@ +//! Colour glyph behaviour (colour emoji): bitmap strikes, COLR layers, and where they sit. +//! +//! The placement cases are pure numbers, so they run everywhere. The rest reads the machine's +//! colour emoji face and skips (like the other suites) on a machine without one; which format it +//! uses depends on the platform (Apple Color Emoji is sbix, Noto Color Emoji is CBDT, Segoe UI +//! Emoji is COLR), so a machine exercises the paths its own font has. + +use super::*; +use crate::fontdb::{db, no_coords, scan_system_fonts}; +use crate::layout::layout; +use figcraft_doc::TextData; +use skrifa::bitmap::{BitmapData, BitmapGlyph, MaskData, Origin}; + +/// A strike glyph described by hand, for the placement cases. +fn glyph(width: u32, height: u32, origin: Origin, bearing: (f32, f32), inner: (f32, f32), ppem: f32) -> BitmapGlyph<'static> { + BitmapGlyph { + data: BitmapData::Png(&[]), + bearing_x: bearing.0, + bearing_y: bearing.1, + inner_bearing_x: inner.0, + inner_bearing_y: inner.1, + ppem_x: ppem, + ppem_y: ppem, + advance: None, + width, + height, + placement_origin: origin, + } +} + +/// A strike glyph with data, for the decoding cases (the fields they don't read stay neutral). +fn strike(data: BitmapData<'_>, width: u32, height: u32) -> BitmapGlyph<'_> { + BitmapGlyph { + data, + bearing_x: 0.0, + bearing_y: 0.0, + inner_bearing_x: 0.0, + inner_bearing_y: 0.0, + ppem_x: 10.0, + ppem_y: 10.0, + advance: None, + width, + height, + placement_origin: Origin::TopLeft, + } +} + +#[test] +fn bitmap_placement_follows_the_strike() { + // CBDT: bearings are pixels from the origin to the bitmap's left and top edges, read at the + // strike's ppem and scaled to the size asked for: 2 px at ppem 10 in a 20 px run → 4 px. + let cbdt = glyph(100, 50, Origin::TopLeft, (0.0, 0.0), (2.0, 8.0), 10.0); + let (left, top) = placement(&cbdt, 20.0, 1000.0); + assert_eq!((left, top), (4.0, -16.0), "the left edge sits right of the pen, the top above it"); + + // sbix: bearings and origin offsets are font units, and the image is placed by its lower-left + // corner, so its top is the whole image above the origin. + let sbix = glyph(10, 10, Origin::BottomLeft, (-1.0, -3.0), (1.0, 2.0), 10.0); + let (left, top) = placement(&sbix, 20.0, 1000.0); + assert!((left - (2.0 - 0.02)).abs() < 1e-9, "{left}"); + assert!((top - (-24.0 + 0.06)).abs() < 1e-9, "{top}"); + + // Doubling the size doubles the offset with it. + let scaled = glyph(10, 10, Origin::TopLeft, (0.0, 0.0), (1.0, 1.0), 10.0); + assert_eq!(placement(&scaled, 40.0, 1000.0).1, -4.0); + assert_eq!(placement(&scaled, 20.0, 1000.0).1, -2.0); +} + +/// A 2×2 PNG with straight alpha, the format strikes carry: row 0 is (255,0,0,128) then +/// (0,255,0,255), row 1 is (0,0,255,0) then (10,20,30,51). +const PNG_STRAIGHT: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x17, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xd0, + 0xc0, 0xf0, 0x1f, 0x08, 0x19, 0x80, 0x98, 0x4b, 0x44, 0xce, 0x18, 0x00, 0x34, 0xb8, 0x04, 0xec, 0xa7, 0x3b, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +#[test] +fn png_strikes_decode_to_premultiplied_rgba() { + let (rgba, width, height) = decode(&strike(BitmapData::Png(PNG_STRAIGHT), 2, 2)).unwrap(); + assert_eq!((width, height), (2, 2)); + assert_eq!( + rgba, + vec![128, 0, 0, 128, 0, 255, 0, 255, 0, 0, 0, 0, 2, 4, 6, 51], + "straight alpha times its own alpha, rounded: the renderer's surfaces are premultiplied" + ); +} + +#[test] +fn a_png_strike_claiming_a_huge_size_is_refused_before_anything_is_allocated() { + // PNG_STRAIGHT with its header saying 1 048 576 × 1 048 576 pixels (4 TiB as RGBA). + let mut png = PNG_STRAIGHT.to_vec(); + assert_eq!(crc32(&png[12..29]).to_be_bytes(), png[29..33], "the patched header passes its checksum"); + png[16..24].copy_from_slice(&[0, 0x10, 0, 0, 0, 0x10, 0, 0]); + let crc = crc32(&png[12..29]); + png[29..33].copy_from_slice(&crc.to_be_bytes()); + assert!(decode_png(&png).is_none()); +} + +/// The CRC-32 a PNG chunk ends with. +fn crc32(bytes: &[u8]) -> u32 { + !bytes.iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 })) +} + +#[test] +fn raw_bgra_strikes_are_already_premultiplied() { + // BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only + // moves the red and blue channels. + let (rgba, width, height) = decode(&strike(BitmapData::Bgra(&[17, 34, 51, 255, 4, 8, 12, 16]), 2, 1)).unwrap(); + assert_eq!((width, height), (2, 1)); + assert_eq!(rgba, vec![51, 34, 17, 255, 12, 8, 4, 16]); +} + +#[test] +fn strikes_that_are_not_colour_decode_to_nothing() { + let mask = MaskData { bpp: 8, is_packed: false, data: &[0; 16] }; + assert!(decode(&strike(BitmapData::Mask(mask), 2, 2)).is_none(), "alpha-only strikes are masks, not colour"); + assert!(decode(&strike(BitmapData::Png(b"not a png"), 2, 2)).is_none(), "a broken PNG is skipped, not drawn"); + assert!(decode(&strike(BitmapData::Bgra(&[1, 2, 3]), 2, 2)).is_none(), "a buffer shorter than the glyph is skipped"); +} + +#[test] +fn scaling_keeps_the_pixels_premultiplied() { + // A 4×4 face: one straight colour over a falling alpha, premultiplied by hand. + let straight = [220u32, 180, 60]; + let ramp = [255u32, 190, 128, 64, 0]; + let mut src = Vec::new(); + for y in 0..4 { + for x in 0..4 { + let a = ramp[(x + y) % ramp.len()]; + src.extend(straight.iter().map(|c| ((c * a + 127) / 255) as u8)); + src.push(a as u8); + } + } + for (dw, dh) in [(2, 2), (3, 4), (9, 7), (16, 16)] { + let out = scaled(&src, 4, 4, dw, dh).unwrap(); + assert_eq!(out.len(), (dw * dh * 4) as usize); + for px in out.chunks_exact(4) { + assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "{dw}×{dh}: {px:?}"); + } + } + assert!(scaled(&src, 4, 4, 0, 4).is_none() && scaled(&src, 0, 0, 4, 4).is_none(), "an empty side scales to nothing"); +} + +/// The machine's colour emoji face, if it has one (tests skip without: no font ships in the repo). +fn emoji_face() -> Option> { + scan_system_fonts(); + let mut db = db().write().unwrap(); + db.emoji_fallbacks().into_iter().find(|f| f.has_color()) +} + +#[test] +fn colour_emoji_carry_their_own_pixels() { + let Some(face) = emoji_face() else { return }; + // A grinning face: a bitmap strike (Apple, Noto) or COLR layers (Segoe) must cover it. + let gid = face.glyph_for('\u{1F600}'); + assert_ne!(gid, 0, "U+1F600 is not in {}", face.family); + if let Some(layers) = face.color_layers(gid) { + assert!(!layers.is_empty()); + assert!(layers.len() < 64, "{} layers", layers.len()); + assert!(layers.iter().all(|l| l.gid != 0), "every layer names a glyph: {layers:?}"); + return; + } + let Some(bitmap) = face.color_bitmap(gid, &no_coords(), 32.0) else { + panic!("{} has colour tables but draws nothing for U+1F600", face.family); + }; + // The image is a sane size for 32 px of text and its pixels are premultiplied (what the + // renderer's surfaces hold), so no channel is brighter than its alpha. + assert!(bitmap.width >= 8 && bitmap.width <= 128, "{} px wide", bitmap.width); + assert!(bitmap.height >= 8 && bitmap.height <= 128, "{} px tall", bitmap.height); + assert_eq!(bitmap.rgba.len(), bitmap.width as usize * bitmap.height as usize * 4); + let mut opaque = 0; + for px in bitmap.rgba.chunks_exact(4) { + assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "premultiplied: {px:?}"); + opaque += usize::from(px[3] == 255); + } + assert!(opaque > 20, "the glyph draws something: {opaque} opaque pixels"); + // A bigger strike is scaled to a bigger image, and a smaller one to a smaller image. + let small = face.color_bitmap(gid, &no_coords(), 16.0); + let large = face.color_bitmap(gid, &no_coords(), 64.0); + if let (Some(small), Some(large)) = (small, large) { + assert!(small.width < bitmap.width && bitmap.width < large.width, "{} {} {}", small.width, bitmap.width, large.width); + } +} + +#[test] +fn emoji_in_plain_text_draw_from_a_colour_face() { + // A symbol font among the fallbacks (Windows has one) draws U+1F600 in one colour: the emoji + // face comes first for it. + if emoji_face().is_none() { + return; + } + let l = layout(&TextData::new("Hi \u{1F600}"), None, None); + assert!(l.glyphs.iter().any(|g| g.color(1.0).is_some()), "the emoji has colour ink"); +} + +#[test] +fn glyphs_place_their_ink_at_a_scale() { + let Some(face) = emoji_face() else { return }; + let mut t = TextData::new("\u{1F600}"); + (t.style.font_family, t.style.font_style) = (face.family.clone(), face.style.clone()); + t.style.font_size = 32.0; + let l = layout(&t, None, None); + let Some(g) = l.glyphs.first() else { return }; + // Zoomed 2:1 the glyph asks for a 64 px raster and places it in layout coordinates. + let Some(ColorInk::Bitmap(bitmap, place)) = g.color(2.0) else { + assert!(g.color(2.0).is_some(), "the emoji glyph has ink"); + return; + }; + assert!(bitmap.width >= 32 && bitmap.width <= 65, "{} px at 2:1", bitmap.width); + let corner = place * figcraft_geom::Point::new(0.0, 0.0); + assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x); + assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y); + // Past the raster cap the image stops growing (a canvas zoomed far in). + let Some(ColorInk::Bitmap(huge, _)) = g.color(1000.0) else { return }; + assert!(f64::from(huge.width) <= MAX_PX_PER_EM + 1.0, "{} px", huge.width); +} + +#[test] +fn text_without_colour_ink_keeps_its_outlines() { + // Faces without colour tables answer "no ink" without touching the font again, so plain text + // (the whole canvas, most of the time) pays one flag per glyph. + let Some(face) = crate::fontdb::find_face("Inter", "Regular") else { return }; + if face.has_color() { + return; + } + let gid = face.glyph_for('A'); + assert!(gid != 0, "Inter draws A"); + assert!(face.color_layers(gid).is_none() && face.color_bitmap(gid, &no_coords(), 32.0).is_none()); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc..525af037 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -11,6 +11,8 @@ use skrifa::raw::{FileRef, TableProvider}; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; +use crate::color::{ColorLayer, Rasters}; + /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { pub family: &'static str, @@ -107,6 +109,11 @@ pub struct Face { charmap: HashMap, outlines: Mutex>>, advances: Mutex>, + /// Colour glyph tables (see [`crate::color`]): bitmap strikes and/or COLR layers. + pub(crate) bitmap_strikes: bool, + pub(crate) colr: bool, + pub(crate) rasters: Mutex, + pub(crate) layers: Mutex>>>>, } impl std::fmt::Debug for Face { @@ -139,6 +146,9 @@ impl Face { gsub_features.dedup(); let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect(); let (axes, instances) = variation_info(&f); + let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some(); + let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT"); + let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3); let weight = { @@ -169,6 +179,10 @@ impl Face { bytes, outlines: Mutex::new(HashMap::new()), advances: Mutex::new(HashMap::new()), + bitmap_strikes, + colr, + rasters: Mutex::new(Rasters::default()), + layers: Mutex::new(HashMap::new()), }) } @@ -613,7 +627,6 @@ impl FontDb { "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", - "Apple Color Emoji", "Noto Sans Arabic", "Apple Symbols", "Segoe UI Symbol", @@ -625,6 +638,19 @@ impl FontDb { out } + /// Colour emoji faces, for characters no other fallback covers. Kept out of + /// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~180 MB) + /// and only texts that actually show emoji need them; each family reads one face. + pub fn emoji_fallbacks(&mut self) -> Vec> { + let mut out = Vec::new(); + for fam in ["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"] { + if let Some(f) = self.find_one(fam, "Regular") { + out.push(f); + } + } + out + } + pub fn face_count(&self) -> usize { self.faces.len() } @@ -699,8 +725,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { Some(buf) } -/// (index, family, style) of each outline face (one we can draw) in a font file. +/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color +/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all). fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), @@ -753,7 +782,10 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { (0..count.min(64)) .filter_map(|i| { let f = skrifa::FontRef::from_index(bytes, i).ok()?; - [b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?; + [b"glyf", b"CFF ", b"CFF2", b"sbix", b"CBDT", b"EBDT", b"COLR"] + .iter() + .any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()) + .then_some(())?; let (family, style) = names(&f); let family = family.filter(|f| !f.starts_with('.'))?; Some((i, family, style.unwrap_or_else(|| "Regular".into()))) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b5b617d0..dcd1eed2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -10,6 +10,7 @@ use figcraft_doc::{ use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; +use crate::color::{ColorInk, MAX_PX_PER_EM}; use crate::fontdb::{Coords, Face, db, no_coords}; /// A positioned glyph (pen position on the baseline, layout coordinates, y down). @@ -37,11 +38,34 @@ pub struct Glyph { impl Glyph { /// The glyph outline placed in layout coordinates. pub fn path(&self) -> BezPath { + self.layer_path(self.gid) + } + + /// The outline of one of the face's glyphs (a COLR layer), placed like this glyph. + pub fn layer_path(&self, gid: u32) -> BezPath { let s = self.size / self.face.upem; - let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone(); + let mut p = (*self.face.outline_at(gid, &self.coords)).clone(); p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y])); p } + + /// The glyph's colour ink at `scale` device pixels per layout unit, placed in layout + /// coordinates (a bitmap's pixels map through the affine; COLR layers are drawn like the + /// outline). `None` — the usual case — means the outline is what draws. + pub fn color(&self, scale: f64) -> Option { + if !scale.is_finite() || scale <= 0.0 { + return None; + } + if let Some(layers) = self.face.color_layers(self.gid) { + return Some(ColorInk::Layers(layers)); + } + // Whole device pixels per em: rasters stay on the pixel grid and the cache stays small. + let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM); + let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?; + let s = px / self.size; + let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s); + Some(ColorInk::Bitmap(bitmap, place)) + } } #[derive(Clone, Debug)] @@ -299,11 +323,16 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) span_style.push(idx); } let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); - let fallbacks = dbw.fallbacks(); + let mut fallbacks = dbw.fallbacks(); let mut faces: Vec>> = Vec::with_capacity(styles.len()); for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); } + // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only + // for a text with emoji, or with characters nothing above covers. + let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; + fallbacks.extend(emoji.iter().cloned()); drop(dbw); // OpenType features the user toggled (global for the node). @@ -334,7 +363,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) if c == '\n' || primary.has_char(c) { return primary.clone(); } - fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) + // Emoji take a colour face before the symbol fonts, which draw many of them in one colour. + let colour = if is_emoji(c) { emoji.iter().find(|f| f.has_char(c)) } else { None }; + colour.or_else(|| fallbacks.iter().find(|f| f.has_char(c))).cloned().unwrap_or_else(|| primary.clone()) }; let first = chars.get(seg_start).copied().unwrap_or(' '); let face = face_for(first); @@ -958,6 +989,13 @@ fn sanitize_size(s: f64) -> f64 { if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 } } +/// A character drawn as a colour emoji by default: flags and the pictograph blocks. +/// shortcut: the older symbols (U+2600–U+27BF) keep their text look even before U+FE0F; read the +/// emoji presentation data if they should turn colour too. +fn is_emoji(c: char) -> bool { + matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 101d8bc5..27456d82 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,12 +7,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod breaks; +pub mod color; pub mod display; pub mod fontdb; mod layout; mod onpath; pub use breaks::grapheme_step; +pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,