SVG cleaner
SVG files made safe and small: scripts, event handlers, outside links, foreignObject and editor clutter removed, links unwrapped, numbers rounded and whitespace squeezed, with a report of what was taken out.
Version 1.0.0 · 3 functions · 2 crates
Functions
What use svg_clean.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use svg_clean, svg_clean_report from svg_clean (Quillo 0.6).
svg_clean(svg: str, options: any) → stror an errorsvg made safe to show and smaller: only drawing elements kept, no scripts, handlers, outside links or editor metadata, numbers rounded to options.precision decimals (2), embedded PNG/JPEG/GIF/WebP images kept unless options.keep_images is false. The cleaned SVG.
svg_clean_report(svg: str, options: any) → anyor an errorWhat cleaning svg does: { safe, removed: [{ what, why }], size_before, size_after }; safe when nothing dangerous (a script, handler, outside link or foreignObject) was in it.
svg_clean_file(path: str, out_path: str, options: any) → anyor an errorAn SVG file cleaned into out_path (folders made): { path, size_before, size_after }. For uploads, before they are served.
Get it
$ quillo install svg_clean # svg_clean.qwer into this folder$ quillo install svg_clean --example # and its example, svg_clean.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/svg_clean.qwer$ curl -O https://quillolang.org/plugins/files/svg_clean.qwe$ curl -O https://quillolang.org/plugins/files/svg_clean_sample.svgThen, in your program, all of it or only what you call:
use svg_clean.qweruse svg_clean, svg_clean_report from svg_clean // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
roxmltree = "0.20"serde_json = { version = "1", features = ["preserve_order"] }Example
// A site where users upload their logo as SVG: each file cleaned before it is stored and shown, with a report of what was taken out.//// quillo run svg_clean.qwe then open http://localhost:8080/reportuse "svg_clean.qwer"route GET /report = svg_clean_report(read_file("svg_clean_sample.svg"), {})?route GET /clean.svg = respond(200, svg_clean(read_file("svg_clean_sample.svg"), { precision: 1 })?, { "content-type": "image/svg+xml" })route GET /checks { { file: svg_clean_file("svg_clean_sample.svg", "data/svg_clean/logo.svg", {})?, rounded: svg_clean("<svg><path d=\"M0.999.5L10.123456-3.5e2\"/></svg>", { precision: 2 })?, not_svg: svg_clean("<html></html>", {}).error, }}// POST an SVG: get back the cleaned one, ready to store.route POST /upload = respond(200, svg_clean(request.body, {})?, { "content-type": "image/svg+xml" })$ quillo run svg_clean.qwe$ curl 'localhost:8080/clean.svg'$ curl 'localhost:8080/report'$ curl 'localhost:8080/checks'Source
//! SVG files made safe and small: scripts, event handlers, external links, foreignObject and editor clutter (Inkscape, Illustrator, Sketch metadata, comments) removed, numbers rounded and whitespace squeezed; for logos and icons users upload, and for your own assets.//! version: 1.0.0//!//! [dependencies]//! roxmltree = "0.20"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Value};const ELEMENTS: &[&str] = &[ "svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "text", "tspan", "textPath", "title", "desc", "linearGradient", "radialGradient", "stop", "clipPath", "mask", "pattern", "marker", "image", "style", "switch", "view", "filter", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix", "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset", "fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence", "animate", "animateTransform", "animateMotion", "mpath",];const NUMERIC: &[&str] = &["d", "points", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "width", "height", "transform", "viewBox", "stroke-width", "offset", "opacity", "fill-opacity", "stroke-opacity", "fx", "fy", "font-size", "stdDeviation", "dx", "dy", "gradientTransform", "patternTransform"];const XLINK: &str = "http://www.w3.org/1999/xlink";const XML: &str = "http://www.w3.org/XML/1998/namespace";const SVG_NS: &str = "http://www.w3.org/2000/svg";struct Ctx { precision: usize, keep_images: bool, removed: Vec<Value>, uses_xlink: bool }fn esc_attr(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('"', """) }fn esc_text(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") }fn round_numbers(s: &str, p: usize) -> String { let c: Vec<char> = s.chars().collect(); let mut out = String::with_capacity(s.len()); let mut i = 0; while i < c.len() { let start = i; let is_num_start = c[i].is_ascii_digit() || (c[i] == '.' && c.get(i + 1).map(|d| d.is_ascii_digit()).unwrap_or(false)) || ((c[i] == '-' || c[i] == '+') && c.get(i + 1).map(|d| d.is_ascii_digit() || *d == '.').unwrap_or(false)); if !is_num_start { out.push(c[i]); i += 1; continue } if c[i] == '-' || c[i] == '+' { i += 1 } let mut dot = false; while i < c.len() && (c[i].is_ascii_digit() || (c[i] == '.' && !dot)) { if c[i] == '.' { dot = true } i += 1 } if i < c.len() && (c[i] == 'e' || c[i] == 'E') && c.get(i + 1).map(|d| d.is_ascii_digit() || *d == '-' || *d == '+').unwrap_or(false) { i += 1; if c[i] == '-' || c[i] == '+' { i += 1 } while i < c.len() && c[i].is_ascii_digit() { i += 1 } } let tok: String = c[start..i].iter().collect(); // Only numbers with more decimals than wanted change: arc flags ("011") and plain integers stay as written. let decimals = tok.split('.').nth(1).map(|d| d.chars().take_while(|x| x.is_ascii_digit()).count()).unwrap_or(0); if dot && decimals > p && !tok.contains(['e', 'E']) { if let Ok(v) = tok.parse::<f64>() { let mut r = format!("{:.*}", p, v); if r.contains('.') { r = r.trim_end_matches('0').trim_end_matches('.').to_string() } if r == "-0" { r = "0".into() } // Keep numbers apart: "1" then ".5" must not become "1.5". if !r.contains('.') && c.get(i) == Some(&'.') { r.push(' ') } out.push_str(&r); continue; } } out.push_str(&tok); } out}fn safe_url(v: &str) -> bool { let low: String = v.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase(); let mut rest = low.as_str(); while let Some(i) = rest.find("url(") { let after = rest[i + 4..].trim_start_matches(['\'', '"']); if !after.starts_with('#') { return false } rest = &rest[i + 4..]; } !low.contains("javascript:") && !low.contains("expression(") && !low.contains("@import") && !low.contains("-moz-binding") && !low.contains("behavior:")}fn element(n: roxmltree::Node, ctx: &mut Ctx, out: &mut String, depth: usize) { let name = n.tag_name().name(); let ns = n.tag_name().namespace().unwrap_or(SVG_NS); // A link is unwrapped: what it holds stays, the link goes. if name == "a" && n.tag_name().namespace().unwrap_or(SVG_NS) == SVG_NS && depth > 0 { ctx.removed.push(json!({ "what": "<a>", "why": "a link (its contents kept)" })); for c in n.children().filter(|c| c.is_element()) { element(c, ctx, out, depth + 1) } return; } let why = if depth > 256 { Some("nested too deeply") } else if ns != SVG_NS { Some("not an SVG element (an editor's own)") } else if name == "script" { Some("a script") } else if name == "foreignObject" { Some("foreignObject can hold HTML") } else if name == "metadata" { Some("metadata") } else if name == "set" { Some("set can change links and handlers") } else if !ELEMENTS.contains(&name) { Some("not an allowed element") } else { None }; if let Some(w) = why { ctx.removed.push(json!({ "what": format!("<{}>", name), "why": w })); return; } if name.starts_with("animate") { if let Some(a) = n.attribute("attributeName") { let al = a.to_lowercase(); if al.starts_with("on") || al.contains("href") { ctx.removed.push(json!({ "what": format!("<{} attributeName=\"{}\">", name, a), "why": "animating a link or a handler" })); return } } } let mut attrs = String::new(); for a in n.attributes() { let (an, ans) = (a.name(), a.namespace()); let full = match ans { Some(XLINK) => format!("xlink:{}", an), Some(XML) => format!("xml:{}", an), Some(_) => { ctx.removed.push(json!({ "what": format!("{} on <{}>", an, name), "why": "an editor's own attribute" })); continue } None => an.to_string() }; let v = a.value(); let lower = an.to_lowercase(); if lower.starts_with("on") { ctx.removed.push(json!({ "what": format!("{} on <{}>", an, name), "why": "an event handler" })); continue } if an == "href" { let vl = v.trim().to_lowercase(); let ok = vl.starts_with('#') || (name == "image" && ctx.keep_images && (vl.starts_with("data:image/png") || vl.starts_with("data:image/jpeg") || vl.starts_with("data:image/gif") || vl.starts_with("data:image/webp"))); if !ok { ctx.removed.push(json!({ "what": format!("{}=\"{}\" on <{}>", full, v.chars().take(60).collect::<String>(), name), "why": "a link outside the file" })); continue } if ans == Some(XLINK) { ctx.uses_xlink = true } } if (an == "style" || v.to_lowercase().contains("url(")) && !safe_url(v) { ctx.removed.push(json!({ "what": format!("{} on <{}>", an, name), "why": "a style or url() reaching outside the file" })); continue } if an.starts_with("data-") && depth > 0 { continue } let val = if NUMERIC.contains(&an) { round_numbers(v, ctx.precision) } else { v.to_string() }; let val = val.split_whitespace().collect::<Vec<_>>().join(" "); attrs.push_str(&format!(" {}=\"{}\"", full, esc_attr(&val))); } if name == "image" && !attrs.contains("href=") { return } if depth == 0 { attrs = format!(" xmlns=\"{}\"{}", SVG_NS, attrs); } let mut inner = String::new(); for c in n.children() { if c.is_element() { element(c, ctx, &mut inner, depth + 1) } else if c.is_text() { let t = c.text().unwrap_or(""); if name == "style" { if !safe_url(t) { ctx.removed.push(json!({ "what": "<style> rules", "why": "a style reaching outside the file" })); continue } inner.push_str(&esc_text(&t.split_whitespace().collect::<Vec<_>>().join(" "))); } else if ["text", "tspan", "textPath", "title", "desc"].contains(&name) { inner.push_str(&esc_text(t)); } else if !t.trim().is_empty() { inner.push_str(&esc_text(t.trim())); } } } // Groups and definitions that hold nothing go. if (name == "g" || name == "defs") && inner.is_empty() && (name == "defs" || attrs.is_empty() || !attrs.contains("id=")) { return } // A group with no attributes adds nothing: its contents take its place. if name == "g" && attrs.is_empty() { out.push_str(&inner); return } if inner.is_empty() { out.push_str(&format!("<{}{}/>", name, attrs)) } else { out.push_str(&format!("<{}{}>{}</{}>", name, attrs, inner, name)) }}fn clean(svg: &str, options: &Value) -> Result<(String, Vec<Value>), String> { let precision = options.get("precision").and_then(|v| v.as_u64()).unwrap_or(2).min(8) as usize; let keep_images = options.get("keep_images").and_then(|v| v.as_bool()).unwrap_or(true); let opt = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: 2_000_000 }; let doc = roxmltree::Document::parse_with_options(svg, opt).map_err(|e| format!("the SVG is not well-formed XML: {}", e))?; let root = doc.root_element(); if root.tag_name().name() != "svg" { return Err(format!("the document is <{}>, not <svg>", root.tag_name().name())) } let mut ctx = Ctx { precision, keep_images, removed: vec![], uses_xlink: false }; let mut out = String::new(); element(root, &mut ctx, &mut out, 0); if ctx.uses_xlink { out = out.replacen(&format!("<svg xmlns=\"{}\"", SVG_NS), &format!("<svg xmlns=\"{}\" xmlns:xlink=\"{}\"", SVG_NS, XLINK), 1) } if svg.contains("<!--") { ctx.removed.push(json!({ "what": "comments", "why": "not needed" })) } Ok((out, ctx.removed))}/// `svg` made safe to show and smaller: only drawing elements kept, no scripts, handlers, outside links or editor metadata, numbers rounded to `options.precision` decimals (2), embedded PNG/JPEG/GIF/WebP images kept unless `options.keep_images` is false. The cleaned SVG.pub fn svg_clean(svg: &str, options: serde_json::Value) -> Result<String, String> { Ok(clean(svg, &options)?.0)}/// What cleaning `svg` does: `{ safe, removed: [{ what, why }], size_before, size_after }`; `safe` when nothing dangerous (a script, handler, outside link or foreignObject) was in it.pub fn svg_clean_report(svg: &str, options: serde_json::Value) -> Result<Value, String> { let (out, removed) = clean(svg, &options)?; let dangerous = removed.iter().any(|r| { let w = r["why"].as_str().unwrap_or(""); w.contains("script") || w.contains("handler") || w.contains("outside") || w.contains("HTML") || w.contains("link") }); Ok(json!({ "safe": !dangerous, "removed": removed, "size_before": svg.len(), "size_after": out.len() }))}/// An SVG file cleaned into `out_path` (folders made): `{ path, size_before, size_after }`. For uploads, before they are served.pub fn svg_clean_file(path: &str, out_path: &str, options: serde_json::Value) -> Result<Value, String> { let meta = std::fs::metadata(path).map_err(|e| format!("{}: {}", path, e))?; if meta.len() > 20 * 1024 * 1024 { return Err(format!("{}: larger than 20 MB", path)) } let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path, e))?; let (out, _) = clean(&text, &options)?; 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())?; } std::fs::write(out_path, &out).map_err(|e| format!("{}: {}", out_path, e))?; Ok(json!({ "path": out_path, "size_before": text.len(), "size_after": out.len() }))}Related
file_type.qwer · 1.0.0What a file really is from its first bytes, not its name: MIME type, extension and kind, for upload checks.7 views · 10 downloadsUpload checks upload_guard.qwer · 1.0.0Uploads checked before they are kept: the real type by magic bytes against the name and an allow list, size, double extensions, programs, zip bombs and zip slips, SVGs and PDFs that run scripts, macros and polyglots.7 views · 7 downloadsAudio info audio_info.qwer · 1.0.0An audio file's length, bitrate, sample rate, tags and cover art, for MP3, FLAC, M4A, Ogg, WAV and AIFF.6 views · 8 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 · 8 downloadsEXIF exif.qwer · 1.0.0A photo's EXIF: camera and lens, when it was taken, where (GPS), exposure and orientation, from JPEG, HEIC, PNG and WebP.7 views · 6 downloadsEXIF strip exif_strip.qwer · 1.0.0Photos made safe to publish: GPS, camera and other EXIF, XMP, IPTC and comments taken out of JPEG, PNG and WebP, without re-encoding.6 views · 7 downloadsThread 0 posts
On SVG cleaner: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.