Merge PR #565: Files: save and open outlines as RTF

Conflicts:
crates/engine/src/cmd/file.rs: export spec keeps main's transparentBackground and adds rtf; probe_clips (main) and outline_text (PR) both kept
docs/file-format-parity.md: PR's outline row (RTF now read) with main's .md wording, main's PNG row
This commit is contained in:
aiongg
2026-10-24 06:18:03 +00:00
10 changed files with 367 additions and 13 deletions
+15 -4
View File
@@ -37,7 +37,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|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(
query "file.info",
"Info",
@@ -167,8 +167,10 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".txt") || lower.ends_with(".md") {
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
let text = String::from_utf8_lossy(bytes);
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
if lower.ends_with(".md") {
deckcraft_format::markdown_to_slides(&mut p, &text);
@@ -219,6 +221,12 @@ fn probe_clips(p: &mut Presentation) {
}
}
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
pub fn outline_text(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
}
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
crate::fonts::install_embedded_fonts(&p);
@@ -334,6 +342,7 @@ 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()),
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(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())),
@@ -409,7 +418,7 @@ 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", "gif", "txt", "md"];
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
/// The format a file named `path` is written in, or `None` when its extension names no format
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
@@ -442,6 +451,8 @@ pub fn format_for_path(path: &str) -> &'static str {
"gif"
} else if l.ends_with(".txt") || l.ends_with(".md") {
"outline"
} else if l.ends_with(".rtf") {
"rtf"
} else {
"deckcraft"
}
@@ -622,7 +633,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
}))
}
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
let bytes = save_bytes(&st.doc, &format)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
+18
View File
@@ -1580,6 +1580,24 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
#[test]
fn outline_rtf_exports_and_opens_back() {
let mut s = session();
s.execute("slide.fromOutline", &json!({"text": "Roadmap\n\tShip 1.0\n\t\tLinux first\n"})).unwrap();
let dir = std::env::temp_dir().join(format!("deckcraft-rtf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("deck.rtf");
s.execute("file.export", &json!({"path": path.to_string_lossy()})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"{\\rtf1"));
let text = crate::cmd::file::outline_text(&bytes);
assert!(text.contains("Roadmap\n\tShip 1.0\n\t\tLinux first\n"), "{text}");
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
let d = s.doc().unwrap();
assert!(d.doc.slides.iter().any(|sl| sl.title() == "Roadmap"));
let _ = std::fs::remove_dir_all(&dir);
}
/// #725: a layout with a small body placeholder ahead of its content placeholder (as Keynote
/// exports "Title & Bullets") still gets the body in the content placeholder (`idx="1"`).
#[test]
+1
View File
@@ -6,6 +6,7 @@
mod chart_parts;
mod finite;
pub mod rtf;
#[cfg(test)]
mod chart_parts_tests;
+321
View File
@@ -0,0 +1,321 @@
//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides.
//!
//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per
//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline.
//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into
//! the tab-indented outline [`crate::outline_to_slides`] understands.
use deckcraft_model::Presentation;
use deckcraft_model::text::{Paragraph, RunKind};
/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants).
fn escape(out: &mut String, s: &str) {
for c in s.chars() {
match c {
'\\' | '{' | '}' => {
out.push('\\');
out.push(c);
}
'\t' => out.push_str("\\tab "),
'\u{b}' | '\n' => out.push_str("\\line "),
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
c if c.is_ascii() => {}
c => {
let mut buf = [0u16; 2];
for u in c.encode_utf16(&mut buf) {
out.push_str(&format!("\\u{}?", *u as i16));
}
}
}
}
}
fn runs(out: &mut String, p: &Paragraph) {
for r in &p.runs {
if matches!(r.kind, RunKind::Break) {
out.push_str("\\line ");
continue;
}
let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none"));
if !(b || i || u) {
escape(out, &r.text);
continue;
}
out.push('{');
out.push_str(if b { "\\b" } else { "" });
out.push_str(if i { "\\i" } else { "" });
out.push_str(if u { "\\ul" } else { "" });
out.push(' ');
escape(out, &r.text);
out.push('}');
}
}
/// The presentation's outline (slide titles and body text) as RTF.
pub fn slides_to_rtf(p: &Presentation) -> String {
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n");
for s in &p.slides {
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
Some(tp) => runs(&mut out, tp),
None => escape(&mut out, &s.title()),
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
continue;
}
let Some(t) = &sh.text else { continue };
for para in t.paragraphs.iter().filter(|p| !p.is_empty()) {
let lvl = para.level.min(8) as u32;
out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1)));
runs(&mut out, para);
out.push_str("\\par\n");
}
}
}
out.push_str("}\n");
out
}
/// Whether `bytes` look like RTF.
pub fn sniff_rtf(bytes: &[u8]) -> bool {
bytes.trim_ascii_start().starts_with(b"{\\rtf")
}
/// Destinations whose text is not document text.
const SKIP: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"header",
"footer",
"footnote",
"listtable",
"listoverridetable",
"generator",
"xmlnstbl",
"themedata",
"datastore",
"latentstyles",
"rsidtbl",
"object",
"fldinst",
];
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
/// indent in 0.25" steps) becomes its number of tabs.
pub fn rtf_to_outline(rtf: &str) -> String {
#[derive(Clone, Copy)]
struct St {
skip: bool,
uc: usize,
}
let b = rtf.as_bytes();
let mut stack: Vec<St> = vec![];
let mut st = St { skip: false, uc: 1 };
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
let mut line = String::new();
let mut out = String::new();
let mut pending_skip = 0usize;
let mut i = 0;
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
let text = line.trim();
if !text.is_empty() {
let n = level.map(|l| l as i64).unwrap_or((indent + 180) / 360).clamp(0, 9) as usize;
out.push_str(&"\t".repeat(n));
out.push_str(text);
out.push('\n');
}
line.clear();
};
while i < b.len() {
let c = b[i];
match c {
b'{' => {
stack.push(st);
i += 1;
// `{\*\dest …}`: an ignorable destination.
if b[i..].starts_with(b"\\*") {
st.skip = true;
}
}
b'}' => {
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
i += 1;
}
b'\\' => {
i += 1;
let Some(&n) = b.get(i) else { break };
if n.is_ascii_alphabetic() {
let s = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let word = &rtf[s..i];
let ns = i;
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
let arg: Option<i64> = rtf[ns..i].parse().ok();
if i < b.len() && b[i] == b' ' {
i += 1;
}
if pending_skip > 0 {
pending_skip -= 1;
continue;
}
if SKIP.contains(&word) {
st.skip = true;
}
if st.skip {
continue;
}
match word {
"par" => {
flush(&mut line, level, indent, &mut out);
}
"pard" => {
level = None;
indent = 0;
}
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
"li" => indent = arg.unwrap_or(0),
"tab" | "line" => line.push(' '),
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
"u" => {
let v = arg.unwrap_or(0);
let v = if v < 0 { v + 65536 } else { v } as u32;
push_utf16(&mut line, v);
pending_skip = 0;
// Skip the fallback characters.
let mut k = st.uc;
while k > 0 && i < b.len() {
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
i += 4;
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
break;
} else {
i += 1;
}
k -= 1;
}
}
"emdash" => line.push('—'),
"endash" => line.push('–'),
"bullet" => line.push('•'),
"lquote" => line.push('‘'),
"rquote" => line.push('’'),
"ldblquote" => line.push('“'),
"rdblquote" => line.push('”'),
_ => {}
}
} else if n == b'\'' {
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
i += 3;
if !st.skip
&& let Some(h) = hex
{
line.push(cp1252(h));
}
} else {
i += 1;
if st.skip {
continue;
}
match n {
b'\\' | b'{' | b'}' => line.push(n as char),
b'~' => line.push('\u{a0}'),
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
_ => {}
}
}
}
b'\r' | b'\n' => i += 1,
_ => {
let s = i;
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
i += 1;
}
if !st.skip {
line.push_str(&rtf[s..i]);
}
}
}
}
flush(&mut line, level, indent, &mut out);
finish_surrogates(out)
}
/// Push a UTF-16 unit; surrogate halves are parked as private markers and joined afterwards.
fn push_utf16(line: &mut String, v: u32) {
match char::from_u32(v) {
Some(c) => line.push(c),
None => line.push_str(&format!("\u{0}{v:04x}")),
}
}
fn finish_surrogates(s: String) -> String {
if !s.contains('\u{0}') {
return s;
}
let mut out = String::new();
let mut rest = s.as_str();
while let Some(p) = rest.find('\u{0}') {
out.push_str(&rest[..p]);
let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok());
let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok());
match (hi, lo) {
(Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => {
out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)));
rest = &rest[p + 10..];
}
_ => {
out.push('\u{fffd}');
rest = rest.get(p + 5..).unwrap_or("");
}
}
}
out.push_str(rest);
out
}
fn cp1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9f => HIGH[(b - 0x80) as usize],
_ => b as char,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn export_reads_back_as_the_same_outline() {
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n";
crate::outline_to_slides(&mut p, outline);
let rtf = slides_to_rtf(&p);
assert!(sniff_rtf(rtf.as_bytes()));
assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}");
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
#[test]
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ pub fn tool_definitions() -> Vec<Value> {
obj(
json!({
"path": string("Output path"),
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "rtf", "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)"),
+2 -1
View File
@@ -214,11 +214,12 @@ impl Backstage {
}
/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail).
pub const SAVE_TYPES: [(&str, &str, &str); 6] = [
pub const SAVE_TYPES: [(&str, &str, &str); 7] = [
("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"),
("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"),
("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"),
("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"),
("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"),
("png", "PNG Picture (*.png)", "The current slide as a picture"),
("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"),
];
+1
View File
@@ -1653,6 +1653,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
("PowerPoint Presentation (.pptx)", "pptx"),
("Outline (.txt)", "outline"),
("Animated GIF (.gif)", "gif"),
("Outline/RTF (.rtf)", "rtf"),
] {
if ui.radio(fmt == f, l).clicked() {
d.fields.insert("fmt".into(), f.into());
+4 -3
View File
@@ -154,7 +154,7 @@ fn open_command(name: &str) -> Option<&'static str> {
let lower = name.to_ascii_lowercase();
if is_presentation_name(&lower) {
Some("file.openBytes")
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
} else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") {
Some("slide.fromOutline")
} else {
None
@@ -175,7 +175,7 @@ fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> {
Some("insert.audio")
} else if ct.starts_with("video/") {
Some("insert.video")
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
} else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") {
Some("slide.fromOutline")
} else {
None
@@ -752,6 +752,7 @@ impl SlideApp {
"jpeg" | "jpg" => ("jpg", "jpeg", false),
"pptx" => ("pptx", "pptx", false),
"outline" | "txt" => ("txt", "outline", false),
"rtf" => ("rtf", "rtf", false),
"deckcraft" => ("deckcraft", "deckcraft", false),
"gif" => ("gif", "gif", false),
other => {
@@ -1487,7 +1488,7 @@ impl SlideApp {
}
};
let params = if cmd == "slide.fromOutline" {
json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
} else {
json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)})
};
+1 -1
View File
@@ -25,7 +25,7 @@ PowerPoint opens and saves; Viewer = opens only).
| `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design.rs` (`browse_theme_tests`) |
| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests |
| PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests |
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` and `.rtf` outline; `.md` as Markdown (headings are slides, list items bullets) (open, drop, `slide.fromOutline`) | `.txt` and `.rtf` outline | — | format + 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. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI 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 | 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 |
+3 -3
View File
@@ -9,12 +9,12 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 45 | 16 | 4 | 82% |
| P2 | 54 | 17 | 25 | 12 | 55% |
| P2 | 54 | 18 | 24 | 12 | 56% |
| Area | Parity |
|---|---|
| Application shell | 81% |
| Files | 75% |
| Files | 76% |
| Slides | 92% |
| Views | 98% |
| Masters & themes | 92% |
@@ -64,7 +64,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Files | Document properties | P2 | D |
| 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 outline / RTF | P2 | D |
| 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 |