diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..a6c9fa24 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -186,6 +186,10 @@ pub struct Chart { pub y2_hidden: bool, /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -225,6 +229,7 @@ impl Default for Chart { y2_format: None, y2_hidden: false, lang: None, + subtotals: vec![], raw: None, } } @@ -304,6 +309,28 @@ impl Chart { /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: /// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 % /// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis. + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() + } + pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { let (lo, hi) = self.axis_range(secondary)?; let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; @@ -328,6 +355,13 @@ impl Chart { pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; let mut hi: f64 = 0.0; + if self.kind == ChartType::Waterfall { + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + return (lo, hi); + } let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if self.kind.stacked() { for i in 0..n { diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index ac14c97b..1207df02 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -110,6 +110,9 @@ pub(crate) fn chartex_from(root: &El) -> Option { if c.categories.is_empty() { c.categories = d.categories.clone(); } + 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(); + } let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default(); c.series.push(Series { name, diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..5df5dd7e 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -59,7 +59,18 @@ fn split_label(name: &str) -> String { } } -fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { +pub(crate) fn label( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + text: &str, + r: Rect, + size: f64, + align: Align, + anchor: Anchor, + col: Rgba, + bold: bool, +) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -150,7 +161,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. - let legend_items: Vec<(String, Rgba)> = if pie { + let waterfall = c.kind == ChartType::Waterfall; + let legend_items: Vec<(String, Rgba)> = if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() + } else if c.kind == ChartType::Funnel { + vec![] + } else if pie { c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() } else { c.series @@ -242,6 +260,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } + if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst) { + crate::chartex::draw(ctx, rctx, c, m, area, base, text_col); + return; + } let combo = c.kind == ChartType::Combo; // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); @@ -399,7 +421,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; - let (a, b) = if stacked { + let (a, b) = if waterfall { + c.waterfall_bars().get(i).copied().unwrap_or_default() + } else if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); pos_acc += v; @@ -416,10 +440,34 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar) } else { Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a)) + } + .abs(); + // A waterfall colours increases, decreases and totals, and joins each bar's end + // to the next bar's start. + let fill = if waterfall { + let slot = if c.subtotals.contains(&i) { + 2 + } else if b < a { + 1 + } else { + 0 + }; + series_color(rctx, c, slot) + } else { + series_fill(rctx, c, *si, Some(s)) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); + ctx.set_paint(color(fill, 1.0)); ctx.fill_rect(&r.abs()); + if waterfall && i + 1 < ncat && !horiz { + let y = to_v(b); + let mut j = BezPath::new(); + j.move_to((r.x1, y)); + j.line_to((r.x1 + (band - bar), y)); + ctx.set_paint(color(Rgba::rgb(0xA6, 0xA6, 0xA6), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&j); + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs new file mode 100644 index 00000000..faf8ec2e --- /dev/null +++ b/crates/render/src/chartex.rs @@ -0,0 +1,227 @@ +//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap and sunburst +//! (waterfall bars are drawn by [`crate::chart`]). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_color}; +use crate::color; + +/// Draw `c` inside `area` (the space left by the title and legend). +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + if vals.iter().all(|v| *v <= 0.0) { + return; + } + let value_label = |v: f64| c.data_label(s, v); + let labels = c.data_labels || c.kind == ChartType::Funnel; + match c.kind { + ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label), + ChartType::Treemap => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)), + _ => sunburst(ctx, rctx, c, m, area, base, &vals), + } +} + +/// Bars centred on one axis, as wide as their value, widest first as given: category labels on +/// the left, values inside. +#[allow(clippy::too_many_arguments)] +fn funnel( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + text_col: Rgba, + vals: &[f64], + value_label: &dyn Fn(f64) -> String, +) { + let n = vals.len().max(1); + let gutter = c.categories.iter().map(|n| n.chars().count()).max().unwrap_or(0) as f64 * base * 0.55 + base; + let plot = Rect::new(area.x0 + gutter.min(area.width() * 0.4), area.y0, area.x1, area.y1); + let max = vals.iter().copied().fold(0.0, f64::max).max(1e-9); + let band = plot.height() / n as f64; + let cx = plot.center().x; + for (i, v) in vals.iter().enumerate() { + let w = v / max * plot.width(); + let y = plot.y0 + band * i as f64; + let r = Rect::new(cx - w / 2.0, y + band * 0.05, cx + w / 2.0, y + band * 0.95); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); + ctx.fill_rect(&r); + if let Some(name) = c.categories.get(i) { + label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + } + label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + } +} + +/// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side, +/// each row kept while it improves the worst aspect ratio. +pub fn squarify(vals: &[f64], r: Rect) -> Vec { + let total: f64 = vals.iter().sum(); + let mut out = vec![Rect::ZERO; vals.len()]; + if total <= 0.0 || r.area() <= 0.0 { + return out; + } + let scale = r.area() / total; + let mut order: Vec = (0..vals.len()).filter(|&i| vals.get(i).is_some_and(|v| *v > 0.0)).collect(); + order.sort_by(|a, b| vals[*b].total_cmp(&vals[*a])); + let mut free = r; + let mut i = 0; + while i < order.len() { + let short = free.width().min(free.height()); + let worst = |row: &[usize]| { + let areas: Vec = row.iter().map(|&k| vals[k] * scale).collect(); + let sum: f64 = areas.iter().sum(); + let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min)); + ((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn)) + }; + let mut end = i + 1; + while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) { + end += 1; + } + let row = &order[i..end]; + let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum(); + let thick = sum / short; + let mut at = 0.0; + for &k in row { + let len = vals[k] * scale / thick; + out[k] = if free.width() >= free.height() { + Rect::new(free.x0, free.y0 + at, free.x0 + thick, free.y0 + at + len) + } else { + Rect::new(free.x0 + at, free.y0, free.x0 + at + len, free.y0 + thick) + }; + at += len; + } + free = if free.width() >= free.height() { + Rect::new(free.x0 + thick, free.y0, free.x1, free.y1) + } else { + Rect::new(free.x0, free.y0 + thick, free.x1, free.y1) + }; + i = end; + } + out +} + +#[allow(clippy::too_many_arguments)] +fn treemap( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + vals: &[f64], + value_label: Option<&dyn Fn(f64) -> String>, +) { + for (i, t) in squarify(vals, area).into_iter().enumerate() { + if t.area() <= 0.0 { + continue; + } + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = c.categories.get(i).cloned().unwrap_or_default(); + if let (Some(f), Some(v)) = (value_label, vals.get(i)) { + text = format!("{text}\n{}", f(*v)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + } +} + +/// One ring of the innermost level, with the centre left open. +fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) { + let total: f64 = vals.iter().sum(); + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.35; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; + if sweep <= 0.0 { + continue; + } + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + hole * a0.cos(), cen.y + hole * a0.sin())); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0).append_iter(0.1)); + let a1 = a0 + sweep; + p.line_to(Point::new(cen.x + hole * a1.cos(), cen.y + hole * a1.sin())); + p.extend(kurbo::Arc::new(cen, (hole, hole), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i).filter(|_| sweep > 0.25) { + let mid = a0 + sweep / 2.0; + let rr = (r + hole) / 2.0; + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + label( + ctx, + rctx, + m, + name, + Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), + base, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + a0 = a1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squarify_tiles_the_area_in_proportion() { + let vals = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0]; + let r = Rect::new(0.0, 0.0, 600.0, 400.0); + let tiles = squarify(&vals, r); + let total: f64 = vals.iter().sum(); + for (t, v) in tiles.iter().zip(vals) { + assert!((t.area() - v / total * r.area()).abs() < 1e-6, "{t:?} for {v}"); + assert!(t.x0 >= -1e-9 && t.y0 >= -1e-9 && t.x1 <= 600.0 + 1e-6 && t.y1 <= 400.0 + 1e-6, "{t:?}"); + // Squarified: no sliver tiles. + let aspect = t.width().max(t.height()) / t.width().min(t.height()); + assert!(aspect < 4.0, "{t:?}"); + } + for (i, a) in tiles.iter().enumerate() { + for b in &tiles[i + 1..] { + assert!(a.intersect(*b).area() < 1e-6, "{a:?} overlaps {b:?}"); + } + } + assert!(squarify(&[0.0, 0.0], r).iter().all(|t| t.area() == 0.0)); + } + + #[test] + fn waterfall_bars_float_and_totals_stand() { + let c = Chart { + kind: ChartType::Waterfall, + subtotals: vec![3], + series: vec![deckcraft_model::chart::Series { values: vec![Some(100.0), Some(40.0), Some(-25.0), Some(115.0)], ..Default::default() }], + ..Default::default() + }; + assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]); + assert_eq!(c.value_range(), (0.0, 140.0)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..e6570c5c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod bevel; mod camera; mod chart; +mod chartex; mod colr; mod compound; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..3b9e1a3a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,38 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +#[test] +fn office_2016_charts_draw_their_own_layouts() { + let chart = |kind: ChartType, vals: &[f64], subtotals: Vec| { + let c = Chart { + kind, + title: None, + legend: None, + gridlines: false, + subtotals, + categories: (0..vals.len()).map(|i| format!("C{i}")).collect(), + series: vec![deckcraft_model::chart::Series { values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + ..Default::default() + }; + let sh = Shape { id: ShapeId(621), 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 ink = |img: &crate::Image, x: u32, y: u32| { + let p = img.pixel(x, y); + p[0] < 235 || p[1] < 235 || p[2] < 235 + }; + // Waterfall: the decrease floats (nothing under it near the axis), the total stands on it. + let img = chart(ChartType::Waterfall, &[100.0, 40.0, -25.0, 115.0], vec![3]); + let plot_bottom = (300..530).rev().find(|&y| ink(&img, 120, y)).unwrap_or(500); + let band = |i: f64| (80.0 + (900.0 - 80.0) * (i + 0.5) / 4.0) as u32; + assert!(!ink(&img, band(2.0), plot_bottom - 20), "the decrease bar reaches the axis"); + assert!(ink(&img, band(3.0), plot_bottom - 20), "the total bar doesn't stand on the axis"); + assert!(ink(&img, band(1.0), 100), "the increase bar is missing"); + // Funnel, treemap and sunburst fill the middle of the chart. + for kind in [ChartType::Funnel, ChartType::Treemap, ChartType::Sunburst] { + let img = chart(kind, &[50.0, 30.0, 20.0], vec![]); + let covered = (0..540).step_by(10).flat_map(|y| (0..960).step_by(10).map(move |x| (x, y))).filter(|&(x, y)| ink(&img, x, y)).count(); + assert!(covered > 1000, "{kind:?}: {covered} samples inked"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index a9cdda81..10e2fa41 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -146,8 +146,10 @@ 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, but they're drawn with the - bar renderer and an edited one is saved as a plain column chart; region maps aren't read. + 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. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).