From 768641615afa017ba239f868543fb0cd1b401a59 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:29:51 +0300 Subject: [PATCH 1/2] Render: draw chart trendlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fit linear, exponential, logarithmic, polynomial, power and moving-average lines, including forecast, and draw them with the equation and R² label. --- ROADMAP.md | 4 +- crates/model/src/chart.rs | 30 ++ crates/model/src/lib.rs | 1 + crates/model/src/trendline.rs | 614 ++++++++++++++++++++++++++++++++++ crates/render/src/chart.rs | 298 ++++++++++++++++- crates/render/src/tests.rs | 106 +++++- docs/file-format-parity.md | 5 +- docs/gaps.md | 10 +- docs/roadmap.md | 3 +- 9 files changed, 1056 insertions(+), 15 deletions(-) create mode 100644 crates/model/src/trendline.rs diff --git a/ROADMAP.md b/ROADMAP.md index eb16cb98..e6a8f9df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines round-trip; previous: minor, hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit | | 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | @@ -151,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: chart trendlines are drawn | | 2026-10-14 | trivial | Progress log: chart trendlines | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 9e4e4a3e..2b166d6b 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -379,6 +379,17 @@ impl Chart { hi = hi.max(p); lo = lo.min(m); } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } if percent { hi = hi.max(1.0); } @@ -436,6 +447,14 @@ impl Chart { if self.kind.percent() { return (0.0, 1.0); } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) } } @@ -546,6 +565,17 @@ mod tests { } } + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + #[test] fn axis_labels_use_the_format_and_language() { let mut c = Chart::sample(ChartType::Column); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..3e30b90d 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -17,6 +17,7 @@ pub mod style; pub mod table; pub mod text; pub mod theme; +mod trendline; pub mod words; use std::sync::Arc; diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs new file mode 100644 index 00000000..eeddd2bd --- /dev/null +++ b/crates/model/src/trendline.rs @@ -0,0 +1,614 @@ +//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a +//! moving average. The fit is what the chart drawer plots and what the axis grows to include. + +use crate::chart::{ChartType, Series, Trendline, TrendlineType}; + +/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x, +/// an exponential that overflowed). +#[derive(Clone, Debug)] +pub struct TrendFit { + kind: TrendlineType, + model: Model, + r2: f64, + x_min: f64, + x_max: f64, +} + +#[derive(Clone, Debug)] +enum Model { + /// `y = c[0] + c[1] x + c[2] x² + …` + Poly(Vec), + /// `y = c · e^(b x)` + Exp { c: f64, b: f64 }, + /// `y = b + c · ln(x)` + Log { b: f64, c: f64 }, + /// `y = c · x^b` + Power { c: f64, b: f64 }, + /// Window averages in x order. No equation. + Moving(Vec<(f64, f64)>), +} + +impl Trendline { + /// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more + /// points than it was given, or the normal equations have no solution. + pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option { + let mut pairs: Vec<(f64, f64)> = + xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + if pairs.len() < 2 { + return None; + } + pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?); + let intercept = self.intercept.filter(|v| v.is_finite()); + let (model, used_y, yhat) = match self.kind { + TrendlineType::Linear => { + let (xs, ys) = split(&pairs); + let (b, m) = linreg(&xs, &ys, intercept)?; + let yhat: Vec = xs.iter().map(|x| b + m * x).collect(); + (Model::Poly(vec![b, m]), ys, yhat) + } + TrendlineType::Poly => { + let order = self.order.unwrap_or(2).clamp(2, 6) as usize; + if pairs.len() < order + 1 { + return None; + } + let (xs, ys) = split(&pairs); + let coeff = polyfit(&xs, &ys, order, intercept)?; + let yhat: Vec = xs.iter().map(|x| poly_y(&coeff, *x)).collect(); + (Model::Poly(coeff), ys, yhat) + } + TrendlineType::Exp => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); + (Model::Exp { c, b }, ys, yhat) + } + TrendlineType::Log => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ys): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip(); + let (b, c) = linreg(&ln_x, &ys, intercept)?; + let yhat: Vec = ln_x.iter().map(|u| b + c * u).collect(); + (Model::Log { b, c }, ys, yhat) + } + TrendlineType::Power => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); + (Model::Power { c, b }, ys, yhat) + } + TrendlineType::MovingAvg => { + let period = self.period.unwrap_or(2).clamp(2, 255) as usize; + if pairs.len() <= period { + return None; + } + let mut pts = Vec::new(); + let mut ys = Vec::new(); + let mut yhat = Vec::new(); + for i in period - 1..pairs.len() { + let window = &pairs[i + 1 - period..=i]; + let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; + if avg.is_finite() { + pts.push((pairs[i].0, avg)); + ys.push(pairs[i].1); + yhat.push(avg); + } + } + if pts.len() < 2 { + return None; + } + (Model::Moving(pts), ys, yhat) + } + }; + if yhat.iter().any(|v| !v.is_finite()) { + return None; + } + // A moving average starts at the first full window, not at the first data point. + let (x_min, x_max) = if let Model::Moving(pts) = &model { + match (pts.first(), pts.last()) { + (Some(a), Some(b)) => (a.0, b.0), + _ => (x_min, x_max), + } + } else { + (x_min, x_max) + }; + Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) + } + + /// The equation and R² lines this trendline asks to show. Empty when neither is on, and a + /// moving average has no equation. + pub fn caption(&self, fit: &TrendFit) -> String { + let mut lines = Vec::new(); + if self.disp_eq + && let Some(eq) = fit.equation() + { + lines.push(eq); + } + if self.disp_r_sqr { + lines.push(fit.r2_label()); + } + lines.join("\n") + } +} + +impl TrendFit { + pub fn y(&self, x: f64) -> Option { + if !x.is_finite() { + return None; + } + let y = match &self.model { + Model::Poly(c) => poly_y(c, x), + Model::Exp { c, b } => c * (b * x).exp(), + Model::Log { b, c } => { + if x <= 0.0 { + return None; + } + b + c * x.ln() + } + Model::Power { c, b } => { + if x <= 0.0 { + return None; + } + c * x.powf(*b) + } + Model::Moving(_) => return None, + }; + y.is_finite().then_some(y) + } + + /// Samples from `x0` to `x1`, forecast included. A moving average returns its window points + /// and ignores the forecast (Excel does not extend one). + pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> { + let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + if !x0.is_finite() || !x1.is_finite() { + return Vec::new(); + } + if let Model::Moving(pts) = &self.model { + return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + } + if matches!(self.kind, TrendlineType::Linear) { + return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect(); + } + let steps = 48; + (0..=steps) + .filter_map(|i| { + let x = x0 + (x1 - x0) * i as f64 / steps as f64; + self.y(x).map(|y| (x, y)) + }) + .collect() + } + + /// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile + /// value cannot walk off the chart. + pub fn span(&self, forward: Option, backward: Option) -> (f64, f64) { + let pad = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0); + if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) } + } + + pub fn equation(&self) -> Option { + let body = match &self.model { + Model::Poly(c) => poly_equation(c), + Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))), + Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)), + Model::Power { c, b } => { + let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) }; + let sign = if *c < 0.0 { "-" } else { "" }; + Some(format!("y = {sign}{mag}{}", x_pow(*b))) + } + Model::Moving(_) => None, + }?; + Some(body) + } + + pub fn r2_label(&self) -> String { + let r = if self.r2.is_finite() { self.r2 } else { 0.0 }; + if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") } + } +} + +impl Series { + /// Trendline y values, including a short forecast, that sit near this series. The axis uses + /// them so a forecast is not clipped off the plot. A value many times the series' own range + /// is left out, so a wild polynomial cannot flatten the chart. + pub fn trendline_ys(&self, scatter: bool) -> Vec { + let n = self.values.len().min(10_000); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..n { + let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y))); + if !data_lo.is_finite() || !data_hi.is_finite() { + return Vec::new(); + } + let span = (data_hi - data_lo).abs().max(1.0); + let mid = (data_lo + data_hi) / 2.0; + let mut out = Vec::new(); + for t in self.trendlines.iter().take(8) { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + for (_, y) in fit.samples(x0, x1) { + if y.is_finite() && (y - mid).abs() <= span * 8.0 { + out.push(y); + } + } + } + out + } +} + +/// Whether a series is plotted against numeric x values rather than category positions. +pub fn series_is_scatter(kind: ChartType) -> bool { + matches!(kind, ChartType::Scatter | ChartType::Bubble) +} + +fn split(pairs: &[(f64, f64)]) -> (Vec, Vec) { + pairs.iter().map(|(x, y)| (*x, *y)).unzip() +} + +/// `y = b + m x`. `intercept` fixes `b`. +fn linreg(xs: &[f64], ys: &[f64], intercept: Option) -> Option<(f64, f64)> { + if xs.len() != ys.len() || xs.len() < 2 { + return None; + } + if let Some(b) = intercept { + let mut sxx = 0.0; + let mut sxy = 0.0; + for (x, y) in xs.iter().zip(ys) { + sxx += x * x; + sxy += x * (y - b); + } + if sxx.abs() < 1e-18 { + return None; + } + let m = sxy / sxx; + return (m.is_finite() && b.is_finite()).then_some((b, m)); + } + let n = xs.len() as f64; + let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0); + for (x, y) in xs.iter().zip(ys) { + sx += x; + sy += y; + sxx += x * x; + sxy += x * y; + } + let den = n * sxx - sx * sx; + if den.abs() < 1e-18 { + return None; + } + let m = (n * sxy - sx * sy) / den; + let b = (sy - m * sx) / n; + (m.is_finite() && b.is_finite()).then_some((b, m)) +} + +fn poly_y(coeff: &[f64], x: f64) -> f64 { + let mut acc = 0.0; + let mut xp = 1.0; + for c in coeff { + acc += c * xp; + if !acc.is_finite() { + return f64::NAN; + } + xp *= x; + } + acc +} + +/// Polynomial through the normal equations. `x` is centred when the intercept is free, then +/// expanded back to powers of `x`, so order 6 on category numbers stays solvable. +#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations +fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option) -> Option> { + let n = xs.len(); + if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 { + return None; + } + if let Some(b0) = intercept { + let m = order; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (x, y) in xs.iter().zip(ys) { + let dy = y - b0; + for i in 0..m { + let xi = x.powi((i + 1) as i32); + atb[i] += dy * xi; + for j in i..m { + ata[i][j] += xi * x.powi((j + 1) as i32); + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let solved = solve(ata, atb)?; + let mut coeff = Vec::with_capacity(order + 1); + coeff.push(b0); + coeff.extend(solved); + return coeff.iter().all(|v| v.is_finite()).then_some(coeff); + } + let xm = xs.iter().sum::() / n as f64; + let us: Vec = xs.iter().map(|x| x - xm).collect(); + let m = order + 1; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (u, y) in us.iter().zip(ys) { + for i in 0..m { + let ui = if i == 0 { 1.0 } else { u.powi(i as i32) }; + atb[i] += y * ui; + for j in i..m { + let uj = if j == 0 { 1.0 } else { u.powi(j as i32) }; + ata[i][j] += ui * uj; + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let centred = solve(ata, atb)?; + let coeff = expand_shift(¢red, xm); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + +/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`. +#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift +fn expand_shift(a: &[f64], xm: f64) -> Vec { + let mut c = vec![0.0; a.len()]; + for (k, ak) in a.iter().enumerate() { + let mut binom = 1.0; + for j in 0..=k { + c[j] += ak * binom * (-xm).powi((k - j) as i32); + if j < k { + binom *= (k - j) as f64 / (j + 1) as f64; + } + } + } + c +} + +#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index +fn solve(mut a: Vec>, mut b: Vec) -> Option> { + let n = b.len(); + if n == 0 || n > 8 || a.len() != n { + return None; + } + for col in 0..n { + let mut piv = col; + for r in col + 1..n { + if a[r][col].abs() > a[piv][col].abs() { + piv = r; + } + } + if a[piv][col].abs() < 1e-12 { + return None; + } + a.swap(col, piv); + b.swap(col, piv); + let div = a[col][col]; + for c in col..n { + a[col][c] /= div; + } + b[col] /= div; + for r in 0..n { + if r == col { + continue; + } + let f = a[r][col]; + if f == 0.0 { + continue; + } + for c in col..n { + a[r][c] -= f * a[col][c]; + } + b[r] -= f * b[col]; + } + } + b.iter().all(|v| v.is_finite()).then_some(b) +} + +fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 { + if ys.is_empty() || ys.len() != yhat.len() { + return 0.0; + } + let mean = ys.iter().sum::() / ys.len() as f64; + let mut ss_tot = 0.0; + let mut ss_res = 0.0; + for (y, h) in ys.iter().zip(yhat) { + ss_tot += (y - mean) * (y - mean); + ss_res += (y - h) * (y - h); + } + if ss_tot < 1e-18 { + return if ss_res < 1e-12 { 1.0 } else { 0.0 }; + } + let r = 1.0 - ss_res / ss_tot; + if r.is_finite() { r } else { 0.0 } +} + +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".to_string(); + } + let a = v.abs(); + if a == 0.0 { + return "0".to_string(); + } + if a >= 1.0e6 || a < 1.0e-3 { + return format!("{v:.2e}").replace('+', ""); + } + let digits = if a >= 100.0 { + 1 + } else if a >= 10.0 { + 2 + } else { + 3 + }; + let mut s = format!("{v:.prec$}", prec = digits); + if let Some(dot) = s.find('.') { + while s.ends_with('0') { + s.pop(); + } + if s.len() == dot + 1 { + s.pop(); + } + } + s +} + +fn x_pow(p: f64) -> String { + if (p - 1.0).abs() < 1e-6 { + "x".to_string() + } else if (p - 2.0).abs() < 1e-6 { + "x²".to_string() + } else if (p - 3.0).abs() < 1e-6 { + "x³".to_string() + } else { + format!("x^{}", num(p)) + } +} + +fn poly_equation(coeff: &[f64]) -> Option { + let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max).max(1.0); + let mut body = String::new(); + let mut first = true; + for (k, c) in coeff.iter().enumerate().rev() { + if c.abs() < scale * 1e-8 { + continue; + } + let pow = match k { + 0 => String::new(), + 1 => "x".to_string(), + _ => x_pow(k as f64), + }; + let mag = if pow.is_empty() { + num(c.abs()) + } else if (c.abs() - 1.0).abs() < scale * 1e-8 { + pow + } else { + format!("{}{}", num(c.abs()), pow) + }; + if first { + if *c < 0.0 { + body.push('-'); + } + body.push_str(&mag); + first = false; + } else if *c < 0.0 { + body.push_str(" - "); + body.push_str(&mag); + } else { + body.push_str(" + "); + body.push_str(&mag); + } + } + if body.is_empty() { + body.push('0'); + } + Some(format!("y = {body}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(kind: TrendlineType) -> Trendline { + Trendline { kind, ..Default::default() } + } + + #[test] + fn linear_recovers_slope_and_a_fixed_intercept() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let ys = [3.0, 5.0, 7.0, 9.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit"); + assert!((fit.y(1.0).unwrap() - 3.0).abs() < 1e-9); + assert!((fit.y(4.0).unwrap() - 9.0).abs() < 1e-9); + assert_eq!(fit.equation().as_deref(), Some("y = 2x + 1")); + assert_eq!(fit.r2_label(), "R² = 1"); + let mut forced = line(TrendlineType::Linear); + forced.intercept = Some(0.0); + let fit = forced.fit(&[1.0, 2.0], &[2.0, 4.0]).expect("fit"); + assert!((fit.y(3.0).unwrap() - 6.0).abs() < 1e-6); + assert_eq!(fit.equation().as_deref(), Some("y = 2x")); + } + + #[test] + fn exp_log_power_and_quadratic_round_their_points() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let exp = line(TrendlineType::Exp).fit(&xs, &[1.0, 2.0, 4.0, 8.0]).expect("exp"); + for (x, y) in xs.iter().zip([1.0, 2.0, 4.0, 8.0]) { + assert!((exp.y(*x).unwrap() - y).abs() < 1e-6, "{x}"); + } + assert!(exp.equation().unwrap().contains("e^(")); + let log = line(TrendlineType::Log).fit(&xs, &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()]).expect("log"); + assert!((log.y(2.0).unwrap() - 2.0_f64.ln()).abs() < 1e-6); + assert!(log.equation().unwrap().contains("ln(x)")); + let power = line(TrendlineType::Power).fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("power"); + assert!((power.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(power.equation().as_deref(), Some("y = x²")); + let mut poly = line(TrendlineType::Poly); + poly.order = Some(2); + let quad = poly.fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("poly"); + assert!((quad.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(quad.equation().as_deref(), Some("y = x²")); + } + + #[test] + fn moving_average_and_forecast_span() { + let fit = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() } + .fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]) + .expect("ma"); + let pts = fit.samples(0.0, 10.0); + assert_eq!(pts.len(), 3); + assert!((pts[0].1 - 5.0).abs() < 1e-9); + assert!((pts[1].1 - 6.0).abs() < 1e-9); + assert!((pts[2].1 - 7.0).abs() < 1e-9); + assert!(fit.equation().is_none()); + let linear = line(TrendlineType::Linear).fit(&[1.0, 2.0], &[1.0, 2.0]).expect("line"); + assert_eq!(linear.span(Some(1.5), Some(-3.0)), (1.0, 3.5)); + assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0)); + } + + #[test] + fn too_few_points_and_non_finite_input_fit_nothing() { + assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none()); + assert!(line(TrendlineType::Exp).fit(&[1.0, 2.0], &[-1.0, -2.0]).is_none()); + assert!(line(TrendlineType::Poly).fit(&[1.0, 2.0], &[1.0, 2.0]).is_none()); + assert!(line(TrendlineType::Linear).fit(&[f64::NAN, f64::INFINITY], &[1.0, 2.0]).is_none()); + let mut t = line(TrendlineType::Linear); + t.disp_eq = true; + t.disp_r_sqr = true; + let fit = t.fit(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("fit"); + assert_eq!(t.caption(&fit), "y = x\nR² = 1"); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..0ead3648 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, axis_scale}; +use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -344,11 +344,15 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } 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 }; + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; // 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) }; + let mid = if horiz { plot.y0 + band * (i as f64 + back + 0.5) } else { plot.x0 + band * (i as f64 + back + 0.5) }; if horiz { label( ctx, @@ -398,7 +402,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h 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 * bi as f64 }; + let start = band * (i as f64 + back) + (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); @@ -471,9 +475,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let xs: Vec = 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) } + if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + back + 0.5) } } - _ => plot.x0 + band * (i as f64 + 0.5), + _ => plot.x0 + band * (i as f64 + back + 0.5), }; pts.push(Point::new(x, to_axis(sec, v))); } @@ -511,6 +515,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } + draw_trendlines(ctx, rctx, c, m, plot, band, back, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -526,6 +531,255 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.stroke_path(&ax); } +/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline). +fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) { + let pad = 4.0; + let shift = n as f64 * (box_h + 2.0); + let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad); + let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad); + let y_top = (plot.y0 + pad + shift).min(y_bottom); + let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)]; + let mut best = corners[0]; + let mut best_d = -1.0; + for (x, y) in corners { + let mut min_d = f64::MAX; + for p in pts { + let dx = if p.x < x { + x - p.x + } else if p.x > x + box_w { + p.x - (x + box_w) + } else { + 0.0 + }; + let dy = if p.y < y { + y - p.y + } else if p.y > y + box_h { + p.y - (y + box_h) + } else { + 0.0 + }; + min_d = min_d.min((dx * dx + dy * dy).sqrt()); + } + if pts.is_empty() { + min_d = 1.0; + } + if min_d > best_d { + best_d = min_d; + best = (x, y); + } + } + best +} + +fn pad_units(v: Option) -> f64 { + v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0) +} + +/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot. +fn forecast_pad(c: &Chart) -> (f64, f64) { + if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) { + return (0.0, 0.0); + } + let mut back = 0.0_f64; + let mut fwd = 0.0_f64; + for s in &c.series { + if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) { + continue; + } + for t in s.trendlines.iter().take(8) { + if t.kind == TrendlineType::MovingAvg { + continue; + } + back = back.max(pad_units(t.backward)); + fwd = fwd.max(pad_units(t.forward)); + } + } + (back, fwd) +} + +const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; + +fn map_xy( + plot: Rect, + band: f64, + back: f64, + horiz: bool, + scatter: bool, + sx0: f64, + sx1: f64, + sec: bool, + x: f64, + y: f64, + to_axis: &impl Fn(bool, f64) -> f64, +) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let value = to_axis(sec, y); + if !value.is_finite() { + return None; + } + let along = if scatter && sx1 > sx0 { + plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + } else { + let origin = if horiz { plot.y0 } else { plot.x0 }; + origin + band * (x - 1.0 + back + 0.5) + }; + if !along.is_finite() { + return None; + } + Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) +} + +/// The part of segment `a`–`b` that lies inside `r`. +fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let (mut t0, mut t1) = (0.0, 1.0); + let dx = b.x - a.x; + let dy = b.y - a.y; + let p = [-dx, dx, -dy, dy]; + let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y]; + for i in 0..4 { + let (pi, qi) = (p[i], q[i]); + if pi.abs() < 1e-12 { + if qi < 0.0 { + return None; + } + } else { + let t = qi / pi; + if pi < 0.0 { + if t > t1 { + return None; + } else if t > t0 { + t0 = t; + } + } else if t < t0 { + return None; + } else if t < t1 { + t1 = t; + } + } + } + Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) +} + +fn draw_trendlines( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + plot: Rect, + band: f64, + back: f64, + ncat: usize, + horiz: bool, + base: f64, + text_col: Rgba, + to_axis: &impl Fn(bool, f64) -> f64, +) { + let mut label_n = 0usize; + for s in &c.series { + if s.trendlines.is_empty() { + continue; + } + let kind = c.series_kind(s); + let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble); + let sec = c.on_secondary(s); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..ncat { + let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + // The same x extent the scatter marks use, so the fit sits on the points. + let (sx0, sx1) = if scatter { + let mut lo = f64::MAX; + let mut hi = f64::MIN; + for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) { + lo = lo.min(v); + hi = hi.max(v); + } + (lo, hi) + } else { + (0.0, 0.0) + }; + for (ti, t) in s.trendlines.iter().take(8).enumerate() { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + let samples = fit.samples(x0, x1); + let mut path = BezPath::new(); + let mut prev: Option = None; + let mut ink_pts = Vec::new(); + let mut any = false; + for w in samples.windows(2) { + let (Some(a), Some(b)) = ( + map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + ) else { + prev = None; + continue; + }; + let Some((ca, cb)) = clip_seg(a, b, plot) else { + prev = None; + continue; + }; + let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6); + if cont { + path.line_to(cb); + } else { + path.move_to(ca); + path.line_to(cb); + } + prev = Some(cb); + if ink_pts.len() < 64 { + ink_pts.push(ca); + ink_pts.push(cb); + } + any = true; + } + if !any { + continue; + } + ctx.set_transform(m); + let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22)); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&path); + let caption = t.caption(&fit); + if caption.is_empty() { + continue; + } + let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1; + let box_h = base * 1.3 * lines as f64 + 2.0; + let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64; + let box_w = (widest * base * 0.62).clamp(48.0, plot.width() * 0.72); + let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h)); + label( + ctx, + rctx, + m, + &caption, + Rect::new(x, y, x + box_w, y + box_h), + (base * 0.9).clamp(8.0, 13.0), + Align::Left, + Anchor::Top, + text_col, + false, + ); + label_n += 1; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -561,4 +815,36 @@ mod tests { assert_eq!(plot, AREA); assert_eq!(o.x, 340.0); } + + #[test] + fn clip_keeps_the_part_inside_the_plot() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped"); + assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6); + assert!((a.y - 25.0).abs() < 1e-6); + assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none()); + let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside"); + assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6); + } + + #[test] + fn caption_keeps_clear_of_a_line_into_the_top_right() { + let plot = Rect::new(0.0, 0.0, 200.0, 100.0); + let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)]; + let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0); + assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}"); + } + + #[test] + fn forecast_reserves_at_most_three_category_slots() { + use deckcraft_model::chart::Trendline; + let mut c = Chart::sample(ChartType::Line); + c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() }); + assert_eq!(forecast_pad(&c), (0.5, 3.0)); + c.series[0].trendlines[0].kind = TrendlineType::MovingAvg; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + c.kind = ChartType::Scatter; + c.series[0].trendlines[0].kind = TrendlineType::Linear; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..470e5505 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,5 +1,5 @@ use super::*; -use deckcraft_model::chart::ChartType; +use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; use deckcraft_model::text::TextBody; use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; @@ -155,6 +155,110 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); } +fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart { + let mut c = Chart::sample(kind); + c.title = Some(title.into()); + c.legend = None; + c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; + c.series.truncate(1); + c.series[0].name = "Data".into(); + c.series[0].values = ys.iter().copied().map(Some).collect(); + c.y_min = Some(0.0); + c.y_max = Some(y_max); + c.series[0].trendlines = vec![trend]; + c +} + +fn trend_line(kind: TrendlineType) -> Trendline { + Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() } +} + +/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit. +fn trend_gallery(draw: bool, forecast: bool) -> Image { + let mut linear = trend_line(TrendlineType::Linear); + if forecast { + linear.forward = Some(1.0); + linear.backward = Some(0.5); + } + let mut poly = trend_line(TrendlineType::Poly); + poly.order = Some(2); + let mut ma = trend_line(TrendlineType::MovingAvg); + ma.period = Some(2); + let mut charts = vec![ + trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0), + trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0), + trend_chart( + "Logarithmic", + ChartType::LineMarkers, + &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()], + trend_line(TrendlineType::Log), + 2.0, + ), + trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0), + trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0), + trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0), + ]; + if !draw { + for c in &mut charts { + c.series[0].trendlines.clear(); + } + } + let shapes = charts + .into_iter() + .enumerate() + .map(|(i, c)| Shape { + id: ShapeId(800 + i as u32), + xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }) + .collect(); + render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn trendlines_draw_through_the_points_with_a_label() { + let on = trend_gallery(true, false); + let off = trend_gallery(false, false); + assert_eq!((on.width, on.height), (960, 540)); + let dark = on + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(off.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(dark > 40, "dashed trendline ink {dark}"); + // A scatter fit uses the real x values and still paints the dash. + let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0); + scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect(); + let sh = Shape { + id: ShapeId(810), + xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), + kind: ShapeKind::Chart(Box::new(scatter.clone())), + ..Default::default() + }; + let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + scatter.series[0].trendlines.clear(); + let sh = + Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() }; + let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let scatter_dark = with + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(without.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}"); + if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() { + let png = trend_gallery(true, true).to_png(); + std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot"); + } +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8c0667e..43afc9e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previously trivial: groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -72,7 +72,7 @@ the round trip even though DeckCraft doesn't model it. | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | Merges and both diagonal borders round-trip | -| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read and written; they are not drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | +| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | @@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Chart trendlines are drawn (linear, exponential, logarithmic, polynomial, power, moving average, forecast, equation and R²) | | 2026-10-14 | minor | Chart trendlines round-trip (`corpus_charts.rs`); merges, diagonal borders, columns, autofit, vertical text and WordArt warp are in that slice | | 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | diff --git a/docs/gaps.md b/docs/gaps.md index 20327e5a..c3b955b2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previous: minor, 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -148,9 +148,10 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are not drawn. A combo - chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back - when the chart is rebuilt. + whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear, + exponential, logarithmic, polynomial, power and moving average, with forecast and the equation + and R² label. A combo chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and + is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -260,6 +261,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open | | 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | diff --git a/docs/roadmap.md b/docs/roadmap.md index 0321e67b..a291388d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines round-trip; the real-deck corpus stays open; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -109,6 +109,7 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Chart trendlines are drawn. The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus (item 2) stays open | | 2026-10-14 | trivial | Chart trendlines round-trip in an owned corpus slice; the real-deck corpus (item 2) stays open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | From 7e4e628b1353ca61d229a178fcb8ae1a9ca15827 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:32:31 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Render:=20omit=20the=20moving-average=20R?= =?UTF-8?q?=C2=B2=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerPoint does not show R² for a moving average, and the value can be negative. The line is still drawn. --- crates/model/src/trendline.rs | 15 ++++++++++++--- docs/gaps.md | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs index eeddd2bd..f893a764 100644 --- a/crates/model/src/trendline.rs +++ b/crates/model/src/trendline.rs @@ -137,8 +137,9 @@ impl Trendline { Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) } - /// The equation and R² lines this trendline asks to show. Empty when neither is on, and a - /// moving average has no equation. + /// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving + /// average has no equation, and PowerPoint does not show its R², so that label is left off + /// even when the file asks for it. pub fn caption(&self, fit: &TrendFit) -> String { let mut lines = Vec::new(); if self.disp_eq @@ -146,7 +147,7 @@ impl Trendline { { lines.push(eq); } - if self.disp_r_sqr { + if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg { lines.push(fit.r2_label()); } lines.join("\n") @@ -599,6 +600,14 @@ mod tests { assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0)); } + #[test] + fn moving_average_shows_neither_equation_nor_r2() { + let t = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), disp_eq: true, disp_r_sqr: true, ..Default::default() }; + let fit = t.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]).expect("ma"); + assert!(fit.r2_label().starts_with("R² =")); + assert!(t.caption(&fit).is_empty(), "{}", t.caption(&fit)); + } + #[test] fn too_few_points_and_non_finite_input_fit_nothing() { assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none()); diff --git a/docs/gaps.md b/docs/gaps.md index c3b955b2..831deb9e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,7 +150,8 @@ Detail in [file-format-parity.md](file-format-parity.md). chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation - and R² label. A combo chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and + and R² label. A moving average is drawn with neither label. A combo chart's secondary value axis + is read (`y2_*`, `Series.secondary_axis`) and is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -261,6 +262,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A moving average trendline is drawn without an equation or an R² label | | 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open | | 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |