docxide-pdf 0.15.3

Library and CLI for converting DOCX files to PDF, matching Microsoft Word's output as closely as possible
Documentation
use std::collections::HashMap;
use std::io::{Read, Seek};

use crate::model::{
    BarGrouping, Chart, ChartAxis, ChartLegend, ChartSeries, ChartType, InlineChart,
    LegendPosition, MarkerSymbol,
};

use super::{chart_ns as chart_child, chart_ns_attr as chart_attr, chart_ns_children as chart_children, dml as dml_child, parse_hex_color, read_zip_text};

fn find_srgb_clr<'a>(sp_pr: roxmltree::Node<'a, 'a>) -> Option<roxmltree::Node<'a, 'a>> {
    dml_child(dml_child(sp_pr, "solidFill")?, "srgbClr")
}

fn extract_srgb_fill(sp_pr: roxmltree::Node) -> Option<[u8; 3]> {
    find_srgb_clr(sp_pr)?
        .attribute("val")
        .and_then(parse_hex_color)
}

fn extract_fill_alpha(sp_pr: roxmltree::Node) -> Option<f32> {
    let alpha_node = dml_child(find_srgb_clr(sp_pr)?, "alpha")?;
    let val = alpha_node.attribute("val")?.parse::<f32>().ok()?;
    Some(val / 100_000.0)
}

fn extract_line_color(sp_pr: roxmltree::Node) -> Option<[u8; 3]> {
    let ln = dml_child(sp_pr, "ln")?;
    if dml_child(ln, "noFill").is_some() {
        return None;
    }
    extract_srgb_fill(ln)
}

fn collect_indexed_pts<T: Clone>(
    cache_node: roxmltree::Node,
    default: T,
    extract: impl Fn(roxmltree::Node) -> Option<T>,
) -> Vec<T> {
    let mut pts: Vec<(usize, T)> = chart_children(cache_node, "pt")
        .filter_map(|pt| {
            let idx = pt.attribute("idx")?.parse::<usize>().ok()?;
            let v = extract(pt)?;
            Some((idx, v))
        })
        .collect();
    pts.sort_by_key(|(idx, _)| *idx);
    let count = pts.last().map(|(idx, _)| idx + 1).unwrap_or(0);
    let mut values = vec![default; count];
    for (idx, v) in pts {
        values[idx] = v;
    }
    values
}

fn parse_num_cache(parent: roxmltree::Node, child_name: &str) -> (Vec<f32>, Option<String>) {
    let num_cache = chart_child(parent, child_name)
        .and_then(|c| chart_child(c, "numRef"))
        .and_then(|nr| chart_child(nr, "numCache"));
    let Some(num_cache) = num_cache else {
        return (Vec::new(), None);
    };
    let format_code = chart_child(num_cache, "formatCode")
        .and_then(|n| n.text())
        .map(str::to_string)
        .filter(|s| s != "General");
    let values = collect_indexed_pts(num_cache, 0.0, |pt| {
        chart_child(pt, "v")?.text()?.parse::<f32>().ok()
    });
    (values, format_code)
}

fn parse_str_cache(container: Option<roxmltree::Node>) -> Vec<String> {
    let str_cache = container
        .and_then(|c| chart_child(c, "strRef"))
        .and_then(|sr| chart_child(sr, "strCache"));
    let Some(str_cache) = str_cache else {
        return Vec::new();
    };
    collect_indexed_pts(str_cache, String::new(), |pt| {
        chart_child(pt, "v")?.text().map(str::to_string)
    })
}

fn parse_marker_symbol(val: &str) -> Option<MarkerSymbol> {
    match val {
        "circle" => Some(MarkerSymbol::Circle),
        "square" => Some(MarkerSymbol::Square),
        "diamond" => Some(MarkerSymbol::Diamond),
        "triangle" => Some(MarkerSymbol::Triangle),
        "plus" => Some(MarkerSymbol::Plus),
        "star" => Some(MarkerSymbol::Star),
        "x" => Some(MarkerSymbol::X),
        "dash" => Some(MarkerSymbol::Dash),
        "dot" => Some(MarkerSymbol::Dot),
        "none" => Some(MarkerSymbol::None),
        _ => None,
    }
}

fn parse_series(ser_node: roxmltree::Node) -> (ChartSeries, Vec<String>, Option<String>) {
    let label = chart_child(ser_node, "tx")
        .and_then(|tx| chart_child(tx, "strRef"))
        .and_then(|sr| chart_child(sr, "strCache"))
        .and_then(|cache| chart_child(cache, "pt"))
        .and_then(|pt| chart_child(pt, "v"))
        .and_then(|v| v.text())
        .unwrap_or("")
        .to_string();

    let marker_node = chart_child(ser_node, "marker");

    let sp_pr = chart_child(ser_node, "spPr");
    let color = sp_pr
        .and_then(extract_srgb_fill)
        .or_else(|| sp_pr.and_then(extract_line_color))
        .or_else(|| {
            marker_node
                .and_then(|m| chart_child(m, "spPr"))
                .and_then(extract_srgb_fill)
        });
    let fill_alpha = sp_pr.and_then(extract_fill_alpha);

    let marker = marker_node
        .and_then(|m| chart_attr(m, "symbol"))
        .and_then(parse_marker_symbol);

    // Scatter/bubble use yVal instead of val
    let (y_vals, y_fmt) = parse_num_cache(ser_node, "yVal");
    let (values, val_fmt) = if !y_vals.is_empty() {
        (y_vals, y_fmt)
    } else {
        parse_num_cache(ser_node, "val")
    };

    let (x_values_raw, _) = parse_num_cache(ser_node, "xVal");
    let x_values = non_empty_vec(x_values_raw);
    let (bubble_raw, _) = parse_num_cache(ser_node, "bubbleSize");
    let bubble_sizes = non_empty_vec(bubble_raw);

    let cat_labels = parse_str_cache(chart_child(ser_node, "cat"));

    (
        ChartSeries {
            label,
            color,
            fill_alpha,
            values,
            x_values,
            bubble_sizes,
            marker,
        },
        cat_labels,
        val_fmt,
    )
}

fn non_empty_vec(v: Vec<f32>) -> Option<Vec<f32>> {
    if v.is_empty() { None } else { Some(v) }
}

fn parse_axis(ax_node: roxmltree::Node) -> ChartAxis {
    let delete = chart_attr(ax_node, "delete") == Some("1");

    let gridline_color = chart_child(ax_node, "majorGridlines")
        .and_then(|gl| chart_child(gl, "spPr"))
        .and_then(extract_line_color);

    let line_color = chart_child(ax_node, "spPr").and_then(extract_line_color);

    ChartAxis {
        labels: Vec::new(),
        delete,
        gridline_color,
        line_color,
    }
}

fn parse_legend(legend_node: roxmltree::Node) -> ChartLegend {
    let position = match chart_attr(legend_node, "legendPos") {
        Some("b") => LegendPosition::Bottom,
        Some("t") => LegendPosition::Top,
        Some("l") => LegendPosition::Left,
        _ => LegendPosition::Right,
    };
    ChartLegend { position }
}

fn collect_series(type_node: roxmltree::Node) -> (Vec<ChartSeries>, Vec<String>, Option<String>) {
    let mut series_list = Vec::new();
    let mut cat_labels = Vec::new();
    let mut val_format_code = None;
    for ser_node in chart_children(type_node, "ser") {
        let (series, labels, fmt) = parse_series(ser_node);
        if cat_labels.is_empty() && !labels.is_empty() {
            cat_labels = labels;
        }
        if val_format_code.is_none() {
            val_format_code = fmt;
        }
        series_list.push(series);
    }
    (series_list, cat_labels, val_format_code)
}

fn assign_cat_labels(cat_axis: &mut Option<ChartAxis>, cat_labels: Vec<String>) {
    if let Some(ref mut ax) = *cat_axis {
        if ax.labels.is_empty() {
            ax.labels = cat_labels;
        }
    } else if !cat_labels.is_empty() {
        *cat_axis = Some(ChartAxis {
            labels: cat_labels,
            delete: true,
            gridline_color: None,
            line_color: None,
        });
    }
}

fn extract_plot_border(plot_area: roxmltree::Node) -> Option<[u8; 3]> {
    chart_child(plot_area, "spPr").and_then(extract_line_color)
}

fn parse_chart_space(xml_content: &str, accent_colors: Vec<[u8; 3]>) -> Option<Chart> {
    let doc = roxmltree::Document::parse(xml_content).ok()?;
    let chart_space = doc.root_element();
    let chart_node = chart_child(chart_space, "chart")?;
    let plot_area = chart_child(chart_node, "plotArea")?;

    let (type_node, chart_type, gap_width_pct) = detect_chart_type(plot_area)?;

    let (series_list, cat_labels, val_format_code) = collect_series(type_node);

    // Scatter/bubble have two valAx; use first as cat_axis, second as val_axis
    let (mut cat_axis, val_axis) = if matches!(chart_type, ChartType::Scatter | ChartType::Bubble) {
        let val_axes: Vec<_> = chart_children(plot_area, "valAx")
            .map(parse_axis)
            .collect();
        (val_axes.first().cloned(), val_axes.get(1).cloned())
    } else {
        (
            chart_child(plot_area, "catAx").map(parse_axis),
            chart_child(plot_area, "valAx").map(parse_axis),
        )
    };

    assign_cat_labels(&mut cat_axis, cat_labels);

    Some(Chart {
        chart_type,
        series: series_list,
        cat_axis,
        val_axis,
        legend: chart_child(chart_node, "legend").map(parse_legend),
        gap_width_pct,
        plot_border_color: extract_plot_border(plot_area),
        accent_colors,
        val_format_code,
    })
}

fn detect_chart_type<'a>(
    plot_area: roxmltree::Node<'a, 'a>,
) -> Option<(roxmltree::Node<'a, 'a>, ChartType, f32)> {
    if let Some(n) = chart_child(plot_area, "barChart") {
        let horizontal = chart_attr(n, "barDir") == Some("bar");
        let grouping = match chart_attr(n, "grouping") {
            Some("stacked") => BarGrouping::Stacked,
            Some("percentStacked") => BarGrouping::PercentStacked,
            _ => BarGrouping::Clustered,
        };
        let gap_width_pct = chart_attr(n, "gapWidth")
            .and_then(|v| v.parse::<f32>().ok())
            .unwrap_or(150.0);
        return Some((
            n,
            ChartType::Bar {
                horizontal,
                grouping,
            },
            gap_width_pct,
        ));
    }

    let default_gap = 150.0;

    if let Some(n) = chart_child(plot_area, "doughnutChart") {
        let hole_size_pct = chart_attr(n, "holeSize")
            .and_then(|v| v.parse::<f32>().ok())
            .unwrap_or(50.0);
        return Some((n, ChartType::Doughnut { hole_size_pct }, default_gap));
    }

    let simple_types: &[(&str, ChartType)] = &[
        ("lineChart", ChartType::Line),
        ("pieChart", ChartType::Pie),
        ("pie3DChart", ChartType::Pie),
        ("areaChart", ChartType::Area),
        ("radarChart", ChartType::Radar),
        ("scatterChart", ChartType::Scatter),
        ("bubbleChart", ChartType::Bubble),
    ];
    for &(element, ref chart_type) in simple_types {
        if let Some(n) = chart_child(plot_area, element) {
            return Some((n, chart_type.clone(), default_gap));
        }
    }

    None
}

pub(super) fn parse_chart_from_zip<R: Read + Seek>(
    r_id: &str,
    rels: &HashMap<String, String>,
    zip: &mut zip::ZipArchive<R>,
    display_w: f32,
    display_h: f32,
    accent_colors: Vec<[u8; 3]>,
) -> Option<InlineChart> {
    let target = rels.get(r_id)?;
    let zip_path = target
        .strip_prefix('/')
        .map(String::from)
        .unwrap_or_else(|| format!("word/{}", target));
    let xml_content = read_zip_text(zip, &zip_path)?;
    let chart = parse_chart_space(&xml_content, accent_colors)?;
    Some(InlineChart {
        chart,
        display_width: display_w,
        display_height: display_h,
    })
}