- 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 f5c66e66..ac8c83fc 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-16 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; 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) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: 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
@@ -131,8 +131,20 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
+| 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-16 | Symbol-font characters: a run's U+F0xx symbol code (from ``) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
+| 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 |
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
+| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
+| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
+| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
@@ -228,7 +240,12 @@ 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-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
+| 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) |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
diff --git a/apps/deckcraft-cli/Cargo.toml b/apps/deckcraft-cli/Cargo.toml
index 898a1fb3..6d2f572e 100644
--- a/apps/deckcraft-cli/Cargo.toml
+++ b/apps/deckcraft-cli/Cargo.toml
@@ -17,3 +17,6 @@ path = "src/main.rs"
deckcraft-engine = { workspace = true }
deckcraft-mcp = { workspace = true }
serde_json = { workspace = true }
+
+[dev-dependencies]
+zip = { workspace = true }
diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs
index 535ce848..4227905f 100644
--- a/apps/deckcraft-cli/src/help.rs
+++ b/apps/deckcraft-cli/src/help.rs
@@ -19,15 +19,16 @@ The shapes of one slide, with their ids, boxes and text, come from the slide.ins
},
Subcommand {
name: "render",
- usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
+ usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT",
summary: "draw slides as PNG images",
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
slide. Prints each image's path and size.
- --slide N the slide at index N; indices start at 0, and 0 is the default
- --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
- --edit draw what the editor shows: placeholder prompts and their dashed outlines
+ --slide N the slide at index N; indices start at 0, and 0 is the default
+ --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
+ --edit draw what the editor shows: placeholder prompts and their dashed outlines
+ --transparent leave the slide background out, so only what is on the slide is drawn
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
},
diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs
index 5934973e..c6276efb 100644
--- a/apps/deckcraft-cli/src/main.rs
+++ b/apps/deckcraft-cli/src/main.rs
@@ -117,11 +117,27 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
}
match input {
- Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
+ Some(p) => {
+ let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
+ warn_if_damaged(p, &opened);
+ Ok(())
+ }
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
}
}
+/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203).
+fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
+ let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len());
+ let (slides, media) = (count("lostSlides"), count("damagedMedia"));
+ if slides > 0 {
+ eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
+ }
+ if media > 0 {
+ eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
+ }
+}
+
/// Median and slowest of `runs` timings of `f`, in milliseconds.
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
let mut ms = Vec::with_capacity(runs);
@@ -244,11 +260,13 @@ fn render(args: &[String]) -> Result<(), String> {
let mut all = false;
let mut scale = 2.0;
let mut edit = false;
+ let mut transparent = false;
let mut out = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--sample" => sample = true,
+ "--transparent" => transparent = true,
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
"--all" => all = true,
"--edit" => edit = true,
@@ -281,7 +299,7 @@ fn render(args: &[String]) -> Result<(), String> {
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
}
for i in list {
- let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
+ let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent);
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({w}×{h})");
@@ -680,6 +698,21 @@ mod tests {
}
}
+ /// `render --transparent` leaves the slide background out (#784): the corner is transparent.
+ #[test]
+ fn render_transparent_leaves_the_background_out() {
+ let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3];
+ let opaque = dir.join("opaque.png");
+ render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap();
+ assert_eq!(alpha(&opaque), 255);
+ let clear = dir.join("clear.png");
+ render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap();
+ assert_eq!(alpha(&clear), 0);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
#[test]
fn exports_an_animated_gif() {
diff --git a/apps/deckcraft-cli/tests/damaged.rs b/apps/deckcraft-cli/tests/damaged.rs
new file mode 100644
index 00000000..de85a66b
--- /dev/null
+++ b/apps/deckcraft-cli/tests/damaged.rs
@@ -0,0 +1,53 @@
+//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203).
+
+use std::io::{Cursor, Read, Write};
+use std::path::PathBuf;
+use std::process::{Command, Output};
+
+fn cli(args: &[&str]) -> Output {
+ Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
+}
+
+fn temp_dir(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+/// `deck` with `ppt/slides/slide2.xml` cut off.
+fn without_slide2(deck: &[u8]) -> Vec {
+ let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap();
+ let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ let options = zip::write::SimpleFileOptions::default();
+ for i in 0..zin.len() {
+ let mut f = zin.by_index(i).unwrap();
+ let mut data = vec![];
+ f.read_to_end(&mut data).unwrap();
+ if f.name() == "ppt/slides/slide2.xml" {
+ data = b" Line {
}
}
+/// One request line, parsed.
+enum Parsed {
+ Request {
+ id: Value,
+ method: String,
+ params: Value,
+ },
+ Blank,
+ /// Not a request: reply with this error and close the connection.
+ Reject(String),
+}
+
+fn parse(bytes: &[u8]) -> Parsed {
+ let Ok(text) = std::str::from_utf8(bytes) else {
+ return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
+ };
+ let text = text.trim();
+ if text.is_empty() {
+ return Parsed::Blank;
+ }
+ if is_http_request_line(text) {
+ return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
+ }
+ let msg = match serde_json::from_str::(text) {
+ Ok(v) => v,
+ Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
+ };
+ let Some(obj) = msg.as_object() else {
+ return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
+ };
+ let Some(method) = obj.get("method").and_then(Value::as_str) else {
+ return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
+ };
+ Parsed::Request {
+ id: obj.get("id").cloned().unwrap_or(Value::Null),
+ method: method.to_string(),
+ params: obj.get("params").cloned().unwrap_or(json!({})),
+ }
+}
+
+/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
+fn is_http_request_line(text: &str) -> bool {
+ const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
+ text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
+ || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
+}
+
fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
@@ -136,20 +186,14 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context
}
Line::Closed => break,
};
- let Ok(line) = String::from_utf8(line) else {
- if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() {
+ let reply = match parse(&line) {
+ Parsed::Blank => continue,
+ Parsed::Reject(e) => {
+ // The connection closes either way; a peer that can't read the reason loses nothing.
+ let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
- continue;
- };
- if line.trim().is_empty() {
- continue;
- }
- let reply = match serde_json::from_str::(&line) {
- Ok(msg) => {
- let id = msg.get("id").cloned().unwrap_or(Value::Null);
- let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
- let params = msg.get("params").cloned().unwrap_or(json!({}));
+ Parsed::Request { id, method, params } => {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
let mut r = match tx.try_send(req) {
@@ -177,7 +221,6 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context
}
r
}
- Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if let Err(e) = writeln!(out, "{reply}") {
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
@@ -245,6 +288,45 @@ mod tests {
assert_eq!(v["result"], "ui.inspect");
}
+ /// Every reply line until the server closes the connection.
+ fn replies_until_closed(r: &mut BufReader) -> Vec {
+ std::iter::from_fn(|| reply(r)).collect()
+ }
+
+ /// A browser can POST to the port: its request line closes the connection before the JSON
+ /// line in its body is read, so nothing is dispatched.
+ #[test]
+ fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
+ let (addr, rx) = server(Limits { max_line: 512, ..small() });
+ let (mut r, mut w) = client(addr);
+ w.write_all(
+ b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
+ {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
+ )
+ .unwrap();
+ let replies = replies_until_closed(&mut r);
+ assert_eq!(replies.len(), 1, "{replies:?}");
+ assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
+ assert!(rx.try_recv().is_err(), "the body was dispatched");
+ }
+
+ #[test]
+ fn a_line_that_is_not_a_request_closes_the_connection() {
+ let (addr, rx) = server(small());
+ ui(rx);
+ for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
+ let (mut r, mut w) = client(addr);
+ w.write_all(junk).unwrap();
+ w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
+ let replies = replies_until_closed(&mut r);
+ assert_eq!(replies.len(), 1, "{replies:?}");
+ assert_eq!(replies[0]["ok"], false);
+ }
+ assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
+ assert!(is_http_request_line("PRI * HTTP/2.0"));
+ assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
+ }
+
#[test]
fn oversized_line_closes_connection() {
let (addr, rx) = server(small());
diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs
index ef80daaa..e40804b6 100644
--- a/apps/deckcraft/src/main.rs
+++ b/apps/deckcraft/src/main.rs
@@ -1,6 +1,7 @@
//! DeckCraft desktop app.
//!
//! Usage: `deckcraft [--control ] [--sample] [--show] [files…]`
+//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint.
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
//!
//! `--control ` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
@@ -134,6 +135,8 @@ Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
--show start the slide show
--help, -h print this help and exit
--version, -V print the version and exit
+
+A PowerPoint Show (.ppsx) given last starts its slide show.
";
enum Startup {
@@ -254,10 +257,9 @@ fn main() -> eframe::Result {
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample: {e}");
}
- for f in files {
- if let Err(e) = app.open_path(&f) {
- log::error!("{f}: {e}");
- }
+ // A .ppsx opened last starts its slide show (#862).
+ for (f, e) in app.open_from_system(&files) {
+ log::error!("{f}: {e}");
}
if show {
app.start_show(0, false);
diff --git a/contributors/people.toml b/contributors/people.toml
index f70303a1..14b0e935 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -54,3 +54,8 @@ real_name = "Nikita Rakitin"
[people.RCShoemaker]
real_name = "Ryan C. Shoemaker"
+
+[people.marcoarnulfo]
+real_name = "Marco Arnulfo"
+display_name = "Marco Arnulfo"
+sync_github_name = false
diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs
index 3d9c241f..a1117c62 100644
--- a/crates/anim/src/show.rs
+++ b/crates/anim/src/show.rs
@@ -299,7 +299,8 @@ impl ShowState {
ShowAction::FinishStep(n)
}
- /// ← / Backspace: undo the last step, else go to the previous slide fully built.
+ /// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic
+ /// first step (With/After Previous) is part of the slide appearing, so it is never undone.
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
if self.ended {
self.ended = false;
@@ -310,13 +311,16 @@ impl ShowState {
self.playing = false;
return ShowAction::Slide(self.slide);
}
- if self.playing {
+ // #844: undoing the automatic first step left the slide as before it appeared, and the show
+ // played the step again at once, so Back never got past the slide.
+ let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) };
+ let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0);
+ if let Some(k) = undo
+ && !auto_first
+ {
self.playing = false;
- return ShowAction::StepBack(self.step);
- }
- if self.step > 0 {
- self.step -= 1;
- return ShowAction::StepBack(self.step);
+ self.step = k;
+ return ShowAction::StepBack(k);
}
let target = match self.prev_index(pres) {
Some(i) => Some(i),
diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs
index 1ac69104..da158ae9 100644
--- a/crates/anim/src/tests.rs
+++ b/crates/anim/src/tests.rs
@@ -808,6 +808,30 @@ fn show_steps_through_builds() {
assert_eq!(s.step, 1);
}
+#[test]
+fn show_back_leaves_a_slide_whose_first_step_is_automatic() {
+ // #844: undoing the automatic first step left the slide as before it appeared, and the show
+ // played that step again at once, so Back never reached the slide before.
+ for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] {
+ let mut p = pres(2);
+ Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")];
+ Arc::make_mut(&mut p.slides[1]).animations =
+ vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)];
+ let mut s = ShowState::new(&p);
+ assert_eq!(s.next(&p), ShowAction::Slide(1));
+ assert!(s.playing, "{start:?}");
+ // While the automatic step plays.
+ assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
+ assert_eq!(s.next(&p), ShowAction::Slide(1));
+ // Once it has played, the click step is undone first, then Back leaves the slide.
+ assert_eq!(s.next(&p), ShowAction::PlayStep(1));
+ assert_eq!(s.step_done(&p), ShowAction::FinishStep(1));
+ assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}");
+ assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
+ assert_eq!((s.step, s.playing), (0, false));
+ }
+}
+
#[test]
fn show_auto_advance() {
let mut p = pres(2);
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 3b4346e1..fdb30f1e 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -175,7 +175,11 @@ fn copied_html(s: &Session) -> String {
fn cut(s: &mut Session, p: &Value) -> Result {
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
- let html = if caret { String::new() } else { copied_html(s) };
+ if caret {
+ // Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
+ return Ok(json!({"text": t, "html": ""}));
+ }
+ let html = copied_html(s);
delete(s, p)?;
Ok(json!({"text": t, "html": html}))
}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 4761dc10..ba567571 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -33,7 +33,7 @@ pub fn specs() -> Vec {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
- cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
+ cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(
query "file.info",
"Info",
@@ -43,7 +43,7 @@ pub fn specs() -> Vec {
has_doc,
info
),
- cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
+ cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(
query "slide.colorAt",
"Color At",
@@ -128,19 +128,35 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result {
open_checked(name, bytes).map(|(p, _)| p)
}
-/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged.
-pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> {
- if deckcraft_format::sniff(bytes) {
- return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()));
- }
- open_other(name, bytes).map(|p| (p, vec![]))
+/// What a file lost on opening.
+#[derive(Debug, Default, Clone)]
+pub struct Damage {
+ /// Pictures and media a `.deckcraft` file had lost or damaged.
+ pub media: Vec,
+ /// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
+ pub slides: Vec,
}
-fn open_other(name: &str, bytes: &[u8]) -> Result {
+impl Damage {
+ pub fn is_empty(&self) -> bool {
+ self.media.is_empty() && self.slides.is_empty()
+ }
+}
+
+/// [`open_presentation`], plus what the file had lost or damaged.
+pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
+ if deckcraft_format::sniff(bytes) {
+ let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
+ return Ok((p, Damage { media, slides: vec![] }));
+ }
+ open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
+}
+
+fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> {
if deckcraft_pptx::sniff(bytes) {
- let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
+ let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut p);
- return Ok(p);
+ return Ok((p, lost_slides));
}
if deckcraft_pptx::is_encrypted(bytes) {
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
@@ -157,7 +173,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result {
if p.slides.is_empty() {
p = defaults::new_presentation(None);
}
- return Ok(p);
+ return Ok((p, vec![]));
}
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
}
@@ -180,8 +196,8 @@ pub struct LoadedFile {
pub doc: Presentation,
/// A native or PPTX file (saving goes back to it); an outline import has no file to save to.
pub native: bool,
- /// Pictures and media a `.deckcraft` file had lost or damaged.
- pub damaged: Vec,
+ /// What the file had lost or damaged.
+ pub damaged: Damage,
}
/// Read and parse the presentation at `path`.
@@ -202,7 +218,10 @@ fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result {
let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc);
report_damage(s, &damaged)?;
let st = s.doc()?;
- Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged}))
+ Ok(json!({
+ "document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
+ "damagedMedia": damaged.media, "lostSlides": damaged.slides,
+ }))
}
#[cfg(not(target_arch = "wasm32"))]
@@ -236,23 +255,35 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result {
let (doc, damaged) = open_checked(name, &data)?;
let i = add_opened(s, name, None, doc);
report_damage(s, &damaged)?;
- Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged}))
+ Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
}
-/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired.
-fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
+/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
+fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
if damaged.is_empty() {
return Ok(());
}
let st = s.doc_mut()?;
st.title = format!("{} (Repaired)", st.title);
- let n = damaged.len();
- let text = format!(
- "{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.",
- if n == 1 { "" } else { "s" },
- if n == 1 { "" } else { "s" },
- if n == 1 { "is" } else { "are" },
- );
+ let mut text = String::new();
+ let n = damaged.media.len();
+ if n > 0 {
+ text = format!(
+ "{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ",
+ if n == 1 { "" } else { "s" },
+ if n == 1 { "" } else { "s" },
+ if n == 1 { "is" } else { "are" },
+ );
+ }
+ let n = damaged.slides.len();
+ if n > 0 {
+ text.push_str(&format!(
+ "{n} slide{} in this file couldn't be read and {} left out. ",
+ if n == 1 { "" } else { "s" },
+ if n == 1 { "was" } else { "were" },
+ ));
+ }
+ text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
s.ui_requests.push(crate::UiRequest::Message { text });
Ok(())
}
@@ -431,8 +462,14 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 {
/// Render slide `index` (current when absent) to PNG bytes.
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) {
+ render_png_with(doc, index, scale, edit, false)
+}
+
+/// [`render_png`]; `transparent` leaves the slide background out (#784).
+pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec, u32, u32) {
let scale = image_scale(doc, scale);
- let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() });
+ let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() };
+ let img = deckcraft_render::render_slide(doc, index, &opts);
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
}
@@ -451,7 +488,7 @@ fn render(s: &mut Session, p: &Value) -> Result {
if i >= st.doc.slides.len() {
return Err(bad("file.render", format!("no slide {i}")));
}
- let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
+ let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false));
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
}
@@ -483,6 +520,10 @@ fn export(s: &mut Session, p: &Value) -> Result {
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
let st = s.doc()?;
let scale = finite_scale(f64_or(p, "scale", 2.0));
+ let transparent = bool_or(p, "transparentBackground", false);
+ if transparent && format != "png" {
+ return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}")));
+ }
match format.as_str() {
"png" | "jpeg" | "jpg" => {
let slides: Vec = match usize_param(p, "slide") {
@@ -497,7 +538,11 @@ fn export(s: &mut Session, p: &Value) -> Result {
let scale = image_scale(&st.doc, scale);
let mut written = vec![];
for (k, i) in slides.iter().enumerate() {
- let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
+ let img = deckcraft_render::render_slide(
+ &st.doc,
+ *i,
+ &deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() },
+ );
let bytes = picture_bytes(&img, &format, scale);
let out = if slides.len() == 1 {
path.clone()
diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs
index 36d54de9..75952c99 100644
--- a/crates/engine/src/cmd/format.rs
+++ b/crates/engine/src/cmd/format.rs
@@ -698,21 +698,28 @@ fn case(s: &mut Session, p: &Value) -> Result {
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { n };
let mut pos = 0;
- for r in &mut para.runs {
+ let mut pieces = Vec::new();
+ for (ri, r) in para.runs.iter().enumerate() {
+ // Breaks and fields are read for context (a break ends a word) but not rewritten.
let len = r.char_len();
- if r.kind == deckcraft_model::text::RunKind::Text {
- let rs = from.max(pos).saturating_sub(pos);
- let re = to.min(pos + len).saturating_sub(pos);
- if re > rs {
- let chars: Vec = r.text.chars().collect();
- let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default();
- let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default();
- let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default();
- r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m));
- }
+ let rs = from.max(pos).saturating_sub(pos);
+ let re = to.min(pos + len).saturating_sub(pos);
+ if re > rs {
+ pieces.push((ri, rs, re));
}
pos += len;
}
+ let mids: Vec = pieces
+ .iter()
+ .map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
+ .collect();
+ let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::>(), &m);
+ for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
+ let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
+ let pre: String = r.text.chars().take(rs).collect();
+ let post: String = r.text.chars().skip(re).collect();
+ r.text = format!("{pre}{new}{post}");
+ }
}
if collapsed {
t.anchor = a;
@@ -734,9 +741,12 @@ fn case(s: &mut Session, p: &Value) -> Result {
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
- for r in &mut para.runs {
+ // Breaks and fields are read for context (a break ends a word) but not rewritten.
+ let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
+ let changed = ed::change_case_parts(&texts, &mode);
+ for (r, new) in para.runs.iter_mut().zip(changed) {
if r.kind == deckcraft_model::text::RunKind::Text {
- r.text = ed::change_case(&r.text, &mode);
+ r.text = new;
}
}
}
diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs
index 38fd22b9..3af2c65f 100644
--- a/crates/engine/src/cmd/points.rs
+++ b/crates/engine/src/cmd/points.rs
@@ -16,7 +16,7 @@ use crate::{Result, Session};
pub fn specs() -> Vec {
vec![
cmd!(
- noundo "shape.editPoints",
+ "shape.editPoints",
"Edit Points",
["Shape Format", "Insert Shapes", "Edit Shape"],
None,
diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs
index 905f425d..1e6c09ac 100644
--- a/crates/engine/src/cmd/reuse.rs
+++ b/crates/engine/src/cmd/reuse.rs
@@ -3,11 +3,13 @@
//! The slides take the destination's look by default (each one moves to the destination layout
//! with the same name, else the same kind); with `keepFormatting` the source master and its
//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are
-//! renumbered with animations, triggers and connector ends following them.
+//! renumbered with animations, triggers and connector ends following them. Links to another
+//! reused slide follow its copy.
use std::collections::HashMap;
use std::sync::Arc;
+use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
@@ -60,6 +62,57 @@ fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap, map: &HashMap) {
+ let Some(h) = h else { return };
+ if let Action::Slide { slide } = &mut h.action {
+ *slide = map.get(slide).copied().unwrap_or(*slide);
+ }
+ if let Some(z) = &mut h.zoom_return {
+ *z = map.get(z).copied().unwrap_or(*z);
+ }
+}
+
+fn relink_run(r: &mut RunProps, map: &HashMap) {
+ relink(&mut r.link, map);
+ relink(&mut r.hover, map);
+}
+
+fn relink_text(t: &mut TextBody, map: &HashMap) {
+ for lv in t.list_style.levels.iter_mut().flatten() {
+ relink_run(&mut lv.run, map);
+ }
+ for p in &mut t.paragraphs {
+ relink_run(&mut p.def_props, map);
+ relink_run(&mut p.end_props, map);
+ for r in &mut p.runs {
+ relink_run(&mut r.props, map);
+ }
+ }
+}
+
+/// Point the slide links under `shapes` (click and hover actions, text links) at the copies.
+fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap, depth: usize) {
+ if depth > 64 {
+ return;
+ }
+ for sh in shapes {
+ relink(&mut sh.click, map);
+ relink(&mut sh.hover, map);
+ if let Some(t) = &mut sh.text {
+ relink_text(t, map);
+ }
+ if let ShapeKind::Table(t) = &mut sh.kind {
+ for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
+ relink_text(&mut c.text, map);
+ }
+ }
+ if let Some(ch) = sh.children_mut() {
+ relink_shapes(ch, map, depth + 1);
+ }
+ }
+}
+
/// Fresh shape ids for `shapes`, and the old → new map.
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result> {
let old = shape_ids(shapes);
@@ -134,8 +187,10 @@ fn reuse(s: &mut Session, p: &Value) -> Result {
doc.masters.push(Arc::new(m));
}
}
- let mut new_ids = vec![];
- for (k, &i) in picked.iter().enumerate() {
+ let mut copies: Vec = vec![];
+ // Source slide → its copy, so links between reused slides follow (forward ones too).
+ let mut slide_map: HashMap = HashMap::new();
+ for &i in &picked {
let Some(from) = src.slides.get(i) else { continue };
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
@@ -154,6 +209,13 @@ fn reuse(s: &mut Session, p: &Value) -> Result {
matching_layout(doc, &lname, kind)
}
};
+ slide_map.insert(from.id, sl.id);
+ copies.push(sl);
+ }
+ let mut new_ids = vec![];
+ for (k, mut sl) in copies.into_iter().enumerate() {
+ relink_shapes(&mut sl.shapes, &slide_map, 0);
+ relink_text(&mut sl.notes, &slide_map);
new_ids.push(sl.id);
doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl));
}
@@ -263,4 +325,76 @@ mod tests {
want.sort();
assert_eq!(got, want, "every reference resolves to its own copied bytes");
}
+
+ fn link_to(slide: SlideId) -> Hyperlink {
+ Hyperlink {
+ action: Action::Slide { slide },
+ tooltip: String::new(),
+ highlight_click: false,
+ sound: None,
+ stop_sound: false,
+ zoom_return: None,
+ }
+ }
+
+ fn linked_text(slide: SlideId) -> TextBody {
+ let mut text = TextBody::from_text("go");
+ if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
+ r.props.link = Some(link_to(slide));
+ }
+ text
+ }
+
+ /// Links to a slide that is reused too follow the copy; links to a slide left behind stay.
+ #[test]
+ fn reused_slides_link_to_each_others_copies() {
+ let mut src = defaults::new_presentation(None);
+ let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide");
+ for _ in 0..2 {
+ let mut sl = template.clone();
+ sl.id = SlideId(src.alloc_id().expect("id"));
+ src.slides.push(Arc::new(sl));
+ }
+ let ids: Vec = src.slides.iter().map(|s| s.id).collect();
+ let id = |i: usize| ids.get(i).copied().unwrap_or_default();
+ if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) {
+ sl.shapes = vec![
+ Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() },
+ Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() },
+ Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() },
+ ];
+ }
+ if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) {
+ sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }];
+ }
+ let bytes = deckcraft_format::save(&src).expect("save");
+ let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open");
+ let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide");
+
+ let mut s = Session::with_new();
+ for _ in 0..3 {
+ s.execute("slide.new", &json!({})).expect("new");
+ }
+ let out = s
+ .execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]}))
+ .expect("reuse");
+ let new: Vec = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids");
+ let doc = &s.doc().expect("doc").doc;
+ let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide");
+ let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone());
+ let first = copy(0);
+ let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() });
+ assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy");
+ let run_link = first
+ .shapes
+ .get(1)
+ .and_then(|sh| sh.text.as_ref())
+ .and_then(|t| t.paragraphs.first())
+ .and_then(|p| p.runs.first())
+ .and_then(|r| r.props.link.as_ref())
+ .map(|h| h.action.clone());
+ assert_eq!(run_link, to(1), "text link follows the copy");
+ assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
+ assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
+ }
}
diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs
index 65933bbd..9b061c8b 100644
--- a/crates/engine/src/cmd/review.rs
+++ b/crates/engine/src/cmd/review.rs
@@ -137,7 +137,14 @@ fn reply(s: &mut Session, p: &Value) -> Result {
fn delete(s: &mut Session, p: &Value) -> Result {
let all = bool_or(p, "all", false);
- let i = usize_param(p, "index");
+ let i = match p.get("index") {
+ None | Some(Value::Null) => None,
+ Some(v) => Some(
+ v.as_u64()
+ .and_then(|v| usize::try_from(v).ok())
+ .ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
+ ),
+ };
s.edit(|doc, sel| {
if all {
for sl in &mut doc.slides {
@@ -793,4 +800,18 @@ mod tests {
s.execute("show.setup", &json!({"custom": ""})).unwrap();
assert!(s.active().unwrap().doc.show.custom_show.is_none());
}
+
+ #[test]
+ fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
+ let mut s = Session::with_new();
+ s.execute("comment.add", &json!({"text": "a"})).unwrap();
+ s.execute("comment.add", &json!({"text": "b"})).unwrap();
+ for bad_index in [json!(-1), json!("2"), json!(1.5)] {
+ let r = s.execute("comment.delete", &json!({"index": bad_index}));
+ assert!(r.is_err(), "index {bad_index} gave {r:?}");
+ assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
+ }
+ s.execute("comment.delete", &json!({})).unwrap();
+ assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
+ }
}
diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs
index f69e55be..fc63314b 100644
--- a/crates/engine/src/cmd/richclip.rs
+++ b/crates/engine/src/cmd/richclip.rs
@@ -274,20 +274,22 @@ fn decode_text(raw: &str) -> String {
break;
};
let entity = &rest[1..end];
- rest = &rest[end + 1..];
- match entity {
- "amp" => out.push('&'),
- "lt" => out.push('<'),
- "gt" => out.push('>'),
- "quot" => out.push('"'),
- "apos" => out.push('\''),
- "nbsp" => out.push(' '),
- _ => {
- let ch = entity.strip_prefix('#').and_then(numeric_char);
- if let Some(c) = ch {
- out.push(c);
- }
- }
+ let decoded = match entity {
+ "amp" => Some('&'),
+ "lt" => Some('<'),
+ "gt" => Some('>'),
+ "quot" => Some('"'),
+ "apos" => Some('\''),
+ "nbsp" => Some(' '),
+ _ => entity.strip_prefix('#').and_then(numeric_char),
+ };
+ if let Some(c) = decoded {
+ out.push(c);
+ rest = &rest[end + 1..];
+ } else {
+ // Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed.
+ out.push('&');
+ rest = &rest[1..];
}
}
if !rest.is_empty() && !rest.starts_with('&') {
@@ -350,6 +352,14 @@ mod tests {
assert!(last.props.underline.is_some());
}
+ #[test]
+ fn bare_ampersand_keeps_text_up_to_next_semicolon() {
+ let text = |html: &str| html_to_body(html).unwrap().text();
+ assert_eq!(text("
R&D costs; up
"), "R&D costs; up");
+ assert_eq!(text("
AT&T; Q&A
"), "AT&T; Q&A");
+ assert_eq!(text("
A & B & C & D
"), "A & B & C & D");
+ }
+
#[test]
fn pages_style_and_escapes() {
let html = "
A & B
C
";
diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs
index e3bf3920..f1a050d1 100644
--- a/crates/engine/src/cmd/shape.rs
+++ b/crates/engine/src/cmd/shape.rs
@@ -169,13 +169,24 @@ pub fn specs() -> Vec {
]
}
+/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
+/// An overflow to infinity lands on the limit; NaN becomes 0.
+fn clamp_side(v: f64) -> f64 {
+ if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) }
+}
+
+/// Keeps a coordinate within `±POS_LIMIT`.
+fn clamp_pos(v: f64) -> f64 {
+ if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) }
+}
+
fn move_by(s: &mut Session, p: &Value) -> Result {
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
edit_geometry(s, p, "shape.move", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
- x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
- x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
+ x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0));
+ x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0));
}
Ok(())
})
@@ -202,10 +213,12 @@ fn resize(s: &mut Session, p: &Value) -> Result {
x.h = h.unwrap_or(x.h);
}
}
+ x.w = clamp_side(x.w);
+ x.h = clamp_side(x.h);
// Rotated shapes resize about their centre; others keep the top-left.
if x.rot != 0.0 {
- x.x = cx - x.w / 2.0;
- x.y = cy - x.h / 2.0;
+ x.x = clamp_pos(cx - x.w / 2.0);
+ x.y = clamp_pos(cy - x.h / 2.0);
}
}
Ok(())
@@ -215,10 +228,10 @@ fn resize(s: &mut Session, p: &Value) -> Result {
fn set_bounds(s: &mut Session, p: &Value) -> Result {
edit_geometry(s, p, "shape.setBounds", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
- x.x = f64_param(p, "x").unwrap_or(x.x);
- x.y = f64_param(p, "y").unwrap_or(x.y);
- x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0);
- x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0);
+ x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x));
+ x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y));
+ x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w));
+ x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h));
}
Ok(())
})
@@ -406,6 +419,8 @@ fn flip(s: &mut Session, p: &Value) -> Result {
} else {
x.flip_h = !x.flip_h;
}
+ // `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation.
+ x.rot = (360.0 - x.rot_norm()) % 360.0;
}
Ok(())
})
diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs
index b203f083..cf8e92ad 100644
--- a/crates/engine/src/cmd/slide.rs
+++ b/crates/engine/src/cmd/slide.rs
@@ -627,6 +627,17 @@ fn from_outline(s: &mut Session, p: &Value) -> Result {
for (k, sl) in new.into_iter().enumerate() {
doc.slides.insert(at + k, sl);
}
+ // Join the section of the slide before, as slide.new does.
+ let ids: Vec = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
+ let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
+ let section = prev.and_then(|p| doc.section_of(p));
+ if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
+ let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
+ let pos = pos.min(sec.slides.len());
+ sec.slides.splice(pos..pos, ids);
+ } else if let Some(first) = doc.sections.first_mut() {
+ first.slides.splice(0..0, ids);
+ }
Ok(json!({"slides": n}))
})
}
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index c6728740..8ab95549 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -537,7 +537,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result {
with_table(s, p, "table.columnWidth", move |t| {
// Keeping the table's width, the column to the right gives or takes the difference (dragging
// an inner border). The last column has none: the table gets wider or narrower.
- let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
+ let pair = t.cols.get(c).zip(t.cols.get(c.saturating_add(1))).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
match pair {
Some(pair) if keep_total => {
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
@@ -545,7 +545,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result {
if let Some(x) = t.cols.get_mut(c) {
*x = w;
}
- if let Some(x) = t.cols.get_mut(c + 1) {
+ if let Some(x) = t.cols.get_mut(c.saturating_add(1)) {
*x = pair - w;
}
}
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index a0408eff..fc6fcacc 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -31,7 +31,7 @@ pub fn specs() -> Vec {
"AutoCorrect Options",
["Tools"],
None,
- "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
+ "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
always,
autocorrect_options
),
@@ -655,7 +655,12 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
} else {
correct_word(&s.prefs, &word, sentence_start, typing)
};
- let Some(rep) = rep else { return Ok(()) };
+ let Some(rep) = rep else {
+ if typing && s.prefs.ac_ordinals && is_ordinal(&word) {
+ return superscript_suffix(s, &t, word_end);
+ }
+ return Ok(());
+ };
let delta = rep.chars().count() as isize - word.chars().count() as isize;
let st = s.doc_mut()?;
let mut d = (*st.doc).clone();
@@ -674,6 +679,43 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
Ok(())
}
+/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
+/// superscripts it.
+fn is_ordinal(word: &str) -> bool {
+ let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase());
+ let suffix = word.get(digits.len()..).unwrap_or_default();
+ if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 {
+ return false;
+ }
+ let d = digits.as_bytes();
+ let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1');
+ let want = match d.last() {
+ _ if teen => "th",
+ Some(b'1') => "st",
+ Some(b'2') => "nd",
+ Some(b'3') => "rd",
+ _ => "th",
+ };
+ suffix == want
+}
+
+/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix).
+fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> {
+ let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end));
+ let st = s.doc_mut()?;
+ if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) {
+ return Ok(());
+ }
+ let mut d = (*st.doc).clone();
+ let sel = st.selection.clone();
+ if let Some(body) = body_mut(&mut d, &sel, t) {
+ ed::format(body, a, b, &|r| r.baseline = Some(0.3));
+ }
+ st.doc = Arc::new(d);
+ st.revision += 1;
+ Ok(())
+}
+
fn autocorrect_options(s: &mut Session, p: &Value) -> Result {
let pr = &mut s.prefs;
for (key, slot) in [
@@ -683,6 +725,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result {
("days", &mut pr.ac_days),
("capsLock", &mut pr.ac_caps_lock),
("fractions", &mut pr.ac_fractions),
+ ("ordinals", &mut pr.ac_ordinals),
] {
if let Some(v) = p.get(key).and_then(Value::as_bool) {
*slot = v;
@@ -729,7 +772,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result {
Ok(json!({
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
- "entries": list,
+ "ordinals": pr.ac_ordinals, "entries": list,
}))
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index ac25d666..418bff04 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -324,12 +324,13 @@ pub struct Prefs {
pub smart_quotes: bool,
/// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first
/// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4
- /// and 3/4 with fraction characters.
+ /// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
pub ac_two_initials: bool,
pub ac_sentence_caps: bool,
pub ac_days: bool,
pub ac_caps_lock: bool,
pub ac_fractions: bool,
+ pub ac_ordinals: bool,
/// Your own "replace … with …" entries; they win over the built-in list.
pub ac_entries: Vec<(String, String)>,
/// Built-in entries you deleted.
@@ -354,6 +355,7 @@ impl Default for Prefs {
ac_days: true,
ac_caps_lock: true,
ac_fractions: true,
+ ac_ordinals: true,
ac_entries: Vec::new(),
ac_removed: Vec::new(),
author: "Presenter".into(),
diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs
index 707d0aad..6996cda7 100644
--- a/crates/engine/src/recovery.rs
+++ b/crates/engine/src/recovery.rs
@@ -311,7 +311,10 @@ mod tests {
let opened = open(&mut s2, &dir).unwrap();
assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}");
assert!(lock_path(&dir, other).exists(), "a live owner's lock stays");
- // Once the owner is gone, its entry is recovered and its lock file cleaned up.
+ // Once the owner is gone, its entry is recovered and its lock file cleaned up. Unlock
+ // rather than only drop: a child spawned meanwhile by a parallel test (mkfifo) shares the
+ // open file and would keep the lock held until it execs.
+ lock.unlock().unwrap();
drop(lock);
let mut s3 = Session::new();
open(&mut s3, &dir).unwrap();
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index f7022eab..dd64b08c 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -94,6 +94,26 @@ fn text_editing_flow() {
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
}
+/// Cut with only a caret copies nothing, so it must not delete the character after the caret.
+#[test]
+fn cut_with_only_a_caret_changes_nothing() {
+ let mut s = session();
+ let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
+ let id = r["id"].as_u64().unwrap();
+ s.execute("text.edit", &json!({"id": id})).unwrap();
+ s.execute("text.move", &json!({"to": "start"})).unwrap();
+ let out = s.execute("edit.cut", &json!({})).unwrap();
+ assert_eq!(out["text"], "");
+ let t = s.execute("text.get", &json!({"id": id})).unwrap();
+ assert_eq!(t["text"], "Hello", "cut with only a caret changed the text");
+ // A real selection is still cut.
+ s.execute("text.move", &json!({"to": "end", "extend": true})).unwrap();
+ let out = s.execute("edit.cut", &json!({})).unwrap();
+ assert_eq!(out["text"], "Hello");
+ let t = s.execute("text.get", &json!({"id": id})).unwrap();
+ assert_eq!(t["text"], "");
+}
+
#[test]
fn editing_text_refits_a_saved_shrink() {
use deckcraft_model::text::AutoFit;
@@ -2777,6 +2797,33 @@ fn a_png_export_records_the_slide_resolution() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// #784: `transparentBackground` leaves the slide background out of a PNG export, so only the
+/// slide's content is opaque; formats without transparency refuse it and write nothing.
+#[test]
+fn a_png_export_can_leave_the_background_transparent() {
+ let mut s = session();
+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
+ s.execute("design.background", &json!({"color": "#0000C8"})).unwrap();
+ let dir = std::env::temp_dir().join(format!("deckcraft-transparent-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let read = |path: &std::path::Path| image::load_from_memory(&std::fs::read(path).unwrap()).unwrap().to_rgba8();
+ let opaque = dir.join("opaque.png");
+ s.execute("file.export", &json!({"path": opaque.to_str().unwrap(), "scale": 1})).unwrap();
+ assert_eq!(read(&opaque).get_pixel(5, 5).0, [0, 0, 200, 255], "the background draws by default");
+ let clear = dir.join("clear.png");
+ s.execute("file.export", &json!({"path": clear.to_str().unwrap(), "scale": 1, "transparentBackground": true})).unwrap();
+ let img = read(&clear);
+ assert_eq!(img.get_pixel(5, 5).0[3], 0, "no background: {:?}", img.get_pixel(5, 5));
+ assert_eq!(img.get_pixel(200, 150).0[3], 255, "the shape stays opaque: {:?}", img.get_pixel(200, 150));
+ for (name, format) in [("slide.jpg", "jpeg"), ("deck.pdf", "pdf"), ("deck.gif", "gif"), ("deck.pptx", "pptx")] {
+ let path = dir.join(name);
+ let e = s.execute("file.export", &json!({"path": path.to_str().unwrap(), "format": format, "transparentBackground": true})).unwrap_err();
+ assert!(e.to_string().contains("for PNG export"), "{format}: {e}");
+ assert!(!path.exists(), "{format}: nothing is written");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?;
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
@@ -3120,6 +3167,37 @@ fn autocorrect_options_as_you_type() {
assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes ");
}
+#[test]
+fn autocorrect_superscripts_ordinals_as_you_type() {
+ let mut s = session();
+ let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
+ // The typed text with each superscript run in ^(…).
+ let typed = |s: &mut Session, text: &str| -> String {
+ s.execute("text.edit", &json!({"id": title})).unwrap();
+ s.execute("text.selectAll", &json!({})).ok();
+ s.execute("text.delete", &json!({"dir": "backward"})).ok();
+ for c in text.chars() {
+ s.execute("text.insert", &json!({"text": c.to_string()})).unwrap();
+ }
+ let st = s.doc().unwrap();
+ let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap();
+ sh.text.as_ref().unwrap().paragraphs[0]
+ .runs
+ .iter()
+ .map(|r| if r.props.baseline.unwrap_or(0.0) > 0.0 { format!("^({})", r.text) } else { r.text.clone() })
+ .collect()
+ };
+ assert_eq!(typed(&mut s, "1st, 22nd and 103rd. 4th 11th x"), "1^(st), 22^(nd) and 103^(rd). 4^(th) 11^(th) x");
+ // Not a word, a number without a suffix it takes, or a suffix not yet followed by a space.
+ assert_eq!(typed(&mut s, "first x1st 1th 2st 21st"), "First x1st 1th 2st 21st");
+ assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["ordinals"], true);
+ s.execute("text.autocorrect", &json!({"ordinals": false})).unwrap();
+ assert_eq!(typed(&mut s, "2nd "), "2nd ");
+ // AutoCorrect off turns it off too.
+ s.execute("text.autocorrect", &json!({"ordinals": true, "on": false})).unwrap();
+ assert_eq!(typed(&mut s, "2nd "), "2nd ");
+}
+
#[test]
fn theme_variants_apply_and_report_the_current_one() {
let mut s = session();
@@ -3517,7 +3595,8 @@ fn background_loads_are_adopted_through_a_command() {
replay.execute(&id, ¶ms).unwrap();
assert_eq!(replay.doc().unwrap().doc.slides.len(), 2);
// A token staged for another path is not taken for this one.
- let other = s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: vec![] });
+ let other =
+ s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: Default::default() });
s.execute("file.adopt", &json!({"path": path, "token": other})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
assert!(s.execute("file.adopt", &json!({})).is_err());
@@ -3592,3 +3671,404 @@ fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
assert_eq!(t.n_cols(), 3);
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
}
+
+#[test]
+fn table_column_width_of_a_huge_column_is_ignored() {
+ let mut s = session();
+ let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
+ for keep_total in [true, false] {
+ let r = s.execute("table.columnWidth", &json!({"id": id.0, "col": u64::MAX, "width": 50.0, "keepTotal": keep_total}));
+ assert!(r.is_ok(), "columnWidth must ignore a huge col (keepTotal {keep_total}), got {r:?}");
+ assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes");
+ }
+}
+
+/// Slides imported from an outline join the section of the slide they follow, like `slide.new`.
+#[test]
+fn slides_from_outline_join_the_section_of_the_slide_they_follow() {
+ for markdown in [false, true] {
+ let mut s = session();
+ s.execute("slide.new", &json!({"title": "A"})).unwrap();
+ s.execute("section.add", &json!({"name": "B", "at": 1})).unwrap();
+ let text = if markdown { "# Imported\n- bullet" } else { "Imported\n\tbullet" };
+ s.execute("slide.fromOutline", &json!({"text": text, "markdown": markdown})).unwrap();
+ let doc = s.doc().unwrap().doc.clone();
+ let orphans: Vec<_> = doc.slides.iter().map(|x| x.id).filter(|id| doc.section_of(*id).is_none()).collect();
+ assert!(orphans.is_empty(), "markdown={markdown}: slides in no section: {orphans:?}; sections {:?}", doc.sections);
+ let order: Vec<_> = doc.sections.iter().flat_map(|x| x.slides.iter().copied()).collect();
+ let slides: Vec<_> = doc.slides.iter().map(|x| x.id).collect();
+ assert_eq!(order, slides, "markdown={markdown}: sections follow the slide order");
+ }
+}
+
+#[test]
+fn opening_a_pptx_with_an_unreadable_slide_says_so() {
+ use std::io::{Cursor, Read, Write};
+ let mut s = session();
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
+ let good = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap();
+ let mut zin = zip::ZipArchive::new(Cursor::new(good.clone())).unwrap();
+ let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ let o = zip::write::SimpleFileOptions::default();
+ for i in 0..zin.len() {
+ let mut f = zin.by_index(i).unwrap();
+ let mut d = vec![];
+ f.read_to_end(&mut d).unwrap();
+ if f.name() == "ppt/slides/slide2.xml" {
+ d = b" = match &mut sh.kind {
+ deckcraft_model::ShapeKind::Table(t) => t
+ .rows
+ .iter_mut()
+ .flat_map(|r| r.cells.iter_mut())
+ .flat_map(|c| c.text.paragraphs.iter_mut())
+ .flat_map(|p| p.runs.iter_mut())
+ .collect(),
+ _ => sh.text.iter_mut().flat_map(|t| t.paragraphs.iter_mut()).flat_map(|p| p.runs.iter_mut()).collect(),
+ };
+ for r in runs {
+ r.props.glow = glow.clone();
+ }
+ }
+ // Inherited styles too: the deck default, a master's body style, a text box's own list
+ // style, and the theme's effect styles that shape styles point at.
+ let level = deckcraft_model::text::LevelStyle {
+ run: deckcraft_model::text::RunProps {
+ glow: Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ doc.default_text_style.set(0, level.clone());
+ let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
+ m.body_style.set(0, level.clone());
+ if let Some(fx) = m.theme.format.effects.first_mut() {
+ fx.glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
+ }
+ let list = crate::shapes_mut(doc, sel).unwrap();
+ if let Some(t) = list.iter_mut().find_map(|sh| sh.text.as_mut()) {
+ t.list_style.set(0, level);
+ }
+ Ok(())
+ })
+ .unwrap();
+ for format in ["deckcraft", "pptx"] {
+ let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, format).unwrap();
+ let mut t = Session::new();
+ t.execute("file.openBytes", &json!({"name": format!("fx.{format}"), "data": cmd::base64_encode(&bytes)})).unwrap();
+ let st = t.doc().unwrap();
+ let slide = st.doc.slides.last().unwrap();
+ let mut glows = vec![];
+ deckcraft_model::walk(&slide.shapes, &mut |sh, _| {
+ if let Some(e) = &sh.effects {
+ glows.extend(e.glow.as_ref().map(|g| g.radius));
+ assert!(e.soft_edge.is_none_or(|v| (0.0..=100.0).contains(&v)), "{format}: {:?}", e.soft_edge);
+ if let Some(sw) = &e.outer_shadow {
+ assert!(sw.blur <= 200.0 && sw.dist <= 200.0 && sw.sx <= 10.0, "{format}: {sw:?}");
+ }
+ if let Some(r) = &e.reflection {
+ assert!(r.blur <= 100.0 && r.dist <= 100.0, "{format}: {r:?}");
+ }
+ }
+ for p in sh.text.iter().flat_map(|t| t.paragraphs.iter()) {
+ glows.extend(p.runs.iter().filter_map(|r| r.props.glow.as_ref().map(|g| g.radius)));
+ }
+ if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
+ let cells = tb.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.text.paragraphs.iter());
+ glows.extend(cells.flat_map(|p| p.runs.iter()).filter_map(|r| r.props.glow.as_ref().map(|g| g.radius)));
+ }
+ });
+ let inherited = |ls: &deckcraft_model::text::ListStyle| {
+ ls.levels.iter().flatten().filter_map(|l| l.run.glow.as_ref().map(|g| g.radius)).collect::>()
+ };
+ glows.extend(inherited(&st.doc.default_text_style));
+ let m = &st.doc.masters[0];
+ for ls in [&m.title_style, &m.body_style, &m.other_style] {
+ glows.extend(inherited(ls));
+ }
+ glows.extend(m.theme.format.effects.iter().filter_map(|e| e.glow.as_ref().map(|g| g.radius)));
+ deckcraft_model::walk(&slide.shapes, &mut |sh, _| glows.extend(sh.text.iter().flat_map(|t| inherited(&t.list_style))));
+ assert!(!glows.is_empty(), "{format}: the glows were kept");
+ assert!(glows.iter().all(|g| (0.0..=150.0).contains(g)), "{format}: {glows:?}");
+ // Capped, the slide renders (checked last: uncapped, this is what ran out of memory).
+ let i = st.doc.slides.len() - 1;
+ t.execute("file.render", &json!({"slide": i, "scale": 0.1})).unwrap();
+ }
+}
+
+/// A .deckcraft file whose shapes repeat an id (groups included) opens with fresh ids for the
+/// repeats: the deck passes `validate`, the first shape keeps its id and none is lost.
+#[test]
+fn opening_a_file_gives_repeated_shape_ids_fresh_ones() {
+ let mut s = Session::with_new();
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ for x in [0, 100, 200, 300] {
+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 80, 80]})).unwrap();
+ }
+ s.execute("edit.selectAll", &json!({})).unwrap();
+ s.execute("arrange.group", &json!({})).unwrap();
+ s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [400, 300, 80, 80]})).unwrap();
+ let first = s.doc().unwrap().shapes()[0].id;
+ // Every shape, the group's children too, takes the group's id.
+ s.edit(|doc, sel| {
+ fn all(shapes: &mut [deckcraft_model::Shape], id: deckcraft_model::ShapeId) {
+ for sh in shapes {
+ sh.id = id;
+ if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
+ all(children, id);
+ }
+ }
+ }
+ all(crate::shapes_mut(doc, sel).unwrap(), first);
+ Ok(())
+ })
+ .unwrap();
+ assert!(!s.doc().unwrap().doc.validate().is_empty(), "the deck repeats ids");
+ let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap();
+ let mut t = Session::new();
+ t.execute("file.openBytes", &json!({"name": "dup.deckcraft", "data": cmd::base64_encode(&bytes)})).unwrap();
+ let st = t.doc().unwrap();
+ assert_eq!(st.doc.validate(), Vec::::new());
+ let slide = st.doc.slides.last().unwrap();
+ let mut ids = vec![];
+ deckcraft_model::walk(&slide.shapes, &mut |sh, _| ids.push(sh.id));
+ assert_eq!(ids.len(), 6, "the group, its four children and the ellipse");
+ assert_eq!(ids[0], first, "the first shape keeps its id");
+ assert!(ids.iter().all(|id| id.0 <= st.doc.next_id), "new ids come from the deck's counter");
+ // Each shape can be selected by its own id now.
+ let last = *ids.last().unwrap();
+ t.execute("slide.go", &json!({"index": st.doc.slides.len() - 1})).unwrap();
+ t.execute("selection.select", &json!({"id": last.0})).unwrap();
+ assert_eq!(t.doc().unwrap().selected_shapes()[0].id, last);
+}
+
+#[test]
+fn change_case_spans_formatting_runs() {
+ let setup = |text: &str, bold_to: usize| {
+ let mut s = session();
+ let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 420.0, 160.0], "text": text})).unwrap();
+ let id = r["id"].as_u64().unwrap() as u32;
+ s.execute("text.edit", &json!({"id": id})).unwrap();
+ s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, bold_to]})).unwrap();
+ s.execute("format.bold", &json!({"on": true})).unwrap();
+ (s, id)
+ };
+ let text_of = |s: &Session, id: u32| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
+ for (mode, input, want) in [("title", "hello world", "Hello World"), ("sentence", "hello world. bye", "Hello world. Bye")] {
+ // Text range.
+ let (mut s, id) = setup(input, 3);
+ let n = input.chars().count();
+ s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, n]})).unwrap();
+ s.execute("format.case", &json!({"mode": mode})).unwrap();
+ assert_eq!(text_of(&s, id).text(), want, "{mode} range");
+ // Whole shape.
+ let (mut s, id) = setup(input, 3);
+ s.execute("text.exit", &json!({})).unwrap();
+ s.execute("format.case", &json!({"mode": mode})).unwrap();
+ assert_eq!(text_of(&s, id).text(), want, "{mode} shape");
+ assert_eq!(text_of(&s, id).paragraphs[0].runs[0].props.bold, Some(true));
+ }
+ // A length-changing mapping keeps each piece in its own run.
+ let (mut s, id) = setup("straße", 4);
+ s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 6]})).unwrap();
+ s.execute("format.case", &json!({"mode": "upper"})).unwrap();
+ let body = text_of(&s, id);
+ assert_eq!(body.text(), "STRASSE");
+ assert_eq!(body.paragraphs[0].runs[0].text, "STRA");
+ assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
+}
+
+#[test]
+fn flipping_a_rotated_shape_mirrors_its_outline() {
+ let corners = |s: &Session, id: deckcraft_model::ShapeId| {
+ let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap();
+ let a = x.affine();
+ [(0.0, 0.0), (x.w, 0.0), (x.w, x.h), (0.0, x.h)].map(|(px, py)| {
+ let p = a * deckcraft_geom::Point::new(px, py);
+ ((p.x * 100.0).round() as i64, (p.y * 100.0).round() as i64)
+ })
+ };
+ let sorted = |mut c: [(i64, i64); 4]| {
+ c.sort_unstable();
+ c
+ };
+ for (cmd, horizontal) in [("arrange.flipHorizontal", true), ("arrange.flipVertical", false)] {
+ let mut s = session();
+ let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 50]})).unwrap();
+ let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
+ s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
+ let before = corners(&s, id);
+ // Mirror about the box's centre line (centre is 200, 125).
+ let mirrored = before.map(|(x, y)| if horizontal { (40000 - x, y) } else { (x, 25000 - y) });
+ s.execute(cmd, &json!({})).unwrap();
+ assert_eq!(sorted(corners(&s, id)), sorted(mirrored), "{cmd} of a 30° rotated rectangle is not its mirror image");
+ let rot = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().rot;
+ assert!((rot - 330.0).abs() < 1e-9, "{cmd} negates the rotation, got {rot}");
+ s.execute(cmd, &json!({})).unwrap();
+ assert_eq!(sorted(corners(&s, id)), sorted(before), "{cmd} twice restores the outline");
+ }
+}
+
+#[test]
+fn shape_geometry_commands_limit_size_and_position() {
+ let mut s = Session::with_new();
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ s.execute("shape.insert", &json!({"preset": "moon", "rect": [100, 100, 200, 100]})).unwrap();
+ let xfrm = |s: &Session| s.doc().unwrap().selected_shapes()[0].xfrm.unwrap();
+ let within = |s: &Session| {
+ let x = xfrm(s);
+ [x.w, x.h].iter().all(|v| v.is_finite() && (0.0..=15_840.0).contains(v)) && [x.x, x.y].iter().all(|v| v.is_finite() && v.abs() <= 100_000.0)
+ };
+ s.execute("shape.setBounds", &json!({"x": 1e100, "y": -1e100, "w": 100000, "h": 1e100})).unwrap();
+ let x = xfrm(&s);
+ assert!(within(&s), "setBounds stored {x:?}");
+ s.execute("shape.resize", &json!({"w": 10, "h": 10})).unwrap();
+ s.execute("shape.resize", &json!({"h": 1e100})).unwrap();
+ let x = xfrm(&s);
+ assert!(within(&s), "resize stored {x:?}");
+ s.execute("shape.resize", &json!({"h": 1e100, "lockAspect": true})).unwrap();
+ let x = xfrm(&s);
+ assert!(within(&s), "locked resize stored {x:?}");
+ s.execute("shape.move", &json!({"x": 1e100, "y": -1e100})).unwrap();
+ let x = xfrm(&s);
+ assert!(within(&s), "move stored {x:?}");
+ // A move that overflows to infinity lands on the limit, not back at 0.
+ s.execute("shape.move", &json!({"x": 1e308, "dx": 1e308})).unwrap();
+ assert_eq!(xfrm(&s).x, 100_000.0);
+ // A rotated shape resizes about its centre and still stays inside the limit.
+ s.execute("shape.setBounds", &json!({"x": 99_000, "y": 0, "w": 10, "h": 10})).unwrap();
+ s.execute("shape.rotate", &json!({"deg": 45})).unwrap();
+ s.execute("shape.resize", &json!({"w": 1e100})).unwrap();
+ let x = xfrm(&s);
+ assert!(within(&s), "rotated resize stored {x:?}");
+}
+
+/// Edit Points turns a preset into a freeform: one undo step, so Undo brings the preset back
+/// (and leaves Edit Points), Redo the freeform; leaving Edit Points adds no step.
+#[test]
+fn edit_points_conversion_is_one_undo_step() {
+ use deckcraft_model::Geom;
+ let mut s = Session::with_new();
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ s.execute("shape.insert", &json!({"preset": "star5", "rect": [100, 100, 200, 100]})).unwrap();
+ let preset = |s: &Session| matches!(s.doc().unwrap().selected_shapes()[0].geom, Geom::Preset { .. });
+ s.execute("shape.editPoints", &json!({"on": true})).unwrap();
+ assert!(!preset(&s), "Edit Points made a freeform");
+ assert!(s.tool.editing_points(s.doc().unwrap()).is_some());
+ s.execute("shape.editPoints", &json!({"on": false})).unwrap();
+ // Leaving changed nothing, so the next undo is the conversion itself.
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(preset(&s), "Undo brings the star back");
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert!(!preset(&s), "Redo makes it a freeform again");
+ // Undo while still editing points ends Edit Points: the star has no points to edit.
+ s.execute("edit.undo", &json!({})).unwrap();
+ s.execute("shape.editPoints", &json!({"on": true})).unwrap();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(preset(&s));
+ assert!(s.tool.editing_points(s.doc().unwrap()).is_none());
+ // A freeform needs no conversion: entering Edit Points again adds no undo step.
+ s.execute("edit.redo", &json!({})).unwrap();
+ s.execute("shape.editPoints", &json!({"on": false})).unwrap();
+ s.execute("shape.editPoints", &json!({"on": true})).unwrap();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(preset(&s), "the only step was the conversion");
+}
+
+/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
+/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
+#[test]
+fn a_shape_in_front_of_edited_text_takes_the_click() {
+ let mut s = Session::with_new();
+ s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
+ let square = s.execute("shape.insert", &json!({"preset": "rect", "rect": [380, 260, 200, 120]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None };
+ let click = |s: &mut Session, x: f64, y: f64| {
+ s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
+ s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
+ };
+ // The content placeholder's edge: its (empty) text is now being edited.
+ click(&mut s, 66.0, 300.0);
+ let body = s.doc().unwrap().selection.text.as_ref().map(|t| t.shape).expect("editing the content placeholder");
+ assert_ne!(body.0 as u64, square);
+ // A click on the text away from the square keeps editing it.
+ click(&mut s, 200.0, 300.0);
+ assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape), Some(body));
+ // A click on the square, in front of the text, selects the square.
+ click(&mut s, 480.0, 320.0);
+ let st = s.doc().unwrap();
+ assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![square]);
+ assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 == square), "{:?}", st.selection.text);
+
+ // A shape over the edited box's edge takes the click there too: the edge would otherwise drag
+ // the box.
+ let edge = {
+ let st = s.doc().unwrap();
+ let x = cmd::xfrm_of(&st.doc, &st.selection, st.shape(body).unwrap());
+ (x.x, x.y + x.h / 2.0)
+ };
+ let corner =
+ s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [edge.0 - 10.0, edge.1 - 10.0, 20, 20]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("edit.deselect", &json!({})).unwrap();
+ s.execute("text.edit", &json!({"id": body.0})).unwrap();
+ // On the box's left edge, inside the band that drags it, clear of its middle handle (handles
+ // stay on top, as in PowerPoint).
+ click(&mut s, edge.0 + 1.0, edge.1 - 9.0);
+ let st = s.doc().unwrap();
+ assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![corner], "{:?}", st.selection);
+
+ // Text inside a group: a member in front of it takes the click instead of the caret.
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ let tb =
+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 150], "text": "Grouped text"})).unwrap()["id"].as_u64().unwrap();
+ let cover = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 150, 60, 40]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("arrange.group", &json!({"ids": [tb, cover]})).unwrap();
+ s.execute("text.edit", &json!({"id": tb})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(tb));
+ click(&mut s, 230.0, 170.0);
+ let st = s.doc().unwrap();
+ assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection);
+}
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index f1acd9e9..a7190b73 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -280,7 +280,12 @@ impl ToolState {
/// The shape in Edit Points, while it is still the one selected shape.
pub fn editing_points(&self, st: &crate::DocState) -> Option {
- self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).is_some())
+ // Only a freeform has points: after Undo takes the conversion back, Edit Points is over.
+ self.edit_points.filter(|id| {
+ st.selection.shapes.as_slice() == [*id]
+ && st.selection.text.is_none()
+ && st.shape(*id).is_some_and(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. }))
+ })
}
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
@@ -409,10 +414,12 @@ impl Session {
}
}
}
- // The frame of the text being edited: grabbing it moves the box.
+ // The frame of the text being edited: grabbing it moves the box, unless a shape in front
+ // of the text covers that spot (#931).
if let Some(e) = edited_box(st)
&& e.movable
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
+ && !shape_in_front(st, st.shapes(), e.id, p, tol, 0)
{
return Some(Hit::Frame { id: e.id });
}
@@ -1228,7 +1235,10 @@ impl Session {
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
};
+ // A shape in front of the edited text takes the click, as in PowerPoint (#931).
+ let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0);
if !inside
+ || in_front
|| matches!(
self.hit_test(p, tol),
Some(
@@ -1650,6 +1660,27 @@ struct EditedBox {
movable: bool,
}
+/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its
+/// list, topmost first, and inside a group that holds it, the members above it.
+fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool {
+ if depth > 64 {
+ return false;
+ }
+ for s in shapes.iter().rev() {
+ if s.id == target {
+ return false;
+ }
+ if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() {
+ // The group holding the text: look among its members.
+ return shape_in_front(st, s.children(), target, p, tol, depth + 1);
+ }
+ if !s.hidden && hit_shape(st, s, p, tol).is_some() {
+ return true;
+ }
+ }
+ false
+}
+
fn edited_box(st: &crate::DocState) -> Option {
let t = st.selection.text.as_ref()?;
if t.notes || t.cell.is_some() {
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/engine/tests/show_clicks.rs b/crates/engine/tests/show_clicks.rs
index 92da2f7f..42c0f701 100644
--- a/crates/engine/tests/show_clicks.rs
+++ b/crates/engine/tests/show_clicks.rs
@@ -59,6 +59,34 @@ fn start(p: &mut Presentation) {
p.slides.clear();
}
+#[test]
+fn saved_trigger_reveals_then_hides_on_separate_clicks() {
+ let mut p = defaults::new_presentation(None);
+ start(&mut p);
+ let button = shape(&mut p, "Button", "Toggle", 20.0, 20.0, 100.0, 40.0);
+ let target = shape(&mut p, "Target", "Answer", 200.0, 100.0, 200.0, 80.0);
+ let animations = vec![
+ Animation {
+ shape: target.id,
+ trigger: Some(button.id),
+ effect: "appear".into(),
+ start: AnimStart::OnClick,
+ duration_ms: 0,
+ ..Default::default()
+ },
+ disappear_on_click(button.id, target.id),
+ ];
+ add_slide(&mut p, vec![button, target], animations, false);
+ let mut show = Show::open(&p);
+ assert!(!show.shown("Target"));
+ for _ in 0..2 {
+ assert_eq!(show.click("Button"), Click::Animated);
+ assert!(show.shown("Target"), "first click reveals the answer until the next click");
+ assert_eq!(show.click("Button"), Click::Animated);
+ assert!(!show.shown("Target"), "second click hides the answer");
+ }
+}
+
/// The slide show window's click handling, on a deck read from a PPTX file.
struct Show {
pres: Presentation,
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 a9db66e2..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))
}
@@ -197,6 +207,7 @@ pub fn repair(p: &mut Presentation) {
}
}
}
+ reid_duplicate_shapes(p);
p.slide_size = sane_slide_size(p.slide_size);
// The notes page only lays out notes, but its size divides too.
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
@@ -205,6 +216,57 @@ pub fn repair(p: &mut Presentation) {
sane_numbers(p);
}
+/// Give a shape that repeats an id already used on its slide, layout or master (groups included)
+/// a fresh one, so edits, selection and the Selection Pane find one shape per id. The first keeps
+/// its id, and with it what animations and connectors point at. An exhausted id space leaves the
+/// rest as they are (#407).
+fn reid_duplicate_shapes(p: &mut Presentation) {
+ fn has_duplicates(shapes: &[deckcraft_model::Shape]) -> bool {
+ let mut seen = std::collections::HashSet::new();
+ let mut dup = false;
+ deckcraft_model::walk(shapes, &mut |sh, _| dup |= !seen.insert(sh.id));
+ dup
+ }
+ fn reid(shapes: &mut [deckcraft_model::Shape], seen: &mut std::collections::HashSet, next: &mut u32, depth: usize) {
+ if depth > 64 {
+ return;
+ }
+ for sh in shapes {
+ if !seen.insert(sh.id)
+ && let Some(id) = (*next).max(1).checked_add(1)
+ {
+ *next = id;
+ sh.id = deckcraft_model::ShapeId(id);
+ seen.insert(sh.id);
+ }
+ if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
+ reid(children, seen, next, depth + 1);
+ }
+ }
+ }
+ let mut next = p.next_id;
+ let mut fix = |shapes: &mut Vec| {
+ if has_duplicates(shapes) {
+ reid(shapes, &mut std::collections::HashSet::new(), &mut next, 0);
+ }
+ };
+ for s in &mut p.slides {
+ if has_duplicates(&s.shapes) {
+ fix(&mut Arc::make_mut(s).shapes);
+ }
+ }
+ for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
+ if has_duplicates(&m.shapes) || m.layouts.iter().any(|l| has_duplicates(&l.shapes)) {
+ let m = Arc::make_mut(m);
+ fix(&mut m.shapes);
+ for l in &mut m.layouts {
+ fix(&mut l.shapes);
+ }
+ }
+ }
+ p.next_id = next;
+}
+
/// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows.
pub const SLIDE_SIDE: std::ops::RangeInclusive = 72.0..=4032.0;
@@ -350,6 +412,66 @@ fn sane_xfrm(x: &mut deckcraft_model::Xfrm) {
fn sane_run(p: &mut deckcraft_model::text::RunProps) {
p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0));
+ for s in p.shadow.iter_mut().chain(p.inner_shadow.iter_mut()) {
+ sane_shadow(s);
+ }
+ if let Some(g) = p.glow.as_mut() {
+ g.radius = effect_size(g.radius, MAX_GLOW);
+ }
+ if let Some(r) = p.reflection.as_mut() {
+ sane_reflection(r);
+ }
+ p.soft_edge = p.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
+}
+
+/// The largest effect sizes, in points: what Format Shape and `shape.effects` allow. A file can
+/// say anything, and the renderer's memory grows with these (a glow of 10^18 pt asked for 34 GB).
+const MAX_GLOW: f64 = 150.0;
+const MAX_SOFT_EDGE: f64 = 100.0;
+const MAX_SHADOW: f64 = 200.0;
+const MAX_REFLECTION: f64 = 100.0;
+
+/// `v` within 0..=`max`; not a number is 0.
+fn effect_size(v: f64, max: f64) -> f64 {
+ if v.is_finite() { v.clamp(0.0, max) } else { 0.0 }
+}
+
+fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
+ s.blur = effect_size(s.blur, MAX_SHADOW);
+ s.dist = effect_size(s.dist, MAX_SHADOW);
+ s.dir = if s.dir.is_finite() { s.dir.rem_euclid(360.0) } else { 0.0 };
+ // Perspective scale (1 = 100%) and skew (degrees).
+ for v in [&mut s.sx, &mut s.sy] {
+ *v = if v.is_finite() { v.clamp(-10.0, 10.0) } else { 1.0 };
+ }
+ for v in [&mut s.kx, &mut s.ky] {
+ *v = if v.is_finite() { v.clamp(-89.0, 89.0) } else { 0.0 };
+ }
+}
+
+fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
+ r.blur = effect_size(r.blur, MAX_REFLECTION);
+ r.dist = effect_size(r.dist, MAX_REFLECTION);
+}
+
+/// Run properties a list style hands down to the text that inherits it.
+fn sane_list_style(ls: &mut deckcraft_model::text::ListStyle) {
+ for level in ls.levels.iter_mut().flatten() {
+ sane_run(&mut level.run);
+ }
+}
+
+fn sane_effects(e: &mut deckcraft_model::Effects) {
+ for s in e.outer_shadow.iter_mut().chain(e.inner_shadow.iter_mut()) {
+ sane_shadow(s);
+ }
+ if let Some(g) = e.glow.as_mut() {
+ g.radius = effect_size(g.radius, MAX_GLOW);
+ }
+ e.soft_edge = e.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
+ if let Some(r) = e.reflection.as_mut() {
+ sane_reflection(r);
+ }
}
fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
@@ -363,7 +485,11 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
if let Some(l) = sh.line.as_mut() {
l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0));
}
+ if let Some(e) = sh.effects.as_mut() {
+ sane_effects(e);
+ }
if let Some(t) = sh.text.as_mut() {
+ sane_list_style(&mut t.list_style);
for para in &mut t.paragraphs {
sane_run(&mut para.end_props);
for r in &mut para.runs {
@@ -381,6 +507,17 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
sane_xfrm(child);
sane_shapes(children, depth + 1);
}
+ deckcraft_model::ShapeKind::Table(t) => {
+ for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
+ sane_list_style(&mut cell.text.list_style);
+ }
+ for para in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).flat_map(|c| c.text.paragraphs.iter_mut()) {
+ sane_run(&mut para.end_props);
+ for r in &mut para.runs {
+ sane_run(&mut r.props);
+ }
+ }
+ }
_ => {}
}
}
@@ -395,12 +532,21 @@ pub fn sane_numbers(p: &mut Presentation) {
*slide = Arc::new(fixed);
}
}
+ // What text and shapes inherit: the deck's default text style, each master's title, body and
+ // other styles, and the theme's effect styles that shape styles point at.
+ sane_list_style(&mut p.default_text_style);
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
let mut fixed = (**m).clone();
sane_shapes(&mut fixed.shapes, 0);
for l in &mut fixed.layouts {
sane_shapes(&mut l.shapes, 0);
}
+ for ls in [&mut fixed.title_style, &mut fixed.body_style, &mut fixed.other_style] {
+ sane_list_style(ls);
+ }
+ for e in &mut fixed.theme.format.effects {
+ sane_effects(e);
+ }
if fixed != **m {
*m = Arc::new(fixed);
}
@@ -492,7 +638,8 @@ pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize {
pub fn slides_to_outline(p: &Presentation) -> String {
let mut out = String::new();
for s in &p.slides {
- out.push_str(&s.title());
+ // One line per title: a newline would start another slide on re-import.
+ out.push_str(&s.title().replace('\n', " "));
out.push('\n');
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
@@ -526,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();
@@ -832,6 +1027,18 @@ mod tests {
assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}");
}
+ #[test]
+ fn outline_export_keeps_multi_paragraph_title_on_one_line() {
+ let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
+ assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2);
+ let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder");
+ title.text = Some(TextBody::from_text("Line one\nLine two"));
+ let o = slides_to_outline(&p);
+ assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n");
+ let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
+ assert_eq!(outline_to_slides(&mut q, &o), 2);
+ }
+
#[test]
fn markdown_headings_and_list_items() {
assert_eq!(md_heading("# Intro"), Some("Intro"));
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