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 Binary files /dev/null and b/crates/draw/assets/NotoColorEmoji-Grinning.ttf differ diff --git a/crates/draw/assets/NotoColorEmoji-OFL.txt b/crates/draw/assets/NotoColorEmoji-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..b2a987e0f72bcbd67e8bb57878f2f6b68b635ad3 --- /dev/null +++ b/crates/draw/assets/NotoColorEmoji-OFL.txt @@ -0,0 +1,94 @@ +Copyright 2022 Google Inc. (https://github.com/googlefonts/noto-emoji) + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index c16123b864c771ba9bfa2871ac62b020848c5ffe..01689747fec1b9bb022763637834e2ea4a81f236 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1,3 +1,4 @@ +mod colr; #[cfg(all(feature = "wgpu", windows))] mod d3d11; #[cfg(all(feature = "wgpu", windows))] @@ -1108,10 +1109,10 @@ impl Renderer { Source::Outline, ]); // Swash rasterizes outlines in Y-up coordinates. - render.format(Format::Alpha).offset(Vector::new( - f32::from(phase[0]) * 0.25, - -f32::from(phase[1]) * 0.25, - )); + let offset = [f32::from(phase[0]) * 0.25, -f32::from(phase[1]) * 0.25]; + render + .format(Format::Alpha) + .offset(Vector::new(offset[0], offset[1])); if run.embolden { render.embolden(size / 24.0); } @@ -1121,7 +1122,16 @@ impl Renderer { Angle::from_degrees(0.0), ))); } - let image = render.render(&mut scaler, glyph_id).map(|mut image| { + let image = colr::paint( + run.font.as_ref(), + run.index, + glyph_id, + size, + run.coords, + offset, + ) + .or_else(|| render.render(&mut scaler, glyph_id)) + .map(|mut image| { if image.content == Content::Mask { let coverage = text_coverage(tone); for alpha in &mut image.data { diff --git a/crates/draw/src/render/colr.rs b/crates/draw/src/render/colr.rs new file mode 100644 index 0000000000000000000000000000000000000000..ff7dcdefa708b87581bc00237c983aa0afae6b20 --- /dev/null +++ b/crates/draw/src/render/colr.rs @@ -0,0 +1,553 @@ +//! COLRv1 glyphs, which swash can't paint: skrifa walks the paint graph and this paints it +//! on the CPU, in premultiplied sRGB as browsers blend it, into an image the atlas takes as +//! any colour glyph. + +use skrifa::{ + FontRef, GlyphId, MetadataProvider, + color::{Brush, ColorGlyphFormat, ColorPainter, ColorStop, CompositeMode, Extend, Transform}, + instance::{LocationRef, Size}, + outline::{DrawSettings, OutlinePen}, + raw::TableProvider, + raw::types::{BoundingBox, F2Dot14}, +}; +use swash::{ + scale::image::{Content, Image}, + zeno::{Command, Mask, Placement, Vector}, +}; + +/// The colour image of glyph `glyph` at `size` pixels per em, moved right and up by +/// `offset` pixels; `None` where the font has no COLRv1 paint for it. +pub(super) fn paint( + data: &[u8], + index: u32, + glyph: u16, + size: f32, + coords: &[i16], + offset: [f32; 2], +) -> 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')