authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 11:22:53-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 12:53:12-07:00
log21adbb5dfae140e40626cefd52ed377a44977c7f
treed5d1c61831f6946eab80eb5fc1d6c878880231e7
parent678a46da0816b22d6b37cd361abf395870595796
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

12 files changed, 746 insertions(+), 22 deletions(-)

Cargo.lock+1
...@@ -1040,6 +1040,7 @@ dependencies = [...@@ -1040,6 +1040,7 @@ dependencies = [
1040 "png",1040 "png",
1041 "pollster",1041 "pollster",
1042 "roxmltree",1042 "roxmltree",
1043 "skrifa",
1043 "subsetter",1044 "subsetter",
1044 "swash",1045 "swash",
1045 "web-time",1046 "web-time",
arc/platforms.md+2-1
...@@ -180,7 +180,8 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -180,7 +180,8 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
180- Spelling is Hunspell's American English dictionary through `spellbook`, fetched beside the180- Spelling is Hunspell's American English dictionary through `spellbook`, fetched beside the
181 module; Add to Dictionary keeps its words in the browser's files. Text the bundled faces181 module; Add to Dictionary keeps its words in the browser's files. Text the bundled faces
182 lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to182 lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to
183 the national standards' characters by `tools/web/subset_cjk.py`).183 the national standards' characters by `tools/web/subset_cjk.py`; emoji in Noto Color
184 Emoji's COLRv1 outlines, which `draw` paints itself), and the page is laid out again.
184- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden185- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden
185 button, then on every visit) the trees AccessKit would get are mirrored as hidden186 button, then on every visit) the trees AccessKit would get are mirrored as hidden
186 elements with ARIA roles; acting on one sends AccessKit's action back.187 elements with ARIA roles; acting on one sends AccessKit's action back.
crates/canvas/src/editor.rs+10
...@@ -1215,6 +1215,16 @@ impl CanvasEditor {...@@ -1215,6 +1215,16 @@ impl CanvasEditor {
1215 Ok(true)1215 Ok(true)
1216 }1216 }
12171217
1218 /// Lays every outline out again, keeping its text and selection, as after fonts arrived
1219 /// that draw characters the old layout had no face for.
1220 pub fn relayout(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
1221 for outline in &mut self.outlines {
1222 outline.shaped =
1223 TextOutline::from_outline(engine, &outline.snapshot(), &self.definitions)?.shaped;
1224 }
1225 Ok(())
1226 }
1227
1218 /// Rebuilds the stored page, restoring the title areas and read-only objects import split up.1228 /// Rebuilds the stored page, restoring the title areas and read-only objects import split up.
1219 pub fn page(&self) -> Result<Page, EditorError> {1229 pub fn page(&self) -> Result<Page, EditorError> {
1220 let mut objects: Vec<PageObject> = Vec::new();1230 let mut objects: Vec<PageObject> = Vec::new();
crates/canvas/src/interaction/mod.rs+6
...@@ -390,6 +390,12 @@ impl PageView {...@@ -390,6 +390,12 @@ impl PageView {
390 self.moved()390 self.moved()
391 }391 }
392392
393 /// Lays the page out again where it stands, as after fonts arrived.
394 pub fn relayout(&mut self) -> Result<Response> {
395 self.editor.relayout(&mut self.engine)?;
396 self.moved()
397 }
398
393 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was399 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was
394 /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and400 /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and
395 /// does not reveal the restored selection. Returns the editor of the page left.401 /// does not reveal the restored selection. Returns the editor of the page left.
crates/draw/Cargo.toml+3-1
...@@ -9,7 +9,7 @@ default = ["wgpu"]...@@ -9,7 +9,7 @@ default = ["wgpu"]
9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through9# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its
11# maker picks one, as Windows 7 has no Direct3D 12.11# maker picks one, as Windows 7 has no Direct3D 12.
12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:skrifa", "dep:swash"]
13wgpu = ["render", "dep:wgpu", "dep:windows"]13wgpu = ["render", "dep:wgpu", "dep:windows"]
14# Writes layers as PDF pages, text as real text in subset fonts.14# Writes layers as PDF pages, text as real text in subset fonts.
15pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]15pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
...@@ -27,6 +27,8 @@ miniz_oxide = { version = "0.8", optional = true }...@@ -27,6 +27,8 @@ miniz_oxide = { version = "0.8", optional = true }
27pdf-writer = { version = "0.15", optional = true }27pdf-writer = { version = "0.15", optional = true }
28roxmltree = { version = "0.21", optional = true }28roxmltree = { version = "0.21", optional = true }
29subsetter = { version = "0.2.6", default-features = false, optional = true }29subsetter = { version = "0.2.6", default-features = false, optional = true }
30# COLRv1 glyphs, which swash can't paint.
31skrifa = { version = "0.44.0", optional = true }
30swash = { version = "0.2.10", optional = true }32swash = { version = "0.2.10", optional = true }
31wgpu = { workspace = true, optional = true }33wgpu = { workspace = true, optional = true }
3234
crates/draw/assets/NotoColorEmoji-Grinning.ttf created
Binary files /dev/null and b/crates/draw/assets/NotoColorEmoji-Grinning.ttf differ
crates/draw/assets/NotoColorEmoji-OFL.txt created+94
...@@ -0,0 +1,94 @@
1Copyright 2022 Google Inc. (https://github.com/googlefonts/noto-emoji)
2
3This Font Software is licensed under the SIL Open Font License,
4Version 1.1.
5
6This license is copied below, and is also available with a FAQ at:
7http://scripts.sil.org/OFL
8
9-----------------------------------------------------------
10SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
11-----------------------------------------------------------
12
13PREAMBLE
14The goals of the Open Font License (OFL) are to stimulate worldwide
15development of collaborative font projects, to support the font
16creation efforts of academic and linguistic communities, and to
17provide a free and open framework in which fonts may be shared and
18improved in partnership with others.
19
20The OFL allows the licensed fonts to be used, studied, modified and
21redistributed freely as long as they are not sold by themselves. The
22fonts, including any derivative works, can be bundled, embedded,
23redistributed and/or sold with any software provided that any reserved
24names are not used by derivative works. The fonts and derivatives,
25however, cannot be released under any other type of license. The
26requirement for fonts to remain under this license does not apply to
27any document created using the fonts or their derivatives.
28
29DEFINITIONS
30"Font Software" refers to the set of files released by the Copyright
31Holder(s) under this license and clearly marked as such. This may
32include source files, build scripts and documentation.
33
34"Reserved Font Name" refers to any names specified as such after the
35copyright statement(s).
36
37"Original Version" refers to the collection of Font Software
38components as distributed by the Copyright Holder(s).
39
40"Modified Version" refers to any derivative made by adding to,
41deleting, or substituting -- in part or in whole -- any of the
42components of the Original Version, by changing formats or by porting
43the Font Software to a new environment.
44
45"Author" refers to any designer, engineer, programmer, technical
46writer or other person who contributed to the Font Software.
47
48PERMISSION & CONDITIONS
49Permission is hereby granted, free of charge, to any person obtaining
50a copy of the Font Software, to use, study, copy, merge, embed,
51modify, redistribute, and sell modified and unmodified copies of the
52Font Software, subject to the following conditions:
53
541) Neither the Font Software nor any of its individual components, in
55Original or Modified Versions, may be sold by itself.
56
572) Original or Modified Versions of the Font Software may be bundled,
58redistributed and/or sold with any software, provided that each copy
59contains the above copyright notice and this license. These can be
60included either as stand-alone text files, human-readable headers or
61in the appropriate machine-readable metadata fields within text or
62binary files as long as those fields can be easily viewed by the user.
63
643) No Modified Version of the Font Software may use the Reserved Font
65Name(s) unless explicit written permission is granted by the
66corresponding Copyright Holder. This restriction only applies to the
67primary font name as presented to the users.
68
694) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70Software shall not be used to promote, endorse or advertise any
71Modified Version, except to acknowledge the contribution(s) of the
72Copyright Holder(s) and the Author(s) or with their explicit written
73permission.
74
755) The Font Software, modified or unmodified, in part or in whole,
76must be distributed entirely under this license, and must not be
77distributed under any other license. The requirement for fonts to
78remain under this license does not apply to any document created using
79the Font Software.
80
81TERMINATION
82This license becomes null and void if any of the above conditions are
83not met.
84
85DISCLAIMER
86THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94OTHER DEALINGS IN THE FONT SOFTWARE.
crates/draw/src/render.rs+15-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1mod colr;
1#[cfg(all(feature = "wgpu", windows))]2#[cfg(all(feature = "wgpu", windows))]
2mod d3d11;3mod d3d11;
3#[cfg(all(feature = "wgpu", windows))]4#[cfg(all(feature = "wgpu", windows))]
...@@ -1108,10 +1109,10 @@ impl Renderer {...@@ -1108,10 +1109,10 @@ impl Renderer {
1108 Source::Outline,1109 Source::Outline,
1109 ]);1110 ]);
1110 // Swash rasterizes outlines in Y-up coordinates.1111 // Swash rasterizes outlines in Y-up coordinates.
1111 render.format(Format::Alpha).offset(Vector::new(1112 let offset = [f32::from(phase[0]) * 0.25, -f32::from(phase[1]) * 0.25];
1112 f32::from(phase[0]) * 0.25,1113 render
1113 -f32::from(phase[1]) * 0.25,1114 .format(Format::Alpha)
1114 ));1115 .offset(Vector::new(offset[0], offset[1]));
1115 if run.embolden {1116 if run.embolden {
1116 render.embolden(size / 24.0);1117 render.embolden(size / 24.0);
1117 }1118 }
...@@ -1121,7 +1122,16 @@ impl Renderer {...@@ -1121,7 +1122,16 @@ impl Renderer {
1121 Angle::from_degrees(0.0),1122 Angle::from_degrees(0.0),
1122 )));1123 )));
1123 }1124 }
1124 let image = render.render(&mut scaler, glyph_id).map(|mut image| {1125 let image = colr::paint(
1126 run.font.as_ref(),
1127 run.index,
1128 glyph_id,
1129 size,
1130 run.coords,
1131 offset,
1132 )
1133 .or_else(|| render.render(&mut scaler, glyph_id))
1134 .map(|mut image| {
1125 if image.content == Content::Mask {1135 if image.content == Content::Mask {
1126 let coverage = text_coverage(tone);1136 let coverage = text_coverage(tone);
1127 for alpha in &mut image.data {1137 for alpha in &mut image.data {
crates/draw/src/render/colr.rs created+553
...@@ -0,0 +1,553 @@
1//! COLRv1 glyphs, which swash can't paint: skrifa walks the paint graph and this paints it
2//! on the CPU, in premultiplied sRGB as browsers blend it, into an image the atlas takes as
3//! any colour glyph.
4
5use skrifa::{
6 FontRef, GlyphId, MetadataProvider,
7 color::{Brush, ColorGlyphFormat, ColorPainter, ColorStop, CompositeMode, Extend, Transform},
8 instance::{LocationRef, Size},
9 outline::{DrawSettings, OutlinePen},
10 raw::TableProvider,
11 raw::types::{BoundingBox, F2Dot14},
12};
13use swash::{
14 scale::image::{Content, Image},
15 zeno::{Command, Mask, Placement, Vector},
16};
17
18/// The colour image of glyph `glyph` at `size` pixels per em, moved right and up by
19/// `offset` pixels; `None` where the font has no COLRv1 paint for it.
20pub(super) fn paint(
21 data: &[u8],
22 index: u32,
23 glyph: u16,
24 size: f32,
25 coords: &[i16],
26 offset: [f32; 2],
27) -> Option<Image> {
28 let font = FontRef::from_index(data, index).ok()?;
29 let glyph = GlyphId::from(glyph);
30 let color = font
31 .color_glyphs()
32 .get_with_format(glyph, ColorGlyphFormat::ColrV1)?;
33 let coords: Vec<F2Dot14> = coords.iter().map(|&c| F2Dot14::from_bits(c)).collect();
34 let location = LocationRef::new(&coords);
35 let bounds = color
36 .bounding_box(location, Size::new(size))
37 .or_else(|| font.metrics(Size::new(size), location).bounds)?;
38 let left = (bounds.x_min + offset[0]).floor();
39 let top = (bounds.y_max + offset[1]).ceil();
40 let width = ((bounds.x_max + offset[0]).ceil() - left) as usize;
41 let height = (top - (bounds.y_min + offset[1]).floor()) as usize;
42 if width == 0 || height == 0 || width * height > 1 << 22 {
43 return None;
44 }
45 let scale = size / f32::from(font.head().ok()?.units_per_em());
46 let palette = font
47 .color_palettes()
48 .get(0)
49 .map(|palette| {
50 palette
51 .colors()
52 .iter()
53 .map(|c| [c.red, c.green, c.blue, c.alpha].map(|v| f32::from(v) / 255.0))
54 .collect()
55 })
56 .unwrap_or_default();
57 let mut painter = Painter {
58 font: &font,
59 location,
60 palette,
61 width,
62 height,
63 transforms: vec![Transform {
64 xx: scale,
65 yy: -scale,
66 dx: offset[0] - left,
67 dy: top - offset[1],
68 ..Transform::default()
69 }],
70 clips: vec![vec![1.0; width * height]],
71 layers: vec![(vec![[0.0; 4]; width * height], CompositeMode::SrcOver)],
72 };
73 color.paint(location, &mut painter).ok()?;
74 let (pixels, _) = painter.layers.pop()?;
75 Some(Image {
76 content: Content::Color,
77 placement: Placement {
78 left: left as i32,
79 top: top as i32,
80 width: width as u32,
81 height: height as u32,
82 },
83 data: pixels
84 .iter()
85 .flat_map(|&[r, g, b, a]| {
86 let straight = |v: f32| match a > 0.0 {
87 true => ((v / a).clamp(0.0, 1.0) * 255.0).round() as u8,
88 false => 0,
89 };
90 [
91 straight(r),
92 straight(g),
93 straight(b),
94 (a * 255.0).round() as u8,
95 ]
96 })
97 .collect(),
98 ..Image::default()
99 })
100}
101
102/// Paints into layers of premultiplied pixels; transforms map font units to pixels, y down.
103struct Painter<'a> {
104 font: &'a FontRef<'a>,
105 location: LocationRef<'a>,
106 palette: Vec<[f32; 4]>,
107 width: usize,
108 height: usize,
109 transforms: Vec<Transform>,
110 /// Coverage, each the intersection of those below it.
111 clips: Vec<Vec<f32>>,
112 layers: Vec<(Vec<[f32; 4]>, CompositeMode)>,
113}
114
115impl Painter<'_> {
116 fn transform(&self) -> Transform {
117 *self.transforms.last().expect("the pixel transform stays")
118 }
119
120 /// Narrows the clip to the path `commands` describes, in pixels.
121 fn clip(&mut self, commands: &[Command]) {
122 let mut coverage = vec![0; self.width * self.height];
123 Mask::new(commands)
124 .size(self.width as u32, self.height as u32)
125 .render_into(&mut coverage, None);
126 let clip = self.clips.last().expect("the whole image stays a clip");
127 let clip = clip
128 .iter()
129 .zip(coverage)
130 .map(|(outer, inner)| outer * f32::from(inner) / 255.0)
131 .collect();
132 self.clips.push(clip);
133 }
134
135 /// The premultiplied colour of palette entry `index` at `alpha`; 0xFFFF, the text's
136 /// colour, is black, as colour glyphs aren't tinted.
137 fn color(&self, index: u16, alpha: f32) -> [f32; 4] {
138 let [r, g, b, a] = match index {
139 0xFFFF => [0.0, 0.0, 0.0, 1.0],
140 _ => self
141 .palette
142 .get(usize::from(index))
143 .copied()
144 .unwrap_or_default(),
145 };
146 let a = a * alpha;
147 [r * a, g * a, b * a, a]
148 }
149}
150
151impl ColorPainter for Painter<'_> {
152 fn push_transform(&mut self, transform: Transform) {
153 self.transforms.push(self.transform() * transform);
154 }
155
156 fn pop_transform(&mut self) {
157 if self.transforms.len() > 1 {
158 self.transforms.pop();
159 }
160 }
161
162 fn push_clip_glyph(&mut self, glyph: GlyphId) {
163 let mut pen = Pen {
164 transform: self.transform(),
165 commands: Vec::new(),
166 };
167 if let Some(outline) = self.font.outline_glyphs().get(glyph) {
168 let _ = outline.draw(
169 DrawSettings::unhinted(Size::unscaled(), self.location),
170 &mut pen,
171 );
172 }
173 self.clip(&pen.commands);
174 }
175
176 fn push_clip_box(&mut self, clip: BoundingBox<f32>) {
177 let mut pen = Pen {
178 transform: self.transform(),
179 commands: Vec::new(),
180 };
181 pen.move_to(clip.x_min, clip.y_min);
182 pen.line_to(clip.x_max, clip.y_min);
183 pen.line_to(clip.x_max, clip.y_max);
184 pen.line_to(clip.x_min, clip.y_max);
185 pen.close();
186 self.clip(&pen.commands);
187 }
188
189 fn pop_clip(&mut self) {
190 if self.clips.len() > 1 {
191 self.clips.pop();
192 }
193 }
194
195 fn fill(&mut self, brush: Brush<'_>) {
196 let shade = Shade::new(self, brush);
197 let inverse = invert(self.transform());
198 let clip = self.clips.last().expect("the whole image stays a clip");
199 let (layer, _) = self.layers.last_mut().expect("the image stays a layer");
200 for (i, (pixel, &coverage)) in layer.iter_mut().zip(clip).enumerate() {
201 if coverage <= 0.0 {
202 continue;
203 }
204 let (x, y) = ((i % self.width) as f32 + 0.5, (i / self.width) as f32 + 0.5);
205 let Some(source) = shade.at(inverse.transform(x, y)) else {
206 continue;
207 };
208 let behind = 1.0 - source[3] * coverage;
209 *pixel = std::array::from_fn(|c| source[c] * coverage + pixel[c] * behind);
210 }
211 }
212
213 fn push_layer(&mut self, mode: CompositeMode) {
214 self.layers
215 .push((vec![[0.0; 4]; self.width * self.height], mode));
216 }
217
218 fn pop_layer(&mut self) {
219 if self.layers.len() < 2 {
220 return;
221 }
222 let (source, mode) = self.layers.pop().expect("checked above");
223 let (backdrop, _) = self.layers.last_mut().expect("checked above");
224 for (b, s) in backdrop.iter_mut().zip(source) {
225 *b = composite(mode, s, *b);
226 }
227 }
228}
229
230/// `source` over `backdrop` in `mode`, both premultiplied.
231fn composite(mode: CompositeMode, s: [f32; 4], b: [f32; 4]) -> [f32; 4] {
232 use CompositeMode::*;
233 let (sa, ba) = (s[3], b[3]);
234 let porter_duff = |fs: f32, fb: f32| std::array::from_fn(|c| s[c] * fs + b[c] * fb);
235 // W3C Compositing: a separable blend of unpremultiplied channels, then source-over.
236 let blend = |f: fn(f32, f32) -> f32| {
237 let mut out: [f32; 4] = std::array::from_fn(|c| {
238 let mixed = match sa > 0.0 && ba > 0.0 {
239 true => sa * ba * f(s[c] / sa, b[c] / ba),
240 false => 0.0,
241 };
242 s[c] * (1.0 - ba) + b[c] * (1.0 - sa) + mixed
243 });
244 out[3] = sa + ba - sa * ba;
245 out
246 };
247 match mode {
248 Clear => [0.0; 4],
249 Src => s,
250 Dest => b,
251 DestOver => porter_duff(1.0 - ba, 1.0),
252 SrcIn => porter_duff(ba, 0.0),
253 DestIn => porter_duff(0.0, sa),
254 SrcOut => porter_duff(1.0 - ba, 0.0),
255 DestOut => porter_duff(0.0, 1.0 - sa),
256 SrcAtop => porter_duff(ba, 1.0 - sa),
257 DestAtop => porter_duff(1.0 - ba, sa),
258 Xor => porter_duff(1.0 - ba, 1.0 - sa),
259 Plus => std::array::from_fn(|c| (s[c] + b[c]).min(1.0)),
260 Screen => blend(|s, b| s + b - s * b),
261 Multiply => blend(|s, b| s * b),
262 Darken => blend(f32::min),
263 Lighten => blend(f32::max),
264 Difference => blend(|s, b| (s - b).abs()),
265 Exclusion => blend(|s, b| s + b - 2.0 * s * b),
266 Overlay => blend(|s, b| hard_light(b, s)),
267 HardLight => blend(hard_light),
268 SoftLight => blend(|s, b| {
269 if s <= 0.5 {
270 b - (1.0 - 2.0 * s) * b * (1.0 - b)
271 } else {
272 let d = match b <= 0.25 {
273 true => ((16.0 * b - 12.0) * b + 4.0) * b,
274 false => b.sqrt(),
275 };
276 b + (2.0 * s - 1.0) * (d - b)
277 }
278 }),
279 ColorDodge => blend(|s, b| match s < 1.0 {
280 true => (b / (1.0 - s)).min(1.0),
281 false => f32::from(b > 0.0),
282 }),
283 ColorBurn => blend(|s, b| match s > 0.0 {
284 true => 1.0 - ((1.0 - b) / s).min(1.0),
285 false => f32::from(b >= 1.0),
286 }),
287 // The HSL modes, rare in emoji, and unknown modes paint as source-over.
288 _ => porter_duff(1.0, 1.0 - sa),
289 }
290}
291
292fn hard_light(s: f32, b: f32) -> f32 {
293 match s <= 0.5 {
294 true => b * 2.0 * s,
295 false => {
296 let s = 2.0 * s - 1.0;
297 b + s - b * s
298 }
299 }
300}
301
302fn invert(m: Transform) -> Transform {
303 let det = m.xx * m.yy - m.xy * m.yx;
304 if det.abs() < f32::EPSILON {
305 return Transform {
306 xx: 0.0,
307 yy: 0.0,
308 ..Transform::default()
309 };
310 }
311 let (xx, xy, yx, yy) = (m.yy / det, -m.xy / det, -m.yx / det, m.xx / det);
312 Transform {
313 xx,
314 yx,
315 xy,
316 yy,
317 dx: -(xx * m.dx + xy * m.dy),
318 dy: -(yx * m.dx + yy * m.dy),
319 }
320}
321
322/// A brush resolved against the palette, evaluated at points in its own space.
323enum Shade {
324 Solid([f32; 4]),
325 Linear {
326 p0: [f32; 2],
327 along: [f32; 2],
328 line: Line,
329 },
330 Radial {
331 c0: [f32; 2],
332 r0: f32,
333 cd: [f32; 2],
334 dr: f32,
335 line: Line,
336 },
337 Sweep {
338 c0: [f32; 2],
339 start: f32,
340 span: f32,
341 line: Line,
342 },
343}
344
345impl Shade {
346 fn new(painter: &Painter, brush: Brush<'_>) -> Self {
347 let line = |stops: &[ColorStop], extend| Line {
348 stops: {
349 let mut stops: Vec<(f32, [f32; 4])> = stops
350 .iter()
351 .map(|s| (s.offset, painter.color(s.palette_index, s.alpha)))
352 .collect();
353 stops.sort_by(|a, b| a.0.total_cmp(&b.0));
354 stops
355 },
356 extend,
357 };
358 match brush {
359 Brush::Solid {
360 palette_index,
361 alpha,
362 } => Shade::Solid(painter.color(palette_index, alpha)),
363 Brush::LinearGradient {
364 p0,
365 p1,
366 color_stops,
367 extend,
368 } => {
369 let d = [p1.x - p0.x, p1.y - p0.y];
370 let length = d[0] * d[0] + d[1] * d[1];
371 Shade::Linear {
372 p0: [p0.x, p0.y],
373 along: match length > 0.0 {
374 true => d.map(|v| v / length),
375 false => [0.0; 2],
376 },
377 line: line(color_stops, extend),
378 }
379 }
380 Brush::RadialGradient {
381 c0,
382 r0,
383 c1,
384 r1,
385 color_stops,
386 extend,
387 } => Shade::Radial {
388 c0: [c0.x, c0.y],
389 r0,
390 cd: [c1.x - c0.x, c1.y - c0.y],
391 dr: r1 - r0,
392 line: line(color_stops, extend),
393 },
394 Brush::SweepGradient {
395 c0,
396 start_angle,
397 end_angle,
398 color_stops,
399 extend,
400 } => Shade::Sweep {
401 c0: [c0.x, c0.y],
402 start: start_angle,
403 span: end_angle - start_angle,
404 line: line(color_stops, extend),
405 },
406 }
407 }
408
409 fn at(&self, (x, y): (f32, f32)) -> Option<[f32; 4]> {
410 match self {
411 Shade::Solid(color) => Some(*color),
412 Shade::Linear { p0, along, line } => {
413 Some(line.at((x - p0[0]) * along[0] + (y - p0[1]) * along[1]))
414 }
415 Shade::Radial {
416 c0,
417 r0,
418 cd,
419 dr,
420 line,
421 } => {
422 // The greatest t whose circle, of radius r0 + t·dr ≥ 0, passes through the
423 // point: a two-point conical gradient, as Skia and the spec draw it.
424 let p = [x - c0[0], y - c0[1]];
425 let a = cd[0] * cd[0] + cd[1] * cd[1] - dr * dr;
426 let b = p[0] * cd[0] + p[1] * cd[1] + r0 * dr;
427 let c = p[0] * p[0] + p[1] * p[1] - r0 * r0;
428 let radius = |t: f32| r0 + t * dr >= 0.0;
429 let t = if a.abs() < 1e-6 {
430 Some(c / (2.0 * b)).filter(|t| t.is_finite() && radius(*t))
431 } else {
432 let discriminant = b * b - a * c;
433 (discriminant >= 0.0)
434 .then(|| {
435 let root = discriminant.sqrt();
436 let (t1, t2) = ((b + root) / a, (b - root) / a);
437 let (high, low) = (t1.max(t2), t1.min(t2));
438 [high, low].into_iter().find(|t| radius(*t))
439 })
440 .flatten()
441 };
442 t.map(|t| line.at(t))
443 }
444 Shade::Sweep {
445 c0,
446 start,
447 span,
448 line,
449 } => {
450 let angle = (y - c0[1]).atan2(x - c0[0]).to_degrees().rem_euclid(360.0);
451 (*span != 0.0).then(|| line.at((angle - start) / span))
452 }
453 }
454 }
455}
456
457/// A colour line: stops by offset, extended past 0 and 1 by `extend`.
458struct Line {
459 stops: Vec<(f32, [f32; 4])>,
460 extend: Extend,
461}
462
463impl Line {
464 fn at(&self, t: f32) -> [f32; 4] {
465 let t = match self.extend {
466 Extend::Repeat => t.rem_euclid(1.0),
467 Extend::Reflect => 1.0 - (t.rem_euclid(2.0) - 1.0).abs(),
468 _ => t,
469 };
470 let Some(first) = self.stops.first() else {
471 return [0.0; 4];
472 };
473 if t <= first.0 {
474 return first.1;
475 }
476 for pair in self.stops.windows(2) {
477 let ((t0, c0), (t1, c1)) = (pair[0], pair[1]);
478 if t <= t1 {
479 let f = match t1 > t0 {
480 true => (t - t0) / (t1 - t0),
481 false => 1.0,
482 };
483 return std::array::from_fn(|c| c0[c] + (c1[c] - c0[c]) * f);
484 }
485 }
486 self.stops.last().expect("checked above").1
487 }
488}
489
490/// Collects an outline as pixel-space path commands.
491struct Pen {
492 transform: Transform,
493 commands: Vec<Command>,
494}
495
496impl Pen {
497 fn point(&self, x: f32, y: f32) -> Vector {
498 let (x, y) = self.transform.transform(x, y);
499 Vector::new(x, y)
500 }
501}
502
503impl OutlinePen for Pen {
504 fn move_to(&mut self, x: f32, y: f32) {
505 self.commands.push(Command::MoveTo(self.point(x, y)));
506 }
507
508 fn line_to(&mut self, x: f32, y: f32) {
509 self.commands.push(Command::LineTo(self.point(x, y)));
510 }
511
512 fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
513 self.commands
514 .push(Command::QuadTo(self.point(cx0, cy0), self.point(x, y)));
515 }
516
517 fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
518 self.commands.push(Command::CurveTo(
519 self.point(cx0, cy0),
520 self.point(cx1, cy1),
521 self.point(x, y),
522 ));
523 }
524
525 fn close(&mut self) {
526 self.commands.push(Command::Close);
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 #[test]
533 fn colrv1_glyphs_paint_their_gradients_in_colour() {
534 // Noto Color Emoji's 😀 alone (assets/NotoColorEmoji-OFL.txt).
535 let font = include_bytes!("../../assets/NotoColorEmoji-Grinning.ttf");
536 let image = super::paint(font, 0, 1, 64.0, &[], [0.0, 0.0]).expect("a COLRv1 glyph");
537 assert_eq!(image.content, swash::scale::image::Content::Color);
538 let p = image.placement;
539 let pixel = |x: u32, y: u32| {
540 let i = ((y * p.width + x) * 4) as usize;
541 [0, 1, 2, 3].map(|c| image.data[i + c])
542 };
543 // The face's radial gradient: yellow, opaque, lighter above than below.
544 let [r, g, b, a] = pixel(p.width / 2, p.height / 6);
545 assert!(
546 a == 255 && r > 240 && g > 180 && b < 100,
547 "{:?}",
548 [r, g, b, a]
549 );
550 assert!(pixel(p.width / 2, p.height / 6)[1] > pixel(p.width / 8, p.height * 3 / 5)[1]);
551 assert_eq!(pixel(0, 0)[3], 0, "outside the face is clear");
552 }
553}
crates/snowbound/src/web.rs+37-13
...@@ -96,7 +96,7 @@ const FOLDERS: &str = "/Folders";...@@ -96,7 +96,7 @@ const FOLDERS: &str = "/Folders";
96const CHOSEN: &str = "/Chosen";96const CHOSEN: &str = "/Chosen";
97/// Faces for what the bundled ones lack, beside them in `fonts/`, fetched the first time a97/// Faces for what the bundled ones lack, beside them in `fonts/`, fetched the first time a
98/// page holds a character in their ranges: Noto's, by script.98/// page holds a character in their ranges: Noto's, by script.
99const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 6] = [99const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 7] = [
100 (100 (
101 "NotoSansArabic.ttf",101 "NotoSansArabic.ttf",
102 &[102 &[
...@@ -128,7 +128,20 @@ const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 6] = [...@@ -128,7 +128,20 @@ const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 6] = [
128 0xff00..=0xffef,128 0xff00..=0xffef,
129 ],129 ],
130 ),130 ),
131 (
132 EMOJI,
133 &[
134 0x20e3..=0x20e3,
135 0x2300..=0x23ff,
136 0x2600..=0x27bf,
137 0x2b00..=0x2bff,
138 0xfe0f..=0xfe0f,
139 0x1f000..=0x1faff,
140 ],
141 ),
131];142];
143/// Noto Color Emoji as COLRv1 outlines, gzipped: a third the size of its bitmaps.
144const EMOJI: &str = "Noto-COLRv1.ttf.gz";
132/// How long changed files wait to be written out, so a burst of edits writes once.145/// How long changed files wait to be written out, so a burst of edits writes once.
133const STORE_AFTER: Duration = Duration::from_millis(500);146const STORE_AFTER: Duration = Duration::from_millis(500);
134147
...@@ -1373,29 +1386,40 @@ fn fetch_fallbacks(text: &str) {...@@ -1373,29 +1386,40 @@ fn fetch_fallbacks(text: &str) {
1373 }1386 }
1374}1387}
13751388
1376/// A fallback face arrived: the page, its loaders and the interface draw what the bundled1389/// Fallback face `name` arrived: the page, its loaders and the interface draw what the
1377/// faces lack with it, and the page is laid out again.1390/// bundled faces lack with it, and the page is laid out again.
1378#[wasm_bindgen]1391#[wasm_bindgen]
1379pub fn font_arrived(bytes: Vec<u8>) {1392pub fn font_arrived(name: String, bytes: Vec<u8>) {
1393 use parley::fontique::{Collection, GenericFamily};
1380 let face = parley::fontique::Blob::new(Arc::new(bytes));1394 let face = parley::fontique::Blob::new(Arc::new(bytes));
1381 send(UserEvent::Then(Box::new(move |state| {1395 // Emoji take the emoji face before any script's fallback, which may draw them plain.
1382 let collection = &mut state.view.engine.fonts.collection;1396 let register = move |collection: &mut Collection, face| {
1383 let families: Vec<String> = collection1397 let ids: Vec<_> = (collection.register_fonts(face, None).into_iter())
1384 .register_fonts(face.clone(), None)1398 .map(|(family, _)| family)
1385 .into_iter()1399 .collect();
1386 .filter_map(|(family, _)| collection.family_name(family).map(String::from))1400 let families: Vec<String> = (ids.iter())
1401 .filter_map(|family| collection.family_name(*family).map(String::from))
1387 .collect();1402 .collect();
1388 draw::fall_back_to(collection, &families);1403 draw::fall_back_to(collection, &families);
1404 if name == EMOJI {
1405 collection.append_generic_families(GenericFamily::Emoji, ids.into_iter());
1406 }
1407 families
1408 };
1409 send(UserEvent::Then(Box::new(move |state| {
1410 let families = register(&mut state.view.engine.fonts.collection, face.clone());
1389 if let Ok(mut layouts) = state.layouts.lock() {1411 if let Ok(mut layouts) = state.layouts.lock() {
1390 layouts.fonts.collection.register_fonts(face.clone(), None);1412 register(&mut layouts.fonts.collection, face.clone());
1391 draw::fall_back_to(&mut layouts.fonts.collection, &families);
1392 }1413 }
1393 for family in &families {1414 for family in &families {
1394 state.ui.preview_font(face.clone(), family);1415 state.ui.preview_font(face.clone(), family);
1395 }1416 }
1396 state.ui.fall_back_to(&families);1417 state.ui.fall_back_to(&families);
1397 state.renderer.clear_glyph_cache();1418 state.renderer.clear_glyph_cache();
1398 state.refresh()1419 let response = state.view.relayout()?;
1420 state.respond(response);
1421 state.window.request_redraw();
1422 Ok(())
1399 })));1423 })));
1400}1424}
14011425
crates/snowbound/web/glue.js+8-2
...@@ -289,8 +289,14 @@ export function pickNotebook() {...@@ -289,8 +289,14 @@ export function pickNotebook() {
289289
290export function fetchFont(name) {290export function fetchFont(name) {
291 fetch(`fonts/${name}`)291 fetch(`fonts/${name}`)
292 .then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))292 .then((response) => {
293 .then((data) => wasm.font_arrived(new Uint8Array(data)))293 if (!response.ok) return Promise.reject(response.status);
294 const body = name.endsWith(".gz")
295 ? new Response(response.body.pipeThrough(new DecompressionStream("gzip")))
296 : response;
297 return body.arrayBuffer();
298 })
299 .then((data) => wasm.font_arrived(name, new Uint8Array(data)))
294 .catch((error) => console.warn("Font", name, error));300 .catch((error) => console.warn("Font", name, error));
295}301}
296302
tools/release_web.py+17
...@@ -6,12 +6,15 @@ static folder and publishes it to the share's web/ folder....@@ -6,12 +6,15 @@ static folder and publishes it to the share's web/ folder.
6 python3 tools/release_web.py --publish # build, then replace web/6 python3 tools/release_web.py --publish # build, then replace web/
7"""7"""
8import argparse8import argparse
9import gzip
10import hashlib
9import os11import os
10from pathlib import Path12from pathlib import Path
11import shutil13import shutil
12import subprocess14import subprocess
13import sys15import sys
14import tempfile16import tempfile
17import urllib.request
1518
16ROOT = Path(__file__).resolve().parents[1]19ROOT = Path(__file__).resolve().parents[1]
17# The one target folder wasm builds share, apart from the native builds in target/.20# The one target folder wasm builds share, apart from the native builds in target/.
...@@ -21,6 +24,10 @@ FONTS = ROOT / 'crates/canvas/assets/fonts'...@@ -21,6 +24,10 @@ FONTS = ROOT / 'crates/canvas/assets/fonts'
21PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')24PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')
22# copyparty lists the folder itself, so the app is at its index.html.25# copyparty lists the folder itself, so the app is at its index.html.
23URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'26URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'
27EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf'
28EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2'
29# As `EMOJI` in src/web.rs names it.
30EMOJI_FONT = 'Noto-COLRv1.ttf.gz'
24# Size over speed where it costs little: the module is most of the first load.31# Size over speed where it costs little: the module is most of the first load.
25PROFILE = ['--config', 'profile.release.opt-level="s"']32PROFILE = ['--config', 'profile.release.opt-level="s"']
2633
...@@ -98,6 +105,16 @@ def fallbacks(fonts):...@@ -98,6 +105,16 @@ def fallbacks(fonts):
98 run(['uv', 'run', '--no-project', '--with', 'fonttools', 'python', ROOT / 'tools/web/subset_cjk.py',105 run(['uv', 'run', '--no-project', '--with', 'fonttools', 'python', ROOT / 'tools/web/subset_cjk.py',
99 collection, cjk])106 collection, cjk])
100 shutil.copy(cjk, fonts)107 shutil.copy(cjk, fonts)
108 # Noto Color Emoji as COLRv1 outlines (4.8 MB), which src/render/colr.rs in draw paints,
109 # gzipped to 2.7 MB for the glue to inflate; nixpkgs has only its 10 MB of bitmaps.
110 emoji = TARGET / EMOJI_FONT
111 if not emoji.exists():
112 data = urllib.request.urlopen(EMOJI_URL).read()
113 if hashlib.sha256(data).hexdigest() != EMOJI_SHA256:
114 sys.exit(f'{EMOJI_URL} is not the font this build pins')
115 emoji.write_bytes(gzip.compress(data, 9, mtime=0))
116 shutil.copy(emoji, fonts)
117 shutil.copy(ROOT / 'crates/draw/assets/NotoColorEmoji-OFL.txt', fonts)
101 (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n')118 (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n')
102119
103120