Merge PR #532 from NikitkaRa: PPTX: read Office 2016 charts (waterfall, funnel, treemap…) and keep them

This commit is contained in:
Brandon Thomas
2026-10-14 13:25:05 -04:00
9 changed files with 321 additions and 5 deletions
+1 -1
View File
@@ -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");
+128
View File
@@ -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)
}
+14 -2
View File
@@ -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"));
+1 -1
View File
@@ -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;
+23
View File
@@ -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")
{
+13
View File
@@ -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,
+21 -1
View File
@@ -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);
+117
View File
@@ -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"));
}
+3
View File
@@ -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).