use super::backend::{
DrawBackend, FontFace, LineEnd, LineStyle, PointShape, PointStyle, RectStyle, TextAnchor,
TextStyle,
};
use super::{Rect, RenderError};
pub struct SvgBackend {
plot_area: Rect,
total_area: Rect,
body: String,
tooltip: Option<String>,
axis_key: Option<String>,
series_key: Option<String>,
value_key: Option<String>,
mark_part: Option<String>,
mark_attrs: Vec<(String, String)>,
mark_geom: Option<String>,
root_attrs: Vec<(String, String)>,
warnings: Vec<String>,
plot_attr: bool,
formatted_values: bool,
mark_fvalue: Option<String>,
mark_fx: Option<String>,
}
impl SvgBackend {
pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
SvgBackend {
plot_area,
total_area: Rect {
x: 0.0,
y: 0.0,
width: width as f64,
height: height as f64,
},
body: String::new(),
tooltip: None,
axis_key: None,
series_key: None,
value_key: None,
mark_part: None,
mark_attrs: Vec::new(),
mark_geom: None,
root_attrs: Vec::new(),
warnings: Vec::new(),
plot_attr: true,
formatted_values: false,
mark_fvalue: None,
mark_fx: None,
}
}
pub(crate) fn set_formatted_values(&mut self, on: bool) {
self.formatted_values = on;
}
pub fn set_root_attrs(&mut self, attrs: Vec<(String, String)>) {
self.root_attrs = attrs;
}
pub fn extend_root_attrs(&mut self, attrs: Vec<(String, String)>) {
self.root_attrs.extend(attrs);
}
pub(crate) fn without_plot_attr(&mut self) {
self.plot_attr = false;
}
pub(crate) fn push_raw(&mut self, markup: &str) {
self.body.push_str(markup);
}
pub(crate) fn body(&self) -> &str {
&self.body
}
pub fn take_warnings(&mut self) -> Vec<String> {
std::mem::take(&mut self.warnings)
}
fn mark_class(&self, kind: &str) -> String {
match &self.mark_geom {
Some(g) => format!(" class=\"gg-mark gg-{kind}\" data-geom=\"{}\"", escape(g)),
None => String::new(),
}
}
fn push_mark(&mut self, tag: &str, kind: &str, attrs: &str) {
let mut pairs: Vec<(String, String)> = Vec::new();
for (name, val) in [
("data-x", &self.axis_key),
("data-series", &self.series_key),
("data-value", &self.value_key),
] {
if let Some(v) = val {
pairs.push((name.to_string(), v.clone()));
}
}
if let Some(fx) = &self.mark_fx {
if self.axis_key.as_deref() != Some(fx.as_str()) {
pairs.push(("data-fx".to_string(), fx.clone()));
}
}
if let Some(fvalue) = &self.mark_fvalue {
pairs.push(("data-fvalue".to_string(), fvalue.clone()));
}
for (name, val) in &self.mark_attrs {
if !is_valid_mark_attr_name(name) {
continue;
}
match pairs.iter_mut().find(|(n, _)| n == name) {
Some(existing) => existing.1 = val.clone(),
None => pairs.push((name.clone(), val.clone())),
}
}
if let Some(part) = &self.mark_part {
match pairs.iter_mut().find(|(n, _)| n == "data-part") {
Some(existing) => existing.1 = part.clone(),
None => pairs.push(("data-part".to_string(), part.clone())),
}
}
let mut data = String::new();
for (name, val) in &pairs {
data.push_str(&format!(" {name}=\"{}\"", escape(val)));
}
if self.mark_part.is_some() {
data.push_str(" aria-hidden=\"true\"");
}
let effective_kind: &str = self.mark_part.as_deref().unwrap_or(kind);
data.push_str(&self.mark_class(effective_kind));
let mark = if self.mark_part.is_some() {
format!("<{tag} {attrs}{data}/>")
} else {
match &self.tooltip {
Some(t) => format!("<{tag} {attrs}{data}><title>{}</title></{tag}>", escape(t)),
None => format!("<{tag} {attrs}{data}/>"),
}
};
self.body.push_str(&mark);
}
fn root_open(&self, prefix: &str) -> String {
let p = &self.plot_area;
let (w, h) = (self.total_area.width as i64, self.total_area.height as i64);
let mut extra = String::new();
for (k, v) in &self.root_attrs {
extra.push_str(&format!(" {k}=\"{}\"", escape(v)));
}
let plot = if self.plot_attr {
format!(
" data-plot=\"{} {} {} {}\"",
num(p.x),
num(p.y),
num(p.width),
num(p.height),
)
} else {
String::new()
};
format!("<svg {prefix}width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"{plot}{extra}>")
}
pub fn finish(self) -> String {
let open = self.root_open("xmlns=\"http://www.w3.org/2000/svg\" ");
format!("{open}{}</svg>", self.body)
}
pub fn finish_fragment(self, x: f64, y: f64) -> String {
let open = self.root_open(&format!("x=\"{}\" y=\"{}\" ", num(x), num(y)));
format!("{open}{}</svg>", self.body)
}
fn draw_line_run(&mut self, pts: &[(f64, f64)], style: &LineStyle) {
let dash = match style
.linetype
.pattern()
.iter()
.flat_map(|(d, g)| [*d, *g])
.map(|v| format!("{v}"))
.collect::<Vec<_>>()
.join(",")
{
s if s.is_empty() => String::new(),
s => format!(" stroke-dasharray=\"{s}\""),
};
let linecap = match style.lineend {
LineEnd::Butt => "",
LineEnd::Round => " stroke-linecap=\"round\"",
LineEnd::Square => " stroke-linecap=\"square\"",
};
let attrs = format!(
"points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"{}{}{}",
points(pts),
rgb(style.color),
finite_or(style.width, 1.0),
opacity_attr("stroke", style.alpha),
dash,
linecap
);
self.push_mark("polyline", "line", &attrs);
}
}
fn num(v: f64) -> String {
if v.is_finite() {
format!("{v:.2}")
} else {
"0".to_string()
}
}
fn finite_or(v: f64, fallback: f64) -> f64 {
if v.is_finite() {
v
} else {
fallback
}
}
fn opacity_attr(prefix: &str, alpha: f64) -> String {
let a = finite_or(alpha, 1.0);
if format!("{a:.3}") == "1.000" {
String::new()
} else {
format!(" {prefix}-opacity=\"{a:.3}\"")
}
}
pub fn root_data_attrs(built: &crate::build::BuiltPlot) -> Vec<(String, String)> {
use crate::aes::Aesthetic;
let mut out = Vec::new();
if !built.panel_scales.is_empty() {
return out;
}
let axis = |aes: &Aesthetic| -> (Option<(f64, f64)>, Option<String>) {
match built.scales.get(aes) {
Some(s) if s.is_discrete() => {
let labels: Vec<String> = s
.breaks()
.into_iter()
.map(|(_, l)| json_string(&l))
.collect();
(None, Some(format!("[{}]", labels.join(","))))
}
Some(s) => (s.expanded_domain(), None),
None => (None, None),
}
};
let (xd, xl) = axis(&Aesthetic::X);
let (yd, yl) = axis(&Aesthetic::Y);
if let (Some((x0, x1)), Some((y0, y1))) = (xd, yd) {
out.push((
"data-domain".into(),
format!("{} {} {} {}", g(x0), g(x1), g(y0), g(y1)),
));
}
if let Some((a, b)) = xd {
out.push(("data-xdomain".into(), format!("{} {}", g(a), g(b))));
}
if let Some((a, b)) = yd {
out.push(("data-ydomain".into(), format!("{} {}", g(a), g(b))));
}
if let Some(l) = xl {
out.push(("data-xlevels".into(), l));
}
if let Some(l) = yl {
out.push(("data-ylevels".into(), l));
}
if built.coord.is_flipped() {
out.push(("data-flip".into(), "true".into()));
}
out
}
fn g(v: f64) -> String {
format!("{v}")
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn is_allowed_root_attr_name(name: &str) -> bool {
matches!(name, "role" | "id" | "class" | "lang")
|| name
.strip_prefix("data-")
.is_some_and(crate::aes::is_valid_data_attr_name)
|| name
.strip_prefix("aria-")
.is_some_and(crate::aes::is_valid_data_attr_name)
}
pub(crate) fn validate_root_attrs(
requested: &[(String, String)],
existing: &[(String, String)],
label: &str,
warnings: &mut Vec<String>,
) -> Vec<(String, String)> {
let built_in: Vec<&str> = existing.iter().map(|(k, _)| k.as_str()).collect();
let mut used: Vec<&str> = built_in.clone();
let mut out = Vec::new();
for (name, value) in requested {
if !is_allowed_root_attr_name(name) {
warnings.push(format!(
"{label}: svg_attr(\"{name}\") is not on the allow-list (data-*, aria-*, role, id, class, lang) — ignored"
));
continue;
}
if used.contains(&name.as_str()) {
if built_in.contains(&name.as_str()) {
warnings.push(format!(
"{label}: svg_attr(\"{name}\") collides with an attribute ggplot-rs already writes to the root — ignored"
));
} else {
warnings.push(format!(
"{label}: svg_attr(\"{name}\") was already set by an earlier svg_attr call — ignored"
));
}
continue;
}
used.push(name.as_str());
out.push((name.clone(), value.clone()));
}
out
}
pub(crate) fn warnings_json(warnings: &[String]) -> String {
let items: Vec<String> = warnings.iter().map(|w| json_string(w)).collect();
format!("[{}]", items.join(","))
}
fn is_valid_mark_attr_name(name: &str) -> bool {
match name.strip_prefix("data-") {
Some(suffix) => crate::aes::is_valid_data_attr_name(suffix),
None => false,
}
}
fn rgb((r, g, b): (u8, u8, u8)) -> String {
format!("#{r:02X}{g:02X}{b:02X}")
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'\t' | '\n' | '\r' => out.push(c),
c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
c => out.push(c),
}
}
out
}
fn points(pts: &[(f64, f64)]) -> String {
pts.iter()
.map(|(x, y)| format!("{x:.2},{y:.2}"))
.collect::<Vec<_>>()
.join(" ")
}
fn finite_pts(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
pts.iter()
.copied()
.filter(|(x, y)| x.is_finite() && y.is_finite())
.collect()
}
fn finite_runs(pts: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
let mut runs = Vec::new();
let mut cur: Vec<(f64, f64)> = Vec::new();
for &(x, y) in pts {
if x.is_finite() && y.is_finite() {
cur.push((x, y));
} else if !cur.is_empty() {
if cur.len() >= 2 {
runs.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
}
}
if cur.len() >= 2 {
runs.push(cur);
}
runs
}
impl DrawBackend for SvgBackend {
fn plot_area(&self) -> Rect {
self.plot_area.clone()
}
fn total_area(&self) -> Rect {
self.total_area.clone()
}
fn set_tooltip(&mut self, tooltip: Option<String>) {
self.tooltip = tooltip;
}
fn set_mark_axis(&mut self, key: Option<String>) {
self.axis_key = key;
}
fn set_mark_series(&mut self, series: Option<String>) {
self.series_key = series;
}
fn set_mark_value(&mut self, value: Option<String>) {
self.value_key = value;
}
fn set_mark_geom(&mut self, geom: Option<&str>) {
self.mark_geom = geom.map(str::to_string);
}
fn warn(&mut self, message: String) {
self.warnings.push(message);
}
fn set_mark_part(&mut self, part: Option<&str>) {
self.mark_part = part.map(|s| s.to_string());
}
fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
self.mark_attrs = attrs;
}
fn wants_formatted_values(&self) -> bool {
self.formatted_values
}
fn set_mark_fvalue(&mut self, value: Option<String>) {
self.mark_fvalue = value;
}
fn set_mark_fx(&mut self, value: Option<String>) {
self.mark_fx = value;
}
fn draw_circle(
&mut self,
(cx, cy): (f64, f64),
radius: f64,
style: &PointStyle,
) -> Result<(), RenderError> {
if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
return Ok(());
}
let attrs = format!(
"cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{:.2}\" fill=\"{}\"{}",
radius.max(0.0),
rgb(style.color),
opacity_attr("fill", style.alpha)
);
self.push_mark("circle", "point", &attrs);
Ok(())
}
fn draw_shape(
&mut self,
(cx, cy): (f64, f64),
radius: f64,
style: &PointStyle,
) -> Result<(), RenderError> {
if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
return Ok(());
}
let r = radius.max(0.0);
let color = rgb(style.color);
let fill_opacity = opacity_attr("fill", style.alpha);
let stroke_opacity = opacity_attr("stroke", style.alpha);
let poly = |pts: &[(f64, f64)]| {
if style.filled {
format!("points=\"{}\" fill=\"{color}\"{fill_opacity}", points(pts))
} else {
format!(
"points=\"{}\" fill=\"none\" stroke=\"{color}\"{stroke_opacity}",
points(pts)
)
}
};
match style.shape {
PointShape::Circle => return self.draw_circle((cx, cy), radius, style),
PointShape::Square => {
let a = poly(&[
(cx - r, cy - r),
(cx + r, cy - r),
(cx + r, cy + r),
(cx - r, cy + r),
]);
self.push_mark("polygon", "point", &a);
}
PointShape::Triangle => {
let a = poly(&[(cx, cy - r), (cx + r, cy + r), (cx - r, cy + r)]);
self.push_mark("polygon", "point", &a);
}
PointShape::Diamond => {
let a = poly(&[(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]);
self.push_mark("polygon", "point", &a);
}
PointShape::Plus | PointShape::Cross => {
let d = if style.shape == PointShape::Plus {
format!(
"M{:.2} {cy:.2}H{:.2}M{cx:.2} {:.2}V{:.2}",
cx - r,
cx + r,
cy - r,
cy + r
)
} else {
format!(
"M{:.2} {:.2}L{:.2} {:.2}M{:.2} {:.2}L{:.2} {:.2}",
cx - r,
cy - r,
cx + r,
cy + r,
cx - r,
cy + r,
cx + r,
cy - r
)
};
let a = format!(
"d=\"{d}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{:.2}\"{stroke_opacity}",
(r / 2.5).clamp(1.0, 3.0)
);
self.push_mark("path", "point", &a);
}
}
Ok(())
}
fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
for run in finite_runs(pts) {
self.draw_line_run(&run, style);
}
Ok(())
}
fn draw_quadratic(
&mut self,
p0: (f64, f64),
ctrl: (f64, f64),
p1: (f64, f64),
style: &LineStyle,
) -> Result<(), RenderError> {
if !(p0.0.is_finite()
&& p0.1.is_finite()
&& ctrl.0.is_finite()
&& ctrl.1.is_finite()
&& p1.0.is_finite()
&& p1.1.is_finite())
{
return Ok(());
}
let dash = match style
.linetype
.pattern()
.iter()
.flat_map(|(d, g)| [*d, *g])
.map(|v| format!("{v}"))
.collect::<Vec<_>>()
.join(",")
{
s if s.is_empty() => String::new(),
s => format!(" stroke-dasharray=\"{s}\""),
};
let linecap = match style.lineend {
LineEnd::Butt => "",
LineEnd::Round => " stroke-linecap=\"round\"",
LineEnd::Square => " stroke-linecap=\"square\"",
};
let d = format!(
"M{:.2} {:.2} Q{:.2} {:.2} {:.2} {:.2}",
p0.0, p0.1, ctrl.0, ctrl.1, p1.0, p1.1
);
let attrs = format!(
"d=\"{d}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"{}{}{}",
rgb(style.color),
finite_or(style.width, 1.0),
opacity_attr("stroke", style.alpha),
dash,
linecap
);
self.push_mark("path", "line", &attrs);
Ok(())
}
fn draw_rect(
&mut self,
(x0, y0): (f64, f64),
(x1, y1): (f64, f64),
style: &RectStyle,
) -> Result<(), RenderError> {
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
return Ok(());
}
let (x, y) = (x0.min(x1), y0.min(y1));
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
let r = style.corner_radius.max(0.0).min(w / 2.0).min(h / 2.0);
let radius = if r > 0.0 {
format!(" rx=\"{r:.2}\" ry=\"{r:.2}\"")
} else {
String::new()
};
let attrs = format!(
"x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\"{radius} fill=\"{fill}\"{} \
stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
opacity_attr("fill", style.alpha),
finite_or(style.stroke_width, 0.0)
);
self.push_mark("rect", "bar", &attrs);
Ok(())
}
fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
let pts = finite_pts(pts);
if pts.len() < 3 {
return Ok(());
}
let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
let attrs = format!(
"points=\"{}\" fill=\"{fill}\"{} stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
points(&pts),
opacity_attr("fill", style.alpha),
finite_or(style.stroke_width, 0.0)
);
self.push_mark("polygon", "band", &attrs);
Ok(())
}
fn draw_polygon_rings(
&mut self,
rings: &[Vec<(f64, f64)>],
style: &RectStyle,
) -> Result<(), RenderError> {
let mut d = String::new();
for ring in rings {
let pts = finite_pts(ring);
if pts.len() < 3 {
continue;
}
d.push('M');
d.push_str(&format!("{},{}", num(pts[0].0), num(pts[0].1)));
for p in &pts[1..] {
d.push('L');
d.push_str(&format!("{},{}", num(p.0), num(p.1)));
}
d.push('Z');
}
if d.is_empty() {
return Ok(());
}
let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
let attrs = format!(
"d=\"{d}\" fill-rule=\"evenodd\" fill=\"{fill}\"{} stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
opacity_attr("fill", style.alpha),
finite_or(style.stroke_width, 0.0)
);
self.push_mark("path", "band", &attrs);
Ok(())
}
fn draw_text(
&mut self,
text: &str,
pos: (f64, f64),
style: &TextStyle,
) -> Result<(), RenderError> {
self.draw_text_with_title(text, pos, style, None)
}
fn draw_text_with_title(
&mut self,
text: &str,
(x, y): (f64, f64),
style: &TextStyle,
title: Option<&str>,
) -> Result<(), RenderError> {
if !(x.is_finite() && y.is_finite()) {
return Ok(());
}
let anchor = match style.anchor {
TextAnchor::Start => "start",
TextAnchor::Middle => "middle",
TextAnchor::End => "end",
};
let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
let weight = if style.face == FontFace::Bold {
" font-weight=\"bold\""
} else {
""
};
let fstyle = if style.face == FontFace::Italic {
" font-style=\"italic\""
} else {
""
};
let angle = finite_or(style.angle, 0.0);
let transform = if angle.abs() > 0.01 {
format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
} else {
String::new()
};
let class = self.mark_class("text");
let title_tag = match title {
Some(t) => format!("<title>{}</title>", escape(t)),
None => String::new(),
};
self.body.push_str(&format!(
"<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}{class}>{title_tag}{}</text>",
finite_or(style.size, 12.0),
rgb(style.color),
escape(text)
));
Ok(())
}
fn draw_text_runs(
&mut self,
runs: &[(&str, Option<&str>)],
(x, y): (f64, f64),
style: &TextStyle,
) -> Result<(), RenderError> {
if !(x.is_finite() && y.is_finite()) {
return Ok(());
}
let anchor = match style.anchor {
TextAnchor::Start => "start",
TextAnchor::Middle => "middle",
TextAnchor::End => "end",
};
let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
let weight = if style.face == FontFace::Bold {
" font-weight=\"bold\""
} else {
""
};
let fstyle = if style.face == FontFace::Italic {
" font-style=\"italic\""
} else {
""
};
let angle = finite_or(style.angle, 0.0);
let transform = if angle.abs() > 0.01 {
format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
} else {
String::new()
};
let class = self.mark_class("text");
let mut inner = String::new();
for (text, href) in runs {
match href {
Some(u) => inner.push_str(&format!(
"<a href=\"{}\" target=\"_blank\" rel=\"noopener\"><tspan text-decoration=\"underline\">{}</tspan></a>",
escape(u),
escape(text)
)),
None => inner.push_str(&format!("<tspan>{}</tspan>", escape(text))),
}
}
self.body.push_str(&format!(
"<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}{class}>{inner}</text>",
finite_or(style.size, 12.0),
rgb(style.color),
));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Rect;
fn backend() -> SvgBackend {
SvgBackend::new(
100,
100,
Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
},
)
}
#[test]
fn draw_quadratic_emits_a_path_with_m_and_q() {
let mut b = backend();
b.draw_quadratic((0.0, 0.0), (10.0, 20.0), (20.0, 0.0), &LineStyle::default())
.unwrap();
let svg = b.finish();
assert!(svg.contains("<path"));
assert!(svg.contains("d=\"M0.00 0.00 Q10.00 20.00 20.00 0.00\""));
assert!(!svg.contains("NaN"));
assert!(!svg.contains("inf"));
}
#[test]
fn draw_quadratic_drops_non_finite_input() {
let mut b = backend();
b.draw_quadratic(
(f64::NAN, 0.0),
(10.0, 20.0),
(20.0, 0.0),
&LineStyle::default(),
)
.unwrap();
b.draw_quadratic(
(0.0, 0.0),
(f64::INFINITY, 20.0),
(20.0, 0.0),
&LineStyle::default(),
)
.unwrap();
let svg = b.finish();
assert!(!svg.contains("<path"));
}
#[test]
fn draw_line_omits_linecap_by_default_but_writes_it_when_set() {
let mut b = backend();
b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &LineStyle::default())
.unwrap();
let svg = b.finish();
assert!(!svg.contains("stroke-linecap"));
let mut b2 = backend();
let style = LineStyle {
lineend: LineEnd::Round,
..Default::default()
};
b2.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
let svg2 = b2.finish();
assert!(svg2.contains("stroke-linecap=\"round\""));
}
#[test]
fn square_linecap_is_written_on_both_draw_line_and_draw_quadratic() {
let style = LineStyle {
lineend: LineEnd::Square,
..Default::default()
};
let mut b = backend();
b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
assert!(b.finish().contains("stroke-linecap=\"square\""));
let mut b2 = backend();
b2.draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &style)
.unwrap();
assert!(b2.finish().contains("stroke-linecap=\"square\""));
}
#[test]
fn set_mark_attrs_defence_in_depth_drops_invalid_names() {
let mut b = backend();
b.set_mark_attrs(vec![
("not-data-prefixed".to_string(), "x".to_string()),
("data-".to_string(), "x".to_string()), ("data-Upper".to_string(), "x".to_string()), ("data-ok".to_string(), "valid".to_string()), ]);
b.draw_circle((1.0, 1.0), 1.0, &PointStyle::default())
.unwrap();
let svg = b.finish();
assert!(!svg.contains("not-data-prefixed"));
assert!(!svg.contains("data-Upper"));
assert!(!svg.contains("data-=\""));
assert!(svg.contains("data-ok=\"valid\""));
}
}