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

1875 lines
78 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, Line, 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!(
"insert.icon",
"Icons",
["Insert", "Illustrations"],
None,
"{name: star|arrow-right|… (see insert.icons), rect?: [x,y,w,h]}",
has_slide,
insert_icon
),
cmd!(query "insert.icons", "Icon Gallery", [], None, "{} → [{name, label, category}]", always, list_icons),
cmd!(query "shape.presets", "Shape Gallery", [], None, "{} → [{name, label, category}]", always, presets),
cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{rect?: [x,y,w,h], text?: string}", has_slide, text_box),
cmd!(
"insert.picture",
"Picture from File…",
["Insert", "Images", "Pictures"],
None,
"{path? | data: base64, name?, rect?: [x,y,w,h]}",
has_slide,
picture
),
cmd!(
"insert.photoAlbum",
"Photo Album…",
["Insert", "Images"],
None,
"{pictures?: [{path?|data: base64, name?, rot?: deg, brightness?: -1..1, contrast?: -1..1} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool, grayscale?: bool, theme?: name} — omit pictures to open the dialog",
has_doc,
photo_album
),
cmd!(
"insert.photoAlbumEdit",
"Edit Photo Album…",
["Insert", "Images"],
None,
"{album?: id, pictures?: same as insert.photoAlbum, layout?, frame?, captions?, grayscale?, theme?} — omit pictures to reopen the dialog on the current album",
has_doc,
photo_album_edit
),
cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table),
cmd!(
"insert.chart",
"Chart…",
["Insert", "Illustrations"],
None,
"{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}]}",
has_slide,
chart
),
cmd!("insert.audio", "Audio from File…", ["Insert", "Media", "Audio"], None, "{path? | data: base64, name?, rect?}", has_slide, audio),
cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video),
cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art),
cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number),
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", 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?, text?: shown when nothing is selected, 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(),
ShapeKind::Model3d { .. } => "3D Model".to_string(),
_ if shape.text_box => "TextBox".to_string(),
_ => shape
.geom
.preset_name()
.and_then(deckcraft_geom::preset::info)
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
.unwrap_or_else(|| "Shape".into()),
};
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1;
shape.name = format!("{base} {n}");
}
let id = shape.id;
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
list.push(shape);
if select {
sel.shapes = vec![id];
sel.text = None;
}
Ok(id)
})
}
fn insert_shape(s: &mut Session, p: &Value) -> Result<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(),
))
}
fn list_icons(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(crate::icons::icons().iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category})).collect()))
}
/// A Lucide stroke icon: custom geometry, no fill, round line scaled from the 24-unit design.
fn insert_icon(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("insert.icon", "missing `name`"))?;
let icon = crate::icons::find(name).ok_or_else(|| bad("insert.icon", format!("unknown icon `{name}`")))?;
let d = crate::icons::path_data(icon.name).ok_or_else(|| bad("insert.icon", format!("could not build icon `{}`", icon.name)))?;
let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, 72.0));
if rect.w < 1.0 || rect.h < 1.0 {
return Err(bad("insert.icon", "rect needs a positive width and height"));
}
let shape = Shape {
name: icon.label.to_string(),
xfrm: Some(rect),
geom: Geom::Custom {
paths: vec![deckcraft_model::CustomPath {
w: crate::icons::VIEW,
h: crate::icons::VIEW,
d,
fill: deckcraft_geom::preset::FillMode::None,
stroke: true,
}],
},
fill: Some(Fill::None),
line: Some(Line {
fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))),
width: Some(crate::icons::stroke_width(icon, rect.w, rect.h)),
cap: Some(deckcraft_model::style::LineCap::Round),
join: Some(deckcraft_model::style::LineJoin::Round),
..Default::default()
}),
style: Some(ShapeStyle::line(SchemeSlot::Tx1)),
..Default::default()
};
let id = add_shape(s, shape, true)?;
Ok(json!({"id": id}))
}
pub(crate) fn new_text_box(rect: Xfrm, text: &str) -> Shape {
let mut body = TextBody::from_text(text);
body.body.wrap = Some(true);
body.body.autofit = Some(AutoFit::Shape);
Shape { xfrm: Some(rect), text_box: true, fill: None, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }
}
fn text_box(s: &mut Session, p: &Value) -> Result<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}))
}
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<u8>,
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<String>,
}
enum AlbumItem {
Picture(AlbumPic),
Text(String),
}
enum AlbumSlide {
Pictures(Vec<AlbumPic>),
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<Value> {
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<Value> {
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<AlbumItem>, opts: AlbumOpts, replace: Option<(u32, Vec<u64>)>) -> Result<Value> {
let (snapshot, sel) = {
let st = s.doc()?;
(st.doc.clone(), st.selection.clone())
};
let result = (|| {
let album_id = match replace.as_ref().map(|(id, _)| *id) {
Some(id) if id != 0 => id,
_ => s.edit(|doc, _| Ok(doc.alloc_id()))?,
};
let at = if let Some((_, ids)) = &replace {
let st = s.doc()?;
let indices: Vec<usize> = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect();
let Some(at) = indices.iter().copied().min() else {
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
};
s.execute("slide.delete", &json!({"ids": ids}))?;
Some(at)
} else {
None
};
let mut opts = opts;
if opts.theme.as_deref().unwrap_or("").is_empty() {
opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty());
}
apply_theme_if_needed(s, opts.theme.as_deref())?;
insert_album(s, items, &opts, at, album_id)
})();
if result.is_err()
&& let Some(st) = s.active_mut()
{
st.doc = snapshot;
st.selection = sel;
}
result
}
fn album_norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
}
fn album_layout(p: &Value) -> Result<AlbumLayout> {
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<AlbumFrame> {
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<bool> {
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<bool> {
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<AlbumOpts> {
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<f64> {
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<f64> {
let n = album_finite(item, "rot")?;
Ok(((n % 360.0) + 360.0) % 360.0)
}
fn album_unit(item: &Value, key: &str) -> Result<f64> {
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<Vec<AlbumItem>> {
let Some(arr) = p.get("pictures").and_then(Value::as_array) else {
return Err(bad("insert.photoAlbum", "`pictures` must be a list"));
};
if arr.is_empty() {
return Err(bad("insert.photoAlbum", "add at least one picture or text box"));
}
if arr.len() > ALBUM_MAX_ITEMS {
return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes"));
}
let mut items = Vec::with_capacity(arr.len());
let mut total = 0usize;
for item in arr {
let Some(obj) = item.as_object() else {
return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box"));
};
let has_media = obj.contains_key("data") || obj.contains_key("path");
if !has_media {
let Some(text) = obj.get("text").and_then(Value::as_str) else {
return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`"));
};
let text = text.trim();
if text.is_empty() {
return Err(bad("insert.photoAlbum", "a text box needs some text"));
}
if text.chars().count() > ALBUM_MAX_TEXT {
return Err(bad("insert.photoAlbum", "text is longer than 400 characters"));
}
items.push(AlbumItem::Text(text.to_string()));
continue;
}
let (name, bytes) = media_bytes(item, "insert.photoAlbum")?;
if bytes.len() > ALBUM_MAX_BYTES {
return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB")));
}
let next = total.saturating_add(bytes.len());
if next > ALBUM_MAX_TOTAL {
return Err(bad("insert.photoAlbum", "the album is larger than 64 MB"));
}
total = next;
let ct = content_type(&name, &bytes);
if !ct.starts_with("image/") {
return Err(bad("insert.photoAlbum", format!("{name} is not a picture")));
}
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read")));
}
let (px_w, px_h) = image_px(&bytes);
items.push(AlbumItem::Picture(AlbumPic {
name,
bytes,
px_w,
px_h,
rot: album_rot(item)?,
brightness: album_unit(item, "brightness")?,
contrast: album_unit(item, "contrast")?,
}));
}
Ok(items)
}
fn image_px(bytes: &[u8]) -> (f64, f64) {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()
.and_then(|r| r.into_dimensions().ok())
.map(|(w, h)| (w as f64, h as f64))
.filter(|(w, h)| *w > 0.0 && *h > 0.0)
.unwrap_or((4.0, 3.0))
}
fn file_stem(name: &str) -> String {
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
let stem = match base.rsplit_once('.') {
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
_ => base,
};
if stem.is_empty() { "Picture".to_string() } else { stem.to_string() }
}
fn album_plan(items: Vec<AlbumItem>, per: usize) -> Vec<AlbumSlide> {
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<Placed>) {
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<Placed>) {
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", &params)?;
Ok(())
}
struct AlbumMark {
id: u32,
layout: String,
frame: String,
captions: bool,
grayscale: bool,
theme: String,
}
fn parse_album_mark(raw: &str) -> Option<AlbumMark> {
let rest = raw.strip_prefix("deckcraft-album:")?;
let (id_str, rest) = rest.split_once(';').unwrap_or((rest, ""));
let id = id_str.parse::<u32>().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<u32> {
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::<u32>().ok().filter(|v| *v != 0),
_ => None,
}
}
fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec<u64> {
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<u64> {
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<u64>)> {
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<f64> =
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<Value> {
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<Value> {
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<AlbumItem>, opts: &AlbumOpts, at: Option<usize>, album_id: u32) -> Result<Value> {
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<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();
// Saved with the date it shows now (the text kept where the date can't be computed).
field_run(s, &f, &super::design::date_field_text(&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};
// `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: false, sound: None, stop_sound: false };
if s.doc()?.selection.text.is_some() {
return super::text::link_text(s, link, &display);
}
edit_shapes(s, p, "insert.hyperlink", |sh| {
sh.click = Some(link.clone());
Ok(())
})
}
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, sound: None, stop_sound: false }),
..Default::default()
};
let id = add_shape(s, shape, true)?;
Ok(json!({"id": id}))
}
#[allow(dead_code)]
fn _kinds() -> RunKind {
RunKind::Text
}
fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result<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)
}
#[cfg(test)]
mod tests {
use deckcraft_model::ShapeKind;
use deckcraft_model::style::LineJoin;
use super::*;
/// 8×8 red PNG.
fn png() -> Vec<u8> {
vec![
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73,
68, 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174,
66, 96, 130,
]
}
fn pic(name: &str) -> Value {
json!({"name": name, "data": base64_encode(&png())})
}
fn n_slides(s: &Session) -> usize {
s.active().map(|d| d.doc.slides.len()).unwrap_or(0)
}
fn pictures(s: &Session, index: usize) -> Vec<deckcraft_model::Shape> {
s.active()
.and_then(|d| d.doc.slides.get(index))
.map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect())
.unwrap_or_default()
}
fn box_text(s: &Session, index: usize) -> Vec<String> {
s.active()
.and_then(|d| d.doc.slides.get(index))
.map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect())
.unwrap_or_default()
}
#[test]
fn two_pictures_one_per_slide_are_one_undo() {
let mut s = Session::with_new();
let before = n_slides(&s);
let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album");
assert_eq!(r["slides"], 2);
assert_eq!(n_slides(&s), before + 2);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
assert_eq!(s.active().unwrap().selection.slide, 1);
let undo = &s.active().unwrap().history.undo;
assert_eq!(undo.len(), 1);
assert_eq!(undo[0].label, "Photo Album…");
s.execute("edit.undo", &json!({})).expect("undo");
assert_eq!(n_slides(&s), before);
assert!(pictures(&s, 1).is_empty());
}
#[test]
fn two_up_shares_one_slide() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album");
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
assert!(box_text(&s, 1).is_empty());
}
#[test]
fn captions_and_titles_use_the_file_stem() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions");
assert_eq!(box_text(&s, 1), ["harbor"]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title");
assert_eq!(box_text(&s, 1), ["harbor"]);
assert_eq!(pictures(&s, 1).len(), 1);
let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y;
assert!(pic_y > 40.0, "the picture sits below the title");
}
#[test]
fn frames_set_the_picture_line() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect");
let line = pictures(&s, 1)[0].line.clone().expect("line");
assert_eq!(line.width, Some(1.5));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded");
let line = pictures(&s, 1)[0].line.clone().expect("line");
assert_eq!(line.width, Some(2.25));
assert_eq!(line.join, Some(LineJoin::Round));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple");
assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0));
}
#[test]
fn a_text_box_breaks_the_picture_row() {
let mut s = Session::with_new();
let r = s
.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"}))
.expect("album");
assert_eq!(r["slides"], 3);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(box_text(&s, 2), ["A day on the water"]);
assert!(pictures(&s, 2).is_empty());
assert_eq!(pictures(&s, 3).len(), 1);
assert_eq!(s.active().unwrap().history.undo.len(), 1);
}
#[test]
fn no_pictures_opens_the_dialog_without_editing() {
let mut s = Session::with_new();
let before = n_slides(&s);
let r = s.execute("insert.photoAlbum", &json!({})).expect("open");
assert_eq!(r["dialog"], "photoAlbum");
assert_eq!(n_slides(&s), before);
assert!(s.active().unwrap().history.undo.is_empty());
assert_eq!(s.ui_requests.len(), 1);
match &s.ui_requests[0] {
crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"),
other => panic!("dialog request, got {other:?}"),
}
}
#[test]
fn bad_input_adds_no_slides() {
let mut s = Session::with_new();
let before = n_slides(&s);
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err());
let mut many = Vec::new();
for i in 0..49 {
many.push(json!({"text": format!("box {i}")}));
}
assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err());
assert_eq!(n_slides(&s), before);
assert!(s.active().unwrap().history.undo.is_empty());
}
#[test]
fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap();
let one = pictures(&s, 1)[0].xfrm.unwrap().h;
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap();
let fitted = pictures(&s, 1)[0].xfrm.unwrap().h;
assert!(fitted > one, "fit {fitted} should be taller than one-up {one}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap();
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 4);
}
fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust {
match &pictures(s, index)[0].kind {
ShapeKind::Picture { fill } => fill.adjust.clone(),
_ => panic!("picture"),
}
}
fn theme_name(s: &Session) -> String {
s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default()
}
#[test]
fn edit_without_pictures_reopens_the_dialog() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
s.ui_requests.clear();
let before = n_slides(&s);
let undos = s.active().unwrap().history.undo.len();
let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen");
assert_eq!(r["dialog"], "photoAlbum");
assert_eq!(n_slides(&s), before);
assert_eq!(s.active().unwrap().history.undo.len(), undos);
let params = match &s.ui_requests[0] {
crate::UiRequest::Dialog { id, params } => {
assert_eq!(id, "photoAlbum");
params
}
other => panic!("dialog request, got {other:?}"),
};
assert_eq!(params["items"][0]["name"], "hill.png");
assert_eq!(params["items"][1]["name"], "lake.png");
assert_eq!(params["layout"], "1");
assert!(params["album"].as_u64().unwrap_or(0) > 0);
}
#[test]
fn edit_reflows_the_album_in_one_undo() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
assert_eq!(n_slides(&s), 3);
let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow");
assert_eq!(r["slides"], 1);
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
let undo = &s.active().unwrap().history.undo;
assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(n_slides(&s), 3);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
}
#[test]
fn picture_options_and_theme_are_one_step() {
let mut s = Session::with_new();
s.execute(
"insert.photoAlbum",
&json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}),
)
.unwrap();
let adj = adjust(&s, 1);
assert!(adj.grayscale);
assert!((adj.brightness - 0.2).abs() < 1e-6);
assert!((adj.contrast + 0.1).abs() < 1e-6);
assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01);
assert_eq!(theme_name(&s), "Harbor");
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
assert!(mark.contains("gray=1"), "{mark}");
assert!(mark.contains("theme=Harbor"), "{mark}");
assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(theme_name(&s), "DeckCraft");
assert_eq!(n_slides(&s), 1);
}
#[test]
fn unknown_theme_and_a_plain_slide_change_nothing() {
let mut s = Session::with_new();
let before = n_slides(&s);
let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err();
assert!(err.to_string().contains("unknown theme"), "{err}");
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err());
let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err();
assert!(err.to_string().contains("not part of a photo album"), "{err}");
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err());
assert_eq!(n_slides(&s), before + 1);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(theme_name(&s), "DeckCraft");
}
#[test]
fn two_albums_stay_independent() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
let last = n_slides(&s) - 1;
s.execute("slide.go", &json!({"index": last})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap();
assert_eq!(n_slides(&s), 5);
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap();
assert_eq!(n_slides(&s), 4);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
assert_eq!(pictures(&s, 3).len(), 2);
let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default();
assert!(first.starts_with("deckcraft-album:"));
assert!(second.starts_with("deckcraft-album:"));
assert_ne!(first.split(';').next(), second.split(';').next());
}
#[test]
fn a_named_run_without_a_marker_still_reflows() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
{
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
for sl in doc.slides.iter_mut().skip(1) {
std::sync::Arc::make_mut(sl).raw_ext = None;
}
}
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap();
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
assert!(mark.starts_with("deckcraft-album:"), "{mark}");
}
}