Files
gridcraft/crates/engine/src/chart_svg.rs
T
Mirza Bilal feac1679a1 HTML export: place pictures and charts at their anchors
Save as Web Page wrote only the cell table, so pictures and charts on a sheet
were dropped. A sheet that has either now keeps its own geometry and draws them:

- The table gets the sheet's column widths and row heights (`table-layout:fixed`,
  a `<colgroup>`, a height per row); hidden columns and rows take no room and
  merged-cell spans count only visible lines.
- A picture is an `<img>` with a `data:` URI, a chart is inline SVG built from
  the chart crate's drawing primitives (titles, axes, legends and data labels
  included), each absolutely positioned at its anchor cell plus offset.
- Sheets without pictures or charts write byte-identical HTML.

Adds contributors/people.toml entry for bilalmughal.
2026-10-21 01:35:58 +05:00

202 lines
8.3 KiB
Rust

//! 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 `<svg>` element placed by `style`, or `None` for a degenerate size.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
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,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
);
for p in prims {
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
if *radius > 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, "<polyline points=\"{}\"", points(pts));
paint(&mut out, None, Some((*color, *width)));
if *dash {
out.push_str(" stroke-dasharray=\"3 2\"");
}
out.push_str("/>");
}
Prim::Polygon { pts, fill, stroke } => {
let _ = write!(out, "<polygon points=\"{}\"", points(pts));
paint(&mut out, Some(*fill), *stroke);
out.push_str("/>");
}
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
let _ = write!(out, "<path d=\"{}\"", wedge_path(*cx, *cy, *r_outer, *r_inner, *a0, *a1));
paint(&mut out, Some(*fill), *stroke);
out.push_str("/>");
}
Prim::Circle { cx, cy, r, fill, stroke } => {
let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\"", num(*cx), num(*cy), num(*r));
paint(&mut out, *fill, *stroke);
out.push_str("/>");
}
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, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));
if *bold {
out.push_str(" font-weight=\"bold\"");
}
if rotation.is_finite() && *rotation != 0.0 {
let _ = write!(out, " transform=\"rotate({} {} {})\"", num(rotation.to_degrees()), num(*x), num(*y));
}
let _ = write!(out, " fill=\"{}\"{}>{}</text>", hex(*color), opacity("fill", *color), esc(text));
}
}
}
out.push_str("</svg>");
out
}
/// Fill and stroke attributes of an open shape element; the caller closes it.
fn paint(out: &mut String, fill: Option<Rgba>, 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::<Vec<_>>().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<B".into(),
size: 10.0,
color: red,
bold: true,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -1.5,
},
];
let svg = prims_svg(&prims, 100.0, 50.0, "left:1px");
for part in [
"<rect x=\"1\" y=\"2\" width=\"3\" height=\"4\" fill=\"#ff0000\"/>",
"<polyline points=\"0,0 5,5.5\" fill=\"none\" stroke=\"#ff0000\" stroke-width=\"1\" stroke-dasharray=\"3 2\"/>",
"<polygon ",
"<path d=\"M10 5 A5 5 0 0 1 ",
"fill-opacity=\"0.50\"",
"text-anchor=\"middle\"",
"font-weight=\"bold\"",
"transform=\"rotate(-85.94 5 6)\"",
">A&lt;B</text>",
] {
assert!(svg.contains(part), "{part} in {svg}");
}
assert!(svg.starts_with("<svg ") && svg.ends_with("</svg>") && 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("<circle cx=\"0\" cy=\"0\" r=\"1\" fill=\"none\"/>"));
}
#[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}");
}
}