//! Insert tab: shapes, text boxes, pictures, tables, charts, audio, video, WordArt, fields. use deckcraft_color::SchemeSlot; use deckcraft_geom::Xfrm; use deckcraft_model::chart::ChartType; use deckcraft_model::style::PictureFill; use deckcraft_model::text::{AutoFit, RunKind, TextBody}; use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table}; use serde_json::{Value, json}; use super::*; use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ cmd!( "shape.insert", "Shapes", ["Insert", "Illustrations"], None, "{preset: rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets), rect: [x,y,w,h] pt, text?: string}", has_slide, insert_shape ), cmd!( "insert.icon", "Icons", ["Insert", "Illustrations"], None, "{name: star|arrow-right|… (see insert.icons), rect?: [x,y,w,h]}", has_slide, insert_icon ), cmd!(query "insert.icons", "Icon Gallery", [], None, "{} → [{name, label, category}]", always, list_icons), cmd!(query "shape.presets", "Shape Gallery", [], None, "{} → [{name, label, category}]", always, presets), cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{rect?: [x,y,w,h], text?: string}", has_slide, text_box), cmd!( "insert.picture", "Picture from File…", ["Insert", "Images", "Pictures"], None, "{path? | data: base64, name?, rect?: [x,y,w,h]}", has_slide, picture ), cmd!( "insert.photoAlbum", "Photo Album…", ["Insert", "Images"], None, "{pictures?: [{path?|data: base64, name?, rot?: deg, brightness?: -1..1, contrast?: -1..1} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool, grayscale?: bool, theme?: name} — omit pictures to open the dialog", has_doc, photo_album ), cmd!( "insert.photoAlbumEdit", "Edit Photo Album…", ["Insert", "Images"], None, "{album?: id, pictures?: same as insert.photoAlbum, layout?, frame?, captions?, grayscale?, theme?} — omit pictures to reopen the dialog on the current album", has_doc, photo_album_edit ), cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table), cmd!( "insert.chart", "Chart…", ["Insert", "Illustrations"], None, "{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}]}", has_slide, chart ), cmd!("insert.audio", "Audio from File…", ["Insert", "Media", "Audio"], None, "{path? | data: base64, name?, rect?}", has_slide, audio), cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video), cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art), cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), cmd!( "insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog", has_slide, date_time ), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), cmd!( "shape.action", "Action Settings", ["Insert", "Links"], None, "{on?: click|hover, action: none|next|previous|first|last|lastViewed|endShow|playMedia|slide|url|customShow|program, slide?: index, url?, show?: custom show name, path?, highlight?: bool, tooltip?, ids?}", has_selection, shape_action ), cmd!( "insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, highlightClick?, ids?}", has_text_or_shapes, hyperlink ), cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd), cmd!( "chart.data", "Edit Data", ["Chart Design", "Data"], None, "{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}", has_selection, chart_data ), cmd!( "chart.options", "Chart Elements", ["Chart Design", "Chart Layouts"], None, "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}", has_selection, chart_options ), cmd!( "insert.smartArt", "SmartArt", ["Insert", "Illustrations"], None, "{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}", has_slide, smart_art ), cmd!( "insert.actionButton", "Action Buttons", ["Insert", "Illustrations"], None, "{kind: back|forward|beginning|end|home|information|return|movie|document|sound|help|custom, rect?}", has_slide, action_button ), ] } /// Default size for a click-inserted shape (1 inch square, PowerPoint style). fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm { let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) } pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Result { s.edit(|doc, sel| { shape.id = ShapeId(doc.alloc_id()); if shape.name.is_empty() { let base = match &shape.kind { ShapeKind::Picture { .. } => "Picture".to_string(), ShapeKind::Table(_) => "Table".to_string(), ShapeKind::Chart(_) => "Chart".to_string(), ShapeKind::Media(m) if m.video => "Video".to_string(), ShapeKind::Media(_) => "Audio".to_string(), ShapeKind::Model3d { .. } => "3D Model".to_string(), _ if shape.text_box => "TextBox".to_string(), _ => shape .geom .preset_name() .and_then(deckcraft_geom::preset::info) .map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string()) .unwrap_or_else(|| "Shape".into()), }; let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1; shape.name = format!("{base} {n}"); } let id = shape.id; let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?; list.push(shape); if select { sel.shapes = vec![id]; sel.text = None; } Ok(id) }) } fn insert_shape(s: &mut Session, p: &Value) -> Result { let preset = str_param(p, "preset").unwrap_or("rect"); let info = deckcraft_geom::preset::info(preset).ok_or_else(|| bad("shape.insert", format!("unknown preset `{preset}`")))?; let line_like = deckcraft_geom::preset::is_line_like(preset); let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, if line_like { 0.0 } else { 72.0 })); let adj: Vec = p.get("adj").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let mut shape = Shape { xfrm: Some(rect), geom: Geom::Preset { name: info.name.into(), adj }, style: Some(if line_like { ShapeStyle::line(SchemeSlot::Accent1) } else { ShapeStyle::accent(SchemeSlot::Accent1) }), ..Default::default() }; if let Some(fl) = p.get("flip").and_then(Value::as_str) && let Some(x) = shape.xfrm.as_mut() { x.flip_h = fl.contains('h'); x.flip_v = fl.contains('v'); } if !line_like { shape.text = Some(TextBody { paragraphs: vec![], ..Default::default() }); if let Some(t) = str_param(p, "text") { shape.text = Some(TextBody::from_text(t)); } if let Some(tb) = shape.text.as_mut() { for para in &mut tb.paragraphs { para.props.align = Some(deckcraft_model::text::Align::Center); } } } else { shape.kind = if preset.contains("Connector") { ShapeKind::Connector { start: None, end: None } } else { ShapeKind::Shape }; if preset == "straightConnector1" { shape.line = Some(deckcraft_model::Line { tail: Some(deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }), ..Default::default() }); } } if let (Some(look), false) = (&s.default_look, line_like) { super::format::apply_painted(&mut shape, look); } let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } fn presets(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array( deckcraft_geom::preset::CATALOG.iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category.label()})).collect(), )) } fn list_icons(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(crate::icons::icons().iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category})).collect())) } /// A Lucide stroke icon: custom geometry, no fill, round line scaled from the 24-unit design. fn insert_icon(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("insert.icon", "missing `name`"))?; let icon = crate::icons::find(name).ok_or_else(|| bad("insert.icon", format!("unknown icon `{name}`")))?; let d = crate::icons::path_data(icon.name).ok_or_else(|| bad("insert.icon", format!("could not build icon `{}`", icon.name)))?; let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, 72.0)); if rect.w < 1.0 || rect.h < 1.0 { return Err(bad("insert.icon", "rect needs a positive width and height")); } let shape = Shape { name: icon.label.to_string(), xfrm: Some(rect), geom: Geom::Custom { paths: vec![deckcraft_model::CustomPath { w: crate::icons::VIEW, h: crate::icons::VIEW, d, fill: deckcraft_geom::preset::FillMode::None, stroke: true, }], }, fill: Some(Fill::None), line: Some(Line { fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), width: Some(crate::icons::stroke_width(icon, rect.w, rect.h)), cap: Some(deckcraft_model::style::LineCap::Round), join: Some(deckcraft_model::style::LineJoin::Round), ..Default::default() }), style: Some(ShapeStyle::line(SchemeSlot::Tx1)), ..Default::default() }; let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } pub(crate) fn new_text_box(rect: Xfrm, text: &str) -> Shape { let mut body = TextBody::from_text(text); body.body.wrap = Some(true); body.body.autofit = Some(AutoFit::Shape); Shape { xfrm: Some(rect), text_box: true, fill: None, text: Some(body), geom: Geom::preset("rect"), ..Default::default() } } fn text_box(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or(""); let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 216.0, 36.0)); let mut shape = new_text_box(rect, text); // Click-to-create (no width) text boxes grow sideways without wrapping. if rect.w < 1.0 { shape.xfrm = Some(Xfrm { w: 100.0, ..rect }); if let Some(t) = shape.text.as_mut() { t.body.wrap = Some(false); } } let id = add_shape(s, shape, true)?; super::text::fit_text_box(s, id)?; if text.is_empty() { // Start typing immediately. s.select(|_, sel| { sel.shapes = vec![id]; sel.text = Some(crate::TextSel { shape: id, ..Default::default() }); })?; } Ok(json!({"id": id})) } /// Read media bytes from `data` (base64) or `path`. pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { if let Some(d) = str_param(p, "data") { let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?; return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes)); } #[cfg(not(target_arch = "wasm32"))] if let Some(path) = str_param(p, "path") { let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); return Ok((name, bytes)); } Err(bad(cmd, "missing `path` or `data`")) } pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { let l = name.to_ascii_lowercase(); if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png" } else if bytes.starts_with(&[0xFF, 0xD8]) { "image/jpeg" } else if bytes.starts_with(b"GIF8") { "image/gif" } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { "image/webp" } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { "audio/wav" } else if bytes.starts_with(b"BM") { "image/bmp" } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { "image/tiff" } else if bytes.starts_with(b"fLaC") { "audio/flac" } else if bytes.starts_with(b"OggS") { if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } } else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 { // ADTS (layer bits 00): AAC. "audio/aac" } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { "audio/mpeg" } else if bytes.starts_with(b"FORM") { "audio/aiff" } else if bytes.get(4..8) == Some(b"ftyp") { if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") { "audio/mp4" } else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") { "video/quicktime" } else { "video/mp4" } } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { if l.ends_with(".mka") || l.ends_with(".weba") { "audio/webm" } else if l.ends_with(".mkv") { "video/x-matroska" } else { "video/webm" } } else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" } } else if bytes.starts_with(b"caff") { "audio/x-caf" } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) .unwrap_or((288.0, 216.0)); let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); (w * k, h * k) } fn picture(s: &mut Session, p: &Value) -> Result { let (name, bytes) = media_bytes(p, "insert.picture")?; let ct = content_type(&name, &bytes); if !ct.starts_with("image/") { return Err(bad("insert.picture", format!("{name} is not a picture"))); } if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { return Err(EngineError::Other(format!("{name}: the picture can't be read"))); } let (w, h) = fitted(s, &bytes); // Into a selected empty picture/content placeholder, if there is one. let target_ph = s.active().and_then(|d| { d.selected_shapes() .into_iter() .find(|x| { x.ph_type() .is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body)) && x.text.as_ref().is_none_or(|t| t.is_empty()) }) .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) }); let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { Some((_, b)) => { // Fill the placeholder, cropping the picture to its aspect. b } None => default_rect(s, w, h), }); let crop = match (target_ph, rect_param(p, "rect")) { (Some(_), None) => { let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); if ia > ba { let c = (1.0 - ba / ia) / 2.0; [c, 0.0, c, 0.0] } else { let c = (1.0 - ia / ba) / 2.0; [0.0, c, 0.0, c] } } _ => [0.0; 4], }; let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; let shape = Shape { xfrm: Some(rect), kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, descr: name.clone(), ..Default::default() }; if let Some((ph_id, _)) = target_ph { // Replace the placeholder with the picture (it keeps the placeholder link). let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); s.edit(|doc, sel| { let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { sh.kind = shape.kind.clone(); sh.xfrm = Some(rect); sh.ph = phv.map(|mut p| { p.kind = deckcraft_model::PhType::Picture; p }); sh.text = None; sh.descr = name.clone(); } sel.shapes = vec![ph_id]; Ok(()) })?; return Ok(json!({"id": ph_id})); } let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } const ALBUM_MAX_ITEMS: usize = 48; const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024; const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024; const ALBUM_MAX_TEXT: usize = 400; #[derive(Clone, Copy)] struct AlbumLayout { cols: usize, rows: usize, per: usize, title: bool, fit: bool, } #[derive(Clone, Copy)] enum AlbumFrame { None, Rect, Rounded, Simple, } struct AlbumPic { name: String, bytes: Vec, px_w: f64, px_h: f64, rot: f64, brightness: f64, contrast: f64, } struct AlbumOpts { layout_key: String, frame_key: String, layout: AlbumLayout, frame: AlbumFrame, captions: bool, grayscale: bool, theme: Option, } enum AlbumItem { Picture(AlbumPic), Text(String), } enum AlbumSlide { Pictures(Vec), Text(String), } /// Insert a photo album after the current slide. /// /// PowerPoint starts a new presentation. These slides go into the open one, so the album is one /// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit. fn photo_album(s: &mut Session, p: &Value) -> Result { if p.get("pictures").is_none_or(Value::is_null) { s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) }); return Ok(json!({"dialog": "photoAlbum"})); } let opts = album_opts(p)?; let items = album_items(p)?; commit_album(s, items, opts, None) } fn photo_album_edit(s: &mut Session, p: &Value) -> Result { if p.get("pictures").is_none_or(Value::is_null) { let seed = album_seed(s)?; let album = seed.get("album").cloned().unwrap_or(Value::Null); s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed }); return Ok(json!({"dialog": "photoAlbum", "album": album})); } let opts = album_opts(p)?; let items = album_items(p)?; let (id, ids) = album_target(s, p)?; commit_album(s, items, opts, Some((id, ids))) } fn commit_album(s: &mut Session, items: Vec, opts: AlbumOpts, replace: Option<(u32, Vec)>) -> Result { let (snapshot, sel) = { let st = s.doc()?; (st.doc.clone(), st.selection.clone()) }; let result = (|| { let album_id = match replace.as_ref().map(|(id, _)| *id) { Some(id) if id != 0 => id, _ => s.edit(|doc, _| Ok(doc.alloc_id()))?, }; let at = if let Some((_, ids)) = &replace { let st = s.doc()?; let indices: Vec = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect(); let Some(at) = indices.iter().copied().min() else { return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); }; s.execute("slide.delete", &json!({"ids": ids}))?; Some(at) } else { None }; let mut opts = opts; if opts.theme.as_deref().unwrap_or("").is_empty() { opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty()); } apply_theme_if_needed(s, opts.theme.as_deref())?; insert_album(s, items, &opts, at, album_id) })(); if result.is_err() && let Some(st) = s.active_mut() { st.doc = snapshot; st.selection = sel; } result } fn album_norm(s: &str) -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() } fn album_layout(p: &Value) -> Result { let key = match p.get("layout") { None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), Some(Value::String(s)) => s.as_str(), Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")), }; match album_norm(key).as_str() { "fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }), "1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), "1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }), "2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }), "2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }), "4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }), "4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }), _ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))), } } fn album_frame(p: &Value) -> Result { let key = match p.get("frame") { None => return Ok(AlbumFrame::None), Some(Value::String(s)) => s.as_str(), Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")), }; match album_norm(key).as_str() { "none" => Ok(AlbumFrame::None), "rect" | "rectangle" => Ok(AlbumFrame::Rect), "rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded), "simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple), _ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))), } } fn album_captions(p: &Value) -> Result { match p.get("captions") { None => Ok(false), Some(Value::Bool(v)) => Ok(*v), Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")), } } fn album_bool(p: &Value, key: &str) -> Result { match p.get(key) { None | Some(Value::Null) => Ok(false), Some(Value::Bool(v)) => Ok(*v), Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))), } } fn layout_key_of(layout: AlbumLayout) -> String { if layout.fit { return "fit".into(); } let n = match layout.per { 2 => "2", 4 => "4", _ => "1", }; if layout.title { format!("{n}title") } else { n.into() } } fn frame_key_of(frame: AlbumFrame) -> &'static str { match frame { AlbumFrame::None => "none", AlbumFrame::Rect => "rect", AlbumFrame::Rounded => "rounded", AlbumFrame::Simple => "simple", } } fn album_opts(p: &Value) -> Result { let layout = album_layout(p)?; let frame = album_frame(p)?; let theme = match p.get("theme") { None | Some(Value::Null) => None, Some(Value::String(s)) if s.trim().is_empty() => None, Some(Value::String(s)) => { let name = s.trim(); let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)); let Some(t) = found else { return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`"))); }; Some(t.name) } Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")), }; Ok(AlbumOpts { layout_key: layout_key_of(layout), frame_key: frame_key_of(frame).into(), layout, frame, captions: album_captions(p)?, grayscale: album_bool(p, "grayscale")?, theme, }) } /// A finite number, or 0 when the key is absent. A present non-number is an error. fn album_finite(item: &Value, key: &str) -> Result { match item.get(key) { None | Some(Value::Null) => Ok(0.0), Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))), } } fn album_rot(item: &Value) -> Result { let n = album_finite(item, "rot")?; Ok(((n % 360.0) + 360.0) % 360.0) } fn album_unit(item: &Value, key: &str) -> Result { let n = album_finite(item, key)?; if !(-1.0..=1.0).contains(&n) { return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1"))); } Ok(n) } fn album_items(p: &Value) -> Result> { let Some(arr) = p.get("pictures").and_then(Value::as_array) else { return Err(bad("insert.photoAlbum", "`pictures` must be a list")); }; if arr.is_empty() { return Err(bad("insert.photoAlbum", "add at least one picture or text box")); } if arr.len() > ALBUM_MAX_ITEMS { return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes")); } let mut items = Vec::with_capacity(arr.len()); let mut total = 0usize; for item in arr { let Some(obj) = item.as_object() else { return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box")); }; let has_media = obj.contains_key("data") || obj.contains_key("path"); if !has_media { let Some(text) = obj.get("text").and_then(Value::as_str) else { return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`")); }; let text = text.trim(); if text.is_empty() { return Err(bad("insert.photoAlbum", "a text box needs some text")); } if text.chars().count() > ALBUM_MAX_TEXT { return Err(bad("insert.photoAlbum", "text is longer than 400 characters")); } items.push(AlbumItem::Text(text.to_string())); continue; } let (name, bytes) = media_bytes(item, "insert.photoAlbum")?; if bytes.len() > ALBUM_MAX_BYTES { return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB"))); } let next = total.saturating_add(bytes.len()); if next > ALBUM_MAX_TOTAL { return Err(bad("insert.photoAlbum", "the album is larger than 64 MB")); } total = next; let ct = content_type(&name, &bytes); if !ct.starts_with("image/") { return Err(bad("insert.photoAlbum", format!("{name} is not a picture"))); } if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read"))); } let (px_w, px_h) = image_px(&bytes); items.push(AlbumItem::Picture(AlbumPic { name, bytes, px_w, px_h, rot: album_rot(item)?, brightness: album_unit(item, "brightness")?, contrast: album_unit(item, "contrast")?, })); } Ok(items) } fn image_px(bytes: &[u8]) -> (f64, f64) { image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .map(|(w, h)| (w as f64, h as f64)) .filter(|(w, h)| *w > 0.0 && *h > 0.0) .unwrap_or((4.0, 3.0)) } fn file_stem(name: &str) -> String { let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name); let stem = match base.rsplit_once('.') { Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s, _ => base, }; if stem.is_empty() { "Picture".to_string() } else { stem.to_string() } } fn album_plan(items: Vec, per: usize) -> Vec { let per = per.max(1); let mut out = Vec::new(); let mut batch = Vec::new(); for item in items { match item { AlbumItem::Text(text) => { if !batch.is_empty() { out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); } out.push(AlbumSlide::Text(text)); } AlbumItem::Picture(pic) => { batch.push(pic); if batch.len() >= per { out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); } } } } if !batch.is_empty() { out.push(AlbumSlide::Pictures(batch)); } out } struct Placed { pic: [f64; 4], caption: Option<[f64; 4]>, } fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) { let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 }; let box_w = box_w.max(1.0); let box_h = box_h.max(1.0); if !aspect.is_finite() || aspect <= 0.0 { return (box_w, box_h); } if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) } } fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec) { let margin = if layout.fit { 18.0 } else { 36.0 }; let title_band = if layout.title { 48.0 } else { 0.0 }; let gap = 12.0; let cap_h = 22.0; let cap_gap = 4.0; let left = margin; let top = margin + title_band; let grid_w = (width - margin - left).max(1.0); let grid_h = (height - margin - top).max(1.0); let cols = layout.cols.max(1); let rows = layout.rows.max(1); let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0); let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0); let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h }; let mut placed = Vec::with_capacity(n); for i in 0..n { let col = i % cols; let row = i / cols; if row >= rows { break; } let cell_x = left + col as f64 * (cell_w + gap); let cell_y = top + row as f64 * (cell_h + gap); placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None }); } let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None }; (title, placed) } fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec) { let (title, slots) = place_album(width, height, layout, pics.len(), captions); let placed = pics .iter() .zip(slots) .map(|(pic, slot)| { let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h); let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0; let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0; let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None }; Placed { pic: [x, y, dw, dh], caption } }) .collect(); (title, placed) } fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> { let params = match frame { AlbumFrame::None => return Ok(()), AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}), AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}), AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}), }; s.execute("shape.line", ¶ms)?; Ok(()) } struct AlbumMark { id: u32, layout: String, frame: String, captions: bool, grayscale: bool, theme: String, } fn parse_album_mark(raw: &str) -> Option { let rest = raw.strip_prefix("deckcraft-album:")?; let (id_str, rest) = rest.split_once(';').unwrap_or((rest, "")); let id = id_str.parse::().ok().filter(|id| *id != 0)?; let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() }; if !rest.is_empty() { for part in rest.split(';') { let Some((k, v)) = part.split_once('=') else { continue }; match k { "layout" => mark.layout = v.to_string(), "frame" => mark.frame = v.to_string(), "captions" => mark.captions = v == "1", "gray" => mark.grayscale = v == "1", "theme" => mark.theme = v.to_string(), _ => {} } } } Some(mark) } fn album_marker(id: u32, opts: &AlbumOpts) -> String { let theme = opts.theme.as_deref().unwrap_or(""); format!( "deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}", opts.layout_key, opts.frame_key, if opts.captions { 1 } else { 0 }, if opts.grayscale { 1 } else { 0 }, ) } fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> { let marker = album_marker(album_id, opts); s.edit(|doc, sel| { let Some(slide) = doc.slides.get_mut(sel.slide) else { return Err(bad("insert.photoAlbum", "no slide to mark")); }; let slide = std::sync::Arc::make_mut(slide); if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) { slide.raw_ext = Some(marker); } slide.name = "Photo Album".into(); Ok(()) }) } fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> { let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) }; let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default(); if current.eq_ignore_ascii_case(name) { return Ok(()); } s.execute("design.theme", &json!({"name": name}))?; Ok(()) } fn album_id_param(p: &Value) -> Option { match p.get("album") { Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0), Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v != 0), _ => None, } } fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec { doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect() } fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec { if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") { return Vec::new(); } let mut start = index; while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") { start -= 1; } let mut end = index; while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") { end += 1; } doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect() } /// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker). fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec)> { let st = s.doc()?; if let Some(id) = album_id_param(p) { let ids = slides_with_album(&st.doc, id); if ids.is_empty() { return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); } return Ok((id, ids)); } let Some(slide) = st.doc.slides.get(st.selection.slide) else { return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); }; if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) { let ids = slides_with_album(&st.doc, mark.id); if ids.is_empty() { return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); } return Ok((mark.id, ids)); } if slide.name == "Photo Album" { let ids = album_name_run(&st.doc, st.selection.slide); if !ids.is_empty() { return Ok((0, ids)); } } Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")) } fn pic_count(slide: &deckcraft_model::Slide) -> usize { slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count() } fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) { let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1); let per = if max_pics >= 3 { "4" } else if max_pics == 2 { "2" } else { "1" }; let title = slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0))); let layout = if title { format!("{per}title") } else { per.into() }; let width = slides.iter().find_map(|s| { s.shapes.iter().find_map(|sh| match &sh.kind { ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width), _ => None, }) }); let frame = match width { None => "none", Some(w) if (w - 6.0).abs() < 0.2 => "simple", Some(w) if (w - 2.25).abs() < 0.2 => "rounded", Some(w) if w < 0.2 => "none", Some(_) => "rect", }; let captions = slides.iter().any(|s| { let bottoms: Vec = s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect(); !bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b))) }); let mut any = false; let mut gray = true; for s in slides { for sh in &s.shapes { if let ShapeKind::Picture { fill } = &sh.kind { any = true; gray &= fill.adjust.grayscale; } } } (layout, frame.into(), captions, any && gray, theme_now.to_string()) } fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec { let mut items = Vec::new(); for slide in slides { let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); if pics.is_empty() { let text = slide .shapes .iter() .filter(|sh| sh.text_box) .filter_map(|sh| sh.text.as_ref().map(|t| t.text())) .find(|t| !t.trim().is_empty()) .unwrap_or_default(); if !text.trim().is_empty() { items.push(json!({"text": text.trim()})); } continue; } for sh in pics { let ShapeKind::Picture { fill } = &sh.kind else { continue }; let media = doc.media(fill.media); let name = if !sh.descr.is_empty() { sh.descr.clone() } else { media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into()) }; items.push(json!({ "name": name, "data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(), "rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0), "brightness": fill.adjust.brightness, "contrast": fill.adjust.contrast, })); } } items } fn album_seed(s: &Session) -> Result { let (id, ids) = album_target(s, &Value::Null)?; let st = s.doc()?; let slides: Vec<&deckcraft_model::Slide> = ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect(); let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into()); let inferred = infer_album(&slides, &theme_now); let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark)); let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark { ( if mark.layout.is_empty() { inferred.0 } else { mark.layout }, if mark.frame.is_empty() { inferred.1 } else { mark.frame }, mark.captions, mark.grayscale, if mark.theme.is_empty() { theme_now } else { mark.theme }, ) } else { inferred }; Ok(json!({ "album": if id == 0 { Value::Null } else { json!(id) }, "layout": layout, "frame": frame, "captions": captions, "grayscale": grayscale, "theme": theme, "items": items_from_slides(&st.doc, &slides), })) } fn insert_album(s: &mut Session, items: Vec, opts: &AlbumOpts, at: Option, album_id: u32) -> Result { let (width, height, first) = { let st = s.doc()?; let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) }); (st.doc.slide_size.width, st.doc.slide_size.height, first) }; let layout = opts.layout; let frame = opts.frame; let captions = opts.captions; let plan = album_plan(items, layout.per); let mut slides = 0usize; let mut pictures = 0usize; let mut pin = at; for slide in plan { if let Some(index) = pin.take() { s.execute("slide.new", &json!({"layout": "blank", "at": index}))?; } else { s.execute("slide.new", &json!({"layout": "blank"}))?; } stamp_album_slide(s, album_id, opts)?; slides += 1; match slide { AlbumSlide::Text(text) => { let rect = if layout.title { [36.0, 18.0, (width - 72.0).max(36.0), 48.0] } else { [36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)] }; s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?; } AlbumSlide::Pictures(pics) => { let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default(); let (title, placed) = place_pictures(width, height, layout, &pics, captions); for (pic, cell) in pics.iter().zip(placed) { let data = base64_encode(&pic.bytes); let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?; let Some(id) = inserted.get("id").and_then(Value::as_u64) else { return Err(bad("insert.photoAlbum", "the picture was not inserted")); }; apply_frame(s, frame, id)?; if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 { s.execute( "picture.adjust", &json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}), )?; } if pic.rot > 0.01 { s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?; } pictures += 1; if let Some(cap) = cell.caption { s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?; } } if let Some(rect) = title { s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?; } } } } s.execute("slide.go", &json!({"index": first}))?; Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id})) } fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); let size = s.doc()?.doc.slide_size; let w = size.width * 0.8; let rh = 40.0; let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.1, (size.height - rh * rows as f64).max(0.0) / 2.0, w, rh * rows as f64)); let mut t = Table::new(rows, cols, rect.w, rect.h / rows as f64); if let Some(data) = p.get("data").and_then(Value::as_array) { for (r, row) in data.iter().enumerate() { for (c, v) in row.as_array().map(|a| a.as_slice()).unwrap_or(&[]).iter().enumerate() { if let (Some(cell), Some(text)) = (t.cell_mut(r, c), v.as_str()) { cell.text = TextBody::from_text(text); } } } } let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?; Ok(json!({"id": id})) } pub fn chart_type(name: &str) -> ChartType { match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { "bar" | "clusteredbar" => ChartType::Bar, "stackedbar" => ChartType::StackedBar, "percentbar" => ChartType::PercentBar, "line" => ChartType::Line, "linemarkers" | "linewithmarkers" => ChartType::LineMarkers, "stackedline" => ChartType::StackedLine, "area" => ChartType::Area, "stackedarea" => ChartType::StackedArea, "pie" => ChartType::Pie, "doughnut" | "donut" => ChartType::Doughnut, "scatter" | "xy" => ChartType::Scatter, "bubble" => ChartType::Bubble, "radar" => ChartType::Radar, "filledradar" => ChartType::FilledRadar, "stackedcolumn" => ChartType::StackedColumn, "percentcolumn" => ChartType::PercentColumn, "waterfall" => ChartType::Waterfall, "funnel" => ChartType::Funnel, "histogram" => ChartType::Histogram, "pareto" => ChartType::Pareto, "treemap" => ChartType::Treemap, "sunburst" => ChartType::Sunburst, "boxwhisker" | "box" => ChartType::BoxWhisker, "stock" => ChartType::Stock, "surface" => ChartType::Surface, "combo" => ChartType::Combo, _ => ChartType::Column, } } fn chart(s: &mut Session, p: &Value) -> Result { let kind = chart_type(str_param(p, "type").unwrap_or("column")); let mut c = Chart::sample(kind); if let Some(cats) = p.get("categories").and_then(Value::as_array) { c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect(); } if let Some(series) = p.get("series").and_then(Value::as_array) { c.series = series .iter() .map(|sv| deckcraft_model::chart::Series { name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), ..Default::default() }) .collect(); } if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } let size = s.doc()?.doc.slide_size; let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; Ok(json!({"id": id})) } fn media(s: &mut Session, p: &Value, video: bool) -> Result { let cmd = if video { "insert.video" } else { "insert.audio" }; let (name, bytes) = media_bytes(p, cmd)?; let ct = content_type(&name, &bytes); let bytes = std::sync::Arc::new(bytes); let info = deckcraft_media::probe(&bytes); let has_video = info.as_ref().is_ok_and(|i| i.video.is_some()); let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some()); match &info { Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))), Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))), // Not something we can read: embed it anyway when the type says it's media (it still // round-trips through PPTX), otherwise refuse. Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))), Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))), _ => {} } let (duration_ms, width, height, playable) = match &info { Ok(i) => { let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)); (i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable()) } Err(_) => (0, 0, 0, false), }; let warning = (!playable).then(|| match &info { Ok(i) => { let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) } Err(e) => format!("{name} was inserted, but it can't be played ({e})"), }); let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None }; let size = s.doc()?.doc.slide_size; let rect = rect_param(p, "rect").unwrap_or_else(|| { if video { // Natural size (96 dpi), fitted inside 90 % of the slide and centred. let (w, h) = if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) }; let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0); let (w, h) = (w * k, h * k); Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) } else { Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) } }); let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); let (media, poster) = s.edit(|doc, _| { let media = doc.add_media(&name, ct, data); let poster = poster_png.map(|png| doc.add_media(&format!("{name}-poster.png"), "image/png", png)); Ok((media, poster)) })?; let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; if let Some(w) = &warning { s.ui_requests.push(crate::UiRequest::Message { text: w.clone() }); } let mut r = json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning}); if let Ok(i) = &info { r["probe"] = super::media::probe_json(i); } Ok(r) } fn audio(s: &mut Session, p: &Value) -> Result { media(s, p, false) } fn video(s: &mut Session, p: &Value) -> Result { media(s, p, true) } fn word_art(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or("Your text here"); let size = s.doc()?.doc.slide_size; let style = usize_param(p, "style").unwrap_or(0); let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text); if let Some(t) = shape.text.as_mut() { for para in &mut t.paragraphs { para.props.align = Some(deckcraft_model::text::Align::Center); for r in &mut para.runs { r.props.size = Some(54.0); r.props.bold = Some(style % 2 == 1); r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { 0 => SchemeSlot::Tx1, 1 => SchemeSlot::Accent1, 2 => SchemeSlot::Accent2, _ => SchemeSlot::Accent4, }))); if style % 3 == 2 { r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); } } } } let id = add_shape(s, shape, true)?; super::text::fit_text_box(s, id)?; Ok(json!({"id": id})) } fn field_run(s: &mut Session, field: &str, text: &str) -> Result { if s.doc()?.selection.text.is_some() { return super::text::insert_field(s, field, text); } // Not editing: turn on the footer field for the slide (Header & Footer). let key = if field == "slidenum" { "slideNumber" } else { "date" }; s.execute("design.headerFooter", &json!({key: true})) } fn slide_number(s: &mut Session, _p: &Value) -> Result { field_run(s, "slidenum", "‹#›") } const DATE_FORMATS: [&str; 13] = [ "datetime1", "datetime2", "datetime3", "datetime4", "datetime5", "datetime6", "datetime7", "datetime8", "datetime9", "datetime10", "datetime11", "datetime12", "datetime13", ]; struct DateOpts { format: String, update: bool, /// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language. lang: Option, sample: String, } /// `true` when the call carries a format, update flag or language (apply it). Empty params open /// the dialog instead. fn date_time_apply(p: &Value) -> bool { p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language")) } fn canon_lang(raw: &str) -> Option<&'static str> { match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { "en" | "en-us" => Some("en-US"), "en-gb" => Some("en-GB"), "fr" | "fr-fr" => Some("fr-FR"), "ja" | "ja-jp" => Some("ja-JP"), "sv" | "sv-se" => Some("sv-SE"), _ => None, } } fn date_opts(p: &Value) -> Result { if p.get("format").is_some() { let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f)); if f.is_none() { return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13")); } } if p.get("update").is_some() && bool_param(p, "update").is_none() { return Err(bad("insert.dateTime", "`update` must be true or false")); } let lang = if p.get("language").is_some() { let raw = str_param(p, "language").filter(|l| !l.trim().is_empty()); let Some(raw) = raw else { return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE")); }; Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string()) } else { None }; let format = str_param(p, "format").unwrap_or("datetime1").to_string(); let sample = date_sample(&format, lang.as_deref())?; Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample }) } /// The text `format` shows now in `lang`. Errors when that language has no names for the format /// (month names and AM/PM are English only). An empty string where there is no clock (wasm). fn date_sample(format: &str, lang: Option<&str>) -> Result { use deckcraft_text::datetime::{DateTime, field_text}; let probe = DateTime::new(2026, 10, 9, 20, 5, 7); if field_text(format, lang, probe).is_none() { return Err(bad("insert.dateTime", "this format isn't available in that language")); } Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default()) } fn date_time(s: &mut Session, p: &Value) -> Result { if !date_time_apply(p) { s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) }); return ok(); } let opts = date_opts(p)?; let (doc, sel) = { let st = s.doc()?; (st.doc.clone(), st.selection.clone()) }; let r = apply_date(s, &opts); if r.is_err() && let Some(st) = s.active_mut() { st.doc = doc; st.selection = sel; } r } fn apply_date(s: &mut Session, o: &DateOpts) -> Result { if s.doc()?.selection.text.is_some() { if o.update { super::text::insert_field(s, &o.format, &o.sample)?; if let Some(lang) = &o.lang { stamp_caret_lang(s, lang)?; } } else { insert_plain(s, &o.sample, o.lang.as_deref())?; } return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); } // Not editing: the slide's date placeholder (this slide only). A layout with no date // placeholder gets a text box instead, so the chosen format is still inserted. s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; if !write_date_placeholder(s, o)? { return insert_date_box(s, o); } Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) } /// Point the current slide's date placeholder at this format (a field, or the sample as plain text). fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { let format = o.format.clone(); let sample = o.sample.clone(); let lang = o.lang.clone(); let update = o.update; s.edit(|doc, sel| { let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) }; let sl = std::sync::Arc::make_mut(sl); let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else { return Ok(false); }; let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); if let Some(lang) = lang { props.lang = Some(lang); } let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() }; let body = sh.text.clone().unwrap_or_default(); sh.text = Some(TextBody { paragraphs: vec![para], ..body }); Ok(true) }) } fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result { let size = s.doc()?.doc.slide_size; let y = (size.height - 48.0).max(0.0); s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?; if o.update { super::text::insert_field(s, &o.format, &o.sample)?; } else { super::text::insert_text(s, &o.sample, None)?; } if let Some(lang) = &o.lang { stamp_caret_lang(s, lang)?; } Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample})) } /// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge, /// so the date stays its own run next to the surrounding text. fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> { let text = text.to_string(); let lang = lang.map(str::to_string); super::text::edit_text(s, move |body, t| { let (a, b) = t.ordered(); let at = deckcraft_model::edit::delete(body, a, b); let mut props = deckcraft_model::edit::props_at(body, at); if let Some(lang) = lang.clone() { props.lang = Some(lang); } let c = deckcraft_model::edit::insert(body, at, &text, Some(props)); t.anchor = c; t.caret = c; })?; Ok(()) } /// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates. fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> { let lang = lang.to_string(); super::text::edit_text(s, move |body, t| { let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return }; let mut at = 0usize; for r in &mut p.runs { let n = r.char_len(); if n > 0 && at < t.caret.1 && t.caret.1 <= at + n { r.props.lang = Some(lang); return; } at += n; } })?; Ok(()) } fn symbol(s: &mut Session, p: &Value) -> Result { let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string(); if s.doc()?.selection.text.is_none() { return Err(bad("insert.symbol", "place the insertion point in text first")); } super::text::insert_text(s, &t, None) } fn hyperlink(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; // `display`: what a caret with nothing to link inserts, the address or the slide's title. let (action, display) = if let Some(u) = str_param(p, "url") { (Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string()) } else if let Some(i) = usize_param(p, "slide") { let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; let title = slide.title(); (Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title }) } else { return Err(bad("insert.hyperlink", "missing `url` or `slide`")); }; let display = str_param(p, "text").map_or(display, str::to_string); let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: bool_or(p, "highlightClick", false), sound: None, stop_sound: false, }; if s.doc()?.selection.text.is_some() { return super::text::link_text(s, link, &display); } edit_shapes(s, p, "insert.hyperlink", |sh| { sh.click = Some(link.clone()); Ok(()) }) } /// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does /// in a show. fn shape_action(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; const CMD: &str = "shape.action"; let hover = match str_param(p, "on").unwrap_or("click") { "click" => false, "hover" | "over" => true, o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))), }; let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`"))); let action = match str_param(p, "action").unwrap_or("none") { "none" => None, "next" => Some(Action::NextSlide), "previous" => Some(Action::PreviousSlide), "first" => Some(Action::FirstSlide), "last" => Some(Action::LastSlide), "lastViewed" => Some(Action::LastViewed), "endShow" => Some(Action::EndShow), "playMedia" => Some(Action::PlayMedia), "url" => Some(Action::Url { url: need("url")? }), "customShow" => { let name = need("show")?; if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { return Err(bad(CMD, format!("no custom show `{name}`"))); } Some(Action::CustomShow { name, returns: true }) } "program" => Some(Action::Program { path: need("path")? }), "slide" => { let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?; let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?; Some(Action::Slide { slide: slide.id }) } a => return Err(bad(CMD, format!("unknown action `{a}`"))), }; let highlight = bool_param(p, "highlight").unwrap_or(true); let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false }); edit_shapes(s, p, CMD, |sh| { if hover { sh.hover = link.clone(); } else { sh.click = link.clone(); } Ok(()) }) } fn action_button(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; let kind = str_param(p, "kind").unwrap_or("custom"); let (preset, action) = match kind { "back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)), "forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)), "beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)), "end" => ("actionButtonEnd", Some(Action::LastSlide)), "home" => ("actionButtonHome", Some(Action::FirstSlide)), "information" => ("actionButtonInformation", None), "return" => ("actionButtonReturn", Some(Action::LastViewed)), "movie" => ("actionButtonMovie", None), "document" => ("actionButtonDocument", None), "sound" => ("actionButtonSound", None), "help" => ("actionButtonHelp", None), _ => ("actionButtonBlank", None), }; let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0)); let shape = Shape { xfrm: Some(rect), geom: Geom::preset(preset), style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false }), ..Default::default() }; let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } #[allow(dead_code)] fn _kinds() -> RunKind { RunKind::Text } fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { edit_shapes(s, p, cmd, |sh| match &mut sh.kind { ShapeKind::Chart(c) => f(c), _ => Err(bad(cmd, "not a chart")), }) } fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { let k = chart_type(str_param(p, "type").unwrap_or("column")); with_chart(s, p, "chart.type", |c| { c.kind = k; c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) || c.series.len() == 1 && c.vary_colors; if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { c.gridlines = true; } c.raw = None; Ok(()) }) } fn chart_data(s: &mut Session, p: &Value) -> Result { let cats: Option> = p .get("categories") .and_then(Value::as_array) .map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect()); let series: Option> = p.get("series").and_then(Value::as_array).map(|a| { a.iter() .map(|sv| deckcraft_model::chart::Series { name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(), ..Default::default() }) .collect() }); let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { a.iter() .take(deckcraft_model::chart::MAX_LEVELS) .map(|l| { l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() }) .collect() }); with_chart(s, p, "chart.data", |c| { if let Some(cs) = &cats { c.categories = cs.clone(); } if let Some(l) = &levels { c.category_levels = l.clone(); } if let Some(se) = &series { // Keep series formatting by position. let old = std::mem::take(&mut c.series); c.series = se .iter() .enumerate() .map(|(i, n)| { let mut x = old.get(i).cloned().unwrap_or_default(); x.name = n.name.clone(); x.values = n.values.clone(); x }) .collect(); } c.raw = None; Ok(()) }) } fn chart_options(s: &mut Session, p: &Value) -> Result { // Histogram bins: a count or a width (setting one clears the other; null for automatic). let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { return Err(bad("chart.options", "binSize must be a positive number")); } // Waterfall total points. let subtotals: Option> = p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); with_chart(s, p, "chart.options", |c| { if let Some(n) = bin_count { c.bin_count = n; if n.is_some() { c.bin_size = None; } } if let Some(w) = bin_size { c.bin_size = w; if w.is_some() { c.bin_count = None; } } if let Some(t) = &subtotals { let n = c.series.first().map_or(0, |s| s.values.len()); let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); t.sort_unstable(); t.dedup(); c.subtotals = t; } if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { c.up_down_bars = b; c.raw = None; } if let Some(q) = p.get("quartiles").and_then(Value::as_str) { c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); c.raw = None; } if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { c.raw = None; } if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } if let Some(l) = p.get("legend") { c.legend = l.as_str().map(String::from); } if let Some(v) = bool_param(p, "dataLabels") { c.data_labels = v; } if let Some(v) = bool_param(p, "gridlines") { c.gridlines = v; } if let Some(v) = str_param(p, "palette") { c.palette = v.to_string(); } c.raw = None; Ok(()) }) } /// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with /// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { let kind = str_param(p, "kind").unwrap_or("process").to_string(); let nodes = match p.get("nodes") { Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("insert.smartArt", e.to_string()))?, None => super::smartart::nodes_from_lines(p.get("items").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)), }; super::smartart::insert(s, &kind, nodes) } #[cfg(test)] mod tests { use deckcraft_model::text::RunKind; use serde_json::json; use crate::Session; fn session() -> Session { Session::with_new() } fn undo(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } fn date_run(s: &Session) -> Option { s.doc() .unwrap() .current_slide() .unwrap() .shapes .iter() .find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) .and_then(|x| x.text.as_ref()) .and_then(|t| t.paragraphs.first()) .and_then(|p| p.runs.first()) .cloned() } fn title_field(s: &Session) -> deckcraft_model::Run { let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); title.paragraphs[0].runs.iter().find(|r| r.text.contains(':') || matches!(r.kind, RunKind::Field { .. })).cloned().unwrap() } fn edit_title(s: &mut Session) { let id = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": id})).unwrap(); s.execute("text.insert", &json!({"text": "On "})).unwrap(); } #[test] fn no_params_opens_the_dialog_and_edits_nothing() { let mut s = session(); let rev = s.doc().unwrap().revision; for p in [json!({}), serde_json::Value::Null] { s.ui_requests.clear(); s.execute("insert.dateTime", &p).unwrap(); assert!(matches!(&s.ui_requests[..], [crate::UiRequest::Dialog { id, .. }] if id == "dateTime")); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo(&s), 0); assert!(date_run(&s).is_none()); } } #[test] fn a_format_at_the_caret_is_one_updating_field() { let mut s = session(); edit_title(&mut s); let before = undo(&s); let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap(); assert_eq!(v["where"], "caret"); let run = title_field(&s); assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); assert!(run.text.contains(':'), "{run:?}"); assert_eq!(undo(&s), before + 1); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Date & Time"); s.execute("edit.undo", &json!({})).unwrap(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().text(); assert_eq!(title, "On "); } #[test] fn update_false_inserts_plain_text_in_the_chosen_language() { let mut s = session(); edit_title(&mut s); let before = undo(&s); let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); let expect = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now).unwrap(); s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); let run = title.paragraphs[0].runs.iter().find(|r| r.text == expect).cloned().unwrap(); assert_eq!(run.kind, RunKind::Text); assert_eq!(run.props.lang.as_deref(), Some("en-GB")); assert_eq!(undo(&s), before + 1); } #[test] fn a_named_format_in_french_and_a_bad_value_change_nothing() { let mut s = session(); edit_title(&mut s); let rev = s.doc().unwrap().revision; let undos = undo(&s); let err = s.execute("insert.dateTime", &json!({"format": "datetime2", "language": "fr-FR"})).unwrap_err(); assert!(err.to_string().contains("isn't available"), "{err}"); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo(&s), undos); for p in [ json!({"format": "datetime14"}), json!({"format": "nope"}), json!({"update": "yes"}), json!({"language": "de-DE"}), json!({"language": ""}), ] { let err = s.execute("insert.dateTime", &p).unwrap_err(); assert!(err.to_string().contains("insert.dateTime"), "{err}"); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo(&s), undos); } } #[test] fn without_a_caret_the_date_lands_on_this_slide_only() { let mut s = session(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); s.execute("slide.go", &json!({"index": 0})).unwrap(); let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); let clock = deckcraft_text::datetime::field_text("datetime10", Some("en-US"), now).unwrap(); let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "language": "en_US"})).unwrap(); assert_eq!(v["where"], "footer"); let run = date_run(&s).unwrap(); assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); assert_eq!(run.text, clock); assert_eq!(run.props.lang.as_deref(), Some("en-US")); s.execute("slide.go", &json!({"index": 1})).unwrap(); assert!(date_run(&s).is_none(), "the other slide keeps its own footer"); s.execute("slide.go", &json!({"index": 0})).unwrap(); let fixed = deckcraft_text::datetime::field_text("datetime12", None, now).unwrap(); s.execute("insert.dateTime", &json!({"format": "datetime12", "update": false})).unwrap(); let run = date_run(&s).unwrap(); assert_eq!((run.kind, run.text), (RunKind::Text, fixed)); s.execute("edit.undo", &json!({})).unwrap(); let run = date_run(&s).unwrap(); assert!(matches!(run.kind, RunKind::Field { .. }), "one undo restores the field"); } } #[cfg(test)] mod icon_and_picture_tests { use deckcraft_model::ShapeKind; use deckcraft_model::style::LineJoin; use super::*; /// 8×8 red PNG. fn png() -> Vec { vec![ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68, 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ] } fn pic(name: &str) -> Value { json!({"name": name, "data": base64_encode(&png())}) } fn n_slides(s: &Session) -> usize { s.active().map(|d| d.doc.slides.len()).unwrap_or(0) } fn pictures(s: &Session, index: usize) -> Vec { s.active() .and_then(|d| d.doc.slides.get(index)) .map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect()) .unwrap_or_default() } fn box_text(s: &Session, index: usize) -> Vec { s.active() .and_then(|d| d.doc.slides.get(index)) .map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect()) .unwrap_or_default() } #[test] fn two_pictures_one_per_slide_are_one_undo() { let mut s = Session::with_new(); let before = n_slides(&s); let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album"); assert_eq!(r["slides"], 2); assert_eq!(n_slides(&s), before + 2); assert_eq!(pictures(&s, 1).len(), 1); assert_eq!(pictures(&s, 2).len(), 1); assert_eq!(s.active().unwrap().selection.slide, 1); let undo = &s.active().unwrap().history.undo; assert_eq!(undo.len(), 1); assert_eq!(undo[0].label, "Photo Album…"); s.execute("edit.undo", &json!({})).expect("undo"); assert_eq!(n_slides(&s), before); assert!(pictures(&s, 1).is_empty()); } #[test] fn two_up_shares_one_slide() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album"); assert_eq!(n_slides(&s), 2); assert_eq!(pictures(&s, 1).len(), 2); assert!(box_text(&s, 1).is_empty()); } #[test] fn captions_and_titles_use_the_file_stem() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions"); assert_eq!(box_text(&s, 1), ["harbor"]); s.execute("edit.undo", &json!({})).unwrap(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title"); assert_eq!(box_text(&s, 1), ["harbor"]); assert_eq!(pictures(&s, 1).len(), 1); let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y; assert!(pic_y > 40.0, "the picture sits below the title"); } #[test] fn frames_set_the_picture_line() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect"); let line = pictures(&s, 1)[0].line.clone().expect("line"); assert_eq!(line.width, Some(1.5)); s.execute("edit.undo", &json!({})).unwrap(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded"); let line = pictures(&s, 1)[0].line.clone().expect("line"); assert_eq!(line.width, Some(2.25)); assert_eq!(line.join, Some(LineJoin::Round)); s.execute("edit.undo", &json!({})).unwrap(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple"); assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0)); } #[test] fn a_text_box_breaks_the_picture_row() { let mut s = Session::with_new(); let r = s .execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"})) .expect("album"); assert_eq!(r["slides"], 3); assert_eq!(pictures(&s, 1).len(), 1); assert_eq!(box_text(&s, 2), ["A day on the water"]); assert!(pictures(&s, 2).is_empty()); assert_eq!(pictures(&s, 3).len(), 1); assert_eq!(s.active().unwrap().history.undo.len(), 1); } #[test] fn no_pictures_opens_the_dialog_without_editing() { let mut s = Session::with_new(); let before = n_slides(&s); let r = s.execute("insert.photoAlbum", &json!({})).expect("open"); assert_eq!(r["dialog"], "photoAlbum"); assert_eq!(n_slides(&s), before); assert!(s.active().unwrap().history.undo.is_empty()); assert_eq!(s.ui_requests.len(), 1); match &s.ui_requests[0] { crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"), other => panic!("dialog request, got {other:?}"), } } #[test] fn bad_input_adds_no_slides() { let mut s = Session::with_new(); let before = n_slides(&s); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err()); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err()); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err()); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err()); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err()); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err()); let mut many = Vec::new(); for i in 0..49 { many.push(json!({"text": format!("box {i}")})); } assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err()); assert_eq!(n_slides(&s), before); assert!(s.active().unwrap().history.undo.is_empty()); } #[test] fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap(); let one = pictures(&s, 1)[0].xfrm.unwrap().h; s.execute("edit.undo", &json!({})).unwrap(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap(); let fitted = pictures(&s, 1)[0].xfrm.unwrap().h; assert!(fitted > one, "fit {fitted} should be taller than one-up {one}"); s.execute("edit.undo", &json!({})).unwrap(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap(); assert_eq!(n_slides(&s), 2); assert_eq!(pictures(&s, 1).len(), 4); } fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust { match &pictures(s, index)[0].kind { ShapeKind::Picture { fill } => fill.adjust.clone(), _ => panic!("picture"), } } fn theme_name(s: &Session) -> String { s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default() } #[test] fn edit_without_pictures_reopens_the_dialog() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); s.ui_requests.clear(); let before = n_slides(&s); let undos = s.active().unwrap().history.undo.len(); let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen"); assert_eq!(r["dialog"], "photoAlbum"); assert_eq!(n_slides(&s), before); assert_eq!(s.active().unwrap().history.undo.len(), undos); let params = match &s.ui_requests[0] { crate::UiRequest::Dialog { id, params } => { assert_eq!(id, "photoAlbum"); params } other => panic!("dialog request, got {other:?}"), }; assert_eq!(params["items"][0]["name"], "hill.png"); assert_eq!(params["items"][1]["name"], "lake.png"); assert_eq!(params["layout"], "1"); assert!(params["album"].as_u64().unwrap_or(0) > 0); } #[test] fn edit_reflows_the_album_in_one_undo() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); assert_eq!(n_slides(&s), 3); let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow"); assert_eq!(r["slides"], 1); assert_eq!(n_slides(&s), 2); assert_eq!(pictures(&s, 1).len(), 2); let undo = &s.active().unwrap().history.undo; assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…")); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(n_slides(&s), 3); assert_eq!(pictures(&s, 1).len(), 1); assert_eq!(pictures(&s, 2).len(), 1); } #[test] fn picture_options_and_theme_are_one_step() { let mut s = Session::with_new(); s.execute( "insert.photoAlbum", &json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}), ) .unwrap(); let adj = adjust(&s, 1); assert!(adj.grayscale); assert!((adj.brightness - 0.2).abs() < 1e-6); assert!((adj.contrast + 0.1).abs() < 1e-6); assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01); assert_eq!(theme_name(&s), "Harbor"); let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); assert!(mark.contains("gray=1"), "{mark}"); assert!(mark.contains("theme=Harbor"), "{mark}"); assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(theme_name(&s), "DeckCraft"); assert_eq!(n_slides(&s), 1); } #[test] fn unknown_theme_and_a_plain_slide_change_nothing() { let mut s = Session::with_new(); let before = n_slides(&s); let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err(); assert!(err.to_string().contains("unknown theme"), "{err}"); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err()); assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err()); let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err(); assert!(err.to_string().contains("not part of a photo album"), "{err}"); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap(); s.execute("slide.go", &json!({"index": 0})).unwrap(); assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err()); assert_eq!(n_slides(&s), before + 1); assert_eq!(pictures(&s, 1).len(), 1); assert_eq!(theme_name(&s), "DeckCraft"); } #[test] fn two_albums_stay_independent() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); let last = n_slides(&s) - 1; s.execute("slide.go", &json!({"index": last})).unwrap(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap(); assert_eq!(n_slides(&s), 5); s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap(); assert_eq!(n_slides(&s), 4); assert_eq!(pictures(&s, 1).len(), 1); assert_eq!(pictures(&s, 2).len(), 1); assert_eq!(pictures(&s, 3).len(), 2); let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default(); assert!(first.starts_with("deckcraft-album:")); assert!(second.starts_with("deckcraft-album:")); assert_ne!(first.split(';').next(), second.split(';').next()); } #[test] fn a_named_run_without_a_marker_still_reflows() { let mut s = Session::with_new(); s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); { let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); for sl in doc.slides.iter_mut().skip(1) { std::sync::Arc::make_mut(sl).raw_ext = None; } } s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap(); assert_eq!(n_slides(&s), 2); assert_eq!(pictures(&s, 1).len(), 2); let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); assert!(mark.starts_with("deckcraft-album:"), "{mark}"); } }