Compare commits

...

5 Commits

Author SHA1 Message Date
Brandon Thomas 05bc0c8c04 rustfmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:19:42 -04:00
Магда М.Г. 38b9e12cd1 Charts: axis number formats, automatic major unit, hidden axes
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.
2026-10-13 04:16:07 -04:00
Магда М.Г. c4d1d7e1c8 Charts: draw combo charts with columns, lines and a secondary axis
A combo chart (columns plus lines, often with the lines on a second value
axis) was read with each series' type but drawn as lines only, on one
scale, so a spend-and-clicks chart showed the clicks as a flat line along
the bottom and no columns.

- Reading: a plot group whose value axis isn't the first group's is on the
  secondary axis (Series::secondary_axis, now set).
- Model: Chart::series_kind, on_secondary and axis_range (per axis; a combo
  stacks only its stacked series).
- Rendering: bar-like series are drawn as bars (clustered or stacked among
  themselves), the others as lines/areas over them, each against its own
  axis; the secondary axis' labels go on the right. Line series use their
  line's colour and width (where PowerPoint keeps them), also in the legend.
  Long category labels wrap onto two lines.
2026-10-13 04:16:06 -04:00
Brandon Thomas 191323be3b Chart legend test: a chart with a real namespace and a bar chart, as the reader needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:15:51 -04:00
MarekSpodBiedry 9995d5dfc8 Chart legend: honour legendPos and overlay in the renderer (#262) 2026-10-13 04:11:10 -04:00
7 changed files with 875 additions and 96 deletions

View File

@ -134,6 +134,10 @@ pub struct Series {
/// Combo charts: this series' own type.
pub kind: Option<ChartType>,
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<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -144,6 +148,8 @@ pub struct Chart {
pub categories: Vec<String>,
pub series: Vec<Series>,
pub legend: Option<String>,
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
pub legend_overlay: bool,
pub data_labels: bool,
pub gridlines: bool,
pub axis_titles: (Option<String>, Option<String>),
@ -159,6 +165,20 @@ pub struct Chart {
pub vary_colors: bool,
pub y_min: Option<f64>,
pub y_max: Option<f64>,
/// The value axis' major unit, when the chart fixes it.
pub y_major: Option<f64>,
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
pub y_format: Option<String>,
/// 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<f64>,
pub y2_max: Option<f64>,
pub y2_major: Option<f64>,
pub y2_format: Option<String>,
pub y2_hidden: bool,
/// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark.
pub lang: Option<String>,
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
@ -172,6 +192,7 @@ impl Default for Chart {
categories: vec![],
series: vec![],
legend: Some("b".into()),
legend_overlay: false,
data_labels: false,
gridlines: true,
axis_titles: (None, None),
@ -186,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,
}
}
@ -210,6 +240,81 @@ impl Chart {
c.vary_colors = n_series == 1;
c
}
/// A series' own chart type: its own in a combo chart (columns when it names none), else the
/// chart's.
pub fn series_kind(&self, s: &Series) -> ChartType {
match (self.kind, s.kind) {
(ChartType::Combo, Some(k)) if k != ChartType::Combo => k,
(ChartType::Combo, _) => ChartType::Column,
(k, _) => k,
}
}
/// Whether a series is plotted against the secondary value axis (combo charts only).
pub fn on_secondary(&self, s: &Series) -> bool {
self.kind == ChartType::Combo && s.secondary_axis
}
/// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo
/// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series
/// uses that axis.
pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> {
if self.kind != ChartType::Combo {
return (!secondary).then(|| self.value_range());
}
let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect();
if ser.is_empty() {
return None;
}
let (mut lo, mut hi) = (0.0_f64, 0.0_f64);
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
let mut percent = false;
for i in 0..n {
let (mut p, mut m) = (0.0, 0.0);
for s in &ser {
let k = self.series_kind(s);
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
if k.percent() {
percent = true;
} else if k.stacked() {
if v >= 0.0 { p += v } else { m += v }
} else {
hi = hi.max(v);
lo = lo.min(v);
}
}
hi = hi.max(p);
lo = lo.min(m);
}
if percent {
hi = hi.max(1.0);
}
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;
@ -242,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<f64>,
fmax: Option<f64>,
major: Option<f64>,
max_intervals: usize,
headroom: bool,
) -> (f64, f64, f64) {
// Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite.
let fin = |v: Option<f64>| 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) };
@ -280,6 +432,67 @@ 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.
let mut c = Chart::sample(ChartType::Combo);
c.series.truncate(2);
c.series[0].kind = Some(ChartType::Column);
c.series[0].values = vec![Some(200_000.0), Some(310_000.0)];
c.series[1].kind = Some(ChartType::Line);
c.series[1].values = vec![Some(6_000.0), Some(7_400.0)];
c.series[1].secondary_axis = true;
assert_eq!(c.axis_range(false), Some((0.0, 310_000.0)));
assert_eq!(c.axis_range(true), Some((0.0, 7_400.0)));
assert_eq!(c.series_kind(&c.series[1]), ChartType::Line);
// Without a secondary series there is no secondary axis; a non-combo chart never has one.
c.series[1].secondary_axis = false;
assert_eq!(c.axis_range(true), None);
let col = Chart::sample(ChartType::Column);
assert_eq!(col.axis_range(false), Some(col.value_range()));
assert_eq!(col.axis_range(true), None);
assert!(!col.on_secondary(&col.series[0]));
}
#[test]
fn sample_ranges() {
let c = Chart::sample(ChartType::StackedColumn);

View File

@ -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;

256
crates/model/src/numfmt.rs Normal file
View File

@ -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<Tok> {
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<char> = 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"));
}
}
}
}

View File

@ -97,10 +97,30 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
c.title = None;
}
c.legend = ch.child("legend").map(|l| l.child_val("legendPos").unwrap_or("r").to_string());
c.legend_overlay = ch.child("legend").and_then(|l| l.child("overlay")).and_then(|o| o.bool("val")).unwrap_or(false);
c.chart_fill = simple_fill(root.child("spPr"));
c.plot_fill = simple_fill(pa.child("spPr"));
let mut kinds: Vec<ChartType> = 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, 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<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
let primary_val: Option<String> = 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") {
@ -166,6 +186,8 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
c.data_labels = true;
}
kinds.push(kind);
let val_axis = group_val_axis(g);
let secondary = val_axis.is_some() && val_axis != primary_val;
for s in g.children_named("ser").take(1000) {
let mut ser = Series {
name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(),
@ -174,11 +196,22 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false),
marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from),
kind: Some(kind),
secondary_axis: secondary,
..Default::default()
};
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));
@ -207,11 +240,29 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
if c.kind != ChartType::Combo {
for s in &mut c.series {
s.kind = None;
s.secondary_axis = false;
}
}
// 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<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);
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();
@ -219,9 +270,70 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
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()));
}
Some(c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legend_position_and_overlay_are_read() {
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
let xml = format!(
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea>{legend}</c:chart></c:chartSpace>"#
);
let c = read_chart_xml(xml.as_bytes()).unwrap();
assert_eq!(c.legend.as_deref(), Some("r"));
assert!(c.legend_overlay);
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea><c:legend/></c:chart></c:chartSpace>"#;
let c = read_chart_xml(xml).unwrap();
assert_eq!(c.legend.as_deref(), Some("r"));
assert!(!c.legend_overlay);
}
#[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##"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:lang val="ru-RU"/><c:chart><c:plotArea>
<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/><c:pt idx="0"><c:v>W1</c:v></c:pt><c:pt idx="1"><c:v>W2</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>200000</c:v></c:pt><c:pt idx="1"><c:v>310000</c:v></c:pt></c:numLit></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart>
<c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="1"/><c:tx><c:v>Clicks</c:v></c:tx><c:spPr><a:ln w="28575"><a:solidFill><a:srgbClr val="00B050"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>6000</c:v></c:pt><c:pt idx="1"><c:v>7400</c:v></c:pt></c:numLit></c:val></c:ser><c:marker val="1"/><c:axId val="3"/><c:axId val="4"/></c:lineChart>
<c:catAx><c:axId val="1"/><c:axPos val="b"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:axPos val="l"/><c:numFmt formatCode="#,##0" sourceLinked="1"/><c:crossAx val="1"/><c:majorUnit val="100000"/></c:valAx>
<c:valAx><c:axId val="4"/><c:scaling><c:max val="8000"/></c:scaling><c:axPos val="r"/><c:numFmt formatCode="0.0" sourceLinked="0"/><c:crossAx val="3"/></c:valAx><c:catAx><c:axId val="3"/><c:delete val="1"/><c:axPos val="b"/><c:crossAx val="4"/></c:catAx>
</c:plotArea></c:chart></c:chartSpace>"##;
let c = read_chart_xml(xml).unwrap();
assert_eq!(c.kind, ChartType::Combo);
assert_eq!(c.series.len(), 2);
assert!(!c.series[0].secondary_axis);
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(
"<c:axPos val=\"l\"/><c:numFmt formatCode=\"#,##0\"",
"<c:axPos val=\"r\"/><c:crosses val=\"max\"/><c:numFmt formatCode=\"#,##0\"",
)
.replace("<c:axPos val=\"r\"/><c:numFmt formatCode=\"0.0\"", "<c:axPos val=\"l\"/><c:numFmt formatCode=\"0.0\"");
let c2 = read_chart_xml(swapped.as_bytes()).unwrap();
assert!(c2.series[0].secondary_axis && !c2.series[1].secondary_axis);
assert_eq!((c2.y_format.as_deref(), c2.y2_format.as_deref()), (Some("0.0"), Some("#,##0")));
// A chart of one type on one axis has no secondary series.
let single = String::from_utf8_lossy(xml)
.replace("<c:axId val=\"3\"/><c:axId val=\"4\"/></c:lineChart>", "<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>");
let c = read_chart_xml(single.as_bytes()).unwrap();
assert!(c.series.iter().all(|s| !s.secondary_axis));
}
}

View File

@ -467,7 +467,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
};
w.open0("c:legend");
w.val("c:legendPos", p);
w.val("c:overlay", 0);
w.val("c:overlay", u8::from(c.legend_overlay));
w.close("c:legend");
}
w.val("c:plotVisOnly", 1);

View File

@ -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};
@ -36,6 +36,29 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart
}
}
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
/// palette's.
fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
match s.line.as_ref().and_then(|l| l.fill.as_ref()) {
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
_ => series_fill(ctx, c, i, Some(s)),
}
}
/// A category label on two lines, split at its middle space or dash ("03.11.2025 - 09.11.2025").
fn split_label(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mid = chars.len() / 2;
let best = chars.iter().enumerate().filter(|(_, c)| c.is_whitespace()).map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid));
match best {
Some(i) if i > 0 && i + 1 < chars.len() => {
let (a, b) = chars.split_at(i);
format!("{}\n{}", a.iter().collect::<String>().trim_end(), b.iter().collect::<String>().trim_start())
}
_ => name.to_string(),
}
}
fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) {
let mut body = TextBody::from_text(text);
body.body.inset_l = Some(0.0);
@ -63,6 +86,42 @@ fn fmt_num(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
}
/// Where the legend goes for `c:legendPos`, and the plot area left over.
/// `size` is the legend block (width, height); the result is its top-left corner and the plot area.
/// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred
/// on the area, `tr` is a vertical stack in the top-right corner, `t` and `b` are centred rows.
/// An overlaid legend leaves the plot area as it was.
fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bool) -> (Point, Rect) {
let (lw, lh) = size;
let gap = base * 0.5;
let cx = (area.x0 + area.x1) / 2.0;
let cy = ((area.y0 + area.y1) / 2.0 - lh / 2.0).max(area.y0);
let mut plot = area;
let origin = match pos {
"b" => {
plot.y1 = area.y1 - lh - gap;
Point::new(cx - lw / 2.0, area.y1 - lh)
}
"t" => {
plot.y0 = area.y0 + lh + gap;
Point::new(cx - lw / 2.0, area.y0)
}
"l" => {
plot.x0 = area.x0 + lw + gap;
Point::new(area.x0, cy)
}
"tr" => {
plot.x1 = area.x1 - lw - gap;
Point::new(area.x1 - lw, area.y0)
}
_ => {
plot.x1 = area.x1 - lw - gap;
Point::new(area.x1 - lw, cy)
}
};
(origin, if overlay { area } else { plot })
}
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
@ -87,25 +146,43 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
area.y0 += base * 2.4;
}
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
// Legend at the bottom.
// Legend placed by `c:legendPos`.
let legend_items: Vec<(String, Rgba)> = if pie {
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
} else {
c.series.iter().enumerate().map(|(i, s)| (s.name.clone(), series_fill(rctx, c, i, Some(s)))).collect()
c.series
.iter()
.enumerate()
.map(|(i, s)| {
let k = c.series_kind(s);
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
})
.collect()
};
if c.legend.is_some() && !legend_items.is_empty() {
let ly = area.y1 - base * 1.4;
let item_w = (legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).sum::<f64>()).min(w * 0.9);
let mut x = (w - item_w) / 2.0;
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
let size = if vertical {
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
} else {
(item_ws.iter().sum::<f64>().min(w * 0.9), base * 1.4)
};
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
area = plot_area;
let (mut x, mut y) = (origin.x, origin.y);
for (name, col) in &legend_items {
ctx.set_transform(m);
ctx.set_paint(color(*col, 1.0));
ctx.fill_rect(&Rect::new(x, ly + base * 0.3, x + base * 0.7, ly + base));
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
let tw = name.chars().count() as f64 * base * 0.55 + base;
label(ctx, rctx, m, name, Rect::new(x + base, ly, x + base + tw, ly + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
x += tw + base * 0.6;
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
if vertical {
y += base * 1.5;
} else {
x += tw + base * 0.6;
}
}
area.y1 = ly - base * 0.5;
}
if pie {
let Some(s) = c.series.first() else { return };
@ -162,26 +239,50 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
}
return;
}
let (lo, hi) = c.value_range();
let (min, max, step) = nice_scale(lo.min(0.0), hi);
let horiz = c.kind.horizontal();
// 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 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 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_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, 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);
}
let span = (max - min).max(1e-9);
let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() };
// A series' value as a position on its own axis.
let to_axis = |sec: bool, v: f64| match (sec, second) {
(true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(),
_ => 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));
@ -190,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,
@ -205,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,
@ -221,9 +322,28 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
ctx.stroke_path(&g);
}
}
// The secondary axis' labels, on the right, without gridlines of their own.
if let Some(sc) = second.filter(|_| !c.y2_hidden) {
for v in ticks(sc) {
let pos = to_axis(true, v);
label(
ctx,
rctx,
m,
&c.axis_label(true, v),
Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base),
base,
Align::Left,
Anchor::Middle,
text_col,
false,
);
}
}
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
// Category labels.
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
for (i, name) in c.categories.iter().enumerate() {
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
if horiz {
@ -240,12 +360,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
false,
);
} else {
let text = if wraps { split_label(name) } else { name.clone() };
label(
ctx,
rctx,
m,
name,
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * 1.6),
&text,
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }),
base,
Align::Center,
Anchor::Top,
@ -254,24 +375,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
);
}
}
let ns = c.series.len().max(1);
let zero = to_v(0.0f64.clamp(min, max));
if c.kind.is_bar_like() {
if let Some(bk) = bar_kind {
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
let ns = bars.len().max(1);
let gap = c.gap_width.clamp(0.0, 5.0);
let group = band / (1.0 + gap);
let stacked = c.kind.stacked();
let stacked = bk.stacked();
let bar = if stacked { group } else { group / ns as f64 };
for i in 0..ncat {
let mut pos_acc = 0.0;
let mut neg_acc = 0.0;
let total: f64 = if c.kind.percent() {
c.series.iter().map(|s| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
let total: f64 = if bk.percent() {
bars.iter().map(|(_, s)| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
} else {
1.0
};
for (si, s) in c.series.iter().enumerate() {
for (bi, (si, s)) in bars.iter().enumerate() {
let sec = c.on_secondary(s);
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * si as f64 };
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
let (a, b) = if stacked {
if v >= 0.0 {
let r = (pos_acc, pos_acc + v);
@ -288,13 +412,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
let r = if horiz {
Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar)
} else {
Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a))
Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a))
};
ctx.set_transform(m);
ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0));
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 {
@ -315,66 +439,72 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
}
}
}
} else {
// Lines, areas, scatter, radar (drawn as lines).
let stacked = c.kind.stacked();
let mut acc = vec![0.0; ncat];
for (si, s) in c.series.iter().enumerate() {
let col = series_fill(rctx, c, si, Some(s));
let mut pts = vec![];
for i in 0..ncat {
let Some(v) = s.values.get(i).copied().flatten() else { continue };
let v = if stacked {
let base_v = acc.get(i).copied().unwrap_or(0.0);
if let Some(a) = acc.get_mut(i) {
*a += v;
}
base_v + v
} else {
v
};
let x = match c.kind {
ChartType::Scatter | ChartType::Bubble => {
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
}
_ => plot.x0 + band * (i as f64 + 0.5),
};
pts.push(Point::new(x, to_v(v)));
}
if pts.is_empty() {
continue;
}
let mut p = BezPath::new();
for (k, q) in pts.iter().enumerate() {
if k == 0 {
p.move_to(*q);
} else {
p.line_to(*q);
}
// Lines, areas, scatter, radar (drawn as lines): the series that aren't bars.
let mut acc = vec![0.0; ncat];
for (si, s) in c.series.iter().enumerate() {
let kind = c.series_kind(s);
if kind.is_bar_like() {
continue;
}
let sec = c.on_secondary(s);
let stacked = kind.stacked();
let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
let mut pts = vec![];
for i in 0..ncat {
let Some(v) = s.values.get(i).copied().flatten() else { continue };
let v = if stacked {
let base_v = acc.get(i).copied().unwrap_or(0.0);
if let Some(a) = acc.get_mut(i) {
*a += v;
}
}
ctx.set_transform(m);
if matches!(c.kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar) {
let mut a = p.clone();
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
a.line_to((l.x, zero));
a.line_to((f.x, zero));
a.close_path();
base_v + v
} else {
v
};
let x = match kind {
ChartType::Scatter | ChartType::Bubble => {
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
}
ctx.set_paint(color(col, 0.85));
ctx.fill_path(&a);
} else if c.kind != ChartType::Scatter {
ctx.set_paint(color(col, 1.0));
ctx.set_stroke(kurbo::Stroke::new(2.25).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
ctx.stroke_path(&p);
_ => plot.x0 + band * (i as f64 + 0.5),
};
pts.push(Point::new(x, to_axis(sec, v)));
}
if pts.is_empty() {
continue;
}
let mut p = BezPath::new();
for (k, q) in pts.iter().enumerate() {
if k == 0 {
p.move_to(*q);
} else {
p.line_to(*q);
}
if matches!(c.kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
ctx.set_paint(color(col, 1.0));
for q in &pts {
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
}
}
ctx.set_transform(m);
if area_kind {
let mut a = p.clone();
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
a.line_to((l.x, zero));
a.line_to((f.x, zero));
a.close_path();
}
ctx.set_paint(color(col, 0.85));
ctx.fill_path(&a);
} else if kind != ChartType::Scatter {
let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0);
ctx.set_paint(color(col, 1.0));
ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
ctx.stroke_path(&p);
}
if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
ctx.set_paint(color(col, 1.0));
for q in &pts {
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
}
}
}
@ -392,3 +522,40 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.stroke_path(&ax);
}
#[cfg(test)]
mod tests {
use super::*;
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
#[test]
fn right_legend_is_a_centred_column_and_narrows_the_plot() {
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false);
assert_eq!((o.x, o.y), (340.0, 100.0));
assert_eq!((plot.x0, plot.x1, plot.y0, plot.y1), (0.0, 335.0, 0.0, 300.0));
}
#[test]
fn left_top_and_corner_positions() {
let (o, plot) = legend_layout("l", (60.0, 100.0), AREA, 10.0, false);
assert_eq!((o.x, o.y, plot.x0, plot.x1), (0.0, 100.0, 65.0, 400.0));
let (o, plot) = legend_layout("t", (100.0, 14.0), AREA, 10.0, false);
assert_eq!((o.x, o.y, plot.y0, plot.y1), (150.0, 0.0, 19.0, 300.0));
let (o, plot) = legend_layout("tr", (60.0, 100.0), AREA, 10.0, false);
assert_eq!((o.x, o.y, plot.x1), (340.0, 0.0, 335.0));
}
#[test]
fn bottom_legend_keeps_the_old_row_layout() {
let (o, plot) = legend_layout("b", (100.0, 14.0), AREA, 10.0, false);
assert_eq!((o.x, o.y, plot.y1, plot.x1), (150.0, 286.0, 281.0, 400.0));
}
#[test]
fn overlay_does_not_shrink_the_plot() {
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, true);
assert_eq!(plot, AREA);
assert_eq!(o.x, 340.0);
}
}

View File

@ -125,6 +125,36 @@ fn effects_tables_charts_render() {
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
}
#[test]
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
// the line must sit up in the plot, not flat along the bottom of the primary scale.
let mut c = Chart::sample(ChartType::Combo);
c.title = None;
c.legend = None;
c.series.truncate(2);
c.categories = vec!["A".into(), "B".into(), "C".into()];
c.series[0].kind = Some(ChartType::Column);
c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)];
c.series[1].kind = Some(ChartType::Line);
c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)];
c.series[1].secondary_axis = true;
c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0));
let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let green = |x: u32, y0: u32, y1: u32| {
(y0..y1).any(|y| {
let p = img.pixel(x, y);
p[1] > 120 && p[0] < 80 && p[2] < 140
})
};
// The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom.
assert!(green(480, 20, 270), "line not in the upper half");
assert!(!green(480, 400, 500), "line along the bottom");
let col = img.pixel(480, 480);
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
}
#[test]
fn placeholder_prompts_only_in_edit_view() {
let p = Presentation::default();