Slide: a new slide's body goes into the layout's content placeholder

slide.new put the body into the first non-title placeholder in shape order,
so a layout with a small body placeholder ahead of its content placeholder
(as Keynote exports "Title & Bullets") got the body in the small box and
left the content placeholder empty.

The body now goes to the main content placeholder: idx 1 first, then a
full-size placeholder over a half/quarter one, then a text placeholder over
a picture/chart/table one, keeping shape order on ties.

Fixes #725
This commit is contained in:
Gert Pellin
2026-10-14 19:06:35 +02:00
parent d78e657e4c
commit 50d9ad00e6
2 changed files with 55 additions and 1 deletions
+20 -1
View File
@@ -106,7 +106,7 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
})
}
/// 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<usize> {
let st = s.doc()?;
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
+35
View File
@@ -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}");
}
}