Chart legend: honour legendPos and overlay in the renderer (#262)

This commit is contained in:
MarekSpodBiedry 2026-10-12 22:25:27 +02:00 committed by Brandon Thomas
parent 1660982e58
commit b585027de2
4 changed files with 115 additions and 10 deletions

View File

@ -144,6 +144,8 @@ pub struct Chart {
pub categories: Vec<String>,
pub series: Vec<Series>,
pub legend: Option<String>,
/// `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<String>, Option<String>),
@ -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),

View File

@ -97,6 +97,7 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
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<Chart> {
}
Some(c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legend_position_and_overlay_are_read() {
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
let xml = format!(r#"<c:chartSpace xmlns:c="c"><c:chart><c:plotArea/>{legend}</c:chart></c:chartSpace>"#);
let c = read_chart_xml(xml.as_bytes()).unwrap();
assert_eq!(c.legend.as_deref(), Some("r"));
assert!(c.legend_overlay);
let xml = br#"<c:chartSpace xmlns:c="c"><c:chart><c:plotArea/><c:legend/></c:chart></c:chartSpace>"#;
let c = read_chart_xml(xml).unwrap();
assert_eq!(c.legend.as_deref(), Some("r"));
assert!(!c.legend_overlay);
}
}

View File

@ -467,7 +467,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
};
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);

View File

@ -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::<f64>()).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<f64> = 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::<f64>().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);
}
}