diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 538d271..c7b6879 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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}"); + } } } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index cd358a4..c8d5b69 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -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, diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs new file mode 100644 index 0000000..5fac0c0 --- /dev/null +++ b/crates/xlsx/src/chartex.rs @@ -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 { + 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 { + 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 { + 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 = 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 { + 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, Option)> = 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."; + +/// `` 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 { + let row = ref_range(wb, si, f).is_some_and(|(_, r)| r.height() == 1 && r.width() > 1); + names.push(f.to_string()); + format!("{NAME_PREFIX}{}", 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 { + 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!("{}{}", 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!("{}", 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( + "\n", + ); + 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(""); + } else { + let _ = write!( + s, + "" + ); + } + } + s.push_str(""); + 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( + "\n", + ); + for i in 1..=6 { + let _ = write!(s, ""); + } + s.push_str(""); + 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!("{color}") } else { String::new() }; + format!("{color}{ln}") +} + +/// Light grey for gridlines and the chart border: the text colour at a quarter strength. +const GREY: &str = ""; + +fn accent(i: usize) -> String { + format!("", 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 { + let hierarchy = matches!(ch.kind, ChartKind::Treemap | ChartKind::Sunburst); + let mut s = String::from( + "\n", + ); + for (i, ser) in ch.series.iter().enumerate() { + let _ = write!(s, ""); + if let Some(c) = ser.categories.as_deref().filter(|_| ch.kind != ChartKind::Histogram) { + let _ = write!(s, "{}", f_xml(wb, si, c, names)); + } + let _ = write!(s, "{}", if hierarchy { "size" } else { "val" }, f_xml(wb, si, &ser.values, names)); + s.push_str(""); + } + s.push_str(""); + if let Some(t) = ch.title.as_deref().filter(|t| !t.is_empty()) { + let _ = write!(s, "{}", esc(t)); + } + s.push_str(""); + 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, "", 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, "{}", fill_xml(&accent(i + 1), false)); + } + } + } + ChartKind::Treemap | ChartKind::Sunburst if ser.color.is_none() => { + for (j, _) in points() { + let _ = write!(s, "{}", fill_xml(&accent(j), false)); + } + } + _ => {} + } + if ch.data_labels { + s.push_str(""); + } + let _ = write!(s, ""); + match ch.kind { + ChartKind::Histogram => s.push_str(""), + ChartKind::BoxWhisker => s.push_str( + "", + ), + ChartKind::Treemap => s.push_str(""), + _ => {} + } + s.push_str(""); + } + s.push_str(""); + 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!("{}", esc(t))) + .unwrap_or_default() + }; + if let Some(gap) = gap { + let _ = + write!(s, "{}", esc_attr(gap), title(ch.x_title.as_deref())); + if ch.kind != ChartKind::Funnel { + let _ = write!( + s, + "{}{}", + title(ch.y_title.as_deref()), + if ch.gridlines { + format!("{GREY}") + } else { + String::new() + } + ); + } + } + s.push_str(""); + 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, ""); + } + // A white chart area with a light border, like other charts. + let _ = write!( + s, + "{GREY}" + ); + s +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index e65a21b..9ad0b71 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -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, + "{}{}This chart type can't be shown by this application.", + 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!( diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7c72476..12a11f3 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -11,6 +11,7 @@ #![forbid(unsafe_code)] mod chart; +mod chartex; mod csv; mod drawing; mod fmla; diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4..47b5243 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -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) diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 94d9b18..af9231e 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -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 { diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index c843323..0b333ed 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -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#" + 4010100150 + + + + "#; + let chartex = r#" + _xlchart.v1.0_xlchart.v1.2 + Bridge + _xlchart.v1.1Amount + + "#; + let drels = r#""#; + let srels = r#""#; + let bytes = super::minimal( + r#""#, + &[ + ("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#""#, + ); + 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("_xlchart.v1.2") && !cx.contains("Data!"), "{cx}"); + // Fills are spelled out, not left to the style part. + assert!(cx.contains(r#""#), "{cx}"); + assert!(cx.contains(r#""#), "{cx}"); + let book = part("xl/workbook.xml"); + assert!(book.contains(r#""#), "{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(" 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, pub drawings: u32, pub tables: u32, pub comments: u32, @@ -138,6 +146,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result, 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, IoError> { } let _ = write!(names, ">{}", esc(&crate::fmla::text_to_file(&n.formula))); } + for (i, f) in out.chart_names.iter().enumerate() { + let _ = write!( + names, + "", + 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 {