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
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 errorThe 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 errorSVG 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.qweOr 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.svgThen, in your program, all of it or only what you call:
use sprites.qweruse sprites_pack, sprites_svg from sprites // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }roxmltree = "0.20"serde_json = { version = "1", features = ["preserve_order"] }Example
// 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
//! 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """) }/// 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)}Related
placeholder.qwer · 1.0.0Placeholder images as SVG: a box of any size with its dimensions or your text in your colours, as markup or a data: URL.8 views · 8 downloadsQR codes qr.qwer · 1.0.0QR codes as SVG for links, tickets, Wi-Fi and two-factor set-up, in any colours, or drawn as text.10 views · 7 downloadsSVG to PNG svg_png.qwer · 1.0.0SVG drawn to PNG at the size you ask, text included with a built-in font: link previews, email images, icons and charts.7 views · 7 downloadsAnimated GIF gif.qwer · 1.0.0Animated GIFs made from a list of pictures with the delay and size you choose, and GIFs read back: frames, size and length.6 views · 7 downloadsASCII art ascii_art.qwer · 1.0.0Pictures turned into ASCII art: characters from light to dark, plain for terminals and emails, or coloured as HTML.6 views · 10 downloadsAudio waveform audio_wave.qwer · 1.0.0A WAV file's waveform for an audio player: the loudness of each slice from 0 to 1, or drawn as an SVG of bars.6 views · 7 downloadsThread 0 posts
On Sprite sheets: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.