From 38b9e12cd17a162eb170c069527aef055d7a53ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Mon, 12 Oct 2026 16:33:15 +0300 Subject: [PATCH] Charts: axis number formats, automatic major unit, hidden axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Value axes showed raw numbers ("800000") with a fixed ~5-interval scale and fixed label gutters, deleted axes were still drawn, and data labels ignored their number format, so Russian business charts read "516600000" where PowerPoint shows "516 600 000 ₽". - New deckcraft_model::numfmt: Excel number format codes (sections for positive/negative/zero, quoted and escaped literals, _x padding, *x fill, [$sym-lcid] currency, [colour]/conditions dropped, 0/#/? placeholders, grouping with ',' or the locale's literal space, trailing-comma scaling, %, General) with digit grouping and decimal mark from the chart's language (c:lang: ru-RU -> "800 000", "12,5%"). - Reading: each value axis' numFmt, c:delete, scaling min/max and majorUnit, for the primary and a combo chart's secondary axis; data labels' numFmt (series, then group, else the source data's formatCode); the chart's c:lang. The primary value axis is the one on the left (bottom) and the secondary the one on the right/top or crossing at max, whatever the plot groups' order. - Scale (Chart::axis_scale): fixed bounds and unit where given, else the data plus 5% headroom rounded out to the smallest 1-2-5 unit that leaves as many intervals as the axis has room for labels, as PowerPoint does (0-800 000 by 100 000, 0-7 000 by 1 000 on the sample deck). - Rendering: labels in the axis format, gutters sized to the longest label, deleted axes draw no labels and take no room, data labels in their own format. --- crates/model/src/chart.rs | 136 ++++++++++++++++++ crates/model/src/lib.rs | 1 + crates/model/src/numfmt.rs | 256 ++++++++++++++++++++++++++++++++++ crates/pptx/src/read/chart.rs | 78 +++++++++-- crates/render/src/chart.rs | 52 ++++--- 5 files changed, 492 insertions(+), 31 deletions(-) create mode 100644 crates/model/src/numfmt.rs diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 3fa6f7c..e83e1ed 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -134,6 +134,10 @@ pub struct Series { /// Combo charts: this series' own type. pub kind: Option, pub secondary_axis: bool, + /// The number format of the series' data labels (Excel code): the labels' own, else the + /// source data's. + #[serde(skip_serializing_if = "Option::is_none")] + pub number_format: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -161,6 +165,20 @@ pub struct Chart { pub vary_colors: bool, pub y_min: Option, pub y_max: Option, + /// The value axis' major unit, when the chart fixes it. + pub y_major: Option, + /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). + pub y_format: Option, + /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. + pub y_hidden: bool, + /// A combo chart's secondary value axis: bounds, major unit and number format. + pub y2_min: Option, + pub y2_max: Option, + pub y2_major: Option, + pub y2_format: Option, + pub y2_hidden: bool, + /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. + pub lang: Option, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -189,6 +207,15 @@ impl Default for Chart { vary_colors: false, y_min: None, y_max: None, + y_major: None, + y_format: None, + y_hidden: false, + y2_min: None, + y2_max: None, + y2_major: None, + y2_format: None, + y2_hidden: false, + lang: None, raw: None, } } @@ -265,6 +292,29 @@ impl Chart { Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) } + /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: + /// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 % + /// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis. + pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { + let (lo, hi) = self.axis_range(secondary)?; + let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; + let full_percent = !secondary && self.kind.percent(); + Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) + } + + /// 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 data label of series `s`: its number format, else its axis', in the chart's language. + pub fn data_label(&self, s: &Series, v: f64) -> String { + let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format }; + let code = s.number_format.as_ref().or(axis.as_ref()); + crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) + } + /// Value range across series (stacked sums for stacked types). pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; @@ -297,6 +347,53 @@ impl Chart { } } +/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` +/// where the chart fixes them; otherwise the range takes in zero, gets 5 % headroom (`headroom`) +/// and rounds out to the smallest 1-2-5 unit giving at most `max_intervals` intervals. +pub fn axis_scale( + lo: f64, + hi: f64, + fmin: Option, + fmax: Option, + major: Option, + max_intervals: usize, + headroom: bool, +) -> (f64, f64, f64) { + // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. + let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); + let (fmin, fmax) = (fin(fmin), fin(fmax)); + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) }; + let mut lo = fmin.unwrap_or(lo.min(0.0)); + let mut hi = fmax.unwrap_or(hi.max(lo)); + if hi - lo < 1e-12 { + hi = lo + lo.abs().max(1.0); + } + let span = hi - lo; + if headroom && fmax.is_none() && hi > 0.0 { + hi += span * 0.05; + } + if headroom && fmin.is_none() && lo < 0.0 { + lo -= span * 0.05; + } + let n = max_intervals.clamp(1, 50) as f64; + let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { + Some(m) => m, + None => { + let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); + let mut k = 0; + // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. + while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { + step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 }; + k += 1; + } + step + } + }; + let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step); + let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step); + (min, max, step) +} + /// "Nice" axis scale: (min, max, major step) covering [lo, hi]. pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; @@ -335,6 +432,45 @@ mod tests { assert!(b > a); } + #[test] + fn automatic_axis_scales_like_powerpoint() { + // Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000. + assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0)); + // Clicks up to 6 600: 0–7 000 by 1 000. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0)); + // Fewer intervals fit on a short axis. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0)); + // Negative data gets room below; fixed bounds and unit are kept. + let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true); + assert!(min <= -30.0 && max >= 105.0); + assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0)); + // 100 % stacked stays at 0–1 without headroom. + assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2)); + // Nonsense never loops or divides by zero. + for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] { + let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true); + assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}"); + } + } + + #[test] + fn axis_labels_use_the_format_and_language() { + let mut c = Chart::sample(ChartType::Column); + c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into()); + c.lang = Some("ru-RU".into()); + assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽"); + assert_eq!(c.axis_label(false, 0.0), "- ₽"); + // No format: General. + assert_eq!(c.axis_label(true, 2.5), "2,5"); + c.lang = None; + assert_eq!(c.axis_label(true, 2.5), "2.5"); + // Data labels: the series' format, else the axis'. + c.lang = Some("ru-RU".into()); + c.series[0].number_format = Some(r#"#,##0\ "₽""#.into()); + assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽"); + assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); + } + #[test] fn combo_axis_ranges() { // Spend in columns on the primary axis, clicks as a line on the secondary one. diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index c5e307d..43561f9 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -11,6 +11,7 @@ pub mod anim; pub mod chart; pub mod defaults; pub mod edit; +pub mod numfmt; pub mod resolve; pub mod style; pub mod table; diff --git a/crates/model/src/numfmt.rs b/crates/model/src/numfmt.rs new file mode 100644 index 0000000..d6f613d --- /dev/null +++ b/crates/model/src/numfmt.rs @@ -0,0 +1,256 @@ +//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart +//! axes and data labels show them, in the chart's language (digit grouping and decimal mark). + +/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`). +fn marks(lang: Option<&str>) -> (char, char) { + let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase(); + match l.as_str() { + // Space-grouped, comma decimal. + "ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy" + | "ka" | "az" => ('\u{a0}', ','), + // Dot-grouped, comma decimal. + "de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','), + _ => (',', '.'), + } +} + +#[derive(Debug, PartialEq)] +enum Tok { + /// Text shown as is. + Lit(String), + /// A digit placeholder: `0`, `#` or `?`. + Digit(char), + Dot, + Comma, + Percent, +} + +/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out). +fn tokens(section: &str) -> Vec { + let mut out = vec![]; + let mut it = section.chars().peekable(); + while let Some(c) = it.next() { + match c { + '"' => { + let mut s = String::new(); + for d in it.by_ref() { + if d == '"' { + break; + } + s.push(d); + } + out.push(Tok::Lit(s)); + } + '\\' => { + if let Some(d) = it.next() { + out.push(Tok::Lit(d.to_string())); + } + } + // Padding the width of the next character: a space. + '_' => { + it.next(); + out.push(Tok::Lit(" ".into())); + } + // Repeat-to-fill: nothing on an axis. + '*' => { + it.next(); + } + '[' => { + let mut s = String::new(); + for d in it.by_ref() { + if d == ']' { + break; + } + s.push(d); + } + if let Some(sym) = s.strip_prefix('$') { + let sym = sym.split('-').next().unwrap_or(""); + if !sym.is_empty() { + out.push(Tok::Lit(sym.to_string())); + } + } + } + '0' | '#' | '?' => out.push(Tok::Digit(c)), + '.' => out.push(Tok::Dot), + ',' => out.push(Tok::Comma), + '%' => out.push(Tok::Percent), + '@' => {} + _ => out.push(Tok::Lit(c.to_string())), + } + } + out +} + +/// The format's sections split at `;` (outside quotes, brackets and escapes). +fn sections(code: &str) -> Vec<&str> { + let mut out = vec![]; + let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false); + for (i, c) in code.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\\' if !quoted => escaped = true, + '"' => quoted = !quoted, + '[' if !quoted => bracket = true, + ']' if !quoted => bracket = false, + ';' if !quoted && !bracket => { + out.push(code.get(start..i).unwrap_or("")); + start = i + 1; + } + _ => {} + } + } + out.push(code.get(start..).unwrap_or("")); + out +} + +/// `v` in the General format: up to 10 significant digits, no grouping. +fn general(v: f64, dec: char) -> String { + if v == 0.0 { + return "0".into(); + } + let a = v.abs(); + let s = if !(1e-9..1e11).contains(&a) { + format!("{v:.5E}") + } else { + let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize; + let s = format!("{v:.digits$}"); + if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } + }; + s.replace('.', &dec.to_string()) +} + +/// Group an integer's digits in threes with `sep`. +fn group(digits: &str, sep: char) -> String { + let n = digits.chars().count(); + let mut out = String::new(); + for (i, d) in digits.chars().enumerate() { + if i > 0 && (n - i).is_multiple_of(3) { + out.push(sep); + } + out.push(d); + } + out +} + +/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text +/// section of a format doesn't apply). +pub fn format(code: &str, v: f64, lang: Option<&str>) -> String { + let (sep, dec) = marks(lang); + if !v.is_finite() { + return String::new(); + } + let secs = sections(code); + // The section for the value's sign; a negative or zero section shows the value unsigned. + let (sec, v, minus) = match (secs.as_slice(), v) { + ([_, neg, ..], v) if v < 0.0 => (*neg, -v, false), + ([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false), + ([pos, ..], v) => (*pos, v.abs(), v < 0.0), + ([], v) => ("General", v.abs(), v < 0.0), + }; + if sec.trim().eq_ignore_ascii_case("general") || sec.trim().is_empty() { + let s = general(v, dec); + return if minus { format!("-{s}") } else { s }; + } + let toks = tokens(sec); + let first = toks.iter().position(|t| matches!(t, Tok::Digit(_))); + let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_))); + let (Some(first), Some(last)) = (first, last) else { + // No digits: just the section's text ("-" for zero, a label). + let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect(); + return s.trim().to_string(); + }; + let percent = toks.contains(&Tok::Percent); + let number = toks.get(first..=last).unwrap_or(&[]); + let dot = number.iter().position(|t| *t == Tok::Dot); + let (int_part, frac_part) = match dot { + Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])), + None => (number, &[][..]), + }; + // Grouping: a comma (or the locale's space, written literally) between integer digits. + let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty())); + // Commas right after the last digit scale by a thousand each (`#,##0,` = thousands). + let after = toks.get(last + 1..).unwrap_or(&[]); + let scale_commas = if dot.is_none() { after.iter().take_while(|t| **t == Tok::Comma).count() } else { 0 }; + let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count(); + let frac_digits: Vec = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect(); + let max_frac = frac_digits.len(); + let min_frac = frac_digits.iter().filter(|c| **c == '0').count(); + let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32); + if !x.is_finite() { + x = 0.0; + } + let s = format!("{x:.max_frac$}"); + let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), "")); + let mut fp = fp.to_string(); + while fp.len() > min_frac && fp.ends_with('0') { + fp.pop(); + } + let ip = ip.trim_start_matches('0'); + let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() }; + let mut num = if grouping { group(&ip, sep) } else { ip }; + if !fp.is_empty() { + num.push(dec); + num.push_str(&fp); + } + let lits = |ts: &[Tok]| -> String { + ts.iter() + .filter_map(|t| match t { + Tok::Lit(s) => Some(s.clone()), + Tok::Percent => Some("%".into()), + _ => None, + }) + .collect() + }; + let before = lits(toks.get(..first).unwrap_or(&[])); + let after = lits(toks.get(last + 1..).unwrap_or(&[])); + let out = format!("{before}{num}{after}"); + let out = out.trim(); + if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#; + + #[test] + fn russian_accounting_roubles() { + assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽"); + assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽"); + assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽"); + assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽"); + } + + #[test] + fn common_codes() { + assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000"); + assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000"); + assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000"); + assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%"); + assert_eq!(format("0.0%", 0.125, None), "12.5%"); + assert_eq!(format("0%", 0.0, None), "0%"); + assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5"); + assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000"); + assert_eq!(format("0.00", 1.23456, None), "1.23"); + assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5"); + assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346"); + assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500"); + assert_eq!(format("0;[Red]-0", -3.0, None), "-3"); + assert_eq!(format("0;0", -3.0, None), "3"); + assert_eq!(format("0", -3.0, None), "-3"); + assert_eq!(format("\"x\"0", 5.0, None), "x5"); + } + + #[test] + fn never_panics() { + for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] { + for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] { + let _ = format(code, v, Some("ru")); + } + } + } +} diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index b057332..1c08d5c 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -104,9 +104,23 @@ pub(crate) fn chart_from(root: &El) -> Option { let mut kinds: Vec = vec![]; // The value axis the first plot group uses is the primary one; a group on another value axis // (a combo chart's line on the right) is on the secondary axis. - let val_ids: Vec<&str> = pa.children_named("valAx").filter_map(|a| a.child_val("axId")).collect(); - let group_val_axis = |g: &El| g.children_named("axId").filter_map(|a| a.attr("val")).find(|id| val_ids.contains(id)).map(str::to_string); - let mut primary_val: Option = None; + let val_ids: Vec<(&str, bool)> = + pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect(); + // A group's value axis: its vertical one when it has two (scatter), else its one. + let group_val_axis = |g: &El| { + let ids: Vec<(&str, bool)> = + g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect(); + ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) + }; + // The primary value axis is the one on the left (bottom for bar charts); the secondary one + // sits on the right or top, or crosses the category axis at its maximum. + let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); + let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect(); + let primary_val: Option = used + .iter() + .find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a))) + .or(used.first()) + .cloned(); for g in pa.elements() { let name = g.local(); if !name.ends_with("Chart") { @@ -173,9 +187,6 @@ pub(crate) fn chart_from(root: &El) -> Option { } kinds.push(kind); let val_axis = group_val_axis(g); - if primary_val.is_none() { - primary_val = val_axis.clone(); - } let secondary = val_axis.is_some() && val_axis != primary_val; for s in g.children_named("ser").take(1000) { let mut ser = Series { @@ -191,6 +202,16 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) { ser.data_labels = Some(d); } + // Data labels' number format: the series' labels', the group's, else the source data's. + let label_fmt = |d: Option<&El>| { + d.and_then(|d| d.child("numFmt")) + .filter(|n| n.attr("sourceLinked") != Some("1")) + .and_then(|n| n.attr("formatCode")) + .map(str::to_string) + }; + let source = + s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); + ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()); for dp in s.children_named("dPt") { if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { ser.point_fills.push((i as usize, f)); @@ -223,8 +244,25 @@ pub(crate) fn chart_from(root: &El) -> Option { } } // Axes. + c.lang = root.child_val("lang").map(str::to_string); let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r")); - let val_ax = pa.children_named("valAx").find(vertical).or_else(|| pa.children_named("valAx").last()); + 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); + 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")); + // The other vertical value axis is the secondary one. + if let Some(a) = pa.children_named("valAx").find(|a| a.child_val("axId") != primary_id && vertical(a)) { + let sc = a.child("scaling"); + c.y2_min = sc.and_then(|s| num(s, "min")); + c.y2_max = sc.and_then(|s| num(s, "max")); + c.y2_major = num(a, "majorUnit"); + c.y2_format = format_code(a); + c.y2_hidden = deleted(a); + } + } let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a))); if let Some(v) = val_ax { c.gridlines = v.child("majorGridlines").is_some(); @@ -232,6 +270,9 @@ pub(crate) fn chart_from(root: &El) -> Option { c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); + c.y_major = num(v, "majorUnit"); + c.y_format = format_code(v); + c.y_hidden = deleted(v); } if let Some(a) = cat_ax { c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); @@ -259,12 +300,12 @@ mod tests { #[test] fn combo_chart_with_a_secondary_axis() { // Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right. - let xml = br#" + let xml = br##" SpendW1W2200000310000 Clicks60007400 - - -"#; + + +"##; let c = read_chart_xml(xml).unwrap(); assert_eq!(c.kind, ChartType::Combo); assert_eq!(c.series.len(), 2); @@ -272,6 +313,21 @@ mod tests { assert!(c.series[1].secondary_axis); assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers)); assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01))); + // Each value axis' number format, bounds and unit; the chart's language. + assert_eq!(c.y_format.as_deref(), Some("#,##0")); + assert_eq!((c.y_major, c.y2_max, c.y2_format.as_deref()), (Some(100000.0), Some(8000.0), Some("0.0"))); + assert_eq!(c.lang.as_deref(), Some("ru-RU")); + // Group order doesn't decide: columns on the right-hand axis are the secondary series even + // when their group comes first, and the line on the left is primary. + let swapped = String::from_utf8_lossy(xml) + .replace( + "", ""); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 0fca0aa..f8417aa 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -2,7 +2,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; -use deckcraft_model::chart::{Chart, ChartType, nice_scale}; +use deckcraft_model::chart::{Chart, ChartType, axis_scale}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -242,17 +242,32 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let combo = c.kind == ChartType::Combo; // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); - let (lo, hi) = c.axis_range(false).unwrap_or_else(|| c.value_range()); - let (min, max, step) = nice_scale(lo.min(0.0), hi); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; + // As many major units as the value axis has room for labels (PowerPoint's automatic unit). + let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; + let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; + let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)); // A combo chart's secondary value axis, on the right (vertical value axes only). - let second = if horiz { None } else { c.axis_range(true).map(|(l, h)| nice_scale(l.min(0.0), h)) }; - // Axis label gutter. - let gutter = base * 3.0; - let plot = if horiz { - Rect::new(area.x0 + gutter * 1.5, area.y0, area.x1, area.y1 - base * 1.6) + let second = if horiz { None } else { c.axis_scale(true, max_intervals) }; + let ticks = |(lo, hi, st): (f64, f64, f64)| { + let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50); + (0..=n).map(move |k| lo + k as f64 * st) + }; + // Label gutters as wide as the longest value label. + let label_w = + |sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6; + let gutter = if horiz { + base * 4.5 + } else if c.y_hidden { + base * 0.5 } else { - Rect::new(area.x0 + gutter, area.y0, area.x1 - if second.is_some() { gutter } else { 0.0 }, area.y1 - base * 1.8) + label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3) + }; + let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(base * 1.5, area.width() * 0.3)).unwrap_or(0.0); + let plot = if horiz { + Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6) + } else { + Rect::new(area.x0 + gutter, area.y0, area.x1 - gutter_r, area.y1 - base * 1.8) }; if let Some(f) = &c.plot_fill { crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); @@ -265,10 +280,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h _ => to_v(v), }; // Gridlines and value labels. - let n_steps = ((span / step).round() as i64).clamp(1, 50); - for k in 0..=n_steps { - let v = min + k as f64 * step; + for v in ticks((min, max, step)) { let pos = to_v(v); + let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); if horiz { g.move_to((pos, plot.y0)); @@ -277,7 +291,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx, rctx, m, - &fmt_num(v), + &text, Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5), base, Align::Center, @@ -292,7 +306,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx, rctx, m, - &fmt_num(v), + &text, Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), base, Align::Right, @@ -309,16 +323,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } // The secondary axis' labels, on the right, without gridlines of their own. - if let Some((min2, max2, step2)) = second { - let n2 = (((max2 - min2) / step2.max(1e-9)).round() as i64).clamp(1, 50); - for k in 0..=n2 { - let v = min2 + k as f64 * step2; + if let Some(sc) = second.filter(|_| !c.y2_hidden) { + for v in ticks(sc) { let pos = to_axis(true, v); label( ctx, rctx, m, - &fmt_num(v), + &c.axis_label(true, v), Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), base, Align::Left, @@ -406,7 +418,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); ctx.fill_rect(&r.abs()); if c.data_labels { - let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0)); + let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1) } else {