Charts: 100 % stacked axes count in percent; linked axis formats come from the data

This commit is contained in:
Nikita Rakitin
2026-10-14 04:19:16 +03:00
parent 457406a7a5
commit 3960ea47cd
3 changed files with 50 additions and 3 deletions
+16 -1
View File
@@ -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.
+30 -1
View File
@@ -249,7 +249,22 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
let by_id = |id: &Option<String>| 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::<f64>().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#"<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:formatCode>#,##0</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>1200</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:catAx><c:valAx><c:axId val="2"/>{ax_fmt}<c:axPos val="l"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#
)
};
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#"<c:numFmt formatCode="General" sourceLinked="1"/>"#).as_deref(), Some("#,##0"));
assert_eq!(fmt(r#"<c:numFmt formatCode="0.0" sourceLinked="0"/>"#).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
+4 -1
View File
@@ -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");