diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index c93d12f25..06f542083 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -324,6 +324,10 @@ pub struct Chart { pub legend: Option, /// `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, /// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub bold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, +} + /// 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, diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index a175d4845..0608171b0 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -190,6 +190,23 @@ fn chart_within(root: &El, mut budget: usize) -> Option { } 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 { + 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(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 { fn rec(e: &El, depth: usize) -> Option { diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 8b4b7927d..7976ec50e 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -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, 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#""# + ) + }; + let xml = format!( + r#"1{}{}"#, + 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("").unwrap()..]; + let (pos, entry0, overlay) = + (legend.find("").unwrap(), legend.find(", 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 = 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 = 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::()) } else { - (item_ws.iter().sum::().min(width * 0.9), base * 1.4) + (item_ws.iter().sum::().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 diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 6bbed810b..e5cd2319e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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| { + 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| 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