Charts: legend entries keep their own text size, weight and colour

A c:legendEntry can give one legend entry its own text (c:txPr), as
decks do to make legend text white on a dark chart. It was never read,
so every entry drew in the legend's text. The chart model keeps each
entry's size, bold and colour (Chart::legend_entries); the reader
takes them from the entry's c:txPr, the legend draws each name, key
and row at its entry's size and in its weight and colour, and a chart
rebuilt after an edit writes them back before c:overlay, in schema
order. Entries that set none of the three are not kept.

Fixes #2067
This commit is contained in:
Hamza
2026-11-12 12:20:32 +05:00
parent 6849fa0149
commit 1d608bb464
5 changed files with 195 additions and 11 deletions
+20
View File
@@ -324,6 +324,10 @@ pub struct Chart {
pub legend: Option<String>,
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
pub legend_overlay: bool,
/// `c:legend/c:legendEntry`: entries whose text has its own size, weight or colour, over
/// the legend's.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub legend_entries: Vec<LegendEntry>,
/// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's);
/// `None`: the file gives none.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -401,6 +405,21 @@ pub struct Chart {
pub kept: ChartKept,
}
/// One legend entry's own text (`c:legendEntry/c:txPr`): what it sets wins over the legend's.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LegendEntry {
/// The entry's place in the legend, from 0 (`c:idx`).
pub idx: u32,
/// Text size in points.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bold: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<ColorRef>,
}
/// One relationship of a chart part, with the target string as it was written.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -453,6 +472,7 @@ impl Default for Chart {
series: vec![],
legend: Some("b".into()),
legend_overlay: false,
legend_entries: vec![],
text_size: None,
title_size: None,
data_labels: false,
+41
View File
@@ -190,6 +190,23 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
}
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);
// An entry's own text (`c:legendEntry/c:txPr`) over the legend's, as for any text.
c.legend_entries = ch
.child("legend")
.into_iter()
.flat_map(|l| l.children_named("legendEntry"))
.take(MAX_LEGEND_ENTRIES)
.filter_map(|e| {
let tx = e.child("txPr");
let entry = deckcraft_model::chart::LegendEntry {
idx: e.child("idx").and_then(|i| i.u32("val"))?,
size: text_size(tx),
bold: text_bold(tx),
color: text_color(tx),
};
(entry.size.is_some() || entry.bold.is_some() || entry.color.is_some()).then_some(entry)
})
.collect();
c.chart_fill = simple_fill(root.child("spPr"));
c.plot_fill = simple_fill(pa.child("spPr"));
@@ -657,6 +674,30 @@ fn copy_part(
true
}
/// Legend entries read from one chart; a legend lists at most one per series or category.
const MAX_LEGEND_ENTRIES: usize = 1024;
/// The weight (`b`) of the first `a:defRPr` or `a:rPr` that sets one under `e`.
fn text_bold(e: Option<&El>) -> Option<bool> {
fn rec(e: &El, depth: usize) -> Option<bool> {
if depth > 8 {
return None;
}
for c in e.elements() {
if (c.is("defRPr") || c.is("rPr"))
&& let Some(b) = c.bool("b")
{
return Some(b);
}
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<f64> {
fn rec(e: &El, depth: usize) -> Option<f64> {
+74
View File
@@ -98,6 +98,37 @@ fn tx_pr(w: &mut W, c: &Chart) {
w.close("c:txPr");
}
/// `c:legendEntry` with the entry's own text size, weight and colour.
fn legend_entry(w: &mut W, e: &deckcraft_model::chart::LegendEntry) {
w.open0("c:legendEntry");
w.val("c:idx", e.idx);
w.open0("c:txPr");
w.empty0("a:bodyPr");
w.empty0("a:lstStyle");
w.open0("a:p");
w.open0("a:pPr");
let mut a = A::new();
if let Some(sz) = e.size.filter(|v| v.is_finite() && *v > 0.0) {
a = a.a("sz", ((sz * 100.0).round().clamp(100.0, 400_000.0) as u32).to_string());
}
if let Some(b) = e.bold {
a = a.a("b", if b { "1" } else { "0" });
}
match &e.color {
Some(c) => {
w.open("a:defRPr", a);
super::dml::solid(w, c);
w.close("a:defRPr");
}
None => w.empty("a:defRPr", a),
}
w.close("a:pPr");
w.empty("a:endParaRPr", A::new().a("lang", "en-US"));
w.close("a:p");
w.close("c:txPr");
w.close("c:legendEntry");
}
fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {
w.open0("c:tx");
w.open0("c:rich");
@@ -776,6 +807,9 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
};
w.open0("c:legend");
w.val("c:legendPos", p);
for e in &c.legend_entries {
legend_entry(&mut w, e);
}
w.val("c:overlay", u8::from(c.legend_overlay));
tx_pr(&mut w, c);
w.close("c:legend");
@@ -1171,6 +1205,46 @@ mod tests {
assert_eq!(back.title.as_deref(), Some("Units"));
}
/// #2067: a legend entry's own text (`c:legendEntry/c:txPr`: size, bold, colour) is read, and
/// an edit that rebuilds the part writes it back, in schema order before `c:overlay`.
#[test]
fn legend_entry_text_is_read_and_survives_an_edit() {
let entry = |idx: u32, rgb: &str| {
format!(
r#"<c:legendEntry><c:idx val="{idx}"/><c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="2000" b="1"><a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill></a:defRPr></a:pPr><a:endParaRPr lang="en-US"/></a:p></c:txPr></c:legendEntry>"#
)
};
let xml = format!(
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:crossAx val="1"/></c:valAx></c:plotArea><c:legend><c:legendPos val="r"/>{}{}<c:legendEntry><c:idx val="2"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr/></a:pPr></a:p></c:txPr></c:legendEntry><c:overlay val="0"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1200"><a:solidFill><a:srgbClr val="7F7F7F"/></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr></c:legend></c:chart></c:chartSpace>"#,
entry(0, "C00000"),
entry(1, "00B050")
);
let c = crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap();
let want = |idx: u32, rgb: (u8, u8, u8)| deckcraft_model::chart::LegendEntry {
idx,
size: Some(20.0),
bold: Some(true),
color: Some(ColorRef::rgb(Rgba::rgb(rgb.0, rgb.1, rgb.2))),
};
// The third entry sets nothing of its own and is not kept.
assert_eq!(c.legend_entries, vec![want(0, (0xC0, 0, 0)), want(1, (0, 0xB0, 0x50))]);
assert_eq!(c.text_size, Some(12.0), "the legend's own size stays the legend's");
let mut e = c.clone();
e.title = Some("Units".into());
e.raw = None;
let (out, kept) = super::chart_xml(&e);
assert!(!kept);
let s = String::from_utf8(out.clone()).unwrap();
let legend = &s[s.find("<c:legend>").unwrap()..];
let (pos, entry0, overlay) =
(legend.find("<c:legendPos").unwrap(), legend.find("<c:legendEntry>").unwrap(), legend.find("<c:overlay").unwrap());
assert!(pos < entry0 && entry0 < overlay, "{s}");
assert_eq!(crate::read::chart::read_chart_xml(&out).unwrap().legend_entries, c.legend_entries);
// None: nothing written.
e.legend_entries.clear();
assert!(!String::from_utf8(super::chart_xml(&e).0).unwrap().contains("legendEntry"));
}
#[test]
fn reversed_category_axis_round_trips() {
let mut c = Chart::sample(ChartType::Bar);
+23 -11
View File
@@ -537,37 +537,49 @@ fn draw_legend(ctx: &mut RenderContext, draw: &LegendDraw<'_, '_>, mut area: Rec
let width = draw.width;
let Some(pos) = c.legend.as_ref().filter(|_| !legend_items.is_empty()) else { return area };
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();
// Each entry's text: its own size, weight and colour (`c:legendEntry`) over the legend's.
let own = |i: usize| c.legend_entries.iter().find(|e| usize::try_from(e.idx).ok() == Some(i));
let styles: Vec<(f64, bool, Rgba)> = (0..legend_items.len())
.map(|i| {
let e = own(i);
let size = e.and_then(|e| e.size).filter(|v| v.is_finite() && *v > 0.0).map_or(base, |v| v.min(base * 8.0));
let col = e.and_then(|e| e.color.as_ref()).map_or(text_col, |cr| rctx.color(cr, None));
(size, e.and_then(|e| e.bold).unwrap_or(false), col)
})
.collect();
let item_ws: Vec<f64> = legend_items.iter().zip(&styles).map(|((n, _, _), (sz, _, _))| n.chars().count() as f64 * sz * 0.55 + sz * 1.6).collect();
let tallest = styles.iter().map(|s| s.0).fold(base, f64::max);
let size = if vertical {
(item_ws.iter().copied().fold(0.0, f64::max).min(width * 0.4), legend_items.len() as f64 * base * 1.5)
(item_ws.iter().copied().fold(0.0, f64::max).min(width * 0.4), styles.iter().map(|s| s.0 * 1.5).sum::<f64>())
} else {
(item_ws.iter().sum::<f64>().min(width * 0.9), base * 1.4)
(item_ws.iter().sum::<f64>().min(width * 0.9), tallest * 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, paint, outline) in legend_items {
let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base);
for ((name, paint, outline), (sz, bold, col)) in legend_items.iter().zip(&styles) {
let (sz, bold, col) = (*sz, *bold, *col);
let key = Rect::new(x, y + sz * 0.3, x + sz * 0.7, y + sz);
fill_with(ctx, paint, key, m);
if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) {
ctx.set_transform(m);
ctx.set_paint(color(oc, 1.0));
ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2)));
ctx.set_stroke(kurbo::Stroke::new(ow.min(sz * 0.2)));
ctx.stroke_path(&key.to_path(0.1));
}
let tw = name.chars().count() as f64 * base * 0.55 + base;
let tw = name.chars().count() as f64 * sz * 0.55 + sz;
label(
ctx,
rctx,
m,
name,
Rect::new(x + base, y, x + base + tw, y + base * 1.3),
LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false },
Rect::new(x + sz, y, x + sz + tw, y + sz * 1.3),
LabelStyle { size: sz, align: Align::Left, anchor: Anchor::Middle, col, bold },
);
if vertical {
y += base * 1.5;
y += sz * 1.5;
} else {
x += tw + base * 0.6;
x += tw + sz * 0.6;
}
}
area
+37
View File
@@ -2127,6 +2127,43 @@ fn group_child_scale_does_not_scale_line_widths() {
}
}
/// #2067: a legend entry with its own text colour, size and weight (`c:legendEntry/c:txPr`) draws
/// its name so; the other entries keep the legend's.
#[test]
fn a_legend_entry_draws_its_own_text_colour_and_size() {
let draw = |entry: Option<deckcraft_model::chart::LegendEntry>| {
let mut c = Chart::sample(ChartType::Line);
c.title = None;
c.legend = Some("r".into());
c.text_size = Some(10.0);
c.text_color = Some(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128)));
c.legend_entries = entry.into_iter().collect();
let sh = Shape { id: ShapeId(634), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 300.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let green = |img: &Image| {
(0..480u32)
.flat_map(|x| (0..300u32).map(move |y| (x, y)))
.filter(|(x, y)| {
let p = img.pixel(*x, *y);
p[1] > 150 && p[0] < 80 && p[2] < 80
})
.count()
};
let entry = |size: Option<f64>| deckcraft_model::chart::LegendEntry {
idx: 0,
size,
bold: Some(true),
color: Some(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 200, 0))),
};
let none = green(&draw(None));
let own = green(&draw(Some(entry(None))));
let big = green(&draw(Some(entry(Some(20.0)))));
assert_eq!(none, 0, "no green in a chart without the entry");
assert!(own > 40, "the first entry's name is drawn in its own colour ({own})");
assert!(big > own * 2, "and at its own size ({own} at 10 pt, {big} at 20 pt)");
}
#[test]
fn a_picture_and_an_embedded_objects_preview_draw_their_fill_behind_transparent_pixels() {
// PowerPoint draws a picture's own fill behind its transparent pixels (and an old equation