From 6f18abcc73758eeb70ac189c1e06048f56f8869e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:49:11 -0500 Subject: [PATCH 1/3] Write DeckCraft's chart styling so PowerPoint draws charts the same Gridlines are written light grey and axis lines off, and text colour and sizes are written when a new chart has none, so PowerPoint no longer falls back to black lines and 10 pt labels. --- crates/pptx/src/write/chart.rs | 78 ++++++++++++++++++++++++++------- crates/pptx/src/write/shapes.rs | 29 ++++++++++++ 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 28e62fe98..7e25d44fd 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -70,50 +70,60 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) { /// `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>) { +fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>, size: Option) { + let a = A::new().o("sz", size.map(sz)); match color { Some(c) => { - w.open0("a:defRPr"); + w.open("a:defRPr", a); super::dml::solid(w, c); w.close("a:defRPr"); } - None => w.empty0("a:defRPr"), + None => w.empty("a:defRPr", a), } } +/// A font size in points as `sz` (hundredths of a point). +fn sz(pt: f64) -> i64 { + (pt * 100.0).round() as i64 +} + /// `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. +/// without a colour or size: the theme's default then applies on reading. fn tx_pr(w: &mut W, c: &Chart) { - let Some(color) = &c.text_color else { return }; + if c.text_color.is_none() && c.text_size.is_none() { + 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)); + def_rpr(w, c.text_color.as_ref(), c.text_size); 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>) { +fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>, size: Option) { 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() { + if color.is_some() || size.is_some() { w.open0("a:pPr"); - def_rpr(w, color); + def_rpr(w, color, size); 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); + if color.is_some() || size.is_some() { + w.open("a:rPr", A::new().a("lang", "en-US").o("sz", size.map(sz))); + if let Some(c) = color { + super::dml::solid(w, c); + } w.close("a:rPr"); } w.elt("a:t", line); @@ -127,7 +137,7 @@ fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) { /// 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, c.text_color.as_ref()); + rich(w, text, c.text_color.as_ref(), c.title_size); w.val("c:overlay", 0); w.close("c:title"); } @@ -405,6 +415,28 @@ fn data_labels(w: &mut W, c: &Chart, show: bool) { const AX_CAT: u32 = 50_001; const AX_VAL: u32 = 50_002; +/// Gridlines in the light grey DeckCraft draws them in; PowerPoint's default is black. +fn grid_lines(w: &mut W, tag: &str, rgb: &str) { + w.open0(tag); + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 9525)); + w.open0("a:solidFill"); + w.val("a:srgbClr", rgb); + w.close("a:solidFill"); + w.close("a:ln"); + w.close("c:spPr"); + w.close(tag); +} + +/// An axis without a line, as DeckCraft draws it (PowerPoint's default is a black line). +fn no_axis_line(w: &mut W) { + w.open0("c:spPr"); + w.open0("a:ln"); + w.empty0("a:noFill"); + w.close("a:ln"); + w.close("c:spPr"); +} + fn axes(w: &mut W, c: &Chart, g: Group) { let horizontal = c.kind.horizontal() && g == Group::Bar; let (cat_pos, val_pos) = if horizontal { ("l", "b") } else { ("b", "l") }; @@ -423,6 +455,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:majorTickMark", "out"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); + no_axis_line(w); tx_pr(w, c); w.val("c:crossAx", AX_VAL); w.val("c:crosses", "autoZero"); @@ -443,6 +476,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"); + no_axis_line(w); tx_pr(w, c); w.val("c:crossAx", AX_VAL); w.val("c:crosses", "autoZero"); @@ -469,10 +503,10 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:delete", 0); w.val("c:axPos", val_pos); if c.gridlines { - w.empty0("c:majorGridlines"); + grid_lines(w, "c:majorGridlines", "D9D9D9"); } if c.minor_gridlines { - w.empty0("c:minorGridlines"); + grid_lines(w, "c:minorGridlines", "F2F2F2"); } if let Some(t) = &c.axis_titles.1 { title(w, t, c); @@ -484,6 +518,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"); + no_axis_line(w); tx_pr(w, c); w.val("c:crossAx", AX_CAT); w.val("c:crosses", "autoZero"); @@ -1051,6 +1086,19 @@ mod tests { use deckcraft_model::ColorRef; use deckcraft_model::chart::{Chart, ChartType}; + /// Gridlines are written light grey and the axes without a line, as DeckCraft draws them + /// (PowerPoint's bare defaults are black lines). + #[test] + fn gridlines_and_axis_lines_are_styled() { + let mut c = Chart::sample(ChartType::Column); + c.gridlines = true; + c.minor_gridlines = true; + let xml = String::from_utf8(super::chart_xml(&c).0).unwrap(); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert_eq!(xml.matches("").count(), 2, "{xml}"); + } + /// 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] diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 87200a890..7b0545b75 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -405,6 +405,9 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth // The kept workbook belongs to the `chartEx` part; the fallback has none. plain.kept = Default::default(); } + if plain.raw.is_none() { + chart_text_defaults(&mut plain, s.xfrm.unwrap_or_default()); + } let rid = x.chart_rel(o, &plain); frame_open(w, x, o, id, s); w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")); @@ -672,6 +675,18 @@ fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { w.close("p:sp"); } +/// Writes the text colour and sizes DeckCraft draws a chart in when the chart has none of its own +/// (without them PowerPoint falls back to black text at 10 pt): the theme's text at 65 %, and a +/// size scaled to the frame, as `render::chart` picks it. +fn chart_text_defaults(c: &mut deckcraft_model::Chart, xf: deckcraft_geom::Xfrm) { + use deckcraft_color::ColorTransform; + use deckcraft_model::{ColorRef, SchemeSlot}; + c.text_color.get_or_insert_with(|| ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000))); + let base = c.text_size.unwrap_or_else(|| (xf.w.min(xf.h) / 22.0).clamp(7.0, 18.0)); + c.text_size = Some(base); + c.title_size.get_or_insert(base * 1.4); +} + fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.open0("p:graphicFrame"); w.open0("p:nvGraphicFramePr"); @@ -1003,6 +1018,20 @@ pub fn tree_header(w: &mut W) { mod tests { use super::*; + /// A chart without text styling gets DeckCraft's drawn colour and frame-scaled sizes, so + /// PowerPoint does not fall back to black 10 pt text; its own values are kept. + #[test] + fn chart_text_defaults_fill_only_the_gaps() { + let mut c = deckcraft_model::Chart::sample(deckcraft_model::chart::ChartType::Column); + chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() }); + assert!(c.text_color.is_some()); + assert_eq!((c.text_size, c.title_size), (Some(10.0), Some(14.0))); + let mut c = deckcraft_model::Chart::sample(deckcraft_model::chart::ChartType::Column); + c.text_size = Some(12.0); + chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() }); + assert_eq!((c.text_size, c.title_size), (Some(12.0), Some(12.0 * 1.4))); + } + /// PowerPoint asks to repair a `star5` whose guide list lacks `hf`/`vf` (found black-box). #[test] fn preset_guides_are_complete() { From 0bdb9006398780cfbfc91cd009e260708df7276c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:34:33 -0500 Subject: [PATCH 2/3] M1.2: fix chart text style review findings --- crates/pptx/src/write/chart.rs | 65 +++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 7e25d44fd..c276a1586 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -84,6 +84,7 @@ fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>, size: Option i64 { + let pt = if pt.is_nan() { 1.0 } else { pt.clamp(1.0, 4000.0) }; (pt * 100.0).round() as i64 } @@ -91,7 +92,11 @@ fn sz(pt: f64) -> i64 { /// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing /// without a colour or size: the theme's default then applies on reading. fn tx_pr(w: &mut W, c: &Chart) { - if c.text_color.is_none() && c.text_size.is_none() { + tx_pr_values(w, c.text_color.as_ref(), c.text_size, false); +} + +fn tx_pr_values(w: &mut W, color: Option<&deckcraft_model::ColorRef>, size: Option, force: bool) { + if !force && color.is_none() && size.is_none() { return; } w.open0("c:txPr"); @@ -99,7 +104,7 @@ fn tx_pr(w: &mut W, c: &Chart) { w.empty0("a:lstStyle"); w.open0("a:p"); w.open0("a:pPr"); - def_rpr(w, c.text_color.as_ref(), c.text_size); + def_rpr(w, color, size); w.close("a:pPr"); w.empty("a:endParaRPr", A::new().a("lang", "en-US")); w.close("a:p"); @@ -135,9 +140,9 @@ fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>, size: } /// A title (the chart's or an axis's) in the chart's text colour. -fn title(w: &mut W, text: &str, c: &Chart) { +fn title(w: &mut W, text: &str, c: &Chart, size: Option) { w.open0("c:title"); - rich(w, text, c.text_color.as_ref(), c.title_size); + rich(w, text, c.text_color.as_ref(), size); w.val("c:overlay", 0); w.close("c:title"); } @@ -307,7 +312,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } point_fills(w, s, g); if let Some(d) = s.data_labels { - data_labels(w, c, d); + data_labels(w, c, d, matches!(g, Group::Pie | Group::Doughnut)); } // After labels, before the category and value caches (ECMA-376 series content order). if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) { @@ -396,9 +401,14 @@ fn trendline(w: &mut W, t: &Trendline) { w.close("c:trendline"); } -fn data_labels(w: &mut W, c: &Chart, show: bool) { +fn data_labels(w: &mut W, c: &Chart, show: bool, pie: bool) { w.open0("c:dLbls"); - tx_pr(w, c); + if pie && c.text_color.is_none() { + let white = deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE); + tx_pr_values(w, Some(&white), c.text_size, true); + } else { + tx_pr(w, c); + } for (n, v) in [ ("c:showLegendKey", false), ("c:showVal", show), @@ -449,7 +459,7 @@ 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, c); + title(w, t, c, c.text_size); } w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1)); w.val("c:majorTickMark", "out"); @@ -470,7 +480,7 @@ 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, c); + title(w, t, c, c.text_size); } w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1)); w.val("c:majorTickMark", "none"); @@ -509,7 +519,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { grid_lines(w, "c:minorGridlines", "F2F2F2"); } if let Some(t) = &c.axis_titles.1 { - title(w, t, c); + title(w, t, c, c.text_size); } 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)), @@ -568,7 +578,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, c, true); + data_labels(w, c, true, matches!(g, Group::Pie | Group::Doughnut)); } match g { Group::Bar => { @@ -676,7 +686,7 @@ pub fn chart_xml(c: &Chart) -> (Vec, bool) { w.open0("c:chart"); match &c.title { Some(t) => { - title(&mut w, t, c); + title(&mut w, t, c, c.title_size); w.val("c:autoTitleDeleted", 0); } None => w.val("c:autoTitleDeleted", 1), @@ -1126,6 +1136,37 @@ mod tests { assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, None); } + #[test] + fn chart_title_sizes_are_bounded_and_axis_titles_use_text_size() { + let mut c = Chart::sample(ChartType::Column); + c.title = Some("Chart title".into()); + c.axis_titles = (Some("X axis".into()), Some("Y axis".into())); + c.title_size = Some(24.0); + c.text_size = Some(11.0); + let xml = String::from_utf8(super::chart_xml(&c).0).unwrap(); + let titles: Vec<_> = xml.split("").skip(1).map(|s| s.split("").next().unwrap_or_default()).collect(); + assert!(titles.first().is_some_and(|t| t.contains("sz=\"2400\"")), "{xml}"); + assert_eq!(titles.iter().filter(|t| t.contains("sz=\"1100\"")).count(), 2, "{xml}"); + + assert_eq!(super::sz(0.5), 100); + assert_eq!(super::sz(4000.1), 400000); + assert_eq!(super::sz(f64::NAN), 100); + } + + #[test] + fn pie_data_label_default_white_does_not_become_chart_text_color() { + let mut c = Chart::sample(ChartType::Pie); + c.data_labels = true; + 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] From f6db3acfbc13c36a5d3e1eb0c3c6365a27b753a4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:28:52 -0500 Subject: [PATCH 3/3] M1.2: preserve default white pie labels --- crates/pptx/src/write.rs | 8 ++++++-- crates/pptx/src/write/chart.rs | 22 +++++++++++++--------- crates/pptx/src/write/shapes.rs | 27 ++++++++++++++++++++------- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs index 5d3e60e92..c0cf8a4de 100644 --- a/crates/pptx/src/write.rs +++ b/crates/pptx/src/write.rs @@ -209,10 +209,14 @@ impl Exp<'_> { Some(o.rels.add_new(RT_CHARTEX, &opc::relative(&o.name, &name), false)) } - pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { + pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart, text_color_explicit: bool) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); - let (xml, preserved) = chart::chart_xml(c); + let (xml, preserved) = if text_color_explicit == c.text_color.is_some() { + chart::chart_xml(c) + } else { + chart::chart_xml_with_text_color_explicit(c, text_color_explicit) + }; self.pkg.xml_part(&name, CT_CHART, xml); if preserved { chart::write_kept(&mut self.pkg, &name, c, &mut self.n_kept); diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index c276a1586..68ab7cd51 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -261,7 +261,7 @@ fn point_fills(w: &mut W, s: &Series, g: Group) { } } -fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) { +fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group, text_color_explicit: bool) { w.open0("c:ser"); w.val("c:idx", i); w.val("c:order", i); @@ -312,7 +312,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } point_fills(w, s, g); if let Some(d) = s.data_labels { - data_labels(w, c, d, matches!(g, Group::Pie | Group::Doughnut)); + data_labels(w, c, d, matches!(g, Group::Pie | Group::Doughnut), text_color_explicit); } // After labels, before the category and value caches (ECMA-376 series content order). if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) { @@ -401,9 +401,9 @@ fn trendline(w: &mut W, t: &Trendline) { w.close("c:trendline"); } -fn data_labels(w: &mut W, c: &Chart, show: bool, pie: bool) { +fn data_labels(w: &mut W, c: &Chart, show: bool, pie: bool, text_color_explicit: bool) { w.open0("c:dLbls"); - if pie && c.text_color.is_none() { + if pie && !text_color_explicit { let white = deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE); tx_pr_values(w, Some(&white), c.text_size, true); } else { @@ -536,7 +536,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.close("c:valAx"); } -fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) { +fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)], text_color_explicit: bool) { let g = group_of(kind); let grouping = if kind.percent() { "percentStacked" @@ -575,10 +575,10 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) w.val("c:varyColors", vary); } for (i, s) in list { - series(w, c, s, *i, kind, g); + series(w, c, s, *i, kind, g, text_color_explicit); } if c.data_labels { - data_labels(w, c, true, matches!(g, Group::Pie | Group::Doughnut)); + data_labels(w, c, true, matches!(g, Group::Pie | Group::Doughnut), text_color_explicit); } match g { Group::Bar => { @@ -655,6 +655,10 @@ fn kept_chartex(c: &Chart) -> Option> { /// The chart part XML, and whether it is the original part (so its relationships still apply). pub fn chart_xml(c: &Chart) -> (Vec, bool) { + chart_xml_with_text_color_explicit(c, c.text_color.is_some()) +} + +pub(crate) fn chart_xml_with_text_color_explicit(c: &Chart, text_color_explicit: bool) -> (Vec, bool) { // Reuse the kept part when the chart is unchanged since it was read. if let Some(raw) = &c.raw && !raw.contains(NS_CX) @@ -719,7 +723,7 @@ pub fn chart_xml(c: &Chart) -> (Vec, bool) { }) .copied() .collect(); - plot_group(&mut w, c, k, &list); + plot_group(&mut w, c, k, &list, text_color_explicit); } axes(&mut w, c, Group::Bar); } else { @@ -746,7 +750,7 @@ pub fn chart_xml(c: &Chart) -> (Vec, bool) { ) { log::warn!("pptx: {} chart written as a column chart", c.kind.label()); } - plot_group(&mut w, c, main, &indexed); + plot_group(&mut w, c, main, &indexed, text_color_explicit); let g = group_of(main); if !matches!(g, Group::Pie | Group::Doughnut) { axes(&mut w, c, g); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 7b0545b75..9b4ac905d 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -405,10 +405,9 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth // The kept workbook belongs to the `chartEx` part; the fallback has none. plain.kept = Default::default(); } - if plain.raw.is_none() { - chart_text_defaults(&mut plain, s.xfrm.unwrap_or_default()); - } - let rid = x.chart_rel(o, &plain); + let text_color_explicit = + if plain.raw.is_none() { chart_text_defaults(&mut plain, s.xfrm.unwrap_or_default()) } else { plain.text_color.is_some() }; + let rid = x.chart_rel(o, &plain, text_color_explicit); frame_open(w, x, o, id, s); w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")); w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid)); @@ -678,13 +677,15 @@ fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { /// Writes the text colour and sizes DeckCraft draws a chart in when the chart has none of its own /// (without them PowerPoint falls back to black text at 10 pt): the theme's text at 65 %, and a /// size scaled to the frame, as `render::chart` picks it. -fn chart_text_defaults(c: &mut deckcraft_model::Chart, xf: deckcraft_geom::Xfrm) { +fn chart_text_defaults(c: &mut deckcraft_model::Chart, xf: deckcraft_geom::Xfrm) -> bool { use deckcraft_color::ColorTransform; use deckcraft_model::{ColorRef, SchemeSlot}; + let text_color_explicit = c.text_color.is_some(); c.text_color.get_or_insert_with(|| ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000))); let base = c.text_size.unwrap_or_else(|| (xf.w.min(xf.h) / 22.0).clamp(7.0, 18.0)); c.text_size = Some(base); c.title_size.get_or_insert(base * 1.4); + text_color_explicit } fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { @@ -1023,15 +1024,27 @@ mod tests { #[test] fn chart_text_defaults_fill_only_the_gaps() { let mut c = deckcraft_model::Chart::sample(deckcraft_model::chart::ChartType::Column); - chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() }); + assert!(!chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() })); assert!(c.text_color.is_some()); assert_eq!((c.text_size, c.title_size), (Some(10.0), Some(14.0))); let mut c = deckcraft_model::Chart::sample(deckcraft_model::chart::ChartType::Column); c.text_size = Some(12.0); - chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() }); + assert!(!chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() })); assert_eq!((c.text_size, c.title_size), (Some(12.0), Some(12.0 * 1.4))); } + #[test] + fn chart_shape_defaults_keep_pie_labels_white() { + let mut c = deckcraft_model::Chart::sample(deckcraft_model::chart::ChartType::Pie); + c.data_labels = true; + let text_color_explicit = chart_text_defaults(&mut c, Xfrm { w: 440.0, h: 220.0, ..Default::default() }); + let xml = String::from_utf8(super::super::chart::chart_xml_with_text_color_explicit(&c, text_color_explicit).0).unwrap(); + let labels = xml.split("").nth(1).unwrap_or_default().split("").next().unwrap_or_default(); + assert!(labels.contains("").next().unwrap_or_default(); + assert!(chart_text.contains("