Chart text colour is written to the chart part, so an imported chart keeps it

`Chart::text_color` was read from a .pptx (the axes', legend's or chart's
`c:txPr`) but never written, and any edit drops the kept part and rebuilds
it from the model, so a save after an edit lost the colour. The writer now
puts it where PowerPoint keeps it: `c:txPr` on both axes, the legend, the
data labels and the chart root, and the run defaults of the title's rich
text (chart and axis titles). Nothing is written without a colour, so the
theme default still applies on reading.

Round-trip tests: a scheme colour with a lumMod, an RGB colour and no
colour come back as set; a chart read with a `tx1` 65 %/35 % colour on its
axis keeps it through a title edit and a rebuild of the part.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Zach Berry
2026-10-23 19:57:50 -05:00
co-authored by Claude Fable 5.1
parent 30b7eff487
commit 3ac39f9bb6
2 changed files with 108 additions and 10 deletions
+107 -10
View File
@@ -58,14 +58,54 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) {
w.close("c:spPr");
}
fn rich(w: &mut W, text: &str) {
/// `a:defRPr` carrying the chart's text colour, where PowerPoint keeps it: the paragraph
/// defaults of a title's rich text and of every `c:txPr`.
fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>) {
match color {
Some(c) => {
w.open0("a:defRPr");
super::dml::solid(w, c);
w.close("a:defRPr");
}
None => w.empty0("a:defRPr"),
}
}
/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on
/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing
/// without a colour: the theme's default then applies on reading.
fn tx_pr(w: &mut W, c: &Chart) {
let Some(color) = &c.text_color else { return };
w.open0("c:txPr");
w.empty0("a:bodyPr");
w.empty0("a:lstStyle");
w.open0("a:p");
w.open0("a:pPr");
def_rpr(w, Some(color));
w.close("a:pPr");
w.empty("a:endParaRPr", A::new().a("lang", "en-US"));
w.close("a:p");
w.close("c:txPr");
}
fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {
w.open0("c:tx");
w.open0("c:rich");
w.empty0("a:bodyPr");
w.empty0("a:lstStyle");
for line in text.split('\n') {
w.open0("a:p");
if color.is_some() {
w.open0("a:pPr");
def_rpr(w, color);
w.close("a:pPr");
}
w.open0("a:r");
if let Some(c) = color {
w.open("a:rPr", A::new().a("lang", "en-US"));
super::dml::solid(w, c);
w.close("a:rPr");
}
w.elt("a:t", line);
w.close("a:r");
w.close("a:p");
@@ -74,9 +114,10 @@ fn rich(w: &mut W, text: &str) {
w.close("c:tx");
}
fn title(w: &mut W, text: &str) {
/// A title (the chart's or an axis's) in the chart's text colour.
fn title(w: &mut W, text: &str, c: &Chart) {
w.open0("c:title");
rich(w, text);
rich(w, text, c.text_color.as_ref());
w.val("c:overlay", 0);
w.close("c:title");
}
@@ -217,7 +258,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
}
}
if let Some(d) = s.data_labels {
data_labels(w, d);
data_labels(w, c, d);
}
// After labels, before the category and value caches (ECMA-376 series content order).
if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) {
@@ -325,8 +366,9 @@ fn trendline(w: &mut W, t: &Trendline) {
w.close("c:trendline");
}
fn data_labels(w: &mut W, show: bool) {
fn data_labels(w: &mut W, c: &Chart, show: bool) {
w.open0("c:dLbls");
tx_pr(w, c);
for (n, v) in [
("c:showLegendKey", false),
("c:showVal", show),
@@ -355,12 +397,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.val("c:delete", 0);
w.val("c:axPos", "b");
if let Some(t) = &c.axis_titles.0 {
title(w, t);
title(w, t, c);
}
w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
w.val("c:majorTickMark", "out");
w.val("c:minorTickMark", "none");
w.val("c:tickLblPos", "nextTo");
tx_pr(w, c);
w.val("c:crossAx", AX_VAL);
w.val("c:crosses", "autoZero");
w.val("c:crossBetween", "midCat");
@@ -374,12 +417,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.val("c:delete", 0);
w.val("c:axPos", cat_pos);
if let Some(t) = &c.axis_titles.0 {
title(w, t);
title(w, t, c);
}
w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
w.val("c:majorTickMark", "none");
w.val("c:minorTickMark", "none");
w.val("c:tickLblPos", "nextTo");
tx_pr(w, c);
w.val("c:crossAx", AX_VAL);
w.val("c:crosses", "autoZero");
w.val("c:auto", 1);
@@ -411,7 +455,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.empty0("c:minorGridlines");
}
if let Some(t) = &c.axis_titles.1 {
title(w, t);
title(w, t, c);
}
match c.y_format.as_deref().filter(|f| !f.is_empty()) {
Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)),
@@ -420,6 +464,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.val("c:majorTickMark", "none");
w.val("c:minorTickMark", "none");
w.val("c:tickLblPos", "nextTo");
tx_pr(w, c);
w.val("c:crossAx", AX_CAT);
w.val("c:crosses", "autoZero");
w.val("c:crossBetween", if g == Group::Area || g == Group::Scatter { "midCat" } else { "between" });
@@ -468,7 +513,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
series(w, c, s, *i, kind, g);
}
if c.data_labels {
data_labels(w, true);
data_labels(w, c, true);
}
match g {
Group::Bar => {
@@ -576,7 +621,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
w.open0("c:chart");
match &c.title {
Some(t) => {
title(&mut w, t);
title(&mut w, t, c);
w.val("c:autoTitleDeleted", 0);
}
None => w.val("c:autoTitleDeleted", 1),
@@ -654,6 +699,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
w.open0("c:legend");
w.val("c:legendPos", p);
w.val("c:overlay", u8::from(c.legend_overlay));
tx_pr(&mut w, c);
w.close("c:legend");
}
w.val("c:plotVisOnly", 1);
@@ -662,6 +708,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
if let Some(f) = &c.chart_fill {
sp_pr(&mut w, Some(f), None);
}
tx_pr(&mut w, c);
w.close("c:chartSpace");
(w.finish(), false)
}
@@ -971,8 +1018,58 @@ mod region_map_tests {
#[cfg(test)]
mod tests {
use deckcraft_color::{ColorTransform, Rgba};
use deckcraft_model::ColorRef;
use deckcraft_model::chart::{Chart, ChartType};
/// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend,
/// data labels and chart, the title's run defaults) and read back as the same colour.
#[test]
fn chart_text_colour_round_trips() {
let mut c = Chart::sample(ChartType::Column);
c.title = Some("Revenue".into());
c.legend = Some("b".into());
c.data_labels = true;
c.axis_titles = (Some("Quarter".into()), Some("EUR".into()));
let tinted = ColorRef::scheme(deckcraft_model::SchemeSlot::Accent2).with(ColorTransform::LumMod(75000));
c.text_color = Some(tinted.clone());
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
// Axes (2), legend, data labels and the chart root; a title keeps it in its rich text.
assert!(xml.matches("<c:txPr>").count() >= 5, "{xml}");
assert!(xml.contains(r#"<a:schemeClr val="accent2"><a:lumMod val="75000"/></a:schemeClr>"#), "{xml}");
assert!(xml.contains("<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr><a:solidFill>"), "{xml}");
assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, Some(tinted));
// An RGB colour and no colour at all round-trip too.
c.text_color = Some(ColorRef::rgb(Rgba::rgb(255, 255, 255)));
let xml = super::chart_xml(&c).0;
assert_eq!(crate::read::chart::read_chart_xml(&xml).unwrap().text_color, c.text_color);
c.text_color = None;
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
assert!(!xml.contains("<c:txPr>"), "{xml}");
assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, None);
}
/// An imported chart's `c:txPr` colour survives an edit that rebuilds the part from the model
/// (the colour used to be read but never written, so a save after any edit lost it).
#[test]
fn imported_text_colour_survives_an_edit_and_a_save() {
let xml = 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:barChart><c:barDir val="col"/><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:barChart><c:catAx><c:axId val="1"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:crossAx val="1"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
let c = crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap();
let grey = ColorRef::scheme(deckcraft_model::SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000));
assert_eq!(c.text_color, Some(grey.clone()));
// Unchanged: the part is written back as read.
assert!(super::chart_xml(&c).1);
// Edited (a title; the editor drops `raw` on any edit): rebuilt with the colour.
let mut e = c.clone();
e.title = Some("Units".into());
e.raw = None;
let (out, kept) = super::chart_xml(&e);
assert!(!kept);
let back = crate::read::chart::read_chart_xml(&out).unwrap();
assert_eq!(back.text_color, Some(grey));
assert_eq!(back.title.as_deref(), Some("Units"));
}
#[test]
fn reversed_category_axis_round_trips() {
let mut c = Chart::sample(ChartType::Bar);
+1
View File
@@ -124,6 +124,7 @@ the round trip even though DeckCraft doesn't model it.
| 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |
| 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) |
| 2026-10-16 | minor | PNG slide export with a transparent background (#784) |
| 2026-10-23 | minor | Chart text colour (`c:txPr` on the axes, legend, data labels and chart; the title's run defaults) is written as well as read, so an imported chart's colours survive an edit and a save; `chart.options {textColor}`, Font Color on a chart (#1015) |
| 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) |
| 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) |
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |