mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-13 09:22:40 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user