diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 83e730d9..c9526c3c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, 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)> { 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, 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> { 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> { } /// 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 { "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()})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7e5d9930..3fbdfffa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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] diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index dc9d08ee..cea34a6a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -6,6 +6,7 @@ mod chart_parts; mod finite; +pub mod rtf; #[cfg(test)] mod chart_parts_tests; diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs new file mode 100644 index 00000000..7e167603 --- /dev/null +++ b/crates/format/src/rtf.rs @@ -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 = vec![]; + let mut st = St { skip: false, uc: 1 }; + let (mut level, mut indent): (Option, 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, 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 = 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"); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b964ea10..c4f7e85d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -119,7 +119,7 @@ pub fn tool_definitions() -> Vec { 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)"), diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 999900d3..b49144e0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -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"), ]; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 266f98c3..1cec3b63 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3111a4b0..93b63c08 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)}) }; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1f32dbe6..2788c625 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3a5c51b9..b5e4d81a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,12 +9,12 @@ The summary between ` |---|---|---|---|---|---| | 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 |