From a03e41691308077409ed0dc9c4293d3fe87d87ab Mon Sep 17 00:00:00 2001 From: minecart551-prog Date: Fri, 9 Oct 2026 00:52:06 -0900 Subject: [PATCH] feat(text): colour emoji render in their own colours, not the words' A title painted every glyph as an outline filled with the words' colour, which a colour emoji face has none of: draws() skipped any face whose glyphs were not outlines, so Noto Color Emoji was never even borrowed and the emoji fell to a monochrome fallback face - the white text of #276 - or to a .notdef box where nothing else had it. A glyph that colours itself now stays out of the line's path and is laid over the words once they are painted: a bitmap strike (CBDT/CBLC, sbix) decoded to its box, or a COLR v0 face's layers outlined in their palette colours. Plain pictures, as asked - no shadow and no stroke on either. A colour face is also borrowed before a monochrome one whatever weight the title asked for, so DejaVu and Symbola only get an emoji when no colour face has it. Faces that could only draw with constructs this crate does not follow - COLR v1 gradients, clips, transforms, SVG documents - are not counted as able to draw, and a glyph whose paint turns out unsupported falls back to its outline as before: nothing that used to paint now paints nothing. The layered case is tested on a COLR face cut from the bundled one inside the test, so it runs on a machine with no fonts installed at all; the bitmap case borrows the machine's colour emoji face and skips when it has none. Fixes #276 Signed-off-by: minecart551-prog --- src/crates/concat-text/src/lib.rs | 709 +++++++++++++++++++++++++++--- 1 file changed, 638 insertions(+), 71 deletions(-) diff --git a/src/crates/concat-text/src/lib.rs b/src/crates/concat-text/src/lib.rs index be7ad41370..e245ad515e 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" + ); + } }