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
@@ -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);@@ -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}";