diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..42b9d0b7 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -106,7 +106,7 @@ fn new(s: &mut Session, p: &Value) -> Result { sh.text = Some(TextBody::from_text(t)); } if let Some(b) = str_param(p, "body") - && let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title())) + && let Some(sh) = body_placeholder(&mut slide.shapes) { sh.text = Some(TextBody::from_text(b)); } @@ -137,6 +137,25 @@ fn new(s: &mut Session, p: &Value) -> Result { }) } +/// The placeholder a new slide's `body` goes into: the layout's main content placeholder. +/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over +/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep +/// shape order, so a layout with a single body placeholder behaves as before. +fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> { + use deckcraft_model::PhType; + let rank = |sh: &deckcraft_model::Shape| { + let ph = sh.ph.as_ref()?; + if ph.kind.is_title() { + return None; + } + let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle); + let small = matches!(ph.size.as_deref(), Some("half" | "quarter")); + Some((ph.idx != 1, small, !text)) + }; + let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1; + shapes.get_mut(best) +} + fn index_of(s: &Session, p: &Value) -> Result { let st = s.doc()?; let i = usize_param(p, "index").unwrap_or(st.selection.slide); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..6dcb4a40 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,38 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// #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] +fn new_slide_body_goes_to_the_content_placeholder() { + for before in [true, false] { + let mut s = session(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let id = deckcraft_model::ShapeId(doc.alloc_id()); + let lay = std::sync::Arc::make_mut(&mut doc.masters[0]) + .layouts + .iter_mut() + .find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent) + .unwrap(); + let at = lay.shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.idx == 1)).unwrap(); + let mut small = lay.shapes[at].clone(); + small.id = id; + small.name = "Subtitle 21".into(); + let ph = small.ph.as_mut().unwrap(); + ph.kind = deckcraft_model::PhType::Body; + ph.idx = 21; + ph.size = Some("quarter".into()); + lay.shapes.insert(if before { at } else { at + 1 }, small); + + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "Line one\nLine two"})).unwrap(); + let doc = &s.doc().unwrap().doc; + let slide = &doc.slides[doc.slides.len() - 1]; + let text = |idx: u32| { + let sh = slide.shapes.iter().find(|x| x.ph.as_ref().is_some_and(|p| p.idx == idx)).unwrap(); + sh.text.as_ref().map(|t| t.text()).unwrap_or_default() + }; + assert_eq!(text(1), "Line one\nLine two", "before = {before}"); + assert_eq!(text(21), "", "before = {before}"); + } +}