use std::borrow::Cow;
use std::fmt::Write;
pub fn escape_xml(input: &str) -> Cow<'_, str> {
if !input.contains(['&', '<', '>', '"', '\'']) {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len() + 16);
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
Cow::Owned(out)
}
#[derive(Debug, Default)]
pub struct SvgWriter {
buf: String,
}
impl SvgWriter {
pub fn new() -> Self {
Self {
buf: String::with_capacity(4096),
}
}
pub fn start_svg(
&mut self,
width: f64,
height: f64,
min_x: f64,
min_y: f64,
view_w: f64,
view_h: f64,
) {
let _ = write!(
self.buf,
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{min_x:.3} {min_y:.3} {view_w:.3} {view_h:.3}" width="{width:.1}mm" height="{height:.1}mm">"#
);
self.buf.push('\n');
}
pub fn end_svg(&mut self) {
self.buf.push_str("</svg>\n");
}
pub fn start_group(&mut self, id: &str, opacity: Option<f64>) {
let id = escape_xml(id);
let _ = write!(self.buf, r#" <g id="{id}""#);
if let Some(op) = opacity {
let _ = write!(self.buf, r#" opacity="{op:.2}""#);
}
self.buf.push_str(">\n");
}
pub fn end_group(&mut self) {
self.buf.push_str(" </g>\n");
}
pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str) {
let fill = escape_xml(fill);
let _ = writeln!(
self.buf,
r#" <rect x="{x:.3}" y="{y:.3}" width="{w:.3}" height="{h:.3}" fill="{fill}" />"#
);
}
pub fn rounded_rect(
&mut self,
bounds: [f64; 4],
rx: f64,
fill: &str,
stroke: Option<(&str, f64)>,
) {
let fill = escape_xml(fill);
let [x, y, w, h] = bounds;
let _ = write!(
self.buf,
r#" <rect x="{x:.3}" y="{y:.3}" width="{w:.3}" height="{h:.3}" rx="{rx:.3}" fill="{fill}""#
);
if let Some((s_color, s_width)) = stroke {
let s_color = escape_xml(s_color);
let _ = write!(
self.buf,
r#" stroke="{s_color}" stroke-width="{s_width:.3}""#
);
}
self.buf.push_str(" />\n");
}
pub fn circle(&mut self, cx: f64, cy: f64, r: f64, fill: &str, stroke: Option<(&str, f64)>) {
let fill = escape_xml(fill);
let _ = write!(
self.buf,
r#" <circle cx="{cx:.3}" cy="{cy:.3}" r="{r:.3}" fill="{fill}""#
);
if let Some((s_color, s_width)) = stroke {
let s_color = escape_xml(s_color);
let _ = write!(
self.buf,
r#" stroke="{s_color}" stroke-width="{s_width:.3}""#
);
}
self.buf.push_str(" />\n");
}
pub fn path(&mut self, d: &str, fill: &str, stroke: Option<(&str, f64)>) {
let d = escape_xml(d);
let fill = escape_xml(fill);
let _ = write!(self.buf, r#" <path d="{d}" fill="{fill}""#);
if let Some((s_color, s_width)) = stroke {
let s_color = escape_xml(s_color);
let _ = write!(
self.buf,
r#" stroke="{s_color}" stroke-width="{s_width:.3}" stroke-linecap="round" stroke-linejoin="round""#
);
}
self.buf.push_str(" />\n");
}
pub fn text(&mut self, x: f64, y: f64, text: &str, font_size: f64, fill: &str, anchor: &str) {
let text = escape_xml(text);
let fill = escape_xml(fill);
let anchor = escape_xml(anchor);
let _ = write!(
self.buf,
r#" <text x="{x:.3}" y="{y:.3}" font-family="sans-serif, monospace" font-size="{font_size:.3}" fill="{fill}" text-anchor="{anchor}">{text}</text>"#
);
self.buf.push('\n');
}
pub fn finish(self) -> String {
self.buf
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_and_attributes_are_escaped() {
let mut w = SvgWriter::new();
w.start_group("g\"><script>", None);
w.text(
0.0,
0.0,
"<img src=x onerror='a&b'>",
1.0,
"red\" onload=\"x",
"middle",
);
w.path("M0 0", "none", Some(("#000\"", 1.0)));
let out = w.finish();
assert!(!out.contains("<script>"));
assert!(!out.contains("<img"));
assert!(out.contains("<img src=x onerror='a&b'>"));
assert!(out.contains(r#"fill="red" onload="x""#));
assert!(out.contains(r#"id="g"><script>""#));
assert!(out.contains(r##"stroke="#000"""##));
}
#[test]
fn plain_strings_are_borrowed() {
assert!(matches!(escape_xml("R1"), Cow::Borrowed("R1")));
}
}