Merge PR #355 from NikitkaRa: Charts: keep the embedded workbook when a chart is saved unchanged (#335)

# Conflicts:
#	contributors/people.toml
#	crates/pptx/src/opc.rs
#	crates/pptx/src/read/chart.rs
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/src/write/chart.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:23:07 -04:00
11 changed files with 377 additions and 14 deletions
+3
View File
@@ -42,6 +42,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: 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: pen pressure scales ink width; 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-13 · **Last updated:** 2026-10-13 · **Change:** trivial (unchanged charts keep their embedded workbook, #335) · **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
@@ -232,6 +233,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |
| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) |
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
| 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 |
@@ -306,6 +308,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | trivial | Progress log: Font dialog |
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
| 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) |
+3
View File
@@ -48,3 +48,6 @@ sync_github_name = true
[people.Momentum96]
sync_github_name = true
[people.NikitkaRa]
real_name = "Nikita Rakitin"
+30
View File
@@ -392,6 +392,35 @@ pub struct Chart {
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
/// Package parts the original chart still points at (embedded workbook, styles, drawings).
/// The writer clears this before comparing modelled state. A native save does not store the bytes.
#[serde(skip)]
pub kept: ChartKept,
}
/// One relationship of a chart part, with the target string as it was written.
#[derive(Clone, Debug, PartialEq)]
pub struct ChartRel {
pub id: String,
pub rel_type: String,
pub target: String,
pub external: bool,
}
/// A part reached from a chart (the workbook, a drawing, an image it uses).
#[derive(Clone, Debug, PartialEq)]
pub struct ChartPart {
/// Absolute part name (`ppt/embeddings/Workbook.xlsx`).
pub name: String,
pub content_type: String,
pub bytes: Vec<u8>,
}
/// Sidecar for an unchanged chart: relationships and the parts they reach.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ChartKept {
pub rels: Vec<ChartRel>,
pub parts: Vec<ChartPart>,
}
impl Default for Chart {
@@ -438,6 +467,7 @@ impl Default for Chart {
bin_count: None,
bin_size: None,
raw: None,
kept: ChartKept::default(),
}
}
}
+53 -2
View File
@@ -132,8 +132,16 @@ impl Package {
for r in doc.root.children_named("Relationship") {
let (Some(id), Some(ty), Some(target)) = (r.attr("Id"), r.attr("Type"), r.attr("Target")) else { continue };
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external"));
let target = if external { target.to_string() } else { resolve(&base, target) };
out.items.push(Rel { id: id.to_string(), kind: ty.rsplit('/').next().unwrap_or(ty).to_string(), target, external });
let target_raw = target.to_string();
let target = if external { target_raw.clone() } else { resolve(&base, target) };
out.items.push(Rel {
id: id.to_string(),
kind: ty.rsplit('/').next().unwrap_or(ty).to_string(),
ty: ty.to_string(),
target_raw,
target,
external,
});
}
out
}
@@ -162,6 +170,10 @@ pub struct Rel {
pub id: String,
/// Last segment of the relationship type (`slide`, `image`, `media`…).
pub kind: String,
/// Full relationship type URI.
pub ty: String,
/// `Target` as written in the rels part (relative path or URL).
pub target_raw: String,
/// Absolute part name for internal targets; the URL for external ones.
pub target: String,
pub external: bool,
@@ -191,6 +203,28 @@ pub fn norm(name: &str) -> String {
name.trim_start_matches('/').replace('\\', "/").to_ascii_lowercase()
}
/// A part a chart may carry along beside its own `chartN.xml` (workbook, style, drawing, picture).
/// Chart documents themselves are rewritten, and nothing outside these folders is copied: a relationship
/// must not be able to replace `ppt/slides/slide1.xml` on save.
pub fn sidecar_part(name: &str) -> bool {
let n = norm(name);
let segs: Vec<&str> = n.split('/').filter(|s| !s.is_empty()).collect();
if segs.iter().any(|s| *s == "." || *s == "..") {
return false;
}
let (Some(root), Some(area)) = (segs.first().copied(), segs.get(1).copied()) else { return false };
if root != "ppt" || !matches!(area, "embeddings" | "charts" | "drawings" | "media") {
return false;
}
let file = segs.last().copied().unwrap_or("");
let stem = file.strip_prefix("chart").unwrap_or("");
let num = stem.strip_suffix(".xml.rels").or_else(|| stem.strip_suffix(".xml")).unwrap_or("");
if stem != file && !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
return false;
}
true
}
pub fn dir_of(part: &str) -> String {
let p = part.trim_start_matches('/');
match p.rfind('/') {
@@ -343,6 +377,10 @@ impl PackageOut {
pub fn raw(&mut self, name: &str, data: Vec<u8>) {
self.files.push((name.to_string(), data, true));
}
pub fn part_data(&self, name: &str) -> Option<&[u8]> {
let n = norm(name);
self.files.iter().find(|(f, _, _)| norm(f) == n).map(|(_, d, _)| d.as_slice())
}
/// Add a binary part; its extension gets a Default content type.
pub fn binary(&mut self, name: &str, content_type: &str, data: Vec<u8>) {
let ext = name.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase();
@@ -429,4 +467,17 @@ mod tests {
assert_eq!(relative("ppt/presentation.xml", "ppt/slides/slide1.xml"), "slides/slide1.xml");
assert_eq!(relative("ppt/slides/slide1.xml", "ppt/slides/slide2.xml"), "slide2.xml");
}
#[test]
fn sidecar_parts_stay_inside_chart_folders() {
assert!(sidecar_part("ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx"));
assert!(sidecar_part("ppt/charts/style1.xml"));
assert!(sidecar_part("ppt/drawings/chartDrawing1.xml"));
assert!(sidecar_part("ppt/media/image1.png"));
assert!(!sidecar_part("ppt/charts/chart1.xml"));
assert!(!sidecar_part("ppt/charts/_rels/chart1.xml.rels"));
assert!(!sidecar_part("ppt/slides/slide1.xml"));
assert!(!sidecar_part("ppt/embeddings/../../ppt/slides/slide1.xml"));
assert!(!sidecar_part("/etc/passwd"));
}
}
+85 -2
View File
@@ -405,6 +405,89 @@ fn text_color(e: Option<&El>) -> Option<deckcraft_model::ColorRef> {
rec(e?, 0)
}
const MAX_KEPT_PARTS: usize = 256;
const MAX_KEPT_DEPTH: usize = 8;
/// Relationships and parts a chart points at, so a later save can write them back.
pub(crate) fn capture_kept(pkg: &crate::opc::Package, chart_part: &str) -> deckcraft_model::chart::ChartKept {
use deckcraft_model::chart::{ChartKept, ChartRel};
let mut kept = ChartKept::default();
// The chart document itself is rewritten; don't store a second copy of it.
let mut seen = std::collections::HashMap::from([(crate::opc::norm(chart_part), true)]);
for r in pkg.rels(chart_part).items {
if !safe_rel(&r.id, &r.ty, &r.target_raw) {
log::warn!("pptx: skipping chart relationship {}", r.id);
continue;
}
if r.external {
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, external: true });
continue;
}
if copy_part(pkg, &r.target, &mut kept.parts, &mut seen, 0) {
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, external: false });
}
}
kept
}
fn safe_rel(id: &str, ty: &str, target: &str) -> bool {
[id, ty, target].iter().all(|s| !s.is_empty() && s.len() <= 4096 && !s.chars().any(|c| c.is_control() || matches!(c, '<' | '>' | '"' | '\'')))
}
/// `true` when `part` is stored (now or from an earlier relationship).
fn copy_part(
pkg: &crate::opc::Package,
part: &str,
out: &mut Vec<deckcraft_model::chart::ChartPart>,
seen: &mut std::collections::HashMap<String, bool>,
depth: usize,
) -> bool {
use deckcraft_model::chart::ChartPart;
if depth > MAX_KEPT_DEPTH || out.len() >= MAX_KEPT_PARTS {
log::warn!("pptx: chart part closure stopped at {part}");
return false;
}
let key = crate::opc::norm(part);
if let Some(ok) = seen.get(&key) {
return *ok;
}
if !crate::opc::sidecar_part(part) {
log::warn!("pptx: chart relationship target {part} is not kept");
seen.insert(key, false);
return false;
}
let Some(bytes) = pkg.get(part) else {
log::warn!("pptx: chart relationship target {part} is missing");
seen.insert(key, false);
return false;
};
let name = pkg.original_name(part).unwrap_or(part).trim_start_matches('/').to_string();
if !crate::opc::sidecar_part(&name) {
log::warn!("pptx: chart part name {name} is not kept");
seen.insert(key, false);
return false;
}
let content_type = pkg.content_type(part).unwrap_or_default();
out.push(ChartPart { name, content_type, bytes: bytes.to_vec() });
seen.insert(key, true);
let rels_name = crate::opc::rels_path(part);
if crate::opc::sidecar_part(&rels_name)
&& seen.get(&crate::opc::norm(&rels_name)).is_none()
&& let Some(rels_bytes) = pkg.get(&rels_name)
{
seen.insert(crate::opc::norm(&rels_name), true);
let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string();
out.push(ChartPart { name, content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), bytes: rels_bytes.to_vec() });
let targets: Vec<String> = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect();
for target in targets {
copy_part(pkg, &target, out, seen, depth + 1);
}
}
true
}
/// 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> {
@@ -460,10 +543,10 @@ mod tests {
fn minor_gridlines_are_read_and_written_back() {
let mut c = Chart::sample(ChartType::Line);
c.minor_gridlines = true;
let back = read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap();
let back = read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap();
assert!(back.gridlines && back.minor_gridlines);
c.minor_gridlines = false;
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap().minor_gridlines);
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap().minor_gridlines);
}
#[test]
+2 -1
View File
@@ -422,8 +422,9 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
if let Some(c) = gd.child("chart")
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
&& let Some(chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
&& let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
{
chart.kept = super::chart::capture_kept(imp.pkg, &rel.target);
s.kind = ShapeKind::Chart(Box::new(chart));
return s;
}
+42 -8
View File
@@ -1,4 +1,5 @@
//! Chart parts: a `c:chartSpace` with cached categories and values (no embedded workbook).
//! Chart parts: a `c:chartSpace` with cached categories and values.
//! An unchanged chart also keeps the embedded workbook and the other parts it points at.
use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType};
use deckcraft_model::style::Fill;
@@ -534,8 +535,8 @@ fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
Some(w.finish())
}
/// The chart part XML.
pub fn chart_xml(c: &Chart) -> Vec<u8> {
/// The chart part XML, and whether it is the original part (so its relationships still apply).
pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
// Reuse the kept part when the chart is unchanged since it was read.
if let Some(raw) = &c.raw
&& !raw.contains(NS_CX)
@@ -547,12 +548,17 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
if let Some(back) = crate::read::chart_from_el(&root) {
let mut me = c.clone();
me.raw = None;
// The sidecar is not modelled chart state. Comparing it would throw away an unchanged chart.
me.kept = Default::default();
if back == me {
let mut keep = doc.root;
keep.remove_all(&["externalData", "userShapes"]);
// Without the parts those elements point at, PowerPoint repairs the file.
if c.kept.rels.is_empty() {
keep.remove_all(&["externalData", "userShapes"]);
}
let mut w = W::new();
w.raw(&keep.to_xml());
return w.finish();
return (w.finish(), true);
}
}
}
@@ -651,7 +657,35 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
sp_pr(&mut w, Some(f), None);
}
w.close("c:chartSpace");
w.finish()
(w.finish(), false)
}
/// Write the chart's own relationships and the parts they reach.
pub fn write_kept(pkg: &mut crate::opc::PackageOut, chart_name: &str, c: &Chart) {
if c.kept.rels.is_empty() && c.kept.parts.is_empty() {
return;
}
let mut rels = crate::opc::RelsOut::default();
for r in &c.kept.rels {
rels.items.push((r.id.clone(), r.rel_type.clone(), r.target.clone(), r.external));
}
pkg.rels(chart_name, &rels);
for p in &c.kept.parts {
match pkg.part_data(&p.name) {
Some(existing) if existing != p.bytes.as_slice() => {
log::warn!("pptx: chart part {} already written with different bytes; keeping the first", p.name);
continue;
}
Some(_) => continue,
None => {}
}
if !crate::opc::sidecar_part(&p.name) {
log::warn!("pptx: not writing chart part {}", p.name);
continue;
}
let ct = if !p.content_type.is_empty() { p.content_type.clone() } else { crate::mime_for(&p.name).to_string() };
pkg.binary(&p.name, &ct, p.bytes.clone());
}
}
/// A `cx:tx` holding plain text.
@@ -862,10 +896,10 @@ mod tests {
fn reversed_category_axis_round_trips() {
let mut c = Chart::sample(ChartType::Bar);
c.cat_reversed = true;
let xml = super::chart_xml(&c);
let xml = super::chart_xml(&c).0;
assert!(String::from_utf8_lossy(&xml).contains(r#"<c:orientation val="maxMin"/>"#));
assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed);
c.cat_reversed = false;
assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c)).unwrap().cat_reversed);
assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c).0).unwrap().cat_reversed);
}
}
+7 -1
View File
@@ -205,7 +205,13 @@ impl Exp<'_> {
pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String {
self.n_chart += 1;
let name = format!("ppt/charts/chart{}.xml", self.n_chart);
self.pkg.xml_part(&name, CT_CHART, chart::chart_xml(c));
let (xml, preserved) = chart::chart_xml(c);
self.pkg.xml_part(&name, CT_CHART, xml);
if preserved {
chart::write_kept(&mut self.pkg, &name, c);
} else if !c.kept.parts.is_empty() {
log::warn!("pptx: chart data changed; the embedded workbook was not written back");
}
o.rels.add_new("chart", &opc::relative(&o.name, &name), false)
}
}
+141
View File
@@ -0,0 +1,141 @@
//! An unchanged chart keeps the embedded workbook its `c:externalData` points at (#335).
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, defaults};
const WORKBOOK: &[u8] = b"chart-workbook-bytes-v1";
const EMBED: &str = "ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx";
fn chart_pptx() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
p.slides.clear();
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
let mut slide = defaults::new_slide(&mut p, layout);
let mut shape = Shape { id: ShapeId(p.alloc_id()), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
shape.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie)));
slide.shapes.push(shape);
p.slides.push(Arc::new(slide));
deckcraft_pptx::export(&p).expect("export")
}
/// Add an embedded workbook and point the chart part at it, the way PowerPoint writes a chart.
fn with_workbook(bytes: &[u8]) -> Vec<u8> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
let mut chart_part = None;
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") {
let xml = String::from_utf8(data).expect("chart xml");
assert!(xml.contains("</c:chartSpace>"), "{name}");
data = xml
.replacen("</c:chartSpace>", r#"<c:externalData r:id="rId1"><c:autoUpdate val="0"/></c:externalData></c:chartSpace>"#, 1)
.into_bytes();
chart_part = Some(name.clone());
} else if name == "[Content_Types].xml" {
let xml = String::from_utf8(data).expect("content types");
data = xml
.replacen(
"</Types>",
r#"<Override PartName="/ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/></Types>"#,
1,
)
.into_bytes();
}
out.start_file(&name, opts).expect("start");
out.write_all(&data).expect("write");
}
let chart_part = chart_part.expect("chart part");
let rels = format!("ppt/charts/_rels/{}.rels", chart_part.rsplit('/').next().unwrap_or("chart1.xml"));
let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet1.xlsx"/><Relationship Id="rIdBad" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide1.xml"/></Relationships>"#;
out.start_file(rels, opts).expect("rels");
out.write_all(rels_xml).expect("rels bytes");
out.start_file(EMBED, opts).expect("embed");
out.write_all(WORKBOOK).expect("workbook");
out.finish().expect("finish").into_inner()
}
fn parts(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = vec![];
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
out.push((name, data));
}
out
}
fn chart_of(bytes: &[u8]) -> deckcraft_model::Chart {
let p = deckcraft_pptx::import(bytes).expect("import");
p.slides
.iter()
.flat_map(|s| s.shapes.iter())
.find_map(|s| match &s.kind {
ShapeKind::Chart(c) if s.name == "Pie" => Some((**c).clone()),
_ => None,
})
.expect("pie chart")
}
#[test]
fn an_unchanged_chart_keeps_its_embedded_workbook() {
let src = with_workbook(&chart_pptx());
let chart = chart_of(&src);
assert_eq!(chart.kept.parts.iter().filter(|p| p.name == EMBED).count(), 1);
assert_eq!(chart.kept.parts.iter().find(|p| p.name == EMBED).map(|p| p.bytes.as_slice()), Some(WORKBOOK));
let saved = deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export");
let saved_parts = parts(&saved);
let workbook = saved_parts.iter().find(|(n, _)| n == EMBED).map(|(_, d)| d.as_slice());
assert_eq!(workbook, Some(WORKBOOK), "the embedding part is dropped on save");
let rels = saved_parts
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/_rels/chart") && n.ends_with(".xml.rels"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart relationships");
assert!(rels.contains("../embeddings/Microsoft_Excel_Worksheet1.xlsx"), "{rels}");
assert!(!rels.contains("slide1.xml"), "a chart relationship must not replace the slide: {rels}");
let slide = saved_parts.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").map(|(_, d)| String::from_utf8_lossy(d).into_owned()).expect("slide");
assert!(slide.contains("sld"), "{slide}");
assert!(!slide.as_bytes().windows(WORKBOOK.len()).any(|w| w == WORKBOOK));
let xml = saved_parts
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart xml");
assert!(xml.contains("externalData"), "{xml}");
}
#[test]
fn an_edited_chart_does_not_write_a_stale_workbook() {
let src = with_workbook(&chart_pptx());
let mut p = deckcraft_pptx::import(&src).expect("import");
let slide = Arc::make_mut(p.slides.get_mut(0).expect("slide"));
let chart = slide.shapes.iter_mut().find_map(|s| match &mut s.kind {
ShapeKind::Chart(c) if s.name == "Pie" => Some(c),
_ => None,
});
let chart = chart.expect("pie");
let value = chart.series.get_mut(0).and_then(|s| s.values.get_mut(0)).expect("value");
*value = Some(99.0);
let saved = parts(&deckcraft_pptx::export(&p).expect("export"));
assert!(saved.iter().all(|(n, _)| n != EMBED), "a stale workbook was written after the chart changed");
let xml = saved
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart xml");
assert!(!xml.contains("externalData"), "{xml}");
}
+6
View File
@@ -12,6 +12,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; 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 (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **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)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (an unchanged chart keeps its embedded workbook on save, #335) · **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**
@@ -100,6 +101,10 @@ the round trip even though DeckCraft doesn't model it.
| 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) |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) |
| SmartArt (`dgm`) | the drawing part as a group, and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn |
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at (#335); editing the chart drops that workbook instead of writing a stale one |
| 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 |
| Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) |
| Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink |
@@ -144,6 +149,7 @@ the round trip even though DeckCraft doesn't model it.
| 2026-10-13 | minor | Custom XML parts, the sensitivity label and custom properties kept on save |
| 2026-10-13 | minor | Owned corpus round-trip for tables, charts, animations, media, sections and masters. Handout-master header, footer, date and slide-number text is written back |
| 2026-10-14 | minor | PNG and JPEG slide export record their resolution (`scale` × 72 dpi; the default 2× picture is 144 dpi) |
| 2026-10-13 | trivial | Unchanged charts keep the embedded workbook (`ppt/embeddings/`) and chart relationships (#335) |
| 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) |
+5
View File
@@ -33,6 +33,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; 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-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; 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 (pen pressure scales ink width; tilt, pinch and trackpad gestures remain; 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-10 · **Last updated:** 2026-10-13 · **Change:** trivial (unchanged chart workbooks round-trip, #335; previous: 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)
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
@@ -243,6 +244,9 @@ Detail in [file-format-parity.md](file-format-parity.md).
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.
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box &
whisker) aren't read. An unchanged chart's embedded workbook now survives save (#335); editing
the chart still drops it rather than writing a stale spreadsheet.
- **Impact:** most business decks have charts; editing their data is routine.
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
@@ -508,6 +512,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing |
| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) |
| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain |
| 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open |
| 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 |