From 0a1f2ce7a84ab2e82ba4ef4a72fbdd27fb1d2416 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:59:25 +0200 Subject: [PATCH] Canvas: draw colour emoji on screen as the PDF does (#1269) (#1504) * #1269: WIP * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * #1269: smaller bitmaps are accepted; dark page keeps emoji colours (test) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/fonts/src/color.rs | 381 ++++++++++++++++++++ crates/fonts/src/lib.rs | 1 + crates/render/src/color_glyph.rs | 439 ++++++++++++++++++++++++ crates/render/src/lib.rs | 24 +- crates/render/src/tests_color_glyphs.rs | 76 ++++ docs/layout-parity.md | 5 +- 7 files changed, 925 insertions(+), 5 deletions(-) create mode 100644 crates/fonts/src/color.rs create mode 100644 crates/render/src/color_glyph.rs create mode 100644 crates/render/src/tests_color_glyphs.rs diff --git a/ROADMAP.md b/ROADMAP.md index f9e6e6443..7bc8451ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: dark-page highlights, #1252; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: colour emoji on screen, #1269; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -169,6 +169,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again | | 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's | | 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) | +| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -334,6 +335,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | | 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | | 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) | +| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/fonts/src/color.rs b/crates/fonts/src/color.rs new file mode 100644 index 000000000..897f31c96 --- /dev/null +++ b/crates/fonts/src/color.rs @@ -0,0 +1,381 @@ +//! Colour glyphs (#1269): emoji and other glyphs drawn from a font's colour tables instead of a +//! plain outline. COLR v0/v1 paint graphs become a flat list of [`ColorOp`]s; bitmap strikes +//! (`sbix`, `CBDT`) become a [`ColorBitmap`] placed in font units. The renderer turns either into +//! fills or an image; nothing here depends on a rasteriser. +//! +//! Fonts are hostile input: every lookup is checked, the op list and bitmap sizes are capped, and +//! a paint graph that fails part way (skrifa rejects cycles and over-deep graphs) yields `None`, so +//! the caller falls back to the outline. + +use kurbo::{Affine, BezPath, Point, Rect}; +use skrifa::color::{Brush, ColorGlyphCollection, ColorPainter, ColorPalettes, ColorStop, Transform}; +use skrifa::raw::types::BoundingBox; +use skrifa::{GlyphId, Tag}; + +pub use skrifa::color::{CompositeMode, Extend}; + +use crate::FontFace; + +/// Upper bound on the ops one colour glyph may expand to. +pub const MAX_OPS: usize = 20_000; +/// Upper bound on a bitmap glyph's width and height in pixels. +pub const MAX_BITMAP_SIDE: u32 = 2048; + +/// A colour from the font's palette, or the text colour (palette index 0xFFFF). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum GlyphColor { + /// Straight (not premultiplied) RGBA. + Rgba([u8; 4]), + /// The run's text colour, with this alpha multiplier. + Foreground(f32), +} + +/// A gradient stop. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Stop { + pub offset: f32, + pub color: GlyphColor, +} + +/// What a [`ColorOp::Fill`] paints the current clip with. Coordinates are in the space set by the +/// enclosing transforms. +#[derive(Clone, Debug, PartialEq)] +pub enum ColorPaint { + Solid(GlyphColor), + Linear { + p0: Point, + p1: Point, + stops: Vec, + extend: Extend, + }, + Radial { + c0: Point, + r0: f32, + c1: Point, + r1: f32, + stops: Vec, + extend: Extend, + }, + /// Angles in degrees, clockwise, as skrifa reports them. + Sweep { + c0: Point, + start_angle: f32, + end_angle: f32, + stops: Vec, + extend: Extend, + }, +} + +/// One drawing step of a COLR glyph, in font units with y up. Pushes and pops are balanced. +#[derive(Clone, Debug, PartialEq)] +pub enum ColorOp { + /// Concatenate a transform onto the current one. + PushTransform(Affine), + PopTransform, + /// Intersect the clip with this path (in the current transform's space). + PushClip(BezPath), + PopClip, + /// Open a layer that composites with this mode when popped. + PushLayer(CompositeMode), + PopLayer, + /// Fill the current clip. + Fill(ColorPaint), +} + +/// The encoded pixels of a bitmap glyph. +#[derive(Clone, Debug, PartialEq)] +pub enum BitmapBytes { + Png(Vec), + /// Premultiplied BGRA rows, `width * height * 4` bytes. + Bgra(Vec), +} + +/// A bitmap glyph: its pixels and where they go, in font units with y down from the pen position +/// (the same space as [`FontFace::glyph_outline`]). +#[derive(Clone, Debug, PartialEq)] +pub struct ColorBitmap { + pub data: BitmapBytes, + pub width: u32, + pub height: u32, + pub rect: Rect, + /// The strike's pixels per em. + pub ppem: f32, +} + +/// A glyph drawn from the font's colour data. +#[derive(Clone, Debug, PartialEq)] +pub enum ColorGlyph { + Layers(Vec), + Bitmap(ColorBitmap), +} + +impl FontFace { + /// Does this face carry colour glyph tables (COLR, sbix or CBDT)? Cheap: a table lookup. + pub fn has_color_glyphs(&self) -> bool { + self.skrifa().is_some_and(|f| [b"COLR", b"sbix", b"CBDT"].iter().any(|t| f.table_data(Tag::new(t)).is_some())) + } + + /// Glyph `gid` drawn in colour: its COLR paint (preferred: it scales), else the bitmap from the + /// strike nearest `ppem` device pixels per em. None when the glyph has no usable colour data; + /// the caller then draws the outline. + pub fn color_glyph(&self, gid: u32, ppem: f32) -> Option { + let f = self.skrifa()?; + let glyph = GlyphId::new(gid); + if let Some(cg) = ColorGlyphCollection::new(&f).get(glyph) { + let palette: Vec<[u8; 4]> = + ColorPalettes::new(&f).get(0).map(|p| p.colors().iter().map(|c| [c.red, c.green, c.blue, c.alpha]).collect()).unwrap_or_default(); + let mut rec = Recorder::new(&palette, |g: GlyphId| { + let mut p = self.glyph_outline(g.to_u32()); + p.apply_affine(Affine::FLIP_Y); + p + }); + if cg.paint(self.location(), &mut rec).is_ok() + && let Some(ops) = rec.finish() + { + return Some(ColorGlyph::Layers(ops)); + } + } + let upem = self.units_per_em(); + let ppem = if ppem.is_finite() && ppem > 0.0 { ppem.min(4096.0) } else { 64.0 }; + let bg = skrifa::bitmap::BitmapStrikes::new(&f).glyph_for_size(skrifa::instance::Size::new(ppem), glyph)?; + bitmap_placement(&bg, upem).map(ColorGlyph::Bitmap) + } +} + +/// Where a bitmap glyph goes, in font units, y down from the pen position. None for an empty, +/// oversized or unsupported (monochrome mask) bitmap. +pub fn bitmap_placement(bg: &skrifa::bitmap::BitmapGlyph<'_>, upem: f64) -> Option { + let (w, h) = (bg.width, bg.height); + if w == 0 || h == 0 || w > MAX_BITMAP_SIDE || h > MAX_BITMAP_SIDE { + return None; + } + let data = match bg.data { + skrifa::bitmap::BitmapData::Png(b) => BitmapBytes::Png(b.to_vec()), + skrifa::bitmap::BitmapData::Bgra(b) => { + let n = (w as usize).checked_mul(h as usize)?.checked_mul(4)?; + BitmapBytes::Bgra(b.get(..n)?.to_vec()) + } + skrifa::bitmap::BitmapData::Mask(_) => return None, + }; + let (px, py) = (bg.ppem_x as f64, bg.ppem_y as f64); + if !(px.is_finite() && py.is_finite() && px > 0.0 && py > 0.0 && upem.is_finite() && upem > 0.0) { + return None; + } + // Font units per bitmap pixel. + let (sx, sy) = (upem / px, upem / py); + let (bw, bh) = (w as f64 * sx, h as f64 * sy); + let x0 = bg.bearing_x as f64 + bg.inner_bearing_x as f64 * sx; + // Top edge, y up. + let top = match bg.placement_origin { + skrifa::bitmap::Origin::TopLeft => bg.bearing_y as f64 + bg.inner_bearing_y as f64 * sy, + skrifa::bitmap::Origin::BottomLeft => bg.bearing_y as f64 + bg.inner_bearing_y as f64 * sy + bh, + }; + let rect = Rect::new(x0, -top, x0 + bw, -top + bh); + let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()); + finite.then_some(ColorBitmap { data, width: w, height: h, rect, ppem: bg.ppem_y }) +} + +/// A [`ColorPainter`] that records the paint calls as [`ColorOp`]s, resolving palette colours and +/// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`]. +pub struct Recorder<'a, F: Fn(GlyphId) -> BezPath> { + palette: &'a [[u8; 4]], + outline: F, + ops: Vec, + overflow: bool, +} + +impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> { + /// A recorder with palette `palette` (straight RGBA) and clip outlines (font units, y up) from + /// `outline`. + pub fn new(palette: &'a [[u8; 4]], outline: F) -> Self { + Recorder { palette, outline, ops: vec![], overflow: false } + } + + /// The recorded ops, None when the glyph was too big or its pushes and pops don't balance. + pub fn finish(self) -> Option> { + if self.overflow { + return None; + } + let mut depth = 0i64; + for op in &self.ops { + match op { + ColorOp::PushTransform(_) | ColorOp::PushClip(_) | ColorOp::PushLayer(_) => depth += 1, + ColorOp::PopTransform | ColorOp::PopClip | ColorOp::PopLayer => depth -= 1, + ColorOp::Fill(_) => {} + } + if depth < 0 { + return None; + } + } + (depth == 0).then_some(self.ops) + } + + fn push(&mut self, op: ColorOp) { + if self.ops.len() >= MAX_OPS { + self.overflow = true; + return; + } + self.ops.push(op); + } + + fn color(&self, palette_index: u16, alpha: f32) -> GlyphColor { + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + if palette_index == 0xFFFF { + return GlyphColor::Foreground(alpha); + } + match self.palette.get(palette_index as usize) { + Some(&[r, g, b, a]) => GlyphColor::Rgba([r, g, b, (a as f32 * alpha).round().clamp(0.0, 255.0) as u8]), + None => GlyphColor::Rgba([0, 0, 0, 0]), + } + } + + fn stops(&self, stops: &[ColorStop]) -> Vec { + let mut v: Vec = + stops.iter().filter(|s| s.offset.is_finite()).map(|s| Stop { offset: s.offset, color: self.color(s.palette_index, s.alpha) }).collect(); + v.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + v + } +} + +fn pt(p: skrifa::raw::types::Point) -> Point { + Point::new(p.x as f64, p.y as f64) +} + +impl BezPath> ColorPainter for Recorder<'_, F> { + fn push_transform(&mut self, t: Transform) { + self.push(ColorOp::PushTransform(Affine::new([t.xx, t.yx, t.xy, t.yy, t.dx, t.dy].map(|v| v as f64)))); + } + fn pop_transform(&mut self) { + self.push(ColorOp::PopTransform); + } + fn push_clip_glyph(&mut self, glyph_id: GlyphId) { + let p = (self.outline)(glyph_id); + self.push(ColorOp::PushClip(p)); + } + fn push_clip_box(&mut self, b: BoundingBox) { + let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64); + self.push(ColorOp::PushClip(kurbo::Shape::to_path(&r, 0.1))); + } + fn pop_clip(&mut self) { + self.push(ColorOp::PopClip); + } + fn fill(&mut self, brush: Brush<'_>) { + let paint = match brush { + Brush::Solid { palette_index, alpha } => ColorPaint::Solid(self.color(palette_index, alpha)), + Brush::LinearGradient { p0, p1, color_stops, extend } => { + ColorPaint::Linear { p0: pt(p0), p1: pt(p1), stops: self.stops(color_stops), extend } + } + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => { + ColorPaint::Radial { c0: pt(c0), r0, c1: pt(c1), r1, stops: self.stops(color_stops), extend } + } + Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend } => { + ColorPaint::Sweep { c0: pt(c0), start_angle, end_angle, stops: self.stops(color_stops), extend } + } + }; + self.push(ColorOp::Fill(paint)); + } + fn push_layer(&mut self, mode: CompositeMode) { + self.push(ColorOp::PushLayer(mode)); + } + fn pop_layer(&mut self) { + self.push(ColorOp::PopLayer); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(_: GlyphId) -> BezPath { + kurbo::Shape::to_path(&Rect::new(0.0, 0.0, 10.0, 10.0), 0.1) + } + + #[test] + fn records_a_clipped_solid_fill_with_palette_colours() { + let palette = [[255, 0, 0, 255], [0, 0, 255, 128]]; + let mut r = Recorder::new(&palette, square); + r.fill_glyph(GlyphId::new(3), None, Brush::Solid { palette_index: 1, alpha: 0.5 }); + r.fill_glyph(GlyphId::new(3), None, Brush::Solid { palette_index: 0xFFFF, alpha: 1.0 }); + let ops = r.finish().unwrap(); + assert_eq!(ops.len(), 6); + assert!(matches!(&ops[0], ColorOp::PushClip(p) if !p.elements().is_empty())); + assert_eq!(ops[1], ColorOp::Fill(ColorPaint::Solid(GlyphColor::Rgba([0, 0, 255, 64])))); + assert_eq!(ops[2], ColorOp::PopClip); + assert_eq!(ops[4], ColorOp::Fill(ColorPaint::Solid(GlyphColor::Foreground(1.0)))); + } + + #[test] + fn hostile_palette_indices_and_stops_never_panic() { + let palette = [[1, 2, 3, 255]]; + let mut r = Recorder::new(&palette, square); + let stops = [ + ColorStop { offset: 1.0, palette_index: 9999, alpha: f32::NAN }, + ColorStop { offset: f32::NAN, palette_index: 0, alpha: 1.0 }, + ColorStop { offset: 0.0, palette_index: 0, alpha: 7.0 }, + ]; + r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad }); + let ops = r.finish().unwrap(); + let ColorOp::Fill(ColorPaint::Linear { stops, .. }) = &ops[0] else { panic!("{ops:?}") }; + assert_eq!(stops.len(), 2, "the NaN offset is dropped"); + assert_eq!(stops[0], Stop { offset: 0.0, color: GlyphColor::Rgba([1, 2, 3, 255]) }); + assert_eq!(stops[1].color, GlyphColor::Rgba([0, 0, 0, 0]), "an out-of-range index is transparent"); + } + + #[test] + fn unbalanced_or_oversized_paint_is_rejected() { + let mut r = Recorder::new(&[], square); + r.pop_clip(); + r.push_clip_glyph(GlyphId::new(1)); + assert!(r.finish().is_none()); + let mut r = Recorder::new(&[], square); + r.push_clip_glyph(GlyphId::new(1)); + assert!(r.finish().is_none()); + let mut r = Recorder::new(&[], square); + for _ in 0..MAX_OPS + 5 { + r.fill(Brush::Solid { palette_index: 0, alpha: 1.0 }); + } + assert!(r.finish().is_none()); + } + + fn bitmap(origin: skrifa::bitmap::Origin, data: skrifa::bitmap::BitmapData<'_>) -> skrifa::bitmap::BitmapGlyph<'_> { + skrifa::bitmap::BitmapGlyph { + data, + bearing_x: 0.0, + bearing_y: 0.0, + inner_bearing_x: 2.0, + inner_bearing_y: 100.0, + ppem_x: 100.0, + ppem_y: 100.0, + advance: None, + width: 120, + height: 110, + placement_origin: origin, + } + } + + /// A CBDT strike places the bitmap's top-left corner `inner_bearing` pixels from the pen; sbix + /// places its bottom-left corner. Both scale pixels to font units by upem / ppem. + #[test] + fn bitmap_glyphs_are_placed_in_font_units() { + let png = [0u8; 4]; + let b = bitmap_placement(&bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Png(&png)), 1000.0).unwrap(); + assert_eq!(b.rect, Rect::new(20.0, -1000.0, 1220.0, 100.0)); + let b = bitmap_placement(&bitmap(skrifa::bitmap::Origin::BottomLeft, skrifa::bitmap::BitmapData::Png(&png)), 1000.0).unwrap(); + assert_eq!(b.rect, Rect::new(20.0, -2100.0, 1220.0, -1000.0)); + // Short BGRA data, a zero ppem and a monochrome mask are refused, not read past. + assert!(bitmap_placement(&bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Bgra(&png)), 1000.0).is_none()); + let mut z = bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Png(&png)); + z.ppem_y = 0.0; + assert!(bitmap_placement(&z, 1000.0).is_none()); + let mut big = bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Png(&png)); + big.width = MAX_BITMAP_SIDE + 1; + assert!(bitmap_placement(&big, 1000.0).is_none()); + } + + #[test] + fn faces_without_colour_tables_have_no_colour_glyphs() { + let face = crate::word::resolve("Source Sans 3", false, false).face; + assert!(!face.has_color_glyphs()); + assert_eq!(face.color_glyph(face.glyph_for('A'), 32.0), None); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 0d04ac7aa..cddcdad4b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod color; mod fontdb; pub mod math; mod script; diff --git a/crates/render/src/color_glyph.rs b/crates/render/src/color_glyph.rs new file mode 100644 index 000000000..8b169d9b6 --- /dev/null +++ b/crates/render/src/color_glyph.rs @@ -0,0 +1,439 @@ +//! Colour glyphs (#1269): emoji and other glyphs whose font draws them from COLR paint graphs or +//! bitmap strikes (sbix, CBDT) instead of outlines. The fonts crate reads the colour data +//! ([`FontFace::color_glyph`]); this module decodes bitmaps, caches the result per glyph and size, +//! and turns it into vello fills. The colours come from the font: a dark page doesn't invert them, +//! the same as pictures. Only the font's "text colour" entries follow the run's colour. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use vello_cpu::kurbo::{self, Affine, Point, Rect, Shape}; +use vello_cpu::peniko; +use vello_cpu::{Pixmap, RenderContext}; +use wordcraft_fonts::FontFace; +use wordcraft_fonts::color::{BitmapBytes, ColorGlyph, ColorOp, ColorPaint, CompositeMode, Extend, GlyphColor, Stop}; + +/// A colour glyph ready to draw: COLR ops, or a decoded bitmap and where it goes (font units, y +/// down from the pen position). +pub(crate) enum Drawable { + Layers(Vec), + Bitmap { pixmap: Arc, rect: Rect }, +} + +impl Drawable { + fn bytes(&self) -> usize { + match self { + Drawable::Layers(ops) => ops.len().saturating_mul(64), + Drawable::Bitmap { pixmap, .. } => pixmap.width() as usize * pixmap.height() as usize * 4, + } + } +} + +/// Upper bounds on the colour-glyph cache: entries and approximate bytes. Past either it starts over. +const CACHE_ENTRIES: usize = 4096; +const CACHE_BYTES: usize = 64 * 1024 * 1024; + +type Key = (u32, u32, u32); + +#[derive(Default)] +struct Cache { + map: HashMap>>, + bytes: usize, +} + +fn cache() -> &'static Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| Mutex::new(Cache::default())) +} + +/// The strike size asked for at `ppem` device pixels per em: the next power of two, so zooming +/// doesn't decode a bitmap per zoom step, and a strike at least as big as needed is preferred. +fn ppem_bucket(ppem: f64) -> u32 { + let p = if ppem.is_finite() { ppem.clamp(1.0, 4096.0) } else { 64.0 }; + (p.ceil() as u32).next_power_of_two() +} + +/// Glyph `gid` of `face` in colour at about `ppem` device pixels per em, None when it has no colour +/// data (draw its outline). `cached` keeps the result in the shared cache (keyed by face id); a face +/// read just for a preview passes false so nothing outlives it. +pub(crate) fn drawable(face: &FontFace, gid: u32, ppem: f64, cached: bool) -> Option> { + let bucket = ppem_bucket(ppem); + if !cached { + return build(face, gid, bucket).map(Arc::new); + } + let key = (face.id(), gid, bucket); + if let Some(v) = crate::lock(cache()).map.get(&key) { + return v.clone(); + } + let d = build(face, gid, bucket).map(Arc::new); + let size = d.as_ref().map_or(0, |d| d.bytes()).saturating_add(64); + let mut c = crate::lock(cache()); + if c.map.len() >= CACHE_ENTRIES || c.bytes.saturating_add(size) > CACHE_BYTES { + c.map.clear(); + c.bytes = 0; + } + if size <= CACHE_BYTES { + c.map.insert(key, d.clone()); + c.bytes = c.bytes.saturating_add(size); + } + d +} + +fn build(face: &FontFace, gid: u32, ppem: u32) -> Option { + match face.color_glyph(gid, ppem as f32)? { + ColorGlyph::Layers(ops) => Some(Drawable::Layers(ops)), + ColorGlyph::Bitmap(b) => { + let pixmap = match &b.data { + BitmapBytes::Png(png) => decode_png(png, b.width, b.height)?, + BitmapBytes::Bgra(bgra) => bgra_pixmap(bgra, b.width, b.height)?, + }; + Some(Drawable::Bitmap { pixmap: Arc::new(pixmap), rect: b.rect }) + } + } +} + +/// Decode a bitmap glyph's PNG, which may be at most the `w`×`h` its strike claims (and at most +/// [`MAX_BITMAP_SIDE`](wordcraft_fonts::color::MAX_BITMAP_SIDE) a side): a hostile font could claim +/// a small glyph and hand over a huge image, so the size is read from the header before decoding. +/// A smaller, non-empty image is drawn stretched to the glyph's box: CBDT-only fonts have no +/// outline to fall back to. +fn decode_png(png: &[u8], w: u32, h: u32) -> Option { + let side = wordcraft_fonts::color::MAX_BITMAP_SIDE; + if w == 0 || h == 0 || w > side || h > side { + return None; + } + let reader = || image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); + let fits = |(iw, ih): (u32, u32)| iw > 0 && ih > 0 && iw <= w && ih <= h; + if !fits(reader().into_dimensions().ok()?) { + return None; + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(w); + limits.max_image_height = Some(h); + limits.max_alloc = Some(w as u64 * h as u64 * 4 * 2 + 64 * 1024); + let mut r = reader(); + r.limits(limits); + let img = r.decode().ok()?.to_rgba8(); + let (iw, ih) = img.dimensions(); + if !fits((iw, ih)) { + return None; + } + crate::premul_pixmap(iw, ih, img.as_raw()) +} + +/// A pixmap from premultiplied BGRA rows. +fn bgra_pixmap(bgra: &[u8], w: u32, h: u32) -> Option { + if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { + return None; + } + let n = (w as usize).checked_mul(h as usize)?.checked_mul(4)?; + let data: Vec = bgra + .get(..n)? + .as_chunks::<4>() + .0 + .iter() + // Premultiplied channels can't exceed alpha; clamp in case the font says otherwise. + .map(|p| vello_cpu::color::PremulRgba8 { r: p[2].min(p[3]), g: p[1].min(p[3]), b: p[0].min(p[3]), a: p[3] }) + .collect(); + Some(Pixmap::from_parts(data, w as u16, h as u16)) +} + +/// Draw a colour glyph. `at` maps font units (y down, pen at the origin) to device pixels; `fg` is +/// the run's text colour for the font's "text colour" entries; `alpha` fades the whole glyph. +pub(crate) fn draw(ctx: &mut RenderContext, d: &Drawable, at: Affine, upem: f64, fg: peniko::Color, alpha: f32) { + if !usable(at) { + return; + } + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + let faded = alpha < 1.0; + if faded { + ctx.push_opacity_layer(alpha); + } + match d { + Drawable::Bitmap { pixmap, rect } => { + let (pw, ph) = (pixmap.width().max(1) as f64, pixmap.height().max(1) as f64); + ctx.set_transform(at); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pixmap.clone()), + sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium), + }); + ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / pw, rect.height() / ph)); + ctx.fill_rect(rect); + ctx.reset_paint_transform(); + } + Drawable::Layers(ops) => draw_ops(ctx, ops, at * Affine::FLIP_Y, upem, fg), + } + if faded { + ctx.pop_layer(); + } +} + +/// Play COLR ops (font units, y up) under `root`. Fills cover a generous box around the em square, +/// clipped by the glyph's clips. Layers left open by a malformed list are closed at the end. +fn draw_ops(ctx: &mut RenderContext, ops: &[ColorOp], root: Affine, upem: f64, fg: peniko::Color) { + let upem = if upem.is_finite() && upem > 0.0 { upem } else { 1000.0 }; + let area = Rect::new(-upem, -upem, upem * 2.0, upem * 2.0); + let mut transforms = vec![root]; + let mut open = 0usize; + for op in ops { + let cur = transforms.last().copied().unwrap_or(root); + match op { + ColorOp::PushTransform(t) => transforms.push(cur * *t), + ColorOp::PopTransform => { + if transforms.len() > 1 { + transforms.pop(); + } + } + ColorOp::PushClip(path) => { + // A degenerate or non-finite transform clips everything away: push an empty clip so + // the matching pop still has a layer to close. + if usable(cur) { + ctx.set_transform(cur); + ctx.push_clip_layer(path); + } else { + ctx.set_transform(root); + ctx.push_clip_layer(&kurbo::BezPath::new()); + } + open += 1; + } + ColorOp::PushLayer(mode) => { + ctx.push_blend_layer(blend_mode(*mode)); + open += 1; + } + ColorOp::PopClip | ColorOp::PopLayer => { + if open > 0 { + ctx.pop_layer(); + open -= 1; + } + } + ColorOp::Fill(paint) => fill(ctx, paint, cur, root, area, fg), + } + } + for _ in 0..open { + ctx.pop_layer(); + } +} + +/// Fill `area` (in root space) with `paint` defined in `cur` space. +fn fill(ctx: &mut RenderContext, paint: &ColorPaint, cur: Affine, root: Affine, area: Rect, fg: peniko::Color) { + if !usable(cur) { + return; + } + let region = (cur.inverse() * root) * area.to_path(0.1); + let solid = |ctx: &mut RenderContext, c: GlyphColor| { + ctx.set_transform(cur); + ctx.set_paint(resolve(c, fg)); + ctx.fill_path(®ion); + }; + let (gradient, stops, extend, transform) = match paint { + ColorPaint::Solid(c) => return solid(ctx, *c), + ColorPaint::Linear { p0, p1, stops, extend } => { + if !finite(&[p0.x, p0.y, p1.x, p1.y]) { + return; + } + (peniko::Gradient::new_linear(*p0, *p1), stops, extend, cur) + } + ColorPaint::Radial { c0, r0, c1, r1, stops, extend } => { + if !finite(&[c0.x, c0.y, c1.x, c1.y, *r0 as f64, *r1 as f64]) || *r1 <= 0.0 { + if let Some(s) = stops.last() { + solid(ctx, s.color); + } + return; + } + (peniko::Gradient::new_two_point_radial(*c0, r0.max(0.0), *c1, *r1), stops, extend, cur) + } + ColorPaint::Sweep { c0, start_angle, end_angle, stops, extend } => { + if !finite(&[c0.x, c0.y, *start_angle as f64, *end_angle as f64]) { + return; + } + let mut end = *end_angle; + if *start_angle == end { + if *extend != Extend::Pad { + return; + } + // A sweep needs distinct angles. + end += 0.01; + } + // Peniko sweeps clockwise in y-down space; COLR's (as skrifa reports them) are in this + // y-up space, so sweep in a flipped frame. + let g = peniko::Gradient::new_sweep(Point::new(c0.x, -c0.y), start_angle.to_radians(), end.to_radians()); + (g, stops, extend, cur * Affine::FLIP_Y) + } + }; + match stops.as_slice() { + [] => {} + [only] => solid(ctx, only.color), + _ => { + let stops: Vec = stops.iter().map(|s: &Stop| (s.offset.clamp(-1e6, 1e6), resolve(s.color, fg)).into()).collect(); + let g = gradient.with_stops(stops.as_slice()).with_extend(peniko_extend(*extend)); + ctx.set_transform(transform); + ctx.set_paint(g); + let region = transform.inverse() * root * area.to_path(0.1); + ctx.fill_path(®ion); + } + } +} + +/// Can a transform be drawn under (finite and invertible)? +fn usable(t: Affine) -> bool { + t.as_coeffs().iter().all(|v| v.is_finite()) && t.determinant().abs() >= 1e-12 +} + +fn finite(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() < 1e7) +} + +fn resolve(c: GlyphColor, fg: peniko::Color) -> peniko::Color { + match c { + GlyphColor::Rgba([r, g, b, a]) => peniko::Color::from_rgba8(r, g, b, a), + GlyphColor::Foreground(a) => fg.multiply_alpha(if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 }), + } +} + +fn peniko_extend(e: Extend) -> peniko::Extend { + match e { + Extend::Repeat => peniko::Extend::Repeat, + Extend::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Pad, + } +} + +fn blend_mode(m: CompositeMode) -> peniko::BlendMode { + use peniko::{Compose as C, Mix as M}; + let (mix, compose) = match m { + CompositeMode::Clear => (M::Normal, C::Clear), + CompositeMode::Src => (M::Normal, C::Copy), + CompositeMode::Dest => (M::Normal, C::Dest), + CompositeMode::DestOver => (M::Normal, C::DestOver), + CompositeMode::SrcIn => (M::Normal, C::SrcIn), + CompositeMode::DestIn => (M::Normal, C::DestIn), + CompositeMode::SrcOut => (M::Normal, C::SrcOut), + CompositeMode::DestOut => (M::Normal, C::DestOut), + CompositeMode::SrcAtop => (M::Normal, C::SrcAtop), + CompositeMode::DestAtop => (M::Normal, C::DestAtop), + CompositeMode::Xor => (M::Normal, C::Xor), + CompositeMode::Plus => (M::Normal, C::Plus), + CompositeMode::Screen => (M::Screen, C::SrcOver), + CompositeMode::Overlay => (M::Overlay, C::SrcOver), + CompositeMode::Darken => (M::Darken, C::SrcOver), + CompositeMode::Lighten => (M::Lighten, C::SrcOver), + CompositeMode::ColorDodge => (M::ColorDodge, C::SrcOver), + CompositeMode::ColorBurn => (M::ColorBurn, C::SrcOver), + CompositeMode::HardLight => (M::HardLight, C::SrcOver), + CompositeMode::SoftLight => (M::SoftLight, C::SrcOver), + CompositeMode::Difference => (M::Difference, C::SrcOver), + CompositeMode::Exclusion => (M::Exclusion, C::SrcOver), + CompositeMode::Multiply => (M::Multiply, C::SrcOver), + CompositeMode::HslHue => (M::Hue, C::SrcOver), + CompositeMode::HslSaturation => (M::Saturation, C::SrcOver), + CompositeMode::HslColor => (M::Color, C::SrcOver), + CompositeMode::HslLuminosity => (M::Luminosity, C::SrcOver), + _ => (M::Normal, C::SrcOver), + }; + peniko::BlendMode::new(mix, compose) +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_fonts::color::{GlyphColor, Stop}; + + fn render(ops: &[ColorOp]) -> crate::Rendered { + let mut ctx = RenderContext::new(40, 40); + ctx.set_paint(peniko::Color::WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, 40.0, 40.0)); + let d = Drawable::Layers(ops.to_vec()); + // 1000 upem → 20 px, pen at (10, 30). + draw(&mut ctx, &d, Affine::translate((10.0, 30.0)) * Affine::scale(0.02), 1000.0, peniko::Color::from_rgba8(0, 128, 0, 255), 1.0); + ctx.flush(); + let mut pixels = vec![0u8; 40 * 40 * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(40, 40, &mut pixels) { + ctx.render(pm, &mut vello_cpu::Resources::new()); + } + crate::Rendered { width: 40, height: 40, pixels } + } + + fn square() -> kurbo::BezPath { + Rect::new(0.0, 0.0, 1000.0, 1000.0).to_path(0.1) + } + + /// A clipped solid fill lands inside the glyph's em box above the baseline, in the palette + /// colour; the text-colour entry follows the run's colour. + #[test] + fn colr_ops_fill_the_clip_in_palette_colours() { + let red = GlyphColor::Rgba([255, 0, 0, 255]); + let img = render(&[ColorOp::PushClip(square()), ColorOp::Fill(ColorPaint::Solid(red)), ColorOp::PopClip]); + assert_eq!(img.pixel(20, 20), [255, 0, 0, 255], "inside the em box"); + assert_eq!(img.pixel(20, 35), [255, 255, 255, 255], "below the baseline"); + assert_eq!(img.pixel(5, 20), [255, 255, 255, 255], "left of the pen"); + let img = render(&[ColorOp::PushClip(square()), ColorOp::Fill(ColorPaint::Solid(GlyphColor::Foreground(1.0))), ColorOp::PopClip]); + assert_eq!(img.pixel(20, 20), [0, 128, 0, 255]); + } + + #[test] + fn colr_gradients_paint_and_hostile_ops_never_panic() { + let stops = + vec![Stop { offset: 0.0, color: GlyphColor::Rgba([255, 0, 0, 255]) }, Stop { offset: 1.0, color: GlyphColor::Rgba([0, 0, 255, 255]) }]; + let linear = ColorPaint::Linear { p0: Point::new(0.0, 0.0), p1: Point::new(1000.0, 0.0), stops: stops.clone(), extend: Extend::Pad }; + let img = render(&[ColorOp::PushClip(square()), ColorOp::Fill(linear), ColorOp::PopClip]); + let (l, r) = (img.pixel(11, 20), img.pixel(29, 20)); + assert!(l[0] > 200 && l[2] < 60, "red at the start: {l:?}"); + assert!(r[2] > 200 && r[0] < 60, "blue at the end: {r:?}"); + let c = Point::new(500.0, 500.0); + let hostile = [ + ColorOp::PopClip, + ColorOp::PopTransform, + ColorOp::PushTransform(Affine::scale(0.0)), + ColorOp::Fill(ColorPaint::Solid(GlyphColor::Foreground(f32::NAN))), + ColorOp::PopTransform, + ColorOp::PushTransform(Affine::new([f64::NAN; 6])), + ColorOp::Fill(ColorPaint::Radial { c0: c, r0: -5.0, c1: c, r1: f32::INFINITY, stops: stops.clone(), extend: Extend::Pad }), + ColorOp::PopTransform, + ColorOp::PushLayer(CompositeMode::Multiply), + ColorOp::Fill(ColorPaint::Sweep { c0: c, start_angle: 0.0, end_angle: 0.0, stops: stops.clone(), extend: Extend::Pad }), + ColorOp::Fill(ColorPaint::Sweep { c0: c, start_angle: 0.0, end_angle: 0.0, stops, extend: Extend::Repeat }), + ColorOp::Fill(ColorPaint::Linear { p0: c, p1: c, stops: vec![], extend: Extend::Pad }), + ColorOp::PushTransform(Affine::new([f64::NAN; 6])), + ColorOp::PushClip(square()), + ColorOp::Fill(ColorPaint::Solid(GlyphColor::Rgba([255, 0, 0, 255]))), + ColorOp::PopClip, + ColorOp::PopTransform, + ColorOp::PushTransform(Affine::scale(0.0)), + ColorOp::PushClip(square()), + ColorOp::PopClip, + ColorOp::PopTransform, + ColorOp::PushClip(square()), + ]; + let _ = render(&hostile); + } + + #[test] + fn bgra_bitmaps_are_swizzled_and_short_data_refused() { + let pm = bgra_pixmap(&[10, 20, 30, 255, 0, 0, 0, 0], 2, 1).unwrap(); + assert_eq!(pm.width(), 2); + assert!(bgra_pixmap(&[1, 2, 3], 1, 1).is_none()); + assert!(decode_png(b"not a png", 4, 4).is_none()); + } + + fn png(w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A bitmap glyph's PNG may not be bigger than its strike claims: a bigger one is refused before + /// it is decoded, so a hostile font can't claim a tiny glyph and make us decode a huge image. A + /// smaller one is drawn (CBDT-only fonts have no outline to fall back to). + #[test] + fn bitmap_png_larger_than_its_claimed_size_is_refused() { + assert_eq!(decode_png(&png(4, 4), 4, 4).map(|p| (p.width(), p.height())), Some((4, 4))); + assert_eq!(decode_png(&png(2, 2), 4, 4).map(|p| (p.width(), p.height())), Some((2, 2)), "smaller than claimed"); + assert!(decode_png(&png(8, 8), 4, 4).is_none(), "wider and taller than claimed"); + assert!(decode_png(&png(8, 4), 4, 4).is_none(), "wider than claimed"); + assert!(decode_png(&png(4, 8), 4, 4).is_none(), "taller than claimed"); + let side = wordcraft_fonts::color::MAX_BITMAP_SIDE; + assert!(decode_png(&png(4, 4), side + 1, 4).is_none(), "past the cap"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 48127cd31..abf4e29df 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display}; use wordcraft_layout::wordart::ArtPaint; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg}; +mod color_glyph; pub mod icon_lib; /// Worker threads for rasterising (0 on the web, where there are no threads). @@ -545,10 +546,16 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, let ascent = if face.ascent.is_finite() { face.ascent.clamp(0.0, upem * 4.0) } else { upem * 0.8 }; let baseline = margin + ascent * k; let mut x = margin; + let colour = face.has_color_glyphs(); for g in wordcraft_fonts::shape(face, text, &[], |c| c) { - let o = face.glyph_outline(g.gid); - ctx.set_transform(view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k)); - ctx.fill_path(&o); + let at = view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k); + if colour && let Some(d) = color_glyph::drawable(face, g.gid, size * scale as f64, false) { + color_glyph::draw(&mut ctx, &d, at, upem, color(Rgb(0, 0, 0), 1.0), 1.0); + ctx.set_paint(color(Rgb(0, 0, 0), 1.0)); + } else { + ctx.set_transform(at); + ctx.fill_path(&face.glyph_outline(g.gid)); + } x += g.x_advance as f64 * k; if x > w as f64 { break; @@ -696,6 +703,9 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter }; ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join)); } + // Emoji and other colour glyphs (#1269) are drawn from the font's colour data. + let colour = face.has_color_glyphs(); + let ppem = *size as f64 * px_per_pt; for (gid, x, y) in glyphs { let (gx, gy) = (*x as f64, *y as f64); // Written so a NaN position is skipped too. @@ -703,6 +713,12 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl if !near { continue; } + if colour && let Some(d) = color_glyph::drawable(face, *gid, ppem, true) { + let at = view * Affine::translate((gx, gy)) * skew * Affine::scale(k); + color_glyph::draw(ctx, &d, at, face.upem, color(opts.ink(*c), 1.0), *alpha); + ctx.set_paint(color(opts.ink(*c), *alpha)); + continue; + } let o = db.outline(face, *gid); if o.elements().is_empty() { continue; @@ -1601,3 +1617,5 @@ mod tests { #[cfg(test)] mod cache_tests; +#[cfg(test)] +mod tests_color_glyphs; diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs new file mode 100644 index 000000000..4b2f8b99c --- /dev/null +++ b/crates/render/src/tests_color_glyphs.rs @@ -0,0 +1,76 @@ +//! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour. + +use super::*; +use wordcraft_layout::{LayoutCache, LayoutOptions, layout}; + +/// Colour emoji families the system fallback may pick. +const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"]; + +/// A strongly coloured (non-grey) pixel. +fn colourful(p: [u8; 4]) -> bool { + let (hi, lo) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2])); + hi - lo > 60 +} + +/// System-dependent: skips (passes) when no colour emoji font is installed. Each emoji glyph's box +/// on the rendered page holds coloured pixels. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn emoji_are_drawn_in_colour() { + let Some((img, boxes)) = emoji_page(&RenderOptions::default()) else { return }; + for (x0, y0, x1, y1) in boxes { + let n = (x0..x1).flat_map(|x| (y0..y1).map(move |y| (x, y))).filter(|(x, y)| colourful(img.pixel(*x, *y))).count(); + assert!(n > 20, "only {n} coloured pixels in emoji box {x0},{y0}..{x1},{y1}"); + } +} + +/// A dark page (View › Switch Modes) doesn't invert the emoji: they keep the font's colours, the +/// same as pictures. The pixels well inside each emoji (coloured with coloured neighbours, so the +/// paper doesn't show through) match the light page's. Skips like [`emoji_are_drawn_in_colour`]. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn emoji_keep_their_colours_on_a_dark_page() { + let Some((light, boxes)) = emoji_page(&RenderOptions::default()) else { return }; + let Some((dark, _)) = emoji_page(&RenderOptions { dark: true, ..Default::default() }) else { return }; + for (x0, y0, x1, y1) in boxes { + let inside = |x: u32, y: u32| (x - 1..=x + 1).all(|i| (y - 1..=y + 1).all(|j| colourful(light.pixel(i, j)))); + let mut n = 0; + for (x, y) in (x0 + 1..x1.saturating_sub(1)).flat_map(|x| (y0 + 1..y1.saturating_sub(1)).map(move |y| (x, y))) { + if !inside(x, y) { + continue; + } + let (l, d) = (light.pixel(x, y), dark.pixel(x, y)); + assert!(l.iter().zip(d).all(|(a, b)| a.abs_diff(b) <= 2), "emoji pixel {x},{y} changed on a dark page: {l:?} → {d:?}"); + n += 1; + } + assert!(n > 10, "only {n} pixels well inside emoji box {x0},{y0}..{x1},{y1}"); + } +} + +/// "Emoji test: 🧠 brain, ✅ check." rendered at 2x with `opts`, and each emoji glyph's box in +/// pixels; None (the test skips) when no colour emoji font is installed. +#[cfg(not(target_arch = "wasm32"))] +fn emoji_page(opts: &RenderOptions) -> Option<(Rendered, Vec<(u32, u32, u32, u32)>)> { + let has_colour_font = FontDb::global().fallback_for('🧠', u32::MAX).is_some_and(|f| COLOR_EMOJI.contains(&f.family.as_str())); + if !has_colour_font { + eprintln!("skipped: no colour emoji font installed"); + return None; + } + let d = Document::from_text("Emoji test: 🧠 brain, ✅ check."); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let page = &l.pages[0]; + let scale = 2.0; + let img = render_page(&d, page, scale, opts); + let mut boxes = vec![]; + for it in page_display(&d, page, &DisplayOptions::default()) { + if let Draw::Glyphs { face, size, glyphs, .. } = it + && COLOR_EMOJI.contains(&face.family.as_str()) + { + for (_, x, y) in glyphs { + boxes.push((((x * scale) as u32), ((y - size) * scale) as u32, ((x + size) * scale) as u32, (y * scale) as u32)); + } + } + } + assert_eq!(boxes.len(), 2, "both emoji use the colour emoji font"); + Some((img, boxes)) +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index c21628949..3fbf0db1a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: colour emoji drawn on screen, partial: CBDT verified, COLR and sbix not yet seen on real fonts, #1269; previously trivial: colour emoji drawn on screen, #1269; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -24,6 +24,7 @@ font can't match Word's metrics. | Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 | | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | +| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | | Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 | @@ -86,6 +87,8 @@ font can't match Word's metrics. | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | +| 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) | +| 2026-10-14 | trivial | Colour emoji and colour-font glyphs (COLR, sbix, CBDT) drawn on screen and in page renders (#1269) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders |