diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 81b3426a..76bc5359 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -14,6 +14,21 @@ use super::{ }; use crate::{EngineError, Result, Session}; +mod album; +mod chart; +mod links; +mod picture; +mod text_fields; + +use album::{photo_album, photo_album_edit}; +pub use chart::chart_type; +use chart::{chart, chart_data, chart_options, chart_type_cmd}; +use links::{action_button, hyperlink, shape_action}; +pub use picture::content_type; +pub(crate) use picture::media_bytes; +use picture::{audio, picture, video}; +use text_fields::{date_time, slide_number, symbol, word_art}; + pub fn specs() -> Vec { vec![ cmd!( @@ -316,880 +331,6 @@ fn text_box(s: &mut Session, p: &Value) -> Result { 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)); - } - if let Some(path) = str_param(p, "path") { - let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - 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, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?; - 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, "insert.picture", 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, - _ => { - let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" }; - s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))? - } - }; - 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(char::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(std::ffi::OsStr::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); @@ -1211,666 +352,6 @@ fn table(s: &mut Session, p: &Value) -> Result { 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, "insert.chart", 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 decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); - let warning = if !playable { - Some(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})"), - }) - } else if video && !decodable_video { - // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame - // can ever show — say so instead of leaving an unexplained black box (#1237). - info.as_ref() - .ok() - .and_then(|i| i.video.as_ref()) - .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) - } else { - None - }; - 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).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?; - let poster = match poster_png { - Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?), - None => None, - }; - Ok((media, poster)) - })?; - let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; - let id = add_shape(s, "insert.media", 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, "insert.wordArt", 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, - zoom_return: None, - }; - 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, zoom_return: None }); - 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, - zoom_return: None, - }), - ..Default::default() - }; - let id = add_shape(s, "insert.actionButton", 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 apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { - 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; - } - } -} - -fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { - 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; - } -} - -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()); - // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's - // default (tx1 at lumMod 65 % / lumOff 35 %). - let text_color = match p.get("textColor") { - None => None, - Some(Value::Null) => Some(None), - Some(v) => { - Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) - } - }; - with_chart(s, p, "chart.options", |c| { - if let Some(t) = &text_color { - c.text_color = t.clone(); - } - apply_bin_options(c, bin_count, bin_size); - apply_subtotals_and_bars(c, p, subtotals.as_deref()); - 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; - // A series' own setting would otherwise keep overriding the chart-wide switch. - for series in &mut c.series { - series.data_labels = None; - } - } - 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 { diff --git a/crates/engine/src/cmd/insert/album.rs b/crates/engine/src/cmd/insert/album.rs new file mode 100644 index 00000000..b06f3e90 --- /dev/null +++ b/crates/engine/src/cmd/insert/album.rs @@ -0,0 +1,724 @@ +use super::picture::{content_type, media_bytes}; +use super::*; + +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. +pub(super) 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) +} + +pub(super) 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, + _ => { + let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" }; + s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))? + } + }; + 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(char::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(std::ffi::OsStr::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})) +} diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs new file mode 100644 index 00000000..41323d39 --- /dev/null +++ b/crates/engine/src/cmd/insert/chart.rs @@ -0,0 +1,218 @@ +use super::*; + +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, + } +} + +pub(super) 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, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, 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")), + }) +} + +pub(super) 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(()) + }) +} + +pub(super) 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 apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { + 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; + } + } +} + +fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { + 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; + } +} + +pub(super) 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()); + // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's + // default (tx1 at lumMod 65 % / lumOff 35 %). + let text_color = match p.get("textColor") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => { + Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) + } + }; + with_chart(s, p, "chart.options", |c| { + if let Some(t) = &text_color { + c.text_color = t.clone(); + } + apply_bin_options(c, bin_count, bin_size); + apply_subtotals_and_bars(c, p, subtotals.as_deref()); + 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; + // A series' own setting would otherwise keep overriding the chart-wide switch. + for series in &mut c.series { + series.data_labels = None; + } + } + 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(()) + }) +} diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs new file mode 100644 index 00000000..f0959688 --- /dev/null +++ b/crates/engine/src/cmd/insert/links.rs @@ -0,0 +1,116 @@ +use super::*; + +pub(super) 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, + zoom_return: None, + }; + if s.doc()?.selection.text.is_some() { + return crate::cmd::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. +pub(super) 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, zoom_return: None }); + edit_shapes(s, p, CMD, |sh| { + if hover { + sh.hover = link.clone(); + } else { + sh.click = link.clone(); + } + Ok(()) + }) +} + +pub(super) 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, + zoom_return: None, + }), + ..Default::default() + }; + let id = add_shape(s, "insert.actionButton", shape, true)?; + Ok(json!({"id": id})) +} diff --git a/crates/engine/src/cmd/insert/picture.rs b/crates/engine/src/cmd/insert/picture.rs new file mode 100644 index 00000000..74bd45da --- /dev/null +++ b/crates/engine/src/cmd/insert/picture.rs @@ -0,0 +1,243 @@ +use super::*; + +/// 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)); + } + if let Some(path) = str_param(p, "path") { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + 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) +} + +pub(super) 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, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?; + 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, "insert.picture", shape, 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 decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); + let warning = if !playable { + Some(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})"), + }) + } else if video && !decodable_video { + // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame + // can ever show — say so instead of leaving an unexplained black box (#1237). + info.as_ref() + .ok() + .and_then(|i| i.video.as_ref()) + .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) + } else { + None + }; + let poster_png = if video && decodable_video { crate::cmd::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).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?; + let poster = match poster_png { + Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?), + None => None, + }; + Ok((media, poster)) + })?; + let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; + let id = add_shape(s, "insert.media", 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"] = crate::cmd::media::probe_json(i); + } + Ok(r) +} + +pub(super) fn audio(s: &mut Session, p: &Value) -> Result { + media(s, p, false) +} + +pub(super) fn video(s: &mut Session, p: &Value) -> Result { + media(s, p, true) +} diff --git a/crates/engine/src/cmd/insert/text_fields.rs b/crates/engine/src/cmd/insert/text_fields.rs new file mode 100644 index 00000000..d19f1dd1 --- /dev/null +++ b/crates/engine/src/cmd/insert/text_fields.rs @@ -0,0 +1,243 @@ +use super::*; + +pub(super) 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, "insert.wordArt", shape, true)?; + crate::cmd::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 crate::cmd::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})) +} + +pub(super) 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()) +} + +pub(super) 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 { + crate::cmd::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 { + crate::cmd::text::insert_field(s, &o.format, &o.sample)?; + } else { + crate::cmd::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); + crate::cmd::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(); + crate::cmd::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(()) +} + +pub(super) 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")); + } + crate::cmd::text::insert_text(s, &t, None) +}