- Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust.
+ Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.
Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations,
- open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux
+ open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux
and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents
(through MCP) can drive.
@@ -74,7 +74,7 @@ Today it can:
audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1).
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
-- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
+- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
MCP server for AI agents.
@@ -132,7 +132,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
-PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
+Parts skipped on import and audio output problems land there. Each launch moves the
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
@@ -158,8 +158,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con
## Roadmap
-DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core
-ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
+DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core
+ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated).
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
[docs/gaps.md](docs/gaps.md) for every known shortfall and
@@ -279,15 +279,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or
-generated in code; no Microsoft artwork, fonts, themes or templates are used.
+generated in code; no proprietary artwork, fonts, themes or templates are used.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index 6f85b959..4b47a643 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **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
@@ -132,10 +132,12 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
+| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
+| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
@@ -238,6 +240,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
+| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
diff --git a/crates/engine/tests/chart_native.rs b/crates/engine/tests/chart_native.rs
new file mode 100644
index 00000000..957be0ad
--- /dev/null
+++ b/crates/engine/tests/chart_native.rs
@@ -0,0 +1,129 @@
+//! Native saves must not discard an imported chart's opaque package parts.
+
+use std::io::{Cursor, Read};
+use std::sync::Arc;
+
+use deckcraft_engine::Session;
+use deckcraft_engine::cmd::{base64_decode, base64_encode};
+use deckcraft_geom::Xfrm;
+use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
+use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};
+use serde_json::json;
+
+const WORKBOOK: &[u8] = b"original-synthetic-workbook\0\xff\x80";
+const DRAWING: &[u8] = br#""#;
+const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+
+fn chart(p: &Presentation) -> &Chart {
+ let ShapeKind::Chart(c) = &p.slides[0].shapes[0].kind else { panic!("chart") };
+ c
+}
+
+fn source() -> Vec {
+ let mut p = defaults::new_presentation(None);
+ let id = ShapeId(p.alloc_id().expect("id"));
+ Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
+ id,
+ name: "Owned chart".into(),
+ xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)),
+ kind: ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))),
+ ..Default::default()
+ }];
+ // Start with our own chart XML, then give it two original, synthetic package parts.
+ let mut p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
+ let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
+ c.raw = c
+ .raw
+ .as_ref()
+ .map(|raw| raw.replace("", r#""#));
+ c.kept = ChartKept {
+ rels: vec![
+ ChartRel {
+ id: "rWorkbook".into(),
+ rel_type: format!("{RT}/package"),
+ target: "../embeddings/owned.xlsx".into(),
+ part: "ppt/embeddings/owned.xlsx".into(),
+ external: false,
+ },
+ ChartRel {
+ id: "rDrawing".into(),
+ rel_type: format!("{RT}/chartUserShapes"),
+ target: "../drawings/owned.xml".into(),
+ part: "ppt/drawings/owned.xml".into(),
+ external: false,
+ },
+ ],
+ parts: vec![
+ ChartPart {
+ name: "ppt/embeddings/owned.xlsx".into(),
+ content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
+ bytes: WORKBOOK.to_vec(),
+ native_entry: None,
+ },
+ ChartPart {
+ name: "ppt/drawings/owned.xml".into(),
+ content_type: "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml".into(),
+ bytes: DRAWING.to_vec(),
+ native_entry: None,
+ },
+ ],
+ };
+ deckcraft_pptx::export(&p).expect("PPTX fixture")
+}
+
+fn open(bytes: &[u8], name: &str) -> Session {
+ let mut s = Session::new();
+ s.execute("file.openBytes", &json!({"name":name,"data":base64_encode(bytes)})).expect("open command");
+ s
+}
+
+fn save(s: &mut Session, format: &str) -> Vec {
+ let out = s.execute("file.saveBytes", &json!({"format":format})).expect("save command");
+ base64_decode(out["data"].as_str().expect("data")).expect("base64")
+}
+
+#[test]
+fn pptx_native_edit_save_reopen_keeps_chart_parts() {
+ let mut s = open(&source(), "owned.pptx");
+ let kept = chart(&s.doc().expect("doc").doc).kept.clone();
+ assert_eq!(kept.rels.len(), 2, "fixture relationships must have been imported");
+ assert_eq!(kept.parts.len(), 2);
+ s.execute("file.properties", &json!({"title":"Edited title"})).expect("edit command");
+ for _ in 0..2 {
+ s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
+ let p = &s.doc().expect("doc").doc;
+ assert_eq!(p.props.title, "Edited title");
+ assert_eq!(chart(p).kept, kept);
+ }
+ let bytes = save(&mut s, "pptx");
+ let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip");
+ let mut payloads = vec![];
+ for i in 0..z.len() {
+ let mut f = z.by_index(i).expect("entry");
+ let mut data = vec![];
+ f.read_to_end(&mut data).expect("read");
+ payloads.push((f.name().to_string(), data));
+ }
+ assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/embeddings/") && d == WORKBOOK));
+ assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/drawings/") && d == DRAWING));
+ let back = deckcraft_pptx::import(&bytes).expect("reopen PPTX");
+ assert_eq!(chart(&back).kept.rels.len(), 2);
+ for part in &kept.parts {
+ assert!(chart(&back).kept.parts.iter().any(|p| p.content_type == part.content_type && p.bytes == part.bytes));
+ }
+}
+
+#[test]
+fn editing_chart_after_native_reopen_does_not_export_a_stale_workbook() {
+ let mut s = open(&source(), "owned.pptx");
+ s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
+ let doc = s.doc_mut().expect("doc");
+ let p = Arc::make_mut(&mut doc.doc);
+ let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
+ assert_eq!(c.kept.rels.len(), 2);
+ c.series[0].values[0] = Some(99.0);
+ let bytes = save(&mut s, "pptx");
+ let back = deckcraft_pptx::import(&bytes).expect("PPTX");
+ assert_eq!(chart(&back).series[0].values[0], Some(99.0));
+ assert!(chart(&back).kept.parts.is_empty(), "stale source workbook must not be attached to edited chart data");
+}
diff --git a/crates/format/src/chart_parts.rs b/crates/format/src/chart_parts.rs
new file mode 100644
index 00000000..f57c0867
--- /dev/null
+++ b/crates/format/src/chart_parts.rs
@@ -0,0 +1,112 @@
+//! Keep opaque chart payloads out of the JSON manifest and within the media load budget.
+use std::collections::HashSet;
+use std::io::Cursor;
+use std::sync::Arc;
+
+use deckcraft_model::chart::ChartPart;
+use deckcraft_model::{Master, Presentation, Shape, ShapeKind};
+
+use crate::{FormatError, MAX_ENTRY, Result, read_entry_max};
+
+const PREFIX: &str = "chart-parts/";
+type Payload = (String, String, Vec);
+
+pub(super) fn separate(p: &mut Presentation) -> Result> {
+ let mut payloads = Vec::new();
+ visit_parts(p, |part| {
+ // The native namespace is independent of the original OPC names, which may repeat
+ // when charts from several decks are combined. No user-supplied path is extracted.
+ let entry = format!("{PREFIX}{}", payloads.len());
+ part.native_entry = Some(entry.clone());
+ payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes)));
+ Ok(())
+ })?;
+ Ok(payloads)
+}
+
+pub(super) fn restore(
+ p: &mut Presentation,
+ z: &mut zip::ZipArchive>,
+ left: &mut u64,
+ mut items: usize,
+ max_items: usize,
+ damaged: &mut Vec,
+) -> Result<()> {
+ let mut seen = HashSet::new();
+ visit_parts(p, |part| {
+ if items >= max_items {
+ return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items")));
+ }
+ items += 1;
+ if let Some(entry) = part.native_entry.take() {
+ // Accept only names produced by separate(), and inflate each member once.
+ let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}"));
+ if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() {
+ return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into()));
+ }
+ if z.index_for_name(&entry).is_none() {
+ // Missing members consume no inflation work and can be reported for recovery.
+ damaged.push(format!("{entry}: missing chart part"));
+ return Ok(());
+ }
+ if z.by_name(&entry).is_ok_and(|f| f.size() > *left) {
+ return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into()));
+ }
+ // A CRC/decode error may already have inflated bytes. Stop on that first error
+ // rather than letting later corrupt members reuse the unchanged byte budget.
+ let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?;
+ *left -= bytes.len() as u64;
+ part.bytes = bytes;
+ } else {
+ // Older manifests used JSON byte arrays. Charge those too, after the existing
+ // bounded manifest parser has read them, so resaving migrates them losslessly.
+ let size = part.bytes.len() as u64;
+ if size > (*left).min(MAX_ENTRY) {
+ return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into()));
+ }
+ *left -= size;
+ }
+ Ok(())
+ })
+}
+
+fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
+ // Use an explicit stack for groups. Charts can also live on masters and their layouts,
+ // including the notes and handout masters; none of these owners may leak inline bytes.
+ for slide in &mut p.slides {
+ shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?;
+ }
+ for master in &mut p.masters {
+ master_parts(Arc::make_mut(master), &mut visit)?;
+ }
+ for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() {
+ master_parts(Arc::make_mut(master), &mut visit)?;
+ }
+ Ok(())
+}
+
+fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
+ shapes(&mut master.shapes, visit)?;
+ for layout in &mut master.layouts {
+ shapes(&mut layout.shapes, visit)?;
+ }
+ Ok(())
+}
+
+fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
+ let mut pending = vec![root];
+ while let Some(shapes) = pending.pop() {
+ for shape in shapes {
+ match &mut shape.kind {
+ ShapeKind::Group { children, .. } => pending.push(children),
+ ShapeKind::Chart(chart) => {
+ for part in &mut chart.kept.parts {
+ visit(part)?;
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/crates/format/src/chart_parts_tests.rs b/crates/format/src/chart_parts_tests.rs
new file mode 100644
index 00000000..61a00dc9
--- /dev/null
+++ b/crates/format/src/chart_parts_tests.rs
@@ -0,0 +1,234 @@
+use super::*;
+use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
+use deckcraft_model::{Master, Shape, ShapeId};
+use serde_json::{Value, json};
+
+const PART: &str = "ppt/embeddings/owned.xlsx";
+
+fn fixture(payload: Vec) -> Presentation {
+ // Construct through the old manifest representation: this also exercises compatibility.
+ let mut part: ChartPart = serde_json::from_value(json!({
+ "name": PART, "contentType": "application/zip", "bytes": []
+ }))
+ .unwrap();
+ part.bytes = payload;
+ let mut chart = Chart::sample(ChartType::Pie);
+ chart.kept.rels.push(ChartRel {
+ id: "rWorkbook".into(),
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
+ target: "../embeddings/owned.xlsx".into(),
+ part: PART.into(),
+ external: false,
+ });
+ chart.kept.parts.push(part);
+ let mut p = Presentation::default();
+ let child = Shape { id: ShapeId(p.alloc_id().unwrap()), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
+ let group = Shape {
+ id: ShapeId(p.alloc_id().unwrap()),
+ kind: ShapeKind::Group { children: vec![child], child: Default::default() },
+ ..Default::default()
+ };
+ Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
+ p
+}
+
+fn part_json(value: &mut Value) -> Option<&mut Value> {
+ if value.get("name").and_then(Value::as_str) == Some(PART) && value.get("contentType").is_some() {
+ return Some(value);
+ }
+ match value {
+ Value::Array(values) => values.iter_mut().find_map(part_json),
+ Value::Object(values) => values.values_mut().find_map(part_json),
+ _ => None,
+ }
+}
+
+fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec {
+ let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
+ let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
+ edit(&mut json);
+ let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ for i in 0..z.len() {
+ let f = z.by_index(i).unwrap();
+ if f.name() == "presentation.json" {
+ out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(&serde_json::to_vec(&json).unwrap()).unwrap();
+ } else {
+ out.raw_copy_file(f).unwrap();
+ }
+ }
+ out.finish().unwrap().into_inner()
+}
+
+#[test]
+fn large_chart_payload_is_a_referenced_zip_entry() {
+ let payload = vec![200; 3 * 1024 * 1024];
+ let p = fixture(payload.clone());
+ let bytes = save(&p).unwrap();
+ let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
+ assert!(z.by_name("presentation.json").unwrap().size() < 128 * 1024, "binary chart bytes must not become JSON numbers");
+ let mut manifest: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
+ let part = part_json(&mut manifest).unwrap();
+ assert!(part.get("bytes").is_none(), "new manifests keep only metadata and a reference");
+ let entry = part["nativeEntry"].as_str().unwrap();
+ assert!(entry.starts_with("chart-parts/"));
+ assert_eq!(read_entry(&mut z, entry).unwrap(), payload);
+ assert!(read_entry_max(&mut z, entry, payload.len() as u64 - 1).is_err());
+ assert_eq!(load(&bytes).unwrap(), p);
+}
+
+#[test]
+fn chart_parts_share_media_byte_and_item_budgets() {
+ let mut p = fixture(vec![7; 600]);
+ p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
+ let bytes = save(&p).unwrap();
+ assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
+ assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
+ assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
+ let duplicated = rewrite_manifest(&bytes, |manifest| {
+ let media = manifest["presentation"]["media"].as_array_mut().unwrap();
+ media.push(media[0].clone());
+ });
+ assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
+}
+
+#[test]
+fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
+ let mut p = fixture(vec![9; 600]);
+ p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
+ let legacy = rewrite_manifest(&save(&p).unwrap(), |manifest| {
+ let part = part_json(manifest).unwrap().as_object_mut().unwrap();
+ part.remove("nativeEntry");
+ part.insert("bytes".into(), json!(vec![9_u8; 600]));
+ });
+ assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
+ assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
+ assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
+}
+
+#[test]
+fn chart_payloads_in_every_shape_owner_get_distinct_entries() {
+ let mut p = fixture(vec![42; 4096]);
+ let shapes = p.slides[0].shapes.clone();
+ let master = Arc::make_mut(&mut p.masters[0]);
+ master.shapes = shapes.clone();
+ master.layouts[0].shapes = shapes.clone();
+ p.notes_master = Some(Arc::new(Master { shapes: shapes.clone(), ..Default::default() }));
+ p.handout_master = Some(Arc::new(Master { shapes, ..Default::default() }));
+ let bytes = save(&p).unwrap();
+ let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
+ assert_eq!(z.file_names().filter(|name| name.starts_with("chart-parts/")).count(), 5);
+ assert_eq!(load(&bytes).unwrap(), p);
+}
+
+#[test]
+fn missing_or_corrupt_chart_payload_is_reported() {
+ let p = fixture(b"SYNTHETIC-CHART-PAYLOAD-0123456789".to_vec());
+ let bytes = save(&p).unwrap();
+ let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
+ let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
+ let name = part_json(&mut json).unwrap()["nativeEntry"].as_str().unwrap().to_owned();
+ let start = z.by_name(&name).unwrap().data_start() as usize;
+ let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ for i in 0..z.len() {
+ let f = z.by_index(i).unwrap();
+ if f.name() != name {
+ out.raw_copy_file(f).unwrap();
+ }
+ }
+ let missing = out.finish().unwrap().into_inner();
+ let (q, damaged) = load_checked(&missing).unwrap();
+ assert_eq!(damaged.len(), 1, "{damaged:?}");
+ let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
+ let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
+ assert!(chart.kept.parts[0].bytes.is_empty());
+ let mut corrupt = bytes.clone();
+ corrupt[start] ^= 0xff;
+ assert!(load_checked(&corrupt).is_err());
+}
+
+#[test]
+fn chart_references_cannot_alias_other_members_or_mix_inline_bytes() {
+ let p = fixture(vec![3; 10]);
+ let bytes = save(&p).unwrap();
+ for entry in ["media/1", "chart-parts/../0", "chart-parts/00", "chart-parts/-1"] {
+ let changed = rewrite_manifest(&bytes, |manifest| {
+ part_json(manifest).unwrap()["nativeEntry"] = json!(entry);
+ });
+ assert!(load(&changed).is_err(), "accepted {entry}");
+ }
+ let ambiguous = rewrite_manifest(&bytes, |manifest| {
+ part_json(manifest).unwrap()["bytes"] = json!([3]);
+ });
+ assert!(load(&ambiguous).is_err());
+
+ let mut p = p;
+ let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
+ let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
+ chart.kept.parts.push(chart.kept.parts[0].clone());
+ let aliased = rewrite_manifest(&save(&p).unwrap(), |manifest| {
+ fn alias(value: &mut Value) {
+ if let Some(entry) = value.get_mut("nativeEntry") {
+ *entry = json!("chart-parts/0");
+ }
+ match value {
+ Value::Array(values) => values.iter_mut().for_each(alias),
+ Value::Object(values) => values.values_mut().for_each(alias),
+ _ => {}
+ }
+ }
+ alias(manifest);
+ });
+ assert!(load(&aliased).is_err(), "one ZIP member must not be inflated repeatedly");
+}
+
+fn with_two_parts() -> Presentation {
+ let mut p = fixture(vec![3; 10]);
+ let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
+ let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
+ let mut second = chart.kept.parts[0].clone();
+ second.bytes = vec![4; 10];
+ chart.kept.parts.push(second);
+ p
+}
+
+#[test]
+fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
+ let mut bytes = save(&with_two_parts()).unwrap();
+ let offsets: Vec<_> = {
+ let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
+ (0..2).map(|i| z.by_name(&format!("chart-parts/{i}")).unwrap().data_start() as usize).collect()
+ };
+ for offset in offsets {
+ bytes[offset] ^= 0xff;
+ }
+ // A failed CRC may already have inflated bytes. Continuing with the same remaining
+ // budget would let each corrupt member consume that work budget again.
+ let result = load_within(&bytes, Budget { total: 10, items: 2 });
+ assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
+ assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
+}
+
+#[test]
+fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
+ let mut bytes = save(&with_two_parts()).unwrap();
+ // ZipWriter refuses duplicate names. Forge equal-length local/central-directory names
+ // instead, to exercise ZipArchive's duplicate-name indexing on an untrusted archive.
+ let original = b"chart-parts/1";
+ let mut replaced = 0;
+ for offset in 0..=bytes.len() - original.len() {
+ if &bytes[offset..offset + original.len()] == original {
+ bytes[offset + original.len() - 1] = b'0';
+ replaced += 1;
+ }
+ }
+ assert_eq!(replaced, 2);
+ let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
+ assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
+ let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
+ assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
+ let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
+ let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
+ assert_eq!(chart.kept.parts[0].bytes, vec![4; 10]);
+ assert!(chart.kept.parts[1].bytes.is_empty());
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 5701a1ec..093cbaa6 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -1,11 +1,15 @@
//! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media
-//! bytes), `media/` files with the embedded media, and `mimetype`. Readers ignore unknown
+//! bytes), `media/` and `chart-parts/` binary entries, and `mimetype`. Readers ignore unknown
//! fields, so newer files open in older builds with what those builds understand.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
+mod chart_parts;
mod finite;
+#[cfg(test)]
+mod chart_parts_tests;
+
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
@@ -20,9 +24,9 @@ pub const EXTENSION: &str = "deckcraft";
pub const VERSION: u32 = 1;
/// Largest entry we will inflate (guards against zip bombs).
const MAX_ENTRY: u64 = 2 << 30;
-/// All embedded media together, inflated (#393).
+/// All embedded media and chart sidecars together, inflated (#393).
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
-/// Media items one file may list.
+/// Media items and chart sidecar parts one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
/// What one load may inflate.
@@ -62,12 +66,10 @@ pub fn save(p: &Presentation) -> Result> {
let w = |e: zip::result::ZipError| FormatError::Write(e.to_string());
z.start_file("mimetype", stored).map_err(w)?;
z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?;
- let m = Manifest {
- format: "deckcraft".into(),
- version: VERSION,
- generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")),
- presentation: p.clone(),
- };
+ let mut presentation = p.clone();
+ let parts = chart_parts::separate(&mut presentation)?;
+ let m =
+ Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation };
let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?;
z.start_file("presentation.json", deflate).map_err(w)?;
z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?;
@@ -80,6 +82,11 @@ pub fn save(p: &Presentation) -> Result> {
z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?;
z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?;
}
+ for (name, content_type, bytes) in parts {
+ let opts = if is_compressed(&content_type) { stored } else { deflate };
+ z.start_file(name, opts).map_err(w)?;
+ z.write_all(&bytes).map_err(|e| FormatError::Write(e.to_string()))?;
+ }
z.finish().map_err(w)?;
}
Ok(buf.into_inner())
@@ -119,14 +126,15 @@ fn read_entry_max(z: &mut zip::ZipArchive>, name: &str, max: u64)
Ok(out)
}
-/// Read a presentation. Embedded media that is missing or damaged is left empty; use
-/// [`load_checked`] to learn which.
+/// Read a presentation. Missing media/chart parts are left empty; use [`load_checked`] to
+/// learn which. A damaged chart member refuses the file before further chart inflation.
pub fn load(bytes: &[u8]) -> Result {
load_checked(bytes).map(|(p, _)| p)
}
-/// Read a presentation and list the embedded media that couldn't be read (missing, too large
-/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage.
+/// Read a presentation and list media that couldn't be read, plus missing chart members.
+/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
+/// Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> {
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
}
@@ -140,7 +148,8 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec budget.items {
+ let items = p.media.len();
+ if items > budget.items {
return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items)));
}
// A repeated id names the same member: keep the first item, so the member is inflated once
@@ -163,6 +172,7 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec damaged.push(e.to_string()),
}
}
+ chart_parts::restore(&mut p, &mut z, &mut left, items, budget.items, &mut damaged)?;
repair(&mut p);
Ok((p, damaged))
}
@@ -663,6 +673,54 @@ pub fn slides_to_outline(p: &Presentation) -> String {
mod tests {
use super::*;
+ #[test]
+ fn chart_sidecars_are_optional_for_older_native_files() {
+ use deckcraft_model::chart::{Chart, ChartType};
+ let chart = Chart::sample(ChartType::Pie);
+ let mut json = serde_json::to_value(&chart).expect("chart JSON");
+ assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files");
+ assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart);
+ json["kept"] = serde_json::json!({});
+ assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart);
+ }
+
+ #[test]
+ fn native_save_keeps_chart_relationships_and_payloads() {
+ use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
+ use deckcraft_model::{Shape, ShapeId, ShapeKind};
+ let kept = ChartKept {
+ rels: vec![ChartRel {
+ id: "rWorkbook".into(),
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
+ target: "../embeddings/data.xlsx".into(),
+ part: "ppt/embeddings/data.xlsx".into(),
+ external: false,
+ }],
+ parts: vec![ChartPart {
+ name: "ppt/embeddings/data.xlsx".into(),
+ content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
+ bytes: vec![0, 255, 128, 42],
+ native_entry: None,
+ }],
+ };
+ let mut p = defaults::new_presentation(None);
+ let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) };
+ let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
+ // Serialization must reach charts inside groups as well as top-level charts.
+ Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
+ id: ShapeId(p.alloc_id().expect("group id")),
+ kind: ShapeKind::Group { children: vec![s], child: Default::default() },
+ ..Default::default()
+ }];
+ let mut q = load(&save(&p).expect("save")).expect("load");
+ for _ in 0..2 {
+ let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
+ let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
+ assert_eq!(chart.kept, kept);
+ q = load(&save(&q).expect("save again")).expect("load again");
+ }
+ }
+
/// The same package without the entries `drop` names.
fn without(bytes: &[u8], drop: &str) -> Vec {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs
index 36dd9708..f5aeb1ee 100644
--- a/crates/model/src/chart.rs
+++ b/crates/model/src/chart.rs
@@ -1,1441 +1,1455 @@
-//! Charts: an embedded data grid plus type and element options.
-
-use serde::{Deserialize, Serialize};
-
-use crate::style::{ColorRef, Fill, Line};
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub enum ChartType {
- #[default]
- Column,
- StackedColumn,
- PercentColumn,
- Bar,
- StackedBar,
- PercentBar,
- Line,
- LineMarkers,
- StackedLine,
- Area,
- StackedArea,
- Pie,
- Doughnut,
- Scatter,
- Bubble,
- Radar,
- FilledRadar,
- Stock,
- Surface,
- Histogram,
- Pareto,
- BoxWhisker,
- Waterfall,
- Funnel,
- Treemap,
- Sunburst,
- /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it
- /// is not offered in the chart menus (PowerPoint needs an online map service to make one).
- RegionMap,
- Combo,
-}
-
-impl ChartType {
- /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus.
- pub const OFFICE_2016: [ChartType; 7] = [
- ChartType::Waterfall,
- ChartType::Funnel,
- ChartType::Histogram,
- ChartType::Pareto,
- ChartType::BoxWhisker,
- ChartType::Treemap,
- ChartType::Sunburst,
- ];
-
- /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback).
- pub fn is_chartex(self) -> bool {
- Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap
- }
-
- pub const MAIN: [ChartType; 12] = [
- ChartType::Column,
- ChartType::Bar,
- ChartType::Line,
- ChartType::Area,
- ChartType::Pie,
- ChartType::Doughnut,
- ChartType::Scatter,
- ChartType::Radar,
- ChartType::Waterfall,
- ChartType::Funnel,
- ChartType::Histogram,
- ChartType::Treemap,
- ];
- pub fn label(self) -> &'static str {
- match self {
- ChartType::Column => "Clustered Column",
- ChartType::StackedColumn => "Stacked Column",
- ChartType::PercentColumn => "100% Stacked Column",
- ChartType::Bar => "Clustered Bar",
- ChartType::StackedBar => "Stacked Bar",
- ChartType::PercentBar => "100% Stacked Bar",
- ChartType::Line => "Line",
- ChartType::LineMarkers => "Line with Markers",
- ChartType::StackedLine => "Stacked Line",
- ChartType::Area => "Area",
- ChartType::StackedArea => "Stacked Area",
- ChartType::Pie => "Pie",
- ChartType::Doughnut => "Doughnut",
- ChartType::Scatter => "Scatter",
- ChartType::Bubble => "Bubble",
- ChartType::Radar => "Radar",
- ChartType::FilledRadar => "Filled Radar",
- ChartType::Stock => "Stock",
- ChartType::Surface => "Surface",
- ChartType::Histogram => "Histogram",
- ChartType::Pareto => "Pareto",
- ChartType::BoxWhisker => "Box and Whisker",
- ChartType::Waterfall => "Waterfall",
- ChartType::Funnel => "Funnel",
- ChartType::Treemap => "Treemap",
- ChartType::Sunburst => "Sunburst",
- ChartType::RegionMap => "Map",
- ChartType::Combo => "Combo",
- }
- }
- pub fn is_bar_like(self) -> bool {
- matches!(
- self,
- ChartType::Column
- | ChartType::StackedColumn
- | ChartType::PercentColumn
- | ChartType::Bar
- | ChartType::StackedBar
- | ChartType::PercentBar
- | ChartType::Histogram
- | ChartType::Pareto
- | ChartType::Waterfall
- )
- }
- pub fn horizontal(self) -> bool {
- matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel)
- }
- pub fn stacked(self) -> bool {
- matches!(
- self,
- ChartType::StackedColumn
- | ChartType::StackedBar
- | ChartType::StackedLine
- | ChartType::StackedArea
- | ChartType::PercentColumn
- | ChartType::PercentBar
- )
- }
- pub fn percent(self) -> bool {
- matches!(self, ChartType::PercentColumn | ChartType::PercentBar)
- }
-}
-
-/// `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 {
- 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,
- /// Polynomial order (`c:order`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub order: Option,
- /// Moving-average period (`c:period`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub period: Option,
- /// Forecast forward and backward, in category units (`c:forward`, `c:backward`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub forward: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub backward: Option,
- /// Cross the value axis at this value (`c:intercept`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub intercept: Option,
- /// 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 {
- pub name: String,
- pub values: Vec