collective

Browse / github.com/storytold/deckcraft / patch

Outline RTF: write PowerPoint heading styles, keep empty and '- ' titles on open

Outline RTF: write PowerPoint heading styles, keep empty and '- ' titles on open PowerPoint's Outline/RTF save writes a stylesheet with `\s1`-`\s9` heading styles and tags each paragraph with one (no `\outlinelevel`); an untitled slide is an empty `\s1` paragraph. - Export: writes the `\s1`-`\s9` stylesheet and tags titles `\s1` and body paragraphs `\s2`-`\s9`, besides `\outlinelevel`. - Import: reads `\sN` as level N-1 when no `\outlinelevel` is given. `rtf_to_slides` builds slides straight from the paragraphs, so an empty `\s1` title makes an untitled slide and a title like `- x` keeps its dash (the plain-text outline path drops both). Opening an RTF file uses it. - The

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1446, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+86 −18 in 2 files
Review
pending
Shared
2026-10-28
Id
82f4f15b9bfdf43b2dd09019

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/file.rs crates/format/src/rtf.rs

diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -170,9 +170,10 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {     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") {+        if rtf {+            deckcraft_format::rtf::rtf_to_slides(&mut p, &text);+        } else if lower.ends_with(".md") {             deckcraft_format::markdown_to_slides(&mut p, &text);         } else {             deckcraft_format::outline_to_slides(&mut p, &text);diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs--- a/crates/format/src/rtf.rs+++ b/crates/format/src/rtf.rs@@ -1,9 +1,11 @@ //! 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.+//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline. Each+//! carries PowerPoint's `\s1`-`\s9` heading style, which is how PowerPoint finds the outline levels.+//! The import reads paragraphs and their `\outlinelevel` (or, failing that, the `\s1`-`\s9` style,+//! or the `\li` indent) into the tab-indented outline [`crate::outline_to_slides`] understands, or,+//! with [`rtf_to_slides`], straight into slides that keep empty titles and titles starting `- `.  use deckcraft_model::Presentation; use deckcraft_model::text::{Paragraph, RunKind};@@ -53,10 +55,14 @@ fn runs(out: &mut String, p: &Paragraph) {  /// 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");+    let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n{\\stylesheet");+    for n in 1..=9 {+        out.push_str(&format!("{{\\s{n}\\li{}\\fi-360\\level{n} heading {n};}}", 360 * n));+    }+    out.push_str("}\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 ");+        out.push_str("\\pard\\s1\\outlinelevel0\\sb240\\b\\fs32 ");         // The title stays one outline paragraph (a `\par` would start another slide on re-import),         // so its paragraphs are joined with line breaks.         let mut tps = title.into_iter().flat_map(|t| &t.paragraphs).filter(|p| !p.is_empty()).peekable();@@ -77,7 +83,7 @@ pub fn slides_to_rtf(p: &Presentation) -> String {             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)));+                out.push_str(&format!("\\pard\\s{}\\outlinelevel{}\\li{}\\fs24 ", (lvl + 2).min(9), lvl + 1, 360 * (lvl + 1)));                 runs(&mut out, para);                 out.push_str("\\par\n");             }@@ -141,15 +147,45 @@ struct Outline<'a> {     stack: Vec<St>,     st: St,     level: Option<u32>,+    /// The level a `\s1`-`\s9` heading style gives, used when no `\outlinelevel` is set.+    style_level: Option<u32>,     indent: i64,+    /// A paragraph's properties were set since the last `\par`: only then can an empty one be a title.+    fresh: bool,     pending_skip: usize,     line: String,-    out: String,+    /// Paragraphs so far: outline level and text (empty only for an empty level-0 title).+    out: Vec<(usize, String)>, }  /// 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 {+    let mut out = String::new();+    for (n, text) in paragraphs(rtf).into_iter().filter(|(_, t)| !t.is_empty()) {+        out.push_str(&"\t".repeat(n));+        out.push_str(&text);+        out.push('\n');+    }+    out+}++/// Append a slide per level-0 paragraph of an RTF outline, its deeper paragraphs the body; unlike+/// [`rtf_to_outline`] then [`crate::outline_to_slides`], an empty title makes an (untitled) slide+/// and a title starting `- ` keeps it, as PowerPoint's own outline export has both.+pub fn rtf_to_slides(p: &mut Presentation, rtf: &str) -> usize {+    let mut slides: crate::OutlineSlides = Vec::new();+    for (n, text) in paragraphs(rtf) {+        if n == 0 {+            slides.push((text, Vec::new()));+        } else if let Some((_, body)) = slides.last_mut() {+            body.push(((n - 1).min(8) as u8, text));+        }+    }+    crate::add_outline_slides(p, slides)+}++fn paragraphs(rtf: &str) -> Vec<(usize, String)> {     let mut p = Outline {         rtf,         b: rtf.as_bytes(),@@ -157,14 +193,16 @@ pub fn rtf_to_outline(rtf: &str) -> String {         stack: vec![],         st: St { skip: false, uc: 1 },         level: None,+        style_level: None,         indent: 0,+        fresh: false,         pending_skip: 0,         line: String::new(),-        out: String::new(),+        out: vec![],     };     p.run();     p.flush();-    finish_surrogates(p.out)+    p.out.into_iter().map(|(n, t)| (n, finish_surrogates(t))).collect() }  impl Outline<'_> {@@ -248,9 +286,18 @@ impl Outline<'_> {             "par" => self.flush(),             "pard" => {                 self.level = None;+                self.style_level = None;                 self.indent = 0;+                self.fresh = true;+            }+            "outlinelevel" => {+                self.level = arg.map(|a| a.clamp(0, 9) as u32);+                self.fresh = true;+            }+            "s" => {+                self.style_level = arg.filter(|a| (1..=9).contains(a)).map(|a| a as u32 - 1);+                self.fresh = true;             }-            "outlinelevel" => self.level = arg.map(|a| a.clamp(0, 9) as u32),             "li" => self.indent = arg.unwrap_or(0),             "tab" | "line" => self.line.push(' '),             "uc" => self.st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,@@ -325,16 +372,17 @@ impl Outline<'_> {         }     } -    /// Ends the current line: a non-empty line becomes one outline entry, indented by its level.+    /// Ends the current line: a non-empty line becomes one entry at its level, and so does an empty+    /// one that is explicitly a title (`\s1` or `\outlinelevel0`): PowerPoint writes an untitled slide so.     fn flush(&mut self) {         let text = self.line.trim();-        if !text.is_empty() {-            let n = self.level.map(|l| l as i64).unwrap_or(self.indent.saturating_add(180) / 360).clamp(0, 9) as usize;-            self.out.push_str(&"\t".repeat(n));-            self.out.push_str(text);-            self.out.push('\n');+        let explicit = self.level.or(self.style_level);+        if !text.is_empty() || self.fresh && explicit == Some(0) {+            let n = explicit.map(|l| l as i64).unwrap_or(self.indent.saturating_add(180) / 360).clamp(0, 9) as usize;+            self.out.push((n, text.to_string()));         }         self.line.clear();+        self.fresh = false;     } } @@ -456,6 +504,25 @@ mod tests {         assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));     } +    /// PowerPoint writes `\\s1`-`\\s9` (no `\\outlinelevel`), an untitled slide as an empty `\\s1`+    /// paragraph, and a title may start `- `; all three must survive reading.+    #[test]+    fn reads_powerpoint_outline_with_styles_and_empty_titles() {+        let rtf = r"{\rtf1\ansi{\stylesheet{\s1\li360 heading 1;}{\s2\li1080 heading 2;}}\pard\plain\s1\li0 {\f1 Title one}{\f1 \par }\pard\plain\s2\li360 {\pntext\pard\plain\f3 \'95\tab}{\f2 Body a\par }\pard\plain\s3\li1080 Body b\par\pard\plain\s1 {\f3 \par }\pard\plain\s1 - dash\par\pard\plain\s2 x\par}";+        assert_eq!(rtf_to_outline(rtf), "Title one\n\tBody a\n\t\tBody b\n- dash\n\tx\n");+        let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);+        assert_eq!(rtf_to_slides(&mut p, rtf), 3);+        assert_eq!(crate::slides_to_outline(&p), "Title one\n\tBody a\n\t\tBody b\n\n- dash\n\tx\n");+    }++    #[test]+    fn export_writes_heading_styles() {+        let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);+        crate::outline_to_slides(&mut p, "A\n\tB\n\t\tC\n");+        let rtf = slides_to_rtf(&p);+        assert!(rtf.contains("{\\s9\\li3240") && rtf.contains("\\pard\\s1\\outlinelevel0") && rtf.contains("\\pard\\s3\\outlinelevel2"), "{rtf}");+    }+     #[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}";