Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SDF icons #77

Merged
merged 6 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/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();
Expand Down
34 changes: 34 additions & 0 deletions tests/fixtures/output/sdf@2x.json
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/sdf@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.