mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 21:16:57 -06:00
Export: animated GIF of the deck (Export… dialog, CLI, MCP) (#171)
There was no way to turn a presentation into an animated GIF. file.export now writes one: format
`gif` (or a .gif path), one frame per slide drawn by the slide renderer and shown for
secondsPerSlide, encoded with the `image` crate's GIF encoder (already a dependency, no new
crates) with a 256-colour palette per frame: exact when a slide has 256 colours or fewer,
NeuQuant otherwise. Slides are composited onto white.
Options: width (small 640, medium 960 (default), large 1280, extraLarge 1920, or 16-3840 px; the
height follows the slide's aspect ratio, no side over 3840), secondsPerSlide (0.1-600, default 5,
written in GIF hundredths), loop (forever by default, or once: no NETSCAPE2.0 block), slides (a
list of indices, in play order) and includeHidden (hidden slides are skipped by default).
Out-of-range or wrongly typed values, NaN and infinities are BadParams errors, never panics; at
most 500 frames and 200 million pixels in total, checked before anything is rendered.
- Engine: crates/engine/src/gif_export.rs (options, frame list, size, encoder);
cmd::file::gif_bytes, save_bytes("gif"), format_for_path(".gif").
- UI: the Export… dialog (Command palette; the app has no File menu yet) gets "Animated GIF (.gif)" with Size, Seconds per slide, From/To slide,
Loop forever and Include hidden slides; an invalid range or value keeps the dialog open with a
message. The web build downloads the bytes (image/gif).
- CLI: convert IN OUT.gif, run --export OUT.gif --export-params JSON; usage lists the format and
options.
- MCP: the export tool lists gif and width/secondsPerSlide/loop (slides and includeHidden now
apply to PDF and GIF).
- Docs: file-format parity GIF row, gaps (video export remains), parity checklist row M -> P
(P2 breadth 36% -> 37%, overall 79%), control protocol, MCP, README, ROADMAP progress log.
Transitions and build animations are not animated in the GIF (tracked with video export).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
be14248577
commit
c871c5f634
@@ -75,7 +75,7 @@ Today it can:
|
||||
- **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
|
||||
with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover.
|
||||
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.
|
||||
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export in the progress log, #171; P2 breadth 36% → 37%; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **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
|
||||
@@ -14,7 +14,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 37%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -139,6 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! ```text
|
||||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .pdf, .png (first slide), .gif (animated)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]
|
||||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||||
//! deckcraft-cli describe ID # one command
|
||||
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
|
||||
@@ -35,12 +35,17 @@ macro_rules! outln {
|
||||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||||
deckcraft-cli convert IN OUT
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]
|
||||
deckcraft-cli commands [FILTER]
|
||||
deckcraft-cli describe COMMAND
|
||||
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
|
||||
deckcraft-cli mcp [--connect PORT] [--sample]
|
||||
deckcraft-cli links | --version";
|
||||
deckcraft-cli links | --version
|
||||
|
||||
Export formats follow OUT's extension: .png .jpg .pdf .pptx .txt .deckcraft .gif (animated, one
|
||||
frame per slide). --export-params passes file.export options, e.g. for a GIF
|
||||
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}'
|
||||
(width: px 16-3840 or small|medium|large|extraLarge; see `deckcraft-cli describe file.export`).";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -168,6 +173,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let fmt = deckcraft_engine::cmd::file::format_for_path(out);
|
||||
let r = if matches!(fmt, "png" | "jpeg") {
|
||||
s.execute("file.export", &json!({"path": out, "slide": 0}))
|
||||
} else if fmt == "gif" {
|
||||
s.execute("file.export", &json!({"path": out}))
|
||||
} else {
|
||||
s.execute("file.saveAs", &json!({"path": out}))
|
||||
};
|
||||
@@ -182,6 +189,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut cmds = vec![];
|
||||
let mut save = None;
|
||||
let mut export = None;
|
||||
let mut export_params = json!({});
|
||||
let mut print = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
@@ -191,6 +199,13 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
let j = it.next().ok_or("--export-params needs a JSON object")?;
|
||||
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
|
||||
if !export_params.is_object() {
|
||||
return Err("--export-params must be a JSON object".into());
|
||||
}
|
||||
}
|
||||
"--print" => print = true,
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
}
|
||||
@@ -211,7 +226,10 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = export {
|
||||
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
export_params["path"] = json!(p);
|
||||
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
|
||||
} else if export_params != json!({}) {
|
||||
return Err("--export-params needs --export OUT".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -278,3 +296,42 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let stdin = std::io::stdin();
|
||||
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gif = dir.join("sample.gif").to_string_lossy().to_string();
|
||||
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
|
||||
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
|
||||
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
|
||||
let bytes = std::fs::read(&gif).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
|
||||
|
||||
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
|
||||
convert(&args(&[&deck, &gif2])).unwrap();
|
||||
let bytes = std::fs::read(&gif2).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
|
||||
|
||||
// Bad options are errors, not panics.
|
||||
let bad = dir.join("bad.gif").to_string_lossy().to_string();
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
|
||||
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("gif") => "image/gif",
|
||||
Some("txt") => "text/plain",
|
||||
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
Some("deckcraft") => "application/zip",
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf|gif, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?; gif: width?: px 16-3840 | small|medium|large|extraLarge, secondsPerSlide?: 0.1-600 (5), loop?: bool (true: forever), slides?: [index], includeHidden?} → {path, bytes} (gif: + frames, width, height, secondsPerSlide)", has_doc, export),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
|
||||
cmd!(
|
||||
@@ -104,12 +104,13 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"document": i}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`, `pdf`, `gif`).
|
||||
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
"pptx" | "potx" | "ppsx" => deckcraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -136,6 +137,11 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]).
|
||||
pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes)
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
let l = path.to_ascii_lowercase();
|
||||
if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") {
|
||||
@@ -146,6 +152,8 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"jpeg"
|
||||
} else if l.ends_with(".pdf") {
|
||||
"pdf"
|
||||
} else if l.ends_with(".gif") {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else {
|
||||
@@ -264,6 +272,18 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
"gif" => {
|
||||
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(p)?)?;
|
||||
write_file(&path, &out.bytes)?;
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"bytes": out.bytes.len(),
|
||||
"frames": out.frames,
|
||||
"width": out.width,
|
||||
"height": out.height,
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
other => {
|
||||
let bytes = save_bytes(&st.doc, other)?;
|
||||
write_file(&path, &bytes)?;
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Animated GIF of the deck (the Export… dialog; `file.export` with format `gif`).
|
||||
//!
|
||||
//! One frame per slide, drawn by the slide renderer and shown for `secondsPerSlide`. Every frame
|
||||
//! gets its own 256-colour palette (exact when the slide has 256 colours or fewer, NeuQuant
|
||||
//! otherwise), so a photo on one slide doesn't steal colours from the next. Transitions and
|
||||
//! build animations are not animated: a slide appears as it looks when its builds have finished.
|
||||
//!
|
||||
//! All parameters are hostile: sizes, delays and slide lists are checked against fixed bounds, and
|
||||
//! the total number of pixels encoded is capped, so a request can't exhaust memory or run for
|
||||
//! hours.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::bad;
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const CMD: &str = "file.export";
|
||||
|
||||
/// Size presets, by frame width in pixels (16:9 slides: 640×360, 960×540, 1280×720, 1920×1080).
|
||||
pub const SIZE_PRESETS: [(&str, u32); 4] = [("small", 640), ("medium", 960), ("large", 1280), ("extraLarge", 1920)];
|
||||
/// Frame width when none is given (the `medium` preset).
|
||||
pub const DEFAULT_WIDTH: u32 = 960;
|
||||
pub const MIN_WIDTH: u32 = 16;
|
||||
pub const MAX_WIDTH: u32 = 3840;
|
||||
/// Longest frame side: a very tall slide is scaled down to fit.
|
||||
pub const MAX_SIDE: u32 = 3840;
|
||||
pub const DEFAULT_SECONDS: f64 = 5.0;
|
||||
/// GIF delays are in hundredths of a second; viewers treat delays under 0.02 s as 0.1 s anyway.
|
||||
pub const MIN_SECONDS: f64 = 0.1;
|
||||
pub const MAX_SECONDS: f64 = 600.0;
|
||||
pub const MAX_FRAMES: usize = 500;
|
||||
/// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540).
|
||||
pub const MAX_TOTAL_PIXELS: u64 = 200_000_000;
|
||||
/// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance.
|
||||
const QUANTIZER_SPEED: i32 = 10;
|
||||
|
||||
/// What to export.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GifOptions {
|
||||
/// Frame width in pixels; the height follows the slide's aspect ratio.
|
||||
pub width: u32,
|
||||
pub seconds_per_slide: f64,
|
||||
/// Loop forever (`true`) or play once.
|
||||
pub loop_forever: bool,
|
||||
/// Slide indices in play order (default: all).
|
||||
pub slides: Option<Vec<usize>>,
|
||||
/// Hidden slides are left out unless this is set.
|
||||
pub include_hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for GifOptions {
|
||||
fn default() -> Self {
|
||||
GifOptions { width: DEFAULT_WIDTH, seconds_per_slide: DEFAULT_SECONDS, loop_forever: true, slides: None, include_hidden: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// An encoded GIF and what is in it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GifOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
pub frames: usize,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Per-frame delay in hundredths of a second, as written.
|
||||
pub delay_cs: u16,
|
||||
}
|
||||
|
||||
fn width_value(v: &Value) -> Result<u32> {
|
||||
let err =
|
||||
|| bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}, or one of small, medium, large, extraLarge"));
|
||||
match v {
|
||||
Value::String(s) => SIZE_PRESETS.iter().find(|(k, _)| k.eq_ignore_ascii_case(s.trim())).map(|(_, w)| *w).ok_or_else(err),
|
||||
Value::Number(n) => n.as_u64().and_then(|w| u32::try_from(w).ok()).filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w)).ok_or_else(err),
|
||||
_ => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a per-slide duration (NaN, infinities and out-of-range values are errors).
|
||||
pub fn check_seconds(s: f64) -> Result<f64> {
|
||||
if (MIN_SECONDS..=MAX_SECONDS).contains(&s) {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(bad(CMD, format!("`secondsPerSlide` must be from {MIN_SECONDS} to {MAX_SECONDS}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Present and not `null`.
|
||||
fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
p.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
impl GifOptions {
|
||||
/// Read `{width?: px | small|medium|large|extraLarge, secondsPerSlide?, loop?: bool |
|
||||
/// forever|once, slides?: [index], includeHidden?}`. Absent or `null` values take their
|
||||
/// defaults; anything else that is out of range or of the wrong type is an error.
|
||||
pub fn from_params(p: &Value) -> Result<Self> {
|
||||
let mut o = GifOptions::default();
|
||||
if let Some(v) = given(p, "width") {
|
||||
o.width = width_value(v)?;
|
||||
}
|
||||
if let Some(v) = given(p, "secondsPerSlide") {
|
||||
let s = v.as_f64().ok_or_else(|| bad(CMD, "`secondsPerSlide` must be a number"))?;
|
||||
o.seconds_per_slide = check_seconds(s)?;
|
||||
}
|
||||
if let Some(v) = given(p, "loop") {
|
||||
o.loop_forever = match v {
|
||||
Value::Bool(b) => *b,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("forever") => true,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("once") => false,
|
||||
_ => return Err(bad(CMD, "`loop` must be true (forever), false (once), \"forever\" or \"once\"")),
|
||||
};
|
||||
}
|
||||
if let Some(v) = given(p, "slides") {
|
||||
let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?;
|
||||
if a.is_empty() {
|
||||
return Err(bad(CMD, "`slides` is empty"));
|
||||
}
|
||||
if a.len() > MAX_FRAMES {
|
||||
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides")));
|
||||
}
|
||||
let list = a
|
||||
.iter()
|
||||
.map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok()))
|
||||
.collect::<Option<Vec<usize>>>()
|
||||
.ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices (0, 1, 2…)"))?;
|
||||
o.slides = Some(list);
|
||||
}
|
||||
if let Some(v) = given(p, "includeHidden") {
|
||||
o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// The slides that become frames, in order: the chosen ones (or all), without hidden slides
|
||||
/// unless `include_hidden`.
|
||||
pub fn frames(&self, doc: &Presentation) -> Result<Vec<usize>> {
|
||||
let n = doc.slides.len();
|
||||
let chosen: Vec<usize> = match &self.slides {
|
||||
Some(list) => {
|
||||
if let Some(i) = list.iter().find(|i| **i >= n) {
|
||||
return Err(bad(CMD, format!("no slide {i} (the presentation has {n})")));
|
||||
}
|
||||
list.clone()
|
||||
}
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let frames: Vec<usize> = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect();
|
||||
if frames.is_empty() {
|
||||
return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)"));
|
||||
}
|
||||
if frames.len() > MAX_FRAMES {
|
||||
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides; choose a range")));
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Delay per frame in hundredths of a second.
|
||||
pub fn delay_cs(&self) -> Result<u16> {
|
||||
let s = check_seconds(self.seconds_per_slide)?;
|
||||
// In range by `check_seconds`: 10..=60_000.
|
||||
Ok((s * 100.0).round().clamp(1.0, f64::from(u16::MAX)) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame size for a slide of `doc`'s size at `width` px: the height follows the aspect ratio and
|
||||
/// no side exceeds [`MAX_SIDE`].
|
||||
pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) {
|
||||
let (sw, sh) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let aspect = if sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0 { sh / sw } else { 9.0 / 16.0 };
|
||||
let w = f64::from(width.clamp(MIN_WIDTH, MAX_WIDTH));
|
||||
let mut h = w * aspect;
|
||||
let mut w2 = w;
|
||||
if h > f64::from(MAX_SIDE) {
|
||||
w2 = w * f64::from(MAX_SIDE) / h;
|
||||
h = f64::from(MAX_SIDE);
|
||||
}
|
||||
let side = |v: f64| if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIDE)) as u32 } else { 1 };
|
||||
(side(w2), side(h))
|
||||
}
|
||||
|
||||
/// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and
|
||||
/// fully transparent pixels would otherwise become see-through).
|
||||
fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec<u8> {
|
||||
let len = (w as usize).saturating_mul(h as usize).saturating_mul(4);
|
||||
if img.width != w || img.height != h || img.pixels.len() != len {
|
||||
log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height);
|
||||
return vec![255; len];
|
||||
}
|
||||
let mut px = img.pixels.clone();
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let back = 255 - p[3];
|
||||
p[0] = p[0].saturating_add(back);
|
||||
p[1] = p[1].saturating_add(back);
|
||||
p[2] = p[2].saturating_add(back);
|
||||
p[3] = 255;
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
/// Render and encode the GIF.
|
||||
pub fn encode(doc: &Presentation, opts: &GifOptions) -> Result<GifOutput> {
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
|
||||
let frames = opts.frames(doc)?;
|
||||
let delay_cs = opts.delay_cs()?;
|
||||
let (w, h) = frame_size(doc, opts.width);
|
||||
let total = (frames.len() as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h));
|
||||
if total > MAX_TOTAL_PIXELS {
|
||||
return Err(bad(CMD, format!("{} slides at {w}×{h} is too large for a GIF; choose a smaller size or fewer slides", frames.len())));
|
||||
}
|
||||
let scale = f64::from(w) / doc.slide_size.width;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let enc_err = |e: image::ImageError| EngineError::Other(format!("GIF encoding failed: {e}"));
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut enc = GifEncoder::new_with_speed(&mut bytes, QUANTIZER_SPEED);
|
||||
// No repeat block at all means "play once"; a repeat count of 0 means "forever".
|
||||
if opts.loop_forever {
|
||||
enc.set_repeat(Repeat::Infinite).map_err(enc_err)?;
|
||||
}
|
||||
for i in &frames {
|
||||
let img = deckcraft_render::render_slide(doc, *i, &deckcraft_render::RenderOpts { scale, size: Some((w, h)), ..Default::default() });
|
||||
let buffer = image::RgbaImage::from_raw(w, h, on_white(&img, w, h))
|
||||
.ok_or_else(|| EngineError::Other(format!("GIF export: slide {i} has no pixels")))?;
|
||||
let delay = image::Delay::from_numer_denom_ms(u32::from(delay_cs) * 10, 1);
|
||||
enc.encode_frame(image::Frame::from_parts(buffer, 0, 0, delay)).map_err(enc_err)?;
|
||||
}
|
||||
}
|
||||
Ok(GifOutput { bytes, frames: frames.len(), width: w, height: h, delay_cs })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use image::AnimationDecoder;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
const COLOURS: [(&str, [u8; 3]); 4] =
|
||||
[("#E02020", [0xE0, 0x20, 0x20]), ("#20A040", [0x20, 0xA0, 0x40]), ("#2040E0", [0x20, 0x40, 0xE0]), ("#F0C000", [0xF0, 0xC0, 0x00])];
|
||||
|
||||
/// Four blank slides with solid backgrounds (red, green, blue, yellow); slide 2 (blue) hidden.
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"blank": true})).unwrap();
|
||||
while s.doc().unwrap().doc.slides.len() < COLOURS.len() {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
for (i, (hex, _)) in COLOURS.iter().enumerate() {
|
||||
s.execute("design.background", &json!({"color": hex, "index": i})).unwrap();
|
||||
}
|
||||
s.execute("slide.hide", &json!({"index": 2, "hidden": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> (image::metadata::LoopCount, Vec<image::Frame>) {
|
||||
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let count = dec.loop_count();
|
||||
(count, dec.into_frames().collect_frames().unwrap())
|
||||
}
|
||||
|
||||
/// The NETSCAPE2.0 application block's loop count, if the GIF has one.
|
||||
fn netscape_loops(bytes: &[u8]) -> Option<u16> {
|
||||
let tag = b"NETSCAPE2.0";
|
||||
let at = bytes.windows(tag.len()).position(|w| w == tag)?;
|
||||
// NETSCAPE2.0, sub-block size 3, id 1, loop count (little endian).
|
||||
let b = bytes.get(at + tag.len()..at + tag.len() + 4)?;
|
||||
Some(u16::from_le_bytes([b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn close(a: [u8; 4], b: [u8; 3]) -> bool {
|
||||
(0..3).all(|k| (i32::from(a[k]) - i32::from(b[k])).abs() <= 12) && a[3] == 255
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_one_frame_per_visible_slide() {
|
||||
let mut s = deck();
|
||||
let path = temp("deck.gif");
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "width": 320, "secondsPerSlide": 1.5})).unwrap();
|
||||
assert_eq!(r["frames"], 3, "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(netscape_loops(&bytes), Some(0), "loops forever by default");
|
||||
let (count, frames) = decode(&bytes);
|
||||
assert!(matches!(count, image::metadata::LoopCount::Infinite));
|
||||
assert_eq!(frames.len(), 3, "the hidden slide is skipped");
|
||||
// Slides are 16:9: 320×180.
|
||||
let expected = [COLOURS[0].1, COLOURS[1].1, COLOURS[3].1];
|
||||
for (f, want) in frames.iter().zip(expected) {
|
||||
assert_eq!((f.buffer().width(), f.buffer().height()), (320, 180));
|
||||
assert_eq!(f.delay().numer_denom_ms(), (1500, 1));
|
||||
let px = f.buffer().get_pixel(160, 90).0;
|
||||
assert!(close(px, want), "frame colour {px:?}, expected {want:?}");
|
||||
}
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180)));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_ranges_hidden_slides_and_play_once() {
|
||||
let s = deck();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let o = GifOptions::from_params(&json!({"width": "small", "loop": false, "slides": [3, 2, 1], "includeHidden": true})).unwrap();
|
||||
let out = encode(doc, &o).unwrap();
|
||||
assert_eq!((out.frames, out.width, out.height, out.delay_cs), (3, 640, 360, 500));
|
||||
assert_eq!(netscape_loops(&out.bytes), None, "play once: no repeat block");
|
||||
let (_, frames) = decode(&out.bytes);
|
||||
let want = [COLOURS[3].1, COLOURS[2].1, COLOURS[1].1];
|
||||
for (f, w) in frames.iter().zip(want) {
|
||||
assert!(close(f.buffer().get_pixel(320, 180).0, w));
|
||||
assert_eq!(f.delay().numer_denom_ms(), (5000, 1), "5 seconds by default");
|
||||
}
|
||||
// A range that holds only the hidden slide has nothing to export.
|
||||
let o = GifOptions::from_params(&json!({"slides": [2]})).unwrap();
|
||||
assert!(encode(doc, &o).is_err());
|
||||
for (preset, w) in SIZE_PRESETS {
|
||||
assert_eq!(GifOptions::from_params(&json!({"width": preset})).unwrap().width, w);
|
||||
}
|
||||
assert_eq!(
|
||||
GifOptions::from_params(&json!({"width": "Large", "loop": "once"})).unwrap(),
|
||||
GifOptions { width: 1280, loop_forever: false, ..Default::default() }
|
||||
);
|
||||
assert_eq!(GifOptions::from_params(&json!({"width": null, "secondsPerSlide": null})).unwrap(), GifOptions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors_not_panics() {
|
||||
for p in [
|
||||
json!({"width": 0}),
|
||||
json!({"width": 15}),
|
||||
json!({"width": 3841}),
|
||||
json!({"width": -5}),
|
||||
json!({"width": 1e308}),
|
||||
json!({"width": 640.5}),
|
||||
json!({"width": "huge"}),
|
||||
json!({"width": [640]}),
|
||||
json!({"secondsPerSlide": 0}),
|
||||
json!({"secondsPerSlide": 0.05}),
|
||||
json!({"secondsPerSlide": -1}),
|
||||
json!({"secondsPerSlide": 601}),
|
||||
json!({"secondsPerSlide": 1e308}),
|
||||
json!({"secondsPerSlide": "5"}),
|
||||
json!({"loop": 1}),
|
||||
json!({"loop": "twice"}),
|
||||
json!({"slides": []}),
|
||||
json!({"slides": [-1]}),
|
||||
json!({"slides": [0.5]}),
|
||||
json!({"slides": "1-3"}),
|
||||
json!({"slides": vec![0; MAX_FRAMES + 1]}),
|
||||
json!({"includeHidden": "yes"}),
|
||||
] {
|
||||
assert!(GifOptions::from_params(&p).is_err(), "{p}");
|
||||
}
|
||||
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MIN_POSITIVE, 1e-300] {
|
||||
assert!(check_seconds(v).is_err(), "{v}");
|
||||
let o = GifOptions { seconds_per_slide: v, ..Default::default() };
|
||||
assert!(o.delay_cs().is_err(), "{v}");
|
||||
}
|
||||
assert_eq!(GifOptions { seconds_per_slide: 600.0, ..Default::default() }.delay_cs().unwrap(), 60_000);
|
||||
assert_eq!(GifOptions { seconds_per_slide: 0.1, ..Default::default() }.delay_cs().unwrap(), 10);
|
||||
|
||||
// Through the command too: errors, the presentation untouched, no file written.
|
||||
let mut s = deck();
|
||||
let path = temp("hostile.gif");
|
||||
for p in [
|
||||
json!({"width": 99999}),
|
||||
json!({"secondsPerSlide": -3}),
|
||||
json!({"slides": [0, 99]}),
|
||||
json!({"slides": [18446744073709551615u64]}),
|
||||
json!({"slides": [2]}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["path"] = json!(path.to_string_lossy());
|
||||
assert!(s.execute("file.export", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(!path.exists());
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_are_bounded() {
|
||||
let mut s = deck();
|
||||
// A very tall slide is scaled down so no side exceeds MAX_SIDE.
|
||||
s.execute("design.slideSize", &json!({"w": 100.0, "h": 4000.0, "scale": "none"})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (w, h) = frame_size(doc, MAX_WIDTH);
|
||||
assert!(w >= 1 && h <= MAX_SIDE && w <= MAX_WIDTH, "{w}×{h}");
|
||||
// The total pixel budget stops huge requests before anything is rendered.
|
||||
let mut big = Presentation::clone(doc);
|
||||
big.slide_size = deckcraft_geom::Size::new(960.0, 540.0);
|
||||
let slide = big.slides[0].clone();
|
||||
big.slides = vec![slide; 200];
|
||||
let o = GifOptions { width: MAX_WIDTH, ..Default::default() };
|
||||
let e = encode(&big, &o).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
// A degenerate slide size falls back to 16:9 rather than dividing by zero.
|
||||
let mut odd = Presentation::clone(doc);
|
||||
odd.slide_size = deckcraft_geom::Size::new(0.0, f64::NAN);
|
||||
assert_eq!(frame_size(&odd, 640), (640, 360));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_is_also_a_save_format_and_inferred_from_the_path() {
|
||||
let s = deck();
|
||||
let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "gif").unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(crate::cmd::file::format_for_path("Talk.GIF"), "gif");
|
||||
let bytes = crate::cmd::file::gif_bytes(&s.doc().unwrap().doc, &json!({"width": 64, "slides": [0]})).unwrap();
|
||||
assert_eq!(decode(&bytes).1.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod gif_export;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
|
||||
@@ -117,3 +117,29 @@ fn export_tool_lists_pdf_and_its_options() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_gif_and_its_options() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let tools = call(&mut s, 1, "tools/list", json!({}));
|
||||
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
|
||||
assert!(export["description"].as_str().unwrap().contains("gif"), "{export}");
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert!(props["format"]["enum"].as_array().unwrap().iter().any(|x| x == "gif"), "{export}");
|
||||
assert_eq!(props["width"]["oneOf"][1]["enum"], json!(["small", "medium", "large", "extraLarge"]));
|
||||
for p in ["secondsPerSlide", "loop", "slides", "includeHidden"] {
|
||||
assert!(props.get(p).is_some(), "{p}: {export}");
|
||||
}
|
||||
// What the schema describes is accepted, and bad values are tool errors, not crashes.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
tool(&mut s, "new_presentation", json!({}));
|
||||
for width in [json!("small"), json!(200)] {
|
||||
let path = dir.join("deck.gif");
|
||||
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "width": width, "secondsPerSlide": 2, "loop": false, "slides": [0]}));
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a"), "{width}");
|
||||
}
|
||||
let r = call(&mut s, 9, "tools/call", json!({"name": "export", "arguments": {"path": dir.join("bad.gif").to_string_lossy(), "width": 0}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+14
-5
@@ -115,21 +115,30 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), or an animated gif of the deck (one frame per slide).",
|
||||
obj(
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
"layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
|
||||
"perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
|
||||
"dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"},
|
||||
"includeHidden": boolean("PDF: include hidden slides (default false)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"},
|
||||
"includeHidden": boolean("PDF and GIF: include hidden slides (default false)"),
|
||||
"textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
|
||||
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)")
|
||||
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"),
|
||||
"width": {
|
||||
"oneOf": [
|
||||
{"type": "integer", "minimum": 16, "maximum": 3840},
|
||||
{"type": "string", "enum": ["small", "medium", "large", "extraLarge"]}
|
||||
],
|
||||
"description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; the height follows the slide's shape"
|
||||
},
|
||||
"secondsPerSlide": {"type": "number", "minimum": 0.1, "maximum": 600, "description": "GIF: seconds each slide shows (default 5)"},
|
||||
"loop": boolean("GIF: loop forever (true, default) or play once (false)")
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
|
||||
@@ -377,6 +377,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("JPEG image (current slide)", "jpeg"),
|
||||
("PowerPoint Presentation (.pptx)", "pptx"),
|
||||
("Outline (.txt)", "outline"),
|
||||
("Animated GIF (.gif)", "gif"),
|
||||
] {
|
||||
if ui.radio(fmt == f, l).clicked() {
|
||||
d.fields.insert("fmt".into(), f.into());
|
||||
@@ -420,6 +421,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("pdfHidden".into(), hidden.to_string());
|
||||
}
|
||||
}
|
||||
let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) };
|
||||
if let Err(e) = &gif_params {
|
||||
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
let pdf_params = {
|
||||
let (layout, per) = match pdf_layout.as_str() {
|
||||
"notes" => ("notes", 0),
|
||||
@@ -437,15 +442,27 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
"jpeg" => ("jpg", false),
|
||||
"pptx" => ("pptx", false),
|
||||
"outline" => ("txt", false),
|
||||
"gif" => ("gif", false),
|
||||
_ => ("png", false),
|
||||
};
|
||||
let suggested = format!("{name}.{ext}");
|
||||
let gif_params = match gif_params {
|
||||
Ok(v) => v,
|
||||
// Keep the dialog open so the range or width can be corrected.
|
||||
Err(e) => {
|
||||
app.set_status(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let extra = match ext {
|
||||
"pdf" => pdf_params.as_object(),
|
||||
"gif" => gif_params.as_object(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(pick) = app.services.pick_save.as_mut() {
|
||||
if let Some(path) = pick(&suggested) {
|
||||
let mut params = json!({"path": path, "all": all});
|
||||
if ext == "pdf"
|
||||
&& let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object())
|
||||
{
|
||||
if let (Some(o), Some(extra)) = (params.as_object_mut(), extra) {
|
||||
o.extend(extra.clone());
|
||||
}
|
||||
let r = app.session.execute("file.export", ¶ms);
|
||||
@@ -465,6 +482,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
|
||||
}
|
||||
"pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params),
|
||||
"gif" => match deckcraft_engine::cmd::file::gif_bytes(&s.doc, &gif_params) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
app.set_status(e.to_string());
|
||||
return true;
|
||||
}
|
||||
},
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
|
||||
};
|
||||
dl(&suggested, &bytes);
|
||||
@@ -923,6 +947,64 @@ fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden
|
||||
/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message.
|
||||
fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result<Value, String> {
|
||||
use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS};
|
||||
let n = app.session.active().map_or(0, |s| s.doc.slides.len());
|
||||
let size = d.get("gifSize", "medium");
|
||||
let size_label = |k: &str| match k {
|
||||
"small" => "Small (640 px wide)",
|
||||
"large" => "Large (1280 px wide)",
|
||||
"extraLarge" => "Extra Large (1920 px wide)",
|
||||
"custom" => "Custom width",
|
||||
_ => "Medium (960 px wide)",
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:"));
|
||||
egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| {
|
||||
for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) {
|
||||
if ui.selectable_label(size == k, size_label(k)).clicked() {
|
||||
d.fields.insert("gifSize".into(), k.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if size == "custom" {
|
||||
field(ui, d, "Width (pixels):", "gifWidth", "960");
|
||||
}
|
||||
field(ui, d, "Seconds per slide:", "gifSeconds", "5");
|
||||
let n_text = n.max(1).to_string();
|
||||
field(ui, d, "From slide:", "gifFrom", "1");
|
||||
field(ui, d, "To slide:", "gifTo", &n_text);
|
||||
let forever = check(ui, d, "Loop forever", "gifLoop", true);
|
||||
let hidden = check(ui, d, "Include hidden slides", "gifHidden", false);
|
||||
|
||||
let width = if size == "custom" {
|
||||
let w = num(d, "gifWidth", 960.0).round();
|
||||
if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) {
|
||||
return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels"));
|
||||
}
|
||||
json!(w as u32)
|
||||
} else {
|
||||
json!(size)
|
||||
};
|
||||
let seconds = num(d, "gifSeconds", f64::NAN);
|
||||
if deckcraft_engine::gif_export::check_seconds(seconds).is_err() {
|
||||
return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}"));
|
||||
}
|
||||
let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden});
|
||||
let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round());
|
||||
if !(from >= 1.0 && from <= to && to <= n as f64) {
|
||||
return Err(format!("Slides: choose a range from 1 to {n}"));
|
||||
}
|
||||
if from > 1.0 || to < n as f64 {
|
||||
p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
|
||||
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
|
||||
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
|
||||
@@ -933,6 +1015,62 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
|
||||
#[test]
|
||||
fn export_dialog_writes_an_animated_gif() {
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.gif");
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let suggested = std::rc::Rc::new(RefCell::new(String::new()));
|
||||
let seen = suggested.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(move |s: &str| {
|
||||
*seen.borrow_mut() = s.to_string();
|
||||
Some(picked.clone())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, services),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.exportDialog", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Animated GIF (.gif)").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif"));
|
||||
harness.get_by_label("Seconds per slide:");
|
||||
harness.get_by_label("Loop forever").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
// A range outside the deck keeps the dialog open with a message, and nothing is written.
|
||||
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into());
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status);
|
||||
assert!(!path.exists());
|
||||
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into());
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "the dialog closes after exporting");
|
||||
assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
|
||||
#[test]
|
||||
fn inset_wrapped_indents_every_row() {
|
||||
|
||||
@@ -26,7 +26,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly |
|
||||
| `ui.resize` / `ui.focus` | | Window control |
|
||||
| `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) |
|
||||
|
||||
The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude
|
||||
and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171; previous: major, created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -28,7 +28,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer | render tests |
|
||||
| TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none |
|
||||
| Animated GIF of the deck | export | n/a | no | | none |
|
||||
| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests |
|
||||
| MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none |
|
||||
| Print | yes | n/a | no | PR #66 (browser) open | none |
|
||||
|
||||
@@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF of the deck: export yes (#171) |
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export landed, #171; the video gap stays; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -115,11 +115,13 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Impact:** handouts and speaker notes are printed in many workplaces.
|
||||
- **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.**
|
||||
|
||||
### No video or animated GIF export
|
||||
- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF.
|
||||
### No video export; the animated GIF has no transitions or builds
|
||||
- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes
|
||||
an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF
|
||||
doesn't animate transitions or build animations.
|
||||
- **Impact:** sharing a talk as a video is a common request.
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part,
|
||||
FilmCraft has one to copy).
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for
|
||||
transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy).
|
||||
|
||||
### Embedded fonts aren't read or written
|
||||
- **Evidence:** no `p:embeddedFontLst` handling in `crates/pptx`.
|
||||
@@ -247,5 +249,6 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ deck.
|
||||
| `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered |
|
||||
| `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command |
|
||||
| `batch` | Run several commands in order and stop at the first error |
|
||||
| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline |
|
||||
| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline, animated GIF (size, seconds per slide, loop, slides) |
|
||||
| `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) |
|
||||
| `render_slide` | A slide as an image the model can look at |
|
||||
| `add_slide`, `go_to_slide` | Slides by layout |
|
||||
@@ -33,5 +33,5 @@ deck.
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
`shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`,
|
||||
`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any
|
||||
`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` (GIF). `deckcraft-cli describe ID` prints any
|
||||
command's parameters.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PowerPoint feature checklist
|
||||
|
||||
> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Generated:** 2026-10-11 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 79%** over 191 features.
|
||||
@@ -9,12 +9,12 @@ The summary between `<!-- SUMMARY -->
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 60 | 12 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 13 | 28 | 36% |
|
||||
| P2 | 54 | 13 | 14 | 27 | 37% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 71% |
|
||||
| Files | 71% |
|
||||
| Files | 72% |
|
||||
| Slides | 90% |
|
||||
| Views | 90% |
|
||||
| Masters & themes | 85% |
|
||||
@@ -65,7 +65,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
||||
| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P |
|
||||
| Files | Export slides as PNG/JPEG | P0 | D |
|
||||
| Files | Export outline / RTF | P2 | P |
|
||||
| Files | Export video (MP4) / animated GIF | P2 | M |
|
||||
| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P |
|
||||
| Files | Print (slides, handouts, notes, outline) | P1 | M |
|
||||
| Files | Password protection | P2 | M |
|
||||
| Files | Reuse slides from another file | P1 | M |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DeckCraft vs Microsoft PowerPoint: parity assessment
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171: P2 breadth 36% → 37%, overall unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
|
||||
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
|
||||
@@ -11,7 +11,7 @@ number.
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 |
|
||||
| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 37%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 |
|
||||
| Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists |
|
||||
| **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) |
|
||||
| **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
@@ -276,6 +276,7 @@ scored done (16 open issues).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF export (#171) moves the checklist's video/GIF row from missing to partial: P2 breadth 36% → 37%; overall breadth (79%), dimension scores and stage unchanged |
|
||||
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
|
||||
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
|
||||
|
||||
Reference in New Issue
Block a user