From 15daed6ca4b0d0c4de2329c3b509951112141cc9 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:08:13 +0300 Subject: [PATCH] Charts: histograms, Pareto and box and whisker; Office 2016 charts save as themselves Histograms and Paretos bin their observations (bin count, bin width or Scott's rule) and draw as touching columns, a Pareto with its cumulative line on a percentage axis. Box and whisker charts draw exclusive-quartile boxes, 1.5 IQR whiskers, means and outliers per category group. Edited and new Office 2016 charts are written as chartEx parts from the model (with the plain-chart fallback), so a waterfall stays a waterfall. The chart menus list all seven types, and chart.options sets bins and waterfall totals. Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 40 ++++- crates/model/src/chart.rs | 305 ++++++++++++++++++++++++++++++++ crates/pptx/src/read/chartex.rs | 4 + crates/pptx/src/write/chart.rs | 151 +++++++++++++++- crates/pptx/tests/roundtrip.rs | 58 +++++- crates/render/src/chart.rs | 17 ++ crates/render/src/chartex.rs | 103 ++++++++++- crates/render/src/tests.rs | 31 ++++ crates/ui-egui/src/ribbon.rs | 25 ++- docs/file-format-parity.md | 2 +- docs/gaps.md | 10 +- 11 files changed, 728 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..810a96e2 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], id?}", has_selection, chart_options ), @@ -644,7 +644,12 @@ fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { let k = chart_type(str_param(p, "type").unwrap_or("column")); with_chart(s, p, "chart.type", |c| { c.kind = k; - c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors; + c.vary_colors = + matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) + || c.series.len() == 1 && c.vary_colors; + if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + c.gridlines = true; + } c.raw = None; Ok(()) }) @@ -688,7 +693,38 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { } fn chart_options(s: &mut Session, p: &Value) -> Result { + // Histogram bins: a count or a width (setting one clears the other; null for automatic). + let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); + let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); + if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { + return Err(bad("chart.options", "binSize must be a positive number")); + } + // Waterfall total points. + let subtotals: Option> = + p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); with_chart(s, p, "chart.options", |c| { + if let Some(n) = bin_count { + c.bin_count = n; + if n.is_some() { + c.bin_size = None; + } + } + if let Some(w) = bin_size { + c.bin_size = w; + if w.is_some() { + c.bin_count = None; + } + } + if let Some(t) = &subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); + t.sort_unstable(); + t.dedup(); + c.subtotals = t; + } + if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { + c.raw = None; + } if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index a6c9fa24..583a447b 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -38,6 +38,22 @@ pub enum ChartType { } impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). + pub fn is_chartex(self) -> bool { + Self::OFFICE_2016.contains(&self) + } + pub const MAIN: [ChartType; 12] = [ ChartType::Column, ChartType::Bar, @@ -190,6 +206,13 @@ pub struct Chart { /// the running total, instead of floating. #[serde(skip_serializing_if = "Vec::is_empty")] pub subtotals: Vec, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -230,6 +253,8 @@ impl Default for Chart { y2_hidden: false, lang: None, subtotals: vec![], + bin_count: None, + bin_size: None, raw: None, } } @@ -241,6 +266,27 @@ impl Chart { let cats = ["Q1", "Q2", "Q3", "Q4"]; let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + // These plot raw observations: a histogram bins one series, a box chart summarises + // each category's points. + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + c.categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c.gridlines = true; + c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); + return c; + } let n_series = if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; for s in 0..n_series { @@ -544,3 +590,262 @@ mod tests { assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); } } + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats(pts) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// The summary of one group's points. +pub fn box_stats(mut pts: Vec) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let (q1, median, q3) = (quantile_exc(&pts, 0.25), quantile_exc(&pts, 0.5), quantile_exc(&pts, 0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index 1207df02..5c6e2f77 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -110,6 +110,10 @@ pub(crate) fn chartex_from(root: &El) -> Option { if c.categories.is_empty() { c.categories = d.categories.clone(); } + if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) { + c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0); + c.bin_size = b.child("binSize").and_then(|n| n.attr("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); + } if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) { c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect(); } diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 98ac50cb..172acf49 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -365,9 +365,14 @@ 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. +/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart +/// is unchanged since it was read (without its workbook link), else one written from the model. +/// `None` for other chart types. pub fn chartex_xml(c: &Chart) -> Option> { + kept_chartex(c).or_else(|| c.kind.is_chartex().then(|| build_chartex(c))) +} + +fn kept_chartex(c: &Chart) -> Option> { let raw = c.raw.as_ref()?; let doc = crate::xml::parse(raw.as_bytes()).ok()?; let mut root = doc.root.clone(); @@ -502,3 +507,145 @@ pub fn chart_xml(c: &Chart) -> Vec { w.close("c:chartSpace"); w.finish() } + +/// A `cx:tx` holding plain text. +fn cx_text(w: &mut W, t: &str) { + w.open0("cx:tx"); + w.open0("cx:txData"); + w.open0("cx:v"); + w.text(t); + w.close("cx:v"); + w.close("cx:txData"); + w.close("cx:tx"); +} + +/// A `cx:lvl` of points. +fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) { + let mut a = A::new().a("ptCount", pts.len()); + if let Some(f) = format { + a = a.a("formatCode", f); + } + w.open("cx:lvl", a); + for (i, p) in pts.iter().enumerate() { + w.open("cx:pt", A::new().a("idx", i)); + w.text(p); + w.close("cx:pt"); + } + w.close("cx:lvl"); +} + +/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data` +/// per series (categories and values), then the plot area with each series' layout, the axes +/// the layout uses, the title and the legend. +pub fn build_chartex(c: &Chart) -> Vec { + let mut w = W::new(); + w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX)); + w.open0("cx:chartData"); + for (i, s) in c.series.iter().enumerate() { + w.open("cx:data", A::new().a("id", i)); + if !c.categories.is_empty() { + w.open("cx:strDim", A::new().a("type", "cat")); + cx_level(&mut w, &c.categories, None); + w.close("cx:strDim"); + } + let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" }; + w.open("cx:numDim", A::new().a("type", ty)); + let vals: Vec = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect(); + cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General"))); + w.close("cx:numDim"); + w.close("cx:data"); + } + w.close("cx:chartData"); + w.open0("cx:chart"); + if let Some(t) = &c.title { + w.open("cx:title", A::new().a("pos", "t").a("align", "ctr").a("overlay", "0")); + cx_text(&mut w, t); + w.close("cx:title"); + } + w.open0("cx:plotArea"); + w.open0("cx:plotAreaRegion"); + let layout = match c.kind { + ChartType::Waterfall => "waterfall", + ChartType::Funnel => "funnel", + ChartType::Treemap => "treemap", + ChartType::Sunburst => "sunburst", + ChartType::BoxWhisker => "boxWhisker", + _ => "clusteredColumn", + }; + let one = !matches!(c.kind, ChartType::BoxWhisker); + for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) { + w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}"))); + cx_text(&mut w, &s.name); + if s.data_labels.unwrap_or(c.data_labels) { + w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" })); + w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1")); + w.close("cx:dataLabels"); + } + w.empty("cx:dataId", A::new().a("val", i)); + match c.kind { + ChartType::Waterfall if !c.subtotals.is_empty() => { + w.open0("cx:layoutPr"); + w.open0("cx:subtotals"); + for t in &c.subtotals { + w.empty("cx:idx", A::new().a("val", t)); + } + w.close("cx:subtotals"); + w.close("cx:layoutPr"); + } + ChartType::Histogram | ChartType::Pareto => { + w.open0("cx:layoutPr"); + w.open("cx:binning", A::new().a("intervalClosed", "r")); + if let Some(size) = c.bin_size { + w.empty("cx:binSize", A::new().a("val", fmt_num(size))); + } else if let Some(n) = c.bin_count { + w.empty("cx:binCount", A::new().a("val", n)); + } + w.close("cx:binning"); + w.close("cx:layoutPr"); + } + ChartType::BoxWhisker => { + w.open0("cx:layoutPr"); + w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1")); + w.empty("cx:statistics", A::new().a("quartileMethod", "exclusive")); + w.close("cx:layoutPr"); + } + _ => {} + } + w.close("cx:series"); + } + if c.kind == ChartType::Pareto { + w.open("cx:series", A::new().a("layoutId", "paretoLine").a("ownerIdx", "0").a("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}")); + w.empty("cx:axisId", A::new().a("val", "2")); + w.close("cx:series"); + } + w.close("cx:plotAreaRegion"); + if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + w.open("cx:axis", A::new().a("id", "0")); + w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" })); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + if c.kind != ChartType::Funnel { + w.open("cx:axis", A::new().a("id", "1")); + w.empty0("cx:valScaling"); + if c.gridlines { + w.empty0("cx:majorGridlines"); + } + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + if c.kind == ChartType::Pareto { + w.open("cx:axis", A::new().a("id", "2")); + w.empty("cx:valScaling", A::new().a("max", "1").a("min", "0")); + w.empty0("cx:units"); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + } + w.close("cx:plotArea"); + if let Some(pos) = &c.legend { + w.empty("cx:legend", A::new().a("pos", pos.as_str()).a("align", "ctr").a("overlay", "0")); + } + w.close("cx:chart"); + w.close("cx:chartSpace"); + w.finish() +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 58d79b6f..addcb39f 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1095,7 +1095,8 @@ fn chartex_waterfall_reads_and_round_trips() { 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. + // Edited: the kept part no longer matches, so a chartEx part is written from the model, + // still a waterfall with the new value. let mut edited = doc.clone(); for sl in &mut edited.slides { for sh in &mut Arc::make_mut(sl).shapes { @@ -1105,6 +1106,57 @@ fn chartex_waterfall_reads_and_round_trips() { } } 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")); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("a written chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#), "{ex}"); + assert!(String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1")); + let again = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(again.kind, ChartType::Waterfall); + assert_eq!(again.series[0].values[1], Some(55.0)); + assert_eq!(again.subtotals, c.subtotals); +} + +/// New Office 2016 charts made in DeckCraft are written as chartEx parts that read back as the +/// same type, data and options. +#[test] +fn chartex_new_charts_save_as_their_own_type() { + use deckcraft_engine::Session; + for (ty, kind) in [ + ("waterfall", ChartType::Waterfall), + ("funnel", ChartType::Funnel), + ("treemap", ChartType::Treemap), + ("sunburst", ChartType::Sunburst), + ("histogram", ChartType::Histogram), + ("pareto", ChartType::Pareto), + ("boxwhisker", ChartType::BoxWhisker), + ] { + 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 mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + let opts = match kind { + ChartType::Histogram | ChartType::Pareto => serde_json::json!({"id": id, "binCount": 4, "title": "T"}), + ChartType::Waterfall => serde_json::json!({"id": id, "subtotals": [3], "title": "T"}), + _ => serde_json::json!({"id": id, "title": "T"}), + }; + s.execute("chart.options", &opts).unwrap(); + let doc = &s.doc().unwrap().doc; + let before = chart(doc).unwrap(); + let out = deckcraft_pptx::export(doc).unwrap(); + assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_some(), "{ty}: no chartEx part"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some(), "{ty}: no fallback chart"); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, kind, "{ty}"); + assert_eq!(after.title.as_deref(), Some("T"), "{ty}"); + assert_eq!(after.categories, before.categories, "{ty}"); + let n = if matches!(kind, ChartType::BoxWhisker) { before.series.len() } else { 1 }; + assert_eq!(after.series.len(), n, "{ty}"); + assert_eq!(after.series[0].values, before.series[0].values, "{ty}"); + assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}"); + } } diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 5df5dd7e..82d1116d 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -134,6 +134,19 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo } pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { + match c.kind { + // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. + ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::BoxWhisker => { + let (view, boxes) = crate::chartex::box_view(c); + draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + } + _ => draw_inner(ctx, rctx, c, m, w, h, None), + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { @@ -400,6 +413,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } let zero = to_v(0.0f64.clamp(min, max)); if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index faf8ec2e..04d0ab1f 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -1,8 +1,9 @@ -//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap and sunburst -//! (waterfall bars are drawn by [`crate::chart`]). +//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap, sunburst and the +//! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as +//! the column chart of their bins). use deckcraft_color::Rgba; -use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::chart::{BoxStats, Chart, ChartType}; use deckcraft_model::resolve::Ctx; use deckcraft_model::text::{Align, Anchor}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; @@ -188,6 +189,102 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec } } +/// A box chart's summaries: per series, per group. +pub struct Boxes { + pub stats: Vec>>, +} + +/// A box and whisker chart as the frame the bar renderer draws (value axis, gridlines, one +/// category per group, legend by series), and the boxes to draw in it. The frame's values are +/// each group's extremes, so the value axis covers every whisker and outlier. +pub fn box_view(c: &Chart) -> (Chart, Boxes) { + let (groups, stats) = c.box_groups(); + let mut v = c.clone(); + v.kind = ChartType::Column; + v.categories = groups; + v.raw = None; + let ext = |b: &BoxStats, hi: bool| { + let o = b.outliers.iter().copied(); + if hi { o.fold(b.high, f64::max) } else { o.fold(b.low, f64::min) } + }; + let lowest = stats.iter().flatten().flatten().map(|b| ext(b, false)).fold(f64::INFINITY, f64::min); + for (s, st) in v.series.iter_mut().zip(&stats) { + s.values = st.iter().map(|b| b.as_ref().map(|b| ext(b, true))).collect(); + } + if lowest.is_finite() && lowest < 0.0 && v.y_min.is_none() { + // The frame's values are the highs; below zero the axis must reach the lowest point too. + let mag = 10f64.powf(lowest.abs().log10().floor()); + v.y_min = Some((lowest / mag).floor() * mag); + } + (v, Boxes { stats }) +} + +/// Boxes from the first to the third quartile with the median line and a mean marker, whiskers +/// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each +/// group's band. +#[allow(clippy::too_many_arguments)] +pub fn draw_boxes( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + b: &Boxes, + m: Affine, + plot: Rect, + band: f64, + horiz: bool, + to_v: &dyn Fn(f64) -> f64, +) { + let ns = b.stats.len().max(1); + let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0)); + let w = group / ns as f64; + let line = Rgba::rgb(0x40, 0x40, 0x40); + for (si, per) in b.stats.iter().enumerate() { + let fill = series_color(rctx, c, si); + for (gi, st) in per.iter().enumerate() { + let Some(st) = st else { continue }; + let c0 = if horiz { plot.y0 } else { plot.x0 } + band * gi as f64 + (band - group) / 2.0 + w * si as f64; + let (a, z) = (c0 + w * 0.1, c0 + w * 0.9); + let mid = (a + z) / 2.0; + // Along the category axis `a..z`, along the value axis `to_v`. + let rect = |p: f64, q: f64, u: f64, v: f64| if horiz { Rect::new(u.min(v), p, u.max(v), q) } else { Rect::new(p, u.min(v), q, u.max(v)) }; + let pt = |along: f64, val: f64| if horiz { Point::new(val, along) } else { Point::new(along, val) }; + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + let bx = rect(a, z, to_v(st.q1), to_v(st.q3)); + ctx.fill_rect(&bx); + let mut p = BezPath::new(); + // Whiskers with caps, and the median. + for (from, to) in [(st.q3, st.high), (st.q1, st.low)] { + p.move_to(pt(mid, to_v(from))); + p.line_to(pt(mid, to_v(to))); + p.move_to(pt(mid - w * 0.2, to_v(to))); + p.line_to(pt(mid + w * 0.2, to_v(to))); + } + p.move_to(pt(a, to_v(st.median))); + p.line_to(pt(z, to_v(st.median))); + ctx.set_paint(color(line, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.25)); + ctx.stroke_path(&p); + ctx.stroke_path(&bx.to_path(0.1)); + // The mean as an x. + let (mx, r) = (pt(mid, to_v(st.mean)), (w * 0.08).clamp(1.5, 5.0)); + let mut x = BezPath::new(); + x.move_to((mx.x - r, mx.y - r)); + x.line_to((mx.x + r, mx.y + r)); + x.move_to((mx.x - r, mx.y + r)); + x.line_to((mx.x + r, mx.y - r)); + ctx.stroke_path(&x); + for o in &st.outliers { + let q = pt(mid, to_v(*o)); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&kurbo::Circle::new(q, r).to_path(0.1)); + ctx.set_paint(color(line, 1.0)); + ctx.stroke_path(&kurbo::Circle::new(q, r).to_path(0.1)); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 3b9e1a3a..7b179485 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1746,3 +1746,34 @@ fn office_2016_charts_draw_their_own_layouts() { assert!(covered > 1000, "{kind:?}: {covered} samples inked"); } } + +/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and +/// box and whisker charts draw a box per group and series. +#[test] +fn histogram_pareto_and_box_charts_draw() { + let shot = |c: Chart| { + let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let inked = |img: &crate::Image| { + (0..540) + .step_by(6) + .flat_map(|y| (0..960).step_by(6).map(move |x| (x, y))) + .filter(|&(x, y)| { + let p = img.pixel(x, y); + p[0] < 200 || p[1] < 200 || p[2] < 200 + }) + .count() + }; + let mut empty = Chart::sample(ChartType::Histogram); + empty.series[0].values.clear(); + let blank = inked(&shot(empty)); + for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] { + let img = shot(Chart::sample(kind)); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png()); + } + let n = inked(&img); + assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})"); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..386dd9db 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1046,8 +1046,13 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { - if ui.button(k.label()).clicked() { + for k in chart_menu_types() { + if k.is_none() { + ui.separator(); + } + if let Some(k) = k + && ui.button(k.label()).clicked() + { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } } @@ -2304,7 +2309,11 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { + for k in chart_menu_types() { + let Some(k) = k else { + ui.separator(); + continue; + }; if ui.button(k.label()).clicked() { // Replace the chart's type keeping its data. if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { @@ -2420,3 +2429,13 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator +/// (`None`), then the Office 2016 types not already listed. +fn chart_menu_types() -> Vec> { + use deckcraft_model::chart::ChartType; + let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); + v.push(None); + v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); + v +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..7d0927f2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -73,7 +73,7 @@ the round trip even though DeckCraft doesn't model it. | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Only the innermost hierarchy level; no region maps | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | diff --git a/docs/gaps.md b/docs/gaps.md index 10e2fa41..253022cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -146,10 +146,12 @@ 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, Pareto, - box & whisker) are read for their data and written back unchanged. Waterfalls (floating bars, - totals, connectors), funnels, treemaps (squarified) and sunbursts (innermost ring) are drawn; - histograms and box & whisker still use the column renderer, an edited one is saved as a plain - column chart, and region maps aren't read. + box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else + written from the model, with a plain chart fallback). Waterfalls float and total, funnels, + treemaps (squarified) and sunbursts (innermost ring only) have their own layouts, histograms and + Paretos bin their data (count, width or Scott's rule), and box & whisker charts use exclusive + quartiles with 1.5 IQR whiskers. Multi-level treemap and sunburst hierarchies, inclusive + quartiles and region maps remain. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).