From ede154d6be565d47b4b9c608103ed88896569ee7 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:07:03 -0500 Subject: [PATCH] Outlines leave footer, date and slide-number placeholders out: export, RTF and the outline view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent run exporting a deck as an outline got the footer text and the slide-number field (‹#›) as body lines under each title. PowerPoint's outline view shows title and body placeholders only, so the text outline, the RTF outline (Save As ▸ Outline/RTF) and the outline view now skip the footer-row placeholders as they already skipped the title. Split out of the frame export PR at review, which asked for all three together. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 32 +++++++++++++++++++++++++++ crates/format/src/lib.rs | 4 +++- crates/format/src/rtf.rs | 3 ++- crates/ui-egui/src/sorter.rs | 5 +++-- crates/ui-egui/src/sorter/ui_tests.rs | 21 ++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 12fac5efc..6741f03d5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6509,3 +6509,35 @@ fn save_to_image_path_is_an_error_not_zip_bytes() { } let _ = std::fs::remove_dir_all(&dir); } + +/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer, +/// date and slide-number placeholders are not body lines. +#[test] +fn outline_export_skips_footer_date_and_slide_number() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap(); + s.execute( + "design.headerFooter", + &json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}), + ) + .unwrap(); + let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.md"); + s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}"); + assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}"); + assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}"); + assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}"); + // The RTF outline (Save As ▸ Outline/RTF) too. + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.rtf"); + s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap(); + let rtf = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}"); + assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}"); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 97caafdb3..4125c2905 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -694,7 +694,9 @@ pub fn slides_to_outline(p: &Presentation) -> String { out.push_str(&s.title().replace('\n', " ")); out.push('\n'); for sh in &s.shapes { - if sh.ph_type().is_some_and(|k| k.is_title()) { + // The title is the slide's line; footer, date and slide-number placeholders are not + // body text (PowerPoint's outline view shows title and body placeholders only). + if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) { continue; } if let Some(t) = &sh.text { diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs index b546a832f..4e7e29583 100644 --- a/crates/format/src/rtf.rs +++ b/crates/format/src/rtf.rs @@ -71,7 +71,8 @@ pub fn slides_to_rtf(p: &Presentation) -> String { } out.push_str("\\b0\\par\n"); for sh in &s.shapes { - if sh.ph_type().is_some_and(|k| k.is_title()) { + // As in the text outline: footer, date and slide-number placeholders are not body text. + if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) { continue; } let Some(t) = &sh.text else { continue }; diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 6c9626380..5205a835d 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -476,7 +476,7 @@ fn outline_estimate(s: &deckcraft_model::Slide) -> f32 { let lines: usize = s .shapes .iter() - .filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) + .filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) .filter_map(|x| x.text.as_ref()) .filter(|tb| !tb.is_empty()) .map(|tb| tb.paragraphs.len().max(1)) @@ -512,7 +512,8 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck ui.data_mut(|d| d.insert_temp(id, v)); } }); - for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) { + // Footer, date and slide-number placeholders are not outline text, as in PowerPoint. + for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) { let Some(tb) = &sh.text else { continue }; if tb.is_empty() { continue; diff --git a/crates/ui-egui/src/sorter/ui_tests.rs b/crates/ui-egui/src/sorter/ui_tests.rs index 966974616..07b42d02f 100644 --- a/crates/ui-egui/src/sorter/ui_tests.rs +++ b/crates/ui-egui/src/sorter/ui_tests.rs @@ -259,3 +259,24 @@ fn shift_and_ctrl_shift_clicks_select_ranges() { click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT); assert_eq!(selected(&h), vec![0, 2, 3]); } + +/// The outline view lists a slide's title and body text, not its footer, date and slide-number +/// placeholders, as PowerPoint's does: turning them on leaves the slide's outline block as it was. +#[test] +fn the_outline_view_leaves_footer_date_and_slide_number_out() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap(); + let slide = |s: &Session| { + let st = s.doc().unwrap(); + st.doc.slides[st.selection.slide].clone() + }; + let plain = super::outline_estimate(&slide(&s)); + s.execute( + "design.headerFooter", + &json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}), + ) + .unwrap(); + let with_footer = slide(&s); + assert!(with_footer.shapes.iter().any(|x| x.ph_type().is_some_and(|k| k.is_footer_kind())), "the footers are on the slide"); + assert_eq!(super::outline_estimate(&with_footer), plain); +}