diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index f4e70368..edcb1f7f 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option { Some(l) } -fn rich_text(t: &El) -> Option { +pub(crate) fn rich_text(t: &El) -> Option { let tx = t.child("tx")?; if let Some(r) = tx.child("rich") { let s = r.children_named("p").map(|p| p.text()).collect::>().join("\n"); diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs new file mode 100644 index 00000000..ac14c97b --- /dev/null +++ b/crates/pptx/src/read/chartex.rs @@ -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, + numbers: HashMap>, Option)>, +} + +/// A level's points, by `idx`. +fn points(lvl: &El) -> Vec { + 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::().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 { + 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 { + 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 { + if !root.is("chartSpace") { + return None; + } + let datas: HashMap = + 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) +} diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index f4e48486..8f093e8d 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -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")); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index b59828d0..51e05306 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -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; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 4005c84f..5ab775a3 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -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> { + 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 { // 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") { diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 27f532e8..71c62ec6 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -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, pub table_styles: Vec, placeholder: Option, @@ -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 { + 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, PptxError> { n_image: 0, n_media: 0, n_chart: 0, + n_chartex: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8bc5d5fb..6053a118 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -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); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 2f8d9649..a99d49d8 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -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> { + 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 { + 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#" +Sheet1!$A$2:$A$5StartSalesCostsEndSheet1!$B$2:$B$510040-25115Cash flowSheet1!$B$1Amount"#; + +/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback. +const WATERFALL_FRAME: &str = r#"This chart isn't available in your version of PowerPoint."#; + +/// 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#""#; + let pptx = repack( + &base, + |name, x| { + if name == slide { + x.replacen("", &format!("{WATERFALL_FRAME}"), 1) + } else if name == slide_rels { + x.replace("", r#""#) + } else if name == "[Content_Types].xml" { + x.replace( + "", + r#""#, + ) + } 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 { + 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")); +} diff --git a/docs/gaps.md b/docs/gaps.md index fb1d0f30..e50bd1db 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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).