Skip to content

Commit

Permalink
Add support for SDF icons (#77)
Browse files Browse the repository at this point in the history
Mapbox and MapLibre both support SDF icons, sprites that store a signed
distance field in their alpha channel. They're not well documented
(Mapbox calls them re-colourable images and MapLibre doesn't really
mention them) and they're pretty much abandoned as an idea (see the
conversation in mapbox/mapbox-gl-style-spec#444). But nevertheless both
style specs support them and so Spreet should be able to generate SDF
spritesheets.

Stadia Maps's sdf_glyph_renderer crate supports signed distance field
generation and so we can use that to do the heavy lifting. Although in
theory a single spritesheet can mix-and-match SDF sprites with non-SDF
sprites, in practice spritesheets tend to include one or the other but
not both. And so for now I think it's fine for support to be
all-or-nothing: a spritesheet can have all SDF icons, or no SDF icons.
If there's demand in the future, we can support mixing.
  • Loading branch information
flother authored Dec 4, 2023
1 parent deb5fcc commit 51e9b00
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 6 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
3 changes: 3 additions & 0 deletions src/bin/spreet/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion src/bin/spreet/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
106 changes: 102 additions & 4 deletions src/sprite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Self> {
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::<Vec<u8>>();
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::<Vec<_>>();
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
Expand Down Expand Up @@ -193,10 +279,12 @@ pub struct SpriteDescription {
serialize_with = "serialize_stretch_y_area"
)]
pub stretch_y: Option<Vec<Rect>>,
#[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,
Expand All @@ -206,6 +294,7 @@ impl SpriteDescription {
content: sprite.content_area(),
stretch_x: sprite.stretch_x_areas(),
stretch_y: sprite.stretch_y_areas(),
sdf,
}
}
}
Expand All @@ -216,13 +305,15 @@ impl SpriteDescription {
pub struct SpritesheetBuilder {
sprites: Option<BTreeMap<String, Sprite>>,
references: Option<MultiMap<String, String>>,
sdf: bool,
}

impl SpritesheetBuilder {
pub fn new() -> Self {
Self {
sprites: None,
references: None,
sdf: false,
}
}

Expand Down Expand Up @@ -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> {
Spritesheet::new(
self.sprites.unwrap_or_default(),
self.references.unwrap_or_default(),
self.sdf,
)
}
}
Expand All @@ -284,6 +381,7 @@ impl Spritesheet {
pub fn new(
sprites: BTreeMap<String, Sprite>,
references: MultiMap<String, String>,
sdf: bool,
) -> Option<Self> {
let mut data_items = Vec::new();
let mut min_area: usize = 0;
Expand Down Expand Up @@ -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
Expand All @@ -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),
);
}
}
Expand Down
24 changes: 24 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,30 @@ fn spreet_can_output_stretchable_icons() -> Result<(), Box<dyn std::error::Error
Ok(())
}

#[test]
fn spreet_can_output_sdf_icons() -> Result<(), Box<dyn std::error::Error>> {
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/[email protected]");
let actual_spritesheet = predicate::path::eq_file(temp.join("[email protected]"));
let expected_index = Path::new("tests/fixtures/output/[email protected]");
let actual_index = predicate::path::eq_file(temp.join("[email protected]"));

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();
Expand Down
34 changes: 34 additions & 0 deletions tests/fixtures/output/[email protected]
Original file line number Diff line number Diff line change
@@ -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
}
}
Binary file added tests/fixtures/output/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 51e9b00

Please sign in to comment.