refactor(engine): split cmd/design.rs into cmd/design/{colors,slide_background,slide_setup,master_view}.rs

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 28371e27d7
commit 9c222be711
5 changed files with 1402 additions and 1382 deletions
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
use super::*;
pub(super) fn colors(s: &mut Session, p: &Value) -> Result<Value> {
let name_v = p.get("name");
let colors_v = p.get("colors");
if name_v.is_none() && colors_v.is_none() {
let sc = current_colors(s)?;
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) });
return ok();
}
let given = match name_v {
None => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty() {
return Err(bad("design.colors", "name is empty"));
}
Some(n.to_string())
}
Some(_) => return Err(bad("design.colors", "`name` must be a string")),
};
let slots = parse_color_slots(colors_v)?;
let current = current_colors(s)?;
let next = next_scheme(&current, given.as_deref(), slots.as_deref())?;
if next == current {
return ok();
}
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?);
m.theme.colors = next;
Ok(())
})?;
ok()
}
pub(super) fn variant(s: &mut Session, p: &Value) -> Result<Value> {
let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize;
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?);
let v = deckcraft_model::theme::theme_variants(&m.theme);
m.theme.colors =
v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?;
Ok(())
})?;
Ok(json!({"variant": index}))
}
pub(super) fn variants(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default();
let v = deckcraft_model::theme::theme_variants(&th);
let current = v.iter().position(|c| c.colors == th.colors.colors);
Ok(json!({
"theme": th.name,
"current": current,
"variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::<Vec<_>>()})).collect::<Vec<_>>(),
}))
}
fn current_colors(s: &Session) -> Result<deckcraft_color::ColorScheme> {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master"))
}
fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value {
let mut colors = serde_json::Map::new();
for slot in deckcraft_color::SchemeSlot::THEME {
colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex())));
}
json!({"name": sc.name, "colors": colors})
}
/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such
/// as `bg1` are rejected because `ColorScheme::set` ignores them.
fn parse_color_slots(colors_v: Option<&Value>) -> Result<Option<Vec<(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)>>> {
let Some(v) = colors_v else { return Ok(None) };
let Some(map) = v.as_object() else {
return Err(bad("design.colors", "`colors` must be an object"));
};
if map.is_empty() {
return Err(bad("design.colors", "`colors` is empty"));
}
let mut slots = Vec::with_capacity(map.len());
for (k, v) in map {
let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else {
return Err(bad("design.colors", format!("unknown colour `{k}`")));
};
if !deckcraft_color::SchemeSlot::THEME.contains(&slot) {
return Err(bad("design.colors", format!("`{k}` is not a theme colour")));
}
let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else {
return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB")));
};
slots.push((slot, rgb));
}
Ok(Some(slots))
}
/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name
/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error.
fn next_scheme(
current: &deckcraft_color::ColorScheme,
given: Option<&str>,
slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>,
) -> Result<deckcraft_color::ColorScheme> {
let mut next = current.clone();
if let Some(name) = given {
if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) {
next = builtin;
} else if slots.is_none() {
return Err(bad("design.colors", format!("no colour scheme `{name}`")));
}
next.name = name.to_string();
}
if let Some(changes) = slots {
for (slot, rgb) in changes {
next.set(*slot, *rgb);
}
if given.is_none() {
next.name = "Custom".into();
}
}
Ok(next)
}
pub(super) fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
let name_v = p.get("name");
let major_v = p.get("major");
let minor_v = p.get("minor");
if name_v.is_none() && major_v.is_none() && minor_v.is_none() {
let scheme = current_fonts(s)?;
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) });
return ok();
}
let given = match name_v {
None => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty() {
return Err(bad("design.fonts", "name is empty"));
}
Some(n.to_string())
}
Some(_) => return Err(bad("design.fonts", "`name` must be a string")),
};
let major = face_param("major", major_v)?;
let minor = face_param("minor", minor_v)?;
let current = current_fonts(s)?;
let next = next_font_scheme(&current, given.as_deref(), major.as_deref(), minor.as_deref())?;
if next == current {
return ok();
}
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next;
Ok(())
})?;
ok()
}
fn current_fonts(s: &Session) -> Result<deckcraft_model::theme::FontScheme> {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master"))
}
fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value {
json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin})
}
/// `None` when the key is absent. A present face must be a non-blank string.
fn face_param(key: &str, v: Option<&Value>) -> Result<Option<String>> {
match v {
None => Ok(None),
Some(Value::String(s)) => {
let s = s.trim();
if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) }
}
Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))),
}
}
/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East
/// Asian, complex-script and per-script slots stay). The stored name is the given string when
/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name.
/// Faces without a name are a custom scheme: an omitted `minor` copies `major`.
fn next_font_scheme(
current: &deckcraft_model::theme::FontScheme,
given: Option<&str>,
major: Option<&str>,
minor: Option<&str>,
) -> Result<deckcraft_model::theme::FontScheme> {
let mut next = current.clone();
if let Some(name) = given {
let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) {
next = builtin;
true
} else {
false
};
if !replaced && major.is_none() && minor.is_none() {
return Err(bad("design.fonts", format!("no font scheme `{name}`")));
}
if major.is_some() || minor.is_some() || !replaced {
next.name = name.to_string();
}
}
if given.is_none() {
if let Some(face) = major {
next.major.latin = face.to_string();
if minor.is_none() {
next.minor.latin = face.to_string();
}
}
if let Some(face) = minor {
next.minor.latin = face.to_string();
}
next.name = "Custom".into();
} else {
if let Some(face) = major {
next.major.latin = face.to_string();
}
if let Some(face) = minor {
next.minor.latin = face.to_string();
}
}
Ok(next)
}
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?;
let scheme = builtin_format_schemes()
.into_iter()
.find(|f| f.name.eq_ignore_ascii_case(name))
.ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?;
let stored = scheme.name.clone();
let same = {
let st = s.doc()?;
let mi = master_index(&st.doc, &st.selection);
st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme)
};
if same {
return Ok(json!({"name": stored}));
}
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme;
Ok(())
})?;
Ok(json!({"name": stored}))
}
+498
View File
@@ -0,0 +1,498 @@
use super::*;
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None
} else {
usize_param(p, "layout")
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
};
let target = match layout {
Some(l) => Target::Layout { master: mi, layout: l },
None => Target::Master { master: mi },
};
s.select(|_, sel| {
sel.target = target;
sel.shapes.clear();
sel.text = None;
})?;
Ok(serde_json::to_value(target).unwrap_or_default())
}
pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) }
}
/// The four handout-master placeholders PowerPoint offers, in a page of `size`.
pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
let (w, h) = (size.width, size.height);
let (name, idx, x, y) = match kind {
PhType::Header => ("Header Placeholder", 0, 0.0, 0.0),
PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0),
PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95),
_ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95),
};
let mut text = TextBody::default();
if let Some(p) = text.paragraphs.first_mut()
&& matches!(kind, PhType::Date | PhType::SlideNum)
{
p.props.align = Some(deckcraft_model::text::Align::Right);
}
Shape {
id,
name: format!("{name} {}", idx + 1),
xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)),
ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }),
text: Some(text),
..Default::default()
}
}
const HANDOUT_KINDS: [(&str, PhType); 4] =
[("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)];
pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result<Value> {
let created = s.doc()?.doc.handout_master.is_none();
if created {
s.edit(|doc, _| {
let mut m = deckcraft_model::Master {
name: "Handout Master".into(),
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
..Default::default()
};
let size = doc.notes_size;
for (_, k) in HANDOUT_KINDS {
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?);
m.shapes.push(handout_placeholder(k, size, id));
}
doc.handout_master = Some(Arc::new(m));
Ok(())
})?;
}
s.select(|_, sel| {
sel.target = Target::Handout;
sel.shapes.clear();
sel.text = None;
})?;
Ok(json!({"created": created}))
}
pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, _| {
let size = doc.notes_size;
let mut ids = vec![];
for (key, _) in HANDOUT_KINDS {
let id = match p.get(key).and_then(Value::as_bool) {
Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)),
None => None,
};
ids.push(id);
}
let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?);
for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) {
let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue };
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
if on && !has {
m.shapes.push(handout_placeholder(kind, size, id));
} else if !on {
m.shapes.retain(|x| x.ph_type() != Some(kind));
}
}
Ok(())
})?;
let m = s.doc()?.doc.handout_master.clone();
let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k)));
Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)}))
}
pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) }
}
const NOTES_KINDS: [(&str, PhType); 6] = [
("header", PhType::Header),
("date", PhType::Date),
("slideImage", PhType::SlideImage),
("body", PhType::Body),
("footer", PhType::Footer),
("pageNumber", PhType::SlideNum),
];
/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master.
pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
let (w, h) = (size.width, size.height);
match kind {
PhType::SlideImage => Shape {
id,
name: "Slide Image Placeholder 3".into(),
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)),
ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }),
fill: Some(Fill::None),
line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)),
..Default::default()
},
PhType::Body => {
let mut text = TextBody::default();
text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")];
Shape {
id,
name: "Notes Placeholder 4".into(),
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)),
ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }),
text: Some(text),
..Default::default()
}
}
_ => {
let mut s = handout_placeholder(kind, size, id);
if let Some(ph) = s.ph.as_mut() {
ph.idx = match kind {
PhType::Header => 0,
PhType::Date => 1,
PhType::Footer => 4,
_ => 5,
};
}
s
}
}
}
pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result<Value> {
let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty());
if created {
s.edit(|doc, _| {
let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master {
name: "Notes Master".into(),
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
..Default::default()
});
let size = doc.notes_size;
for (_, k) in NOTES_KINDS {
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?);
m.shapes.push(notes_placeholder(k, size, id));
}
doc.notes_master = Some(Arc::new(m));
Ok(())
})?;
}
s.select(|_, sel| {
sel.target = Target::NotesMaster;
sel.shapes.clear();
sel.text = None;
})?;
Ok(json!({"created": created}))
}
pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, _| {
let size = doc.notes_size;
let ids: Vec<ShapeId> =
NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::<Result<_>>()?;
let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?);
for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) {
let Some(on) = p.get(key).and_then(Value::as_bool) else { continue };
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
if on && !has {
m.shapes.push(notes_placeholder(kind, size, id));
} else if !on {
m.shapes.retain(|x| x.ph_type() != Some(kind));
}
}
Ok(())
})?;
let m = s.doc()?.doc.notes_master.clone();
let mut out = serde_json::Map::new();
for (key, kind) in NOTES_KINDS {
out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind)))));
}
Ok(Value::Object(out))
}
pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result<Value> {
s.select(|_, sel| {
sel.target = Target::Slides;
sel.shapes.clear();
sel.text = None;
})?;
ok()
}
pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Custom Layout").to_string();
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
let size = doc.slide_size;
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?);
let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| {
t.id = tid;
t.text = Some(TextBody::default());
t
});
let _ = size;
let at = match sel.target {
Target::Layout { layout, .. } => layout + 1,
_ => m.layouts.len(),
};
m.layouts.insert(
at.min(m.layouts.len()),
deckcraft_model::Layout {
id,
name,
kind: deckcraft_model::LayoutType::Custom,
shapes: title.into_iter().collect(),
background: None,
show_master_shapes: true,
preserve: true,
color_map: None,
raw_ext: None,
},
);
sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) };
Ok(json!({"id": id}))
})
}
fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let st = s.doc()?;
let (m, l) = match st.selection.target {
Target::Layout { master, layout } => (master, layout),
_ => (master_index(&st.doc, &st.selection), 0),
};
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
}
pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p, "master.renameLayout")?;
let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string();
s.edit(|doc, _| {
let l =
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?;
l.name = name;
Ok(())
})?;
ok()
}
pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p, "master.deleteLayout")?;
s.edit(|doc, sel| {
let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?;
if doc.slides.iter().any(|x| x.layout == id) {
return Err(bad("master.deleteLayout", "slides use this layout"));
}
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?);
if m.layouts.len() <= 1 {
return Err(bad("master.deleteLayout", "a master needs at least one layout"));
}
m.layouts.remove(li);
sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) };
Ok(())
})?;
ok()
}
pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let kind = match str_param(p, "kind").unwrap_or("content") {
"text" => PhType::Body,
"picture" => PhType::Picture,
"chart" => PhType::Chart,
"table" => PhType::Table,
"media" => PhType::Media,
_ => PhType::Obj,
};
if !matches!(s.doc()?.selection.target, Target::Layout { .. }) {
return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view"));
}
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4));
let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1;
let shape = Shape {
xfrm: Some(rect),
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
text: Some(TextBody::default()),
..Default::default()
};
let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?;
Ok(json!({"id": id}))
}
pub(super) fn preserve(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let (on_master, mi, li, current) = match st.selection.target {
Target::Master { master } => {
let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?;
(true, master, 0, on)
}
Target::Layout { master, layout } => {
let on = st
.doc
.masters
.get(master)
.and_then(|m| m.layouts.get(layout))
.map(|l| l.preserve)
.ok_or_else(|| bad("master.preserve", "no such layout"))?;
(false, master, layout, on)
}
Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")),
};
let next = bool_param(p, "preserve").unwrap_or(!current);
if next == current {
return Ok(json!({"preserve": next}));
}
s.edit(|doc, _| {
if on_master {
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?);
m.preserve = next;
} else {
let l =
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?;
l.preserve = next;
}
Ok(())
})?;
Ok(json!({"preserve": next}))
}
pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result<Value> {
let title = bool_param(p, "title");
let footers = bool_param(p, "footers");
if title.is_none() && footers.is_none() {
return Err(bad("master.placeholders", "give title or footers"));
}
let st = s.doc()?;
if matches!(st.selection.target, Target::Slides) {
return Err(bad("master.placeholders", "open Slide Master view"));
}
let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
let title_changes = title.is_some_and(|want| want != has_title);
let footer_changes = footers.is_some_and(|want| want != has_footers);
if !title_changes && !footer_changes {
return Ok(json!({"title": has_title, "footers": has_footers}));
}
let mut add = Vec::new();
if title == Some(true) && !has_title {
add.push(PhType::Title);
}
if footers == Some(true) && !has_footers {
add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]);
}
let size = st.doc.slide_size;
s.edit(|doc, sel| {
let mut ids = Vec::with_capacity(add.len());
for _ in 0..add.len() {
ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?));
}
let prototypes = match sel.target {
Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(),
Target::Slides => Vec::new(),
Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")),
};
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?;
if title == Some(false) {
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
}
if footers == Some(false) {
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind));
}
for (kind, id) in add.into_iter().zip(ids) {
let idx = free_ph_idx(shapes, preferred_ph_idx(kind));
let proto = prototypes.iter().find(|sh| match kind {
PhType::Title => sh.ph_type().is_some_and(PhType::is_title),
other => sh.ph_type() == Some(other),
});
let shape = match proto {
Some(src) => retarget_placeholder(src.clone(), id, idx),
None => synthesized_placeholder(kind, id, idx, size),
};
shapes.push(shape);
}
Ok(())
})?;
let st = s.doc()?;
let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
Ok(json!({"title": title, "footers": footers}))
}
fn preferred_ph_idx(kind: PhType) -> u32 {
match kind {
PhType::Title | PhType::CtrTitle => 0,
PhType::Date => 10,
PhType::Footer => 11,
PhType::SlideNum => 12,
_ => 1,
}
}
fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 {
let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx));
if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) }
}
/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide
/// number keeps its `‹#›` field.
fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape {
sh.id = id;
if let Some(ph) = sh.ph.as_mut() {
ph.idx = idx;
}
let slide_num = sh.ph_type() == Some(PhType::SlideNum);
if slide_num {
let paras = vec![slide_number_paragraph()];
if let Some(text) = sh.text.as_mut() {
text.paragraphs = paras;
} else {
sh.text = Some(TextBody { paragraphs: paras, ..Default::default() });
}
} else if let Some(text) = sh.text.as_mut() {
text.paragraphs.clear();
}
sh
}
fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape {
let (name, text) = match kind {
PhType::Date => ("Date Placeholder", TextBody::default()),
PhType::Footer => ("Footer Placeholder", TextBody::default()),
PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }),
_ => ("Title", TextBody::default()),
};
Shape {
id,
name: name.into(),
xfrm: Some(placeholder_rect(kind, size)),
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
text: Some(text),
..Default::default()
}
}
/// Standard placeholder boxes at 960×540, scaled to the slide size.
fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm {
let sx = size.width / 960.0;
let sy = size.height / 540.0;
let (x, y, w, h) = match kind {
PhType::Date => (60.0, 500.0, 216.0, 28.0),
PhType::Footer => (318.0, 500.0, 324.0, 28.0),
PhType::SlideNum => (684.0, 500.0, 216.0, 28.0),
_ => (60.0, 29.0, 840.0, 104.0),
};
deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy)
}
fn slide_number_paragraph() -> deckcraft_model::Paragraph {
deckcraft_model::Paragraph {
runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }],
..Default::default()
}
}
@@ -0,0 +1,298 @@
use super::*;
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "copy", false) {
return copy_slide_background(s);
}
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false);
let color = color_param(p, "color");
let style = usize_param(p, "style");
let op = if reset {
BgOp::Set(None)
} else if let Some(mut c) = color {
if let Some(t) = transparency {
set_solid_transparency(&mut c, t);
}
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
} else if let Some(i) = style {
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
let slots =
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
let i = i.clamp(1, 12) - 1;
BgOp::Set(Some(Background::Ref {
idx: 1001 + (i / 4) as u32,
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
}))
} else if let Some(g) = p.get("gradient") {
let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
g.rotate_with_shape = false;
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
} else if let Some(pt) = p.get("pattern") {
BgOp::Set(Some(Background::Fill {
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
}),
}))
} else if p.get("picture").is_some() {
let (name, bytes) =
crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
let ct = crate::cmd::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?;
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
if let Some(pp) = &props {
apply_picture_props(&mut pf, pp);
}
if let Some(t) = transparency {
set_picture_transparency(&mut pf, t);
}
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
} else if props.is_some() || transparency.is_some() {
BgOp::Adjust
} else {
return Err(bad(
"design.background",
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
));
};
let all = bool_or(p, "all", false);
let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) {
let next = {
let st = s.doc()?;
let current = background_of(&st.doc, &st.selection, index);
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
if props.is_some() && !picture {
return Err(bad("design.background", "picture options need a picture background"));
}
if transparency.is_some() && !picture && !solid {
return Err(bad("design.background", "transparency needs a solid or picture background"));
}
let mut next = current.clone();
if let Some(bg) = next.as_mut() {
adjust_background(bg, props.as_ref(), transparency);
}
if next == current { None } else { Some(next) }
};
let Some(next) = next else { return ok() };
s.edit(|doc, sel| {
write_background(doc, sel, index, false, next);
Ok(())
})?;
return ok();
}
let BgOp::Set(bg) = op else { return ok() };
let unchanged = {
let st = s.doc()?;
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
};
if unchanged {
return ok();
}
s.edit(|doc, sel| {
write_background(doc, sel, index, all, bg);
Ok(())
})?;
ok()
}
enum BgOp {
Set(Option<Background>),
Adjust,
}
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
match sel.target {
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
}
}
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
let fallback = (!doc.masters.is_empty()).then_some(0);
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
}
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
match sel.target {
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
Target::Slides => {
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
if all && bg.is_some() {
let masters = used_master_indices(doc);
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
let layouts_match = !masters.is_empty()
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
slides_match && masters_match && layouts_match
} else {
slides_match
}
}
}
}
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
match sel.target {
Target::Master { master } => {
if let Some(m) = doc.masters.get_mut(master) {
Arc::make_mut(m).background = bg;
}
}
Target::Layout { master, layout } => {
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
l.background = bg;
}
}
t @ (Target::Handout | Target::NotesMaster) => {
if let Some(m) = crate::page_master_mut(doc, t) {
Arc::make_mut(m).background = bg;
}
}
Target::Slides => {
if all && bg.is_some() {
for idx in used_master_indices(doc) {
if let Some(m) = doc.masters.get_mut(idx) {
let m = Arc::make_mut(m);
m.background = bg.clone();
for l in &mut m.layouts {
l.background = None;
}
}
}
}
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).background = bg.clone();
}
}
}
}
}
fn copy_slide_background(s: &mut Session) -> Result<Value> {
let bg = {
let st = s.doc()?;
if !matches!(st.selection.target, Target::Slides) {
return Err(bad("design.background", "copy applies to slides"));
}
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
let masters_match = if bg.is_some() {
let masters = used_master_indices(&st.doc);
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
} else {
true
};
if slides_match && masters_match {
return ok();
}
bg
};
s.edit(|doc, sel| {
write_background(doc, sel, None, true, bg);
Ok(())
})?;
ok()
}
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
match bg {
Background::Fill { fill: Fill::Picture(pf) } => {
if let Some(pp) = props {
apply_picture_props(pf, pp);
}
if let Some(t) = transparency {
set_picture_transparency(pf, t);
}
}
Background::Fill { fill: Fill::Solid { color } } => {
if let Some(t) = transparency {
set_solid_transparency(color, t);
}
}
_ => {}
}
}
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
let a = 1.0 - t.clamp(0.0, 1.0);
pf.alpha = (a < 1.0).then_some(a);
}
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
let t = t.clamp(0.0, 1.0);
if t > 0.0 {
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
}
}
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
use deckcraft_model::style::PictureMode;
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
pf.upright = !up;
}
match pp.get("tile").and_then(Value::as_bool) {
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
}
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
_ => {}
}
match &mut pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
if let Some(v) = f("offsetX") {
*tx = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("offsetY") {
*ty = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("scaleX") {
*sx = v.clamp(0.01, 10.0);
}
if let Some(v) = f("scaleY") {
*sy = v.clamp(0.01, 10.0);
}
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
*flip = m.to_string();
}
if let Some(a) =
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
{
*align = a.to_string();
}
}
PictureMode::Stretch { fill_rect } => {
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
for (slot, v) in fill_rect.iter_mut().zip(arr) {
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
*slot = v.clamp(-10.0, 0.99);
}
}
}
}
}
}
pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, sel| {
if let Some(sl) = doc.slides.get_mut(sel.slide) {
let sl = Arc::make_mut(sl);
sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes);
}
Ok(())
})?;
ok()
}
+336
View File
@@ -0,0 +1,336 @@
use super::*;
pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) {
for sh in shapes {
if let Some(x) = sh.xfrm.as_mut() {
x.x = x.x * sx + ox;
x.y = x.y * sy + oy;
x.w *= sx;
x.h *= sy;
}
if let Some(t) = sh.text.as_mut() {
deckcraft_model::edit::format_all(t, &|r| {
if let Some(sz) = r.size.as_mut() {
*sz = (*sz * k).max(1.0);
}
});
}
}
}
pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result<Value> {
let size = if let Some(pre) = str_param(p, "preset") {
let k = pre.to_ascii_lowercase();
let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k));
let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?;
Size::new(*w, *h)
} else {
Size::new(
f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0),
f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0),
)
};
let size = match str_param(p, "orientation") {
Some("portrait") if size.width > size.height => Size::new(size.height, size.width),
Some("landscape") if size.height > size.width => Size::new(size.height, size.width),
Some("portrait" | "landscape") | None => size,
Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))),
};
let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string();
s.edit(|doc, _| {
let old = doc.slide_size;
let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
let (sx, sy, k, ox, oy) = match mode.as_str() {
"none" => (1.0, 1.0, 1.0, 0.0, 0.0),
"maximize" => {
let k = sx.max(sy);
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
}
_ => {
let k = sx.min(sy);
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
}
};
// Masters and layouts stretch to the new size; slides scale by mode.
let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
for m in &mut doc.masters {
let m = Arc::make_mut(m);
scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0);
for l in &mut m.layouts {
scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0);
}
}
for sl in &mut doc.slides {
scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy);
}
doc.slide_size = size;
Ok(())
})?;
Ok(json!({"w": size.width, "h": size.height}))
}
pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, sel| {
let hf = &mut doc.header_footer;
if let Some(v) = bool_param(p, "date") {
hf.date = v;
}
if let Some(v) = str_param(p, "dateText") {
hf.date_text = v.into();
}
if let Some(v) = str_param(p, "dateFormat") {
// Refused rather than ignored: a format that renders nothing would leave the
// placeholder showing its saved text for ever.
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
}
hf.date_format = v.into();
}
if let Some(v) = bool_param(p, "slideNumber") {
hf.slide_number = v;
}
if let Some(v) = bool_param(p, "footer") {
hf.footer = v;
}
if let Some(v) = str_param(p, "footerText") {
hf.footer_text = v.into();
hf.footer = hf.footer || !v.is_empty();
}
if let Some(v) = bool_param(p, "hideOnTitle") {
hf.hide_on_title = v;
}
let hf = hf.clone();
let date_given = str_param(p, "dateText").is_some();
let format_given = str_param(p, "dateFormat").is_some();
let all = bool_or(p, "all", true);
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
for i in list {
let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue };
let lay = doc.layout(layout).map(|(_, l)| l.clone());
let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title);
let want = |k: PhType| {
!(hf.hide_on_title && is_title)
&& match k {
PhType::Date => hf.date,
PhType::SlideNum => hf.slide_number,
PhType::Footer => hf.footer,
_ => false,
}
};
let mut new_ids = vec![];
for _ in 0..3 {
new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?));
}
let Some(sl) = doc.slides.get_mut(i) else { continue };
let sl = Arc::make_mut(sl);
for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() {
let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k));
match (has, want(k)) {
(Some(pos), false) => {
sl.shapes.remove(pos);
}
(None, true) => {
let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned());
let Some(lp) = lp else { continue };
let body = match k {
PhType::SlideNum => TextBody {
paragraphs: vec![deckcraft_model::Paragraph {
runs: vec![Run {
text: "‹#›".into(),
props: Default::default(),
kind: RunKind::Field { field: "slidenum".into() },
}],
..Default::default()
}],
..Default::default()
},
PhType::Date if hf.date_text.is_empty() => TextBody {
paragraphs: vec![deckcraft_model::Paragraph {
runs: vec![Run {
text: date_now(&hf.date_format),
props: Default::default(),
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
}],
..Default::default()
}],
..Default::default()
},
PhType::Date => TextBody::from_text(&hf.date_text),
_ => TextBody::from_text(&hf.footer_text),
};
sl.shapes.push(Shape {
id: new_ids.get(n).copied().unwrap_or_default(),
name: lp.name.clone(),
ph: lp.ph.clone(),
text: Some(body),
..Default::default()
});
}
(Some(pos), true) if k == PhType::Footer => {
if let Some(sh) = sl.shapes.get_mut(pos) {
sh.text = Some(TextBody::from_text(&hf.footer_text));
}
}
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
if let Some(sh) = sl.shapes.get_mut(pos) {
set_date(sh, &hf.date_text, &hf.date_format);
}
}
_ => {}
}
}
}
Ok(())
})?;
ok()
}
/// The date field type `format` names, `datetime1` when it is empty or unknown.
fn date_kind(format: &str) -> &str {
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
}
/// The saved text of a date field of `format`, as it shows now.
fn date_now(format: &str) -> String {
let kind = date_kind(format);
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
}
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
/// `fixed`, at an automatic date field (one it already has keeps its format).
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
let field_now = runs().find_map(|r| match &r.kind {
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
_ => None,
});
let has_field = field_now.is_some();
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
let want = date_kind(format);
let (text, kind) = match fixed {
// Automatic: keep the field, but follow a format that has changed.
"" if field_now.as_deref() == Some(want) => return,
"" if has_field && format.is_empty() => return,
"" => (date_now(format), RunKind::Field { field: want.into() }),
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
_ => (fixed.to_string(), RunKind::Text),
};
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
}
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
/// field. Empty where there is no clock (wasm).
pub fn today() -> String {
date_field_text("datetime1")
}
/// What date field `kind` shows now (empty if unknown or there is no clock).
pub fn date_field_text(kind: &str) -> String {
use deckcraft_text::datetime::{DateTime, field_text};
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
}
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
pub fn civil(z: i64) -> (i64, u32, u32) {
use chrono::Datelike;
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
// 0..146_097 days from it always fits.
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1),
}
}
#[cfg(test)]
mod header_footer_date_tests {
use deckcraft_model::PhType;
use deckcraft_model::text::RunKind;
use serde_json::json;
use crate::Session;
/// The date field of the Date placeholders of every slide: (saved text, field type).
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
let d = &s.doc().unwrap().doc;
d.slides
.iter()
.flat_map(|sl| sl.shapes.iter())
.filter(|sh| sh.ph_type() == Some(PhType::Date))
.flat_map(|sh| sh.text.iter())
.flat_map(|t| t.paragraphs.iter())
.flat_map(|p| p.runs.iter())
.map(|r| {
let kind = match &r.kind {
RunKind::Field { field } => Some(field.clone()),
_ => None,
};
(r.text.clone(), kind)
})
.collect()
}
fn sample(kind: &str) -> String {
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
}
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
/// The clock is read before and after the command, so a second ticking over between the
/// command and the read cannot fail the comparison. Returns the run text.
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
let before = sample(kind);
s.execute("design.headerFooter", &params).unwrap();
let after = sample(kind);
let got = dates(s);
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1.as_deref(), Some(kind));
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
got[0].0.clone()
}
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
/// existed for this and was never read: the placeholder was always `datetime1`, and a
/// `dateFormat` parameter was accepted and silently dropped.
#[test]
fn the_automatic_date_takes_the_format_it_is_given() {
let mut s = Session::with_new();
// Default: the short date, as before.
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
// A format: the field type changes and the saved text is that format's.
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
// A time format works the same way, and these two differ, so the format is really read.
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
assert_ne!(time, sample("datetime5"));
// Asking again changes nothing (and does not rewrite the run).
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
assert_eq!(again, time);
// A fixed date still wins: plain text, no field.
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
// Back to automatic: the remembered format comes back, not datetime1.
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
// An unknown format is refused rather than ignored, and changes nothing.
for bad in ["datetime0", "datetime14", "nonsense", ""] {
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
let got = dates(&s);
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
}
}