rust-office-charts 0.1.7

Render Office Open XML charts to SVG from native Rust or WebAssembly
Documentation
use super::chart_colors::ChartColors;
use super::util::escape_xml_text;
use crate::chart::{cx_child, ChartData, ChartSeries, CHARTEX_NS};
use roxmltree::Node;
use std::fmt::Write;

mod box_plot;
mod drawing;
mod flow;
mod fonts;
mod geo_cache;
mod hierarchy;
mod histogram;
#[cfg(feature = "map-fallback")]
mod map_atlas;
mod map_projection;
mod region_map;

struct VisibleData<'a> {
    title: Option<&'a str>,
    series: Vec<&'a ChartSeries>,
}

struct PaletteEntry {
    automatic: String,
    point_fill: Option<String>,
}

struct Canvas<'a, 'input> {
    width: f64,
    height: f64,
    title_bottom: f64,
    root: Node<'a, 'input>,
    data: VisibleData<'a>,
    source_indices: Vec<usize>,
    series_nodes: Vec<Node<'a, 'input>>,
    palette: Vec<PaletteEntry>,
    colors: &'a ChartColors,
    fonts: Option<fonts::Fonts>,
}

impl<'a, 'input> Canvas<'a, 'input> {
    fn color(&self, index: usize) -> &str {
        let entry = &self.palette[index % self.palette.len()];
        entry.point_fill.as_deref().unwrap_or(&entry.automatic)
    }

    fn paint(&self, series: usize, point: u32, palette: usize) -> &str {
        self.colors
            .explicit_fill(self.source_indices[series], Some(point))
            .unwrap_or_else(|| self.color(palette))
    }

    fn stroke(&self, series: usize) -> String {
        if let Some(outline) = self.colors.outline(self.source_indices[series], None) {
            return outline.to_owned();
        }
        let automatic = &self.palette[series].automatic;
        self.colors
            .style_stroke("dataPointLine", automatic)
            .unwrap_or_else(|| automatic.clone())
    }

    fn plot_background(&self, out: &mut String, left: f64, top: f64, right: f64, bottom: f64) {
        let props = cx_child(self.root, "chart")
            .and_then(|chart| cx_child(chart, "plotArea"))
            .and_then(|plot| cx_child(plot, "spPr"))
            .filter(|_| !self.colors.spreadsheet_defaults);
        let fill = props
            .and_then(|p| self.colors.shape_fill(p))
            .or_else(|| self.colors.style_fill("plotArea", "none"));
        let stroke = props
            .and_then(|p| self.colors.shape_stroke(p))
            .or_else(|| self.colors.style_stroke("plotArea", "none"));
        if fill.is_some() || stroke.is_some() {
            write!(
                out,
                r#"<rect x="{left}" y="{top}" width="{}" height="{}" fill="{}" stroke="{}"/>"#,
                (right - left).max(0.),
                (bottom - top).max(0.),
                fill.as_deref().unwrap_or("none"),
                stroke.as_deref().unwrap_or("none")
            )
            .unwrap();
        }
    }

    fn series(&self, index: usize) -> Option<Node<'a, 'input>> {
        self.series_nodes.get(index).copied()
    }
}

pub(super) fn render(
    root: Node,
    data: &ChartData,
    colors: &ChartColors,
    width: f64,
    height: f64,
    options: &crate::RenderOptions,
) -> Option<String> {
    if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
        return None;
    }
    let chart = cx_child(root, "chart")?;
    let plot = cx_child(cx_child(chart, "plotArea")?, "plotAreaRegion")?;
    let series_nodes: Vec<_> = plot
        .children()
        .filter(|s| s.has_tag_name((CHARTEX_NS, "series")) && s.attribute("layoutId").is_some())
        .collect();
    let primary_kind = |nodes: &[Node]| {
        nodes
            .iter()
            .filter_map(|n| n.attribute("layoutId"))
            .find(|kind| *kind != "paretoLine")
            .map(str::to_owned)
    };
    let first_kind = primary_kind(&series_nodes)?;
    let (source_indices, series_nodes): (Vec<_>, Vec<_>) = series_nodes
        .into_iter()
        .enumerate()
        .filter(|(_, s)| !matches!(s.attribute("hidden"), Some("1" | "true")))
        .unzip();
    let visible_kind = primary_kind(&series_nodes);
    let kind = visible_kind.as_deref().unwrap_or(&first_kind);
    if !matches!(
        kind,
        "treemap"
            | "sunburst"
            | "waterfall"
            | "funnel"
            | "clusteredColumn"
            | "boxWhisker"
            | "regionMap"
    ) {
        return None;
    }
    let fonts = fonts::Fonts::new(root, options);
    let family = fonts.as_ref().map_or_else(
        || "Calibri, Carlito, Arial, sans-serif".into(),
        |fonts| super::chart_svg::font_family(None, fonts.resolve(None)),
    );
    let mut svg = format!(
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" font-family="{family}">"#
    );
    let props = cx_child(root, "spPr");
    let background = props
        .and_then(|p| colors.shape_fill(p))
        .or_else(|| colors.style_fill("chartArea", "#ffffff"))
        .unwrap_or_else(|| "#ffffff".to_owned());
    let border = props
        .and_then(|p| colors.shape_stroke(p))
        .or_else(|| colors.style_stroke("chartArea", "#d9d9d9"))
        .unwrap_or_else(|| "#d9d9d9".to_owned());
    write!(
        svg,
        r#"<rect width="{width}" height="{height}" fill="{background}" stroke="{border}"/>"#
    )
    .unwrap();
    let map_font = (kind == "regionMap").then(|| {
        fonts
            .as_ref()
            .map_or(options.font_family.unwrap_or("Cambria"), |fonts| {
                fonts.resolve(None)
            })
            .to_owned()
    });
    let title_bottom = render_title(
        &mut svg,
        chart,
        data,
        colors,
        width,
        height,
        map_font.as_deref(),
        fonts.as_ref(),
    );
    let data = VisibleData {
        title: data.title.as_deref(),
        series: source_indices
            .iter()
            .filter_map(|i| data.series.get(*i))
            .collect(),
    };
    if data.series.is_empty()
        || (kind != "regionMap" && data.series.iter().all(|s| s.points.is_empty()))
    {
        svg.push_str("</svg>");
        return Some(svg);
    }
    let mut palette_size = match kind {
        "waterfall" => 4,
        _ => series_nodes.len().max(1),
    };
    if matches!(kind, "treemap" | "sunburst") {
        if let Some(series) = data.series.first() {
            if let Some(top_level) = series
                .points
                .iter()
                .flat_map(|p| p.categories.iter().map(|c| c.level))
                .max()
            {
                palette_size = palette_size.max(
                    series
                        .points
                        .iter()
                        .filter(|p| p.categories.iter().any(|c| c.level == top_level))
                        .count(),
                );
            } else if colors.spreadsheet_defaults {
                palette_size = palette_size.max(series.points.len());
            }
        }
    }
    let palette_indices: Vec<usize> = if matches!(kind, "clusteredColumn" | "boxWhisker") {
        series_nodes
            .iter()
            .zip(&source_indices)
            .map(|(node, source)| {
                node.attribute("formatIdx")
                    .and_then(|value| value.parse::<u32>().ok())
                    .map_or(*source, |index| index as usize)
            })
            .collect()
    } else {
        (0..palette_size).collect()
    };
    let highest = palette_indices.iter().copied().max().unwrap_or(0);
    let palette = palette_indices
        .into_iter()
        .map(|index| {
            let automatic = colors.palette(index, highest);
            let point_fill = colors.style_point_fill(&automatic);
            PaletteEntry {
                automatic,
                point_fill,
            }
        })
        .collect();
    let ctx = Canvas {
        width,
        height,
        root,
        data,
        title_bottom,
        source_indices,
        series_nodes,
        palette,
        colors,
        fonts,
    };
    match kind {
        "treemap" | "sunburst" => hierarchy::render(&ctx, &mut svg),
        "waterfall" | "funnel" => flow::render(&ctx, &mut svg),
        "clusteredColumn" => histogram::render(&ctx, &mut svg),
        "boxWhisker" => box_plot::render(&ctx, &mut svg),
        "regionMap" => region_map::render(&ctx, map_font.as_deref()?, &mut svg),
        _ => return None,
    }?;
    svg.push_str("</svg>");
    Some(svg)
}

fn render_title(
    svg: &mut String,
    chart: Node,
    data: &ChartData,
    colors: &ChartColors,
    width: f64,
    height: f64,
    map_font: Option<&str>,
    fonts: Option<&fonts::Fonts>,
) -> f64 {
    let inset = if map_font.is_some() { 7. } else { 8. };
    let title = cx_child(chart, "title");
    let value = data.title.as_deref().or_else(|| {
        (colors.spreadsheet_defaults && title.is_some_and(|n| cx_child(n, "tx").is_none()))
            .then_some("Chart Title")
    });
    let Some((title, value)) = title.zip(value) else {
        return inset;
    };
    let properties = title
        .descendants()
        .filter(|n| matches!(n.tag_name().name(), "rPr" | "defRPr"));
    let size = properties
        .clone()
        .find_map(|n| n.attribute("sz")?.parse::<u32>().ok())
        .map(|v| f64::from(v) / 75.)
        .filter(|p| *p > 0.)
        .or_else(|| map_font.and_then(|_| colors.style_font_size("title")))
        .unwrap_or(56. / 3.)
        .clamp(1., 512.);
    let fill = properties
        .clone()
        .find_map(|n| colors.shape_fill(n))
        .or_else(|| colors.style_text("title", "#595959"))
        .unwrap_or_else(|| "#595959".into());
    let typeface = properties.clone().find_map(|n| {
        n.children()
            .find(|e| e.tag_name().name() == "latin")?
            .attribute("typeface")
    });
    let font = fonts.map_or_else(
        || {
            typeface
                .filter(|font| !font.starts_with('+'))
                .unwrap_or(map_font.unwrap_or("Calibri"))
        },
        |fonts| fonts.resolve(typeface),
    );
    let fallback = if (map_font.is_some() || fonts.is_some())
        && matches!(font, "Cambria" | "Georgia" | "Times New Roman")
    {
        "Georgia, Times New Roman, serif"
    } else {
        "Carlito, Arial, sans-serif"
    };
    let bold = properties
        .clone()
        .any(|n| matches!(n.attribute("b"), Some("1" | "true")));
    let italic = properties
        .clone()
        .any(|n| matches!(n.attribute("i"), Some("1" | "true")));
    let bottom = title.attribute("pos") == Some("b");
    let (x, anchor) = match title.attribute("align") {
        Some("l") => (inset, "start"),
        Some("r") => (width - inset, "end"),
        _ => (width / 2., "middle"),
    };
    let y = if bottom {
        height - inset
    } else {
        inset + size * if map_font.is_some() { 0.96 } else { 0.94 }
    };
    write!(svg, r#"<text x="{x:.3}" y="{y:.3}" text-anchor="{anchor}" font-family="{}, {fallback}" font-size="{size:.3}" font-weight="{}" font-style="{}" fill="{fill}">{}</text>"#,
        escape_xml_text(font), if bold {"bold"} else {"normal"}, if italic {"italic"} else {"normal"},escape_xml_text(value)).unwrap();
    if bottom || matches!(title.attribute("overlay"), Some("1" | "true")) {
        inset
    } else {
        inset + size * if map_font.is_some() { 1.44 } else { 1.46 }
    }
}