//! Design Ideas: arrangements of what is on the current slide (picture full-bleed with the title //! on a band, picture on one half, a framed picture, text-only layouts). An idea moves and //! resizes the slide's own shapes and crops pictures to their new box; it changes only that //! slide. Everything is computed here, offline. use deckcraft_geom::Xfrm; use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind}; use serde_json::{Value, json}; use super::*; use crate::{Result, Selection, Session}; pub fn specs() -> Vec { vec![ cmd!(query "design.ideas", "Design Ideas", [], None, "{slide?} → [{id, label}] ideas for the slide", has_slide, ideas_cmd), cmd!("design.applyIdea", "Apply Design Idea", ["Home", "Designer"], None, "{id, slide?}", has_slide, apply_cmd), ] } /// An idea: id and label. pub type Idea = (&'static str, &'static str); const PICTURE_IDEAS: [Idea; 4] = [ ("fullBleed", "Full-bleed picture, title on a band"), ("pictureLeft", "Picture left, text right"), ("pictureRight", "Picture right, text left"), ("framed", "Framed picture beside the title"), ]; const TEXT_IDEAS: [Idea; 3] = [("titleColumn", "Title column beside the text"), ("centred", "Centred title over the text"), ("titleBottom", "Text above, title at the foot")]; /// The roles of a slide's top-level shapes, by index. struct Roles { title: Option, picture: Option, text: Vec, } fn roles(shapes: &[Shape]) -> Roles { let mut r = Roles { title: None, picture: None, text: vec![] }; for (i, s) in shapes.iter().enumerate() { if s.hidden { continue; } if s.ph.as_ref().is_some_and(|p| p.kind.is_title()) && r.title.is_none() { r.title = Some(i); } else if matches!(s.kind, ShapeKind::Picture { .. }) { // The largest picture leads. let area = |j: usize| shapes.get(j).and_then(|s| s.xfrm).map(|x| x.w * x.h).unwrap_or(0.0); if r.picture.is_none_or(|p| area(i) > area(p)) { r.picture = Some(i); } } else if s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()) { r.text.push(i); } } r } /// The ideas for slide `index` (none when it has nothing to arrange). pub fn ideas_for(doc: &Presentation, index: usize) -> Vec { let Some(slide) = doc.slides.get(index) else { return vec![] }; let r = roles(&slide.shapes); if r.picture.is_some() { PICTURE_IDEAS.to_vec() } else if r.title.is_some() && !r.text.is_empty() { TEXT_IDEAS.to_vec() } else { vec![] } } /// Crop a picture whose source has aspect `src` (w/h) to fill a box of aspect `dst`, centred. fn cover_crop(src: f64, dst: f64) -> [f64; 4] { if !(src.is_finite() && dst.is_finite() && src > 0.0 && dst > 0.0) { return [0.0; 4]; } if src > dst { let cut = (1.0 - dst / src) / 2.0; [cut, 0.0, cut, 0.0] } else { let cut = (1.0 - src / dst) / 2.0; [0.0, cut, 0.0, cut] } } /// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes). pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> { if !ideas_for(doc, index).iter().any(|i| i.0 == id) { return Err(bad("design.applyIdea", format!("no idea `{id}` for this slide (see design.ideas)"))); } let (sw, sh) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0)); let sel = Selection { slide: index, ..sel.clone() }; let shapes = doc.slides.get(index).map(|s| s.shapes.clone()).ok_or_else(|| bad("design.applyIdea", "no slide"))?; let r = roles(&shapes); let boxes: Vec = shapes.iter().map(|s| xfrm_of(doc, &sel, s)).collect(); let m = sw.min(sh) * 0.06; let mut place: Vec<(usize, Xfrm)> = vec![]; // Text shapes stacked in a box. let stack = |b: Xfrm, out: &mut Vec<(usize, Xfrm)>| { let n = r.text.len().max(1) as f64; let gap = m * 0.4; let h = ((b.h - gap * (n - 1.0)) / n).max(1.0); for (k, i) in r.text.iter().enumerate() { out.push((*i, Xfrm::new(b.x, b.y + k as f64 * (h + gap), b.w, h))); } }; let mut band = false; match id { "fullBleed" => { if let Some(p) = r.picture { place.push((p, Xfrm::new(0.0, 0.0, sw, sh))); } if let Some(t) = r.title { place.push((t, Xfrm::new(m, sh * 0.68, sw - 2.0 * m, sh * 0.2))); band = true; } stack(Xfrm::new(m, sh * 0.06, sw * 0.45, sh * 0.5), &mut place); } "pictureLeft" | "pictureRight" => { let left = id == "pictureLeft"; let half = sw * 0.5; let (px, tx) = if left { (0.0, half + m) } else { (half, m) }; if let Some(p) = r.picture { place.push((p, Xfrm::new(px, 0.0, half, sh))); } let tw = half - 2.0 * m; if let Some(t) = r.title { place.push((t, Xfrm::new(tx, m, tw, sh * 0.24))); } stack(Xfrm::new(tx, m + sh * 0.28, tw, sh - 2.0 * m - sh * 0.28), &mut place); } "framed" => { if let Some(p) = r.picture { place.push((p, Xfrm::new(sw * 0.42, m * 1.5, sw * 0.58 - m * 1.5, sh - 3.0 * m))); } let tw = sw * 0.42 - 2.0 * m; if let Some(t) = r.title { place.push((t, Xfrm::new(m, m * 1.5, tw, sh * 0.3))); } stack(Xfrm::new(m, m * 1.5 + sh * 0.34, tw, sh - 3.0 * m - sh * 0.34), &mut place); } "titleColumn" => { if let Some(t) = r.title { place.push((t, Xfrm::new(m, m, sw * 0.32, sh - 2.0 * m))); } stack(Xfrm::new(sw * 0.36, m, sw * 0.64 - m, sh - 2.0 * m), &mut place); } "centred" => { if let Some(t) = r.title { place.push((t, Xfrm::new(sw * 0.1, m, sw * 0.8, sh * 0.26))); } stack(Xfrm::new(sw * 0.18, m + sh * 0.3, sw * 0.64, sh - 2.0 * m - sh * 0.3), &mut place); } "titleBottom" => { stack(Xfrm::new(m, m, sw - 2.0 * m, sh * 0.62), &mut place); if let Some(t) = r.title { place.push((t, Xfrm::new(m, sh * 0.72, sw - 2.0 * m, sh * 0.2))); } } _ => return Err(bad("design.applyIdea", format!("no idea `{id}`"))), } let slide = doc.slides.get_mut(index).ok_or_else(|| bad("design.applyIdea", "no slide"))?; let slide = std::sync::Arc::make_mut(slide); for (i, x) in place { let (Some(s), Some(old)) = (slide.shapes.get_mut(i), boxes.get(i)) else { continue }; if let ShapeKind::Picture { fill } = &mut s.kind { // The source's aspect, from the current box and crop. let c = fill.crop; let (fw, fh) = ((1.0 - c[0] - c[2]).max(0.01), (1.0 - c[1] - c[3]).max(0.01)); let src = (old.w / fw) / (old.h / fh).max(1e-6); fill.crop = cover_crop(src, x.w / x.h.max(1e-6)); } s.xfrm = Some(Xfrm { rot: 0.0, ..x }); } // The picture goes to the back, so text over it stays readable. if let Some(p) = r.picture && p < slide.shapes.len() { let pic = slide.shapes.remove(p); slide.shapes.insert(0, pic); } if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))); } Ok(()) } fn ideas_cmd(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let i = usize_param(p, "slide").unwrap_or(st.selection.slide); Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>())) } fn apply_cmd(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string(); let slide = usize_param(p, "slide"); s.edit(|doc, sel| { let i = slide.unwrap_or(sel.slide); apply_idea(doc, sel, i, &id)?; Ok(json!({"slide": i, "id": id})) }) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use std::sync::Arc; use deckcraft_model::style::PictureFill; use deckcraft_model::{PhType, Placeholder, ShapeId}; use super::*; fn deck(picture: bool) -> Session { let mut s = Session::with_new(); let st = s.doc_mut().unwrap(); let doc = Arc::make_mut(&mut st.doc); let slide = Arc::make_mut(&mut doc.slides[0]); slide.shapes.clear(); let title = Shape { id: ShapeId(500), ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }), xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)), text: Some(deckcraft_model::text::TextBody::from_text("Harbour at dawn")), ..Default::default() }; let body = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)), text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")), ..Default::default() }; slide.shapes.push(title); slide.shapes.push(body); if picture { // A 2:1 picture covering most of the slide, over the title. let pic = Shape { id: ShapeId(502), kind: ShapeKind::Picture { fill: PictureFill::default() }, xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 450.0)), ..Default::default() }; slide.shapes.push(pic); } s } fn shape(s: &Session, id: u32) -> Shape { s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.id == ShapeId(id)).cloned().unwrap() } fn overlaps(a: Xfrm, b: Xfrm) -> bool { a.x < b.x + b.w - 0.5 && b.x < a.x + a.w - 0.5 && a.y < b.y + b.h - 0.5 && b.y < a.y + a.h - 0.5 } #[test] fn picture_slides_get_layout_ideas_and_text_slides_get_text_ideas() { let s = deck(true); let ids: Vec<&str> = ideas_for(&s.doc().unwrap().doc, 0).iter().map(|i| i.0).collect(); assert_eq!(ids, ["fullBleed", "pictureLeft", "pictureRight", "framed"]); let s = deck(false); assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3); assert!(ideas_for(&s.doc().unwrap().doc, 9).is_empty()); } #[test] fn picture_left_puts_text_beside_the_picture_cropped_to_fill() { let mut s = deck(true); let theme_before = s.doc().unwrap().doc.masters[0].theme.name.clone(); s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); let (pic, title, body) = (shape(&s, 502), shape(&s, 500), shape(&s, 501)); let px = pic.xfrm.unwrap(); assert_eq!((px.x, px.y, px.w, px.h), (0.0, 0.0, 480.0, 540.0)); assert!(!overlaps(px, title.xfrm.unwrap()) && !overlaps(px, body.xfrm.unwrap())); assert!(!overlaps(title.xfrm.unwrap(), body.xfrm.unwrap())); // 2:1 source into an 8:9 box: cut equally from left and right. let ShapeKind::Picture { fill } = &pic.kind else { panic!() }; assert!(fill.crop[0] > 0.25 && (fill.crop[0] - fill.crop[2]).abs() < 1e-9 && fill.crop[1] == 0.0, "{:?}", fill.crop); // Only this slide's arrangement changed, not the theme; undo restores it. assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, theme_before); assert_eq!(s.doc().unwrap().doc.slides[0].shapes[0].id, ShapeId(502), "picture at the back"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(shape(&s, 502).xfrm.unwrap().w, 900.0); } #[test] fn full_bleed_covers_the_slide_and_puts_the_title_on_a_band() { let mut s = deck(true); s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); let px = shape(&s, 502).xfrm.unwrap(); assert_eq!((px.w, px.h), (960.0, 540.0)); let t = shape(&s, 500); assert!(t.fill.is_some()); assert!(t.xfrm.unwrap().y > 300.0); } #[test] fn every_idea_keeps_shapes_on_the_slide_and_unknown_ideas_are_refused() { for pic in [true, false] { let probe = deck(pic); for (id, _) in ideas_for(&probe.doc().unwrap().doc, 0) { let mut s = deck(pic); s.execute("design.applyIdea", &json!({"id": id})).unwrap(); for sh in &s.doc().unwrap().doc.slides[0].shapes { let x = sh.xfrm.unwrap(); assert!(x.x >= -0.01 && x.y >= -0.01 && x.x + x.w <= 960.01 && x.y + x.h <= 540.01 && x.w > 0.0 && x.h > 0.0, "{id}: {x:?}"); } } } let mut s = deck(false); assert!(s.execute("design.applyIdea", &json!({"id": "fullBleed"})).is_err()); assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err()); } #[test] fn cover_crop_matches_the_box_aspect() { let c = cover_crop(2.0, 1.0); assert!((c[0] - 0.25).abs() < 1e-12 && c[1] == 0.0); let c = cover_crop(1.0, 2.0); assert!((c[1] - 0.25).abs() < 1e-12 && c[0] == 0.0); assert_eq!(cover_crop(f64::NAN, 1.0), [0.0; 4]); } }