From 3f31f9951c0ba93f984239a25925c2c493815b91 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:08:56 +0300 Subject: [PATCH] =?UTF-8?q?PPTX:=20read=20Office=202016=20charts=20(waterf?= =?UTF-8?q?all,=20funnel,=20treemap=E2=80=A6)=20and=20keep=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waterfall, funnel, treemap, sunburst, histogram, Pareto and box & whisker charts live in a chartEx part behind an mc:AlternateContent choice requiring cx1. DeckCraft took the fallback, usually a text box saying the chart isn't available, so these charts were lost on open and on save. The cx1 choice is now taken for chartEx frames and the part is read into the chart model: categories (innermost level), values, number format, series name, title, legend and data labels. Unchanged charts are written back as their chartEx part in a cx1 choice with a plain chart of the same data as fallback, as PowerPoint writes them; an edited one is saved as a plain chart. Region maps are not read. Co-authored-by: Cursor --- crates/pptx/src/read/chart.rs | 2 +- crates/pptx/src/read/chartex.rs | 128 ++++++++++++++++++++++++++++++++ crates/pptx/src/read/mod.rs | 16 +++- crates/pptx/src/read/shapes.rs | 2 +- crates/pptx/src/write/chart.rs | 23 ++++++ crates/pptx/src/write/mod.rs | 13 ++++ crates/pptx/src/write/shapes.rs | 22 +++++- crates/pptx/tests/roundtrip.rs | 117 +++++++++++++++++++++++++++++ docs/gaps.md | 5 +- 9 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 crates/pptx/src/read/chartex.rs diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..7cecf457 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 057614eb..f48e6223 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; @@ -117,7 +119,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"))) +} /// Replace every `mc:AlternateContent` by the children of the branch we can read best. pub fn resolve_mc(el: &mut El, depth: usize) { @@ -132,7 +143,8 @@ pub fn resolve_mc(el: &mut El, depth: usize) { 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 c4b3f77f..c6226bde 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -368,7 +368,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.get(&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 76567c9e..98ac50cb 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}; 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…). @@ -362,10 +365,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 7fb6a713..48a1141c 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -30,6 +30,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"; const CT_CORE: &str = "application/vnd.openxmlformats-package.core-properties+xml"; @@ -43,6 +45,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, @@ -158,6 +161,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); @@ -385,6 +397,7 @@ pub fn export(p: &Presentation) -> 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 8b2ea35d..4196f5ea 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -365,12 +365,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 db6d0beb..58d79b6f 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,120 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +/// 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 a2894b42..a9cdda81 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -145,8 +145,9 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other - chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. + 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).