Merge PR #590 from NikitkaRa: PPTX: round-trip chart trendlines

This commit is contained in:
Brandon Thomas
2026-10-14 13:08:57 -04:00
8 changed files with 393 additions and 5 deletions
+3
View File
@@ -13,6 +13,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus for comments, links, custom shows, groups and inherited placeholders; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: PPTX corpus for models, media and timing) · **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 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)
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
@@ -159,6 +160,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 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 |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -196,6 +198,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 |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
+84
View File
@@ -116,6 +116,87 @@ impl ChartType {
}
}
/// `c:trendlineType`: the six trendlines DrawingML charts store.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TrendlineType {
Exp,
#[default]
Linear,
Log,
MovingAvg,
Poly,
Power,
}
impl TrendlineType {
pub fn xml(self) -> &'static str {
match self {
TrendlineType::Exp => "exp",
TrendlineType::Linear => "linear",
TrendlineType::Log => "log",
TrendlineType::MovingAvg => "movingAvg",
TrendlineType::Poly => "poly",
TrendlineType::Power => "power",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
Some(match s {
"exp" => TrendlineType::Exp,
"linear" => TrendlineType::Linear,
"log" => TrendlineType::Log,
"movingAvg" => TrendlineType::MovingAvg,
"poly" => TrendlineType::Poly,
"power" => TrendlineType::Power,
_ => return None,
})
}
}
/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Trendline {
pub kind: TrendlineType,
/// `c:name`, when the file sets one.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Polynomial order (`c:order`).
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
/// Moving-average period (`c:period`).
#[serde(skip_serializing_if = "Option::is_none")]
pub period: Option<u32>,
/// Forecast forward and backward, in category units (`c:forward`, `c:backward`).
#[serde(skip_serializing_if = "Option::is_none")]
pub forward: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backward: Option<f64>,
/// Cross the value axis at this value (`c:intercept`).
#[serde(skip_serializing_if = "Option::is_none")]
pub intercept: Option<f64>,
/// Show the equation (`c:dispEq`).
pub disp_eq: bool,
/// Show R² (`c:dispRSqr`).
pub disp_r_sqr: bool,
}
impl Default for Trendline {
fn default() -> Self {
Trendline {
kind: TrendlineType::Linear,
name: None,
order: None,
period: None,
forward: None,
backward: None,
intercept: None,
disp_eq: false,
disp_r_sqr: false,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Series {
@@ -138,6 +219,9 @@ pub struct Series {
/// source data's.
#[serde(skip_serializing_if = "Option::is_none")]
pub number_format: Option<String>,
/// Trendlines (`c:trendline`), in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub trendlines: Vec<Trendline>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+52 -1
View File
@@ -1,6 +1,6 @@
//! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`.
use deckcraft_model::chart::{Chart, ChartType, Series};
use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType};
use deckcraft_model::style::Fill;
use crate::xml::El;
@@ -224,6 +224,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
ser.data_labels = Some(d);
}
for t in s.children_named("trendline").take(16) {
if let Some(t) = trendline(t) {
ser.trendlines.push(t);
}
}
// Data labels' number format: the series' labels', the group's, else the source data's.
let label_fmt = |d: Option<&El>| {
d.and_then(|d| d.child("numFmt"))
@@ -307,6 +312,33 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
Some(c)
}
fn trend_num(e: &El, k: &str) -> Option<f64> {
e.child_val(k).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && v.abs() <= 1.0e12)
}
fn trend_name(s: &str) -> Option<String> {
let cleaned: String =
s.chars().filter(|c| matches!(c, '\t' | '\n' | '\r') || (*c >= ' ' && *c != '\u{FFFE}' && *c != '\u{FFFF}')).take(255).collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() { None } else { Some(cleaned.to_string()) }
}
/// One `c:trendline`. Unknown types are skipped.
fn trendline(e: &El) -> Option<Trendline> {
let kind = TrendlineType::from_xml(e.child_val("trendlineType")?)?;
Some(Trendline {
kind,
name: e.child("name").map(|n| n.text()).as_deref().and_then(trend_name),
order: if kind == TrendlineType::Poly { e.child("order").and_then(|o| o.u32("val")).map(|v| v.clamp(2, 6)) } else { None },
period: if kind == TrendlineType::MovingAvg { e.child("period").and_then(|o| o.u32("val")).map(|v| v.clamp(2, 255)) } else { None },
forward: trend_num(e, "forward"),
backward: trend_num(e, "backward"),
intercept: trend_num(e, "intercept"),
disp_eq: e.child("dispEq").and_then(|d| d.bool("val")).unwrap_or(false),
disp_r_sqr: e.child("dispRSqr").and_then(|d| d.bool("val")).unwrap_or(false),
})
}
/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths).
fn text_size(e: Option<&El>) -> Option<f64> {
fn rec(e: &El, depth: usize) -> Option<f64> {
@@ -436,4 +468,23 @@ mod tests {
);
assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one");
}
#[test]
fn trendlines_are_read() {
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx>
<c:trendline><c:name>Fit &amp; forecast</c:name><c:trendlineType val="linear"/><c:forward val="1"/><c:backward val="0.5"/><c:intercept val="0"/><c:dispRSqr val="1"/><c:dispEq val="1"/></c:trendline>
<c:trendline><c:trendlineType val="poly"/><c:order val="9"/></c:trendline>
<c:trendline><c:trendlineType val="movingAvg"/><c:period val="3"/></c:trendline>
<c:trendline><c:trendlineType val="notAType"/></c:trendline>
<c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:lineChart></c:plotArea></c:chart></c:chartSpace>"#;
let c = read_chart_xml(xml).unwrap();
let t = &c.series[0].trendlines;
assert_eq!(t.len(), 3);
assert_eq!(t[0].name.as_deref(), Some("Fit & forecast"));
assert_eq!(t[0].kind, TrendlineType::Linear);
assert_eq!((t[0].forward, t[0].backward, t[0].intercept), (Some(1.0), Some(0.5), Some(0.0)));
assert!(t[0].disp_eq && t[0].disp_r_sqr);
assert_eq!((t[1].kind, t[1].order), (TrendlineType::Poly, Some(6)));
assert_eq!((t[2].kind, t[2].period), (TrendlineType::MovingAvg, Some(3)));
}
}
+52 -1
View File
@@ -1,6 +1,6 @@
//! Chart parts: a `c:chartSpace` with cached categories and values (no embedded workbook).
use deckcraft_model::chart::{Chart, ChartType, Series};
use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType};
use deckcraft_model::style::Fill;
use crate::opc::{NS_A, NS_C, NS_R};
@@ -174,6 +174,12 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
if let Some(d) = s.data_labels {
data_labels(w, d);
}
// After labels, before the category and value caches (ECMA-376 series content order).
if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) {
for t in s.trendlines.iter().take(16) {
trendline(w, t);
}
}
let n = c.categories.len().max(s.values.len());
let last = n + 1;
if matches!(g, Group::Scatter | Group::Bubble) {
@@ -207,6 +213,51 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
w.close("c:ser");
}
fn trend_num(v: Option<f64>) -> Option<f64> {
v.filter(|v| v.is_finite() && v.abs() <= 1.0e12)
}
fn trend_name(s: &str) -> Option<String> {
let cleaned: String =
s.chars().filter(|c| matches!(c, '\t' | '\n' | '\r') || (*c >= ' ' && *c != '\u{FFFE}' && *c != '\u{FFFF}')).take(255).collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() { None } else { Some(cleaned.to_string()) }
}
fn trendline(w: &mut W, t: &Trendline) {
w.open0("c:trendline");
if let Some(name) = t.name.as_deref().and_then(trend_name) {
w.elt("c:name", &name);
}
w.val("c:trendlineType", t.kind.xml());
if t.kind == TrendlineType::Poly
&& let Some(o) = t.order
{
w.val("c:order", o.clamp(2, 6));
}
if t.kind == TrendlineType::MovingAvg
&& let Some(p) = t.period
{
w.val("c:period", p.clamp(2, 255));
}
if let Some(v) = trend_num(t.forward) {
w.val("c:forward", fmt_num(v));
}
if let Some(v) = trend_num(t.backward) {
w.val("c:backward", fmt_num(v));
}
if let Some(v) = trend_num(t.intercept) {
w.val("c:intercept", fmt_num(v));
}
if t.disp_r_sqr {
w.val("c:dispRSqr", 1);
}
if t.disp_eq {
w.val("c:dispEq", 1);
}
w.close("c:trendline");
}
fn data_labels(w: &mut W, show: bool) {
w.open0("c:dLbls");
for (n, v) in [
+187
View File
@@ -0,0 +1,187 @@
//! Owned corpus slice: combo charts (labels and trendlines), merged tables with diagonal
//! borders, and text columns, autofit, vertical text and a WordArt warp.
//!
//! A combo series can sit on the secondary value axis (`secondary_axis`, `y2_*`). That axis is
//! read from files; rebuilding a chart does not write it yet, so this test does not require it
//! to survive.
use std::sync::Arc;
use deckcraft_color::Rgba;
use deckcraft_geom::Xfrm;
use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType};
use deckcraft_model::style::{ColorRef, Line};
use deckcraft_model::table::Table;
use deckcraft_model::text::{AutoFit, TextBody, TextDir};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults};
fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() }
}
fn round(p: &Presentation) -> Presentation {
let bytes = deckcraft_pptx::export(p).expect("export");
deckcraft_pptx::import(&bytes).expect("import")
}
fn by_name<'a>(s: &'a Slide, name: &str) -> &'a Shape {
s.shapes.iter().find(|x| x.name == name).unwrap_or_else(|| panic!("no shape {name}"))
}
fn plain(t: &TextBody) -> String {
t.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>()).collect::<Vec<_>>().join("\n")
}
fn trend(kind: TrendlineType) -> Trendline {
Trendline { kind, ..Default::default() }
}
fn corpus() -> Presentation {
let mut p = defaults::new_presentation(None);
p.props.title = "Chart and table corpus".into();
let mut s = {
let layout = defaults::layout_of_kind(&p, deckcraft_model::LayoutType::Blank).unwrap_or_default();
defaults::new_slide(&mut p, layout)
};
let chart = Chart {
kind: ChartType::Combo,
title: Some("Spend and clicks".into()),
categories: vec!["W1".into(), "W2".into(), "W3".into(), "W4".into()],
data_labels: true,
y2_max: Some(8000.0),
y2_format: Some("0.0".into()),
series: vec![
Series {
name: "Spend".into(),
values: vec![Some(200.0), Some(310.0), Some(280.0), Some(360.0)],
kind: Some(ChartType::Column),
trendlines: vec![
Trendline { kind: TrendlineType::Poly, name: Some("Cubic".into()), order: Some(3), ..Default::default() },
trend(TrendlineType::Exp),
],
..Default::default()
},
Series {
name: "Clicks".into(),
values: vec![Some(6000.0), Some(7400.0), Some(7100.0), Some(7900.0)],
kind: Some(ChartType::LineMarkers),
secondary_axis: true,
data_labels: Some(false),
trendlines: vec![
Trendline {
kind: TrendlineType::Linear,
name: Some("Fit & forecast".into()),
forward: Some(1.0),
backward: Some(0.5),
intercept: Some(0.0),
disp_eq: true,
disp_r_sqr: true,
..Default::default()
},
trend(TrendlineType::Log),
trend(TrendlineType::Power),
Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() },
],
..Default::default()
},
],
..Default::default()
};
let mut ch = shape(&mut p, "Combo", 40.0, 40.0, 420.0, 260.0);
ch.kind = ShapeKind::Chart(Box::new(chart));
s.shapes.push(ch);
let mut table = Table::new(3, 3, 360.0, 28.0);
if let Some(cell) = table.cell_mut(0, 0) {
cell.text = TextBody::from_text("Merged");
}
assert!(table.merge(0, 0, 1, 1));
if let Some(cell) = table.cell_mut(0, 2) {
cell.text = TextBody::from_text("Diag");
cell.diag_down = Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5));
cell.diag_up = Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0));
}
let mut ts = shape(&mut p, "Merged table", 40.0, 320.0, 360.0, 84.0);
ts.kind = ShapeKind::Table(table);
s.shapes.push(ts);
let mut columns = TextBody::from_text("Two columns\nof vertical text");
columns.body.columns = Some(3);
columns.body.col_spacing = Some(18.0);
columns.body.autofit = Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 });
columns.body.vert = Some(TextDir::Vertical270);
let mut col = shape(&mut p, "Columns", 480.0, 40.0, 200.0, 160.0);
col.text = Some(columns);
s.shapes.push(col);
let mut warp = TextBody::from_text("Arch");
warp.body.warp = Some("textArchUp".into());
warp.body.warp_adj = vec![5_000_000.0];
warp.body.autofit = Some(AutoFit::Shape);
let mut word = shape(&mut p, "Warp", 480.0, 220.0, 200.0, 80.0);
word.text = Some(warp);
s.shapes.push(word);
p.slides.push(Arc::new(s));
p
}
fn chart_of(s: &Slide) -> &Chart {
let ShapeKind::Chart(c) = &by_name(s, "Combo").kind else { panic!("not a chart") };
c
}
#[test]
fn chart_table_and_text_corpus_round_trips() {
let p = corpus();
let q = round(&p);
let slide = &q.slides[1];
let src = chart_of(&p.slides[1]);
let got = chart_of(slide);
assert_eq!(got.kind, ChartType::Combo);
assert_eq!(got.title.as_deref(), Some("Spend and clicks"));
assert_eq!(got.categories, src.categories);
assert!(got.data_labels);
assert_eq!(got.series.len(), 2);
for (a, b) in src.series.iter().zip(got.series.iter()) {
assert_eq!(a.name, b.name);
assert_eq!(a.values, b.values);
assert_eq!(a.kind, b.kind);
assert_eq!(a.data_labels, b.data_labels);
assert_eq!(a.trendlines, b.trendlines);
}
let twice = round(&q);
let again = chart_of(&twice.slides[1]);
for (a, b) in got.series.iter().zip(again.series.iter()) {
assert_eq!(a.trendlines, b.trendlines);
assert_eq!(a.data_labels, b.data_labels);
}
let ShapeKind::Table(t) = &by_name(slide, "Merged table").kind else { panic!("not a table") };
let anchor = t.cell(0, 0).expect("anchor");
assert_eq!((anchor.grid_span, anchor.row_span), (2, 2));
assert!(!anchor.h_merge && !anchor.v_merge);
assert_eq!(plain(&anchor.text), "Merged");
assert!(t.cell(0, 1).is_some_and(|c| c.h_merge && !c.v_merge));
assert!(t.cell(1, 0).is_some_and(|c| !c.h_merge && c.v_merge));
assert!(t.cell(1, 1).is_some_and(|c| c.h_merge && c.v_merge));
let diag = t.cell(0, 2).expect("diag");
assert_eq!(diag.diag_down, Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5)));
assert_eq!(diag.diag_up, Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0)));
assert_eq!(plain(&diag.text), "Diag");
let cols = by_name(slide, "Columns").text.as_ref().expect("columns");
assert_eq!(plain(cols), "Two columns\nof vertical text");
assert_eq!(cols.body.columns, Some(3));
assert_eq!(cols.body.col_spacing, Some(18.0));
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 }));
assert_eq!(cols.body.vert, Some(TextDir::Vertical270));
let warp = by_name(slide, "Warp").text.as_ref().expect("warp");
assert_eq!(plain(warp), "Arch");
assert_eq!(warp.body.warp.as_deref(), Some("textArchUp"));
assert_eq!(warp.body.warp_adj, vec![5_000_000.0]);
assert_eq!(warp.body.autofit, Some(AutoFit::Shape));
}
+4 -2
View File
@@ -8,6 +8,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders; paragraph `defRPr` and action sounds kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **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)
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**
@@ -86,8 +87,8 @@ the round trip even though DeckCraft doesn't model it.
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow`, date fields, gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing (`corpus_style.rs`) |
| 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 | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
| 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 (`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) |
@@ -123,6 +124,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 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 |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
+9 -1
View File
@@ -9,6 +9,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned PPTX corpus for comments, links, custom shows, groups and inherited placeholders; paragraph defaults and action sounds kept; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors; run effect lists and bevel presets kept; 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 (owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end round-trip; the real-deck corpus and #70/#74/#77 stay open) · **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; 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)
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
@@ -109,10 +110,14 @@ Detail in [file-format-parity.md](file-format-parity.md).
(saved as freeform lines), `.glb` 3-D models, and animation repeat, bounce end and triggers.
Playback options (#74) and rewind (#77) are still lost.
- **Evidence:** every PPTX test (`crates/pptx/tests`, 30 tests) uses decks DeckCraft generates;
- **Evidence:** every PPTX test (`crates/pptx/tests`) uses decks DeckCraft generates;
the 2026-10-07 alpha checklist already listed this as open. No reference renders from PowerPoint
exist to compare against. `crates/pptx/tests/corpus_themes.rs` starts on PowerPoint-shaped XML:
themes and two masters with their layouts, shadow/glow/soft-edge/reflection effects, and text
and shape hyperlinks and actions (text Mouse Over actions were dropped; now kept).
exist to compare against. `corpus_charts.rs` round-trips one owned slice (a combo chart with
data labels and trendlines, a merged table with diagonal borders, text columns, autofit,
vertical text and a WordArt warp). That is still a generated deck, not a corpus of real ones.
- **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks
(corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown.
- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules
@@ -197,7 +202,9 @@ 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.
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.
- **Impact:** most business decks have charts; editing their data is routine.
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
@@ -342,6 +349,7 @@ 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 | 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
| 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 |
+2
View File
@@ -6,6 +6,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus covers comments, links, custom shows, groups and inherited placeholders; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus covers text effects, body layout, theme effects, fills, bevels and connectors; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (`corpus_media.rs` keeps 3-D models and animation bounce end; real-deck corpus still open) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **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)
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
@@ -137,5 +138,6 @@ 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 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 |