use super::backend::{
DrawBackend, FillPattern, 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>,
patterns: Vec<(FillPattern, String)>,
id_prefix: String,
}
static NEXT_ID_PREFIX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
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,
patterns: Vec::new(),
id_prefix: format!(
"gg{:x}",
NEXT_ID_PREFIX.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
),
}
}
fn pattern_url(&mut self, pattern: &FillPattern) -> String {
if let Some((_, id)) = self.patterns.iter().find(|(p, _)| p == pattern) {
return format!("url(#{id})");
}
let id = format!("{}-pat-{}", self.id_prefix, self.patterns.len());
self.patterns.push((*pattern, id.clone()));
format!("url(#{id})")
}
fn rect_markup(
&mut self,
(x0, y0): (f64, f64),
(x1, y1): (f64, f64),
fill: &str,
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 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 polygon_markup(
&mut self,
pts: &[(f64, f64)],
fill: &str,
style: &RectStyle,
) -> Result<(), RenderError> {
let pts = finite_pts(pts);
if pts.len() < 3 {
return Ok(());
}
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 polygon_rings_markup(
&mut self,
rings: &[Vec<(f64, f64)>],
fill: &str,
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 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 pattern_defs(&self) -> String {
if self.patterns.is_empty() {
return String::new();
}
let mut defs = String::from("<defs>");
for (p, id) in &self.patterns {
let FillPattern::Stripe {
fg,
bg,
size_px,
angle_deg,
} = p;
let size = if *size_px > 0.0 { *size_px } else { 1.0 };
defs.push_str(&format!(
"<pattern id=\"{id}\" width=\"{size}\" height=\"{size}\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate({angle_deg})\"><rect width=\"{size}\" height=\"{size}\" fill=\"{}\"/><rect width=\"{:.3}\" height=\"{size}\" fill=\"{}\"/></pattern>",
rgb(*bg),
size * 0.3,
rgb(*fg),
));
}
defs.push_str("</defs>");
defs
}
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(crate) fn wrap_body_in_group(&mut self, attrs: &[(String, String)]) {
if attrs.is_empty() {
return;
}
let mut open = String::from("<g");
for (k, v) in attrs {
open.push_str(&format!(" {k}=\"{}\"", escape(v)));
}
open.push('>');
self.body = format!("{open}{}</g>", 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\" ");
let defs = self.pattern_defs();
format!("{open}{defs}{}</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)));
let defs = self.pattern_defs();
format!("{open}{defs}{}</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 compact_rings_d(rings: &[Vec<(f64, f64)>], precision: u8) -> String {
let mut d = String::new();
for ring in rings {
let pts = finite_pts(ring);
if pts.len() < 3 {
continue;
}
d.push('M');
push_num(&mut d, &fmt_compact(pts[0].0, precision));
push_num(&mut d, &fmt_compact(pts[0].1, precision));
if pts.len() > 1 {
d.push('l');
let mut prev = pts[0];
for p in &pts[1..] {
push_num(&mut d, &fmt_compact(p.0 - prev.0, precision));
push_num(&mut d, &fmt_compact(p.1 - prev.1, precision));
prev = *p;
}
}
d.push('z');
}
d
}
fn push_num(d: &mut String, s: &str) {
let needs_sep =
matches!(d.chars().last(), Some(c) if c.is_ascii_digit()) && !s.starts_with('-');
if needs_sep {
d.push(',');
}
d.push_str(s);
}
fn fmt_compact(v: f64, precision: u8) -> String {
if !v.is_finite() {
return "0".to_string();
}
let s = format!("{v:.*}", precision as usize);
let trimmed = if s.contains('.') {
s.trim_end_matches('0').trim_end_matches('.')
} else {
s.as_str()
};
match trimmed {
"" | "-" | "-0" => "0".to_string(),
other => other.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,
top_left: (f64, f64),
bottom_right: (f64, f64),
style: &RectStyle,
) -> Result<(), RenderError> {
let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
self.rect_markup(top_left, bottom_right, &fill, style)
}
fn draw_rect_pattern(
&mut self,
top_left: (f64, f64),
bottom_right: (f64, f64),
pattern: &FillPattern,
style: &RectStyle,
) -> Result<(), RenderError> {
let fill = self.pattern_url(pattern);
self.rect_markup(top_left, bottom_right, &fill, style)
}
fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
self.polygon_markup(pts, &fill, style)
}
fn draw_polygon_pattern(
&mut self,
pts: &[(f64, f64)],
pattern: &FillPattern,
style: &RectStyle,
) -> Result<(), RenderError> {
let fill = self.pattern_url(pattern);
self.polygon_markup(pts, &fill, style)
}
fn draw_polygon_rings(
&mut self,
rings: &[Vec<(f64, f64)>],
style: &RectStyle,
) -> Result<(), RenderError> {
let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
self.polygon_rings_markup(rings, &fill, style)
}
fn draw_polygon_rings_pattern(
&mut self,
rings: &[Vec<(f64, f64)>],
pattern: &FillPattern,
style: &RectStyle,
) -> Result<(), RenderError> {
let fill = self.pattern_url(pattern);
self.polygon_rings_markup(rings, &fill, style)
}
fn draw_polygon_rings_precise(
&mut self,
rings: &[Vec<(f64, f64)>],
pattern: Option<&FillPattern>,
precision: Option<u8>,
style: &RectStyle,
) -> Result<(), RenderError> {
let Some(precision) = precision else {
return match pattern {
Some(p) => self.draw_polygon_rings_pattern(rings, p, style),
None => self.draw_polygon_rings(rings, style),
};
};
let d = compact_rings_d(rings, precision);
if d.is_empty() {
return Ok(());
}
let fill = match pattern {
Some(p) => self.pattern_url(p),
None => 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\""));
}
fn stripe() -> FillPattern {
FillPattern::Stripe {
fg: (10, 20, 30),
bg: (200, 210, 220),
size_px: 4.0,
angle_deg: 45.0,
}
}
fn pattern_id(svg: &str) -> String {
let i = svg.find("<pattern id=\"").unwrap() + "<pattern id=\"".len();
svg[i..i + svg[i..].find('"').unwrap()].to_string()
}
#[test]
fn a_pattern_fill_registers_one_defs_pattern_and_references_it() {
let mut b = backend();
b.draw_rect_pattern((0.0, 0.0), (10.0, 10.0), &stripe(), &RectStyle::default())
.unwrap();
let svg = b.finish();
assert_eq!(svg.matches("<pattern ").count(), 1, "{svg}");
let id = pattern_id(&svg);
assert!(
svg.contains(&format!("fill=\"url(#{id})\"")),
"rect references the registered pattern id {id}: {svg}"
);
}
#[test]
fn a_rounded_rect_pattern_still_rounds_its_corners() {
let mut b = backend();
b.draw_rect_pattern(
(0.0, 0.0),
(10.0, 10.0),
&stripe(),
&RectStyle {
corner_radius: 3.0,
..Default::default()
},
)
.unwrap();
let svg = b.finish();
assert!(svg.contains("rx=\"3.00\""), "{svg}");
assert!(svg.contains("fill=\"url(#"), "{svg}");
}
#[test]
fn an_equal_pattern_reuses_the_same_defs_entry() {
let mut b = backend();
b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
.unwrap();
b.draw_polygon_pattern(
&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)],
&stripe(),
&RectStyle::default(),
)
.unwrap();
let svg = b.finish();
assert_eq!(
svg.matches("<pattern ").count(),
1,
"two equal patterns share one <defs> entry: {svg}"
);
}
#[test]
fn different_patterns_get_their_own_defs_entries() {
let mut b = backend();
b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
.unwrap();
let other = FillPattern::Stripe {
fg: (1, 1, 1),
bg: (2, 2, 2),
size_px: 8.0,
angle_deg: 0.0,
};
b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &other, &RectStyle::default())
.unwrap();
let svg = b.finish();
assert_eq!(svg.matches("<pattern ").count(), 2, "{svg}");
}
#[test]
fn no_pattern_used_means_no_defs_block_at_all() {
let mut b = backend();
b.draw_rect((0.0, 0.0), (5.0, 5.0), &RectStyle::default())
.unwrap();
let svg = b.finish();
assert!(!svg.contains("<defs>"), "{svg}");
}
#[test]
fn pattern_fill_cuts_out_holes_like_a_flat_fill_does() {
let mut b = backend();
let outer = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
let hole = vec![(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)];
b.draw_polygon_rings_pattern(&[outer, hole], &stripe(), &RectStyle::default())
.unwrap();
let svg = b.finish();
assert!(svg.contains("fill-rule=\"evenodd\""), "{svg}");
assert_eq!(
svg.matches("<path").count(),
1,
"one path, both rings: {svg}"
);
}
#[test]
fn two_backends_on_one_page_never_collide_on_a_pattern_id() {
let mut a = backend();
a.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
.unwrap();
let mut b = backend();
b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
.unwrap();
let (sa, sb) = (a.finish(), b.finish());
assert_ne!(pattern_id(&sa), pattern_id(&sb), "{sa}\n{sb}");
}
fn decode_compact_d(d: &str) -> Vec<Vec<(f64, f64)>> {
let b = d.as_bytes();
let mut i = 0;
let read_num = |b: &[u8], i: &mut usize| -> f64 {
let start = *i;
if *i < b.len() && b[*i] == b'-' {
*i += 1;
}
while *i < b.len() && b[*i].is_ascii_digit() {
*i += 1;
}
if *i < b.len() && b[*i] == b'.' {
*i += 1;
while *i < b.len() && b[*i].is_ascii_digit() {
*i += 1;
}
}
std::str::from_utf8(&b[start..*i]).unwrap().parse().unwrap()
};
let is_num_start = |c: u8| c.is_ascii_digit() || c == b'-' || c == b'.';
let mut rings = Vec::new();
let mut current = Vec::new();
let mut cursor = (0.0, 0.0);
while i < b.len() {
match b[i] {
b'M' => {
if !current.is_empty() {
rings.push(std::mem::take(&mut current));
}
i += 1;
let x = read_num(b, &mut i);
if i < b.len() && b[i] == b',' {
i += 1;
}
let y = read_num(b, &mut i);
cursor = (x, y);
current.push(cursor);
}
b'l' => {
i += 1;
while i < b.len() && is_num_start(b[i]) {
let dx = read_num(b, &mut i);
if i < b.len() && b[i] == b',' {
i += 1;
}
let dy = read_num(b, &mut i);
if i < b.len() && b[i] == b',' {
i += 1;
}
cursor = (cursor.0 + dx, cursor.1 + dy);
current.push(cursor);
}
}
b'z' => {
i += 1;
if !current.is_empty() {
rings.push(std::mem::take(&mut current));
}
}
_ => i += 1,
}
}
if !current.is_empty() {
rings.push(current);
}
rings
}
fn noisy_ring(n: usize, cx: f64, cy: f64, r: f64) -> Vec<(f64, f64)> {
(0..n)
.map(|i| {
let t = i as f64 / n as f64 * std::f64::consts::TAU;
let wobble = 1.0 + 0.03 * ((i * 7) as f64).sin();
(cx + r * wobble * t.cos(), cy + r * wobble * t.sin())
})
.collect()
}
#[test]
fn compact_encoding_is_well_under_the_absolute_encodings_size() {
let ring = noisy_ring(2000, 400.0, 300.0, 280.0);
let mut b = backend();
b.draw_polygon_rings(std::slice::from_ref(&ring), &RectStyle::default())
.unwrap();
let absolute_svg = b.finish();
let absolute_d = absolute_svg
.split("d=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("absolute path d");
let compact_d = compact_rings_d(std::slice::from_ref(&ring), 1);
assert!(
compact_d.len() * 3 < absolute_d.len() * 2,
"compact {} bytes should be well under 2/3 of absolute {} bytes",
compact_d.len(),
absolute_d.len()
);
}
#[test]
fn compact_encoding_round_trips_within_precision() {
let ring = noisy_ring(40, 100.0, 80.0, 60.0);
let precision: u8 = 1;
let d = compact_rings_d(std::slice::from_ref(&ring), precision);
let decoded = decode_compact_d(&d);
assert_eq!(decoded.len(), 1, "{d}");
assert_eq!(decoded[0].len(), ring.len(), "{d}");
let step_tol = 0.5 * 10f64.powi(-(precision as i32));
for (k, (orig, got)) in ring.iter().zip(decoded[0].iter()).enumerate() {
let tol = step_tol * (k as f64 + 1.0) + 1e-9;
assert!(
(orig.0 - got.0).abs() <= tol && (orig.1 - got.1).abs() <= tol,
"point {k}: {orig:?} vs {got:?} (tol {tol}): {d}"
);
}
}
#[test]
fn compact_encoding_round_trips_a_hole_and_a_multipolygons_parts() {
let part1 = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)];
let part2_outer = vec![(50.0, 0.0), (90.0, 0.0), (90.0, 40.0), (50.0, 40.0)];
let part2_hole = vec![(60.0, 10.0), (80.0, 10.0), (80.0, 30.0), (60.0, 30.0)];
let rings = vec![part1.clone(), part2_outer.clone(), part2_hole.clone()];
let d = compact_rings_d(&rings, 1);
let decoded = decode_compact_d(&d);
assert_eq!(decoded.len(), 3, "{d}");
for (orig, got) in [part1, part2_outer, part2_hole].iter().zip(decoded.iter()) {
assert_eq!(orig.len(), got.len(), "{d}");
for (o, g) in orig.iter().zip(got.iter()) {
assert!((o.0 - g.0).abs() <= 0.1 && (o.1 - g.1).abs() <= 0.1, "{d}");
}
}
assert!(d.contains("M0,0l20,0,0,20-20,0z"), "{d}");
}
}