//! Design tab and masters: themes, colours, fonts, backgrounds, slide size, header & footer, //! Slide Master view. use std::collections::BTreeSet; use std::sync::Arc; use deckcraft_geom::Size; use deckcraft_model::text::{Run, RunKind, TextBody}; use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, builtin_themes}; use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults}; use serde_json::{Value, json}; use super::*; use crate::{Result, Session, Target}; pub fn specs() -> Vec { vec![ cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), cmd!( "design.browseTheme", "Browse for Themes…", ["Design", "Themes"], None, "{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file", has_doc, browse_theme ), cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes, font schemes and effect schemes", always, themes), cmd!( "design.colors", "Colors", ["Design", "Variants"], None, "{} opens Create New Theme Colors | {name} | {colors: {dk1|lt1|dk2|lt2|accent1..6|hlink|folHlink: #RRGGBB}}", has_doc, colors ), cmd!("design.variant", "Variants", ["Design", "Variants"], None, "{index: 0..3} (see design.variants)", has_doc, variant), cmd!(query "design.variants", "Variant Gallery", [], None, "{} → the current theme's four colour variants and which is applied", has_doc, variants), cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{} opens Create New Theme Fonts | {name} | {major, minor}", has_doc, fonts), cmd!("design.effects", "Effects", ["Design", "Variants"], None, "{name} (see design.themes)", has_doc, effects), cmd!( "design.background", "Format Background", ["Design", "Customize"], None, "{color? | gradient? | picture?: base64 | pattern? | style?: 1..12 | reset?, all?: bool, index?, transparency?: 0..1, pictureProps?: {tile?, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0.01..10, align?, mirror?, stretch?: [l,t,r,b], rotateWithShape?}, copy?: bool}", has_slide, background ), cmd!("design.hideBackgroundGraphics", "Hide Background Graphics", ["Format Background"], None, "{hide?: bool}", has_slide, hide_bg_graphics), cmd!( "design.slideSize", "Slide Size", ["Design", "Customize"], None, "{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}", has_doc, slide_size ), cmd!( "design.headerFooter", "Header and Footer…", ["Insert", "Text"], None, "{date?: bool, dateText?: fixed text, slideNumber?: bool, footer?: bool, footerText?, hideOnTitle?: bool, all?: bool (default true)}", has_doc, header_footer ), cmd!(noundo "view.slideMaster", "Slide Master", ["View", "Master Views"], None, "{master?: index, layout?: index}", has_doc, master_view), cmd!( "view.handoutMaster", "Handout Master", ["View", "Master Views"], None, "{} (creates the handout master with header, date, footer and page number if the deck has none)", has_doc, handout_view ), cmd!( "handout.placeholders", "Handout Placeholders", ["Handout Master", "Placeholders"], None, "{header?: bool, date?: bool, footer?: bool, pageNumber?: bool} (show or hide each)", in_handout, handout_placeholders ), cmd!( "view.notesMaster", "Notes Master", ["View", "Master Views"], None, "{} (creates the notes master with header, date, slide image, notes body, footer and page number if the deck has none)", has_doc, notes_view ), cmd!( "notes.placeholders", "Notes Placeholders", ["Notes Master", "Placeholders"], None, "{header?, date?, slideImage?, body?, footer?, pageNumber?: bool} (show or hide each)", in_notes_master, notes_placeholders ), cmd!(noundo "view.closeMaster", "Close Master View", ["Slide Master", "Close"], None, "{}", has_doc, close_master), cmd!("master.insertLayout", "Insert Layout", ["Slide Master", "Edit Master"], None, "{name?}", has_doc, insert_layout), cmd!("master.renameLayout", "Rename Layout", ["Slide Master", "Edit Master"], None, "{name, master?, layout?}", has_doc, rename_layout), cmd!("master.deleteLayout", "Delete Layout", ["Slide Master", "Edit Master"], None, "{master?, layout?}", has_doc, delete_layout), cmd!( "master.insertPlaceholder", "Insert Placeholder", ["Slide Master", "Master Layout"], None, "{kind: content|text|picture|chart|table|media, rect?: [x,y,w,h]}", has_doc, insert_placeholder ), cmd!("master.preserve", "Preserve", ["Slide Master", "Edit Master"], None, "{preserve?: bool}", has_doc, preserve), cmd!( "master.placeholders", "Layout Placeholders", ["Slide Master", "Edit Master"], None, "{title?: bool, footers?: bool}", has_doc, placeholders ), ] } fn theme(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("design.theme", "missing `name`"))?; let t = builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad("design.theme", format!("no theme `{name}`")))?; s.edit(|doc, sel| { let mi = master_index(doc, sel); let m = doc.masters.get_mut(mi).ok_or_else(|| bad("design.theme", "no master"))?; let m = Arc::make_mut(m); m.theme = t.clone(); m.name = t.name.clone(); m.color_map = vec![]; Ok(()) })?; Ok(json!({"theme": name})) } fn browse_theme(s: &mut Session, p: &Value) -> Result { let bytes = if let Some(data) = str_param(p, "data") { super::base64_decode(data).ok_or_else(|| bad("design.browseTheme", "invalid base64 `data`"))? } else { #[cfg(not(target_arch = "wasm32"))] { let path = str_param(p, "path").ok_or_else(|| bad("design.browseTheme", "give `path` or `data`"))?; crate::io::read_file(path).map_err(crate::EngineError::Other)? } #[cfg(target_arch = "wasm32")] return Err(bad("design.browseTheme", "give `data`")); }; if !deckcraft_pptx::sniff(&bytes) { return Err(bad("design.browseTheme", "not a theme or PowerPoint file")); } let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?; let name = theme.name.clone(); s.edit(|doc, sel| { // The theme's pictures (format-scheme blip fills) get ids of this deck. let mut map = std::collections::HashMap::new(); for m in &media { let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("design.browseTheme", NO_ID_SPACE))?; map.insert(m.id.0, id.0); } let theme = if map.is_empty() { theme.clone() } else { remap_media_ids(&theme, &map)? }; let mi = master_index(doc, sel); let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.browseTheme", "no master"))?); m.theme = theme; m.color_map = vec![]; Ok(()) })?; Ok(json!({"theme": name})) } /// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills). fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap) -> Result { fn rec(v: &mut Value, map: &std::collections::HashMap, depth: usize) { if depth > 64 { return; } match v { Value::Object(o) => { for (k, x) in o.iter_mut() { if k == "media" && let Some(n) = x.as_u64().and_then(|n| u32::try_from(n).ok()) && let Some(new) = map.get(&n) { *x = json!(new); } else { rec(x, map, depth + 1); } } } Value::Array(a) => a.iter_mut().for_each(|x| rec(x, map, depth + 1)), _ => {} } } let mut v = serde_json::to_value(theme).map_err(|e| bad("design.browseTheme", e.to_string()))?; rec(&mut v, map, 0); serde_json::from_value(v).map_err(|e| bad("design.browseTheme", e.to_string())) } fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize { match sel.target { Target::Master { master } | Target::Layout { master, .. } => master, Target::Slides | Target::Handout | Target::NotesMaster => { doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0) } } } fn themes(_s: &mut Session, _p: &Value) -> Result { Ok(json!({ "themes": builtin_themes().iter().map(|t| json!({"name": t.name, "major": t.fonts.major.latin, "minor": t.fonts.minor.latin, "colors": t.colors.colors.iter().map(|c| format!("#{}", c.hex())).collect::>()})).collect::>(), "colorSchemes": builtin_color_schemes().iter().map(|c| c.name.clone()).collect::>(), "fontSchemes": builtin_font_schemes().iter().map(|f| json!({"name": f.name, "major": f.major.latin, "minor": f.minor.latin})).collect::>(), "effectSchemes": builtin_format_schemes().iter().map(|f| f.name.clone()).collect::>(), })) } 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) = 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 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: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".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 => { if let Some(sh) = sl.shapes.get_mut(pos) { set_date(sh, &hf.date_text); } } _ => {} } } } Ok(()) })?; ok() } /// 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) { let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field))); let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); let (text, kind) = match fixed { "" if has_field => return, "" => (today(), RunKind::Field { field: "datetime1".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) -> 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((usize_param(p, "master").unwrap_or(m), usize_param(p, "layout").unwrap_or(l))) } fn rename_layout(s: &mut Session, p: &Value) -> Result { let (mi, li) = layout_target(s, p)?; 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)?; 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 { use std::sync::Arc; use deckcraft_color::SchemeSlot; use deckcraft_model::style::PictureMode; use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes}; use deckcraft_model::{Background, Fill}; use serde_json::{Value, json}; use crate::{Session, UiRequest}; fn scheme(s: &Session) -> deckcraft_color::ColorScheme { s.doc().unwrap().doc.masters[0].theme.colors.clone() } fn font_scheme(s: &Session) -> FontScheme { s.doc().unwrap().doc.masters[0].theme.fonts.clone() } fn undo_len(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } fn png() -> String { let mut img = image::RgbaImage::new(2, 2); img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255])); let mut bytes = Vec::new(); image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); crate::cmd::base64_encode(&bytes) } fn picture(s: &Session) -> deckcraft_model::style::PictureFill { match &s.doc().unwrap().doc.slides[0].background { Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(), other => panic!("expected a picture background, got {other:?}"), } } #[test] fn civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); } #[test] fn colors_dialog_apply_and_reject() { let mut s = Session::with_new(); let original = scheme(&s); let n = undo_len(&s); s.execute("design.colors", &json!({})).unwrap(); s.execute("design.colors", &Value::Null).unwrap(); assert_eq!(scheme(&s), original); assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); assert_eq!(s.ui_requests.len(), 2); match &s.ui_requests[0] { UiRequest::Dialog { id, params } => { assert_eq!(id, "themeColors"); assert_eq!(params["name"], "DeckCraft"); let colors = params["colors"].as_object().unwrap(); assert_eq!(colors.len(), 12); assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex())); assert!(colors.contains_key("folHlink")); } other => panic!("expected a dialog, got {other:?}"), } s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap(); assert_eq!(scheme(&s).name, "Harbor"); assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); assert_eq!(undo_len(&s), n + 1); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); assert!(err.contains("no colour scheme `Nope`"), "{err}"); assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); assert!(s.execute("design.colors", &json!({"name": 1})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {}})).is_err()); assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); assert_eq!(scheme(&s).name, "Harbor"); assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); assert_eq!(undo_len(&s), n + 1); s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); assert_eq!(scheme(&s).name, "Custom"); assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233"); assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); assert_eq!(undo_len(&s), n + 2); let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); assert_eq!(scheme(&s).name, "Meadow"); assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); assert_eq!(undo_len(&s), n + 3); s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); assert_eq!(scheme(&s).name, "Talk"); assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(scheme(&s).name, "Meadow"); assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); } #[test] fn fonts_dialog_apply_and_reject() { let mut s = Session::with_new(); let original = font_scheme(&s); let n = undo_len(&s); s.execute("design.fonts", &json!({})).unwrap(); s.execute("design.fonts", &Value::Null).unwrap(); s.execute("design.fonts", &json!([1, 2])).unwrap(); assert_eq!(font_scheme(&s), original); assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); assert_eq!(s.ui_requests.len(), 3); match &s.ui_requests[0] { UiRequest::Dialog { id, params } => { assert_eq!(id, "themeFonts"); assert_eq!(params["name"], original.name); assert_eq!(params["major"], original.major.latin); assert_eq!(params["minor"], original.minor.latin); } other => panic!("expected a dialog, got {other:?}"), } s.execute("design.fonts", &json!({"name": "classic"})).unwrap(); let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap(); assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name"); assert_eq!(undo_len(&s), n + 1); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); s.execute("design.fonts", &json!({"name": "Classic"})).unwrap(); assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string(); assert!(err.contains("no font scheme `Nope`"), "{err}"); assert!(s.execute("design.fonts", &json!({"name": " "})).is_err()); assert!(s.execute("design.fonts", &json!({"name": 1})).is_err()); assert!(s.execute("design.fonts", &json!({"major": ""})).is_err()); assert!(s.execute("design.fonts", &json!({"major": " "})).is_err()); assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err()); assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err()); assert_eq!(font_scheme(&s), classic); assert_eq!(undo_len(&s), n + 1); s.edit(|doc, _| { let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts; fonts.major.ea = "EA Major".into(); fonts.minor.cs = "CS Minor".into(); fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() }); Ok(()) }) .unwrap(); let marked = font_scheme(&s); let n = undo_len(&s); s.execute("design.fonts", &json!({"major": " Lato "})).unwrap(); let custom = font_scheme(&s); assert_eq!(custom.name, "Custom"); assert_eq!(custom.major.latin, "Lato"); assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major"); assert_eq!(custom.major.ea, "EA Major"); assert_eq!(custom.minor.cs, "CS Minor"); assert_eq!(custom.major.scripts, marked.major.scripts); assert_eq!(undo_len(&s), n + 1); s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap(); assert_eq!(font_scheme(&s).name, "Custom"); assert_eq!(font_scheme(&s).major.latin, "Lato"); assert_eq!(font_scheme(&s).minor.latin, "Roboto"); assert_eq!(font_scheme(&s).major.ea, "EA Major"); assert_eq!(undo_len(&s), n + 2); s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap(); assert_eq!(font_scheme(&s).name, "Classic"); assert_eq!(font_scheme(&s).major.latin, "Inter"); assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); assert_eq!(font_scheme(&s).major.ea, ""); assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); assert_eq!(undo_len(&s), n + 3); s.edit(|doc, _| { std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into(); Ok(()) }) .unwrap(); s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap(); assert_eq!(font_scheme(&s).name, "Talk"); assert_eq!(font_scheme(&s).major.latin, "Inter"); assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans"); assert_eq!(font_scheme(&s).major.ea, "Kept"); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(font_scheme(&s).name, "Classic"); assert_eq!(font_scheme(&s).major.ea, "Kept"); assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); } #[test] fn background_picture_options_tile_transparency_and_copy() { let mut s = Session::with_new(); let n = undo_len(&s); let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string(); assert!(err.contains("picture background"), "{err}"); assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err()); assert_eq!(undo_len(&s), n); s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap(); match &s.doc().unwrap().doc.slides[0].background { Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()), other => panic!("expected a solid background, got {other:?}"), } let n = undo_len(&s); s.execute("design.background", &json!({"transparency": 0.25})).unwrap(); assert_eq!(undo_len(&s), n, "the same transparency is not an undo step"); s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap(); let pf = picture(&s); assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6); assert!(matches!(pf.mode, PictureMode::Tile { .. })); let n = undo_len(&s); s.execute( "design.background", &json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}), ) .unwrap(); match picture(&s).mode { PictureMode::Tile { tx, ty, sx, sy, flip, align } => { assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0)); assert_eq!(flip, "xy"); assert_eq!(align, "ctr"); } other => panic!("expected a tile, got {other:?}"), } assert!(picture(&s).upright); s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap(); match picture(&s).mode { PictureMode::Tile { tx, align, .. } => { assert_eq!(tx, 12.0); assert_eq!(align, "ctr"); } other => panic!("expected the tile to stay, got {other:?}"), } assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step"); s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap(); match picture(&s).mode { PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]), other => panic!("expected a stretch, got {other:?}"), } s.execute("slide.new", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.slides[1].background.is_none()); s.execute("slide.go", &json!({"index": 0})).unwrap(); let n = undo_len(&s); s.execute("design.background", &json!({"copy": true})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background); assert_eq!(undo_len(&s), n + 1); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background"); s.execute("design.background", &json!({"copy": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step"); } #[test] fn background_all_applies_to_master_and_new_slides_inherit() { let mut s = Session::with_new(); s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); assert_eq!(s0["background"], "#121826"); let n = undo_len(&s); s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1); // Idempotency: re-applying the same background to all is a no-op (no extra undo step) s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step"); // Slide 0 has new background, master has new background, layouts cleared let doc = &s.doc().unwrap().doc; assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. }))); assert_eq!(doc.masters[0].background, doc.slides[0].background); for l in &doc.masters[0].layouts { assert!(l.background.is_none()); } // Newly added slides inherit the master background (fixes #891) s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap(); let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap(); assert_eq!(s0_after["background"], "#0D0D0D"); assert_eq!(s1_after["background"], "#0D0D0D"); // Undo restores previous slide and master backgrounds s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new assert_eq!(s.doc().unwrap().doc.slides.len(), 1); s.execute("edit.undo", &json!({})).unwrap(); // undos design.background let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); assert_eq!(s0_reverted["background"], "#121826"); } #[test] fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() { let mut s = Session::with_new(); s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) }; s.edit(|doc, _| { let m = Arc::make_mut(&mut doc.masters[0]); m.layouts[0].background = Some(custom_layout_bg.clone()); Ok(()) }) .unwrap(); // Slide 0 follows master (background: None) assert!(s.doc().unwrap().doc.slides[0].background.is_none()); // Give Slide 1 an explicit override s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap(); assert!(s.doc().unwrap().doc.slides[1].background.is_some()); let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); assert!(orig_master_bg.is_some()); // Select slide 0 (which follows master) and apply copy to all s.execute("slide.go", &json!({"index": 0})).unwrap(); let n = undo_len(&s); s.execute("design.background", &json!({"copy": true})).unwrap(); assert_eq!(undo_len(&s), n + 1); // Slide 1 has been reset to None (follows master), slide 0 remains None let doc = &s.doc().unwrap().doc; assert!(doc.slides[0].background.is_none()); assert!(doc.slides[1].background.is_none()); // Master and layout backgrounds survived! assert_eq!(doc.masters[0].background, orig_master_bg); assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); // Idempotency: re-applying copy is a no-op s.execute("design.background", &json!({"copy": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op"); } #[test] fn background_reset_all_preserves_master_and_layout_backgrounds() { let mut s = Session::with_new(); s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) }; s.edit(|doc, _| { let m = Arc::make_mut(&mut doc.masters[0]); m.layouts[0].background = Some(custom_layout_bg.clone()); Ok(()) }) .unwrap(); let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); assert!(orig_master_bg.is_some()); // Set explicit backgrounds on both slides individually (master remains untouched) s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap(); s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap(); assert!(s.doc().unwrap().doc.slides[0].background.is_some()); assert!(s.doc().unwrap().doc.slides[1].background.is_some()); // Now perform reset with all let n = undo_len(&s); s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1); // Slides reset to None to follow master let doc = &s.doc().unwrap().doc; assert!(doc.slides[0].background.is_none()); assert!(doc.slides[1].background.is_none()); // Master and layout backgrounds survived! assert_eq!(doc.masters[0].background, orig_master_bg); assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); // Idempotency: re-running reset all is a no-op s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op"); } #[test] fn background_all_only_touches_used_masters() { let mut s = Session::with_new(); s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); // Add a second master to presentation let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) }; s.edit(|doc, _| { let mut m2 = (*doc.masters[0]).clone(); m2.id = deckcraft_model::MasterId(999); m2.background = Some(second_master_bg.clone()); doc.masters.push(Arc::new(m2)); Ok(()) }) .unwrap(); assert_eq!(s.doc().unwrap().doc.masters.len(), 2); // Slides only use master 0 (default layout) s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); let doc = &s.doc().unwrap().doc; // Master 0 updated assert_eq!(doc.masters[0].background, doc.slides[0].background); // Master 1 preserved! assert_eq!(doc.masters[1].background, Some(second_master_bg)); } #[test] fn background_all_clears_layout_overrides_even_if_slides_and_master_match() { let mut s = Session::with_new(); s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); // Set all slides and master to #AA0000 s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background); // Add a layout override on layout 0 let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) }; s.edit(|doc, _| { let m = Arc::make_mut(&mut doc.masters[0]); m.layouts[0].background = Some(custom_bg); Ok(()) }) .unwrap(); // Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override. let n = undo_len(&s); s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op"); let doc = &s.doc().unwrap().doc; assert!(doc.masters[0].layouts[0].background.is_none()); // Now that layout override is cleared, repeating is an actual no-op s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); } } #[cfg(test)] mod browse_theme_tests { use deckcraft_model::PhType; use deckcraft_model::text::RunKind; use serde_json::json; use crate::{Session, Target}; #[test] fn applies_the_theme_of_another_deck_and_undoes() { let mut other = Session::with_new(); other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap(); other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap(); let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); let before = s.doc().unwrap().doc.masters[0].theme.clone(); s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); let t = &s.doc().unwrap().doc.masters[0].theme; assert_eq!(t.fonts.major.latin, "Lato"); assert_eq!(t.fonts.minor.latin, "Source Sans 3"); assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456"); assert!(s.doc().unwrap().doc.validate().is_empty()); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.masters[0].theme, before); assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err()); assert!(s.execute("design.browseTheme", &json!({})).is_err()); } fn undo_len(s: &crate::Session) -> usize { s.doc().unwrap().history.undo.len() } #[test] fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() { let mut s = crate::Session::with_new(); let listed = s.execute("design.themes", &json!({})).unwrap(); let names: Vec = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]); let n = undo_len(&s); s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap(); assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step"); let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string(); assert!(err.contains("no effect scheme"), "{err}"); assert!(s.execute("design.effects", &json!({})).is_err()); assert_eq!(undo_len(&s), n); s.execute("design.theme", &json!({"name": "Harbor"})).unwrap(); let before = s.doc().unwrap().doc.masters[0].theme.clone(); s.execute("design.effects", &json!({"name": "soft"})).unwrap(); let theme = s.doc().unwrap().doc.masters[0].theme.clone(); assert_eq!(theme.name, "Harbor"); assert_eq!(theme.colors, before.colors); assert_eq!(theme.fonts, before.fonts); assert_eq!(theme.format.name, "Soft"); assert!(theme.format.effects[0].outer_shadow.is_some()); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects"); let n = undo_len(&s); s.execute("design.effects", &json!({"name": "Soft"})).unwrap(); assert_eq!(undo_len(&s), n, "the same scheme is not an undo step"); s.execute("design.effects", &json!({"name": "Glow"})).unwrap(); let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone(); assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6)); s.execute("design.effects", &json!({"name": "Plain"})).unwrap(); let plain = s.doc().unwrap().doc.masters[0].theme.format.clone(); assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none())); assert_eq!(plain.lines[2].width, Some(0.75)); } fn shapes(s: &Session) -> Vec { s.doc().unwrap().shapes().to_vec() } fn has_title(s: &Session) -> bool { shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)) } fn has_footers(s: &Session) -> bool { shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)) } #[test] fn preserve_and_placeholders_edit_the_open_master_or_layout() { for id in ["master.preserve", "master.placeholders"] { assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}"); } let mut s = Session::with_new(); let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); s.execute("view.slideMaster", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 }); s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 }); assert!(!s.doc().unwrap().doc.masters[0].preserve); let n = s.doc().unwrap().history.undo.len(); s.execute("master.preserve", &json!({"preserve": false})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step"); s.execute("master.preserve", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.masters[0].preserve); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); s.execute("edit.undo", &json!({})).unwrap(); assert!(!s.doc().unwrap().doc.masters[0].preserve); s.execute("master.placeholders", &json!({"footers": false})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); assert!(has_title(&s)); assert!(!has_footers(&s)); s.execute("edit.undo", &json!({})).unwrap(); assert!(has_footers(&s)); s.execute("view.closeMaster", &json!({})).unwrap(); let n = s.doc().unwrap().history.undo.len(); let err = s.execute("master.preserve", &json!({})).unwrap_err(); assert!(err.to_string().contains("open Slide Master view"), "{err}"); let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err(); assert!(err.to_string().contains("open Slide Master view"), "{err}"); let err = s.execute("master.placeholders", &json!({})).unwrap_err(); assert!(err.to_string().contains("give title or footers"), "{err}"); assert_eq!(s.doc().unwrap().history.undo.len(), n); s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); assert!(!has_title(&s)); assert!(has_footers(&s)); let n = s.doc().unwrap().history.undo.len(); s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state"); s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); assert!(has_title(&s)); assert!(!has_footers(&s)); let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); assert_eq!(title.name, "Title 1"); assert_eq!(title.ph.unwrap().idx, 0); assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); s.execute("edit.undo", &json!({})).unwrap(); assert!(!has_title(&s)); assert!(has_footers(&s)); s.execute("master.placeholders", &json!({"footers": false})).unwrap(); s.execute("master.placeholders", &json!({"footers": true})).unwrap(); let restored = shapes(&s); assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0]; assert_eq!(run.text, "‹#›"); assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum")); assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); s.execute("master.preserve", &json!({"preserve": false})).unwrap(); assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); s.edit(|doc, _| { let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); Ok(()) }) .unwrap(); s.execute("master.placeholders", &json!({"title": true})).unwrap(); let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); assert_eq!(title.name, "Title"); let box_ = title.xfrm.unwrap(); assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); } }