From 14060be018e6eb167fbe281b2ab8103138bbf8dc Mon Sep 17 00:00:00 2001 From: Matt Riggott Date: Fri, 1 Dec 2023 22:45:47 +0000 Subject: [PATCH 1/6] Initial implementation of SDF icons Missing tests, plus the calculation of the alpha value isn't quite right yet. --- Cargo.lock | 10 ++++ Cargo.toml | 1 + src/bin/spreet/cli.rs | 3 ++ src/bin/spreet/main.rs | 9 +++- src/sprite/mod.rs | 108 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3b5197..e165b03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdf_glyph_renderer" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b05c114d181e20b509e03b05856cc5823bc6189d581c276fe37c5ebc5e3b3b9" +dependencies = [ + "thiserror", +] + [[package]] name = "semver" version = "1.0.19" @@ -990,6 +999,7 @@ dependencies = [ "png", "predicates", "resvg", + "sdf_glyph_renderer", "serde", "serde_json", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 4cca461..7788cd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ multimap = "0.9" oxipng = { version = "9.0", features = ["parallel", "zopfli", "filetime"], default-features = false } png = "0.17" resvg = "0.36" +sdf_glyph_renderer = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" diff --git a/src/bin/spreet/cli.rs b/src/bin/spreet/cli.rs index 6897aa7..f9caeb5 100644 --- a/src/bin/spreet/cli.rs +++ b/src/bin/spreet/cli.rs @@ -28,6 +28,9 @@ pub struct Cli { /// Remove whitespace from the JSON index file #[arg(short, long)] pub minify_index_file: bool, + /// Output a spritesheet using a signed distance field for each sprite + #[arg(long)] + pub sdf: bool, } /// Clap validator to ensure that a string is an existing directory. diff --git a/src/bin/spreet/main.rs b/src/bin/spreet/main.rs index 58322c0..92ada2d 100644 --- a/src/bin/spreet/main.rs +++ b/src/bin/spreet/main.rs @@ -25,7 +25,11 @@ fn main() { .iter() .map(|svg_path| { if let Ok(tree) = load_svg(svg_path) { - let sprite = Sprite::new(tree, pixel_ratio).expect("failed to load a sprite"); + let sprite = if args.sdf { + Sprite::new_sdf(tree, pixel_ratio).expect("failed to load an SDF sprite") + } else { + Sprite::new(tree, pixel_ratio).expect("failed to load a sprite") + }; if let Ok(name) = sprite_name(svg_path, args.input.as_path()) { (name, sprite) } else { @@ -49,6 +53,9 @@ fn main() { if args.unique { spritesheet_builder.make_unique(); }; + if args.sdf { + spritesheet_builder.make_sdf(); + }; // Generate sprite sheet let Some(spritesheet) = spritesheet_builder.generate() else { diff --git a/src/sprite/mod.rs b/src/sprite/mod.rs index 56bcb07..934c8b6 100644 --- a/src/sprite/mod.rs +++ b/src/sprite/mod.rs @@ -7,8 +7,9 @@ use std::path::Path; use crunch::{Item, PackedItem, PackedItems, Rotation}; use multimap::MultiMap; use oxipng::optimize_from_memory; -use resvg::tiny_skia::{Pixmap, PixmapPaint, Transform}; +use resvg::tiny_skia::{Color, Pixmap, PixmapPaint, Transform}; use resvg::usvg::{NodeExt, Rect, Tree}; +use sdf_glyph_renderer::BitmapGlyph; use serde::Serialize; use self::serialize::{serialize_rect, serialize_stretch_x_area, serialize_stretch_y_area}; @@ -47,6 +48,93 @@ impl Sprite { }) } + /// Create a sprite by rasterising an SVG, generating its signed distance field, and storing + /// that in the sprite's alpha channel. + /// + /// The method comes from Valve's original 2007 paper, [Improved alpha-tested magnification for + /// vector textures and special effects][1] and its general implementation is available in the + /// [sdf_glyph_renderer][2]. There's [further details in this blog post from demofox.org][3]. + /// + /// There are SDF value [cut-offs and ranges][4] specific to Mapbox and MapLibre icons: + /// + /// > To render images with signed distance fields, we create a glyph texture that stores the + /// > distance to the next outline in every pixel. Inside of a glyph, the distance is negative; + /// > outside, it is positive. As an additional optimization, to fit into a one-byte unsigned + /// > integer, Mapbox shifts these ranges so that values between 192 and 255 represent “inside” + /// > a glyph and values from 0 to 191 represent "outside". This gives the appearance of a range + /// > of values from black (0) to white (255). + /// + /// JavaScript code for [handling the cut-off][5] is available in Elastic's fork of Fontnik. + /// + /// Note SDF icons are buffered by 3px on each side and so are 6px wider and 6px higher. + /// + /// [1]: https://dl.acm.org/doi/10.1145/1281500.1281665 + /// [2]: https://crates.io/crates/sdf_glyph_renderer + /// [3]: https://blog.demofox.org/2014/06/30/distance-field-textures/ + /// [4]: https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/ + /// [5]: https://github.com/elastic/fontnik/blob/fcaecc174d7561d9147499ba4f254dc7e1b0feea/lib/sdf.js#L225-L230 + pub fn new_sdf(tree: Tree, pixel_ratio: u8) -> Option { + let svg_tree = resvg::Tree::from_usvg(&tree); + let pixel_ratio_f32 = pixel_ratio.into(); + let unbuff_pixmap_size = svg_tree.size.to_int_size().scale_by(pixel_ratio_f32)?; + let mut unbuff_pixmap = + Pixmap::new(unbuff_pixmap_size.width(), unbuff_pixmap_size.height())?; + let render_ts = Transform::from_scale(pixel_ratio_f32, pixel_ratio_f32); + svg_tree.render(render_ts, &mut unbuff_pixmap.as_mut()); + + // Buffer from https://github.com/elastic/spritezero/blob/3b89dc0fef2acbf9db1e77a753a68b02f74939a8/index.js#L144 + let buffer = 3_i32; + let mut buff_pixmap = Pixmap::new( + unbuff_pixmap_size.width() + 2 * buffer as u32, + unbuff_pixmap_size.height() + 2 * buffer as u32, + )?; + buff_pixmap.draw_pixmap( + buffer, + buffer, + unbuff_pixmap.as_ref(), + &PixmapPaint::default(), + Transform::default(), + None, + ); + let alpha = buff_pixmap + .pixels() + .iter() + .map(|pixel| pixel.alpha()) + .collect::>(); + let bitmap = BitmapGlyph::new( + alpha, + unbuff_pixmap_size.width() as usize, + unbuff_pixmap_size.height() as usize, + buffer as usize, + ) + .ok()?; + let colors = bitmap + // Radius from https://github.com/elastic/fontnik/blob/fcaecc174d7561d9147499ba4f254dc7e1b0feea/lib/sdf.js#L186. + .render_sdf(8) + .into_iter() + .map(|sdf| { + // Cut-off from https://github.com/elastic/spritezero/blob/3b89dc0fef2acbf9db1e77a753a68b02f74939a8/index.js#L145 + let cutoff = 0.25; + let shifted_sdf = sdf + cutoff; + // The `/ 2.0 + 0.5` bit below is to convert from a -1 to 1 range to a 0 to 1 range. + let alpha = (1.0 - shifted_sdf).clamp(-1.0, 1.0) / 2.0 + 0.5; + Color::from_rgba(0.0, 0.0, 0.0, alpha as f32) + .unwrap() + .premultiply() + .to_color_u8() + }) + .collect::>(); + for (i, pixel) in buff_pixmap.pixels_mut().iter_mut().enumerate() { + *pixel = colors[i]; + } + + Some(Self { + tree, + pixel_ratio, + pixmap: buff_pixmap, + }) + } + /// Get the sprite's SVG tree. pub fn tree(&self) -> &Tree { &self.tree @@ -193,10 +281,12 @@ pub struct SpriteDescription { serialize_with = "serialize_stretch_y_area" )] pub stretch_y: Option>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub sdf: bool, } impl SpriteDescription { - pub(crate) fn new(rect: &crunch::Rect, sprite: &Sprite) -> Self { + pub(crate) fn new(rect: &crunch::Rect, sprite: &Sprite, sdf: bool) -> Self { Self { height: rect.h as u32, width: rect.w as u32, @@ -206,6 +296,7 @@ impl SpriteDescription { content: sprite.content_area(), stretch_x: sprite.stretch_x_areas(), stretch_y: sprite.stretch_y_areas(), + sdf, } } } @@ -216,6 +307,7 @@ impl SpriteDescription { pub struct SpritesheetBuilder { sprites: Option>, references: Option>, + sdf: bool, } impl SpritesheetBuilder { @@ -223,6 +315,7 @@ impl SpritesheetBuilder { Self { sprites: None, references: None, + sdf: false, } } @@ -261,10 +354,16 @@ impl SpritesheetBuilder { self } + pub fn make_sdf(&mut self) -> &mut Self { + self.sdf = true; + self + } + pub fn generate(self) -> Option { Spritesheet::new( self.sprites.unwrap_or_default(), self.references.unwrap_or_default(), + self.sdf, ) } } @@ -284,6 +383,7 @@ impl Spritesheet { pub fn new( sprites: BTreeMap, references: MultiMap, + sdf: bool, ) -> Option { let mut data_items = Vec::new(); let mut min_area: usize = 0; @@ -340,7 +440,7 @@ impl Spritesheet { ); index.insert( data.name.to_string(), - SpriteDescription::new(&rect, &data.sprite), + SpriteDescription::new(&rect, &data.sprite, sdf), ); // If multiple names are used for a unique sprite, insert an entry in the index // for each of the other names. This is to allow for multiple names to reference @@ -350,7 +450,7 @@ impl Spritesheet { for other_sprite_name in other_sprite_names { index.insert( other_sprite_name.to_string(), - SpriteDescription::new(&rect, &data.sprite), + SpriteDescription::new(&rect, &data.sprite, sdf), ); } } From fb91dc31e5b204748a15e8459b76a5af7fbd8453 Mon Sep 17 00:00:00 2001 From: Matt Riggott Date: Sun, 3 Dec 2023 22:04:10 +0000 Subject: [PATCH 2/6] Use clamp_to_u8 to calculate alpha values From the sdf_glyph_renderer crate. It actually does the maths properly, unlike my original implementation. --- src/sprite/mod.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/sprite/mod.rs b/src/sprite/mod.rs index 934c8b6..feebec6 100644 --- a/src/sprite/mod.rs +++ b/src/sprite/mod.rs @@ -9,7 +9,7 @@ use multimap::MultiMap; use oxipng::optimize_from_memory; use resvg::tiny_skia::{Color, Pixmap, PixmapPaint, Transform}; use resvg::usvg::{NodeExt, Rect, Tree}; -use sdf_glyph_renderer::BitmapGlyph; +use sdf_glyph_renderer::{clamp_to_u8, BitmapGlyph}; use serde::Serialize; use self::serialize::{serialize_rect, serialize_stretch_x_area, serialize_stretch_y_area}; @@ -108,17 +108,13 @@ impl Sprite { buffer as usize, ) .ok()?; - let colors = bitmap - // Radius from https://github.com/elastic/fontnik/blob/fcaecc174d7561d9147499ba4f254dc7e1b0feea/lib/sdf.js#L186. - .render_sdf(8) + // Radius and cutoff are recommended to be 8 and 0.25 respectively. Taken from + // https://github.com/stadiamaps/sdf_font_tools/blob/97c5634b8e3515ac7761d0a4f67d12e7f688b042/pbf_font_tools/src/ft_generate.rs#L32-L34 + let colors = clamp_to_u8(&bitmap.render_sdf(8), 0.25) + .ok()? .into_iter() - .map(|sdf| { - // Cut-off from https://github.com/elastic/spritezero/blob/3b89dc0fef2acbf9db1e77a753a68b02f74939a8/index.js#L145 - let cutoff = 0.25; - let shifted_sdf = sdf + cutoff; - // The `/ 2.0 + 0.5` bit below is to convert from a -1 to 1 range to a 0 to 1 range. - let alpha = (1.0 - shifted_sdf).clamp(-1.0, 1.0) / 2.0 + 0.5; - Color::from_rgba(0.0, 0.0, 0.0, alpha as f32) + .map(|alpha| { + Color::from_rgba(0.0, 0.0, 0.0, alpha as f32 / 255.0) .unwrap() .premultiply() .to_color_u8() From 0a4fbe0d6421d238a979ef78b826a797f5bf0cf7 Mon Sep 17 00:00:00 2001 From: Matt Riggott Date: Sun, 3 Dec 2023 22:04:27 +0000 Subject: [PATCH 3/6] Tidy up doc comment --- src/sprite/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sprite/mod.rs b/src/sprite/mod.rs index feebec6..af84f5a 100644 --- a/src/sprite/mod.rs +++ b/src/sprite/mod.rs @@ -53,7 +53,8 @@ impl Sprite { /// /// The method comes from Valve's original 2007 paper, [Improved alpha-tested magnification for /// vector textures and special effects][1] and its general implementation is available in the - /// [sdf_glyph_renderer][2]. There's [further details in this blog post from demofox.org][3]. + /// [sdf_glyph_renderer][2] crate. There are [further details in this blog post from + /// demofox.org][3]. /// /// There are SDF value [cut-offs and ranges][4] specific to Mapbox and MapLibre icons: /// @@ -66,7 +67,8 @@ impl Sprite { /// /// JavaScript code for [handling the cut-off][5] is available in Elastic's fork of Fontnik. /// - /// Note SDF icons are buffered by 3px on each side and so are 6px wider and 6px higher. + /// Note SDF icons are buffered by 3px on each side and so are 6px wider and 6px higher than the + /// original SVG image.. /// /// [1]: https://dl.acm.org/doi/10.1145/1281500.1281665 /// [2]: https://crates.io/crates/sdf_glyph_renderer From e1a591513c4adfcf45c4c46b5bf35dcc58345858 Mon Sep 17 00:00:00 2001 From: Matt Riggott Date: Sun, 3 Dec 2023 22:09:32 +0000 Subject: [PATCH 4/6] Add CLI test for SDF icons --- tests/cli.rs | 24 +++++++++++++++++++++ tests/fixtures/output/sdf@2x.json | 34 ++++++++++++++++++++++++++++++ tests/fixtures/output/sdf@2x.png | Bin 0 -> 1863 bytes 3 files changed, 58 insertions(+) create mode 100644 tests/fixtures/output/sdf@2x.json create mode 100644 tests/fixtures/output/sdf@2x.png diff --git a/tests/cli.rs b/tests/cli.rs index 5db0bd3..77eba1b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -199,6 +199,30 @@ fn spreet_can_output_stretchable_icons() -> Result<(), Box Result<(), Box> { + let temp = assert_fs::TempDir::new().unwrap(); + + let mut cmd = Command::cargo_bin("spreet")?; + cmd.arg("tests/fixtures/svgs") + .arg(temp.join("sdf@2x")) + .arg("--sdf") + .arg("--retina") + .arg("--recursive") + .assert() + .success(); + + let expected_spritesheet = Path::new("tests/fixtures/output/sdf@2x.png"); + let actual_spritesheet = predicate::path::eq_file(temp.join("sdf@2x.png")); + let expected_index = Path::new("tests/fixtures/output/sdf@2x.json"); + let actual_index = predicate::path::eq_file(temp.join("sdf@2x.json")); + + assert!(actual_spritesheet.eval(expected_spritesheet)); + assert!(actual_index.eval(expected_index)); + + Ok(()) +} + #[test] fn spreet_rejects_non_existent_input_directory() { let mut cmd = Command::cargo_bin("spreet").unwrap(); diff --git a/tests/fixtures/output/sdf@2x.json b/tests/fixtures/output/sdf@2x.json new file mode 100644 index 0000000..56b6df4 --- /dev/null +++ b/tests/fixtures/output/sdf@2x.json @@ -0,0 +1,34 @@ +{ + "another_bicycle": { + "height": 36, + "pixelRatio": 2, + "width": 36, + "x": 84, + "y": 0, + "sdf": true + }, + "bicycle": { + "height": 36, + "pixelRatio": 2, + "width": 36, + "x": 84, + "y": 36, + "sdf": true + }, + "circle": { + "height": 46, + "pixelRatio": 2, + "width": 46, + "x": 0, + "y": 0, + "sdf": true + }, + "recursive/bear": { + "height": 38, + "pixelRatio": 2, + "width": 38, + "x": 46, + "y": 0, + "sdf": true + } +} \ No newline at end of file diff --git a/tests/fixtures/output/sdf@2x.png b/tests/fixtures/output/sdf@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8f370ed6dd5cce870bb272483466b87a21264e78 GIT binary patch literal 1863 zcmV-N2e|l&P)6Q$ zFq+V>3RRW6iYliys?lUDK+bx78IO$245qXNmRMnxHP+c+lP23!Ws?mWtg*@pODvFW zpIG4wU)f6Bx5ZaJv%;s$wV$V6`$gBzseQs=g}TEYha63jL-yIBY1jsx{V3t?vY)+z!Ars)1D&doY?2@v18C#oe|@n;`g;U5pp9by7q(? ze)VaLoYxjvVmUE*%Pg_@QQWVBJ?6|_+ToTy_dF1pJkY1hrLd1VU{5+ro3`Wr;Dv|* zKlv6TZ>MBO*M8$C10tUJfg=~&WW&X6t_W9VCD;qPJPGraSH`?E=2gEu(W66~GmfRR z^lP?+@JPgn2_uFK_)d%Cscv#in_C79b#=s}khgL*`sKI2zgUbbvohFcB8G;Ul`%uZ zzT-kV+eM}wYk8X^({2<$6$Kg+~d-t%asj|;Yvolq<|NvX*3xpVyjqV z`D3^}hbQou{BW;JT>xhIl5=$-~Ed7;X1 zb>puh&BST}rXkysfH?c$O!dj^!vPHr+fjEcL92 zzqs(cERh8^v7jY9z0-!^l4ph*_#-7Z&15Dh{VFz3W&)mQyrpA4$H?u{gZaw6JxMVk zl?BRxW!N|J;Tt7HX5?{l~AfuN)P7P4b$AiXA-5n zk{c{&NnsH^8TEzwl?lekHfBfihWFV;q3g(uTEG>b9&x3B)ksd9mIXd2K6mZ!C4D04 zx^4eIX-4k4V;C}936q;$hCE3#n>cW<67Y>rFPcfra4idT`Nhb7dFIm6(<@^cs@F2X z7yz*eKWF(Sn+FVKVK6@lhE|5{* z>O(^UH{+SaG;caP(*O(=In>WBT?MX|UEz_uiGNT);C^^uSBx?`3tb9SPa0(J?+|Ga zEa~)T30NK+M9jSA1^j?QnQ2pjit9L1-^+a4^wIq?^M?d*Fb|d&&kH!tq6Du%)wciI z-mk3j$?iYV?G7OQ z+Vi+^<@tP#<#f=}X6|7kDCPDifggCCL(p3GOueKRPdsDOQgh0*WWH*Uns)O0eOgjX z3qLJstsR@yxWc-t$+|7$<0Y&o9+p_#dty=4KmWvI(N^0^UX>Gl)G507s%sh0CWRp> zwM?guMg2-G&Brb^{y|gAc*|`^e*Cf7-V#OW*LOjEsoB!vA+tCr=-1+5Q{(@{F! zAicmZ>Yx7x04!)*#y-O==?_EB4_4nLSV-29NF77k(B{5RrL8g7>q*Koj=J!U2 zn5Nt}H`&CL0!?#d19{I(k4a43pUtAi#%~0b}>o!Y`M-AJoH6Ar< zGuC+24SD7okGdg;s`02z3svJ$n-;3Zqc$y6jYn--s2Y#jv`{r3wP`cgc+?Ykh8mCm z0&roCNBx0I*Lc+3q)XR$)Hi6&P~%b0X{mCJM_m(-(ls7+Pc2K=c+`FIP`bvW?#sgp zH6Hce0H{>sQRgk5N;Mw!-o&d^<5BNz+W%Ym_BV#=4rJQV9dQ5v002ovPDHLkV1jL* BqW%B? literal 0 HcmV?d00001 From 03068b6a8a3dc46bf7b0a4583d1cfea6b7db511e Mon Sep 17 00:00:00 2001 From: Matt Riggott Date: Sun, 3 Dec 2023 22:10:42 +0000 Subject: [PATCH 5/6] Add --sdf option to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cfc6912..8757dd4 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Options: --unique Store only unique images in the spritesheet, and map them to multiple names --recursive Include images in sub-directories -m, --minify-index-file Remove whitespace from the JSON index file + --sdf Output a spritesheet using a signed distance field for each sprite -h, --help Print help -V, --version Print version ``` From ca4e7fa3a4a8380001a6844d055903b8ab796304 Mon Sep 17 00:00:00 2001 From: Matt Riggott Date: Sun, 3 Dec 2023 22:23:41 +0000 Subject: [PATCH 6/6] Describe SDF support in the changelog file --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf041f2..b35dbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## Development version -_No changes yet._ +- Add support for SDF icons (aka [re-colourable images](https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/)). See [#58](https://github.com/flother/spreet/issues/58) +- **Breaking change**: due to the addition of SDF icons, both the `SpriteDescription` and `SpritesheetBuilder` structs have a new boolean field named `sdf`, while `SpriteDescription::new()` also takes a new `sdf` argument. Set these to `false` if you want to match the existing behaviour (i.e. no SDF icons). To create a spritesheet of SDF icons, call `SpritesheetBuilder::make_sdf()`. +- Add a new constructor, `Sprite::new_sdf()`. This rasterises an SVG to a bitmap as usual, but generates a signed distance field for the image and stores that data in the bitmap's alpha channel ## v0.10.0 (2023-11-29)