mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 12:36:56 -06:00
# Conflicts: # apps/deckcraft-cli/src/main.rs # crates/engine/src/cmd/file.rs # crates/engine/src/lib.rs # crates/mcp/src/tests.rs # crates/mcp/src/tools.rs # crates/ui-egui/src/dialogs.rs
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: 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-12 · **Change:** minor (progress log for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: 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-12 · **Change:** minor (progress log: table cell editing and border resizing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-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
|
||||
@@ -48,6 +49,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 39%) | 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) |
|
||||
@@ -232,6 +234,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 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 |
|
||||
@@ -287,6 +290,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 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 |
|
||||
|
||||
@@ -398,6 +398,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
open(&mut s, Some(input), false)?;
|
||||
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}))
|
||||
};
|
||||
@@ -422,6 +424,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() {
|
||||
@@ -431,6 +434,13 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--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,
|
||||
"--help" | "-h" => {
|
||||
outln!("{}", help_page("run"));
|
||||
@@ -458,7 +468,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(())
|
||||
}
|
||||
@@ -591,6 +604,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
@@ -631,6 +646,34 @@ mod tests {
|
||||
assert!(!out.exists(), "{flag}: rendered anyway");
|
||||
}
|
||||
}
|
||||
|
||||
/// `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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,7 +24,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 (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?}", 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?, 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",
|
||||
@@ -208,12 +208,13 @@ fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`).
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`).
|
||||
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).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())),
|
||||
}
|
||||
}
|
||||
@@ -287,16 +288,21 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "txt", "md"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.gif`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// those as native `.deckcraft` bytes under the wrong name.
|
||||
pub fn writable_format_for_path(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path))
|
||||
}
|
||||
|
||||
/// 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") {
|
||||
@@ -311,6 +317,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 {
|
||||
@@ -470,6 +478,23 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
"gif" => {
|
||||
// `slides` may be a range, as for PDF ("1-3,5").
|
||||
let mut gp = p.clone();
|
||||
if let Some(text) = p.get("slides").and_then(Value::as_str) {
|
||||
gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
|
||||
}
|
||||
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?;
|
||||
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,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod gif_export;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
pub mod keys;
|
||||
|
||||
@@ -216,3 +216,29 @@ fn export_rejects_unknown_format() {
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("docx"), "{r}");
|
||||
assert!(!path.exists(), "nothing may be written for an unknown format");
|
||||
}
|
||||
|
||||
#[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,23 +115,32 @@ 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)"),
|
||||
"paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"},
|
||||
"orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"}
|
||||
"orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"},
|
||||
"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"],
|
||||
),
|
||||
|
||||
@@ -1520,6 +1520,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());
|
||||
@@ -1563,6 +1564,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),
|
||||
@@ -1573,7 +1578,19 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
};
|
||||
let (ok, cancel) = buttons(ui, "Export");
|
||||
if ok {
|
||||
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
|
||||
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 fmt.as_str() {
|
||||
"pdf" => pdf_params,
|
||||
"gif" => gif_params,
|
||||
_ => json!({}),
|
||||
};
|
||||
app.export_as(&fmt, extra);
|
||||
}
|
||||
ok || cancel
|
||||
@@ -2377,6 +2394,64 @@ mod size_position_tests;
|
||||
#[path = "photo_album_tests.rs"]
|
||||
mod photo_album_tests;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "theme_colors_tests.rs"]
|
||||
mod theme_colors_tests;
|
||||
@@ -2419,6 +2494,68 @@ mod tests {
|
||||
h
|
||||
}
|
||||
|
||||
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
|
||||
#[test]
|
||||
fn export_dialog_writes_an_animated_gif() {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_link_opens_as_a_new_web_address() {
|
||||
let seed = seed_from(CurrentLink::None, &[]);
|
||||
|
||||
@@ -28,6 +28,8 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.resize` / `ui.focus` | | Window control |
|
||||
| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order |
|
||||
| `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}`) |
|
||||
| `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}` 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 METHOD [PARAMS]` sends one request from a shell.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **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**
|
||||
@@ -46,7 +47,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. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine 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 |
|
||||
|
||||
@@ -152,5 +153,6 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited |
|
||||
| 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 |
|
||||
| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) |
|
||||
|
||||
+8
-4
@@ -25,6 +25,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **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
|
||||
@@ -190,11 +191,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 MicroType Express font subsets aren't drawn
|
||||
- **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in.
|
||||
@@ -452,5 +455,6 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||
| 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
-1
@@ -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 |
|
||||
@@ -36,4 +36,5 @@ Useful commands for building decks: `slide.new {layout, title, body}`, `shape.in
|
||||
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 COMMAND` 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: 80%** over 191 features.
|
||||
@@ -16,11 +16,14 @@ The summary between `<!-- SUMMARY -->
|
||||
| P2 | 54 | 14 | 15 | 25 | 40% |
|
||||
| P1 | 65 | 39 | 21 | 5 | 76% |
|
||||
| P2 | 54 | 14 | 17 | 23 | 42% |
|
||||
| P2 | 54 | 13 | 14 | 27 | 37% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 81% |
|
||||
| Files | 71% |
|
||||
| Application shell | 71% |
|
||||
| Files | 72% |
|
||||
| Slides | 90% |
|
||||
| Views | 93% |
|
||||
| Masters & themes | 85% |
|
||||
@@ -76,7 +79,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| 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 |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **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;
|
||||
@@ -28,6 +29,7 @@ number.
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 39%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md) |
|
||||
| **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) |
|
||||
@@ -340,6 +342,7 @@ scored done (16 open issues).
|
||||
| 2026-10-13 | trivial | Essentials discoverability note: the command palette's Export failure (#79) is fixed; the ×0.95 discount stays for the menu bar and start screen |
|
||||
| 2026-10-14 | minor | Design ▸ Effects noted under what works today. Percentages stay 79% / ~60% / ~55%. The d556ff9 snapshot and the menu measurement stay at 226 commands; the live registry is 227 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages unchanged |
|
||||
| 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