Merge PR #603 from NikitkaRa: Render: draw chart trendlines

# Conflicts:
#	crates/render/src/chart.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:47:08 -04:00
9 changed files with 1067 additions and 8 deletions
+3
View File
@@ -19,6 +19,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: previous and next comment; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (View › New Window; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (six original starter decks; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **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
@@ -165,6 +166,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open |
| 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open |
| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open |
| 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-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design |
| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu. Breadth stays 79% |
@@ -209,6 +211,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders |
| 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors |
| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end |
| 2026-10-14 | trivial | Progress log: chart trendlines are drawn |
| 2026-10-14 | trivial | Progress log: chart trendlines |
| 2026-10-14 | trivial | Progress log: Lucide icon gallery |
| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% |
+30
View File
@@ -487,6 +487,17 @@ impl Chart {
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 {
lo = lo.min(0.0);
hi = hi.max(1.0);
@@ -579,6 +590,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))
}
}
@@ -820,6 +839,17 @@ mod tests {
assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]);
}
#[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);
+1
View File
@@ -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;
+623
View File
@@ -0,0 +1,623 @@
//! 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<f64>),
/// `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<TrendFit> {
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<f64> = 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<f64> = 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<f64>, Vec<f64>) = 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<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = 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<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip();
let (b, c) = linreg(&ln_x, &ys, intercept)?;
let yhat: Vec<f64> = 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<f64>, Vec<f64>) = 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<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = 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::<f64>() / 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. 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
&& let Some(eq) = fit.equation()
{
lines.push(eq);
}
if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg {
lines.push(fit.r2_label());
}
lines.join("\n")
}
}
impl TrendFit {
pub fn y(&self, x: f64) -> Option<f64> {
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<f64>, backward: Option<f64>) -> (f64, f64) {
let pad = |v: Option<f64>| 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<String> {
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<f64> {
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<f64>, Vec<f64>) {
pairs.iter().map(|(x, y)| (*x, *y)).unzip()
}
/// `y = b + m x`. `intercept` fixes `b`.
fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> 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<f64>) -> Option<Vec<f64>> {
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::<f64>() / n as f64;
let us: Vec<f64> = 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(&centred, 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<f64> {
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<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
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::<f64>() / 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<String> {
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 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());
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");
}
}
+292 -6
View File
@@ -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};
@@ -403,11 +403,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
}
}
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,
@@ -461,7 +465,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
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 waterfall {
c.waterfall_bars().get(i).copied().unwrap_or_default()
} else if stacked {
@@ -566,9 +570,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
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) }
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)));
}
@@ -606,6 +610,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
}
}
}
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 {
@@ -628,6 +633,255 @@ pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 {
if v > hi { hi } else { v }
}
/// 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>) -> 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<Point> {
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<Point> = 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::*;
@@ -663,4 +917,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));
}
}
+105 -1
View File
@@ -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 chart_axis_titles_are_drawn() {
let draw = |titles: (Option<String>, Option<String>)| {
+3 -1
View File
@@ -9,6 +9,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for text effects, body layout, theme effects, fills, bevels and connectors; a run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, survive a save; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (3-D model parts and animation bounce end round-trip; `corpus_media.rs` covers SmartArt and OLE XML, media bookmarks and trims, sections, fields, ink and timing) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **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**
@@ -88,7 +89,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; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) |
@@ -128,6 +129,7 @@ the round trip even though DeckCraft doesn't model it.
| 2026-10-13 | minor | Owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds (including custom-show return) survive a save |
| 2026-10-13 | minor | Owned corpus for text effects, body layout, theme effects, fills, bevels and connectors. A run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, survive a save |
| 2026-10-14 | minor | 3-D models (`.glb`) round-trip, including an alternate-content fallback picture; animation bounce end is read and written (`corpus_media.rs`) |
| 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 |
+8
View File
@@ -13,6 +13,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Icons places a Lucide stroke set; 3-D models and stock content remain; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog; previous: minor, 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (six original starter decks on File › New; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **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
@@ -219,6 +220,11 @@ Detail in [file-format-parity.md](file-format-parity.md).
or lines), stock charts as high-low lines with close dashes or up/down bars and saved as
`c:stockChart`, and combo charts start as columns with a line. Secondary-axis writing and
trendlines are tracked separately.
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 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).
@@ -373,6 +379,8 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-13 | minor | Benchmark suite (`cargo xtask bench`, `deckcraft-cli bench`): the performance gap is now frame rate and a PowerPoint comparison |
| 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open |
| 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip |
| 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-14 | trivial | 3-D contours |
| 2026-10-14 | trivial | 3-D light rigs and materials; extrusion sides lit per side |
+2
View File
@@ -9,6 +9,7 @@
> **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 (Review previous/next comment; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (six original starter decks; the large template gallery stays after beta; 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
@@ -143,6 +144,7 @@ plan above.
| 2026-10-13 | trivial | Owned PPTX corpus covers comments, links and actions, custom shows, rotated groups and inherited placeholders. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 |
| 2026-10-13 | trivial | Owned PPTX corpus covers text effects, body layout, theme effects, fills, bevels and connectors. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 |
| 2026-10-14 | trivial | Current focus: `corpus_media.rs` keeps 3-D models and animation bounce end; the real-deck corpus stays open |
| 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-14 | trivial | Review workflow: previous and next comment across the deck |
| 2026-10-14 | minor | After beta: six original starter decks exist; a larger template gallery remains |