From a48a2d706a79437a4a3e1217a51f21ac35d7543e Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:52:27 -0500 Subject: [PATCH 1/7] chart.options takes textColor so a chart's text can be coloured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a dark theme a chart's title, axis labels, legend and data labels render in a fixed grey and nothing could change them, though the model already carries `text_color`. `chart.options {textColor}` now sets it (#RRGGBB, accent1…accent6, tx1…, or null for the default) and refuses a string that isn't a colour. docs/mcp.md mentions it. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/insert.rs | 13 ++++++++++++- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ docs/mcp.md | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 149957df..901cb01e 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for the theme's text colour), id?}", has_selection, chart_options ), @@ -1771,7 +1771,18 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { // Waterfall total points. let subtotals: Option> = p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); + // Colour of the chart's text (title, axes, legend, data labels); null goes back to the theme's. + let text_color = match p.get("textColor") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => { + Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) + } + }; with_chart(s, p, "chart.options", |c| { + if let Some(t) = &text_color { + c.text_color = t.clone(); + } if let Some(n) = bin_count { c.bin_count = n; if n.is_some() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab39..041b221f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4403,3 +4403,28 @@ fn duplicating_a_moved_copy_repeats_the_move() { assert_eq!(boxed(&mut s, f), (340.0 + off, 100.0 + off)); assert_eq!(s.doc().unwrap().doc.validate(), Vec::::new()); } + +fn chart_of(s: &Session, id: u64) -> deckcraft_model::Chart { + match &s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().kind { + deckcraft_model::ShapeKind::Chart(c) => (**c).clone(), + k => panic!("not a chart: {k:?}"), + } +} + +/// A chart's text (title, axes, legend, labels) can be coloured: `chart.options {textColor}` sets +/// it, `null` goes back to the default, and a colour that isn't one is refused. +#[test] +fn chart_options_text_color_sets_clears_and_refuses() { + let mut s = session(); + let id = s.execute("insert.chart", &json!({"type": "column", "title": "Sales"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(chart_of(&s, id).text_color, None); + s.execute("chart.options", &json!({"id": id, "textColor": "#FF0000"})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + s.execute("chart.options", &json!({"id": id, "textColor": "accent2"})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); + let e = s.execute("chart.options", &json!({"id": id, "textColor": "reddish"})).unwrap_err().to_string(); + assert!(e.contains("textColor"), "{e}"); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)), "refused: unchanged"); + s.execute("chart.options", &json!({"id": id, "textColor": null})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, None); +} diff --git a/docs/mcp.md b/docs/mcp.md index a65fcda3..0b3b3a3d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -99,6 +99,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` +`chart.*` (`chart.options {textColor}` colours a chart's title, axes, legend and labels), `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` (GIF). `deckcraft-cli describe COMMAND` prints any command's parameters. From 1b03ae95defa4f7e3c7227952431eb0ede070daa Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:45 -0500 Subject: [PATCH 2/7] format.color on a selected chart colours the chart's text Font Color with a chart selected answered and changed nothing: a chart has no text runs to colour. It now sets the chart's text colour (title, axes, legend and data labels), the same thing chart.options {textColor} sets; a mixed selection still colours the other shapes' runs. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/format.rs | 23 ++++++++++++++++++++++- crates/engine/src/tests.rs | 10 ++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index b92cd85d..35a9906a 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -4,7 +4,7 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; -use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape}; +use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind}; use serde_json::{Value, json}; use super::text::{format_body, format_para, format_run}; @@ -340,6 +340,27 @@ fn shrink(s: &mut Session, _p: &Value) -> Result { } fn color(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; + // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels), + // which used to answer and change nothing. + let st = s.doc()?; + if st.selection.text.is_none() { + let charts: Vec = + st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect(); + let only_charts = charts.len() == st.selection.shapes.len(); + if !charts.is_empty() { + let tc = c.clone(); + edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.text_color = Some(tc.clone()); + ch.raw = None; + } + Ok(()) + })?; + if only_charts { + return ok(); + } + } + } format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) } /// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 041b221f..f8b48097 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4428,3 +4428,13 @@ fn chart_options_text_color_sets_clears_and_refuses() { s.execute("chart.options", &json!({"id": id, "textColor": null})).unwrap(); assert_eq!(chart_of(&s, id).text_color, None); } + +/// Font Color on a selected chart colours the chart's text (it used to answer and change nothing). +#[test] +fn format_color_on_a_selected_chart_sets_its_text_color() { + let mut s = session(); + let id = s.execute("insert.chart", &json!({"type": "column", "title": "Sales"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("format.color", &json!({"color": "accent2"})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); +} From be9bf48eb234877a5ef598795348cdfa04df8b32 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:57:13 -0500 Subject: [PATCH 3/7] Chart text without a colour of its own takes the theme's text colour A chart's title, axis labels, legend and data labels were drawn in a fixed grey (0x595959) whenever `text_color` was unset, which is near-invisible on a dark theme. The renderer now resolves the theme's tx1 instead, so a chart is readable by default on Nocturne as well as on Harbor; an explicit colour still wins. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/chart.rs | 4 +++- crates/render/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac185780..09338405 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -207,7 +207,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h #[allow(clippy::too_many_arguments)] fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { - let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); + // Text without a colour of its own takes the theme's text colour, so a chart on a dark + // theme is readable (a fixed grey used to vanish there). + let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&ColorRef::scheme(SchemeSlot::Tx1)), None); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { crate::paint::fill_path( diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b65098b0..baccc9dd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2717,3 +2717,33 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179)); assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } + +/// A chart that has no text colour of its own takes the theme's: light on Nocturne's dark slides +/// (it used to be a fixed grey, near-invisible there), dark on Harbor's light ones. +#[test] +fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { + let draw = |theme: &str, title: Option<&str>| { + let mut c = Chart::sample(ChartType::Column); + c.title = title.map(String::from); + c.legend = None; + c.data_labels = false; + let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let mut p = deck_with(vec![sh]); + let t = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == theme).expect("theme"); + std::sync::Arc::make_mut(&mut p.masters[0]).theme = t; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let lum = |px: [u8; 4]| (px[0] as u32 * 299 + px[1] as u32 * 587 + px[2] as u32 * 114) / 1000; + // Pixels in the title band that pass `f`. + let count = |img: &Image, f: &dyn Fn(u32) -> bool| { + (200..760u32).flat_map(|x| (4..70u32).map(move |y| (x, y))).filter(|(x, y)| f(lum(img.pixel(*x, *y)))).count() + }; + let (dark_bare, dark_titled) = (draw("Nocturne", None), draw("Nocturne", Some("Quarterly revenue"))); + assert!(lum(dark_bare.pixel(5, 5)) < 60, "Nocturne's slide is dark: {:?}", dark_bare.pixel(5, 5)); + let light_ink = count(&dark_titled, &|l| l > 200) as i64 - count(&dark_bare, &|l| l > 200) as i64; + assert!(light_ink > 100, "the title is light on a dark theme (light pixels added: {light_ink})"); + let (light_bare, light_titled) = (draw("Harbor", None), draw("Harbor", Some("Quarterly revenue"))); + assert!(lum(light_bare.pixel(5, 5)) > 200, "Harbor's slide is light: {:?}", light_bare.pixel(5, 5)); + let dark_ink = count(&light_titled, &|l| l < 80) as i64 - count(&light_bare, &|l| l < 80) as i64; + assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})"); +} From 30b7eff487fe513f84bf593cc41b0e4e6d50edef Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:37:14 -0500 Subject: [PATCH 4/7] Pie data labels take the chart's text colour when it has one A textColor set on a chart (chart.options, or Font Color with the chart selected) coloured the title, axes and legend but left a pie's data labels white. They stay white by default, on the slices, and follow a colour the chart's text was given. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/chart.rs | 3 ++- crates/render/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 09338405..cb07b363 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -324,7 +324,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, base, Align::Center, Anchor::Middle, - Rgba::WHITE, + // On the slices themselves: white, unless the chart's text was given a colour. + if c.text_color.is_some() { text_col } else { Rgba::WHITE }, false, ); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index baccc9dd..07794e09 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2747,3 +2747,27 @@ fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { let dark_ink = count(&light_titled, &|l| l < 80) as i64 - count(&light_bare, &|l| l < 80) as i64; assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})"); } + +/// Pie data labels sit on the slices in white; a colour given to the chart's text (chart.options +/// textColor, Font Color on the chart) colours them too, as it does the title and legend. +#[test] +fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() { + let draw = |colour: Option| { + let mut c = Chart::sample(ChartType::Pie); + c.title = None; + c.legend = None; + c.data_labels = true; + c.text_color = colour; + let sh = Shape { id: ShapeId(632), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 400.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let count = |img: &Image, f: &dyn Fn([u8; 4]) -> bool| { + (0..400u32).flat_map(|x| (0..400u32).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count() + }; + let red = |p: [u8; 4]| p[0] > 200 && p[1] < 60 && p[2] < 60; + let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245; + let (plain, coloured) = (draw(None), draw(Some(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))); + assert_eq!(count(&plain, &red), 0, "no red without a text colour"); + assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red)); + assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before"); +} From 3ac39f9bb6db6d3cb6c6beba70314e651affbb19 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:18 -0500 Subject: [PATCH 5/7] 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 --- crates/pptx/src/write/chart.rs | 117 ++++++++++++++++++++++++++++++--- docs/file-format-parity.md | 1 + 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 88917450..a7d19ffa 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -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, 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, 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, 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("").count() >= 5, "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(""), "{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(""), "{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#"1"#; + 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); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d5f67246..fb4bb2db 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | From 0c510426af107a9c8890615ef8935f4c1a5f9313 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:18 -0500 Subject: [PATCH 6/7] Chart text without a colour of its own takes PowerPoint's default grey The theme's bare text colour made chart text black on a light theme, where it used to be 595959. PowerPoint's default is `tx1` at lumMod 65000 and lumOff 35000: 595959 on a light theme, a light grey on a dark one, so the dark-theme fix stays. The render test now checks the light-theme title is dark but not black; the chart.options doc says what null gives. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/insert.rs | 5 +++-- crates/render/src/chart.rs | 14 +++++++++++--- crates/render/src/tests.rs | 10 +++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 901cb01e..3bc9643e 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for the theme's text colour), id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}", has_selection, chart_options ), @@ -1771,7 +1771,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { // Waterfall total points. let subtotals: Option> = p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); - // Colour of the chart's text (title, axes, legend, data labels); null goes back to the theme's. + // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's + // default (tx1 at lumMod 65 % / lumOff 35 %). let text_color = match p.get("textColor") { None => None, Some(Value::Null) => Some(None), diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index cb07b363..a4f209e0 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -205,11 +205,19 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } +/// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which +/// is 595959 on a light theme and a light grey on a dark one. +pub fn default_text_color() -> ColorRef { + use deckcraft_color::ColorTransform; + ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)) +} + #[allow(clippy::too_many_arguments)] fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { - // Text without a colour of its own takes the theme's text colour, so a chart on a dark - // theme is readable (a fixed grey used to vanish there). - let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&ColorRef::scheme(SchemeSlot::Tx1)), None); + // Text without a colour of its own takes PowerPoint's default, the theme's text colour at + // lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart + // on a dark theme is readable (a fixed grey used to vanish there). + let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&default_text_color()), None); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { crate::paint::fill_path( diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 07794e09..c72d4500 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2718,8 +2718,9 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } -/// A chart that has no text colour of its own takes the theme's: light on Nocturne's dark slides -/// (it used to be a fixed grey, near-invisible there), dark on Harbor's light ones. +/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour +/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible +/// there), 595959 grey on Harbor's light ones. #[test] fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { let draw = |theme: &str, title: Option<&str>| { @@ -2744,8 +2745,11 @@ fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { assert!(light_ink > 100, "the title is light on a dark theme (light pixels added: {light_ink})"); let (light_bare, light_titled) = (draw("Harbor", None), draw("Harbor", Some("Quarterly revenue"))); assert!(lum(light_bare.pixel(5, 5)) > 200, "Harbor's slide is light: {:?}", light_bare.pixel(5, 5)); - let dark_ink = count(&light_titled, &|l| l < 80) as i64 - count(&light_bare, &|l| l < 80) as i64; + // 595959 has luminance 89: dark, but not black. + let dark_ink = count(&light_titled, &|l| l < 120) as i64 - count(&light_bare, &|l| l < 120) as i64; assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})"); + let black_ink = count(&light_titled, &|l| l < 40) as i64 - count(&light_bare, &|l| l < 40) as i64; + assert!(black_ink < 20, "the title is PowerPoint's 595959 grey, not black (black pixels added: {black_ink})"); } /// Pie data labels sit on the slices in white; a colour given to the chart's text (chart.options From aaba9febc2aba5e6cdc7398581bb8d65f4aec1c8 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:18 -0500 Subject: [PATCH 7/7] Progress log: chart text colour Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index d54c160f..080941c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |