Charts: write and read chartex parts

Histogram, box & whisker, waterfall, funnel, treemap and sunburst charts
were written as clustered columns, and reading skipped such charts in
files made by Excel, which keeps them as cx:chartSpace (chartex) parts.

The writer now writes these kinds as chartEx parts, as Excel lays them
out: the data goes through hidden _xlchart.v1.N workbook names, each
part comes with chart style and colour parts, and the drawing frames it
in mc:AlternateContent with a text-box fallback. Fills are written out
(series in theme accents, waterfall decreases and treemap/sunburst boxes
in their own, a white chart area), so they don't depend on the style
part. The reader reads the parts back (resolving the _xlchart names,
which aren't kept as names) with data, title, axis titles, legend,
labels and colours, and follows mc:AlternateContent in drawings.
This commit is contained in:
nihilist-narwhal 2026-10-09 23:31:18 -05:00
parent 925bf22e24
commit f16fed557e
10 changed files with 567 additions and 11 deletions

View File

@ -187,6 +187,12 @@ fn every_chart_kind_survives_xlsx() {
let kinds = [
("combo", ChartKind::Combo),
("stock", ChartKind::Stock),
("histogram", ChartKind::Histogram),
("boxWhisker", ChartKind::BoxWhisker),
("waterfall", ChartKind::Waterfall),
("funnel", ChartKind::Funnel),
("treemap", ChartKind::Treemap),
("sunburst", ChartKind::Sunburst),
("column", ChartKind::ColumnClustered),
("line", ChartKind::Line),
("scatter", ChartKind::Scatter),
@ -198,12 +204,17 @@ fn every_chart_kind_survives_xlsx() {
let before = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
assert_eq!(before.kind, kind);
reopen(&mut s);
assert!(s.doc().unwrap().wb.names.is_empty(), "{t}");
let after = &s.doc().unwrap().wb.active().unwrap().charts[0];
assert_eq!(after.kind, kind, "{t}");
assert_eq!(after.title.as_deref(), Some("Week"), "{t}");
assert_eq!(after.series.len(), before.series.len(), "{t}");
for (a, b) in after.series.iter().zip(&before.series) {
assert_eq!((&a.name, &a.categories, &a.values), (&b.name, &b.categories, &b.values), "{t}");
assert_eq!((&a.name, &a.values, &a.color), (&b.name, &b.values, &b.color), "{t}");
// A histogram bins its values; it has no categories in the file.
if kind != ChartKind::Histogram {
assert_eq!(a.categories, b.categories, "{t}");
}
}
}
}

View File

@ -340,7 +340,7 @@ pub enum LegendPos {
Right,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Series {
/// Formula text for the series name (`Sheet1!$B$1`) or a literal.
pub name: Option<String>,

365
crates/xlsx/src/chartex.rs Normal file
View File

@ -0,0 +1,365 @@
//! Office 2016 chart types (histogram, box & whisker, waterfall, funnel, treemap, sunburst),
//! which files keep as `cx:chartSpace` "chartex" parts (`xl/charts/chartExN.xml`) rather than
//! DrawingML chart parts.
use std::fmt::Write as _;
use gridcraft_core::RangeRef;
use gridcraft_formula::{Expr, SheetSel};
use gridcraft_model::{Chart, ChartKind, LegendPos, Series, Workbook};
use crate::drawing::{dml_color, srgb};
use crate::xml::{El, esc, esc_attr};
pub const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
pub const REL_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx";
pub const CT_STYLE: &str = "application/vnd.ms-office.chartstyle+xml";
pub const CT_COLORS: &str = "application/vnd.ms-office.chartcolorstyle+xml";
pub const REL_STYLE: &str = "http://schemas.microsoft.com/office/2011/relationships/chartStyle";
pub const REL_COLORS: &str = "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle";
/// Kinds written as chartex parts.
pub fn is_chartex(k: ChartKind) -> bool {
matches!(k, ChartKind::Histogram | ChartKind::BoxWhisker | ChartKind::Waterfall | ChartKind::Funnel | ChartKind::Treemap | ChartKind::Sunburst)
}
/// The `mc:Choice` requirement for a kind's drawing frame: (prefix, namespace). Funnel charts
/// came in a later revision of the format.
pub fn requires(k: ChartKind) -> (&'static str, &'static str) {
match k {
ChartKind::Funnel => ("cx2", "http://schemas.microsoft.com/office/drawing/2015/10/21/chartex"),
_ => ("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"),
}
}
// ---------------------------------------------------------------- reading
fn kind_of(layout: &str) -> Option<ChartKind> {
Some(match layout {
"clusteredColumn" | "paretoLine" => ChartKind::Histogram,
"boxWhisker" => ChartKind::BoxWhisker,
"waterfall" => ChartKind::Waterfall,
"funnel" => ChartKind::Funnel,
"treemap" => ChartKind::Treemap,
"sunburst" => ChartKind::Sunburst,
_ => return None,
})
}
fn formula(dim: &El) -> Option<String> {
let f = dim.child("f")?;
let t = f.text.trim();
Some(crate::fmla::from_file(t.strip_prefix('=').unwrap_or(t)))
}
fn text(tx: &El) -> Option<String> {
if let Some(d) = tx.child("txData") {
if let Some(f) = d.child("f") {
let t = f.text.trim();
return Some(t.strip_prefix('=').unwrap_or(t).to_string());
}
return d.child("v").map(|v| v.text.clone());
}
let rich = tx.child("rich")?;
let paras: Vec<String> = rich.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect()).collect();
let s = paras.join("\n");
if s.is_empty() { None } else { Some(s) }
}
pub fn read_chartex(root: &El) -> Option<Chart> {
let chart = root.child("chart")?;
let plot = chart.child("plotArea")?;
let region = plot.child("plotAreaRegion")?;
// Data by id: (categories, values).
let data: Vec<(String, Option<String>, Option<String>)> = root
.child("chartData")
.map(|cd| {
cd.kids("data")
.map(|d| {
let id = d.attr("id").unwrap_or("").to_string();
let cat = d.kids("strDim").chain(d.kids("numDim")).find(|x| matches!(x.attr("type"), Some("cat" | "x"))).and_then(formula);
let val = d.kids("numDim").find(|x| matches!(x.attr("type"), Some("val" | "size" | "y"))).and_then(formula);
(id, cat, val)
})
.collect()
})
.unwrap_or_default();
let mut kind = None;
let mut series = vec![];
let mut data_labels = false;
for s in region.kids("series") {
let layout = s.attr("layoutId").unwrap_or("");
// A Pareto line belongs to its histogram.
if layout == "paretoLine" {
continue;
}
let k = kind_of(layout)?;
kind.get_or_insert(k);
let id = s.child_val("dataId").unwrap_or("");
let (cat, val) = data.iter().find(|(i, _, _)| i == id).map(|(_, c, v)| (c.clone(), v.clone())).unwrap_or_default();
data_labels |= s.path(&["dataLabels", "visibility"]).is_some_and(|v| v.flag("value", false));
series.push(Series {
name: s.child("tx").and_then(text),
categories: cat,
values: val.unwrap_or_default(),
// The default colour (the series' theme accent, which the writer spells out) isn't kept.
color: s
.path(&["spPr", "solidFill"])
.and_then(dml_color)
.filter(|c| *c != gridcraft_model::Color::Theme(4 + (series.len() % 6) as u8, 0)),
..Default::default()
});
}
let legend = match chart.child("legend") {
None => LegendPos::None,
Some(l) => match l.attr("pos").unwrap_or("r") {
"b" => LegendPos::Bottom,
"t" => LegendPos::Top,
"l" => LegendPos::Left,
_ => LegendPos::Right,
},
};
let axis_title = |id: &str| plot.kids("axis").find(|a| a.attr("id") == Some(id)).and_then(|a| a.path(&["title", "tx"])).and_then(text);
Some(Chart {
id: 0,
kind: kind?,
anchor: Default::default(),
title: chart.path(&["title", "tx"]).and_then(text),
series,
legend,
data_labels,
gridlines: plot.kids("axis").any(|a| a.child("majorGridlines").is_some()),
style: 2,
x_title: axis_title("0"),
y_title: axis_title("1"),
source: None,
by_rows: false,
})
}
// ---------------------------------------------------------------- writing
/// Prefix of the hidden defined names chartex formulas go through: a chartex part refers to
/// its data by names (`_xlchart.v1.0`), which the workbook defines, not by references.
pub const NAME_PREFIX: &str = "_xlchart.v1.";
/// `<cx:f>` for a reference: a new hidden workbook name for it (collected in `names`, written
/// with the workbook's defined names). A reference along a row says so; otherwise each column
/// is one level.
fn f_xml(wb: &Workbook, si: usize, f: &str, names: &mut Vec<String>) -> String {
let row = ref_range(wb, si, f).is_some_and(|(_, r)| r.height() == 1 && r.width() > 1);
names.push(f.to_string());
format!("<cx:f{}>{NAME_PREFIX}{}</cx:f>", if row { " dir=\"row\"" } else { "" }, names.len() - 1)
}
/// A reference's sheet and range (for the row/column direction).
fn ref_range(wb: &Workbook, si: usize, f: &str) -> Option<(usize, RangeRef)> {
let Ok(Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None };
let sheet = match &r.sheet {
SheetSel::Current => si,
SheetSel::Named(n) => wb.sheet_index(n)?,
SheetSel::Span(..) => return None,
};
Some((sheet, r.range()))
}
fn tx_xml(t: &str, wb: &Workbook, si: usize, names: &mut Vec<String>) -> String {
if ref_range(wb, si, t).is_some() {
let v = crate::chart::ref_values(wb, si, t).and_then(|v| v.into_iter().next()).map(|v| v.display()).unwrap_or_default();
format!("<cx:tx><cx:txData>{}<cx:v>{}</cx:v></cx:txData></cx:tx>", f_xml(wb, si, t, names), esc(&v))
} else {
let lit = t.trim().strip_prefix('"').and_then(|x| x.strip_suffix('"')).unwrap_or(t);
format!("<cx:tx><cx:txData><cx:v>{}</cx:v></cx:txData></cx:tx>", esc(lit))
}
}
/// The chart style part a chartex part comes with: every element at its plain default.
pub fn style_xml() -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cs:chartStyle xmlns:cs=\"http://schemas.microsoft.com/office/drawing/2012/chartStyle\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" id=\"201\">",
);
for e in [
"axisTitle",
"categoryAxis",
"chartArea",
"dataLabel",
"dataLabelCallout",
"dataPoint",
"dataPoint3D",
"dataPointLine",
"dataPointMarker",
"dataPointMarkerLayout",
"dataPointWireframe",
"dataTable",
"downBar",
"dropLine",
"errorBar",
"floor",
"gridlineMajor",
"gridlineMinor",
"hiLoLine",
"leaderLine",
"legend",
"plotArea",
"plotArea3D",
"seriesAxis",
"seriesLine",
"title",
"trendline",
"trendlineLabel",
"upBar",
"valueAxis",
"wall",
] {
if e == "dataPointMarkerLayout" {
s.push_str("<cs:dataPointMarkerLayout symbol=\"circle\" size=\"5\"/>");
} else {
let _ = write!(
s,
"<cs:{e}><cs:lnRef idx=\"0\"/><cs:fillRef idx=\"0\"/><cs:effectRef idx=\"0\"/><cs:fontRef idx=\"minor\"><a:schemeClr val=\"tx1\"/></cs:fontRef></cs:{e}>"
);
}
}
s.push_str("</cs:chartStyle>");
s
}
/// The colour style part a chartex part comes with: the theme accents in turn.
pub fn colors_xml() -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cs:colorStyle xmlns:cs=\"http://schemas.microsoft.com/office/drawing/2012/chartStyle\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" meth=\"cycle\" id=\"10\">",
);
for i in 1..=6 {
let _ = write!(s, "<a:schemeClr val=\"accent{i}\"/>");
}
s.push_str("<cs:variation/></cs:colorStyle>");
s
}
/// A solid fill (and outline, for line-drawn parts like whiskers) as `cx:spPr`.
fn fill_xml(color: &str, line: bool) -> String {
let ln = if line { format!("<a:ln w=\"9525\"><a:solidFill>{color}</a:solidFill></a:ln>") } else { String::new() };
format!("<cx:spPr><a:solidFill>{color}</a:solidFill>{ln}</cx:spPr>")
}
/// Light grey for gridlines and the chart border: the text colour at a quarter strength.
const GREY: &str = "<a:schemeClr val=\"tx1\"><a:lumMod val=\"25000\"/><a:lumOff val=\"75000\"/></a:schemeClr>";
fn accent(i: usize) -> String {
format!("<a:schemeClr val=\"accent{}\"/>", i % 6 + 1)
}
/// Chartex part XML for a model chart on sheet `si`. The references it uses are appended to
/// `names` (see [`NAME_PREFIX`]).
pub fn write_chartex(wb: &Workbook, si: usize, ch: &Chart, names: &mut Vec<String>) -> String {
let hierarchy = matches!(ch.kind, ChartKind::Treemap | ChartKind::Sunburst);
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cx:chartSpace xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:cx=\"http://schemas.microsoft.com/office/drawing/2014/chartex\"><cx:chartData>",
);
for (i, ser) in ch.series.iter().enumerate() {
let _ = write!(s, "<cx:data id=\"{i}\">");
if let Some(c) = ser.categories.as_deref().filter(|_| ch.kind != ChartKind::Histogram) {
let _ = write!(s, "<cx:strDim type=\"cat\">{}</cx:strDim>", f_xml(wb, si, c, names));
}
let _ = write!(s, "<cx:numDim type=\"{}\">{}</cx:numDim>", if hierarchy { "size" } else { "val" }, f_xml(wb, si, &ser.values, names));
s.push_str("</cx:data>");
}
s.push_str("</cx:chartData><cx:chart>");
if let Some(t) = ch.title.as_deref().filter(|t| !t.is_empty()) {
let _ = write!(s, "<cx:title pos=\"t\" align=\"ctr\" overlay=\"0\"><cx:tx><cx:txData><cx:v>{}</cx:v></cx:txData></cx:tx></cx:title>", esc(t));
}
s.push_str("<cx:plotArea><cx:plotAreaRegion>");
let layout = match ch.kind {
ChartKind::BoxWhisker => "boxWhisker",
ChartKind::Waterfall => "waterfall",
ChartKind::Funnel => "funnel",
ChartKind::Treemap => "treemap",
ChartKind::Sunburst => "sunburst",
_ => "clusteredColumn",
};
for (i, ser) in ch.series.iter().enumerate() {
let _ = write!(s, "<cx:series layoutId=\"{layout}\" uniqueId=\"{{00000000-0000-0000-0000-{:08X}{i:04X}}}\">", ch.id);
if let Some(n) = &ser.name {
s.push_str(&tx_xml(n, wb, si, names));
}
// Fills are written out: they don't come from the style part. Waterfall decreases and
// treemap/sunburst boxes get their own colours.
let color = ser.color.as_ref().and_then(|c| srgb(c, &wb.theme)).unwrap_or_else(|| accent(i));
s.push_str(&fill_xml(&color, ch.kind == ChartKind::BoxWhisker));
let points = || crate::chart::ref_values(wb, si, &ser.values).unwrap_or_default().into_iter().take(10_000).enumerate();
match ch.kind {
ChartKind::Waterfall => {
for (j, v) in points() {
if v.as_f64().is_some_and(|n| n < 0.0) {
let _ = write!(s, "<cx:dataPt idx=\"{j}\">{}</cx:dataPt>", fill_xml(&accent(i + 1), false));
}
}
}
ChartKind::Treemap | ChartKind::Sunburst if ser.color.is_none() => {
for (j, _) in points() {
let _ = write!(s, "<cx:dataPt idx=\"{j}\">{}</cx:dataPt>", fill_xml(&accent(j), false));
}
}
_ => {}
}
if ch.data_labels {
s.push_str("<cx:dataLabels><cx:visibility seriesName=\"0\" categoryName=\"0\" value=\"1\"/></cx:dataLabels>");
}
let _ = write!(s, "<cx:dataId val=\"{i}\"/>");
match ch.kind {
ChartKind::Histogram => s.push_str("<cx:layoutPr><cx:binning intervalClosed=\"r\"/></cx:layoutPr>"),
ChartKind::BoxWhisker => s.push_str(
"<cx:layoutPr><cx:visibility meanLine=\"0\" meanMarker=\"1\" nonoutliers=\"0\" outliers=\"1\"/><cx:statistics quartileMethod=\"exclusive\"/></cx:layoutPr>",
),
ChartKind::Treemap => s.push_str("<cx:layoutPr><cx:parentLabelLayout val=\"overlapping\"/></cx:layoutPr>"),
_ => {}
}
s.push_str("</cx:series>");
}
s.push_str("</cx:plotAreaRegion>");
let gap = match ch.kind {
ChartKind::Histogram => Some("0"),
ChartKind::BoxWhisker => Some("1"),
ChartKind::Waterfall => Some("0.5"),
ChartKind::Funnel => Some("0.06"),
_ => None,
};
let title = |t: Option<&str>| {
t.filter(|t| !t.is_empty())
.map(|t| format!("<cx:title><cx:tx><cx:txData><cx:v>{}</cx:v></cx:txData></cx:tx></cx:title>", esc(t)))
.unwrap_or_default()
};
if let Some(gap) = gap {
let _ =
write!(s, "<cx:axis id=\"0\"><cx:catScaling gapWidth=\"{}\"/>{}<cx:tickLabels/></cx:axis>", esc_attr(gap), title(ch.x_title.as_deref()));
if ch.kind != ChartKind::Funnel {
let _ = write!(
s,
"<cx:axis id=\"1\"><cx:valScaling/>{}{}<cx:tickLabels/></cx:axis>",
title(ch.y_title.as_deref()),
if ch.gridlines {
format!("<cx:majorGridlines><cx:spPr><a:ln w=\"9525\"><a:solidFill>{GREY}</a:solidFill></a:ln></cx:spPr></cx:majorGridlines>")
} else {
String::new()
}
);
}
}
s.push_str("</cx:plotArea>");
let pos = match ch.legend {
LegendPos::None => None,
LegendPos::Bottom => Some("b"),
LegendPos::Top => Some("t"),
LegendPos::Left => Some("l"),
LegendPos::Right => Some("r"),
};
if let Some(p) = pos {
let _ = write!(s, "<cx:legend pos=\"{p}\" align=\"ctr\" overlay=\"0\"/>");
}
// A white chart area with a light border, like other charts.
let _ = write!(
s,
"</cx:chart><cx:spPr><a:solidFill><a:schemeClr val=\"bg1\"/></a:solidFill><a:ln w=\"9525\"><a:solidFill>{GREY}</a:solidFill></a:ln></cx:spPr></cx:chartSpace>"
);
s
}

View File

@ -116,7 +116,22 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(
let rels = cx.pkg.rels(part)?;
for a in &x.children {
let Some(anchor) = read_anchor(a, sheet) else { continue };
for obj in &a.children {
// Objects in markup-compatibility blocks: the first choice (chartex frames), else the
// fallback.
let objs: Vec<&El> = a
.children
.iter()
.flat_map(|o| match o.name.as_str() {
"AlternateContent" => o
.child("Choice")
.filter(|c| c.child("graphicFrame").is_some())
.or_else(|| o.child("Fallback"))
.map(|c| c.children.iter().collect())
.unwrap_or_default(),
_ => vec![o],
})
.collect();
for obj in objs {
match obj.name.as_str() {
"pic" => {
let Some(rid) = obj.path(&["blipFill", "blip"]).and_then(|b| b.attr("embed")) else { continue };
@ -140,7 +155,8 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(
let Some(rel) = rels.iter().find(|r| r.id == rid) else { continue };
let target = rel.target.clone();
if let Some(cx_xml) = cx.optional_xml(&target)? {
match crate::chart::read_chart(&cx_xml) {
let chartex = uri.contains("chartex");
match if chartex { crate::chartex::read_chartex(&cx_xml) } else { crate::chart::read_chart(&cx_xml) } {
Some(mut ch) => {
ch.id = cx.next_object_id();
ch.anchor = anchor;
@ -150,7 +166,7 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(
}
}
} else if uri.contains("chartex") || uri.contains("chartEx") {
cx.warn("Excel 2016 chart types (histogram, waterfall, treemap…) are not supported yet and were skipped");
cx.warn("a histogram, waterfall, treemap… chart without its chart part was skipped");
} else {
cx.warn("an embedded object (SmartArt, OLE…) was skipped");
}
@ -255,6 +271,18 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
xfrm(&img.anchor, "a:xfrm")
);
}
Obj::Chart(ch, rid) if crate::chartex::is_chartex(ch.kind) => {
// Chartex frames come with a fallback for readers that don't know them.
s.push_str(&anchor_open(sheet, &ch.anchor));
let (prefix, ns) = crate::chartex::requires(ch.kind);
let _ = write!(
s,
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice xmlns:{prefix}=\"{ns}\" Requires=\"{prefix}\"><xdr:graphicFrame macro=\"\"><xdr:nvGraphicFramePr><xdr:cNvPr id=\"{id}\" name=\"Chart {}\"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>{}<a:graphic><a:graphicData uri=\"http://schemas.microsoft.com/office/drawing/2014/chartex\"><cx:chart xmlns:cx=\"http://schemas.microsoft.com/office/drawing/2014/chartex\" r:id=\"{rid}\"/></a:graphicData></a:graphic></xdr:graphicFrame></mc:Choice><mc:Fallback><xdr:sp macro=\"\" textlink=\"\"><xdr:nvSpPr><xdr:cNvPr id=\"0\" name=\"\"/><xdr:cNvSpPr><a:spLocks noTextEdit=\"1\"/></xdr:cNvSpPr></xdr:nvSpPr><xdr:spPr>{}<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang=\"en-US\"/><a:t>This chart type can't be shown by this application.</a:t></a:r></a:p></xdr:txBody></xdr:sp></mc:Fallback></mc:AlternateContent>",
id - 1,
xfrm(&ch.anchor, "xdr:xfrm"),
xfrm(&ch.anchor, "a:xfrm")
);
}
Obj::Chart(ch, rid) => {
s.push_str(&anchor_open(sheet, &ch.anchor));
let _ = write!(

View File

@ -11,6 +11,7 @@
#![forbid(unsafe_code)]
mod chart;
mod chartex;
mod csv;
mod drawing;
mod fmla;

View File

@ -216,7 +216,8 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
}
wb.active_sheet = file_to_model.get(active_tab).copied().flatten().unwrap_or(0);
// Defined names.
// Defined names. Chartex parts refer to their data through hidden `_xlchart.*` names.
let mut chart_names: Vec<(String, String)> = vec![];
if let Some(dn) = wb_xml.child("definedNames") {
for d in dn.kids("definedName") {
let Some(name) = d.attr("name") else { continue };
@ -232,6 +233,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
if lname == "_xlnm._filterdatabase" {
continue;
}
if lname.starts_with("_xlchart.") {
chart_names.push((lname, crate::fmla::from_file(text)));
continue;
}
if lname == "_xlnm.print_area" || lname == "_xlnm.print_titles" {
if let Some(si) = scope
&& let Some(sheet) = wb.sheet_mut(si)
@ -256,6 +261,23 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
}
}
if !chart_names.is_empty() {
let resolve = |f: &mut String| {
if let Some((_, r)) = chart_names.iter().find(|(n, _)| f.eq_ignore_ascii_case(n)) {
*f = r.clone();
}
};
for sheet in wb.sheets.iter_mut().filter(|s| !s.charts.is_empty()) {
for ch in Arc::make_mut(sheet).charts.iter_mut() {
for s in ch.series.iter_mut() {
resolve(&mut s.values);
s.categories.iter_mut().for_each(&resolve);
s.name.iter_mut().for_each(&resolve);
}
}
}
}
// Document properties.
if let Some(r) = find_rel(&root_rels, "core-properties").cloned()
&& let Ok(Some(x)) = cx.pkg.read_xml(&r.target)

View File

@ -465,9 +465,23 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
}
for ch in &sheet.charts {
out.charts += 1;
let name = format!("xl/charts/chart{}.xml", out.charts);
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch).into_bytes());
let rid = drels.add("chart", &format!("../charts/chart{}.xml", out.charts));
let rid = if crate::chartex::is_chartex(ch.kind) {
let n = out.charts;
let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names);
out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes());
// Excel expects a chartex part's style and colour parts.
out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes());
out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes());
let mut crels = Rels::default();
crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml"));
crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml"));
out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes());
drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml"))
} else {
let name = format!("xl/charts/chart{}.xml", out.charts);
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch).into_bytes());
drels.add("chart", &format!("../charts/chart{}.xml", out.charts))
};
objs.push(Obj::Chart(ch, rid));
}
for sh in &sheet.shapes {

View File

@ -337,3 +337,102 @@ fn drawing_with_picture_and_chart() {
assert_eq!(ch.legend, gridcraft_model::LegendPos::Top);
assert_eq!(ch.x_title.as_deref(), Some("Region"));
}
#[test]
fn drawing_with_chartex_in_alternate_content() {
let drawing = r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<xdr:twoCellAnchor><xdr:from><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>15</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" Requires="cx1">
<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chart xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" r:id="rId1"/></a:graphicData></a:graphic></xdr:graphicFrame>
</mc:Choice><mc:Fallback><xdr:sp macro="" textlink=""><xdr:nvSpPr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:sp></mc:Fallback></mc:AlternateContent>
<xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>"#;
let chartex = r#"<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex">
<cx:chartData><cx:data id="0"><cx:strDim type="cat"><cx:f>_xlchart.v1.0</cx:f></cx:strDim><cx:numDim type="val"><cx:f>_xlchart.v1.2</cx:f></cx:numDim></cx:data></cx:chartData>
<cx:chart><cx:title pos="t" align="ctr" overlay="0"><cx:tx><cx:txData><cx:v>Bridge</cx:v></cx:txData></cx:tx></cx:title>
<cx:plotArea><cx:plotAreaRegion><cx:series layoutId="waterfall" uniqueId="{1}"><cx:tx><cx:txData><cx:f>_xlchart.v1.1</cx:f><cx:v>Amount</cx:v></cx:txData></cx:tx><cx:dataLabels pos="outEnd"><cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels><cx:dataId val="0"/><cx:layoutPr><cx:subtotals><cx:idx val="3"/></cx:subtotals></cx:layoutPr></cx:series></cx:plotAreaRegion>
<cx:axis id="0"><cx:catScaling gapWidth="0.5"/><cx:tickLabels/></cx:axis><cx:axis id="1"><cx:valScaling/><cx:majorGridlines/><cx:tickLabels/></cx:axis></cx:plotArea>
<cx:legend pos="t" align="ctr" overlay="0"/></cx:chart></cx:chartSpace>"#;
let drels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="../charts/chartEx1.xml"/></Relationships>"#;
let srels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#;
let bytes = super::minimal(
r#"<sheetData/><drawing r:id="rId1"/>"#,
&[
("xl/worksheets/_rels/sheet1.xml.rels", srels),
("xl/drawings/drawing1.xml", drawing),
("xl/drawings/_rels/drawing1.xml.rels", drels),
("xl/charts/chartEx1.xml", chartex),
],
"",
// Chartex data goes through hidden names.
r#"<definedNames><definedName name="_xlchart.v1.0" hidden="1">Data!$A$2:$A$5</definedName><definedName name="_xlchart.v1.1" hidden="1">Data!$B$1</definedName><definedName name="_xlchart.v1.2" hidden="1">Data!$B$2:$B$5</definedName></definedNames>"#,
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let s = wb.sheet(0).unwrap();
assert_eq!(s.charts.len(), 1);
assert!(s.shapes.is_empty(), "the fallback isn't read as well");
assert!(wb.names.is_empty(), "the chart's names aren't workbook names");
let ch = &s.charts[0];
assert_eq!(ch.kind, gridcraft_model::ChartKind::Waterfall);
assert_eq!(ch.title.as_deref(), Some("Bridge"));
assert_eq!(ch.anchor.cell, at("E2"));
assert_eq!(ch.series.len(), 1);
assert_eq!(ch.series[0].name.as_deref(), Some("Data!$B$1"));
assert_eq!(ch.series[0].categories.as_deref(), Some("Data!$A$2:$A$5"));
assert_eq!(ch.series[0].values, "Data!$B$2:$B$5");
assert!(ch.data_labels && ch.gridlines);
assert_eq!(ch.legend, gridcraft_model::LegendPos::Top);
}
#[test]
fn chartex_parts_as_excel_expects_them() {
use gridcraft_model::{Chart, ChartKind, LegendPos, Series};
let mut wb = gridcraft_model::Workbook::new();
let mut sheet = gridcraft_model::Sheet::new("Data");
sheet.charts.push(Chart {
id: 1,
kind: ChartKind::Waterfall,
anchor: Default::default(),
title: None,
series: vec![Series {
name: Some("Data!$B$1".into()),
categories: Some("Data!$A$2:$A$5".into()),
values: "Data!$B$2:$B$5".into(),
..Default::default()
}],
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 2,
x_title: None,
y_title: None,
source: None,
by_rows: false,
});
wb.sheets = vec![std::sync::Arc::new(sheet)];
let bytes = crate::write_xlsx(&wb).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
let mut part = |n: &str| {
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name(n).unwrap_or_else(|_| panic!("{n}")), &mut s).unwrap();
s
};
// Data through hidden `_xlchart` names, with style and colour parts.
let cx = part("xl/charts/chartEx1.xml");
assert!(cx.contains("<cx:f>_xlchart.v1.2</cx:f>") && !cx.contains("Data!"), "{cx}");
// Fills are spelled out, not left to the style part.
assert!(cx.contains(r#"<cx:spPr><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></cx:spPr>"#), "{cx}");
assert!(cx.contains(r#"</cx:chart><cx:spPr><a:solidFill><a:schemeClr val="bg1"/>"#), "{cx}");
let book = part("xl/workbook.xml");
assert!(book.contains(r#"<definedName name="_xlchart.v1.1" hidden="1">Data!$B$2:$B$5</definedName>"#), "{book}");
let rels = part("xl/charts/_rels/chartEx1.xml.rels");
assert!(rels.contains("relationships/chartStyle") && rels.contains("relationships/chartColorStyle"));
assert!(part("xl/charts/style1.xml").contains("<cs:chartStyle"));
assert!(part("xl/charts/colors1.xml").contains("<cs:colorStyle"));
let types = part("[Content_Types].xml");
assert!(types.contains("chartstyle+xml") && types.contains("chartcolorstyle+xml") && types.contains("chartex+xml"));
// Reading resolves the names and doesn't keep them.
let (back, _) = read_xlsx(&bytes).unwrap();
assert!(back.names.is_empty());
let s = &back.sheet(0).unwrap().charts[0].series[0];
assert_eq!((s.name.as_deref(), s.categories.as_deref(), s.values.as_str()), (Some("Data!$B$1"), Some("Data!$A$2:$A$5"), "Data!$B$2:$B$5"));
}

View File

@ -426,8 +426,7 @@ fn full_roundtrip() {
}
assert_eq!(b.charts.len(), a.charts.len());
for (x, y) in a.charts.iter().zip(&b.charts) {
let expect_kind = if x.kind == ChartKind::Waterfall { ChartKind::ColumnClustered } else { x.kind };
assert_eq!(y.kind, expect_kind, "chart kind");
assert_eq!(y.kind, x.kind, "chart kind");
assert_eq!(y.title, x.title);
assert_eq!(y.legend, x.legend);
assert_eq!(y.data_labels, x.data_labels);

View File

@ -43,6 +43,12 @@ impl Rels {
self.items.push((id.clone(), format!("{NS_REL}/{kind}"), target.to_string(), true));
id
}
/// Adds a Microsoft extension relationship with its complete namespace URI.
pub fn add_uri(&mut self, ty: &str, target: &str) -> String {
let id = format!("rId{}", self.items.len() + 1);
self.items.push((id.clone(), ty.to_string(), target.to_string(), false));
id
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
@ -91,6 +97,8 @@ pub struct Out {
pub sst: Sst,
pub images: u32,
pub charts: u32,
/// References of chartex parts, defined as hidden `_xlchart.v1.N` names.
pub chart_names: Vec<String>,
pub drawings: u32,
pub tables: u32,
pub comments: u32,
@ -138,6 +146,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
sst: Sst::default(),
images: 0,
charts: 0,
chart_names: vec![],
drawings: 0,
tables: 0,
comments: 0,
@ -202,6 +211,14 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
}
let _ = write!(names, ">{}</definedName>", esc(&crate::fmla::text_to_file(&n.formula)));
}
for (i, f) in out.chart_names.iter().enumerate() {
let _ = write!(
names,
"<definedName name=\"{}{i}\" hidden=\"1\">{}</definedName>",
crate::chartex::NAME_PREFIX,
esc(&crate::fmla::text_to_file(f))
);
}
for (i, sheet) in wb.sheets.iter().enumerate() {
let q = quote_sheet(&sheet.name);
if let Some(pa) = &sheet.print.print_area {