From cbc6cd0e347e6a7af3e67a7a42d7f3d9d8481b3b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:03:16 +0300 Subject: [PATCH 1/2] Charts: draw data-point fills on columns and bars, smoothed lines, and the file's chart text colour --- crates/pptx/src/read/chart.rs | 31 ++++++++++++++++++++++ crates/render/src/chart.rs | 48 ++++++++++++++++++++++++++++------- crates/render/src/tests.rs | 24 ++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..0045e4a5 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -281,10 +281,33 @@ pub(crate) fn chart_from(root: &El) -> Option { // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size); + // Text colour likewise: the axes' and legend's, else the whole chart's. + c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); Some(c) } +/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`. +fn text_color(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is("defRPr") || c.is("rPr")) + && let Some(Fill::Solid { color }) = simple_fill(Some(c)) + { + return Some(color); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + /// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths). fn text_size(e: Option<&El>) -> Option { fn rec(e: &El, depth: usize) -> Option { @@ -363,6 +386,14 @@ mod tests { assert!(c.series.iter().all(|s| !s.secondary_axis)); } + #[test] + fn text_colour_is_read_from_the_axes() { + // LibreOffice's templates set light axis text for dark slides on the axes' c:txPr. + let xml = r#"1"#; + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + } + #[test] fn text_sizes_are_read_from_the_file() { // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..bbdc2330 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -36,6 +36,43 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart } } +/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'. +fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba { + match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) { + Some(Fill::Solid { color: cc }) => ctx.color(cc, None), + _ => series_fill(ctx, c, i, Some(s)), + } +} + +fn polyline(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + for (k, q) in pts.iter().enumerate() { + if k == 0 { + p.move_to(*q); + } else { + p.line_to(*q); + } + } + p +} + +/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve +/// PowerPoint draws, passing through every point. +pub(crate) fn smooth_path(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + let Some(first) = pts.first() else { return p }; + p.move_to(*first); + for k in 0..pts.len() - 1 { + let p0 = pts[k.saturating_sub(1)]; + let (p1, p2) = (pts[k], pts[k + 1]); + let p3 = pts[(k + 2).min(pts.len() - 1)]; + let c1 = p1 + (p2 - p0) / 6.0; + let c2 = p2 - (p3 - p1) / 6.0; + p.curve_to(c1, c2, p2); + } + p +} + /// 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 { @@ -418,7 +455,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h 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(point_fill(rctx, c, *si, s, i), 1.0)); ctx.fill_rect(&r.abs()); if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); @@ -480,14 +517,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h 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); - } - } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; ctx.set_transform(m); if area_kind { let mut a = p.clone(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..232352b4 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -155,6 +155,30 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); } +#[test] +fn column_points_take_their_own_fill_and_smooth_lines_curve() { + // One column series where only the middle point is given a red c:dPt fill. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + 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() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]); + assert!(red(480), "the middle column takes its point's fill"); + assert!(!red(200) && !red(760), "the others keep the series colour"); + + // A smoothed line through a peak overshoots no point but leaves the straight chord. + let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)]; + let p = crate::chart::smooth_path(&pts); + assert_eq!(p.elements().len(), 3, "a cubic per segment"); + let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5); + assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}"); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); From 8e124d32a869a5831de716e3221ad1685415f3db Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:09:16 +0300 Subject: [PATCH 2/2] Charts: clustered bar widths from gap width and overlap as fractions of one bar; schema defaults when absent --- crates/pptx/src/read/chart.rs | 9 +++++++++ crates/render/src/chart.rs | 11 ++++++++--- crates/render/src/tests.rs | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 0045e4a5..2f306858 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -173,11 +173,18 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(v) = g.child_val("varyColors") { c.vary_colors = v == "1" || v == "true"; } + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; } if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; } if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { c.hole_size = (v / 100.0).clamp(0.0, 0.9); @@ -392,6 +399,8 @@ mod tests { let xml = r#"1"#; let c = read_chart_xml(xml.as_bytes()).unwrap(); assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. + assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); } #[test] diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index bbdc2330..9b394f71 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -420,10 +420,15 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h 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); + // Gap width and overlap are fractions of one bar's width: a category's band holds its bars + // (overlapping by `overlap` of a bar between neighbours) plus one gap. let gap = c.gap_width.clamp(0.0, 5.0); - let group = band / (1.0 + gap); let stacked = bk.stacked(); - let bar = if stacked { group } else { group / ns as f64 }; + let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) }; + let n = ns as f64; + let bar = band / (n - (n - 1.0) * ov + gap); + let step = bar * (1.0 - ov); + let group = bar + step * (n - 1.0); for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; @@ -435,7 +440,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h 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 * bi as f64 }; + let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; let (a, b) = if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 232352b4..8409ca5c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -171,6 +171,25 @@ fn column_points_take_their_own_fill_and_smooth_lines_curve() { assert!(red(480), "the middle column takes its point's fill"); assert!(!red(200) && !red(760), "the others keep the series colour"); + // Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and + // LibreOffice's geometry), not band / 2.5 split in two. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(2); + c.categories = vec!["A".into()]; + c.series[0].values = vec![Some(10.0)]; + c.series[1].values = vec![Some(10.0)]; + c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + (c.gap_width, c.overlap) = (1.5, 0.0); + 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() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5; + let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64; + // The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band + // was split as 1 + gap and then shared between the series). + assert!((200.0..280.0).contains(&reds), "bar {reds} px"); + // A smoothed line through a peak overshoots no point but leaves the straight chord. let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)]; let p = crate::chart::smooth_path(&pts);