//! Insert tab: shapes, text boxes, pictures, tables, charts, audio, video, WordArt, fields. use deckcraft_color::SchemeSlot; use deckcraft_geom::Xfrm; use deckcraft_model::chart::ChartType; use deckcraft_model::style::PictureFill; use deckcraft_model::text::{AutoFit, RunKind, TextBody}; use deckcraft_model::{Chart, ColorRef, Fill, Geom, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table}; use serde_json::{Value, json}; use super::*; use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ cmd!( "shape.insert", "Shapes", ["Insert", "Illustrations"], None, "{preset: rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets), rect: [x,y,w,h] pt, text?: string}", has_slide, insert_shape ), cmd!(query "shape.presets", "Shape Gallery", [], None, "{} → [{name, label, category}]", always, presets), cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{rect?: [x,y,w,h], text?: string}", has_slide, text_box), cmd!( "insert.picture", "Picture from File…", ["Insert", "Images", "Pictures"], None, "{path? | data: base64, name?, rect?: [x,y,w,h]}", has_slide, picture ), cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table), cmd!( "insert.chart", "Chart…", ["Insert", "Illustrations"], None, "{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}]}", has_slide, chart ), cmd!("insert.audio", "Audio from File…", ["Insert", "Media", "Audio"], None, "{path? | data: base64, name?, rect?}", has_slide, audio), cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video), cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art), cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink), cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd), cmd!( "chart.data", "Edit Data", ["Chart Design", "Data"], None, "{categories?: [..], series?: [{name, values}], id?}", has_selection, chart_data ), cmd!( "chart.options", "Chart Elements", ["Chart Design", "Chart Layouts"], None, "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}", has_selection, chart_options ), cmd!( "insert.smartArt", "SmartArt", ["Insert", "Illustrations"], None, "{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}", has_slide, smart_art ), cmd!( "insert.actionButton", "Action Buttons", ["Insert", "Illustrations"], None, "{kind: back|forward|beginning|end|home|information|return|movie|document|sound|help|custom, rect?}", has_slide, action_button ), ] } /// Default size for a click-inserted shape (1 inch square, PowerPoint style). fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm { let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) } pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Result { s.edit(|doc, sel| { shape.id = ShapeId(doc.alloc_id()); if shape.name.is_empty() { let base = match &shape.kind { ShapeKind::Picture { .. } => "Picture".to_string(), ShapeKind::Table(_) => "Table".to_string(), ShapeKind::Chart(_) => "Chart".to_string(), ShapeKind::Media(m) if m.video => "Video".to_string(), ShapeKind::Media(_) => "Audio".to_string(), _ if shape.text_box => "TextBox".to_string(), _ => shape .geom .preset_name() .and_then(deckcraft_geom::preset::info) .map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string()) .unwrap_or_else(|| "Shape".into()), }; let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1; shape.name = format!("{base} {n}"); } let id = shape.id; let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?; list.push(shape); if select { sel.shapes = vec![id]; sel.text = None; } Ok(id) }) } fn insert_shape(s: &mut Session, p: &Value) -> Result { let preset = str_param(p, "preset").unwrap_or("rect"); let info = deckcraft_geom::preset::info(preset).ok_or_else(|| bad("shape.insert", format!("unknown preset `{preset}`")))?; let line_like = deckcraft_geom::preset::is_line_like(preset); let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, if line_like { 0.0 } else { 72.0 })); let adj: Vec = p.get("adj").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let mut shape = Shape { xfrm: Some(rect), geom: Geom::Preset { name: info.name.into(), adj }, style: Some(if line_like { ShapeStyle::line(SchemeSlot::Accent1) } else { ShapeStyle::accent(SchemeSlot::Accent1) }), ..Default::default() }; if let Some(fl) = p.get("flip").and_then(Value::as_str) && let Some(x) = shape.xfrm.as_mut() { x.flip_h = fl.contains('h'); x.flip_v = fl.contains('v'); } if !line_like { shape.text = Some(TextBody { paragraphs: vec![], ..Default::default() }); if let Some(t) = str_param(p, "text") { shape.text = Some(TextBody::from_text(t)); } if let Some(tb) = shape.text.as_mut() { for para in &mut tb.paragraphs { para.props.align = Some(deckcraft_model::text::Align::Center); } } } else { shape.kind = if preset.contains("Connector") { ShapeKind::Connector { start: None, end: None } } else { ShapeKind::Shape }; if preset == "straightConnector1" { shape.line = Some(deckcraft_model::Line { tail: Some(deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }), ..Default::default() }); } } if let (Some(look), false) = (&s.default_look, line_like) { super::format::apply_painted(&mut shape, look); } let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } fn presets(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array( deckcraft_geom::preset::CATALOG.iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category.label()})).collect(), )) } pub(crate) fn new_text_box(rect: Xfrm, text: &str) -> Shape { let mut body = TextBody::from_text(text); body.body.wrap = Some(true); body.body.autofit = Some(AutoFit::Shape); Shape { xfrm: Some(rect), text_box: true, fill: None, text: Some(body), geom: Geom::preset("rect"), ..Default::default() } } fn text_box(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or(""); let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 216.0, 36.0)); let mut shape = new_text_box(rect, text); // Click-to-create (no width) text boxes grow sideways without wrapping. if rect.w < 1.0 { shape.xfrm = Some(Xfrm { w: 100.0, ..rect }); if let Some(t) = shape.text.as_mut() { t.body.wrap = Some(false); } } let id = add_shape(s, shape, true)?; super::text::fit_text_box(s, id)?; if text.is_empty() { // Start typing immediately. s.select(|_, sel| { sel.shapes = vec![id]; sel.text = Some(crate::TextSel { shape: id, ..Default::default() }); })?; } Ok(json!({"id": id})) } /// Read media bytes from `data` (base64) or `path`. pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { if let Some(d) = str_param(p, "data") { let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?; return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes)); } #[cfg(not(target_arch = "wasm32"))] if let Some(path) = str_param(p, "path") { let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); return Ok((name, bytes)); } Err(bad(cmd, "missing `path` or `data`")) } pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { let l = name.to_ascii_lowercase(); if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png" } else if bytes.starts_with(&[0xFF, 0xD8]) { "image/jpeg" } else if bytes.starts_with(b"GIF8") { "image/gif" } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { "image/webp" } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { "audio/wav" } else if bytes.starts_with(b"BM") { "image/bmp" } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { "image/tiff" } else if bytes.starts_with(b"fLaC") { "audio/flac" } else if bytes.starts_with(b"OggS") { if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } } else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 { // ADTS (layer bits 00): AAC. "audio/aac" } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { "audio/mpeg" } else if bytes.starts_with(b"FORM") { "audio/aiff" } else if bytes.get(4..8) == Some(b"ftyp") { if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") { "audio/mp4" } else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") { "video/quicktime" } else { "video/mp4" } } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { if l.ends_with(".mka") || l.ends_with(".weba") { "audio/webm" } else if l.ends_with(".mkv") { "video/x-matroska" } else { "video/webm" } } else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" } } else if bytes.starts_with(b"caff") { "audio/x-caf" } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) .unwrap_or((288.0, 216.0)); let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); (w * k, h * k) } fn picture(s: &mut Session, p: &Value) -> Result { let (name, bytes) = media_bytes(p, "insert.picture")?; let ct = content_type(&name, &bytes); if !ct.starts_with("image/") { return Err(bad("insert.picture", format!("{name} is not a picture"))); } if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { return Err(EngineError::Other(format!("{name}: the picture can't be read"))); } let (w, h) = fitted(s, &bytes); // Into a selected empty picture/content placeholder, if there is one. let target_ph = s.active().and_then(|d| { d.selected_shapes() .into_iter() .find(|x| { x.ph_type() .is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body)) && x.text.as_ref().is_none_or(|t| t.is_empty()) }) .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) }); let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { Some((_, b)) => { // Fill the placeholder, cropping the picture to its aspect. b } None => default_rect(s, w, h), }); let crop = match (target_ph, rect_param(p, "rect")) { (Some(_), None) => { let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); if ia > ba { let c = (1.0 - ba / ia) / 2.0; [c, 0.0, c, 0.0] } else { let c = (1.0 - ia / ba) / 2.0; [0.0, c, 0.0, c] } } _ => [0.0; 4], }; let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; let shape = Shape { xfrm: Some(rect), kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, descr: name.clone(), ..Default::default() }; if let Some((ph_id, _)) = target_ph { // Replace the placeholder with the picture (it keeps the placeholder link). let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); s.edit(|doc, sel| { let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { sh.kind = shape.kind.clone(); sh.xfrm = Some(rect); sh.ph = phv.map(|mut p| { p.kind = deckcraft_model::PhType::Picture; p }); sh.text = None; sh.descr = name.clone(); } sel.shapes = vec![ph_id]; Ok(()) })?; return Ok(json!({"id": ph_id})); } let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); let size = s.doc()?.doc.slide_size; let w = size.width * 0.8; let rh = 40.0; let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.1, (size.height - rh * rows as f64).max(0.0) / 2.0, w, rh * rows as f64)); let mut t = Table::new(rows, cols, rect.w, rect.h / rows as f64); if let Some(data) = p.get("data").and_then(Value::as_array) { for (r, row) in data.iter().enumerate() { for (c, v) in row.as_array().map(|a| a.as_slice()).unwrap_or(&[]).iter().enumerate() { if let (Some(cell), Some(text)) = (t.cell_mut(r, c), v.as_str()) { cell.text = TextBody::from_text(text); } } } } let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?; Ok(json!({"id": id})) } pub fn chart_type(name: &str) -> ChartType { match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { "bar" | "clusteredbar" => ChartType::Bar, "stackedbar" => ChartType::StackedBar, "percentbar" => ChartType::PercentBar, "line" => ChartType::Line, "linemarkers" | "linewithmarkers" => ChartType::LineMarkers, "stackedline" => ChartType::StackedLine, "area" => ChartType::Area, "stackedarea" => ChartType::StackedArea, "pie" => ChartType::Pie, "doughnut" | "donut" => ChartType::Doughnut, "scatter" | "xy" => ChartType::Scatter, "bubble" => ChartType::Bubble, "radar" => ChartType::Radar, "filledradar" => ChartType::FilledRadar, "stackedcolumn" => ChartType::StackedColumn, "percentcolumn" => ChartType::PercentColumn, "waterfall" => ChartType::Waterfall, "funnel" => ChartType::Funnel, "histogram" => ChartType::Histogram, "pareto" => ChartType::Pareto, "treemap" => ChartType::Treemap, "sunburst" => ChartType::Sunburst, "boxwhisker" | "box" => ChartType::BoxWhisker, "stock" => ChartType::Stock, "surface" => ChartType::Surface, "combo" => ChartType::Combo, _ => ChartType::Column, } } fn chart(s: &mut Session, p: &Value) -> Result { let kind = chart_type(str_param(p, "type").unwrap_or("column")); let mut c = Chart::sample(kind); if let Some(cats) = p.get("categories").and_then(Value::as_array) { c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect(); } if let Some(series) = p.get("series").and_then(Value::as_array) { c.series = series .iter() .map(|sv| deckcraft_model::chart::Series { name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), ..Default::default() }) .collect(); } if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } let size = s.doc()?.doc.slide_size; let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; Ok(json!({"id": id})) } fn media(s: &mut Session, p: &Value, video: bool) -> Result { let cmd = if video { "insert.video" } else { "insert.audio" }; let (name, bytes) = media_bytes(p, cmd)?; let ct = content_type(&name, &bytes); let bytes = std::sync::Arc::new(bytes); let info = deckcraft_media::probe(&bytes); let has_video = info.as_ref().is_ok_and(|i| i.video.is_some()); let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some()); match &info { Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))), Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))), // Not something we can read: embed it anyway when the type says it's media (it still // round-trips through PPTX), otherwise refuse. Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))), Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))), _ => {} } let (duration_ms, width, height, playable) = match &info { Ok(i) => { let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)); (i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable()) } Err(_) => (0, 0, 0, false), }; let warning = (!playable).then(|| match &info { Ok(i) => { let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) } Err(e) => format!("{name} was inserted, but it can't be played ({e})"), }); let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None }; let size = s.doc()?.doc.slide_size; let rect = rect_param(p, "rect").unwrap_or_else(|| { if video { // Natural size (96 dpi), fitted inside 90 % of the slide and centred. let (w, h) = if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) }; let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0); let (w, h) = (w * k, h * k); Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) } else { Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) } }); let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); let (media, poster) = s.edit(|doc, _| { let media = doc.add_media(&name, ct, data); let poster = poster_png.map(|png| doc.add_media(&format!("{name}-poster.png"), "image/png", png)); Ok((media, poster)) })?; let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; if let Some(w) = &warning { s.ui_requests.push(crate::UiRequest::Message { text: w.clone() }); } let mut r = json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning}); if let Ok(i) = &info { r["probe"] = super::media::probe_json(i); } Ok(r) } fn audio(s: &mut Session, p: &Value) -> Result { media(s, p, false) } fn video(s: &mut Session, p: &Value) -> Result { media(s, p, true) } fn word_art(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or("Your text here"); let size = s.doc()?.doc.slide_size; let style = usize_param(p, "style").unwrap_or(0); let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text); if let Some(t) = shape.text.as_mut() { for para in &mut t.paragraphs { para.props.align = Some(deckcraft_model::text::Align::Center); for r in &mut para.runs { r.props.size = Some(54.0); r.props.bold = Some(style % 2 == 1); r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { 0 => SchemeSlot::Tx1, 1 => SchemeSlot::Accent1, 2 => SchemeSlot::Accent2, _ => SchemeSlot::Accent4, }))); if style % 3 == 2 { r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); } } } } let id = add_shape(s, shape, true)?; super::text::fit_text_box(s, id)?; Ok(json!({"id": id})) } fn field_run(s: &mut Session, field: &str, text: &str) -> Result { if s.doc()?.selection.text.is_some() { return super::text::insert_field(s, field, text); } // Not editing: turn on the footer field for the slide (Header & Footer). let key = if field == "slidenum" { "slideNumber" } else { "date" }; s.execute("design.headerFooter", &json!({key: true})) } fn slide_number(s: &mut Session, _p: &Value) -> Result { field_run(s, "slidenum", "‹#›") } fn date_time(s: &mut Session, p: &Value) -> Result { let f = str_param(p, "format").unwrap_or("datetime1").to_string(); field_run(s, &f, "") } 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}; let action = if let Some(u) = str_param(p, "url") { Action::Url { url: u.to_string() } } else if let Some(i) = usize_param(p, "slide") { let id = s.doc()?.doc.slides.get(i).map(|x| x.id).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; Action::Slide { slide: id } } else { return Err(bad("insert.hyperlink", "missing `url` or `slide`")); }; let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false }; if s.doc()?.selection.text.is_some() { let l = link.clone(); return super::text::format_run(s, &move |rp| rp.link = Some(l.clone())); } edit_shapes(s, p, "insert.hyperlink", |sh| { sh.click = Some(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 }), ..Default::default() }; let id = add_shape(s, shape, true)?; Ok(json!({"id": id})) } #[allow(dead_code)] fn _kinds() -> RunKind { RunKind::Text } fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { edit_shapes(s, p, cmd, |sh| match &mut sh.kind { ShapeKind::Chart(c) => f(c), _ => Err(bad(cmd, "not a chart")), }) } fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { let k = chart_type(str_param(p, "type").unwrap_or("column")); with_chart(s, p, "chart.type", |c| { c.kind = k; c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors; 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() }); with_chart(s, p, "chart.data", |c| { if let Some(cs) = &cats { c.categories = cs.clone(); } if let Some(se) = &series { // Keep series formatting by position. let old = std::mem::take(&mut c.series); c.series = se .iter() .enumerate() .map(|(i, n)| { let mut x = old.get(i).cloned().unwrap_or_default(); x.name = n.name.clone(); x.values = n.values.clone(); x }) .collect(); } c.raw = None; Ok(()) }) } fn chart_options(s: &mut Session, p: &Value) -> Result { with_chart(s, p, "chart.options", |c| { if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } if let Some(l) = p.get("legend") { c.legend = l.as_str().map(String::from); } if let Some(v) = bool_param(p, "dataLabels") { c.data_labels = v; } if let Some(v) = bool_param(p, "gridlines") { c.gridlines = v; } if let Some(v) = str_param(p, "palette") { c.palette = v.to_string(); } c.raw = None; Ok(()) }) } /// SmartArt-style graphics built from shapes (grouped), with our own layouts. fn smart_art(s: &mut Session, p: &Value) -> Result { let kind = str_param(p, "kind").unwrap_or("process").to_string(); let items: Vec = p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default(); let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items }; let n = items.len().clamp(1, 12); let size = s.doc()?.doc.slide_size; let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; let mut shapes: Vec<(String, Xfrm, usize)> = vec![]; match kind.as_str() { "cycle" => { let r = area.h.min(area.w) * 0.38; let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); let d = r * 0.62; for i in 0..n { let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i)); } } "hierarchy" => { let w = area.w / (n.max(2) - 1) as f64 * 0.8; shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0)); for i in 1..n { let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64; shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i)); } } "pyramid" => { let h = area.h / n as f64; for i in 0..n { let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i)); } } "matrix" => { let (w, h) = (area.w / 2.0, area.h / 2.0); for i in 0..n.min(4) { shapes.push(( "roundRect".into(), Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0), i, )); } } "list" => { let h = area.h / n as f64; for i in 0..n { shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i)); } } _ => { let gap = 24.0; let w = (area.w - gap * (n as f64 - 1.0)) / n as f64; for i in 0..n { shapes.push(( if i + 1 < n { "homePlate".into() } else { "roundRect".into() }, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, )); } } } let mut ids = vec![]; for (preset, x, i) in shapes { let mut sh = Shape { xfrm: Some(x), geom: Geom::preset(&preset), style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))), ..Default::default() }; let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or("")); body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); for para in &mut body.paragraphs { para.props.align = Some(deckcraft_model::text::Align::Center); for r in &mut para.runs { r.props.size = Some(20.0); } } sh.text = Some(body); ids.push(add_shape(s, sh, false)?.0); } let g = s.execute("arrange.group", &json!({"ids": ids}))?; if let Some(gid) = g.get("id").and_then(Value::as_u64) { let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")})); } Ok(g) }