rust-office-charts 0.1.7

Render Office Open XML charts to SVG from native Rust or WebAssembly
Documentation
use super::*;

fn show(table: Node, name: &str) -> bool {
    child(table, name).is_some_and(|n| {
        n.attribute("val")
            .is_none_or(|v| !matches!(v, "0" | "false" | "off"))
    })
}

pub(super) fn reserved_height(plot: Node, data: &ChartData, size: f64) -> f64 {
    let Some(table) = child(plot, "dTable") else {
        return 0.;
    };
    let size = font_size(text_props(Some(table), false), size);
    size * (3.22 + data.series.len() as f64 * 1.558)
}

fn displayed_value(raw: &str, code: &str) -> String {
    let Some(n) = number(Some(raw)) else {
        return raw.to_owned();
    };
    if code.eq_ignore_ascii_case("General") || code.is_empty() {
        if n == 0. {
            return "0".into();
        }
        let exponent = n.abs().log10().floor() as i32;
        if !(-9..=10).contains(&exponent) {
            let scientific = format!("{n:.8e}");
            if let Some((mantissa, power)) = scientific.split_once('e') {
                let sign = if power.starts_with('-') { "" } else { "+" };
                return format!(
                    "{}E{sign}{power}",
                    mantissa.trim_end_matches('0').trim_end_matches('.')
                );
            }
            return scientific;
        }
        let decimals = (9 - exponent).max(0) as usize;
        let formatted = format!("{n:.decimals$}");
        return if decimals > 0 {
            formatted.trim_end_matches('0').trim_end_matches('.').into()
        } else {
            formatted
        };
    }
    let section = code.split(';').next().unwrap_or(code);
    if section
        .chars()
        .all(|c| matches!(c, '0' | '#' | '.' | ',' | '%' | '$' | ' '))
    {
        let decimals = section
            .split_once('.')
            .map(|(_, d)| {
                d.chars()
                    .take_while(|c| matches!(c, '0' | '#'))
                    .count()
                    .min(15)
            })
            .unwrap_or(0);
        let percent = section.contains('%');
        let value = if percent { 100. * n } else { n };
        let mut formatted = format!("{value:.decimals$}");
        if section.contains(',') {
            let integer = formatted.find('.').unwrap_or(formatted.len());
            let sign = usize::from(formatted.starts_with('-'));
            let mut i = integer;
            while i > sign + 3 {
                i -= 3;
                formatted.insert(i, ',');
            }
        }
        if section.contains('$') {
            formatted.insert(0, '$');
        }
        if percent {
            formatted.push('%');
        }
        return formatted;
    }
    raw.into()
}

pub(super) fn draw(
    out: &mut String,
    plot: Node,
    data: &ChartData,
    colors: &ChartColors,
    width: f64,
    height: f64,
    plot_bottom: f64,
    font: &str,
    size: f64,
) {
    let Some(table) = child(plot, "dTable") else {
        return;
    };
    let count = data
        .series
        .iter()
        .filter_map(|s| s.points.last())
        .map(|p| u64::from(p.index) + 1)
        .max()
        .unwrap_or(0);
    if count == 0 {
        return;
    }
    let props = text_props(Some(table), false);
    let size = font_size(props, size);
    let local_font = props.map(|p| font_family(Some(p), "Calibri"));
    let font = local_font.as_deref().unwrap_or(font);
    let key = if show(table, "showKeys") {
        size * 0.55
    } else {
        0.
    };
    let names: Vec<_> = data
        .series
        .iter()
        .enumerate()
        .map(|(i, s)| {
            s.name
                .clone()
                .unwrap_or_else(|| format!("Series {}", i + 1))
        })
        .collect();
    let label_width = (names
        .iter()
        .map(|n| {
            text_width(n, size)
                * if font.starts_with("Calibri,") {
                    0.88
                } else {
                    1.
                }
        })
        .fold(0., f64::max)
        + size * 0.85
        + key)
        .min(width * 0.40);
    let manual = child(plot, "layout").and_then(|n| child(n, "manualLayout"));
    let left = num(manual, "x").unwrap_or(0.12).clamp(0., 1.) * width;
    let span = num(manual, "w").unwrap_or(0.84).clamp(0., 1.) * width;
    let header_x = (left + 1.57 * size).max(label_width + 1.).min(width);
    let right = (left + span - 0.60 * size).clamp(header_x, width);
    let x = header_x - label_width;
    let header_height = size * 1.22;
    let row_height = size * 1.558;
    let y = plot_bottom + size * 2.;
    let top_row = y + header_height;
    let rows = data
        .series
        .len()
        .min(((height - top_row) / row_height).ceil().max(0.) as usize);
    if rows == 0 || right <= header_x {
        return;
    }
    let bottom = top_row + rows as f64 * row_height;
    let cell_width = (right - header_x) / count as f64;
    let sp = child(table, "spPr");
    let stroke = sp
        .and_then(|n| colors.shape_stroke(n))
        .unwrap_or_else(|| "#868686".into());
    let fill = sp
        .and_then(|n| colors.shape_fill(n))
        .unwrap_or_else(|| "none".into());
    let outline = if show(table, "showOutline") {
        stroke.as_str()
    } else {
        "none"
    };
    write!(out,r#"<path d="M{header_x:.2} {y:.2}H{right:.2}V{bottom:.2}H{x:.2}V{top_row:.2}H{header_x:.2}Z" fill="{fill}" stroke="{outline}"/>"#).unwrap();
    if show(table, "showHorzBorder") {
        line(out, header_x, top_row, right, top_row, &stroke);
        for i in 1..rows {
            let yy = top_row + i as f64 * row_height;
            line(out, x, yy, right, yy, &stroke);
        }
    }
    let stride = (1. / cell_width).ceil().max(1.) as usize;
    if show(table, "showVertBorder") {
        line(out, header_x, top_row, header_x, bottom, &stroke);
        for i in (1..count).step_by(stride) {
            let xx = header_x + i as f64 * cell_width;
            line(out, xx, y, xx, bottom, &stroke);
        }
    }
    let mut categories = BTreeMap::new();
    for s in &data.series {
        for point in &s.points {
            if let Some(cat) = point.categories.first() {
                categories.entry(point.index).or_insert(cat.label.as_str());
            }
        }
    }
    let center = |idx: u32| header_x + (f64::from(idx) + 0.5) * cell_width;
    let text_stride = (size * 1.4 / cell_width).ceil().max(1.) as u64;
    for (index, label) in categories {
        if u64::from(index) % text_stride == 0 {
            text(
                out,
                center(index),
                y + size * 0.89,
                "middle",
                size,
                font,
                label,
            );
        }
    }
    let format_codes: Vec<_> = plot
        .children()
        .filter(|n| n.tag_name().name().ends_with("Chart"))
        .flat_map(|g| g.children().filter(|n| chart_node(*n, "ser")))
        .map(|s| {
            child(s, "val")
                .and_then(|v| v.descendants().find(|n| chart_node(*n, "formatCode")))
                .and_then(|n| n.text())
                .unwrap_or("General")
        })
        .collect();
    for (i, series) in data.series.iter().take(rows).enumerate() {
        let mid = top_row + (i as f64 + 0.5) * row_height;
        let key_x = x + size * 0.35;
        if key > 0. {
            write!(
                out,
                r#"<rect x="{key_x:.2}" y="{:.2}" width="{key:.2}" height="{key:.2}" fill="{}"/>"#,
                mid - key / 2.,
                colors.fill(i, None)
            )
            .unwrap();
        }
        text(
            out,
            key_x + key + if key > 0. { size * 0.24 } else { 0. },
            mid + size * 0.26,
            "start",
            size,
            font,
            &names[i],
        );
        for p in &series.points {
            if u64::from(p.index) % text_stride != 0 {
                continue;
            }
            if let Some(raw) = p.value.as_deref() {
                let value = displayed_value(raw, format_codes.get(i).copied().unwrap_or("General"));
                text(
                    out,
                    center(p.index),
                    mid + size * 0.28,
                    "middle",
                    size,
                    font,
                    &value,
                );
            }
        }
    }
}