Files
deckcraft/crates/engine/src/cmd/insert.rs
T

790 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<CommandSpec> {
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<ShapeId> {
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<Value> {
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<f64> = 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<Value> {
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<Value> {
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<u8>)> {
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"<svg") {
"image/svg+xml"
} else if l.ends_with(".aac") {
"audio/aac"
} else {
"application/octet-stream"
}
}
/// Natural size of an image in points (96 dpi), fitted inside the slide.
fn fitted(s: &Session, bytes: &[u8]) -> (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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
media(s, p, false)
}
fn video(s: &mut Session, p: &Value) -> Result<Value> {
media(s, p, true)
}
fn word_art(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
field_run(s, "slidenum", "‹#›")
}
fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "format").unwrap_or("datetime1").to_string();
field_run(s, &f, "")
}
fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
let cats: Option<Vec<String>> = 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<Vec<deckcraft_model::chart::Series>> = 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<Value> {
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<Value> {
let kind = str_param(p, "kind").unwrap_or("process").to_string();
let items: Vec<String> =
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)
}