//! Charts as inline SVG for Save as Web Page: the chart crate's drawing primitives, text included. use std::fmt::Write as _; use gridcraft_chart::{HAlign, Prim, Rgba, VAlign}; use gridcraft_model::{Chart, Workbook}; use crate::io::esc; /// Widest or tallest chart written; larger anchors are clamped. const MAX_SIDE: f32 = 16_384.0; struct HelveticaMeasure; impl gridcraft_chart::Measure for HelveticaMeasure { fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { gridcraft_pdf::text_width(if bold { gridcraft_pdf::Font::HelveticaBold } else { gridcraft_pdf::Font::Helvetica }, size, text) } } /// `chart` of sheet `sheet` as an `` element placed by `style`, or `None` for a degenerate size. pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option { let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE)); if !(w >= 1.0 && h >= 1.0) { return None; } let data = gridcraft_chart::resolve(wb, sheet, chart); let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure); Some(prims_svg(&prims, w, h, style)) } fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String { let mut out = String::new(); let _ = write!( out, "" ); for p in prims { match p { Prim::Rect { x, y, w, h, fill, stroke, radius } => { let _ = write!(out, " 0.0 { let _ = write!(out, " rx=\"{}\"", num(*radius)); } paint(&mut out, *fill, *stroke); out.push_str("/>"); } Prim::Line { pts, color, width, dash } => { let _ = write!(out, ""); } Prim::Polygon { pts, fill, stroke } => { let _ = write!(out, ""); } Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { let _ = write!(out, ""); } Prim::Circle { cx, cy, r, fill, stroke } => { let _ = write!(out, ""); } Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { // Baseline offsets as in the PDF export. let dy = match valign { VAlign::Top => size * 0.78, VAlign::Middle => size * 0.35, VAlign::Bottom => -size * 0.21, }; let anchor = match align { HAlign::Left => "start", HAlign::Center => "middle", HAlign::Right => "end", }; let _ = write!(out, "{}", hex(*color), opacity("fill", *color), esc(text)); } } } out.push_str(""); out } /// Fill and stroke attributes of an open shape element; the caller closes it. fn paint(out: &mut String, fill: Option, stroke: Option<(Rgba, f32)>) { match fill { Some(c) => { let _ = write!(out, " fill=\"{}\"{}", hex(c), opacity("fill", c)); } None => out.push_str(" fill=\"none\""), } if let Some((c, w)) = stroke { let _ = write!(out, " stroke=\"{}\" stroke-width=\"{}\"{}", hex(c), num(w), opacity("stroke", c)); } } /// A pie or doughnut sector. Angles run clockwise from 12 o'clock, like the chart crate's. fn wedge_path(cx: f32, cy: f32, r_outer: f32, r_inner: f32, a0: f32, a1: f32) -> String { let span = (a1 - a0).clamp(-std::f32::consts::TAU + 0.001, std::f32::consts::TAU - 0.001); let a1 = a0 + span; let at = |a: f32, r: f32| format!("{} {}", num(cx + r * a.sin()), num(cy - r * a.cos())); let large = u8::from(span.abs() > std::f32::consts::PI); let sweep = u8::from(span >= 0.0); let mut d = format!("M{} A{r} {r} 0 {large} {sweep} {}", at(a0, r_outer), at(a1, r_outer), r = num(r_outer)); if r_inner > 0.0 { let _ = write!(d, " L{} A{r} {r} 0 {large} {} {} Z", at(a1, r_inner), 1 - sweep, at(a0, r_inner), r = num(r_inner)); } else { let _ = write!(d, " L{} {} Z", num(cx), num(cy)); } d } fn points(pts: &[[f32; 2]]) -> String { pts.iter().map(|p| format!("{},{}", num(p[0]), num(p[1]))).collect::>().join(" ") } fn hex(c: Rgba) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } fn opacity(attr: &str, c: Rgba) -> String { if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) } } /// A coordinate with two decimals; a non-finite value is 0 so the SVG stays well-formed. fn num(v: f32) -> String { if !v.is_finite() { return "0".into(); } let s = format!("{v:.2}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn every_primitive_becomes_an_svg_element() { let red = [255, 0, 0, 255]; let prims = vec![ Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some(red), stroke: None, radius: 0.0 }, Prim::Line { pts: vec![[0.0, 0.0], [5.0, 5.5]], color: red, width: 1.0, dash: true }, Prim::Polygon { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], fill: red, stroke: Some(([0, 0, 0, 255], 1.0)) }, Prim::Wedge { cx: 10.0, cy: 10.0, r_outer: 5.0, r_inner: 2.0, a0: 0.0, a1: 1.0, fill: red, stroke: None }, Prim::Circle { cx: 1.0, cy: 1.0, r: 2.0, fill: Some([0, 0, 255, 128]), stroke: None }, Prim::Text { x: 5.0, y: 6.0, text: "A", "", "A<B", ] { assert!(svg.contains(part), "{part} in {svg}"); } assert!(svg.starts_with("") && svg.contains("style=\"left:1px\""), "{svg}"); } #[test] fn non_finite_numbers_do_not_break_the_markup() { let prims = [Prim::Circle { cx: f32::NAN, cy: f32::INFINITY, r: 1.0, fill: None, stroke: None }]; assert!(prims_svg(&prims, 10.0, 10.0, "").contains("")); } #[test] fn a_full_circle_wedge_keeps_a_drawable_arc() { let d = wedge_path(0.0, 0.0, 10.0, 0.0, 0.0, std::f32::consts::TAU); assert!(d.contains("A10 10 0 1 1 ") && d.ends_with("L0 0 Z"), "{d}"); } }