From b585027de2a50ea27b721c89beaa4de2da13f8a4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:25:27 +0200 Subject: [PATCH] Chart legend: honour legendPos and overlay in the renderer (#262) --- crates/model/src/chart.rs | 3 + crates/pptx/src/read/chart.rs | 19 +++++++ crates/pptx/src/write/chart.rs | 2 +- crates/render/src/chart.rs | 101 ++++++++++++++++++++++++++++++--- 4 files changed, 115 insertions(+), 10 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 5ba72fc..36b92db 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -144,6 +144,8 @@ pub struct Chart { pub categories: Vec, pub series: Vec, pub legend: Option, + /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. + pub legend_overlay: bool, pub data_labels: bool, pub gridlines: bool, pub axis_titles: (Option, Option), @@ -172,6 +174,7 @@ impl Default for Chart { categories: vec![], series: vec![], legend: Some("b".into()), + legend_overlay: false, data_labels: false, gridlines: true, axis_titles: (None, None), diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index a9a960a..a7b7c7e 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -97,6 +97,7 @@ pub(crate) fn chart_from(root: &El) -> Option { c.title = None; } c.legend = ch.child("legend").map(|l| l.child_val("legendPos").unwrap_or("r").to_string()); + c.legend_overlay = ch.child("legend").and_then(|l| l.child("overlay")).and_then(|o| o.bool("val")).unwrap_or(false); c.chart_fill = simple_fill(root.child("spPr")); c.plot_fill = simple_fill(pa.child("spPr")); @@ -225,3 +226,21 @@ pub(crate) fn chart_from(root: &El) -> Option { } Some(c) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legend_position_and_overlay_are_read() { + let legend = r#""#; + let xml = format!(r#"{legend}"#); + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.legend.as_deref(), Some("r")); + assert!(c.legend_overlay); + let xml = br#""#; + let c = read_chart_xml(xml).unwrap(); + assert_eq!(c.legend.as_deref(), Some("r")); + assert!(!c.legend_overlay); + } +} diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index d832c34..76567c9 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -467,7 +467,7 @@ pub fn chart_xml(c: &Chart) -> Vec { }; w.open0("c:legend"); w.val("c:legendPos", p); - w.val("c:overlay", 0); + w.val("c:overlay", u8::from(c.legend_overlay)); w.close("c:legend"); } w.val("c:plotVisOnly", 1); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index a29b202..f6cf82d 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -63,6 +63,42 @@ fn fmt_num(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") } } +/// Where the legend goes for `c:legendPos`, and the plot area left over. +/// `size` is the legend block (width, height); the result is its top-left corner and the plot area. +/// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred +/// on the area, `tr` is a vertical stack in the top-right corner, `t` and `b` are centred rows. +/// An overlaid legend leaves the plot area as it was. +fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bool) -> (Point, Rect) { + let (lw, lh) = size; + let gap = base * 0.5; + let cx = (area.x0 + area.x1) / 2.0; + let cy = ((area.y0 + area.y1) / 2.0 - lh / 2.0).max(area.y0); + let mut plot = area; + let origin = match pos { + "b" => { + plot.y1 = area.y1 - lh - gap; + Point::new(cx - lw / 2.0, area.y1 - lh) + } + "t" => { + plot.y0 = area.y0 + lh + gap; + Point::new(cx - lw / 2.0, area.y0) + } + "l" => { + plot.x0 = area.x0 + lw + gap; + Point::new(area.x0, cy) + } + "tr" => { + plot.x1 = area.x1 - lw - gap; + Point::new(area.x1 - lw, area.y0) + } + _ => { + plot.x1 = area.x1 - lw - gap; + Point::new(area.x1 - lw, cy) + } + }; + (origin, if overlay { area } else { plot }) +} + pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { 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); @@ -87,25 +123,35 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h area.y0 += base * 2.4; } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); - // Legend at the bottom. + // Legend placed by `c:legendPos`. 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() }; - if c.legend.is_some() && !legend_items.is_empty() { - let ly = area.y1 - base * 1.4; - let item_w = (legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).sum::()).min(w * 0.9); - let mut x = (w - item_w) / 2.0; + if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { + let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); + let item_ws: Vec = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let size = if vertical { + (item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5) + } else { + (item_ws.iter().sum::().min(w * 0.9), base * 1.4) + }; + let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); + area = plot_area; + let (mut x, mut y) = (origin.x, origin.y); for (name, col) in &legend_items { ctx.set_transform(m); ctx.set_paint(color(*col, 1.0)); - ctx.fill_rect(&Rect::new(x, ly + base * 0.3, x + base * 0.7, ly + base)); + ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base)); let tw = name.chars().count() as f64 * base * 0.55 + base; - label(ctx, rctx, m, name, Rect::new(x + base, ly, x + base + tw, ly + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); - x += tw + base * 0.6; + label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); + if vertical { + y += base * 1.5; + } else { + x += tw + base * 0.6; + } } - area.y1 = ly - base * 0.5; } if pie { let Some(s) = c.series.first() else { return }; @@ -392,3 +438,40 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.set_stroke(kurbo::Stroke::new(0.75)); ctx.stroke_path(&ax); } + +#[cfg(test)] +mod tests { + use super::*; + + const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0); + + #[test] + fn right_legend_is_a_centred_column_and_narrows_the_plot() { + let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false); + assert_eq!((o.x, o.y), (340.0, 100.0)); + assert_eq!((plot.x0, plot.x1, plot.y0, plot.y1), (0.0, 335.0, 0.0, 300.0)); + } + + #[test] + fn left_top_and_corner_positions() { + let (o, plot) = legend_layout("l", (60.0, 100.0), AREA, 10.0, false); + assert_eq!((o.x, o.y, plot.x0, plot.x1), (0.0, 100.0, 65.0, 400.0)); + let (o, plot) = legend_layout("t", (100.0, 14.0), AREA, 10.0, false); + assert_eq!((o.x, o.y, plot.y0, plot.y1), (150.0, 0.0, 19.0, 300.0)); + let (o, plot) = legend_layout("tr", (60.0, 100.0), AREA, 10.0, false); + assert_eq!((o.x, o.y, plot.x1), (340.0, 0.0, 335.0)); + } + + #[test] + fn bottom_legend_keeps_the_old_row_layout() { + let (o, plot) = legend_layout("b", (100.0, 14.0), AREA, 10.0, false); + assert_eq!((o.x, o.y, plot.y1, plot.x1), (150.0, 286.0, 281.0, 400.0)); + } + + #[test] + fn overlay_does_not_shrink_the_plot() { + let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, true); + assert_eq!(plot, AREA); + assert_eq!(o.x, 340.0); + } +}