Media

Sprite sheets

Sprite sheets: small pictures packed into one PNG with a CSS class or a JSON frame for each, retina sheets too, and SVG icons gathered into one SVG of symbols to draw with <use href>.

Version 1.0.0 · 2 functions · 3 crates

#images#css#svg#games

Functions

What use sprites.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use sprites_pack, sprites_svg from sprites (Quillo 0.6).

sprites_pack(paths: list, out_path: str, options: any) → anyor an error

The pictures at paths packed into one PNG at out_path (folders made), rows filled tallest first. options: padding between pictures (2), max_width of the sheet (1024), names (one per path; the file names without their extension otherwise), prefix of the CSS classes ("sprite-"), url of the sheet in the CSS (the out path's file name), scale (2 for a sheet drawn at double size for retina screens: the CSS halves it). { path, width, height, frames: { name: { x, y, width, height } }, css }: frames as game engines and canvas code want them, css a class per picture.

sprites_svg(paths: list, prefix: str, names: list) → stror an error

SVG icon files gathered into one SVG of <symbol>s, each with its file's viewBox and an id from its file name (or names), prefixed with prefix: put it once in the page (or serve it) and draw an icon with <svg><use href="#icon-home"/></svg>. Scripts and comments are left out. The SVG text.

Get it

$ quillo install sprites               # sprites.qwer into this folder$ quillo install sprites --example     # and its example, sprites.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/sprites.qwer$ curl -O https://quillolang.org/plugins/files/sprites.qwe$ curl -O https://quillolang.org/plugins/files/sprites_sample_heart.png$ curl -O https://quillolang.org/plugins/files/sprites_sample_star.png$ curl -O https://quillolang.org/plugins/files/sprites_sample_cart.png$ curl -O https://quillolang.org/plugins/files/sprites_sample_home.svg$ curl -O https://quillolang.org/plugins/files/sprites_sample_user.svg

Then, in your program, all of it or only what you call:

main.qwe
use sprites.qweruse sprites_pack, sprites_svg from sprites      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }roxmltree = "0.20"serde_json = { version = "1", features = ["preserve_order"] }

Example

sprites.qwe
// A shop's icons: the PNG ones packed into one sheet with CSS classes (one download instead of many), and the SVG ones gathered into one SVG of symbols.////   quillo run sprites.qwe      then open http://localhost:8080/use "sprites.qwer"let PNGS = ["sprites_sample_heart.png", "sprites_sample_star.png", "sprites_sample_cart.png"]let SHEET = sprites_pack(PNGS, "data/sprites/icons.png", { names: ["heart", "star", "cart"], prefix: "icon-", url: "/sprites/icons.png" })?let SYMBOLS = sprites_svg(["sprites_sample_home.svg", "sprites_sample_user.svg"], "icon-", ["home", "user"])?route GET /sprites/icons.png = file("data/sprites/icons.png")route GET /sheet = { width: SHEET.width, height: SHEET.height, frames: SHEET.frames, retina: sprites_pack(PNGS, "data/sprites/icons@2x.png", { names: ["heart", "star", "cart"], scale: 2.0 })?.css }route GET /icons.css = respond(200, SHEET.css, { "content-type": "text/css" })route GET /symbols.svg = respond(200, SYMBOLS, { "content-type": "image/svg+xml" })route GET / = <html>    <head><link rel="stylesheet" href="/icons.css"/></head>    <body>        {raw(SYMBOLS)}        <p><span class="icon-heart"></span> <span class="icon-star"></span> <span class="icon-cart"></span></p>        <p><svg width="24" height="24"><use href="#icon-home"/></svg> <svg width="24" height="24"><use href="#icon-user"/></svg></p>    </body></html>
$ quillo run sprites.qwe$ curl 'localhost:8080/sheet'$ curl 'localhost:8080/icons.css'$ curl 'localhost:8080/symbols.svg'

Source

sprites.qwer
//! Sprite sheets: many small pictures (icons, game frames, flags) packed into one PNG with the CSS classes or the JSON frame map to show each one, retina sizes too; and SVG icons gathered into one SVG of `<symbol>`s for `<use href="#name">`.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }//! roxmltree = "0.20"//! serde_json = { version = "1", features = ["preserve_order"] }use image::{imageops, RgbaImage};use serde_json::{json, Map, Value};fn stem(path: &str) -> String {    std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()}fn class_name(s: &str) -> String {    let c: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' }).collect();    c.trim_matches('-').to_string()}fn esc(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }/// The pictures at `paths` packed into one PNG at `out_path` (folders made), rows filled tallest first./// `options`: `padding` between pictures (2), `max_width` of the sheet (1024), `names` (one per path; the file names without their extension otherwise), `prefix` of the CSS classes (`"sprite-"`), `url` of the sheet in the CSS (the out path's file name), `scale` (2 for a sheet drawn at double size for retina screens: the CSS halves it)./// `{ path, width, height, frames: { name: { x, y, width, height } }, css }`: `frames` as game engines and canvas code want them, `css` a class per picture.pub fn sprites_pack(paths: Vec<String>, out_path: &str, options: serde_json::Value) -> Result<Value, String> {    if paths.is_empty() { return Err("no pictures to pack".into()) }    if paths.len() > 10_000 { return Err("at most 10,000 pictures".into()) }    let pad = options.get("padding").and_then(|v| v.as_u64()).unwrap_or(2) as u32;    let max_w = options.get("max_width").and_then(|v| v.as_u64()).unwrap_or(1024).max(16) as u32;    let prefix = options.get("prefix").and_then(|v| v.as_str()).unwrap_or("sprite-").to_string();    let scale = options.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0).max(0.1);    let url = options.get("url").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| std::path::Path::new(out_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default());    let names: Vec<String> = match options.get("names").and_then(|v| v.as_array()) {        Some(n) if n.len() == paths.len() => n.iter().map(|x| x.as_str().unwrap_or("").to_string()).collect(),        Some(_) => return Err("`names` has one name per path".into()),        None => paths.iter().map(|p| stem(p)).collect(),    };    let mut imgs: Vec<(usize, RgbaImage)> = Vec::new();    for (i, p) in paths.iter().enumerate() {        let im = image::open(p).map_err(|e| format!("{}: {}", p, e))?.to_rgba8();        if im.width() + 2 * pad > max_w { return Err(format!("{} is wider than the sheet's {} pixels", p, max_w)) }        imgs.push((i, im));    }    // Shelf packing: tallest first, left to right, a new row when one is full.    let mut order: Vec<usize> = (0..imgs.len()).collect();    order.sort_by(|&a, &b| imgs[b].1.height().cmp(&imgs[a].1.height()).then(imgs[b].1.width().cmp(&imgs[a].1.width())).then(a.cmp(&b)));    let mut pos = vec![(0u32, 0u32); imgs.len()];    let (mut x, mut y, mut row_h, mut sheet_w) = (pad, pad, 0u32, 0u32);    for &i in &order {        let (w, h) = imgs[i].1.dimensions();        if x + w + pad > max_w { x = pad; y += row_h + pad; row_h = 0 }        pos[i] = (x, y);        x += w + pad;        row_h = row_h.max(h);        sheet_w = sheet_w.max(x);    }    let sheet_h = y + row_h + pad;    let mut sheet = RgbaImage::new(sheet_w, sheet_h);    let mut frames = Map::new();    let mut css = Vec::new();    let k = 1.0 / scale;    let r = |v: f64| { let x = (v * 100.0).round() / 100.0; if x.fract() == 0.0 { format!("{}", x as i64) } else { format!("{}", x) } };    css.push(format!("[class^=\"{p}\"], [class*=\" {p}\"] {{ display: inline-block; background-image: url(\"{u}\"); background-repeat: no-repeat; background-size: {w}px {h}px; }}", p = prefix, u = url, w = r(sheet_w as f64 * k), h = r(sheet_h as f64 * k)));    for (i, im) in &imgs {        let (px, py) = pos[*i];        imageops::overlay(&mut sheet, im, px as i64, py as i64);        let name = &names[*i];        if frames.contains_key(name) { return Err(format!("two pictures are named `{}`", name)) }        frames.insert(name.clone(), json!({ "x": px, "y": py, "width": im.width(), "height": im.height() }));        css.push(format!(".{}{} {{ width: {}px; height: {}px; background-position: -{}px -{}px; }}", prefix, class_name(name), r(im.width() as f64 * k), r(im.height() as f64 * k), r(px as f64 * k), r(py as f64 * k)));    }    if let Some(d) = std::path::Path::new(out_path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(d).map_err(|e| e.to_string())?; }    sheet.save_with_format(out_path, image::ImageFormat::Png).map_err(|e| format!("{}: {}", out_path, e))?;    Ok(json!({ "path": out_path, "width": sheet_w, "height": sheet_h, "frames": frames, "css": css.join("\n") }))}fn inner_xml(n: roxmltree::Node, src: &str) -> String {    let r = n.range();    let text = &src[r.clone()];    // Everything between the root's opening and closing tags.    match (text.find('>'), text.rfind("</")) {        (Some(a), Some(b)) if a + 1 <= b && !text[..a].ends_with('/') => text[a + 1..b].trim().to_string(),        _ => String::new(),    }}/// SVG icon files gathered into one SVG of `<symbol>`s, each with its file's `viewBox` and an `id` from its file name (or `names`), prefixed with `prefix`: put it once in the page (or serve it) and draw an icon with `<svg><use href="#icon-home"/></svg>`. Scripts and comments are left out. The SVG text.pub fn sprites_svg(paths: Vec<String>, prefix: &str, names: Vec<String>) -> Result<String, String> {    if !names.is_empty() && names.len() != paths.len() { return Err("`names` has one name per path, or none".into()) }    let mut out = String::from("<svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display: none\">");    let mut seen = std::collections::HashSet::new();    for (i, p) in paths.iter().enumerate() {        let src = std::fs::read_to_string(p).map_err(|e| format!("{}: {}", p, e))?;        let doc = roxmltree::Document::parse(&src).map_err(|e| format!("{}: not well-formed: {}", p, e))?;        let root = doc.root_element();        if root.tag_name().name() != "svg" { return Err(format!("{}: not an SVG", p)) }        let view_box = root.attribute("viewBox").map(String::from).or_else(|| {            let (w, h) = (root.attribute("width")?, root.attribute("height")?);            Some(format!("0 0 {} {}", w.trim_end_matches("px"), h.trim_end_matches("px")))        }).ok_or_else(|| format!("{}: no viewBox, nor width and height", p))?;        let id = format!("{}{}", prefix, class_name(names.get(i).map(|s| s.as_str()).unwrap_or(&stem(p))));        if !seen.insert(id.clone()) { return Err(format!("two icons are named `{}`", id)) }        if root.descendants().any(|d| d.tag_name().name() == "script" || d.tag_name().name() == "foreignObject" || d.attributes().any(|a| a.name().to_lowercase().starts_with("on"))) {            return Err(format!("{}: has scripts or event handlers; clean it first", p));        }        // Presentation attributes of the root (fill, stroke...) move to the symbol.        let keep: Vec<String> = root.attributes().filter(|a| a.namespace().is_none() && ["fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "fill-rule", "clip-rule"].contains(&a.name())).map(|a| format!(" {}=\"{}\"", a.name(), esc(a.value()))).collect();        let body = inner_xml(root, &src);        let body: String = { let mut b = body; while let (Some(s), Some(e)) = (b.find("<!--"), b.find("-->")) { if e < s { break } b.replace_range(s..e + 3, ""); } b };        out.push_str(&format!("<symbol id=\"{}\" viewBox=\"{}\"{}>{}</symbol>", esc(&id), esc(&view_box), keep.concat(), body));    }    out.push_str("</svg>");    Ok(out)}
Thread
86Useful? Like it, or add to its thread below.

Thread 0 posts

On Sprite sheets: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.