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 28e62fe98..337fc2170 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -70,50 +70,65 @@ 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 { + let pt = if pt.is_nan() { 1.0 } else { pt.clamp(1.0, 4000.0) }; + (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 }; + 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"); w.empty0("a:bodyPr"); w.empty0("a:lstStyle"); w.open0("a:p"); w.open0("a:pPr"); - def_rpr(w, Some(color)); + def_rpr(w, color, 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); @@ -125,9 +140,9 @@ 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) { +fn title(w: &mut W, text: &str, c: &Chart, size: Option) { w.open0("c:title"); - rich(w, text, c.text_color.as_ref()); + rich(w, text, c.text_color.as_ref(), size); w.val("c:overlay", 0); w.close("c:title"); } @@ -246,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); @@ -297,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), 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) { @@ -386,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, text_color_explicit: bool) { w.open0("c:dLbls"); - tx_pr(w, c); + 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 { + tx_pr(w, c); + } for (n, v) in [ ("c:showLegendKey", false), ("c:showVal", show), @@ -405,6 +425,38 @@ 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 axis_line(w: &mut W) { + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 9525)); + w.open0("a:solidFill"); + w.empty("a:srgbClr", A::new().a("val", "BFBFBF")); + w.close("a:solidFill"); + 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") }; @@ -417,12 +469,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, 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"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); + axis_line(w); tx_pr(w, c); w.val("c:crossAx", AX_VAL); w.val("c:crosses", "autoZero"); @@ -437,12 +490,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, 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"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); + axis_line(w); tx_pr(w, c); w.val("c:crossAx", AX_VAL); w.val("c:crosses", "autoZero"); @@ -469,13 +523,13 @@ 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); + 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)), @@ -484,6 +538,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"); @@ -491,7 +546,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" @@ -530,10 +585,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); + data_labels(w, c, true, matches!(g, Group::Pie | Group::Doughnut), text_color_explicit); } match g { Group::Bar => { @@ -610,6 +665,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) @@ -641,7 +700,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), @@ -674,7 +733,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 { @@ -701,7 +760,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); @@ -1051,6 +1110,26 @@ mod tests { use deckcraft_model::ColorRef; use deckcraft_model::chart::{Chart, ChartType}; + /// Gridlines and the category axis are styled as DeckCraft draws them; the value axis is hidden. + #[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(), 1, "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + } + + #[test] + fn scatter_x_axis_is_styled_and_value_axis_is_hidden() { + let xml = String::from_utf8(super::chart_xml(&Chart::sample(ChartType::Scatter)).0).unwrap(); + assert_eq!(xml.matches("val=\"BFBFBF\"").count(), 1, "{xml}"); + assert_eq!(xml.matches("").count(), 1, "{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] @@ -1078,6 +1157,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] diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 87200a890..9b4ac905d 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -405,7 +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(); } - 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)); @@ -672,6 +674,20 @@ 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) -> 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) { w.open0("p:graphicFrame"); w.open0("p:nvGraphicFramePr"); @@ -1003,6 +1019,32 @@ 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); + 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); + 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("