Merge pull request #161 from nihilist-narwhal/fix-chart-kinds-roundtrip
Charts: histogram, box & whisker, waterfall, funnel, treemap and sunburst keep their kind through XLSX
This commit is contained in:
commit
88d0c772fb
|
|
@ -1056,6 +1056,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),
|
||||
|
|
@ -1067,12 +1073,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -124,7 +124,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 };
|
||||
|
|
@ -148,7 +163,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;
|
||||
|
|
@ -158,7 +174,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");
|
||||
}
|
||||
|
|
@ -276,6 +292,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) => {
|
||||
let (open, close) = anchor_open(sheet, &ch.anchor);
|
||||
close_tag = close;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod csv;
|
||||
mod drawing;
|
||||
mod fmla;
|
||||
|
|
|
|||
|
|
@ -226,7 +226,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 };
|
||||
|
|
@ -242,6 +243,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)
|
||||
|
|
@ -266,6 +271,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)
|
||||
|
|
|
|||
|
|
@ -471,9 +471,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 {
|
||||
|
|
|
|||
|
|
@ -348,3 +348,102 @@ fn drawing_with_picture_and_chart() {
|
|||
assert_eq!(sp.kind, gridcraft_model::ShapeKind::Ellipse);
|
||||
assert_eq!(sp.anchor.mode, gridcraft_model::AnchorMode::Absolute);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -434,8 +434,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);
|
||||
|
|
|
|||
|
|
@ -97,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,
|
||||
|
|
@ -148,6 +150,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,
|
||||
|
|
@ -213,6 +216,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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue