Merge PR #1015: Chart text can be coloured (chart.options textColor, format.color) and follows the theme by default

Conflicts: crates/engine/src/cmd/insert.rs (kept main's apply_bin_options / apply_subtotals_and_bars helpers and added the textColor assignment)
This commit is contained in:
aiongg
2026-10-24 06:15:53 +00:00
9 changed files with 251 additions and 15 deletions
+1
View File
@@ -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) |
+22 -1
View File
@@ -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<Value> {
}
fn color(s: &mut Session, p: &Value) -> Result<Value> {
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<ShapeId> =
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.
+13 -1
View File
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
// Waterfall total points.
let subtotals: Option<Vec<usize>> =
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() {
+35
View File
@@ -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::<String>::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)));
}
+107 -10
View File
@@ -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<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),
@@ -664,6 +709,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);
@@ -672,6 +718,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)
}
@@ -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("<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);
+13 -2
View File
@@ -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,
);
}
+58
View File
@@ -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<ColorRef>| {
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");
}
+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 |
+1 -1
View File
@@ -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.