diff --git a/ROADMAP.md b/ROADMAP.md index bc8faa92..18f03e16 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) | 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/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index c0966f99..d860a3bd 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 PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}", has_selection, chart_options ), @@ -1804,7 +1804,19 @@ 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 PowerPoint's + // default (tx1 at lumMod 65 % / lumOff 35 %). + 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(); + } apply_bin_options(c, bin_count, bin_size); apply_subtotals_and_bars(c, p, subtotals.as_deref()); if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab39..f8b48097 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4403,3 +4403,38 @@ 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); +} + +/// 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))); +} diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 8e317086..5847f72e 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -68,14 +68,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"); @@ -84,9 +124,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"); } @@ -227,7 +268,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) { @@ -335,8 +376,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), @@ -365,12 +407,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"); @@ -384,12 +427,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); @@ -421,7 +465,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)), @@ -430,6 +474,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" }); @@ -478,7 +523,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 => { @@ -586,7 +631,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), @@ -664,6 +709,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); @@ -672,6 +718,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) } @@ -981,8 +1028,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/crates/render/src/chart.rs b/crates/render/src/chart.rs index db4783d6..bf41b2f0 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -255,9 +255,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>) { - 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 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( @@ -389,7 +399,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 07cfed34..afb70420 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2754,3 +2754,61 @@ 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 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>| { + 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)); + // 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 +/// 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"); +} 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 | 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.