| author | |
| committer | |
| log | 21adbb5dfae140e40626cefd52ed377a44977c7f |
| tree | d5d1c61831f6946eab80eb5fc1d6c878880231e7 |
| parent | 678a46da0816b22d6b37cd361abf395870595796 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.512 files changed, 746 insertions(+), 22 deletions(-)
Cargo.lock+1| ... | ... | @@ -1040,6 +1040,7 @@ dependencies = [ |
| 1040 | 1040 | "png", |
| 1041 | 1041 | "pollster", |
| 1042 | 1042 | "roxmltree", |
| 1043 | "skrifa", | |
| 1043 | 1044 | "subsetter", |
| 1044 | 1045 | "swash", |
| 1045 | 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 | 180 | - Spelling is Hunspell's American English dictionary through `spellbook`, fetched beside the |
| 181 | 181 | module; Add to Dictionary keeps its words in the browser's files. Text the bundled faces |
| 182 | 182 | 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 | 185 | - AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden |
| 185 | 186 | button, then on every visit) the trees AccessKit would get are mirrored as hidden |
| 186 | 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 | 1215 | Ok(true) |
| 1216 | 1216 | } |
| 1217 | 1217 | |
| 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 | 1228 | /// Rebuilds the stored page, restoring the title areas and read-only objects import split up. |
| 1219 | 1229 | pub fn page(&self) -> Result<Page, EditorError> { |
| 1220 | 1230 | let mut objects: Vec<PageObject> = Vec::new(); |
crates/canvas/src/interaction/mod.rs+6| ... | ... | @@ -390,6 +390,12 @@ impl PageView { |
| 390 | 390 | self.moved() |
| 391 | 391 | } |
| 392 | 392 | |
| 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 | 399 | /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was |
| 394 | 400 | /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and |
| 395 | 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 | 9 | # The renderer; `edit` needs only parley. It submits through wgpu, or without it through |
| 10 | 10 | # OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its |
| 11 | 11 | # maker picks one, as Windows 7 has no Direct3D 12. |
| 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] | |
| 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:skrifa", "dep:swash"] | |
| 13 | 13 | wgpu = ["render", "dep:wgpu", "dep:windows"] |
| 14 | 14 | # Writes layers as PDF pages, text as real text in subset fonts. |
| 15 | 15 | pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] |
| ... | ... | @@ -27,6 +27,8 @@ miniz_oxide = { version = "0.8", optional = true } |
| 27 | 27 | pdf-writer = { version = "0.15", optional = true } |
| 28 | 28 | roxmltree = { version = "0.21", optional = true } |
| 29 | 29 | subsetter = { version = "0.2.6", default-features = false, optional = true } |
| 30 | # COLRv1 glyphs, which swash can't paint. | |
| 31 | skrifa = { version = "0.44.0", optional = true } | |
| 30 | 32 | swash = { version = "0.2.10", optional = true } |
| 31 | 33 | wgpu = { workspace = true, optional = true } |
| 32 | 34 |
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 @@ |
| 1 | Copyright 2022 Google Inc. (https://github.com/googlefonts/noto-emoji) | |
| 2 | ||
| 3 | This Font Software is licensed under the SIL Open Font License, | |
| 4 | Version 1.1. | |
| 5 | ||
| 6 | This license is copied below, and is also available with a FAQ at: | |
| 7 | http://scripts.sil.org/OFL | |
| 8 | ||
| 9 | ----------------------------------------------------------- | |
| 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | |
| 11 | ----------------------------------------------------------- | |
| 12 | ||
| 13 | PREAMBLE | |
| 14 | The goals of the Open Font License (OFL) are to stimulate worldwide | |
| 15 | development of collaborative font projects, to support the font | |
| 16 | creation efforts of academic and linguistic communities, and to | |
| 17 | provide a free and open framework in which fonts may be shared and | |
| 18 | improved in partnership with others. | |
| 19 | ||
| 20 | The OFL allows the licensed fonts to be used, studied, modified and | |
| 21 | redistributed freely as long as they are not sold by themselves. The | |
| 22 | fonts, including any derivative works, can be bundled, embedded, | |
| 23 | redistributed and/or sold with any software provided that any reserved | |
| 24 | names are not used by derivative works. The fonts and derivatives, | |
| 25 | however, cannot be released under any other type of license. The | |
| 26 | requirement for fonts to remain under this license does not apply to | |
| 27 | any document created using the fonts or their derivatives. | |
| 28 | ||
| 29 | DEFINITIONS | |
| 30 | "Font Software" refers to the set of files released by the Copyright | |
| 31 | Holder(s) under this license and clearly marked as such. This may | |
| 32 | include source files, build scripts and documentation. | |
| 33 | ||
| 34 | "Reserved Font Name" refers to any names specified as such after the | |
| 35 | copyright statement(s). | |
| 36 | ||
| 37 | "Original Version" refers to the collection of Font Software | |
| 38 | components as distributed by the Copyright Holder(s). | |
| 39 | ||
| 40 | "Modified Version" refers to any derivative made by adding to, | |
| 41 | deleting, or substituting -- in part or in whole -- any of the | |
| 42 | components of the Original Version, by changing formats or by porting | |
| 43 | the Font Software to a new environment. | |
| 44 | ||
| 45 | "Author" refers to any designer, engineer, programmer, technical | |
| 46 | writer or other person who contributed to the Font Software. | |
| 47 | ||
| 48 | PERMISSION & CONDITIONS | |
| 49 | Permission is hereby granted, free of charge, to any person obtaining | |
| 50 | a copy of the Font Software, to use, study, copy, merge, embed, | |
| 51 | modify, redistribute, and sell modified and unmodified copies of the | |
| 52 | Font Software, subject to the following conditions: | |
| 53 | ||
| 54 | 1) Neither the Font Software nor any of its individual components, in | |
| 55 | Original or Modified Versions, may be sold by itself. | |
| 56 | ||
| 57 | 2) Original or Modified Versions of the Font Software may be bundled, | |
| 58 | redistributed and/or sold with any software, provided that each copy | |
| 59 | contains the above copyright notice and this license. These can be | |
| 60 | included either as stand-alone text files, human-readable headers or | |
| 61 | in the appropriate machine-readable metadata fields within text or | |
| 62 | binary files as long as those fields can be easily viewed by the user. | |
| 63 | ||
| 64 | 3) No Modified Version of the Font Software may use the Reserved Font | |
| 65 | Name(s) unless explicit written permission is granted by the | |
| 66 | corresponding Copyright Holder. This restriction only applies to the | |
| 67 | primary font name as presented to the users. | |
| 68 | ||
| 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | |
| 70 | Software shall not be used to promote, endorse or advertise any | |
| 71 | Modified Version, except to acknowledge the contribution(s) of the | |
| 72 | Copyright Holder(s) and the Author(s) or with their explicit written | |
| 73 | permission. | |
| 74 | ||
| 75 | 5) The Font Software, modified or unmodified, in part or in whole, | |
| 76 | must be distributed entirely under this license, and must not be | |
| 77 | distributed under any other license. The requirement for fonts to | |
| 78 | remain under this license does not apply to any document created using | |
| 79 | the Font Software. | |
| 80 | ||
| 81 | TERMINATION | |
| 82 | This license becomes null and void if any of the above conditions are | |
| 83 | not met. | |
| 84 | ||
| 85 | DISCLAIMER | |
| 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | |
| 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | |
| 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | |
| 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
| 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | |
| 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | |
| 94 | OTHER DEALINGS IN THE FONT SOFTWARE. |
crates/draw/src/render.rs+15-5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | mod colr; | |
| 1 | 2 | #[cfg(all(feature = "wgpu", windows))] |
| 2 | 3 | mod d3d11; |
| 3 | 4 | #[cfg(all(feature = "wgpu", windows))] |
| ... | ... | @@ -1108,10 +1109,10 @@ impl Renderer { |
| 1108 | 1109 | Source::Outline, |
| 1109 | 1110 | ]); |
| 1110 | 1111 | // Swash rasterizes outlines in Y-up coordinates. |
| 1111 | render.format(Format::Alpha).offset(Vector::new( | |
| 1112 | f32::from(phase[0]) * 0.25, | |
| 1113 | -f32::from(phase[1]) * 0.25, | |
| 1114 | )); | |
| 1112 | let offset = [f32::from(phase[0]) * 0.25, -f32::from(phase[1]) * 0.25]; | |
| 1113 | render | |
| 1114 | .format(Format::Alpha) | |
| 1115 | .offset(Vector::new(offset[0], offset[1])); | |
| 1115 | 1116 | if run.embolden { |
| 1116 | 1117 | render.embolden(size / 24.0); |
| 1117 | 1118 | } |
| ... | ... | @@ -1121,7 +1122,16 @@ impl Renderer { |
| 1121 | 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 | 1135 | if image.content == Content::Mask { |
| 1126 | 1136 | let coverage = text_coverage(tone); |
| 1127 | 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 | ||
| 5 | use 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 | }; | |
| 13 | use 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. | |
| 20 | pub(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. | |
| 103 | struct 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 | ||
| 115 | impl 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 | ||
| 151 | impl 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. | |
| 231 | fn 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 | ||
| 292 | fn 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 | ||
| 302 | fn 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. | |
| 323 | enum 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 | ||
| 345 | impl 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`. | |
| 458 | struct Line { | |
| 459 | stops: Vec<(f32, [f32; 4])>, | |
| 460 | extend: Extend, | |
| 461 | } | |
| 462 | ||
| 463 | impl 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. | |
| 491 | struct Pen { | |
| 492 | transform: Transform, | |
| 493 | commands: Vec<Command>, | |
| 494 | } | |
| 495 | ||
| 496 | impl 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 | ||
| 503 | impl 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)] | |
| 531 | mod 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 | 96 | const CHOSEN: &str = "/Chosen"; |
| 97 | 97 | /// Faces for what the bundled ones lack, beside them in `fonts/`, fetched the first time a |
| 98 | 98 | /// page holds a character in their ranges: Noto's, by script. |
| 99 | const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 6] = [ | |
| 99 | const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 7] = [ | |
| 100 | 100 | ( |
| 101 | 101 | "NotoSansArabic.ttf", |
| 102 | 102 | &[ |
| ... | ... | @@ -128,7 +128,20 @@ const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 6] = [ |
| 128 | 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. | |
| 144 | const EMOJI: &str = "Noto-COLRv1.ttf.gz"; | |
| 132 | 145 | /// How long changed files wait to be written out, so a burst of edits writes once. |
| 133 | 146 | const STORE_AFTER: Duration = Duration::from_millis(500); |
| 134 | 147 | |
| ... | ... | @@ -1373,29 +1386,40 @@ fn fetch_fallbacks(text: &str) { |
| 1373 | 1386 | } |
| 1374 | 1387 | } |
| 1375 | 1388 | |
| 1376 | /// A fallback face arrived: the page, its loaders and the interface draw what the bundled | |
| 1377 | /// faces lack with it, and the page is laid out again. | |
| 1389 | /// Fallback face `name` arrived: the page, its loaders and the interface draw what the | |
| 1390 | /// bundled faces lack with it, and the page is laid out again. | |
| 1378 | 1391 | #[wasm_bindgen] |
| 1379 | pub fn font_arrived(bytes: Vec<u8>) { | |
| 1392 | pub fn font_arrived(name: String, bytes: Vec<u8>) { | |
| 1393 | use parley::fontique::{Collection, GenericFamily}; | |
| 1380 | 1394 | let face = parley::fontique::Blob::new(Arc::new(bytes)); |
| 1381 | send(UserEvent::Then(Box::new(move |state| { | |
| 1382 | let collection = &mut state.view.engine.fonts.collection; | |
| 1383 | let families: Vec<String> = collection | |
| 1384 | .register_fonts(face.clone(), None) | |
| 1385 | .into_iter() | |
| 1386 | .filter_map(|(family, _)| collection.family_name(family).map(String::from)) | |
| 1395 | // Emoji take the emoji face before any script's fallback, which may draw them plain. | |
| 1396 | let register = move |collection: &mut Collection, face| { | |
| 1397 | let ids: Vec<_> = (collection.register_fonts(face, None).into_iter()) | |
| 1398 | .map(|(family, _)| family) | |
| 1399 | .collect(); | |
| 1400 | let families: Vec<String> = (ids.iter()) | |
| 1401 | .filter_map(|family| collection.family_name(*family).map(String::from)) | |
| 1387 | 1402 | .collect(); |
| 1388 | 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 | 1411 | if let Ok(mut layouts) = state.layouts.lock() { |
| 1390 | layouts.fonts.collection.register_fonts(face.clone(), None); | |
| 1391 | draw::fall_back_to(&mut layouts.fonts.collection, &families); | |
| 1412 | register(&mut layouts.fonts.collection, face.clone()); | |
| 1392 | 1413 | } |
| 1393 | 1414 | for family in &families { |
| 1394 | 1415 | state.ui.preview_font(face.clone(), family); |
| 1395 | 1416 | } |
| 1396 | 1417 | state.ui.fall_back_to(&families); |
| 1397 | 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 | } |
| 1401 | 1425 |
crates/snowbound/web/glue.js+8-2| ... | ... | @@ -289,8 +289,14 @@ export function pickNotebook() { |
| 289 | 289 | |
| 290 | 290 | export function fetchFont(name) { |
| 291 | 291 | fetch(`fonts/${name}`) |
| 292 | .then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status))) | |
| 293 | .then((data) => wasm.font_arrived(new Uint8Array(data))) | |
| 292 | .then((response) => { | |
| 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 | 300 | .catch((error) => console.warn("Font", name, error)); |
| 295 | 301 | } |
| 296 | 302 |
tools/release_web.py+17| ... | ... | @@ -6,12 +6,15 @@ static folder and publishes it to the share's web/ folder. |
| 6 | 6 | python3 tools/release_web.py --publish # build, then replace web/ |
| 7 | 7 | """ |
| 8 | 8 | import argparse |
| 9 | import gzip | |
| 10 | import hashlib | |
| 9 | 11 | import os |
| 10 | 12 | from pathlib import Path |
| 11 | 13 | import shutil |
| 12 | 14 | import subprocess |
| 13 | 15 | import sys |
| 14 | 16 | import tempfile |
| 17 | import urllib.request | |
| 15 | 18 | |
| 16 | 19 | ROOT = Path(__file__).resolve().parents[1] |
| 17 | 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 | 24 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') |
| 22 | 25 | # copyparty lists the folder itself, so the app is at its index.html. |
| 23 | 26 | URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' |
| 27 | EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf' | |
| 28 | EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2' | |
| 29 | # As `EMOJI` in src/web.rs names it. | |
| 30 | EMOJI_FONT = 'Noto-COLRv1.ttf.gz' | |
| 24 | 31 | # Size over speed where it costs little: the module is most of the first load. |
| 25 | 32 | PROFILE = ['--config', 'profile.release.opt-level="s"'] |
| 26 | 33 | |
| ... | ... | @@ -98,6 +105,16 @@ def fallbacks(fonts): |
| 98 | 105 | run(['uv', 'run', '--no-project', '--with', 'fonttools', 'python', ROOT / 'tools/web/subset_cjk.py', |
| 99 | 106 | collection, cjk]) |
| 100 | 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 | 118 | (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n') |
| 102 | 119 | |
| 103 | 120 |