diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 36b92db..3fa6f7c 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -213,6 +213,58 @@ impl Chart { c.vary_colors = n_series == 1; c } + /// A series' own chart type: its own in a combo chart (columns when it names none), else the + /// chart's. + pub fn series_kind(&self, s: &Series) -> ChartType { + match (self.kind, s.kind) { + (ChartType::Combo, Some(k)) if k != ChartType::Combo => k, + (ChartType::Combo, _) => ChartType::Column, + (k, _) => k, + } + } + + /// Whether a series is plotted against the secondary value axis (combo charts only). + pub fn on_secondary(&self, s: &Series) -> bool { + self.kind == ChartType::Combo && s.secondary_axis + } + + /// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo + /// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series + /// uses that axis. + pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind != ChartType::Combo { + return (!secondary).then(|| self.value_range()); + } + let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); + if ser.is_empty() { + return None; + } + let (mut lo, mut hi) = (0.0_f64, 0.0_f64); + let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); + let mut percent = false; + for i in 0..n { + let (mut p, mut m) = (0.0, 0.0); + for s in &ser { + let k = self.series_kind(s); + let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + if k.percent() { + percent = true; + } else if k.stacked() { + if v >= 0.0 { p += v } else { m += v } + } else { + hi = hi.max(v); + lo = lo.min(v); + } + } + hi = hi.max(p); + lo = lo.min(m); + } + if percent { + hi = hi.max(1.0); + } + Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + } + /// Value range across series (stacked sums for stacked types). pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; @@ -283,6 +335,28 @@ mod tests { assert!(b > a); } + #[test] + fn combo_axis_ranges() { + // Spend in columns on the primary axis, clicks as a line on the secondary one. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].kind = Some(ChartType::Column); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(6_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_range(false), Some((0.0, 310_000.0))); + assert_eq!(c.axis_range(true), Some((0.0, 7_400.0))); + assert_eq!(c.series_kind(&c.series[1]), ChartType::Line); + // Without a secondary series there is no secondary axis; a non-combo chart never has one. + c.series[1].secondary_axis = false; + assert_eq!(c.axis_range(true), None); + let col = Chart::sample(ChartType::Column); + assert_eq!(col.axis_range(false), Some(col.value_range())); + assert_eq!(col.axis_range(true), None); + assert!(!col.on_secondary(&col.series[0])); + } + #[test] fn sample_ranges() { let c = Chart::sample(ChartType::StackedColumn); diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 9c6bf4b..b057332 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -102,6 +102,11 @@ pub(crate) fn chart_from(root: &El) -> Option { c.plot_fill = simple_fill(pa.child("spPr")); let mut kinds: Vec = vec![]; + // The value axis the first plot group uses is the primary one; a group on another value axis + // (a combo chart's line on the right) is on the secondary axis. + let val_ids: Vec<&str> = pa.children_named("valAx").filter_map(|a| a.child_val("axId")).collect(); + let group_val_axis = |g: &El| g.children_named("axId").filter_map(|a| a.attr("val")).find(|id| val_ids.contains(id)).map(str::to_string); + let mut primary_val: Option = None; for g in pa.elements() { let name = g.local(); if !name.ends_with("Chart") { @@ -167,6 +172,11 @@ pub(crate) fn chart_from(root: &El) -> Option { c.data_labels = true; } kinds.push(kind); + let val_axis = group_val_axis(g); + if primary_val.is_none() { + primary_val = val_axis.clone(); + } + let secondary = val_axis.is_some() && val_axis != primary_val; for s in g.children_named("ser").take(1000) { let mut ser = Series { name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(), @@ -175,6 +185,7 @@ pub(crate) fn chart_from(root: &El) -> Option { smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), kind: Some(kind), + secondary_axis: secondary, ..Default::default() }; if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) { @@ -208,6 +219,7 @@ pub(crate) fn chart_from(root: &El) -> Option { if c.kind != ChartType::Combo { for s in &mut c.series { s.kind = None; + s.secondary_axis = false; } } // Axes. @@ -243,4 +255,27 @@ mod tests { assert_eq!(c.legend.as_deref(), Some("r")); assert!(!c.legend_overlay); } + + #[test] + fn combo_chart_with_a_secondary_axis() { + // Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right. + let xml = br#" +SpendW1W2200000310000 +Clicks60007400 + + +"#; + let c = read_chart_xml(xml).unwrap(); + assert_eq!(c.kind, ChartType::Combo); + assert_eq!(c.series.len(), 2); + assert!(!c.series[0].secondary_axis); + assert!(c.series[1].secondary_axis); + assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers)); + assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01))); + // A chart of one type on one axis has no secondary series. + let single = String::from_utf8_lossy(xml) + .replace("", ""); + let c = read_chart_xml(single.as_bytes()).unwrap(); + assert!(c.series.iter().all(|s| !s.secondary_axis)); + } } diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index f6cf82d..0fca0aa 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -36,6 +36,29 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart } } +/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the +/// palette's. +fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { + match s.line.as_ref().and_then(|l| l.fill.as_ref()) { + Some(Fill::Solid { color: cc }) => ctx.color(cc, None), + _ => series_fill(ctx, c, i, Some(s)), + } +} + +/// A category label on two lines, split at its middle space or dash ("03.11.2025 - 09.11.2025"). +fn split_label(name: &str) -> String { + let chars: Vec = name.chars().collect(); + let mid = chars.len() / 2; + let best = chars.iter().enumerate().filter(|(_, c)| c.is_whitespace()).map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid)); + match best { + Some(i) if i > 0 && i + 1 < chars.len() => { + let (a, b) = chars.split_at(i); + format!("{}\n{}", a.iter().collect::().trim_end(), b.iter().collect::().trim_start()) + } + _ => name.to_string(), + } +} + 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); @@ -127,7 +150,15 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let legend_items: Vec<(String, Rgba)> = if pie { c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() } else { - c.series.iter().enumerate().map(|(i, s)| (s.name.clone(), series_fill(rctx, c, i, Some(s)))).collect() + c.series + .iter() + .enumerate() + .map(|(i, s)| { + let k = c.series_kind(s); + let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + (s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) }) + }) + .collect() }; if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); @@ -208,21 +239,31 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } - let (lo, hi) = c.value_range(); + 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()); + let (lo, hi) = c.axis_range(false).unwrap_or_else(|| c.value_range()); let (min, max, step) = nice_scale(lo.min(0.0), hi); - let horiz = c.kind.horizontal(); + let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; + // A combo chart's secondary value axis, on the right (vertical value axes only). + let second = if horiz { None } else { c.axis_range(true).map(|(l, h)| nice_scale(l.min(0.0), h)) }; // Axis label gutter. let gutter = base * 3.0; let plot = if horiz { Rect::new(area.x0 + gutter * 1.5, area.y0, area.x1, area.y1 - base * 1.6) } else { - Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.8) + Rect::new(area.x0 + gutter, area.y0, area.x1 - if second.is_some() { gutter } else { 0.0 }, area.y1 - base * 1.8) }; if let Some(f) = &c.plot_fill { crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } let span = (max - min).max(1e-9); let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }; + // A series' value as a position on its own axis. + let to_axis = |sec: bool, v: f64| match (sec, second) { + (true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(), + _ => to_v(v), + }; // Gridlines and value labels. let n_steps = ((span / step).round() as i64).clamp(1, 50); for k in 0..=n_steps { @@ -267,9 +308,30 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.stroke_path(&g); } } + // The secondary axis' labels, on the right, without gridlines of their own. + if let Some((min2, max2, step2)) = second { + let n2 = (((max2 - min2) / step2.max(1e-9)).round() as i64).clamp(1, 50); + for k in 0..=n2 { + let v = min2 + k as f64 * step2; + let pos = to_axis(true, v); + label( + ctx, + rctx, + m, + &fmt_num(v), + Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), + base, + Align::Left, + Anchor::Middle, + text_col, + false, + ); + } + } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; - // Category labels. + // Category labels: wrapped onto two lines when they don't fit their band (long date ranges). + let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band); for (i, name) in c.categories.iter().enumerate() { let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; if horiz { @@ -286,12 +348,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h false, ); } else { + let text = if wraps { split_label(name) } else { name.clone() }; label( ctx, rctx, m, - name, - Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * 1.6), + &text, + Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }), base, Align::Center, Anchor::Top, @@ -300,24 +363,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let ns = c.series.len().max(1); let zero = to_v(0.0f64.clamp(min, max)); - if c.kind.is_bar_like() { + if let Some(bk) = bar_kind { + let bars: Vec<(usize, &deckcraft_model::chart::Series)> = + c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); + let ns = bars.len().max(1); let gap = c.gap_width.clamp(0.0, 5.0); let group = band / (1.0 + gap); - let stacked = c.kind.stacked(); + let stacked = bk.stacked(); let bar = if stacked { group } else { group / ns as f64 }; for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; - let total: f64 = if c.kind.percent() { - c.series.iter().map(|s| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::().max(1e-9) + let total: f64 = if bk.percent() { + bars.iter().map(|(_, s)| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::().max(1e-9) } else { 1.0 }; - for (si, s) in c.series.iter().enumerate() { + for (bi, (si, s)) in bars.iter().enumerate() { + 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 * si as f64 }; + let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; let (a, b) = if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); @@ -334,10 +400,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let r = if horiz { Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar) } else { - Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a)) + Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a)) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0)); + ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); ctx.fill_rect(&r.abs()); if c.data_labels { let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0)); @@ -361,66 +427,72 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } - } else { - // Lines, areas, scatter, radar (drawn as lines). - let stacked = c.kind.stacked(); - let mut acc = vec![0.0; ncat]; - for (si, s) in c.series.iter().enumerate() { - let col = series_fill(rctx, c, si, Some(s)); - let mut pts = vec![]; - for i in 0..ncat { - let Some(v) = s.values.get(i).copied().flatten() else { continue }; - let v = if stacked { - let base_v = acc.get(i).copied().unwrap_or(0.0); - if let Some(a) = acc.get_mut(i) { - *a += v; - } - base_v + v - } else { - v - }; - let x = match c.kind { - ChartType::Scatter | ChartType::Bubble => { - let xs: Vec = s.x_values.iter().flatten().copied().collect(); - let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); - let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); - if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } - } - _ => plot.x0 + band * (i as f64 + 0.5), - }; - pts.push(Point::new(x, to_v(v))); - } - if pts.is_empty() { - continue; - } - let mut p = BezPath::new(); - for (k, q) in pts.iter().enumerate() { - if k == 0 { - p.move_to(*q); - } else { - p.line_to(*q); + } + // Lines, areas, scatter, radar (drawn as lines): the series that aren't bars. + let mut acc = vec![0.0; ncat]; + for (si, s) in c.series.iter().enumerate() { + let kind = c.series_kind(s); + if kind.is_bar_like() { + continue; + } + let sec = c.on_secondary(s); + let stacked = kind.stacked(); + let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; + let mut pts = vec![]; + for i in 0..ncat { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let v = if stacked { + let base_v = acc.get(i).copied().unwrap_or(0.0); + if let Some(a) = acc.get_mut(i) { + *a += v; } - } - ctx.set_transform(m); - if matches!(c.kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar) { - let mut a = p.clone(); - if let (Some(l), Some(f)) = (pts.last(), pts.first()) { - a.line_to((l.x, zero)); - a.line_to((f.x, zero)); - a.close_path(); + base_v + v + } else { + v + }; + let x = match kind { + ChartType::Scatter | ChartType::Bubble => { + let xs: Vec = s.x_values.iter().flatten().copied().collect(); + let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); + let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); + if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } } - ctx.set_paint(color(col, 0.85)); - ctx.fill_path(&a); - } else if c.kind != ChartType::Scatter { - ctx.set_paint(color(col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(2.25).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); - ctx.stroke_path(&p); + _ => plot.x0 + band * (i as f64 + 0.5), + }; + pts.push(Point::new(x, to_axis(sec, v))); + } + if pts.is_empty() { + continue; + } + let mut p = BezPath::new(); + for (k, q) in pts.iter().enumerate() { + if k == 0 { + p.move_to(*q); + } else { + p.line_to(*q); } - if matches!(c.kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { - ctx.set_paint(color(col, 1.0)); - for q in &pts { - ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); - } + } + ctx.set_transform(m); + if area_kind { + let mut a = p.clone(); + if let (Some(l), Some(f)) = (pts.last(), pts.first()) { + a.line_to((l.x, zero)); + a.line_to((f.x, zero)); + a.close_path(); + } + ctx.set_paint(color(col, 0.85)); + ctx.fill_path(&a); + } else if kind != ChartType::Scatter { + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&p); + } + if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { + ctx.set_paint(color(col, 1.0)); + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); } } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7fd3db5..800f71a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -125,6 +125,36 @@ fn effects_tables_charts_render() { assert!(px[0] < 240 && px[0] == px[2], "{px:?}"); } +#[test] +fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { + // Columns in the hundreds of thousands, a green line in the thousands on the secondary axis: + // the line must sit up in the plot, not flat along the bottom of the primary scale. + let mut c = Chart::sample(ChartType::Combo); + c.title = None; + c.legend = None; + c.series.truncate(2); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].kind = Some(ChartType::Column); + c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)]; + c.series[1].secondary_axis = true; + c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0)); + let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let green = |x: u32, y0: u32, y1: u32| { + (y0..y1).any(|y| { + let p = img.pixel(x, y); + p[1] > 120 && p[0] < 80 && p[2] < 140 + }) + }; + // The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom. + assert!(green(480, 20, 270), "line not in the upper half"); + assert!(!green(480, 400, 500), "line along the bottom"); + let col = img.pixel(480, 480); + assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default();