rust-office-charts 0.1.7

Render Office Open XML charts to SVG from native Rust or WebAssembly
Documentation
use super::{flag, number, val, Coords, Extent};
use crate::chart::{chart_node, child, ChartSeries};
use crate::parse::chart_colors::ChartColors;
use roxmltree::Node;
use std::collections::BTreeMap;
use std::fmt::Write;

struct Sample {
    index: u32,
    x: f64,
    y: f64,
}

struct Bar {
    index: u32,
    start: (f64, f64),
    end: (f64, f64),
    cap_start: bool,
    cap_end: bool,
}

fn samples(node: Node, series: &ChartSeries) -> Vec<Sample> {
    let implicit_x = child(node, "xVal").is_none();
    series
        .points
        .iter()
        .filter_map(|p| {
            Some(Sample {
                index: p.index,
                x: if implicit_x {
                    f64::from(p.index) + 1.
                } else {
                    number(p.x.as_deref())?
                },
                y: number(p.y.as_deref())?,
            })
        })
        .collect()
}

fn custom_values(node: Node, side: &str) -> BTreeMap<u32, f64> {
    let Some(source) = child(node, side) else {
        return BTreeMap::new();
    };
    let cache = child(source, "numLit")
        .or_else(|| child(source, "numRef").and_then(|reference| child(reference, "numCache")));
    cache
        .into_iter()
        .flat_map(|cache| cache.children())
        .filter(|n| chart_node(*n, "pt"))
        .filter_map(|n| {
            Some((
                n.attribute("idx")?.parse().ok()?,
                number(child(n, "v")?.text())?.abs(),
            ))
        })
        .collect()
}

fn bars(node: Node, samples: &[Sample]) -> Vec<Bar> {
    let horizontal = val(Some(node), "errDir") == Some("x");
    let kind = val(Some(node), "errValType").unwrap_or("fixedVal");
    let side = val(Some(node), "errBarType").unwrap_or("both");
    let minus = matches!(side, "both" | "minus");
    let plus = matches!(side, "both" | "plus");
    let caps = !flag(Some(node), "noEndCap", false);
    let amount = number(val(Some(node), "val")).unwrap_or(1.).abs();
    let custom_minus = custom_values(node, "minus");
    let custom_plus = custom_values(node, "plus");
    let mut count = 0.;
    let mut mean = 0.;
    let mut variance = 0.;
    if matches!(kind, "stdDev" | "stdErr") {
        for p in samples {
            let value = if horizontal { p.x } else { p.y };
            count += 1.;
            let delta = value - mean;
            mean += delta / count;
            variance += delta * (value - mean);
        }
    }
    let deviation = if count > 1. {
        (variance / (count - 1.)).max(0.).sqrt()
    } else {
        0.
    };
    samples
        .iter()
        .filter_map(|p| {
            let value = if horizontal { p.x } else { p.y };
            let magnitude = match kind {
                "fixedVal" => Some(amount),
                "percentage" => Some(value.abs() * amount / 100.),
                "stdDev" => Some(deviation * amount),
                "stdErr" => Some(deviation * amount / count.max(1.).sqrt()),
                "cust" => None,
                _ => return None,
            };
            let below = minus
                .then(|| magnitude.or_else(|| custom_minus.get(&p.index).copied()))
                .flatten()
                .filter(|n| n.is_finite() && *n > 0.);
            let above = plus
                .then(|| magnitude.or_else(|| custom_plus.get(&p.index).copied()))
                .flatten()
                .filter(|n| n.is_finite() && *n > 0.);
            if below.is_none() && above.is_none() {
                return None;
            }
            let center = if kind == "stdDev" { mean } else { value };
            let low = center - below.unwrap_or(0.);
            let high = center + above.unwrap_or(0.);
            (low.is_finite() && high.is_finite()).then_some(Bar {
                index: p.index,
                start: if horizontal { (low, p.y) } else { (p.x, low) },
                end: if horizontal { (high, p.y) } else { (p.x, high) },
                cap_start: caps && below.is_some(),
                cap_end: caps && above.is_some(),
            })
        })
        .collect()
}

pub(super) fn ranges(node: Node, series: &ChartSeries) -> (Extent, Extent) {
    let mut x = Extent::default();
    let mut y = Extent::default();
    let samples = samples(node, series);
    for error in node.children().filter(|n| chart_node(*n, "errBars")) {
        for bar in bars(error, &samples) {
            x.record(bar.start.0);
            x.record(bar.end.0);
            y.record(bar.start.1);
            y.record(bar.end.1);
        }
    }
    (x, y)
}

pub(super) fn draw(
    svg: &mut String,
    node: Node,
    series: &ChartSeries,
    ordinal: usize,
    colors: &ChartColors,
    coords: &Coords,
) {
    let samples = samples(node, series);
    for error in node.children().filter(|n| chart_node(*n, "errBars")) {
        let line = child(error, "spPr").and_then(|p| {
            p.children()
                .find(|n| n.is_element() && n.tag_name().name() == "ln")
        });
        let paint = line
            .and_then(|n| colors.shape_fill(n))
            .unwrap_or_else(|| "#000000".to_owned());
        let width = line
            .and_then(|n| number(n.attribute("w")))
            .map_or(1., |w| (w / 9525.).clamp(0., 256.));
        if paint == "none" || width == 0. {
            continue;
        }
        let dash = line
            .and_then(|n| {
                n.children()
                    .find(|n| n.is_element() && n.tag_name().name() == "prstDash")
            })
            .and_then(|n| n.attribute("val"));
        let dash = match dash {
            Some("dot" | "sysDot") => format!(r#" stroke-dasharray="{width} {}""#, width * 2.),
            Some("dash" | "sysDash") => {
                format!(r#" stroke-dasharray="{} {}""#, width * 4., width * 3.)
            }
            Some("lgDash") => format!(r#" stroke-dasharray="{} {}""#, width * 8., width * 3.),
            Some("dashDot" | "sysDashDot" | "lgDashDot") => format!(
                r#" stroke-dasharray="{} {} {width} {}""#,
                width * 4.,
                width * 2.,
                width * 2.
            ),
            _ => String::new(),
        };
        for bar in bars(error, &samples) {
            let a = coords.point(bar.start.0, bar.start.1);
            let b = coords.point(bar.end.0, bar.end.1);
            let mut path = format!("M{:.2} {:.2}L{:.2} {:.2}", a.0, a.1, b.0, b.1);
            let horizontal = (b.0 - a.0).abs() > (b.1 - a.1).abs();
            let cap = 3.;
            for (point, enabled) in [(a, bar.cap_start), (b, bar.cap_end)] {
                if enabled {
                    let (dx, dy) = if horizontal { (0., cap) } else { (cap, 0.) };
                    write!(
                        path,
                        "M{:.2} {:.2}L{:.2} {:.2}",
                        point.0 - dx,
                        point.1 - dy,
                        point.0 + dx,
                        point.1 + dy
                    )
                    .unwrap();
                }
            }
            svg.push_str("<path");
            colors.hit_attrs(svg, "errorBar", ordinal, Some(bar.index));
            write!(
                svg,
                r#" d="{path}" fill="none" stroke="{paint}" stroke-width="{width:.2}"{dash}/>"#
            )
            .unwrap();
        }
    }
}