mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
PPTX: round-trip chart trendlines
An owned corpus slice checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp. Trendlines (type, order, period, forecast, intercept, equation and R²) are now read and written.
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **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
|
||||
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
@@ -202,6 +202,11 @@ pub(crate) fn chart_from(root: &El) -> 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"))
|
||||
@@ -285,6 +290,33 @@ pub(crate) fn chart_from(root: &El) -> 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> {
|
||||
@@ -388,4 +420,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 & 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; 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 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**
|
||||
@@ -71,8 +71,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` and date fields |
|
||||
| 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 | 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 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
-4
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; 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 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
|
||||
@@ -59,9 +59,11 @@ revision history.
|
||||
Detail in [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
### PPTX has never met a corpus of real decks
|
||||
- **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.
|
||||
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 unknown.
|
||||
- **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules
|
||||
@@ -146,7 +148,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).
|
||||
|
||||
@@ -256,6 +260,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# DeckCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **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
|
||||
@@ -109,5 +109,6 @@ plan above.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user