diff --git a/src/crates/concat-text/src/lib.rs b/src/crates/concat-text/src/lib.rs index be7ad4137..e245ad515 100644 --- a/src/crates/concat-text/src/lib.rs +++ b/src/crates/concat-text/src/lib.rs @@ -5,9 +5,9 @@ //! //! A text clip is a style and some words; the compositor wants a picture. This //! crate is the step between: it finds the face, shapes each line, turns the -//! glyph outlines into paths, and paints them - plate, shadow, outline, fill - -//! onto a canvas the size of the output frame, transparent everywhere the -//! words are not. +//! glyph outlines into paths, and paints them - plate, shadow, outline, fill, +//! and the glyphs that paint their own colours - onto a canvas the size of +//! the output frame, transparent everywhere the words are not. //! //! Frame-sized on purpose. The compositor places a picture by fitting it into //! the frame and then applying the clip's transform about its centre, so a @@ -35,8 +35,8 @@ use std::ops::Range; use std::sync::{Arc, Mutex}; use tiny_skia::{ - Color, FillRule, LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Rect, - Stroke, Transform, + Color, FillRule, FilterQuality, LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, + PixmapPaint, Rect, Stroke, Transform, }; use unicode_bidi::ParagraphBidiInfo; use unicode_linebreak::BreakOpportunity; @@ -214,10 +214,28 @@ struct Cache { /// Each face a title has been set in, opened once, with its index in /// its collection. data: HashMap, - /// Per character, every face that can draw it, in the database's - /// order. Learnt a title's missing characters at a time, since each - /// lesson reads every face; emptied when a font is added. - covers: HashMap>, + /// Per character, the faces that can draw it; learnt a title's missing + /// characters at a time, since each lesson reads every face; emptied + /// when a font is added. + covers: HashMap, +} + +/// The faces that can draw one character: those that paint colours of their +/// own first, then the rest in the database's order. An emoji is wanted in +/// colour whatever the title's weight, so a monochrome face that also has +/// it - DejaVu, Symbola, hundreds of glyphs each - is only reached when no +/// colour face does. +#[derive(Default)] +struct Covers { + colour: Vec, + plain: Vec, +} + +impl Covers { + /// Whether `id` is one of them. + fn has(&self, id: fontdb::ID) -> bool { + self.colour.contains(&id) || self.plain.contains(&id) + } } impl Default for Fonts { @@ -448,7 +466,7 @@ impl Fonts { return; } for &ch in chars { - cache.covers.insert(ch, Vec::new()); + cache.covers.insert(ch, Covers::default()); } for info in self.db.faces() { // Apple's last resort draws every character as a labelled box: @@ -465,10 +483,13 @@ impl Fonts { return; }; for &ch in chars { - if draws(&face, ch) - && let Some(faces) = cache.covers.get_mut(&ch) - { - faces.push(info.id); + let (Some(how), Some(covers)) = (draws(&face, ch), cache.covers.get_mut(&ch)) + else { + continue; + }; + match how { + Draw::Colour => covers.colour.push(info.id), + Draw::Outline => covers.plain.push(info.id), } } }); @@ -488,9 +509,10 @@ impl Fonts { let mut out = vec![0; text.len()]; let mut last = 0; for (at, ch) in text.char_indices() { - let covers = cache.covers.get(&ch).map_or(&[][..], Vec::as_slice); - let lends = - |ids: &[fontdb::ID], index: usize| index != 0 && covers.contains(&ids[index]); + let covers = cache.covers.get(&ch); + let lends = |ids: &[fontdb::ID], index: usize| { + index != 0 && covers.is_some_and(|covers| covers.has(ids[index])) + }; let index = if joins(ch) && at > 0 { // A mark, a joiner, a skin tone: with what it modifies. last @@ -512,17 +534,22 @@ impl Fonts { out } - /// Of the faces in `covers`, the one closest to the style's slant and - /// weight; the database's order breaks a tie. - fn nearest(&self, covers: &[fontdb::ID], want: Want) -> Option { - covers - .iter() - .filter_map(|&id| self.db.face(id)) - .min_by_key(|info| { - let italic = info.style != fontdb::Style::Normal; - (italic != want.italic, info.weight.0.abs_diff(want.weight)) - }) - .map(|info| info.id) + /// Of the faces that cover the character, the one closest to the style's + /// slant and weight; the database's order breaks a tie. A colour face + /// wins before weight is considered: its colours are the point of it, + /// and a title in a heavy weight still wants its emoji in colour. + fn nearest(&self, covers: Option<&Covers>, want: Want) -> Option { + let covers = covers?; + let closest = |ids: &[fontdb::ID]| { + ids.iter() + .filter_map(|&id| self.db.face(id)) + .min_by_key(|info| { + let italic = info.style != fontdb::Style::Normal; + (italic != want.italic, info.weight.0.abs_diff(want.weight)) + }) + .map(|info| info.id) + }; + closest(&covers.colour).or_else(|| closest(&covers.plain)) } /// A face's bytes, opened once and kept. @@ -563,10 +590,21 @@ struct Casting { paragraphs: Vec>, } -/// Whether `face` can draw `ch`: it has a glyph, and the glyph is an outline. -/// A colour emoji face maps its characters to pictures with no outline, -/// which would paint nothing here, so it does not count. -fn draws(face: &ttf_parser::Face<'_>, ch: char) -> bool { +/// What `face` draws `ch` with, when it draws it: colours of its own, or +/// an outline for the title's fill to colour. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Draw { + /// A bitmap strike or layered `COLR` paints - a picture the face + /// colours itself, as an emoji is. + Colour, + /// An outline the words' fill colours. + Outline, +} + +/// Whether `face` can draw `ch`, and how: it has a glyph, and the glyph +/// either carries colours the face paints on its own - a colour emoji +/// face's picture, which has no outline to fill - or is an outline. +fn draws(face: &ttf_parser::Face<'_>, ch: char) -> Option { struct Nowhere; impl ttf_parser::OutlineBuilder for Nowhere { fn move_to(&mut self, _: f32, _: f32) {} @@ -575,10 +613,103 @@ fn draws(face: &ttf_parser::Face<'_>, ch: char) -> bool { fn curve_to(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32) {} fn close(&mut self) {} } - let Some(glyph) = face.glyph_index(ch) else { + let glyph = face.glyph_index(ch)?; + if own_colours(face, glyph) { + return Some(Draw::Colour); + } + (ch.is_whitespace() || face.outline_glyph(glyph, &mut Nowhere).is_some()) + .then_some(Draw::Outline) +} + +/// Whether `glyph` paints colours of its own that this crate can draw: a +/// bitmap strike in a colour format, or the layered paints of a `COLR` +/// face simple enough to follow - solid fills over outlines, which is all +/// a layered emoji face needs. A monochrome strike is not one; that is +/// the outline's job, done better. An `SVG` document would want another +/// renderer whole, and stays out. https://github.com/jub0t/Concat/issues/276 +fn own_colours(face: &ttf_parser::Face<'_>, glyph: ttf_parser::GlyphId) -> bool { + if let Some(image) = face.glyph_raster_image(glyph, u16::MAX) + && matches!( + image.format, + ttf_parser::RasterImageFormat::PNG | ttf_parser::RasterImageFormat::BitmapPremulBgra32 + ) + { + return true; + } + if !face.is_color_glyph(glyph) { return false; - }; - ch.is_whitespace() || face.outline_glyph(glyph, &mut Nowhere).is_some() + } + // Probed rather than trusted: a `COLR` v1 glyph drawn with gradients + // or clips would come out wrong, and a face that only has those would + // draw nothing at all - a box, or a fallback face, beats that. + Layering::of(face, glyph, ttf_parser::RgbaColor::new(0, 0, 0, 255)).is_some() +} + +/// What a face's `COLR` says about one glyph: the layers it paints, as +/// glyph and palette colour, in the order they go down - and whether it +/// painted only what this crate follows. A gradient, a clip, a transform +/// or a composite mode would come out wrong or not at all, so the glyph +/// falls back to its outline instead. +struct Layering { + layers: Vec<(ttf_parser::GlyphId, ttf_parser::RgbaColor)>, + /// The outline awaiting its colour; `COLR` pairs them. + pending: Option, + /// Something outside the simple case turned up. + other: bool, +} + +impl Layering { + /// The layers `COLR` paints for `glyph`, or `None` when it paints none + /// or paints them in a way this crate does not follow. + fn of( + face: &ttf_parser::Face<'_>, + glyph: ttf_parser::GlyphId, + foreground: ttf_parser::RgbaColor, + ) -> Option> { + let mut layering = Layering { + layers: Vec::new(), + pending: None, + other: false, + }; + face.paint_color_glyph(glyph, 0, foreground, &mut layering)?; + (!layering.other && layering.pending.is_none() && !layering.layers.is_empty()) + .then_some(layering.layers) + } +} + +impl<'a> ttf_parser::colr::Painter<'a> for Layering { + fn outline_glyph(&mut self, glyph_id: ttf_parser::GlyphId) { + self.pending = Some(glyph_id); + } + fn paint(&mut self, paint: ttf_parser::colr::Paint<'a>) { + match (paint, self.pending.take()) { + (ttf_parser::colr::Paint::Solid(colour), Some(glyph_id)) => { + self.layers.push((glyph_id, colour)); + } + _ => self.other = true, + } + } + fn push_clip(&mut self) { + self.other = true; + } + fn push_clip_box(&mut self, _: ttf_parser::colr::ClipBox) { + self.other = true; + } + fn pop_clip(&mut self) { + self.other = true; + } + fn push_layer(&mut self, _: ttf_parser::colr::CompositeMode) { + self.other = true; + } + fn pop_layer(&mut self) { + self.other = true; + } + fn push_transform(&mut self, _: ttf_parser::Transform) { + self.other = true; + } + fn pop_transform(&mut self) { + self.other = true; + } } /// A character that belongs with the one before it and is set in its face: @@ -595,10 +726,33 @@ fn joins(ch: char) -> bool { /// pen space, left to right. struct Line { path: Option, + /// The glyphs that paint their own colours - a picture rather than an + /// outline - at that same pen origin, for laying over the words. The + /// fill would wash their colours out, so they stay out of `path`. + marks: Vec, width: f32, words: Vec<(f32, f32)>, } +/// A glyph that colours itself, in pen space at its line's origin: an +/// emoji, or a layered `COLR` face's glyphs. Drawn after the words, with +/// no shadow and no outline of their own - they are pictures, and a box +/// around them would be a box. https://github.com/jub0t/Concat/issues/276 +enum Mark { + /// The font's own bitmap, decoded, at its box: left edge, top edge, + /// and size in pixels. + Bitmap { + x: f32, + y: f32, + width: f32, + height: f32, + pixmap: Pixmap, + }, + /// Outlines each with the colour the face paints it in, already + /// placed. + Layers(Vec<(Path, Color)>), +} + /// A colour from `#rrggbb` or `#rrggbbaa`; anything else is `None`. fn colour(hex: &str) -> Option { let hex = hex.trim().strip_prefix('#')?; @@ -610,6 +764,16 @@ fn colour(hex: &str) -> Option { } } +/// The same colour in the bytes a font's palette wants. +fn rgba(color: Color) -> ttf_parser::RgbaColor { + ttf_parser::RgbaColor::new( + (color.red() * 255.0).round() as u8, + (color.green() * 255.0).round() as u8, + (color.blue() * 255.0).round() as u8, + (color.alpha() * 255.0).round() as u8, + ) +} + /// Collects a glyph's outline, scaled and placed, into a path under /// construction. The font's y goes up; the canvas's goes down. struct Outliner<'a> { @@ -657,6 +821,9 @@ struct Setter<'a> { faces: &'a [rustybuzz::Face<'a>], em: f32, tracking: f32, + /// The words' colour, for the `COLR` palette entry that means "paint + /// it with the text's own colour". + foreground: ttf_parser::RgbaColor, } /// One line of the content, ready to be set: its text, its directions, @@ -723,12 +890,14 @@ impl Setter<'_> { if range.is_empty() { return Line { path: None, + marks: Vec::new(), width: 0.0, words: Vec::new(), }; } let (levels, runs) = para.bidi.visual_runs(range.clone()); let mut builder = PathBuilder::new(); + let mut marks: Vec = Vec::new(); let mut pen = 0.0_f32; // Each glyph's cluster, as a byte into the paragraph, and the span // it advances over. @@ -765,16 +934,25 @@ impl Setter<'_> { .zip(shaped.glyph_positions().iter()) { if outline { - let mut outliner = Outliner { - builder: &mut builder, - scale, - x: pen + position.x_offset as f32 * scale, - y: -(position.y_offset as f32 * scale), - }; - face.outline_glyph( - ttf_parser::GlyphId(info.glyph_id as u16), - &mut outliner, - ); + let glyph = ttf_parser::GlyphId(info.glyph_id as u16); + let x = pen + position.x_offset as f32 * scale; + let y = -(position.y_offset as f32 * scale); + // A glyph that colours itself - a strike's + // picture, layered paints - goes into the line's + // marks instead of its path: the words' fill + // would wash its colours out. Anything else is + // an outline, as always. + if let Some(mark) = self.mark(face, glyph, x, y, scale) { + marks.push(mark); + } else { + let mut outliner = Outliner { + builder: &mut builder, + scale, + x, + y, + }; + face.outline_glyph(glyph, &mut outliner); + } } let advance = position.x_advance as f32 * scale; spans.push((piece.start + info.cluster as usize, pen, pen + advance)); @@ -805,10 +983,91 @@ impl Setter<'_> { }; Line { path: builder.finish(), + marks, width, words, } } + + /// A glyph's own colours, at its origin `(x, y)` on the baseline: its + /// font's bitmap, decoded and boxed to the strike's size, or its + /// `COLR` layers outlined in their palette colours. `None` for a plain + /// outline - most glyphs - and for a colour glyph this crate does not + /// follow, which falls back to its outline as before. + fn mark( + &self, + face: &rustybuzz::Face<'_>, + glyph: ttf_parser::GlyphId, + x: f32, + y: f32, + scale: f32, + ) -> Option { + if let Some(image) = face.glyph_raster_image(glyph, u16::MAX) + && matches!( + image.format, + ttf_parser::RasterImageFormat::PNG + | ttf_parser::RasterImageFormat::BitmapPremulBgra32 + ) + && let Some(pixmap) = bitmap(&image) + { + // The strike's pixels are in the font's y-up, measured from + // the glyph's origin to the bitmap's *bottom* edge - both for + // `CBDT` and for `sbix` - while the canvas goes down. `k` + // turns a strike pixel into canvas pixels at this size. + let k = self.em / f32::from(image.pixels_per_em.max(1)); + let height = f32::from(image.height) * k; + return Some(Mark::Bitmap { + x: x + f32::from(image.x) * k, + y: y - (f32::from(image.y) * k) - height, + width: f32::from(image.width) * k, + height, + pixmap, + }); + } + let layers = Layering::of(face, glyph, self.foreground)?; + let mut placed = Vec::with_capacity(layers.len()); + for (glyph_id, colour) in layers { + let mut builder = PathBuilder::new(); + face.outline_glyph( + glyph_id, + &mut Outliner { + builder: &mut builder, + scale, + x, + y, + }, + ); + // A layer with no outline paints nothing; the rest still do. + if let Some(path) = builder.finish() { + placed.push(( + path, + Color::from_rgba8(colour.red, colour.green, colour.blue, colour.alpha), + )); + } + } + (!placed.is_empty()).then_some(Mark::Layers(placed)) + } +} + +/// The strike's bitmap, decoded into pixels the canvas can lay over the +/// words: premultiplied RGBA, whether it arrived as a PNG or as raw +/// `BGRA`. Colour formats only - a monochrome strike is the outline's +/// job, and this crate's caller has an outline to fall back to. +fn bitmap(image: &ttf_parser::RasterGlyphImage<'_>) -> Option { + match image.format { + ttf_parser::RasterImageFormat::PNG => Pixmap::decode_png(image.data).ok(), + ttf_parser::RasterImageFormat::BitmapPremulBgra32 => { + let size = tiny_skia::IntSize::from_wh(image.width.into(), image.height.into())?; + let mut rgba = Vec::with_capacity(image.data.len()); + for pixel in image.data.chunks_exact(4) { + rgba.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } + // `from_vec` checks the length, so a strike that lies about + // its size comes out `None` rather than skewed. + Pixmap::from_vec(rgba, size) + } + _ => None, + } } /// Line separators other than the newline the content is split at: a line @@ -1008,11 +1267,14 @@ fn paint( }; // Shape every line with the pen at the origin; placement comes after, - // once the block's width is known. + // once the block's width is known. The words' colour goes along: a + // `COLR` palette entry of 0xFFFF means "paint it with the text's own". + let fill = colour(&style.color).unwrap_or(Color::WHITE); let setter = Setter { faces: &faces, em, tracking, + foreground: rgba(fill), }; let lines: Vec = style .content @@ -1067,22 +1329,24 @@ fn paint( // One path for all the words, placed. Each line is aligned within the // box's width and sits on its own baseline. A word's box rides the - // same indent and baseline, in reading order. + // same indent and baseline, in reading order - a line that paints + // its glyphs' own colours has no path to place but still has words. let mut words = PathBuilder::new(); let mut word_rects = Vec::new(); for (row, line) in lines.iter().enumerate() { - let Some(path) = &line.path else { continue }; let indent = match style.align { Align::Left => 0.0, Align::Center => (box_w - line.width) / 2.0, Align::Right => box_w - line.width, }; let baseline = top + ascent + row as f32 * pitch; - let placed = path - .clone() - .transform(Transform::from_translate(left + indent, baseline)) - .expect("a translated glyph path stays finite"); - words.push_path(&placed); + if let Some(path) = &line.path { + let placed = path + .clone() + .transform(Transform::from_translate(left + indent, baseline)) + .expect("a translated glyph path stays finite"); + words.push_path(&placed); + } let line_top = baseline - ascent; for &(start_x, end_x) in &line.words { word_rects.push(WordRect { @@ -1093,13 +1357,7 @@ fn paint( }); } } - let Some(words) = words.finish() else { - return Ok(( - canvas, - (outer_w.round() as u32, outer_h.round() as u32, block_dx, 0), - word_rects, - )); - }; + let words = words.finish(); let mut paint = Paint { anti_alias: true, @@ -1128,6 +1386,7 @@ fn paint( // The shadow: the words again, offset down and right, black, blurred on // a layer of their own and laid under the real words. if style.shadow + && let Some(words) = &words && let Some(mut layer) = Pixmap::new(width, height) { let mut shade = Paint { @@ -1136,7 +1395,7 @@ fn paint( }; shade.set_color(Color::from_rgba8(0, 0, 0, 150)); let offset = Transform::from_translate(em * 0.05, em * 0.07); - layer.fill_path(&words, &shade, FillRule::Winding, offset, None); + layer.fill_path(words, &shade, FillRule::Winding, offset, None); blur(&mut layer, (em * 0.04).round().max(1.0) as usize); canvas.draw_pixmap( 0, @@ -1153,6 +1412,7 @@ fn paint( let stroke_w = style.stroke_width.max(0.0) as f32 * frame_h; if stroke_w > 0.0 && let Some(edge) = colour(&style.stroke_color) + && let Some(words) = &words { paint.set_color(edge); let stroke = Stroke { @@ -1161,18 +1421,78 @@ fn paint( line_cap: LineCap::Round, ..Stroke::default() }; - canvas.stroke_path(&words, &paint, &stroke, Transform::identity(), None); + canvas.stroke_path(words, &paint, &stroke, Transform::identity(), None); } // The words. - paint.set_color(colour(&style.color).unwrap_or(Color::WHITE)); - canvas.fill_path( - &words, - &paint, - FillRule::Winding, - Transform::identity(), - None, - ); + if let Some(words) = &words { + paint.set_color(fill); + canvas.fill_path( + words, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + + // The glyphs that paint their own colours - an emoji's picture, a + // layered face's paints - over the words: their colours are their + // own, so the fill cannot have them. No shadow and no outline round + // them either; they are pictures, and a box would be a box. + // https://github.com/jub0t/Concat/issues/276 + for (row, line) in lines.iter().enumerate() { + if line.marks.is_empty() { + continue; + } + let indent = match style.align { + Align::Left => 0.0, + Align::Center => (box_w - line.width) / 2.0, + Align::Right => box_w - line.width, + }; + let baseline = top + ascent + row as f32 * pitch; + let placed = Transform::from_translate(left + indent, baseline); + for mark in &line.marks { + match mark { + Mark::Bitmap { + x, + y, + width, + height, + pixmap, + } => { + let scale_x = *width / pixmap.width() as f32; + let scale_y = *height / pixmap.height() as f32; + canvas.draw_pixmap( + 0, + 0, + pixmap.as_ref(), + &PixmapPaint { + // A strike is drawn far bigger than it lands; + // nearest would stair-step its round edges. + quality: FilterQuality::Bilinear, + ..PixmapPaint::default() + }, + Transform::from_row( + scale_x, + 0.0, + 0.0, + scale_y, + left + indent + *x, + baseline + *y, + ), + None, + ); + } + Mark::Layers(layers) => { + for (path, layer_colour) in layers { + paint.set_color(*layer_colour); + canvas.fill_path(path, &paint, FillRule::Winding, placed, None); + } + } + } + } + } Ok(( canvas, @@ -1916,7 +2236,7 @@ mod tests { } let (data, index) = &casting.faces[face_of[0]]; let lent = ttf_parser::Face::parse((**data).as_ref(), *index).expect("parses"); - assert!(draws(&lent, '漢') && draws(&lent, '字')); + assert!(draws(&lent, '漢').is_some() && draws(&lent, '字').is_some()); assert_eq!(face_of[0], face_of['漢'.len_utf8()], "one face for the run"); } @@ -2037,4 +2357,251 @@ mod tests { let png = render_shape(&square, 64, 64).expect("encodes"); assert!(png.png.starts_with(b"\x89PNG")); } + + // ── colour emoji: https://github.com/jub0t/Concat/issues/276 ── + + /// The solid pixels whose colour passes: the words are white and + /// their shadow and outline grey, so anything that passes is a + /// picture's own. + fn coloured_pixels(png: &[u8], passes: impl Fn(u8, u8, u8) -> bool) -> usize { + let pixmap = Pixmap::decode_png(png).expect("our own PNG decodes"); + pixmap + .pixels() + .iter() + .filter(|p| p.alpha() > 200 && passes(p.red(), p.green(), p.blue())) + .count() + } + + fn is_red(r: u8, g: u8, b: u8) -> bool { + r > 150 && g < 90 && b < 90 + } + + fn is_blue(r: u8, g: u8, b: u8) -> bool { + b > 150 && r < 90 && g < 90 + } + + /// The font's bytes with `extra` tables in the directory: laid out + /// four bytes at a time as a sfnt likes, the directory in tag order, + /// checksums summed. Enough for a test to give a real face tables it + /// does not carry. + fn with_tables(base: &[u8], extra: &[(&[u8], &[u8])]) -> Vec { + let tables = usize::from(u16::from_be_bytes([base[4], base[5]])); + let mut entries: Vec<(Vec, &[u8])> = Vec::with_capacity(tables + extra.len()); + for index in 0..tables { + let at = 12 + index * 16; + let word = |at: usize| { + u32::from_be_bytes(base[at..at + 4].try_into().expect("four bytes")) as usize + }; + let (offset, length) = (word(at + 8), word(at + 12)); + entries.push((base[at..at + 4].to_vec(), &base[offset..offset + length])); + } + entries.extend(extra.iter().map(|(tag, data)| (tag.to_vec(), *data))); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let count = entries.len() as u16; + let entry_selector = (u16::BITS - 1 - count.leading_zeros()) as u16; + let search_range = 16u16 << entry_selector; + let mut out = Vec::new(); + out.extend_from_slice(&base[..4]); // the sfnt version + out.extend_from_slice(&count.to_be_bytes()); + out.extend_from_slice(&search_range.to_be_bytes()); + out.extend_from_slice(&entry_selector.to_be_bytes()); + out.extend_from_slice(&(count * 16 - search_range).to_be_bytes()); + + // Each table padded to four bytes, as a sfnt likes. + let padded: Vec = entries.iter().map(|(_, d)| (d.len() + 3) & !3).collect(); + let mut offsets = Vec::with_capacity(entries.len()); + let mut next = 12 + entries.len() * 16; + for len in &padded { + offsets.push(next); + next += len; + } + for ((tag, data), offset) in entries.iter().zip(&offsets) { + let mut sum = 0u32; + for word in data.chunks(4) { + let mut bytes = [0u8; 4]; + bytes[..word.len()].copy_from_slice(word); + sum = sum.wrapping_add(u32::from_be_bytes(bytes)); + } + out.extend_from_slice(tag); + out.extend_from_slice(&sum.to_be_bytes()); + out.extend_from_slice(&(*offset as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + } + for ((_, data), len) in entries.iter().zip(&padded) { + out.extend_from_slice(data); + out.resize(out.len() + (len - data.len()), 0); + } + out + } + + /// The bundled face with `COLR` and `CPAL` laid over it: the glyph + /// for `A` painted as two layered outlines - `L` in the palette's + /// red, `I` in its blue. A colour face in the bytes a colour face + /// ships, cut from a font the test already carries, so it runs on a + /// machine with no fonts installed at all. + fn layered_face() -> Vec { + let face = ttf_parser::Face::parse(BUNDLED[0], 0).expect("the bundled face parses"); + let base = face.glyph_index('A').expect("an A").0; + let first = face.glyph_index('L').expect("an L").0; + let second = face.glyph_index('I').expect("an I").0; + + // COLR v0: a 14-byte header, one base glyph, two layers. + let mut colr = Vec::new(); + colr.extend_from_slice(&0u16.to_be_bytes()); // version + colr.extend_from_slice(&1u16.to_be_bytes()); // numBaseGlyphRecords + colr.extend_from_slice(&14u32.to_be_bytes()); // baseGlyphRecordsOffset + colr.extend_from_slice(&20u32.to_be_bytes()); // layerRecordsOffset + colr.extend_from_slice(&2u16.to_be_bytes()); // numLayerRecords + colr.extend_from_slice(&base.to_be_bytes()); + colr.extend_from_slice(&0u16.to_be_bytes()); // firstLayerIndex + colr.extend_from_slice(&2u16.to_be_bytes()); // numLayers + colr.extend_from_slice(&first.to_be_bytes()); + colr.extend_from_slice(&0u16.to_be_bytes()); // palette entry: red + colr.extend_from_slice(&second.to_be_bytes()); + colr.extend_from_slice(&1u16.to_be_bytes()); // palette entry: blue + + // CPAL v0: one palette of two entries, records at byte 14 - just + // after the header's 12 bytes and the one palette index. + let mut cpal = Vec::new(); + cpal.extend_from_slice(&0u16.to_be_bytes()); // version + cpal.extend_from_slice(&2u16.to_be_bytes()); // numPaletteEntries + cpal.extend_from_slice(&1u16.to_be_bytes()); // numPalettes + cpal.extend_from_slice(&2u16.to_be_bytes()); // numColorRecords + cpal.extend_from_slice(&14u32.to_be_bytes()); // colorRecordsArrayOffset + cpal.extend_from_slice(&0u16.to_be_bytes()); // colorRecordIndices[0] + cpal.extend_from_slice(&[0, 0, 255, 255]); // red: blue, green, red, alpha + cpal.extend_from_slice(&[255, 0, 0, 255]); // blue + + with_tables(BUNDLED[0], &[(&b"COLR"[..], &colr), (&b"CPAL"[..], &cpal)]) + } + + /// A font set with no system fonts at all: the layered face and the + /// bundled weights around it, no more. + fn layered_fonts() -> Fonts { + let mut db = fontdb::Database::new(); + db.load_font_data(layered_face()); + for face in &BUNDLED[1..] { + db.load_font_data(face.to_vec()); + } + // None of the weights around it is a regular, so a regular in + // the database is the layered face - if it parsed at all. + assert!( + db.faces() + .any(|info| { info.weight.0 == 400 && info.style == fontdb::Style::Normal }), + "the layered face parses" + ); + Fonts { + db, + cache: Mutex::default(), + } + } + + /// A face that paints a glyph in colours of its own draws it, and + /// those colours are what lands: layered `COLR` paints over the + /// words, in their palette's red and blue rather than the fill's + /// white - which is all the same face without those tables can + /// paint. And a colour face is the one borrowed first, whatever + /// weight the title asked for. + #[test] + fn a_colr_face_paints_its_layers_in_their_own_colours() { + let fonts = layered_fonts(); + let mut title = style("A"); + title.font_family = BUNDLED_FAMILY.to_owned(); + title.font_weight = 400.0; + title.shadow = false; + // Big enough that each layer's pixels are counted past doubt. + title.font_size = 0.2; + let layered = render(&fonts, &title, 640, 360).expect("renders"); + assert!( + coloured_pixels(&layered.png, is_red) > 20, + "the first layer's red" + ); + assert!( + coloured_pixels(&layered.png, is_blue) > 20, + "the second layer's blue" + ); + + // The same face without those two tables paints only what the + // words' fill says: no red and no blue anywhere. + let mut plain_db = fontdb::Database::new(); + plain_db.load_font_data(BUNDLED[0].to_vec()); + let plain_fonts = Fonts { + db: plain_db, + cache: Mutex::default(), + }; + let plain = render(&plain_fonts, &title, 640, 360).expect("renders"); + assert_eq!(coloured_pixels(&plain.png, is_red), 0); + assert_eq!(coloured_pixels(&plain.png, is_blue), 0); + + // And the faces say so: `draws` calls the layered one colour + // and the plain one an outline, and nearest takes the colour + // face before weight ever comes into it. + let bytes = layered_face(); + let cut = ttf_parser::Face::parse(&bytes, 0).expect("parses"); + assert_eq!(draws(&cut, 'A'), Some(Draw::Colour)); + let bundled = ttf_parser::Face::parse(BUNDLED[0], 0).expect("parses"); + assert_eq!(draws(&bundled, 'A'), Some(Draw::Outline)); + assert_eq!(draws(&bundled, '漢'), None, "no glyph, no drawing"); + + let want = Want { + weight: 700, + italic: false, + }; + let id = |weight: u16, slant: fontdb::Style| { + fonts + .db + .faces() + .find(|info| info.weight.0 == weight && info.style == slant) + .expect("the face") + .id + }; + let bold = id(700, fontdb::Style::Normal); + let colour_face = id(400, fontdb::Style::Normal); + let mut covers = Covers::default(); + covers.plain.push(bold); + covers.colour.push(colour_face); + assert_eq!( + fonts.nearest(Some(&covers), want), + Some(colour_face), + "colour before weight" + ); + covers.colour.clear(); + assert_eq!( + fonts.nearest(Some(&covers), want), + Some(bold), + "weight among the plain" + ); + assert_eq!(fonts.nearest(None, want), None); + } + + /// An emoji the style's face lacks is lent to a face that draws it + /// in colour, and painted in those colours rather than the words' + /// own. Skipped on a machine with no colour emoji face. + #[test] + fn an_emoji_is_painted_in_the_colours_of_the_face_that_draws_it() { + let fonts = Fonts::new(); + let mut party = style("party 🎉 ❤️"); + party.font_family = BUNDLED_FAMILY.to_owned(); + party.shadow = false; + let casting = fonts.cast(&party).expect("casts"); + let at = party.content.find('🎉').expect("the emoji"); + let lent = casting + .faces + .get(casting.paragraphs[0][at]) + .and_then(|(data, index)| ttf_parser::Face::parse((**data).as_ref(), *index).ok()) + .is_some_and(|face| draws(&face, '🎉') == Some(Draw::Colour)); + if !lent { + eprintln!("no colour face on this machine draws 🎉; skipped"); + return; + } + let out = render(&fonts, &party, 640, 360).expect("renders"); + let picture = coloured_pixels(&out.png, |r, g, b| { + r.max(g).max(b).saturating_sub(r.min(g).min(b)) > 60 + }); + assert!( + picture > 50, + "{picture} coloured pixels: the emoji came out in colour" + ); + } }