mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Merge PR #532 from NikitkaRa: PPTX: read Office 2016 charts (waterfall, funnel, treemap…) and keep them
This commit is contained in:
@@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option<deckcraft_model::Line> {
|
||||
Some(l)
|
||||
}
|
||||
|
||||
fn rich_text(t: &El) -> Option<String> {
|
||||
pub(crate) fn rich_text(t: &El) -> Option<String> {
|
||||
let tx = t.child("tx")?;
|
||||
if let Some(r) = tx.child("rich") {
|
||||
let s = r.children_named("p").map(|p| p.text()).collect::<Vec<_>>().join("\n");
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Office 2016 charts (`cx:chartSpace`, the `chartEx` part): waterfall, funnel, treemap,
|
||||
//! sunburst, histogram, Pareto and box & whisker. Their data sits in `cx:chartData` as levels of
|
||||
//! points, and each series names its layout (`layoutId`).
|
||||
|
||||
use super::chart::rich_text;
|
||||
use crate::xml::El;
|
||||
use deckcraft_model::chart::{Chart, ChartType, Series};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Points per level and levels per dimension we keep (guards against hostile parts).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// One `cx:data`: its categories (the innermost level) and named numeric dimensions.
|
||||
#[derive(Default)]
|
||||
struct Data {
|
||||
categories: Vec<String>,
|
||||
numbers: HashMap<String, (Vec<Option<f64>>, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A level's points, by `idx`.
|
||||
fn points(lvl: &El) -> Vec<String> {
|
||||
let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize;
|
||||
let mut out = vec![String::new(); n];
|
||||
for pt in lvl.children_named("pt").take(MAX_POINTS) {
|
||||
let i = pt.u32("idx").unwrap_or(0) as usize;
|
||||
if i >= MAX_POINTS {
|
||||
continue;
|
||||
}
|
||||
if i >= out.len() {
|
||||
out.resize(i + 1, String::new());
|
||||
}
|
||||
if let Some(s) = out.get_mut(i) {
|
||||
*s = pt.text();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn data(d: &El) -> Data {
|
||||
let mut out = Data::default();
|
||||
for dim in d.elements() {
|
||||
// The first level is the innermost: a treemap's leaves, a waterfall's categories.
|
||||
let Some(lvl) = dim.child("lvl") else { continue };
|
||||
let ty = dim.attr("type").unwrap_or("");
|
||||
if dim.is("strDim") || (dim.is("numDim") && ty == "cat") {
|
||||
if ty == "cat" || out.categories.is_empty() {
|
||||
out.categories = points(lvl);
|
||||
}
|
||||
} else if dim.is("numDim") {
|
||||
let vals = points(lvl).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn kind_of(layout: &str) -> Option<ChartType> {
|
||||
Some(match layout {
|
||||
"waterfall" => ChartType::Waterfall,
|
||||
"funnel" => ChartType::Funnel,
|
||||
"treemap" => ChartType::Treemap,
|
||||
"sunburst" => ChartType::Sunburst,
|
||||
"clusteredColumn" => ChartType::Histogram,
|
||||
"boxWhisker" => ChartType::BoxWhisker,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type
|
||||
/// for (region maps).
|
||||
pub fn read_chartex_xml(bytes: &[u8]) -> Option<Chart> {
|
||||
let doc = crate::xml::parse(bytes).ok()?;
|
||||
let mut root = doc.root;
|
||||
super::resolve_mc(&mut root, 0);
|
||||
let mut c = chartex_from(&root)?;
|
||||
c.raw = Some(String::from_utf8_lossy(bytes).into_owned());
|
||||
Some(c)
|
||||
}
|
||||
|
||||
pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
if !root.is("chartSpace") {
|
||||
return None;
|
||||
}
|
||||
let datas: HashMap<String, Data> =
|
||||
root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect();
|
||||
let ch = root.child("chart")?;
|
||||
let region = ch.child("plotArea")?.child("plotAreaRegion")?;
|
||||
let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() };
|
||||
c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into()));
|
||||
c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string());
|
||||
let mut kind = None;
|
||||
for s in region.children_named("series") {
|
||||
let layout = s.attr("layoutId").unwrap_or("");
|
||||
if layout == "paretoLine" {
|
||||
// The cumulative line PowerPoint adds to a Pareto histogram.
|
||||
kind = Some(ChartType::Pareto);
|
||||
continue;
|
||||
}
|
||||
let k = kind_of(layout)?;
|
||||
if s.bool("hidden") == Some(true) {
|
||||
continue;
|
||||
}
|
||||
kind = Some(match kind {
|
||||
Some(ChartType::Pareto) => ChartType::Pareto,
|
||||
_ => k,
|
||||
});
|
||||
let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?;
|
||||
let (values, format) =
|
||||
["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default();
|
||||
if c.categories.is_empty() {
|
||||
c.categories = d.categories.clone();
|
||||
}
|
||||
let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default();
|
||||
c.series.push(Series {
|
||||
name,
|
||||
values,
|
||||
data_labels: s.child("dataLabels").map(|l| l.bool("hidden") != Some(true)),
|
||||
number_format: format,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
c.kind = kind?;
|
||||
c.data_labels = c.series.iter().any(|s| s.data_labels == Some(true));
|
||||
c.vary_colors = matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall);
|
||||
// Waterfall, histogram and box charts have a value axis with gridlines by default.
|
||||
c.gridlines = matches!(c.kind, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker);
|
||||
Some(c)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
//! PPTX import: package → [`Presentation`].
|
||||
|
||||
mod chart;
|
||||
mod chartex;
|
||||
pub(crate) mod dml;
|
||||
mod shapes;
|
||||
mod theme;
|
||||
mod timing;
|
||||
|
||||
pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml};
|
||||
pub(crate) use chartex::{chartex_from, read_chartex_xml};
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
@@ -155,7 +157,16 @@ pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String {
|
||||
}
|
||||
|
||||
/// Namespace prefixes whose `mc:Choice` content we understand.
|
||||
const SUPPORTED_MC: [&str; 9] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188"];
|
||||
const SUPPORTED_MC: [&str; 10] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188", "cx1"];
|
||||
|
||||
/// Office 2016 charts sit in a `cx1` choice; take it only for a layout DeckCraft reads (the
|
||||
/// fallback is a picture or "this chart isn't available" text). The part is checked later, when
|
||||
/// the frame is read, so here only the namespace counts.
|
||||
fn chartex_readable(ch: &El) -> bool {
|
||||
let mut found = vec![];
|
||||
ch.find_all("graphicData", &mut found);
|
||||
found.iter().any(|g| g.attr("uri").is_some_and(|u| u.ends_with("/chartex")))
|
||||
}
|
||||
|
||||
/// A relationship to an embedded 3-D model (`.glb`).
|
||||
pub(crate) fn is_model3d(rel: &opc::Rel) -> bool {
|
||||
@@ -202,7 +213,8 @@ fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) {
|
||||
let choice = ac.children_named("Choice").find(|ch| {
|
||||
let req_ok = ch.attr("Requires").unwrap_or("").split_whitespace().all(|r| SUPPORTED_MC.contains(&r));
|
||||
// Ink content parts are not modelled; prefer their fallback picture.
|
||||
let modelled = !ch.elements().any(|e| e.is("contentPart"));
|
||||
let modelled = !ch.elements().any(|e| e.is("contentPart"))
|
||||
&& (!ch.attr("Requires").unwrap_or("").contains("cx1") || chartex_readable(ch));
|
||||
req_ok && modelled
|
||||
});
|
||||
let branch = choice.or_else(|| ac.child("Fallback"));
|
||||
|
||||
@@ -421,7 +421,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
if let Some(c) = gd.child("chart")
|
||||
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
|
||||
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
|
||||
&& let Some(chart) = super::read_chart_xml(bytes)
|
||||
&& let Some(chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
|
||||
{
|
||||
s.kind = ShapeKind::Chart(Box::new(chart));
|
||||
return s;
|
||||
|
||||
@@ -4,6 +4,9 @@ use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}
|
||||
use deckcraft_model::style::Fill;
|
||||
|
||||
use crate::opc::{NS_A, NS_C, NS_R};
|
||||
|
||||
/// The Office 2016 chart namespace.
|
||||
pub const NS_CX: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
|
||||
use crate::xml::{A, W};
|
||||
|
||||
/// Column letter for a 0-based index (A, B, … Z, AA…).
|
||||
@@ -416,10 +419,30 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
/// The kept `chartEx` part of an Office 2016 chart (waterfall, funnel…) that is unchanged since
|
||||
/// it was read, without its workbook link; `None` when it was edited or isn't one.
|
||||
pub fn chartex_xml(c: &Chart) -> Option<Vec<u8>> {
|
||||
let raw = c.raw.as_ref()?;
|
||||
let doc = crate::xml::parse(raw.as_bytes()).ok()?;
|
||||
let mut root = doc.root.clone();
|
||||
crate::read::resolve_mc(&mut root, 0);
|
||||
let mut back = crate::read::chartex_from(&root)?;
|
||||
back.raw = c.raw.clone();
|
||||
if &back != c {
|
||||
return None;
|
||||
}
|
||||
let mut keep = doc.root;
|
||||
keep.remove_all(&["externalData"]);
|
||||
let mut w = W::new();
|
||||
w.raw(&keep.to_xml());
|
||||
Some(w.finish())
|
||||
}
|
||||
|
||||
/// The chart part XML.
|
||||
pub fn chart_xml(c: &Chart) -> Vec<u8> {
|
||||
// Reuse the kept part when the chart is unchanged since it was read.
|
||||
if let Some(raw) = &c.raw
|
||||
&& !raw.contains(NS_CX)
|
||||
&& let Ok(doc) = crate::xml::parse(raw.as_bytes())
|
||||
&& doc.root.is("chartSpace")
|
||||
{
|
||||
|
||||
@@ -33,6 +33,8 @@ const CT_PRES_PROPS: &str = "application/vnd.openxmlformats-officedocument.prese
|
||||
const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml";
|
||||
const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml";
|
||||
const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
|
||||
const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
|
||||
const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx";
|
||||
const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
|
||||
const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
|
||||
/// Modern threaded comments (read by PowerPoint 2021+ and DeckCraft; the flattened legacy
|
||||
@@ -52,6 +54,7 @@ pub struct Exp<'a> {
|
||||
n_image: u32,
|
||||
n_media: u32,
|
||||
n_chart: u32,
|
||||
n_chartex: u32,
|
||||
pub slide_parts: HashMap<SlideId, String>,
|
||||
pub table_styles: Vec<String>,
|
||||
placeholder: Option<String>,
|
||||
@@ -168,6 +171,15 @@ impl Exp<'_> {
|
||||
};
|
||||
o.rels.add("image", &opc::relative(&o.name, &part), false)
|
||||
}
|
||||
/// An unchanged Office 2016 chart's `chartEx` part and its relationship id.
|
||||
pub fn chartex_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> Option<String> {
|
||||
let xml = chart::chartex_xml(c)?;
|
||||
self.n_chartex += 1;
|
||||
let name = format!("ppt/charts/chartEx{}.xml", self.n_chartex);
|
||||
self.pkg.xml_part(&name, CT_CHARTEX, xml);
|
||||
Some(o.rels.add_new(RT_CHARTEX, &opc::relative(&o.name, &name), false))
|
||||
}
|
||||
|
||||
pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String {
|
||||
self.n_chart += 1;
|
||||
let name = format!("ppt/charts/chart{}.xml", self.n_chart);
|
||||
@@ -467,6 +479,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
n_image: 0,
|
||||
n_media: 0,
|
||||
n_chart: 0,
|
||||
n_chartex: 0,
|
||||
slide_parts: HashMap::new(),
|
||||
table_styles: vec![],
|
||||
placeholder: None,
|
||||
|
||||
@@ -370,12 +370,32 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
}
|
||||
ShapeKind::Chart(c) => {
|
||||
let id = cx.id_for(s);
|
||||
let rid = x.chart_rel(o, c);
|
||||
// An unchanged Office 2016 chart goes back as itself, with a plain chart of the same
|
||||
// data for readers without `cx1`, as PowerPoint writes it.
|
||||
let ex = x.chartex_rel(o, c);
|
||||
if let Some(exid) = &ex {
|
||||
w.open("mc:AlternateContent", A::new().a("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"));
|
||||
w.open("mc:Choice", A::new().a("xmlns:cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex").a("Requires", "cx1"));
|
||||
frame_open(w, x, o, id, s);
|
||||
w.open("a:graphicData", A::new().a("uri", super::chart::NS_CX));
|
||||
w.empty("cx:chart", A::new().a("xmlns:cx", super::chart::NS_CX).a("r:id", exid));
|
||||
w.close("a:graphicData");
|
||||
frame_close(w);
|
||||
w.close("mc:Choice");
|
||||
w.open0("mc:Fallback");
|
||||
}
|
||||
let mut plain = (**c).clone();
|
||||
plain.raw = plain.raw.filter(|r| !r.contains(super::chart::NS_CX));
|
||||
let rid = x.chart_rel(o, &plain);
|
||||
frame_open(w, x, o, id, s);
|
||||
w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart"));
|
||||
w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid));
|
||||
w.close("a:graphicData");
|
||||
frame_close(w);
|
||||
if ex.is_some() {
|
||||
w.close("mc:Fallback");
|
||||
w.close("mc:AlternateContent");
|
||||
}
|
||||
}
|
||||
ShapeKind::Media(m) => {
|
||||
let id = cx.id_for(s);
|
||||
|
||||
@@ -1375,3 +1375,120 @@ fn color_pulse_keeps_its_effect_colour_and_duration() {
|
||||
assert_eq!(a.amount, None, "no grow/shrink scale is invented");
|
||||
assert_eq!(a.duration_ms, 1000);
|
||||
}
|
||||
|
||||
/// A part's bytes, if the package has it.
|
||||
fn pkg_part(pkg: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap();
|
||||
let mut f = z.by_name(name).ok()?;
|
||||
let mut v = vec![];
|
||||
f.read_to_end(&mut v).unwrap();
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Rewrite a package's parts with `edit`, then add `extra` parts.
|
||||
fn repack(pkg: &[u8], edit: impl Fn(&str, String) -> String, extra: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
use std::io::{Read, Write};
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name.ends_with(".xml") || name.ends_with(".rels") {
|
||||
data = edit(&name, String::from_utf8(data).unwrap()).into_bytes();
|
||||
}
|
||||
out.start_file(name, opts).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
for (name, data) in extra {
|
||||
out.start_file(*name, opts).unwrap();
|
||||
out.write_all(data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
const WATERFALL_CX: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<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><cx:externalData r:id="rId1" cx:autoUpdate="0"/><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$5</cx:f><cx:lvl ptCount="4"><cx:pt idx="0">Start</cx:pt><cx:pt idx="1">Sales</cx:pt><cx:pt idx="2">Costs</cx:pt><cx:pt idx="3">End</cx:pt></cx:lvl></cx:strDim><cx:numDim type="val"><cx:f>Sheet1!$B$2:$B$5</cx:f><cx:lvl ptCount="4" formatCode="General"><cx:pt idx="0">100</cx:pt><cx:pt idx="1">40</cx:pt><cx:pt idx="2">-25</cx:pt><cx:pt idx="3">115</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"><cx:tx><cx:txData><cx:v>Cash flow</cx:v></cx:txData></cx:tx></cx:title><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="waterfall" uniqueId="{1}"><cx:tx><cx:txData><cx:f>Sheet1!$B$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="0"/><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="b" align="ctr" overlay="0"/></cx:chart></cx:chartSpace>"#;
|
||||
|
||||
/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback.
|
||||
const WATERFALL_FRAME: &str = r#"<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"><p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Waterfall 1"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="914400" y="914400"/><a:ext cx="6096000" cy="4064000"/></p: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="rIdCx"/></a:graphicData></a:graphic></p:graphicFrame></mc:Choice><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="90" name="Waterfall 1"/><p:cNvSpPr><a:spLocks noTextEdit="1"/></p:cNvSpPr><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="6096000" cy="4064000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:t>This chart isn't available in your version of PowerPoint.</a:t></a:r></a:p></p:txBody></p:sp></mc:Fallback></mc:AlternateContent>"#;
|
||||
|
||||
/// Office 2016 charts (`chartEx`): a waterfall reads as a waterfall chart with its data, not as
|
||||
/// the "isn't available" fallback text, and an unchanged one is written back as itself with a
|
||||
/// plain chart fallback; an edited one is written as a plain chart.
|
||||
#[test]
|
||||
fn chartex_waterfall_reads_and_round_trips() {
|
||||
use deckcraft_engine::Session;
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
|
||||
let base = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let slide = (1..=9).map(|i| format!("ppt/slides/slide{i}.xml")).find(|n| pkg_part(&base, n).is_some()).unwrap();
|
||||
let slide_rels = slide.replace("slides/", "slides/_rels/") + ".rels";
|
||||
let cx_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/></Relationships>"#;
|
||||
let pptx = repack(
|
||||
&base,
|
||||
|name, x| {
|
||||
if name == slide {
|
||||
x.replacen("</p:spTree>", &format!("{WATERFALL_FRAME}</p:spTree>"), 1)
|
||||
} else if name == slide_rels {
|
||||
x.replace("</Relationships>", r#"<Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="../charts/chartEx1.xml"/></Relationships>"#)
|
||||
} else if name == "[Content_Types].xml" {
|
||||
x.replace(
|
||||
"</Types>",
|
||||
r#"<Override PartName="/ppt/charts/chartEx1.xml" ContentType="application/vnd.ms-office.chartex+xml"/></Types>"#,
|
||||
)
|
||||
} else {
|
||||
x
|
||||
}
|
||||
},
|
||||
&[("ppt/charts/chartEx1.xml", WATERFALL_CX.as_bytes()), ("ppt/charts/_rels/chartEx1.xml.rels", cx_rels.as_bytes())],
|
||||
);
|
||||
let doc = deckcraft_pptx::import(&pptx).unwrap();
|
||||
let chart = |d: &Presentation| -> Option<Chart> {
|
||||
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
|
||||
ShapeKind::Chart(c) => Some((**c).clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let c = chart(&doc).expect("the waterfall is a chart, not the fallback text");
|
||||
assert_eq!(c.kind, ChartType::Waterfall);
|
||||
assert_eq!(c.title.as_deref(), Some("Cash flow"));
|
||||
assert_eq!(c.categories, ["Start", "Sales", "Costs", "End"]);
|
||||
assert_eq!(c.series.len(), 1);
|
||||
assert_eq!(c.series[0].name, "Amount");
|
||||
assert_eq!(c.series[0].values, [Some(100.0), Some(40.0), Some(-25.0), Some(115.0)]);
|
||||
assert!(c.data_labels);
|
||||
assert_eq!(c.legend.as_deref(), Some("b"));
|
||||
let no_fallback_text = doc.slides.iter().flat_map(|s| s.shapes.iter()).all(|sh| !format!("{:?}", sh.kind).contains("isn't available"));
|
||||
assert!(no_fallback_text);
|
||||
|
||||
// Unchanged: chartEx back in a cx1 choice, a plain chart as fallback, no workbook link.
|
||||
let out = deckcraft_pptx::export(&doc).unwrap();
|
||||
let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("chartEx part")).unwrap();
|
||||
assert!(ex.contains(r#"layoutId="waterfall""#) && !ex.contains("externalData"), "{ex}");
|
||||
let types = String::from_utf8(pkg_part(&out, "[Content_Types].xml").unwrap()).unwrap();
|
||||
assert!(types.contains("application/vnd.ms-office.chartex+xml"));
|
||||
let sx = String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap();
|
||||
assert!(sx.contains(r#"Requires="cx1""#) && sx.contains("mc:Fallback") && sx.contains("c:chart "), "{sx}");
|
||||
let srels = String::from_utf8(pkg_part(&out, &slide_rels).unwrap()).unwrap();
|
||||
assert!(srels.contains("relationships/chartEx") && srels.contains("relationships/chart\""), "{srels}");
|
||||
assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some());
|
||||
let again = deckcraft_pptx::import(&out).unwrap();
|
||||
assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1)));
|
||||
|
||||
// Edited: the kept part no longer matches, so only a plain chart is written.
|
||||
let mut edited = doc.clone();
|
||||
for sl in &mut edited.slides {
|
||||
for sh in &mut Arc::make_mut(sl).shapes {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.series[0].values[1] = Some(55.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = deckcraft_pptx::export(&edited).unwrap();
|
||||
assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_none());
|
||||
assert!(!String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1"));
|
||||
}
|
||||
|
||||
@@ -208,6 +208,9 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are not drawn. A combo
|
||||
chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back
|
||||
when the chart is rebuilt.
|
||||
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto,
|
||||
box & whisker) are read for their data and written back unchanged, but they're drawn with the
|
||||
bar renderer and an edited one is saved as a plain column chart; region maps aren't read.
|
||||
- **Impact:** most business decks have charts; editing their data is routine.
|
||||
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user