From 9c222be711f94fd0fc4ad8a332f161231ff95a71 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:52:33 +0000 Subject: [PATCH] refactor(engine): split cmd/design.rs into cmd/design/{colors,slide_background,slide_setup,master_view}.rs --- crates/engine/src/cmd/design.rs | 1397 +---------------- crates/engine/src/cmd/design/colors.rs | 255 +++ crates/engine/src/cmd/design/master_view.rs | 498 ++++++ .../engine/src/cmd/design/slide_background.rs | 298 ++++ crates/engine/src/cmd/design/slide_setup.rs | 336 ++++ 5 files changed, 1402 insertions(+), 1382 deletions(-) create mode 100644 crates/engine/src/cmd/design/colors.rs create mode 100644 crates/engine/src/cmd/design/master_view.rs create mode 100644 crates/engine/src/cmd/design/slide_background.rs create mode 100644 crates/engine/src/cmd/design/slide_setup.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 520b7b6a..0a1202f7 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -16,6 +16,21 @@ use super::{ }; use crate::{Result, Session, Target}; +mod colors; +mod master_view; +mod slide_background; +mod slide_setup; + +use colors::{colors, effects, fonts, variant, variants}; +use master_view::{ + close_master, delete_layout, handout_placeholders, handout_view, in_handout, in_notes_master, insert_layout, insert_placeholder, master_view, + notes_placeholders, notes_view, placeholders, preserve, rename_layout, +}; +pub use master_view::{handout_placeholder, notes_placeholder}; +use slide_background::{background, hide_bg_graphics}; +pub use slide_setup::{civil, date_field_text, today}; +use slide_setup::{header_footer, scale_shapes, slide_size}; + pub fn specs() -> Vec { vec![ cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), @@ -288,1390 +303,8 @@ fn themes(_s: &mut Session, _p: &Value) -> Result { })) } -fn colors(s: &mut Session, p: &Value) -> Result { - let name_v = p.get("name"); - let colors_v = p.get("colors"); - if name_v.is_none() && colors_v.is_none() { - let sc = current_colors(s)?; - s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); - return ok(); - } - let given = match name_v { - None => None, - Some(Value::String(n)) => { - let n = n.trim(); - if n.is_empty() { - return Err(bad("design.colors", "name is empty")); - } - Some(n.to_string()) - } - Some(_) => return Err(bad("design.colors", "`name` must be a string")), - }; - let slots = parse_color_slots(colors_v)?; - let current = current_colors(s)?; - let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; - if next == current { - return ok(); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); - m.theme.colors = next; - Ok(()) - })?; - ok() -} - -fn variant(s: &mut Session, p: &Value) -> Result { - let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); - let v = deckcraft_model::theme::theme_variants(&m.theme); - m.theme.colors = - v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; - Ok(()) - })?; - Ok(json!({"variant": index})) -} - -fn variants(s: &mut Session, _p: &Value) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); - let v = deckcraft_model::theme::theme_variants(&th); - let current = v.iter().position(|c| c.colors == th.colors.colors); - Ok(json!({ - "theme": th.name, - "current": current, - "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), - })) -} - -fn current_colors(s: &Session) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) -} - -fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { - let mut colors = serde_json::Map::new(); - for slot in deckcraft_color::SchemeSlot::THEME { - colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); - } - json!({"name": sc.name, "colors": colors}) -} - -/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such -/// as `bg1` are rejected because `ColorScheme::set` ignores them. -fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { - let Some(v) = colors_v else { return Ok(None) }; - let Some(map) = v.as_object() else { - return Err(bad("design.colors", "`colors` must be an object")); - }; - if map.is_empty() { - return Err(bad("design.colors", "`colors` is empty")); - } - let mut slots = Vec::with_capacity(map.len()); - for (k, v) in map { - let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { - return Err(bad("design.colors", format!("unknown colour `{k}`"))); - }; - if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { - return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); - } - let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { - return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); - }; - slots.push((slot, rgb)); - } - Ok(Some(slots)) -} - -/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name -/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. -fn next_scheme( - current: &deckcraft_color::ColorScheme, - given: Option<&str>, - slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, -) -> Result { - let mut next = current.clone(); - if let Some(name) = given { - if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { - next = builtin; - } else if slots.is_none() { - return Err(bad("design.colors", format!("no colour scheme `{name}`"))); - } - next.name = name.to_string(); - } - if let Some(changes) = slots { - for (slot, rgb) in changes { - next.set(*slot, *rgb); - } - if given.is_none() { - next.name = "Custom".into(); - } - } - Ok(next) -} - -fn fonts(s: &mut Session, p: &Value) -> Result { - let name_v = p.get("name"); - let major_v = p.get("major"); - let minor_v = p.get("minor"); - if name_v.is_none() && major_v.is_none() && minor_v.is_none() { - let scheme = current_fonts(s)?; - s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) }); - return ok(); - } - let given = match name_v { - None => None, - Some(Value::String(n)) => { - let n = n.trim(); - if n.is_empty() { - return Err(bad("design.fonts", "name is empty")); - } - Some(n.to_string()) - } - Some(_) => return Err(bad("design.fonts", "`name` must be a string")), - }; - let major = face_param("major", major_v)?; - let minor = face_param("minor", minor_v)?; - let current = current_fonts(s)?; - let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?; - if next == current { - return ok(); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next; - Ok(()) - })?; - ok() -} - -fn current_fonts(s: &Session) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master")) -} - -fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value { - json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin}) -} - -/// `None` when the key is absent. A present face must be a non-blank string. -fn face_param(key: &str, v: Option<&Value>) -> Result> { - match v { - None => Ok(None), - Some(Value::String(s)) => { - let s = s.trim(); - if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) } - } - Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))), - } -} - -/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East -/// Asian, complex-script and per-script slots stay). The stored name is the given string when -/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name. -/// Faces without a name are a custom scheme: an omitted `minor` copies `major`. -fn next_font_scheme( - current: &deckcraft_model::theme::FontScheme, - given: Option<&str>, - major: Option<&str>, - minor: Option<&str>, -) -> Result { - let mut next = current.clone(); - if let Some(name) = given { - let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) { - next = builtin; - true - } else { - false - }; - if !replaced && major.is_none() && minor.is_none() { - return Err(bad("design.fonts", format!("no font scheme `{name}`"))); - } - if major.is_some() || minor.is_some() || !replaced { - next.name = name.to_string(); - } - } - if given.is_none() { - if let Some(face) = major { - next.major.latin = face.to_string(); - if minor.is_none() { - next.minor.latin = face.to_string(); - } - } - if let Some(face) = minor { - next.minor.latin = face.to_string(); - } - next.name = "Custom".into(); - } else { - if let Some(face) = major { - next.major.latin = face.to_string(); - } - if let Some(face) = minor { - next.minor.latin = face.to_string(); - } - } - Ok(next) -} - -fn effects(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?; - let scheme = builtin_format_schemes() - .into_iter() - .find(|f| f.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?; - let stored = scheme.name.clone(); - let same = { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme) - }; - if same { - return Ok(json!({"name": stored})); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme; - Ok(()) - })?; - Ok(json!({"name": stored})) -} - -fn background(s: &mut Session, p: &Value) -> Result { - if bool_or(p, "copy", false) { - return copy_slide_background(s); - } - let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); - let transparency = f64_param(p, "transparency"); - let reset = bool_or(p, "reset", false); - let color = color_param(p, "color"); - let style = usize_param(p, "style"); - let op = if reset { - BgOp::Set(None) - } else if let Some(mut c) = color { - if let Some(t) = transparency { - set_solid_transparency(&mut c, t); - } - BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) - } else if let Some(i) = style { - // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). - let slots = - [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; - let i = i.clamp(1, 12) - 1; - BgOp::Set(Some(Background::Ref { - idx: 1001 + (i / 4) as u32, - color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), - })) - } else if let Some(g) = p.get("gradient") { - let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; - g.rotate_with_shape = false; - BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) - } else if let Some(pt) = p.get("pattern") { - BgOp::Set(Some(Background::Fill { - fill: Fill::Pattern(deckcraft_model::style::PatternFill { - preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), - fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), - bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), - }), - })) - } else if p.get("picture").is_some() { - let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; - let ct = super::insert::content_type(&name, &bytes); - let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?; - let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; - if let Some(pp) = &props { - apply_picture_props(&mut pf, pp); - } - if let Some(t) = transparency { - set_picture_transparency(&mut pf, t); - } - BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) - } else if props.is_some() || transparency.is_some() { - BgOp::Adjust - } else { - return Err(bad( - "design.background", - "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", - )); - }; - let all = bool_or(p, "all", false); - let index = usize_param(p, "index"); - if matches!(op, BgOp::Adjust) { - let next = { - let st = s.doc()?; - let current = background_of(&st.doc, &st.selection, index); - let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); - let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); - if props.is_some() && !picture { - return Err(bad("design.background", "picture options need a picture background")); - } - if transparency.is_some() && !picture && !solid { - return Err(bad("design.background", "transparency needs a solid or picture background")); - } - let mut next = current.clone(); - if let Some(bg) = next.as_mut() { - adjust_background(bg, props.as_ref(), transparency); - } - if next == current { None } else { Some(next) } - }; - let Some(next) = next else { return ok() }; - s.edit(|doc, sel| { - write_background(doc, sel, index, false, next); - Ok(()) - })?; - return ok(); - } - let BgOp::Set(bg) = op else { return ok() }; - let unchanged = { - let st = s.doc()?; - backgrounds_already(&st.doc, &st.selection, index, all, &bg) - }; - if unchanged { - return ok(); - } - s.edit(|doc, sel| { - write_background(doc, sel, index, all, bg); - Ok(()) - })?; - ok() -} - -enum BgOp { - Set(Option), - Adjust, -} - -fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { - match sel.target { - Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()), - Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), - } -} - -fn used_master_indices(doc: &Presentation) -> BTreeSet { - let fallback = (!doc.masters.is_empty()).then_some(0); - doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect() -} - -fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { - match sel.target { - Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg), - Target::Slides => { - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)); - if all && bg.is_some() { - let masters = used_master_indices(doc); - let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg)); - let layouts_match = !masters.is_empty() - && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none()))); - slides_match && masters_match && layouts_match - } else { - slides_match - } - } - } -} - -fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { - match sel.target { - Target::Master { master } => { - if let Some(m) = doc.masters.get_mut(master) { - Arc::make_mut(m).background = bg; - } - } - Target::Layout { master, layout } => { - if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { - l.background = bg; - } - } - t @ (Target::Handout | Target::NotesMaster) => { - if let Some(m) = crate::page_master_mut(doc, t) { - Arc::make_mut(m).background = bg; - } - } - Target::Slides => { - if all && bg.is_some() { - for idx in used_master_indices(doc) { - if let Some(m) = doc.masters.get_mut(idx) { - let m = Arc::make_mut(m); - m.background = bg.clone(); - for l in &mut m.layouts { - l.background = None; - } - } - } - } - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - for i in list { - if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).background = bg.clone(); - } - } - } - } -} - -fn copy_slide_background(s: &mut Session) -> Result { - let bg = { - let st = s.doc()?; - if !matches!(st.selection.target, Target::Slides) { - return Err(bad("design.background", "copy applies to slides")); - } - let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); - let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg); - let masters_match = if bg.is_some() { - let masters = used_master_indices(&st.doc); - !masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg)) - } else { - true - }; - if slides_match && masters_match { - return ok(); - } - bg - }; - s.edit(|doc, sel| { - write_background(doc, sel, None, true, bg); - Ok(()) - })?; - ok() -} - -fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { - match bg { - Background::Fill { fill: Fill::Picture(pf) } => { - if let Some(pp) = props { - apply_picture_props(pf, pp); - } - if let Some(t) = transparency { - set_picture_transparency(pf, t); - } - } - Background::Fill { fill: Fill::Solid { color } } => { - if let Some(t) = transparency { - set_solid_transparency(color, t); - } - } - _ => {} - } -} - -fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { - let a = 1.0 - t.clamp(0.0, 1.0); - pf.alpha = (a < 1.0).then_some(a); -} - -fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { - color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); - let t = t.clamp(0.0, 1.0); - if t > 0.0 { - color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); - } -} - -/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). -fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { - use deckcraft_model::style::PictureMode; - let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { - pf.upright = !up; - } - match pp.get("tile").and_then(Value::as_bool) { - Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { - pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; - } - Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), - _ => {} - } - match &mut pf.mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - if let Some(v) = f("offsetX") { - *tx = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("offsetY") { - *ty = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("scaleX") { - *sx = v.clamp(0.01, 10.0); - } - if let Some(v) = f("scaleY") { - *sy = v.clamp(0.01, 10.0); - } - if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { - *flip = m.to_string(); - } - if let Some(a) = - pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) - { - *align = a.to_string(); - } - } - PictureMode::Stretch { fill_rect } => { - if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { - for (slot, v) in fill_rect.iter_mut().zip(arr) { - if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { - *slot = v.clamp(-10.0, 0.99); - } - } - } - } - } -} - -fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, sel| { - if let Some(sl) = doc.slides.get_mut(sel.slide) { - let sl = Arc::make_mut(sl); - sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes); - } - Ok(()) - })?; - ok() -} - -fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { - for sh in shapes { - if let Some(x) = sh.xfrm.as_mut() { - x.x = x.x * sx + ox; - x.y = x.y * sy + oy; - x.w *= sx; - x.h *= sy; - } - if let Some(t) = sh.text.as_mut() { - deckcraft_model::edit::format_all(t, &|r| { - if let Some(sz) = r.size.as_mut() { - *sz = (*sz * k).max(1.0); - } - }); - } - } -} - -fn slide_size(s: &mut Session, p: &Value) -> Result { - let size = if let Some(pre) = str_param(p, "preset") { - let k = pre.to_ascii_lowercase(); - let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k)); - let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?; - Size::new(*w, *h) - } else { - Size::new( - f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0), - f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0), - ) - }; - let size = match str_param(p, "orientation") { - Some("portrait") if size.width > size.height => Size::new(size.height, size.width), - Some("landscape") if size.height > size.width => Size::new(size.height, size.width), - Some("portrait" | "landscape") | None => size, - Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))), - }; - let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); - s.edit(|doc, _| { - let old = doc.slide_size; - let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); - let (sx, sy, k, ox, oy) = match mode.as_str() { - "none" => (1.0, 1.0, 1.0, 0.0, 0.0), - "maximize" => { - let k = sx.max(sy); - (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) - } - _ => { - let k = sx.min(sy); - (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) - } - }; - // Masters and layouts stretch to the new size; slides scale by mode. - let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); - for m in &mut doc.masters { - let m = Arc::make_mut(m); - scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); - for l in &mut m.layouts { - scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); - } - } - for sl in &mut doc.slides { - scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); - } - doc.slide_size = size; - Ok(()) - })?; - Ok(json!({"w": size.width, "h": size.height})) -} - -fn header_footer(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, sel| { - let hf = &mut doc.header_footer; - if let Some(v) = bool_param(p, "date") { - hf.date = v; - } - if let Some(v) = str_param(p, "dateText") { - hf.date_text = v.into(); - } - if let Some(v) = str_param(p, "dateFormat") { - // Refused rather than ignored: a format that renders nothing would leave the - // placeholder showing its saved text for ever. - if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { - return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); - } - hf.date_format = v.into(); - } - if let Some(v) = bool_param(p, "slideNumber") { - hf.slide_number = v; - } - if let Some(v) = bool_param(p, "footer") { - hf.footer = v; - } - if let Some(v) = str_param(p, "footerText") { - hf.footer_text = v.into(); - hf.footer = hf.footer || !v.is_empty(); - } - if let Some(v) = bool_param(p, "hideOnTitle") { - hf.hide_on_title = v; - } - let hf = hf.clone(); - let date_given = str_param(p, "dateText").is_some(); - let format_given = str_param(p, "dateFormat").is_some(); - let all = bool_or(p, "all", true); - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; - for i in list { - let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; - let lay = doc.layout(layout).map(|(_, l)| l.clone()); - let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title); - let want = |k: PhType| { - !(hf.hide_on_title && is_title) - && match k { - PhType::Date => hf.date, - PhType::SlideNum => hf.slide_number, - PhType::Footer => hf.footer, - _ => false, - } - }; - let mut new_ids = vec![]; - for _ in 0..3 { - new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?)); - } - let Some(sl) = doc.slides.get_mut(i) else { continue }; - let sl = Arc::make_mut(sl); - for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { - let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); - match (has, want(k)) { - (Some(pos), false) => { - sl.shapes.remove(pos); - } - (None, true) => { - let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); - let Some(lp) = lp else { continue }; - let body = match k { - PhType::SlideNum => TextBody { - paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { - text: "‹#›".into(), - props: Default::default(), - kind: RunKind::Field { field: "slidenum".into() }, - }], - ..Default::default() - }], - ..Default::default() - }, - PhType::Date if hf.date_text.is_empty() => TextBody { - paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { - text: date_now(&hf.date_format), - props: Default::default(), - kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, - }], - ..Default::default() - }], - ..Default::default() - }, - PhType::Date => TextBody::from_text(&hf.date_text), - _ => TextBody::from_text(&hf.footer_text), - }; - sl.shapes.push(Shape { - id: new_ids.get(n).copied().unwrap_or_default(), - name: lp.name.clone(), - ph: lp.ph.clone(), - text: Some(body), - ..Default::default() - }); - } - (Some(pos), true) if k == PhType::Footer => { - if let Some(sh) = sl.shapes.get_mut(pos) { - sh.text = Some(TextBody::from_text(&hf.footer_text)); - } - } - (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { - if let Some(sh) = sl.shapes.get_mut(pos) { - set_date(sh, &hf.date_text, &hf.date_format); - } - } - _ => {} - } - } - } - Ok(()) - })?; - ok() -} - -/// The date field type `format` names, `datetime1` when it is empty or unknown. -fn date_kind(format: &str) -> &str { - if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } -} - -/// The saved text of a date field of `format`, as it shows now. -fn date_now(format: &str) -> String { - let kind = date_kind(format); - deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) -} - -/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty -/// `fixed`, at an automatic date field (one it already has keeps its format). -fn set_date(sh: &mut Shape, fixed: &str, format: &str) { - let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); - let field_now = runs().find_map(|r| match &r.kind { - RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), - _ => None, - }); - let has_field = field_now.is_some(); - let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); - let want = date_kind(format); - let (text, kind) = match fixed { - // Automatic: keep the field, but follow a format that has changed. - "" if field_now.as_deref() == Some(want) => return, - "" if has_field && format.is_empty() => return, - "" => (date_now(format), RunKind::Field { field: want.into() }), - _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, - _ => (fixed.to_string(), RunKind::Text), - }; - let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() }; - sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() }); -} - -/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date -/// field. Empty where there is no clock (wasm). -pub fn today() -> String { - date_field_text("datetime1") -} - -/// What date field `kind` shows now (empty if unknown or there is no clock). -pub fn date_field_text(kind: &str) -> String { - use deckcraft_text::datetime::{DateTime, field_text}; - DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() -} - -/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. -pub fn civil(z: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 - // years (146_097 days). chrono converts the day within its cycle and whole cycles go back - // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and - // 0..146_097 days from it always fits. - let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - None => (1970, 1, 1), - } -} - -fn master_view(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); - // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects - // that layout. With neither key, follow the current slide's layout. - let layout = if p.get("master").is_some() && p.get("layout").is_none() { - None - } else { - usize_param(p, "layout") - .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) - }; - let target = match layout { - Some(l) => Target::Layout { master: mi, layout: l }, - None => Target::Master { master: mi }, - }; - s.select(|_, sel| { - sel.target = target; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(serde_json::to_value(target).unwrap_or_default()) -} - -fn in_handout(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) } -} - -/// The four handout-master placeholders PowerPoint offers, in a page of `size`. -pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { - let (w, h) = (size.width, size.height); - let (name, idx, x, y) = match kind { - PhType::Header => ("Header Placeholder", 0, 0.0, 0.0), - PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0), - PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95), - _ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95), - }; - let mut text = TextBody::default(); - if let Some(p) = text.paragraphs.first_mut() - && matches!(kind, PhType::Date | PhType::SlideNum) - { - p.props.align = Some(deckcraft_model::text::Align::Right); - } - Shape { - id, - name: format!("{name} {}", idx + 1), - xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)), - ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }), - text: Some(text), - ..Default::default() - } -} - -const HANDOUT_KINDS: [(&str, PhType); 4] = - [("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)]; - -fn handout_view(s: &mut Session, _p: &Value) -> Result { - let created = s.doc()?.doc.handout_master.is_none(); - if created { - s.edit(|doc, _| { - let mut m = deckcraft_model::Master { - name: "Handout Master".into(), - theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), - color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), - ..Default::default() - }; - let size = doc.notes_size; - for (_, k) in HANDOUT_KINDS { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?); - m.shapes.push(handout_placeholder(k, size, id)); - } - doc.handout_master = Some(Arc::new(m)); - Ok(()) - })?; - } - s.select(|_, sel| { - sel.target = Target::Handout; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(json!({"created": created})) -} - -fn handout_placeholders(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, _| { - let size = doc.notes_size; - let mut ids = vec![]; - for (key, _) in HANDOUT_KINDS { - let id = match p.get(key).and_then(Value::as_bool) { - Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)), - None => None, - }; - ids.push(id); - } - let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?); - for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) { - let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue }; - let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); - if on && !has { - m.shapes.push(handout_placeholder(kind, size, id)); - } else if !on { - m.shapes.retain(|x| x.ph_type() != Some(kind)); - } - } - Ok(()) - })?; - let m = s.doc()?.doc.handout_master.clone(); - let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k))); - Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) -} - -fn in_notes_master(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } -} - -const NOTES_KINDS: [(&str, PhType); 6] = [ - ("header", PhType::Header), - ("date", PhType::Date), - ("slideImage", PhType::SlideImage), - ("body", PhType::Body), - ("footer", PhType::Footer), - ("pageNumber", PhType::SlideNum), -]; - -/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. -pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { - let (w, h) = (size.width, size.height); - match kind { - PhType::SlideImage => Shape { - id, - name: "Slide Image Placeholder 3".into(), - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), - ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), - fill: Some(Fill::None), - line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), - ..Default::default() - }, - PhType::Body => { - let mut text = TextBody::default(); - text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; - Shape { - id, - name: "Notes Placeholder 4".into(), - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), - ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), - text: Some(text), - ..Default::default() - } - } - _ => { - let mut s = handout_placeholder(kind, size, id); - if let Some(ph) = s.ph.as_mut() { - ph.idx = match kind { - PhType::Header => 0, - PhType::Date => 1, - PhType::Footer => 4, - _ => 5, - }; - } - s - } - } -} - -fn notes_view(s: &mut Session, _p: &Value) -> Result { - let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); - if created { - s.edit(|doc, _| { - let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { - name: "Notes Master".into(), - theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), - color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), - ..Default::default() - }); - let size = doc.notes_size; - for (_, k) in NOTES_KINDS { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?); - m.shapes.push(notes_placeholder(k, size, id)); - } - doc.notes_master = Some(Arc::new(m)); - Ok(()) - })?; - } - s.select(|_, sel| { - sel.target = Target::NotesMaster; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(json!({"created": created})) -} - -fn notes_placeholders(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, _| { - let size = doc.notes_size; - let ids: Vec = - NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; - let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); - for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { - let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; - let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); - if on && !has { - m.shapes.push(notes_placeholder(kind, size, id)); - } else if !on { - m.shapes.retain(|x| x.ph_type() != Some(kind)); - } - } - Ok(()) - })?; - let m = s.doc()?.doc.notes_master.clone(); - let mut out = serde_json::Map::new(); - for (key, kind) in NOTES_KINDS { - out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); - } - Ok(Value::Object(out)) -} - -fn close_master(s: &mut Session, _p: &Value) -> Result { - s.select(|_, sel| { - sel.target = Target::Slides; - sel.shapes.clear(); - sel.text = None; - })?; - ok() -} - -fn insert_layout(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").unwrap_or("Custom Layout").to_string(); - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); - let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); - let size = doc.slide_size; - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); - let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { - t.id = tid; - t.text = Some(TextBody::default()); - t - }); - let _ = size; - let at = match sel.target { - Target::Layout { layout, .. } => layout + 1, - _ => m.layouts.len(), - }; - m.layouts.insert( - at.min(m.layouts.len()), - deckcraft_model::Layout { - id, - name, - kind: deckcraft_model::LayoutType::Custom, - shapes: title.into_iter().collect(), - background: None, - show_master_shapes: true, - preserve: true, - color_map: None, - raw_ext: None, - }, - ); - sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; - Ok(json!({"id": id})) - }) -} - -fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { - let st = s.doc()?; - let (m, l) = match st.selection.target { - Target::Layout { master, layout } => (master, layout), - _ => (master_index(&st.doc, &st.selection), 0), - }; - Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l))) -} - -fn rename_layout(s: &mut Session, p: &Value) -> Result { - let (mi, li) = layout_target(s, p, "master.renameLayout")?; - let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); - s.edit(|doc, _| { - let l = - doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; - l.name = name; - Ok(()) - })?; - ok() -} - -fn delete_layout(s: &mut Session, p: &Value) -> Result { - let (mi, li) = layout_target(s, p, "master.deleteLayout")?; - s.edit(|doc, sel| { - let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; - if doc.slides.iter().any(|x| x.layout == id) { - return Err(bad("master.deleteLayout", "slides use this layout")); - } - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); - if m.layouts.len() <= 1 { - return Err(bad("master.deleteLayout", "a master needs at least one layout")); - } - m.layouts.remove(li); - sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; - Ok(()) - })?; - ok() -} - -fn insert_placeholder(s: &mut Session, p: &Value) -> Result { - let kind = match str_param(p, "kind").unwrap_or("content") { - "text" => PhType::Body, - "picture" => PhType::Picture, - "chart" => PhType::Chart, - "table" => PhType::Table, - "media" => PhType::Media, - _ => PhType::Obj, - }; - if !matches!(s.doc()?.selection.target, Target::Layout { .. }) { - return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view")); - } - let size = s.doc()?.doc.slide_size; - let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); - let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; - let shape = Shape { - xfrm: Some(rect), - ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), - text: Some(TextBody::default()), - ..Default::default() - }; - let id = super::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; - Ok(json!({"id": id})) -} - -fn preserve(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let (on_master, mi, li, current) = match st.selection.target { - Target::Master { master } => { - let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; - (true, master, 0, on) - } - Target::Layout { master, layout } => { - let on = st - .doc - .masters - .get(master) - .and_then(|m| m.layouts.get(layout)) - .map(|l| l.preserve) - .ok_or_else(|| bad("master.preserve", "no such layout"))?; - (false, master, layout, on) - } - Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")), - }; - let next = bool_param(p, "preserve").unwrap_or(!current); - if next == current { - return Ok(json!({"preserve": next})); - } - s.edit(|doc, _| { - if on_master { - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); - m.preserve = next; - } else { - let l = - doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; - l.preserve = next; - } - Ok(()) - })?; - Ok(json!({"preserve": next})) -} - -fn placeholders(s: &mut Session, p: &Value) -> Result { - let title = bool_param(p, "title"); - let footers = bool_param(p, "footers"); - if title.is_none() && footers.is_none() { - return Err(bad("master.placeholders", "give title or footers")); - } - let st = s.doc()?; - if matches!(st.selection.target, Target::Slides) { - return Err(bad("master.placeholders", "open Slide Master view")); - } - let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); - let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); - let title_changes = title.is_some_and(|want| want != has_title); - let footer_changes = footers.is_some_and(|want| want != has_footers); - if !title_changes && !footer_changes { - return Ok(json!({"title": has_title, "footers": has_footers})); - } - let mut add = Vec::new(); - if title == Some(true) && !has_title { - add.push(PhType::Title); - } - if footers == Some(true) && !has_footers { - add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); - } - let size = st.doc.slide_size; - s.edit(|doc, sel| { - let mut ids = Vec::with_capacity(add.len()); - for _ in 0..add.len() { - ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?)); - } - let prototypes = match sel.target { - Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), - Target::Slides => Vec::new(), - Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")), - }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; - if title == Some(false) { - shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); - } - if footers == Some(false) { - shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); - } - for (kind, id) in add.into_iter().zip(ids) { - let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); - let proto = prototypes.iter().find(|sh| match kind { - PhType::Title => sh.ph_type().is_some_and(PhType::is_title), - other => sh.ph_type() == Some(other), - }); - let shape = match proto { - Some(src) => retarget_placeholder(src.clone(), id, idx), - None => synthesized_placeholder(kind, id, idx, size), - }; - shapes.push(shape); - } - Ok(()) - })?; - let st = s.doc()?; - let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); - let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); - Ok(json!({"title": title, "footers": footers})) -} - -fn preferred_ph_idx(kind: PhType) -> u32 { - match kind { - PhType::Title | PhType::CtrTitle => 0, - PhType::Date => 10, - PhType::Footer => 11, - PhType::SlideNum => 12, - _ => 1, - } -} - -fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { - let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); - if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } -} - -/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide -/// number keeps its `‹#›` field. -fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { - sh.id = id; - if let Some(ph) = sh.ph.as_mut() { - ph.idx = idx; - } - let slide_num = sh.ph_type() == Some(PhType::SlideNum); - if slide_num { - let paras = vec![slide_number_paragraph()]; - if let Some(text) = sh.text.as_mut() { - text.paragraphs = paras; - } else { - sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); - } - } else if let Some(text) = sh.text.as_mut() { - text.paragraphs.clear(); - } - sh -} - -fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { - let (name, text) = match kind { - PhType::Date => ("Date Placeholder", TextBody::default()), - PhType::Footer => ("Footer Placeholder", TextBody::default()), - PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), - _ => ("Title", TextBody::default()), - }; - Shape { - id, - name: name.into(), - xfrm: Some(placeholder_rect(kind, size)), - ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), - text: Some(text), - ..Default::default() - } -} - -/// Standard placeholder boxes at 960×540, scaled to the slide size. -fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { - let sx = size.width / 960.0; - let sy = size.height / 540.0; - let (x, y, w, h) = match kind { - PhType::Date => (60.0, 500.0, 216.0, 28.0), - PhType::Footer => (318.0, 500.0, 324.0, 28.0), - PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), - _ => (60.0, 29.0, 840.0, 104.0), - }; - deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) -} - -fn slide_number_paragraph() -> deckcraft_model::Paragraph { - deckcraft_model::Paragraph { - runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], - ..Default::default() - } -} - #[cfg(test)] mod tests; #[cfg(test)] mod browse_theme_tests; - -#[cfg(test)] -mod header_footer_date_tests { - use deckcraft_model::PhType; - use deckcraft_model::text::RunKind; - use serde_json::json; - - use crate::Session; - - /// The date field of the Date placeholders of every slide: (saved text, field type). - fn dates(s: &Session) -> Vec<(String, Option)> { - let d = &s.doc().unwrap().doc; - d.slides - .iter() - .flat_map(|sl| sl.shapes.iter()) - .filter(|sh| sh.ph_type() == Some(PhType::Date)) - .flat_map(|sh| sh.text.iter()) - .flat_map(|t| t.paragraphs.iter()) - .flat_map(|p| p.runs.iter()) - .map(|r| { - let kind = match &r.kind { - RunKind::Field { field } => Some(field.clone()), - _ => None, - }; - (r.text.clone(), kind) - }) - .collect() - } - - fn sample(kind: &str) -> String { - let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); - deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") - } - - /// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field. - /// The clock is read before and after the command, so a second ticking over between the - /// command and the read cannot fail the comparison. Returns the run text. - fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String { - let before = sample(kind); - s.execute("design.headerFooter", ¶ms).unwrap(); - let after = sample(kind); - let got = dates(s); - assert_eq!(got.len(), 1, "{got:?}"); - assert_eq!(got[0].1.as_deref(), Some(kind)); - assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}"); - got[0].0.clone() - } - - /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` - /// existed for this and was never read: the placeholder was always `datetime1`, and a - /// `dateFormat` parameter was accepted and silently dropped. - #[test] - fn the_automatic_date_takes_the_format_it_is_given() { - let mut s = Session::with_new(); - - // Default: the short date, as before. - run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1"); - - // A format: the field type changes and the saved text is that format's. - run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5"); - assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); - - // A time format works the same way, and these two differ, so the format is really read. - let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); - assert_ne!(time, sample("datetime5")); - - // Asking again changes nothing (and does not rewrite the run). - let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); - assert_eq!(again, time); - - // A fixed date still wins: plain text, no field. - s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); - assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); - - // Back to automatic: the remembered format comes back, not datetime1. - run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11"); - - // An unknown format is refused rather than ignored, and changes nothing. - for bad in ["datetime0", "datetime14", "nonsense", ""] { - assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); - } - assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); - let got = dates(&s); - assert_eq!(got.len(), 1, "{got:?}"); - assert_eq!(got[0].1.as_deref(), Some("datetime11")); - } -} diff --git a/crates/engine/src/cmd/design/colors.rs b/crates/engine/src/cmd/design/colors.rs new file mode 100644 index 00000000..9ce4f544 --- /dev/null +++ b/crates/engine/src/cmd/design/colors.rs @@ -0,0 +1,255 @@ +use super::*; + +pub(super) fn colors(s: &mut Session, p: &Value) -> Result { + let name_v = p.get("name"); + let colors_v = p.get("colors"); + if name_v.is_none() && colors_v.is_none() { + let sc = current_colors(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.colors", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.colors", "`name` must be a string")), + }; + let slots = parse_color_slots(colors_v)?; + let current = current_colors(s)?; + let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; + if next == current { + return ok(); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); + m.theme.colors = next; + Ok(()) + })?; + ok() +} + +pub(super) fn variant(s: &mut Session, p: &Value) -> Result { + let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); + let v = deckcraft_model::theme::theme_variants(&m.theme); + m.theme.colors = + v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; + Ok(()) + })?; + Ok(json!({"variant": index})) +} + +pub(super) fn variants(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); + let v = deckcraft_model::theme::theme_variants(&th); + let current = v.iter().position(|c| c.colors == th.colors.colors); + Ok(json!({ + "theme": th.name, + "current": current, + "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), + })) +} + +fn current_colors(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) +} + +fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { + let mut colors = serde_json::Map::new(); + for slot in deckcraft_color::SchemeSlot::THEME { + colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); + } + json!({"name": sc.name, "colors": colors}) +} + +/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such +/// as `bg1` are rejected because `ColorScheme::set` ignores them. +fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { + let Some(v) = colors_v else { return Ok(None) }; + let Some(map) = v.as_object() else { + return Err(bad("design.colors", "`colors` must be an object")); + }; + if map.is_empty() { + return Err(bad("design.colors", "`colors` is empty")); + } + let mut slots = Vec::with_capacity(map.len()); + for (k, v) in map { + let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { + return Err(bad("design.colors", format!("unknown colour `{k}`"))); + }; + if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { + return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); + } + let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { + return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); + }; + slots.push((slot, rgb)); + } + Ok(Some(slots)) +} + +/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name +/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. +fn next_scheme( + current: &deckcraft_color::ColorScheme, + given: Option<&str>, + slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { + next = builtin; + } else if slots.is_none() { + return Err(bad("design.colors", format!("no colour scheme `{name}`"))); + } + next.name = name.to_string(); + } + if let Some(changes) = slots { + for (slot, rgb) in changes { + next.set(*slot, *rgb); + } + if given.is_none() { + next.name = "Custom".into(); + } + } + Ok(next) +} + +pub(super) fn fonts(s: &mut Session, p: &Value) -> Result { + let name_v = p.get("name"); + let major_v = p.get("major"); + let minor_v = p.get("minor"); + if name_v.is_none() && major_v.is_none() && minor_v.is_none() { + let scheme = current_fonts(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.fonts", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.fonts", "`name` must be a string")), + }; + let major = face_param("major", major_v)?; + let minor = face_param("minor", minor_v)?; + let current = current_fonts(s)?; + let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?; + if next == current { + return ok(); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next; + Ok(()) + })?; + ok() +} + +fn current_fonts(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master")) +} + +fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value { + json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin}) +} + +/// `None` when the key is absent. A present face must be a non-blank string. +fn face_param(key: &str, v: Option<&Value>) -> Result> { + match v { + None => Ok(None), + Some(Value::String(s)) => { + let s = s.trim(); + if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) } + } + Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))), + } +} + +/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East +/// Asian, complex-script and per-script slots stay). The stored name is the given string when +/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name. +/// Faces without a name are a custom scheme: an omitted `minor` copies `major`. +fn next_font_scheme( + current: &deckcraft_model::theme::FontScheme, + given: Option<&str>, + major: Option<&str>, + minor: Option<&str>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) { + next = builtin; + true + } else { + false + }; + if !replaced && major.is_none() && minor.is_none() { + return Err(bad("design.fonts", format!("no font scheme `{name}`"))); + } + if major.is_some() || minor.is_some() || !replaced { + next.name = name.to_string(); + } + } + if given.is_none() { + if let Some(face) = major { + next.major.latin = face.to_string(); + if minor.is_none() { + next.minor.latin = face.to_string(); + } + } + if let Some(face) = minor { + next.minor.latin = face.to_string(); + } + next.name = "Custom".into(); + } else { + if let Some(face) = major { + next.major.latin = face.to_string(); + } + if let Some(face) = minor { + next.minor.latin = face.to_string(); + } + } + Ok(next) +} + +pub(super) fn effects(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?; + let scheme = builtin_format_schemes() + .into_iter() + .find(|f| f.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?; + let stored = scheme.name.clone(); + let same = { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme) + }; + if same { + return Ok(json!({"name": stored})); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme; + Ok(()) + })?; + Ok(json!({"name": stored})) +} diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs new file mode 100644 index 00000000..f72aa22d --- /dev/null +++ b/crates/engine/src/cmd/design/master_view.rs @@ -0,0 +1,498 @@ +use super::*; + +pub(super) fn master_view(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); + // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects + // that layout. With neither key, follow the current slide's layout. + let layout = if p.get("master").is_some() && p.get("layout").is_none() { + None + } else { + usize_param(p, "layout") + .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) + }; + let target = match layout { + Some(l) => Target::Layout { master: mi, layout: l }, + None => Target::Master { master: mi }, + }; + s.select(|_, sel| { + sel.target = target; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(serde_json::to_value(target).unwrap_or_default()) +} + +pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) } +} + +/// The four handout-master placeholders PowerPoint offers, in a page of `size`. +pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + let (name, idx, x, y) = match kind { + PhType::Header => ("Header Placeholder", 0, 0.0, 0.0), + PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0), + PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95), + _ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95), + }; + let mut text = TextBody::default(); + if let Some(p) = text.paragraphs.first_mut() + && matches!(kind, PhType::Date | PhType::SlideNum) + { + p.props.align = Some(deckcraft_model::text::Align::Right); + } + Shape { + id, + name: format!("{name} {}", idx + 1), + xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)), + ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +const HANDOUT_KINDS: [(&str, PhType); 4] = + [("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)]; + +pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.handout_master.is_none(); + if created { + s.edit(|doc, _| { + let mut m = deckcraft_model::Master { + name: "Handout Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }; + let size = doc.notes_size; + for (_, k) in HANDOUT_KINDS { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?); + m.shapes.push(handout_placeholder(k, size, id)); + } + doc.handout_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::Handout; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let mut ids = vec![]; + for (key, _) in HANDOUT_KINDS { + let id = match p.get(key).and_then(Value::as_bool) { + Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)), + None => None, + }; + ids.push(id); + } + let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?); + for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) { + let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(handout_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.handout_master.clone(); + let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k))); + Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) +} + +pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } +} + +const NOTES_KINDS: [(&str, PhType); 6] = [ + ("header", PhType::Header), + ("date", PhType::Date), + ("slideImage", PhType::SlideImage), + ("body", PhType::Body), + ("footer", PhType::Footer), + ("pageNumber", PhType::SlideNum), +]; + +/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. +pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + match kind { + PhType::SlideImage => Shape { + id, + name: "Slide Image Placeholder 3".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), + fill: Some(Fill::None), + line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), + ..Default::default() + }, + PhType::Body => { + let mut text = TextBody::default(); + text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; + Shape { + id, + name: "Notes Placeholder 4".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } + } + _ => { + let mut s = handout_placeholder(kind, size, id); + if let Some(ph) = s.ph.as_mut() { + ph.idx = match kind { + PhType::Header => 0, + PhType::Date => 1, + PhType::Footer => 4, + _ => 5, + }; + } + s + } + } +} + +pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); + if created { + s.edit(|doc, _| { + let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { + name: "Notes Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }); + let size = doc.notes_size; + for (_, k) in NOTES_KINDS { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?); + m.shapes.push(notes_placeholder(k, size, id)); + } + doc.notes_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::NotesMaster; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let ids: Vec = + NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; + let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); + for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { + let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(notes_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.notes_master.clone(); + let mut out = serde_json::Map::new(); + for (key, kind) in NOTES_KINDS { + out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); + } + Ok(Value::Object(out)) +} + +pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + sel.target = Target::Slides; + sel.shapes.clear(); + sel.text = None; + })?; + ok() +} + +pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("Custom Layout").to_string(); + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); + let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); + let size = doc.slide_size; + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); + let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { + t.id = tid; + t.text = Some(TextBody::default()); + t + }); + let _ = size; + let at = match sel.target { + Target::Layout { layout, .. } => layout + 1, + _ => m.layouts.len(), + }; + m.layouts.insert( + at.min(m.layouts.len()), + deckcraft_model::Layout { + id, + name, + kind: deckcraft_model::LayoutType::Custom, + shapes: title.into_iter().collect(), + background: None, + show_master_shapes: true, + preserve: true, + color_map: None, + raw_ext: None, + }, + ); + sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; + Ok(json!({"id": id})) + }) +} + +fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let st = s.doc()?; + let (m, l) = match st.selection.target { + Target::Layout { master, layout } => (master, layout), + _ => (master_index(&st.doc, &st.selection), 0), + }; + Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l))) +} + +pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p, "master.renameLayout")?; + let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); + s.edit(|doc, _| { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; + l.name = name; + Ok(()) + })?; + ok() +} + +pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p, "master.deleteLayout")?; + s.edit(|doc, sel| { + let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; + if doc.slides.iter().any(|x| x.layout == id) { + return Err(bad("master.deleteLayout", "slides use this layout")); + } + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); + if m.layouts.len() <= 1 { + return Err(bad("master.deleteLayout", "a master needs at least one layout")); + } + m.layouts.remove(li); + sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; + Ok(()) + })?; + ok() +} + +pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result { + let kind = match str_param(p, "kind").unwrap_or("content") { + "text" => PhType::Body, + "picture" => PhType::Picture, + "chart" => PhType::Chart, + "table" => PhType::Table, + "media" => PhType::Media, + _ => PhType::Obj, + }; + if !matches!(s.doc()?.selection.target, Target::Layout { .. }) { + return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view")); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); + let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; + let shape = Shape { + xfrm: Some(rect), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(TextBody::default()), + ..Default::default() + }; + let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; + Ok(json!({"id": id})) +} + +pub(super) fn preserve(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let (on_master, mi, li, current) = match st.selection.target { + Target::Master { master } => { + let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; + (true, master, 0, on) + } + Target::Layout { master, layout } => { + let on = st + .doc + .masters + .get(master) + .and_then(|m| m.layouts.get(layout)) + .map(|l| l.preserve) + .ok_or_else(|| bad("master.preserve", "no such layout"))?; + (false, master, layout, on) + } + Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")), + }; + let next = bool_param(p, "preserve").unwrap_or(!current); + if next == current { + return Ok(json!({"preserve": next})); + } + s.edit(|doc, _| { + if on_master { + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); + m.preserve = next; + } else { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; + l.preserve = next; + } + Ok(()) + })?; + Ok(json!({"preserve": next})) +} + +pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result { + let title = bool_param(p, "title"); + let footers = bool_param(p, "footers"); + if title.is_none() && footers.is_none() { + return Err(bad("master.placeholders", "give title or footers")); + } + let st = s.doc()?; + if matches!(st.selection.target, Target::Slides) { + return Err(bad("master.placeholders", "open Slide Master view")); + } + let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + let title_changes = title.is_some_and(|want| want != has_title); + let footer_changes = footers.is_some_and(|want| want != has_footers); + if !title_changes && !footer_changes { + return Ok(json!({"title": has_title, "footers": has_footers})); + } + let mut add = Vec::new(); + if title == Some(true) && !has_title { + add.push(PhType::Title); + } + if footers == Some(true) && !has_footers { + add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); + } + let size = st.doc.slide_size; + s.edit(|doc, sel| { + let mut ids = Vec::with_capacity(add.len()); + for _ in 0..add.len() { + ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?)); + } + let prototypes = match sel.target { + Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), + Target::Slides => Vec::new(), + Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")), + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; + if title == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + } + if footers == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); + } + for (kind, id) in add.into_iter().zip(ids) { + let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); + let proto = prototypes.iter().find(|sh| match kind { + PhType::Title => sh.ph_type().is_some_and(PhType::is_title), + other => sh.ph_type() == Some(other), + }); + let shape = match proto { + Some(src) => retarget_placeholder(src.clone(), id, idx), + None => synthesized_placeholder(kind, id, idx, size), + }; + shapes.push(shape); + } + Ok(()) + })?; + let st = s.doc()?; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + Ok(json!({"title": title, "footers": footers})) +} + +fn preferred_ph_idx(kind: PhType) -> u32 { + match kind { + PhType::Title | PhType::CtrTitle => 0, + PhType::Date => 10, + PhType::Footer => 11, + PhType::SlideNum => 12, + _ => 1, + } +} + +fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { + let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); + if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } +} + +/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide +/// number keeps its `‹#›` field. +fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { + sh.id = id; + if let Some(ph) = sh.ph.as_mut() { + ph.idx = idx; + } + let slide_num = sh.ph_type() == Some(PhType::SlideNum); + if slide_num { + let paras = vec![slide_number_paragraph()]; + if let Some(text) = sh.text.as_mut() { + text.paragraphs = paras; + } else { + sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); + } + } else if let Some(text) = sh.text.as_mut() { + text.paragraphs.clear(); + } + sh +} + +fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { + let (name, text) = match kind { + PhType::Date => ("Date Placeholder", TextBody::default()), + PhType::Footer => ("Footer Placeholder", TextBody::default()), + PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), + _ => ("Title", TextBody::default()), + }; + Shape { + id, + name: name.into(), + xfrm: Some(placeholder_rect(kind, size)), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +/// Standard placeholder boxes at 960×540, scaled to the slide size. +fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { + let sx = size.width / 960.0; + let sy = size.height / 540.0; + let (x, y, w, h) = match kind { + PhType::Date => (60.0, 500.0, 216.0, 28.0), + PhType::Footer => (318.0, 500.0, 324.0, 28.0), + PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), + _ => (60.0, 29.0, 840.0, 104.0), + }; + deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) +} + +fn slide_number_paragraph() -> deckcraft_model::Paragraph { + deckcraft_model::Paragraph { + runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], + ..Default::default() + } +} diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs new file mode 100644 index 00000000..39205a34 --- /dev/null +++ b/crates/engine/src/cmd/design/slide_background.rs @@ -0,0 +1,298 @@ +use super::*; + +pub(super) fn background(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "copy", false) { + return copy_slide_background(s); + } + let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); + let transparency = f64_param(p, "transparency"); + let reset = bool_or(p, "reset", false); + let color = color_param(p, "color"); + let style = usize_param(p, "style"); + let op = if reset { + BgOp::Set(None) + } else if let Some(mut c) = color { + if let Some(t) = transparency { + set_solid_transparency(&mut c, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) + } else if let Some(i) = style { + // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). + let slots = + [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; + let i = i.clamp(1, 12) - 1; + BgOp::Set(Some(Background::Ref { + idx: 1001 + (i / 4) as u32, + color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), + })) + } else if let Some(g) = p.get("gradient") { + let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; + g.rotate_with_shape = false; + BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) + } else if let Some(pt) = p.get("pattern") { + BgOp::Set(Some(Background::Fill { + fill: Fill::Pattern(deckcraft_model::style::PatternFill { + preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), + fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), + }), + })) + } else if p.get("picture").is_some() { + let (name, bytes) = + crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; + let ct = crate::cmd::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?; + let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; + if let Some(pp) = &props { + apply_picture_props(&mut pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(&mut pf, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) + } else if props.is_some() || transparency.is_some() { + BgOp::Adjust + } else { + return Err(bad( + "design.background", + "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", + )); + }; + let all = bool_or(p, "all", false); + let index = usize_param(p, "index"); + if matches!(op, BgOp::Adjust) { + let next = { + let st = s.doc()?; + let current = background_of(&st.doc, &st.selection, index); + let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); + let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); + if props.is_some() && !picture { + return Err(bad("design.background", "picture options need a picture background")); + } + if transparency.is_some() && !picture && !solid { + return Err(bad("design.background", "transparency needs a solid or picture background")); + } + let mut next = current.clone(); + if let Some(bg) = next.as_mut() { + adjust_background(bg, props.as_ref(), transparency); + } + if next == current { None } else { Some(next) } + }; + let Some(next) = next else { return ok() }; + s.edit(|doc, sel| { + write_background(doc, sel, index, false, next); + Ok(()) + })?; + return ok(); + } + let BgOp::Set(bg) = op else { return ok() }; + let unchanged = { + let st = s.doc()?; + backgrounds_already(&st.doc, &st.selection, index, all, &bg) + }; + if unchanged { + return ok(); + } + s.edit(|doc, sel| { + write_background(doc, sel, index, all, bg); + Ok(()) + })?; + ok() +} + +enum BgOp { + Set(Option), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + match sel.target { + Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()), + Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), + } +} + +fn used_master_indices(doc: &Presentation) -> BTreeSet { + let fallback = (!doc.masters.is_empty()).then_some(0); + doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect() +} + +fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { + match sel.target { + Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg), + Target::Slides => { + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)); + if all && bg.is_some() { + let masters = used_master_indices(doc); + let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg)); + let layouts_match = !masters.is_empty() + && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none()))); + slides_match && masters_match && layouts_match + } else { + slides_match + } + } + } +} + +fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { + match sel.target { + Target::Master { master } => { + if let Some(m) = doc.masters.get_mut(master) { + Arc::make_mut(m).background = bg; + } + } + Target::Layout { master, layout } => { + if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { + l.background = bg; + } + } + t @ (Target::Handout | Target::NotesMaster) => { + if let Some(m) = crate::page_master_mut(doc, t) { + Arc::make_mut(m).background = bg; + } + } + Target::Slides => { + if all && bg.is_some() { + for idx in used_master_indices(doc) { + if let Some(m) = doc.masters.get_mut(idx) { + let m = Arc::make_mut(m); + m.background = bg.clone(); + for l in &mut m.layouts { + l.background = None; + } + } + } + } + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).background = bg.clone(); + } + } + } + } +} + +fn copy_slide_background(s: &mut Session) -> Result { + let bg = { + let st = s.doc()?; + if !matches!(st.selection.target, Target::Slides) { + return Err(bad("design.background", "copy applies to slides")); + } + let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); + let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg); + let masters_match = if bg.is_some() { + let masters = used_master_indices(&st.doc); + !masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg)) + } else { + true + }; + if slides_match && masters_match { + return ok(); + } + bg + }; + s.edit(|doc, sel| { + write_background(doc, sel, None, true, bg); + Ok(()) + })?; + ok() +} + +fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { + match bg { + Background::Fill { fill: Fill::Picture(pf) } => { + if let Some(pp) = props { + apply_picture_props(pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(pf, t); + } + } + Background::Fill { fill: Fill::Solid { color } } => { + if let Some(t) = transparency { + set_solid_transparency(color, t); + } + } + _ => {} + } +} + +fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); +} + +fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { + color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); + let t = t.clamp(0.0, 1.0); + if t > 0.0 { + color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); + } +} + +/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). +fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { + use deckcraft_model::style::PictureMode; + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + +pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + if let Some(sl) = doc.slides.get_mut(sel.slide) { + let sl = Arc::make_mut(sl); + sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/design/slide_setup.rs b/crates/engine/src/cmd/design/slide_setup.rs new file mode 100644 index 00000000..672a73aa --- /dev/null +++ b/crates/engine/src/cmd/design/slide_setup.rs @@ -0,0 +1,336 @@ +use super::*; + +pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + x.x = x.x * sx + ox; + x.y = x.y * sy + oy; + x.w *= sx; + x.h *= sy; + } + if let Some(t) = sh.text.as_mut() { + deckcraft_model::edit::format_all(t, &|r| { + if let Some(sz) = r.size.as_mut() { + *sz = (*sz * k).max(1.0); + } + }); + } + } +} + +pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result { + let size = if let Some(pre) = str_param(p, "preset") { + let k = pre.to_ascii_lowercase(); + let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k)); + let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?; + Size::new(*w, *h) + } else { + Size::new( + f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0), + f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0), + ) + }; + let size = match str_param(p, "orientation") { + Some("portrait") if size.width > size.height => Size::new(size.height, size.width), + Some("landscape") if size.height > size.width => Size::new(size.height, size.width), + Some("portrait" | "landscape") | None => size, + Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))), + }; + let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); + s.edit(|doc, _| { + let old = doc.slide_size; + let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + let (sx, sy, k, ox, oy) = match mode.as_str() { + "none" => (1.0, 1.0, 1.0, 0.0, 0.0), + "maximize" => { + let k = sx.max(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + _ => { + let k = sx.min(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + }; + // Masters and layouts stretch to the new size; slides scale by mode. + let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + for m in &mut doc.masters { + let m = Arc::make_mut(m); + scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); + for l in &mut m.layouts { + scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); + } + } + for sl in &mut doc.slides { + scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); + } + doc.slide_size = size; + Ok(()) + })?; + Ok(json!({"w": size.width, "h": size.height})) +} + +pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + let hf = &mut doc.header_footer; + if let Some(v) = bool_param(p, "date") { + hf.date = v; + } + if let Some(v) = str_param(p, "dateText") { + hf.date_text = v.into(); + } + if let Some(v) = str_param(p, "dateFormat") { + // Refused rather than ignored: a format that renders nothing would leave the + // placeholder showing its saved text for ever. + if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { + return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); + } + hf.date_format = v.into(); + } + if let Some(v) = bool_param(p, "slideNumber") { + hf.slide_number = v; + } + if let Some(v) = bool_param(p, "footer") { + hf.footer = v; + } + if let Some(v) = str_param(p, "footerText") { + hf.footer_text = v.into(); + hf.footer = hf.footer || !v.is_empty(); + } + if let Some(v) = bool_param(p, "hideOnTitle") { + hf.hide_on_title = v; + } + let hf = hf.clone(); + let date_given = str_param(p, "dateText").is_some(); + let format_given = str_param(p, "dateFormat").is_some(); + let all = bool_or(p, "all", true); + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; + for i in list { + let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; + let lay = doc.layout(layout).map(|(_, l)| l.clone()); + let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title); + let want = |k: PhType| { + !(hf.hide_on_title && is_title) + && match k { + PhType::Date => hf.date, + PhType::SlideNum => hf.slide_number, + PhType::Footer => hf.footer, + _ => false, + } + }; + let mut new_ids = vec![]; + for _ in 0..3 { + new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?)); + } + let Some(sl) = doc.slides.get_mut(i) else { continue }; + let sl = Arc::make_mut(sl); + for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { + let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); + match (has, want(k)) { + (Some(pos), false) => { + sl.shapes.remove(pos); + } + (None, true) => { + let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); + let Some(lp) = lp else { continue }; + let body = match k { + PhType::SlideNum => TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![Run { + text: "‹#›".into(), + props: Default::default(), + kind: RunKind::Field { field: "slidenum".into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date if hf.date_text.is_empty() => TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![Run { + text: date_now(&hf.date_format), + props: Default::default(), + kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date => TextBody::from_text(&hf.date_text), + _ => TextBody::from_text(&hf.footer_text), + }; + sl.shapes.push(Shape { + id: new_ids.get(n).copied().unwrap_or_default(), + name: lp.name.clone(), + ph: lp.ph.clone(), + text: Some(body), + ..Default::default() + }); + } + (Some(pos), true) if k == PhType::Footer => { + if let Some(sh) = sl.shapes.get_mut(pos) { + sh.text = Some(TextBody::from_text(&hf.footer_text)); + } + } + (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { + if let Some(sh) = sl.shapes.get_mut(pos) { + set_date(sh, &hf.date_text, &hf.date_format); + } + } + _ => {} + } + } + } + Ok(()) + })?; + ok() +} + +/// The date field type `format` names, `datetime1` when it is empty or unknown. +fn date_kind(format: &str) -> &str { + if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } +} + +/// The saved text of a date field of `format`, as it shows now. +fn date_now(format: &str) -> String { + let kind = date_kind(format); + deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) +} + +/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty +/// `fixed`, at an automatic date field (one it already has keeps its format). +fn set_date(sh: &mut Shape, fixed: &str, format: &str) { + let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); + let field_now = runs().find_map(|r| match &r.kind { + RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), + _ => None, + }); + let has_field = field_now.is_some(); + let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); + let want = date_kind(format); + let (text, kind) = match fixed { + // Automatic: keep the field, but follow a format that has changed. + "" if field_now.as_deref() == Some(want) => return, + "" if has_field && format.is_empty() => return, + "" => (date_now(format), RunKind::Field { field: want.into() }), + _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, + _ => (fixed.to_string(), RunKind::Text), + }; + let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() }; + sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() }); +} + +/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date +/// field. Empty where there is no clock (wasm). +pub fn today() -> String { + date_field_text("datetime1") +} + +/// What date field `kind` shows now (empty if unknown or there is no clock). +pub fn date_field_text(kind: &str) -> String { + use deckcraft_text::datetime::{DateTime, field_text}; + DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() +} + +/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. +pub fn civil(z: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 + // years (146_097 days). chrono converts the day within its cycle and whole cycles go back + // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and + // 0..146_097 days from it always fits. + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), + } +} + +#[cfg(test)] +mod header_footer_date_tests { + use deckcraft_model::PhType; + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::Session; + + /// The date field of the Date placeholders of every slide: (saved text, field type). + fn dates(s: &Session) -> Vec<(String, Option)> { + let d = &s.doc().unwrap().doc; + d.slides + .iter() + .flat_map(|sl| sl.shapes.iter()) + .filter(|sh| sh.ph_type() == Some(PhType::Date)) + .flat_map(|sh| sh.text.iter()) + .flat_map(|t| t.paragraphs.iter()) + .flat_map(|p| p.runs.iter()) + .map(|r| { + let kind = match &r.kind { + RunKind::Field { field } => Some(field.clone()), + _ => None, + }; + (r.text.clone(), kind) + }) + .collect() + } + + fn sample(kind: &str) -> String { + let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); + deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") + } + + /// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field. + /// The clock is read before and after the command, so a second ticking over between the + /// command and the read cannot fail the comparison. Returns the run text. + fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String { + let before = sample(kind); + s.execute("design.headerFooter", ¶ms).unwrap(); + let after = sample(kind); + let got = dates(s); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].1.as_deref(), Some(kind)); + assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}"); + got[0].0.clone() + } + + /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` + /// existed for this and was never read: the placeholder was always `datetime1`, and a + /// `dateFormat` parameter was accepted and silently dropped. + #[test] + fn the_automatic_date_takes_the_format_it_is_given() { + let mut s = Session::with_new(); + + // Default: the short date, as before. + run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1"); + + // A format: the field type changes and the saved text is that format's. + run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5"); + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); + + // A time format works the same way, and these two differ, so the format is really read. + let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); + assert_ne!(time, sample("datetime5")); + + // Asking again changes nothing (and does not rewrite the run). + let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); + assert_eq!(again, time); + + // A fixed date still wins: plain text, no field. + s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); + + // Back to automatic: the remembered format comes back, not datetime1. + run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11"); + + // An unknown format is refused rather than ignored, and changes nothing. + for bad in ["datetime0", "datetime14", "nonsense", ""] { + assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); + let got = dates(&s); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].1.as_deref(), Some("datetime11")); + } +}