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) 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/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 ``` 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..af84f5a 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::{clamp_to_u8, BitmapGlyph}; use serde::Serialize; use self::serialize::{serialize_rect, serialize_stretch_x_area, serialize_stretch_y_area}; @@ -47,6 +48,91 @@ 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] 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: + /// + /// > 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 than the + /// original SVG image.. + /// + /// [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()?; + // 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(|alpha| { + Color::from_rgba(0.0, 0.0, 0.0, alpha as f32 / 255.0) + .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 +279,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 +294,7 @@ impl SpriteDescription { content: sprite.content_area(), stretch_x: sprite.stretch_x_areas(), stretch_y: sprite.stretch_y_areas(), + sdf, } } } @@ -216,6 +305,7 @@ impl SpriteDescription { pub struct SpritesheetBuilder { sprites: Option>, references: Option>, + sdf: bool, } impl SpritesheetBuilder { @@ -223,6 +313,7 @@ impl SpritesheetBuilder { Self { sprites: None, references: None, + sdf: false, } } @@ -261,10 +352,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 +381,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 +438,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 +448,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), ); } } 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 0000000..8f370ed Binary files /dev/null and b/tests/fixtures/output/sdf@2x.png differ