diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..b19ca11c 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -314,7 +314,12 @@ impl Chart { /// A value axis label in the axis' number format and the chart's language. pub fn axis_label(&self, secondary: bool, v: f64) -> String { let code = if secondary { &self.y2_format } else { &self.y_format }; - crate::numfmt::format(code.as_deref().unwrap_or("General"), v, self.lang.as_deref()) + // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. + let code = match code.as_deref() { + None | Some("General") if !secondary && self.kind.percent() => "0%", + c => c.unwrap_or("General"), + }; + crate::numfmt::format(code, v, self.lang.as_deref()) } /// A data label of series `s`: its number format, else its axis', in the chart's language. @@ -480,6 +485,16 @@ mod tests { assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); } + #[test] + fn full_percent_axes_count_in_percent() { + let mut c = Chart::sample(ChartType::PercentColumn); + assert_eq!(c.axis_label(false, 0.5), "50%"); + c.y_format = Some("0.0%".into()); + assert_eq!(c.axis_label(false, 0.5), "50.0%"); + let c = Chart::sample(ChartType::Column); + assert_eq!(c.axis_label(false, 0.5), "0.5"); + } + #[test] fn combo_axis_ranges() { // Spend in columns on the primary axis, clicks as a line on the secondary one. diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..3e6c6399 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -249,7 +249,22 @@ pub(crate) fn chart_from(root: &El) -> Option { let by_id = |id: &Option| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref()); let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); - let format_code = |a: &El| a.child("numFmt").and_then(|n| n.attr("formatCode")).map(str::to_string); + // An axis' number format: its own unless linked to the source (`sourceLinked="1"`, or no + // c:numFmt at all), in which case the data's format of the first series plotted on it. + let source_format = |ax: &El| { + let id = ax.child_val("axId"); + pa.elements() + .filter(|g| g.children_named("axId").any(|a| a.attr("val") == id)) + .flat_map(|g| g.children_named("ser")) + .find_map(|s| { + s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()) + }) + .filter(|f| !f.is_empty() && f != "General") + }; + let format_code = |a: &El| match a.child("numFmt") { + Some(n) if n.attr("sourceLinked") != Some("1") => n.attr("formatCode").map(str::to_string), + n => source_format(a).or_else(|| n.and_then(|n| n.attr("formatCode")).map(str::to_string)), + }; let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true); if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) { let primary_id = val_ax.and_then(|a| a.child_val("axId")); @@ -363,6 +378,20 @@ mod tests { assert!(c.series.iter().all(|s| !s.secondary_axis)); } + #[test] + fn linked_axis_formats_come_from_the_data() { + // No c:numFmt on the value axis, or a source-linked one: the series' own format. + let xml = |ax_fmt: &str| { + format!( + r#"#,##01200{ax_fmt}"# + ) + }; + let fmt = |x: &str| read_chart_xml(xml(x).as_bytes()).unwrap().y_format; + assert_eq!(fmt("").as_deref(), Some("#,##0")); + assert_eq!(fmt(r#""#).as_deref(), Some("#,##0")); + assert_eq!(fmt(r#""#).as_deref(), Some("0.0")); + } + #[test] fn text_sizes_are_read_from_the_file() { // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..dcac10c3 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -289,7 +289,10 @@ fn axes(w: &mut W, c: &Chart, g: Group) { if let Some(t) = &c.axis_titles.1 { title(w, t); } - w.empty("c:numFmt", A::new().a("formatCode", if c.kind.percent() { "0%" } else { "General" }).a("sourceLinked", 1)); + 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)), + None => w.empty("c:numFmt", A::new().a("formatCode", if c.kind.percent() { "0%" } else { "General" }).a("sourceLinked", 1)), + } w.val("c:majorTickMark", "none"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo");