From 71b02327cfdfdaf337fa1f582be340a5898b4f36 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:16:35 +0300 Subject: [PATCH] Charts: category axis orientation (maxMin) read, written and drawn; horizontal bars run bottom-up --- crates/model/src/chart.rs | 4 ++++ crates/pptx/src/read/chart.rs | 8 ++++++++ crates/pptx/src/read/mod.rs | 2 +- crates/pptx/src/write/chart.rs | 18 +++++++++++++++++- crates/render/src/chart.rs | 12 ++++++++---- crates/render/src/tests.rs | 20 ++++++++++++++++++++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..49f16c63 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -178,6 +178,9 @@ pub struct Chart { pub y_format: Option, /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, /// A combo chart's secondary value axis: bounds, major unit and number format. pub y2_min: Option, pub y2_max: Option, @@ -219,6 +222,7 @@ impl Default for Chart { y_major: None, y_format: None, y_hidden: false, + cat_reversed: false, y2_min: None, y2_max: None, y2_major: None, diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 2f306858..63ee3125 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -282,6 +282,7 @@ pub(crate) fn chart_from(root: &El) -> Option { c.y_hidden = deleted(v); } if let Some(a) = cat_ax { + c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to @@ -399,6 +400,13 @@ 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)))); + assert!(!c.cat_reversed); + let rev = read_chart_xml( + xml.replace("", "") + .as_bytes(), + ) + .unwrap(); + assert!(rev.cat_reversed, "maxMin reverses the category axis"); // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); } diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..9a8b305d 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -1,6 +1,6 @@ //! PPTX import: package → [`Presentation`]. -mod chart; +pub(crate) mod chart; pub(crate) mod dml; mod shapes; mod theme; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..b1125d2c 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -251,7 +251,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:catAx"); w.val("c:axId", AX_CAT); w.open0("c:scaling"); - w.val("c:orientation", "minMax"); + w.val("c:orientation", if c.cat_reversed { "maxMin" } else { "minMax" }); w.close("c:scaling"); w.val("c:delete", 0); w.val("c:axPos", cat_pos); @@ -479,3 +479,19 @@ pub fn chart_xml(c: &Chart) -> Vec { w.close("c:chartSpace"); w.finish() } + +#[cfg(test)] +mod tests { + use deckcraft_model::chart::{Chart, ChartType}; + + #[test] + fn reversed_category_axis_round_trips() { + let mut c = Chart::sample(ChartType::Bar); + c.cat_reversed = true; + let xml = super::chart_xml(&c); + assert!(String::from_utf8_lossy(&xml).contains(r#""#)); + assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed); + c.cat_reversed = false; + assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c)).unwrap().cat_reversed); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 9b394f71..20dae6b4 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -382,10 +382,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } 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 }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 } else { i as f64 }; // 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) }; + let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) }; if horiz { label( ctx, @@ -440,7 +444,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 { step * bi as f64 }; + let start = band * slot(i) + (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); @@ -513,9 +517,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h 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) } + if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (slot(i) + 0.5) } } - _ => plot.x0 + band * (i as f64 + 0.5), + _ => plot.x0 + band * (slot(i) + 0.5), }; pts.push(Point::new(x, to_axis(sec, v))); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 8409ca5c..1743f873 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -198,6 +198,26 @@ fn column_points_take_their_own_fill_and_smooth_lines_curve() { assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}"); } +#[test] +fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() { + let draw = |reversed: bool| { + let mut c = Chart::sample(ChartType::Bar); + 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![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + c.cat_reversed = reversed; + let sh = Shape { id: ShapeId(623), 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 rows: Vec = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect(); + (rows[0] + rows[rows.len() - 1]) / 2 + }; + assert!(draw(false) > 360, "first category at the bottom: {}", draw(false)); + assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true)); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default();