From 21adbb5dfae140e40626cefd52ed377a44977c7f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 11:22:53 -0700 Subject: [PATCH] fix: the web build draws emoji in colour (#63) Noto Color Emoji's COLRv1 build (2.7 MB gzipped, against 10 MB of CBDT bitmaps) is fetched like the other Noto fallbacks and joins the emoji generic family, so emoji presentation beats Noto Sans Symbols 2. draw paints COLRv1 glyphs on the CPU through skrifa's paint graph, which swash can't, so the same faces draw on Linux too. A fallback face arriving now lays the open page out again instead of waiting for the next edit. Assisted-by: claude-opus-5.5 --- Cargo.lock | 1 + arc/platforms.md | 3 +- crates/canvas/src/editor.rs | 10 + crates/canvas/src/interaction/mod.rs | 6 + crates/draw/Cargo.toml | 4 +- .../draw/assets/NotoColorEmoji-Grinning.ttf | Bin 0 -> 3060 bytes crates/draw/assets/NotoColorEmoji-OFL.txt | 94 +++ crates/draw/src/render.rs | 20 +- crates/draw/src/render/colr.rs | 553 ++++++++++++++++++ crates/snowbound/src/web.rs | 50 +- crates/snowbound/web/glue.js | 10 +- tools/release_web.py | 17 + 12 files changed, 746 insertions(+), 22 deletions(-) create mode 100644 crates/draw/assets/NotoColorEmoji-Grinning.ttf create mode 100644 crates/draw/assets/NotoColorEmoji-OFL.txt create mode 100644 crates/draw/src/render/colr.rs diff --git a/Cargo.lock b/Cargo.lock index ec52665c0e0f5c05d5032e37b76e11f99607b4d7..4043255a6f0aec3527a4360e4ec931a7a6552565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1040,6 +1040,7 @@ dependencies = [ "png", "pollster", "roxmltree", + "skrifa", "subsetter", "swash", "web-time", diff --git a/arc/platforms.md b/arc/platforms.md index a47bd0d46ecd439a3b812d92c3b8c90864a3ef86..53b8ba79bffac25d3831bee378976397079cf180 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -180,7 +180,8 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. - Spelling is Hunspell's American English dictionary through `spellbook`, fetched beside the module; Add to Dictionary keeps its words in the browser's files. Text the bundled faces lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to - the national standards' characters by `tools/web/subset_cjk.py`). + the national standards' characters by `tools/web/subset_cjk.py`; emoji in Noto Color + Emoji's COLRv1 outlines, which `draw` paints itself), and the page is laid out again. - AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden button, then on every visit) the trees AccessKit would get are mirrored as hidden elements with ARIA roles; acting on one sends AccessKit's action back. diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 2f2f9e815f051bd4ecb095abc50c245517944814..995b670a5ef90c06b8612486164a9dfebe473bb9 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -1215,6 +1215,16 @@ impl CanvasEditor { Ok(true) } + /// Lays every outline out again, keeping its text and selection, as after fonts arrived + /// that draw characters the old layout had no face for. + pub fn relayout(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { + for outline in &mut self.outlines { + outline.shaped = + TextOutline::from_outline(engine, &outline.snapshot(), &self.definitions)?.shaped; + } + Ok(()) + } + /// Rebuilds the stored page, restoring the title areas and read-only objects import split up. pub fn page(&self) -> Result { let mut objects: Vec = Vec::new(); diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index fdfb3d7b56c0d824aaacd60de94cafd4279332a4..54f96f6e81b71da4b80374e011968eef3132389e 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -390,6 +390,12 @@ impl PageView { self.moved() } + /// Lays the page out again where it stands, as after fonts arrived. + pub fn relayout(&mut self) -> Result { + self.editor.relayout(&mut self.engine)?; + self.moved() + } + /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and /// does not reveal the restored selection. Returns the editor of the page left. diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index aa8792578116a7e385e6d1fcf41b2bb55f057622..bda33f7bf9760675c899447d104afdce310524b2 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -9,7 +9,7 @@ default = ["wgpu"] # The renderer; `edit` needs only parley. It submits through wgpu, or without it through # OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its # maker picks one, as Windows 7 has no Direct3D 12. -render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] +render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:skrifa", "dep:swash"] wgpu = ["render", "dep:wgpu", "dep:windows"] # Writes layers as PDF pages, text as real text in subset fonts. pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] @@ -27,6 +27,8 @@ miniz_oxide = { version = "0.8", optional = true } pdf-writer = { version = "0.15", optional = true } roxmltree = { version = "0.21", optional = true } subsetter = { version = "0.2.6", default-features = false, optional = true } +# COLRv1 glyphs, which swash can't paint. +skrifa = { version = "0.44.0", optional = true } swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } diff --git a/crates/draw/assets/NotoColorEmoji-Grinning.ttf b/crates/draw/assets/NotoColorEmoji-Grinning.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8bf133393596ce73b71e1195fa0b26737d6d2350 GIT binary patch literal 3060 zcmb7GYiv|S6h1R^_wEC_rKN0Z0%hsKE)?2A+uiaK+wy2hYq#`;8Vs!4r*7L#pFqG6 zO^6y2K?x8-w2Fy{_(K#VT9QUV`QtBwKTw1CNHk)i35r6<^*eL7v=EGXH#2k2nKNg; z^Ub+;mIM){(JoR{zdBmma5m@YBSfAgk(jr-zM^*Dt-N1}rW}G-VO3*u<(Zpju0y{7 zJwMu5RJ=KV%W@*46#ABUcPvSn*IJlq&%LY0&3(b`NaztT3-){!i#RiQafj?obubkFnkz+nSWO!>!PtgYNH%b+>K* z^6jUw{v46xg=BC4fOVQmfWMySNr+Klh#O)VO+h|Br)h?k1j{0kZ1I!V?U>=wohVJq z^f(>5BS9_JCgXq_pln1ej=(uw8Vm)4fjqC8k(I0DP~u$8hNh+sHJ^0X9odsuwJNda z$QxA^)#rzYKdY)-JAcE@_hGSHrpXBSB~k2~k!1#!w`Rhvf)k6&xN32J(WzP_8UVK{r-xZmzF6 zmw4>tp_1@R$C@>GIr8kO;q%oMRpCU#`prcE|LM?c2qBd9m%1h**xjn(@`wkx3J3S<_Geep2WHOE+!|Ti!BH!sn#7ouI;1jGv5;ZuU~DdW zSN#ZVGoVC#*$Cz|LuM!#;&6;ky*fSQb_mn)<_BXZOovc5I8o&EX1Lu-Mx|_#%3<2@ zI9qy0XS;AbTj%0P1nGwI5E(#&0j9v`TLRl;3cho@T!!0lU@uRG>3sRHI=tU$X1Go1 zFx}2^Ru1mV3JPCFDASeh%APaZxcQK(jdHYFUFzhSgBE!@*R zKo8MI>ZOA;Mn4FzXx8gwnm1$B>FUktD}N_t=q2A@{0Q#_5yo>AuuS8!It|q%si(=f z#qWt2r4}(Puh3DU#3kVZC%~ajTPEJATNI!<#$`L6y-N3j4C9vAOE?yJShmCVusw=) zDiMf!_mkRC8|$KVYiZ2FWA6b>H!;Dv1DO0AgP+`50RW6y_%3<$I2Aq7Ox^Pe@dtV| zdv!W}2v1?~TK*Q_jsJ;DI{U|FNOb7T8`Oeu$Ezc37Bff4q^P-sTDkzVy+E6&$HeN zpJF|`47it~l_C;1mdhZu-N}V{urry+V}&0>;6< zjmWeXV-z^q*H06XYwwD{tlV28oFz9u-kAT6tW_iA6Y2sJgTP|rv+FLVQRGntu<$8X>dOKPi zQTYw!3LC`OfqfV!pI0An@M({~wRZLMDf1de$+^VP?6;cdx!#8K+8$W#y4?obpw5h| zne(vU70%}0>TROV?JDmFVm{dMt)kBI`ZAU+(jWdu(~o zi}4ZXe}Ll;zU~k`g1`Ly@VE$VBiiF=JJ6m%JArl*?JOGqBfSVmd|z>SO_M{S6L$)F ziGMT7kBM?xew7^MRF12qoMXnwz^#J7PWuKz1b(o$^+nD!`a1mzoK}(pPWaE7se?;N zqTIf7w6pCm9vq>Re*u}=(J2W#Y0)J~r9`(RXG-+o!YkJsbGq1cs-SYK+!#^2NIxzZ zU68!^gTAoRn!DwBi(dTMN)G>G1zx{yEgZXHE&k> Option { + let font = FontRef::from_index(data, index).ok()?; + let glyph = GlyphId::from(glyph); + let color = font + .color_glyphs() + .get_with_format(glyph, ColorGlyphFormat::ColrV1)?; + let coords: Vec = coords.iter().map(|&c| F2Dot14::from_bits(c)).collect(); + let location = LocationRef::new(&coords); + let bounds = color + .bounding_box(location, Size::new(size)) + .or_else(|| font.metrics(Size::new(size), location).bounds)?; + let left = (bounds.x_min + offset[0]).floor(); + let top = (bounds.y_max + offset[1]).ceil(); + let width = ((bounds.x_max + offset[0]).ceil() - left) as usize; + let height = (top - (bounds.y_min + offset[1]).floor()) as usize; + if width == 0 || height == 0 || width * height > 1 << 22 { + return None; + } + let scale = size / f32::from(font.head().ok()?.units_per_em()); + let palette = font + .color_palettes() + .get(0) + .map(|palette| { + palette + .colors() + .iter() + .map(|c| [c.red, c.green, c.blue, c.alpha].map(|v| f32::from(v) / 255.0)) + .collect() + }) + .unwrap_or_default(); + let mut painter = Painter { + font: &font, + location, + palette, + width, + height, + transforms: vec![Transform { + xx: scale, + yy: -scale, + dx: offset[0] - left, + dy: top - offset[1], + ..Transform::default() + }], + clips: vec![vec![1.0; width * height]], + layers: vec![(vec![[0.0; 4]; width * height], CompositeMode::SrcOver)], + }; + color.paint(location, &mut painter).ok()?; + let (pixels, _) = painter.layers.pop()?; + Some(Image { + content: Content::Color, + placement: Placement { + left: left as i32, + top: top as i32, + width: width as u32, + height: height as u32, + }, + data: pixels + .iter() + .flat_map(|&[r, g, b, a]| { + let straight = |v: f32| match a > 0.0 { + true => ((v / a).clamp(0.0, 1.0) * 255.0).round() as u8, + false => 0, + }; + [ + straight(r), + straight(g), + straight(b), + (a * 255.0).round() as u8, + ] + }) + .collect(), + ..Image::default() + }) +} + +/// Paints into layers of premultiplied pixels; transforms map font units to pixels, y down. +struct Painter<'a> { + font: &'a FontRef<'a>, + location: LocationRef<'a>, + palette: Vec<[f32; 4]>, + width: usize, + height: usize, + transforms: Vec, + /// Coverage, each the intersection of those below it. + clips: Vec>, + layers: Vec<(Vec<[f32; 4]>, CompositeMode)>, +} + +impl Painter<'_> { + fn transform(&self) -> Transform { + *self.transforms.last().expect("the pixel transform stays") + } + + /// Narrows the clip to the path `commands` describes, in pixels. + fn clip(&mut self, commands: &[Command]) { + let mut coverage = vec![0; self.width * self.height]; + Mask::new(commands) + .size(self.width as u32, self.height as u32) + .render_into(&mut coverage, None); + let clip = self.clips.last().expect("the whole image stays a clip"); + let clip = clip + .iter() + .zip(coverage) + .map(|(outer, inner)| outer * f32::from(inner) / 255.0) + .collect(); + self.clips.push(clip); + } + + /// The premultiplied colour of palette entry `index` at `alpha`; 0xFFFF, the text's + /// colour, is black, as colour glyphs aren't tinted. + fn color(&self, index: u16, alpha: f32) -> [f32; 4] { + let [r, g, b, a] = match index { + 0xFFFF => [0.0, 0.0, 0.0, 1.0], + _ => self + .palette + .get(usize::from(index)) + .copied() + .unwrap_or_default(), + }; + let a = a * alpha; + [r * a, g * a, b * a, a] + } +} + +impl ColorPainter for Painter<'_> { + fn push_transform(&mut self, transform: Transform) { + self.transforms.push(self.transform() * transform); + } + + fn pop_transform(&mut self) { + if self.transforms.len() > 1 { + self.transforms.pop(); + } + } + + fn push_clip_glyph(&mut self, glyph: GlyphId) { + let mut pen = Pen { + transform: self.transform(), + commands: Vec::new(), + }; + if let Some(outline) = self.font.outline_glyphs().get(glyph) { + let _ = outline.draw( + DrawSettings::unhinted(Size::unscaled(), self.location), + &mut pen, + ); + } + self.clip(&pen.commands); + } + + fn push_clip_box(&mut self, clip: BoundingBox) { + let mut pen = Pen { + transform: self.transform(), + commands: Vec::new(), + }; + pen.move_to(clip.x_min, clip.y_min); + pen.line_to(clip.x_max, clip.y_min); + pen.line_to(clip.x_max, clip.y_max); + pen.line_to(clip.x_min, clip.y_max); + pen.close(); + self.clip(&pen.commands); + } + + fn pop_clip(&mut self) { + if self.clips.len() > 1 { + self.clips.pop(); + } + } + + fn fill(&mut self, brush: Brush<'_>) { + let shade = Shade::new(self, brush); + let inverse = invert(self.transform()); + let clip = self.clips.last().expect("the whole image stays a clip"); + let (layer, _) = self.layers.last_mut().expect("the image stays a layer"); + for (i, (pixel, &coverage)) in layer.iter_mut().zip(clip).enumerate() { + if coverage <= 0.0 { + continue; + } + let (x, y) = ((i % self.width) as f32 + 0.5, (i / self.width) as f32 + 0.5); + let Some(source) = shade.at(inverse.transform(x, y)) else { + continue; + }; + let behind = 1.0 - source[3] * coverage; + *pixel = std::array::from_fn(|c| source[c] * coverage + pixel[c] * behind); + } + } + + fn push_layer(&mut self, mode: CompositeMode) { + self.layers + .push((vec![[0.0; 4]; self.width * self.height], mode)); + } + + fn pop_layer(&mut self) { + if self.layers.len() < 2 { + return; + } + let (source, mode) = self.layers.pop().expect("checked above"); + let (backdrop, _) = self.layers.last_mut().expect("checked above"); + for (b, s) in backdrop.iter_mut().zip(source) { + *b = composite(mode, s, *b); + } + } +} + +/// `source` over `backdrop` in `mode`, both premultiplied. +fn composite(mode: CompositeMode, s: [f32; 4], b: [f32; 4]) -> [f32; 4] { + use CompositeMode::*; + let (sa, ba) = (s[3], b[3]); + let porter_duff = |fs: f32, fb: f32| std::array::from_fn(|c| s[c] * fs + b[c] * fb); + // W3C Compositing: a separable blend of unpremultiplied channels, then source-over. + let blend = |f: fn(f32, f32) -> f32| { + let mut out: [f32; 4] = std::array::from_fn(|c| { + let mixed = match sa > 0.0 && ba > 0.0 { + true => sa * ba * f(s[c] / sa, b[c] / ba), + false => 0.0, + }; + s[c] * (1.0 - ba) + b[c] * (1.0 - sa) + mixed + }); + out[3] = sa + ba - sa * ba; + out + }; + match mode { + Clear => [0.0; 4], + Src => s, + Dest => b, + DestOver => porter_duff(1.0 - ba, 1.0), + SrcIn => porter_duff(ba, 0.0), + DestIn => porter_duff(0.0, sa), + SrcOut => porter_duff(1.0 - ba, 0.0), + DestOut => porter_duff(0.0, 1.0 - sa), + SrcAtop => porter_duff(ba, 1.0 - sa), + DestAtop => porter_duff(1.0 - ba, sa), + Xor => porter_duff(1.0 - ba, 1.0 - sa), + Plus => std::array::from_fn(|c| (s[c] + b[c]).min(1.0)), + Screen => blend(|s, b| s + b - s * b), + Multiply => blend(|s, b| s * b), + Darken => blend(f32::min), + Lighten => blend(f32::max), + Difference => blend(|s, b| (s - b).abs()), + Exclusion => blend(|s, b| s + b - 2.0 * s * b), + Overlay => blend(|s, b| hard_light(b, s)), + HardLight => blend(hard_light), + SoftLight => blend(|s, b| { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = match b <= 0.25 { + true => ((16.0 * b - 12.0) * b + 4.0) * b, + false => b.sqrt(), + }; + b + (2.0 * s - 1.0) * (d - b) + } + }), + ColorDodge => blend(|s, b| match s < 1.0 { + true => (b / (1.0 - s)).min(1.0), + false => f32::from(b > 0.0), + }), + ColorBurn => blend(|s, b| match s > 0.0 { + true => 1.0 - ((1.0 - b) / s).min(1.0), + false => f32::from(b >= 1.0), + }), + // The HSL modes, rare in emoji, and unknown modes paint as source-over. + _ => porter_duff(1.0, 1.0 - sa), + } +} + +fn hard_light(s: f32, b: f32) -> f32 { + match s <= 0.5 { + true => b * 2.0 * s, + false => { + let s = 2.0 * s - 1.0; + b + s - b * s + } + } +} + +fn invert(m: Transform) -> Transform { + let det = m.xx * m.yy - m.xy * m.yx; + if det.abs() < f32::EPSILON { + return Transform { + xx: 0.0, + yy: 0.0, + ..Transform::default() + }; + } + let (xx, xy, yx, yy) = (m.yy / det, -m.xy / det, -m.yx / det, m.xx / det); + Transform { + xx, + yx, + xy, + yy, + dx: -(xx * m.dx + xy * m.dy), + dy: -(yx * m.dx + yy * m.dy), + } +} + +/// A brush resolved against the palette, evaluated at points in its own space. +enum Shade { + Solid([f32; 4]), + Linear { + p0: [f32; 2], + along: [f32; 2], + line: Line, + }, + Radial { + c0: [f32; 2], + r0: f32, + cd: [f32; 2], + dr: f32, + line: Line, + }, + Sweep { + c0: [f32; 2], + start: f32, + span: f32, + line: Line, + }, +} + +impl Shade { + fn new(painter: &Painter, brush: Brush<'_>) -> Self { + let line = |stops: &[ColorStop], extend| Line { + stops: { + let mut stops: Vec<(f32, [f32; 4])> = stops + .iter() + .map(|s| (s.offset, painter.color(s.palette_index, s.alpha))) + .collect(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + stops + }, + extend, + }; + match brush { + Brush::Solid { + palette_index, + alpha, + } => Shade::Solid(painter.color(palette_index, alpha)), + Brush::LinearGradient { + p0, + p1, + color_stops, + extend, + } => { + let d = [p1.x - p0.x, p1.y - p0.y]; + let length = d[0] * d[0] + d[1] * d[1]; + Shade::Linear { + p0: [p0.x, p0.y], + along: match length > 0.0 { + true => d.map(|v| v / length), + false => [0.0; 2], + }, + line: line(color_stops, extend), + } + } + Brush::RadialGradient { + c0, + r0, + c1, + r1, + color_stops, + extend, + } => Shade::Radial { + c0: [c0.x, c0.y], + r0, + cd: [c1.x - c0.x, c1.y - c0.y], + dr: r1 - r0, + line: line(color_stops, extend), + }, + Brush::SweepGradient { + c0, + start_angle, + end_angle, + color_stops, + extend, + } => Shade::Sweep { + c0: [c0.x, c0.y], + start: start_angle, + span: end_angle - start_angle, + line: line(color_stops, extend), + }, + } + } + + fn at(&self, (x, y): (f32, f32)) -> Option<[f32; 4]> { + match self { + Shade::Solid(color) => Some(*color), + Shade::Linear { p0, along, line } => { + Some(line.at((x - p0[0]) * along[0] + (y - p0[1]) * along[1])) + } + Shade::Radial { + c0, + r0, + cd, + dr, + line, + } => { + // The greatest t whose circle, of radius r0 + t·dr ≥ 0, passes through the + // point: a two-point conical gradient, as Skia and the spec draw it. + let p = [x - c0[0], y - c0[1]]; + let a = cd[0] * cd[0] + cd[1] * cd[1] - dr * dr; + let b = p[0] * cd[0] + p[1] * cd[1] + r0 * dr; + let c = p[0] * p[0] + p[1] * p[1] - r0 * r0; + let radius = |t: f32| r0 + t * dr >= 0.0; + let t = if a.abs() < 1e-6 { + Some(c / (2.0 * b)).filter(|t| t.is_finite() && radius(*t)) + } else { + let discriminant = b * b - a * c; + (discriminant >= 0.0) + .then(|| { + let root = discriminant.sqrt(); + let (t1, t2) = ((b + root) / a, (b - root) / a); + let (high, low) = (t1.max(t2), t1.min(t2)); + [high, low].into_iter().find(|t| radius(*t)) + }) + .flatten() + }; + t.map(|t| line.at(t)) + } + Shade::Sweep { + c0, + start, + span, + line, + } => { + let angle = (y - c0[1]).atan2(x - c0[0]).to_degrees().rem_euclid(360.0); + (*span != 0.0).then(|| line.at((angle - start) / span)) + } + } + } +} + +/// A colour line: stops by offset, extended past 0 and 1 by `extend`. +struct Line { + stops: Vec<(f32, [f32; 4])>, + extend: Extend, +} + +impl Line { + fn at(&self, t: f32) -> [f32; 4] { + let t = match self.extend { + Extend::Repeat => t.rem_euclid(1.0), + Extend::Reflect => 1.0 - (t.rem_euclid(2.0) - 1.0).abs(), + _ => t, + }; + let Some(first) = self.stops.first() else { + return [0.0; 4]; + }; + if t <= first.0 { + return first.1; + } + for pair in self.stops.windows(2) { + let ((t0, c0), (t1, c1)) = (pair[0], pair[1]); + if t <= t1 { + let f = match t1 > t0 { + true => (t - t0) / (t1 - t0), + false => 1.0, + }; + return std::array::from_fn(|c| c0[c] + (c1[c] - c0[c]) * f); + } + } + self.stops.last().expect("checked above").1 + } +} + +/// Collects an outline as pixel-space path commands. +struct Pen { + transform: Transform, + commands: Vec, +} + +impl Pen { + fn point(&self, x: f32, y: f32) -> Vector { + let (x, y) = self.transform.transform(x, y); + Vector::new(x, y) + } +} + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + self.commands.push(Command::MoveTo(self.point(x, y))); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.commands.push(Command::LineTo(self.point(x, y))); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.commands + .push(Command::QuadTo(self.point(cx0, cy0), self.point(x, y))); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.commands.push(Command::CurveTo( + self.point(cx0, cy0), + self.point(cx1, cy1), + self.point(x, y), + )); + } + + fn close(&mut self) { + self.commands.push(Command::Close); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn colrv1_glyphs_paint_their_gradients_in_colour() { + // Noto Color Emoji's 😀 alone (assets/NotoColorEmoji-OFL.txt). + let font = include_bytes!("../../assets/NotoColorEmoji-Grinning.ttf"); + let image = super::paint(font, 0, 1, 64.0, &[], [0.0, 0.0]).expect("a COLRv1 glyph"); + assert_eq!(image.content, swash::scale::image::Content::Color); + let p = image.placement; + let pixel = |x: u32, y: u32| { + let i = ((y * p.width + x) * 4) as usize; + [0, 1, 2, 3].map(|c| image.data[i + c]) + }; + // The face's radial gradient: yellow, opaque, lighter above than below. + let [r, g, b, a] = pixel(p.width / 2, p.height / 6); + assert!( + a == 255 && r > 240 && g > 180 && b < 100, + "{:?}", + [r, g, b, a] + ); + assert!(pixel(p.width / 2, p.height / 6)[1] > pixel(p.width / 8, p.height * 3 / 5)[1]); + assert_eq!(pixel(0, 0)[3], 0, "outside the face is clear"); + } +} diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index a42a840e85884a7b4f065c37f8baafde98914678..b3c45cae1c3e58f6a90ea85ba895d8fd24357c2a 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -96,7 +96,7 @@ const FOLDERS: &str = "/Folders"; const CHOSEN: &str = "/Chosen"; /// Faces for what the bundled ones lack, beside them in `fonts/`, fetched the first time a /// page holds a character in their ranges: Noto's, by script. -const FALLBACKS: [(&str, &[RangeInclusive]); 6] = [ +const FALLBACKS: [(&str, &[RangeInclusive]); 7] = [ ( "NotoSansArabic.ttf", &[ @@ -128,7 +128,20 @@ const FALLBACKS: [(&str, &[RangeInclusive]); 6] = [ 0xff00..=0xffef, ], ), + ( + EMOJI, + &[ + 0x20e3..=0x20e3, + 0x2300..=0x23ff, + 0x2600..=0x27bf, + 0x2b00..=0x2bff, + 0xfe0f..=0xfe0f, + 0x1f000..=0x1faff, + ], + ), ]; +/// Noto Color Emoji as COLRv1 outlines, gzipped: a third the size of its bitmaps. +const EMOJI: &str = "Noto-COLRv1.ttf.gz"; /// How long changed files wait to be written out, so a burst of edits writes once. const STORE_AFTER: Duration = Duration::from_millis(500); @@ -1373,29 +1386,40 @@ fn fetch_fallbacks(text: &str) { } } -/// A fallback face arrived: the page, its loaders and the interface draw what the bundled -/// faces lack with it, and the page is laid out again. +/// Fallback face `name` arrived: the page, its loaders and the interface draw what the +/// bundled faces lack with it, and the page is laid out again. #[wasm_bindgen] -pub fn font_arrived(bytes: Vec) { +pub fn font_arrived(name: String, bytes: Vec) { + use parley::fontique::{Collection, GenericFamily}; let face = parley::fontique::Blob::new(Arc::new(bytes)); - send(UserEvent::Then(Box::new(move |state| { - let collection = &mut state.view.engine.fonts.collection; - let families: Vec = collection - .register_fonts(face.clone(), None) - .into_iter() - .filter_map(|(family, _)| collection.family_name(family).map(String::from)) + // Emoji take the emoji face before any script's fallback, which may draw them plain. + let register = move |collection: &mut Collection, face| { + let ids: Vec<_> = (collection.register_fonts(face, None).into_iter()) + .map(|(family, _)| family) + .collect(); + let families: Vec = (ids.iter()) + .filter_map(|family| collection.family_name(*family).map(String::from)) .collect(); draw::fall_back_to(collection, &families); + if name == EMOJI { + collection.append_generic_families(GenericFamily::Emoji, ids.into_iter()); + } + families + }; + send(UserEvent::Then(Box::new(move |state| { + let families = register(&mut state.view.engine.fonts.collection, face.clone()); if let Ok(mut layouts) = state.layouts.lock() { - layouts.fonts.collection.register_fonts(face.clone(), None); - draw::fall_back_to(&mut layouts.fonts.collection, &families); + register(&mut layouts.fonts.collection, face.clone()); } for family in &families { state.ui.preview_font(face.clone(), family); } state.ui.fall_back_to(&families); state.renderer.clear_glyph_cache(); - state.refresh() + let response = state.view.relayout()?; + state.respond(response); + state.window.request_redraw(); + Ok(()) }))); } diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index c8aac1ee4f5a2885820568e0f70fd081a3d0e26d..799f7f06881a06b23ce35dc45e68bc1adc0e7f3d 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -289,8 +289,14 @@ export function pickNotebook() { export function fetchFont(name) { fetch(`fonts/${name}`) - .then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status))) - .then((data) => wasm.font_arrived(new Uint8Array(data))) + .then((response) => { + if (!response.ok) return Promise.reject(response.status); + const body = name.endsWith(".gz") + ? new Response(response.body.pipeThrough(new DecompressionStream("gzip"))) + : response; + return body.arrayBuffer(); + }) + .then((data) => wasm.font_arrived(name, new Uint8Array(data))) .catch((error) => console.warn("Font", name, error)); } diff --git a/tools/release_web.py b/tools/release_web.py index 9d2c8ff94ed90f3bfcf453d33776790f32a0d771..ddbb8bb22e9ca11ed75f31d174a92de1f18bd429 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -6,12 +6,15 @@ static folder and publishes it to the share's web/ folder. python3 tools/release_web.py --publish # build, then replace web/ """ import argparse +import gzip +import hashlib import os from pathlib import Path import shutil import subprocess import sys import tempfile +import urllib.request ROOT = Path(__file__).resolve().parents[1] # The one target folder wasm builds share, apart from the native builds in target/. @@ -21,6 +24,10 @@ FONTS = ROOT / 'crates/canvas/assets/fonts' PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') # copyparty lists the folder itself, so the app is at its index.html. URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' +EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf' +EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2' +# As `EMOJI` in src/web.rs names it. +EMOJI_FONT = 'Noto-COLRv1.ttf.gz' # Size over speed where it costs little: the module is most of the first load. PROFILE = ['--config', 'profile.release.opt-level="s"'] @@ -98,6 +105,16 @@ def fallbacks(fonts): run(['uv', 'run', '--no-project', '--with', 'fonttools', 'python', ROOT / 'tools/web/subset_cjk.py', collection, cjk]) shutil.copy(cjk, fonts) + # Noto Color Emoji as COLRv1 outlines (4.8 MB), which src/render/colr.rs in draw paints, + # gzipped to 2.7 MB for the glue to inflate; nixpkgs has only its 10 MB of bitmaps. + emoji = TARGET / EMOJI_FONT + if not emoji.exists(): + data = urllib.request.urlopen(EMOJI_URL).read() + if hashlib.sha256(data).hexdigest() != EMOJI_SHA256: + sys.exit(f'{EMOJI_URL} is not the font this build pins') + emoji.write_bytes(gzip.compress(data, 9, mtime=0)) + shutil.copy(emoji, fonts) + shutil.copy(ROOT / 'crates/draw/assets/NotoColorEmoji-OFL.txt', fonts) (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n') -- 2.54.0