mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 20:16:50 -06:00
View ▸ Handout Master opens the handout master (creating one with header, date, footer and page-number placeholders if the deck has none) on a page at the notes size, with a dashed preview of where 1-9 slides go. Its contextual tab toggles the placeholders, sets the background and closes the view. Stacked on #533, which reads and writes the handout master.
675 lines
29 KiB
Rust
675 lines
29 KiB
Rust
//! Design tab and masters: themes, colours, fonts, backgrounds, slide size, header & footer,
|
||
//! Slide Master view.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use deckcraft_geom::Size;
|
||
use deckcraft_model::text::{Run, RunKind, TextBody};
|
||
use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builtin_themes};
|
||
use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::*;
|
||
use crate::{Result, Session, Target};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme),
|
||
cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes and font schemes", always, themes),
|
||
cmd!("design.colors", "Colors", ["Design", "Variants"], None, "{name} | {colors: {accent1: #RRGGBB, …}}", has_doc, colors),
|
||
cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{name} | {major, minor}", has_doc, fonts),
|
||
cmd!(
|
||
"design.background",
|
||
"Format Background",
|
||
["Design", "Customize"],
|
||
None,
|
||
"{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}",
|
||
has_slide,
|
||
background
|
||
),
|
||
cmd!("design.hideBackgroundGraphics", "Hide Background Graphics", ["Format Background"], None, "{hide?: bool}", has_slide, hide_bg_graphics),
|
||
cmd!(
|
||
"design.slideSize",
|
||
"Slide Size",
|
||
["Design", "Customize"],
|
||
None,
|
||
"{preset?: widescreen|standard|… | w, h: pt, scale?: maximize|ensureFit|none}",
|
||
has_doc,
|
||
slide_size
|
||
),
|
||
cmd!(
|
||
"design.headerFooter",
|
||
"Header and Footer…",
|
||
["Insert", "Text"],
|
||
None,
|
||
"{date?: bool, dateText?: fixed text, slideNumber?: bool, footer?: bool, footerText?, hideOnTitle?: bool, all?: bool (default true)}",
|
||
has_doc,
|
||
header_footer
|
||
),
|
||
cmd!(noundo "view.slideMaster", "Slide Master", ["View", "Master Views"], None, "{master?: index, layout?: index}", has_doc, master_view),
|
||
cmd!(
|
||
"view.handoutMaster",
|
||
"Handout Master",
|
||
["View", "Master Views"],
|
||
None,
|
||
"{} (creates the handout master with header, date, footer and page number if the deck has none)",
|
||
has_doc,
|
||
handout_view
|
||
),
|
||
cmd!(
|
||
"handout.placeholders",
|
||
"Handout Placeholders",
|
||
["Handout Master", "Placeholders"],
|
||
None,
|
||
"{header?: bool, date?: bool, footer?: bool, pageNumber?: bool} (show or hide each)",
|
||
in_handout,
|
||
handout_placeholders
|
||
),
|
||
cmd!(noundo "view.closeMaster", "Close Master View", ["Slide Master", "Close"], None, "{}", has_doc, close_master),
|
||
cmd!("master.insertLayout", "Insert Layout", ["Slide Master", "Edit Master"], None, "{name?}", has_doc, insert_layout),
|
||
cmd!("master.renameLayout", "Rename Layout", ["Slide Master", "Edit Master"], None, "{name, master?, layout?}", has_doc, rename_layout),
|
||
cmd!("master.deleteLayout", "Delete Layout", ["Slide Master", "Edit Master"], None, "{master?, layout?}", has_doc, delete_layout),
|
||
cmd!(
|
||
"master.insertPlaceholder",
|
||
"Insert Placeholder",
|
||
["Slide Master", "Master Layout"],
|
||
None,
|
||
"{kind: content|text|picture|chart|table|media, rect?: [x,y,w,h]}",
|
||
has_doc,
|
||
insert_placeholder
|
||
),
|
||
]
|
||
}
|
||
|
||
fn theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let name = str_param(p, "name").ok_or_else(|| bad("design.theme", "missing `name`"))?;
|
||
let t =
|
||
builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad("design.theme", format!("no theme `{name}`")))?;
|
||
s.edit(|doc, sel| {
|
||
let mi = master_index(doc, sel);
|
||
let m = doc.masters.get_mut(mi).ok_or_else(|| bad("design.theme", "no master"))?;
|
||
let m = Arc::make_mut(m);
|
||
m.theme = t.clone();
|
||
m.name = t.name.clone();
|
||
m.color_map = vec![];
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"theme": name}))
|
||
}
|
||
|
||
fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize {
|
||
match sel.target {
|
||
Target::Master { master } | Target::Layout { master, .. } => master,
|
||
Target::Slides | Target::Handout => {
|
||
doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn themes(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||
Ok(json!({
|
||
"themes": builtin_themes().iter().map(|t| json!({"name": t.name, "major": t.fonts.major.latin, "minor": t.fonts.minor.latin, "colors": t.colors.colors.iter().map(|c| format!("#{}", c.hex())).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||
"colorSchemes": builtin_color_schemes().iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
|
||
"fontSchemes": builtin_font_schemes().iter().map(|f| json!({"name": f.name, "major": f.major.latin, "minor": f.minor.latin})).collect::<Vec<_>>(),
|
||
}))
|
||
}
|
||
|
||
fn colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let scheme = if let Some(name) = str_param(p, "name") {
|
||
Some(
|
||
builtin_color_schemes()
|
||
.into_iter()
|
||
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||
.ok_or_else(|| bad("design.colors", format!("no colour scheme `{name}`")))?,
|
||
)
|
||
} else {
|
||
None
|
||
};
|
||
let custom = p.get("colors").and_then(Value::as_object).cloned();
|
||
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"))?);
|
||
if let Some(sc) = &scheme {
|
||
m.theme.colors = sc.clone();
|
||
}
|
||
if let Some(c) = &custom {
|
||
for (k, v) in c {
|
||
if let (Some(slot), Some(rgb)) = (deckcraft_color::SchemeSlot::from_xml(k), v.as_str().and_then(deckcraft_color::Rgba::from_hex)) {
|
||
m.theme.colors.set(slot, rgb);
|
||
}
|
||
}
|
||
m.theme.colors.name = "Custom".into();
|
||
}
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let scheme = if let Some(name) = str_param(p, "name") {
|
||
builtin_font_schemes()
|
||
.into_iter()
|
||
.find(|f| f.name.eq_ignore_ascii_case(name))
|
||
.ok_or_else(|| bad("design.fonts", format!("no font scheme `{name}`")))?
|
||
} else {
|
||
let major = str_param(p, "major").ok_or_else(|| bad("design.fonts", "give `name`, or `major` and `minor`"))?;
|
||
let minor = str_param(p, "minor").unwrap_or(major);
|
||
deckcraft_model::theme::FontScheme {
|
||
name: "Custom".into(),
|
||
major: deckcraft_model::theme::FontSet { latin: major.into(), ..Default::default() },
|
||
minor: deckcraft_model::theme::FontSet { latin: minor.into(), ..Default::default() },
|
||
}
|
||
};
|
||
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 = scheme;
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let bg: Option<Background> = if bool_or(p, "reset", false) {
|
||
None
|
||
} else if let Some(c) = color_param(p, "color") {
|
||
Some(Background::Fill { fill: Fill::solid(c) })
|
||
} else if let Some(i) = usize_param(p, "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;
|
||
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 = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
|
||
g.rotate_with_shape = false;
|
||
Some(Background::Fill { fill: Fill::Gradient(g) })
|
||
} else if let Some(pt) = p.get("pattern") {
|
||
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) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
|
||
let ct = super::insert::content_type(&name, &bytes);
|
||
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
|
||
Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) })
|
||
} else {
|
||
return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`"));
|
||
};
|
||
let all = bool_or(p, "all", false);
|
||
let index = usize_param(p, "index");
|
||
s.edit(|doc, sel| {
|
||
match sel.target {
|
||
Target::Master { master } => {
|
||
if let Some(m) = doc.masters.get_mut(master) {
|
||
Arc::make_mut(m).background = bg.clone();
|
||
}
|
||
}
|
||
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.clone();
|
||
}
|
||
}
|
||
Target::Handout => {
|
||
if let Some(m) = doc.handout_master.as_mut() {
|
||
Arc::make_mut(m).background = bg.clone();
|
||
}
|
||
}
|
||
Target::Slides => {
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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}))
|
||
}
|
||
|
||
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) = 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 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()));
|
||
}
|
||
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: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".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 => {
|
||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||
set_date(sh, &hf.date_text);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
/// 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) {
|
||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||
let (text, kind) = match fixed {
|
||
"" if has_field => return,
|
||
"" => (today(), RunKind::Field { field: "datetime1".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) (Howard Hinnant's algorithm).
|
||
pub fn civil(z: i64) -> (i64, u32, u32) {
|
||
let z = z + 719_468;
|
||
let era = z.div_euclid(146_097);
|
||
let doe = z.rem_euclid(146_097);
|
||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||
let y = yoe + era * 400;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||
}
|
||
|
||
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));
|
||
let layout = 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())
|
||
}
|
||
|
||
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)];
|
||
|
||
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());
|
||
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}))
|
||
}
|
||
|
||
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 {
|
||
ids.push(p.get(key).and_then(Value::as_bool).map(|_| ShapeId(doc.alloc_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)}))
|
||
}
|
||
|
||
fn close_master(s: &mut Session, _p: &Value) -> Result<Value> {
|
||
s.select(|_, sel| {
|
||
sel.target = Target::Slides;
|
||
sel.shapes.clear();
|
||
sel.text = None;
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
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());
|
||
let tid = ShapeId(doc.alloc_id());
|
||
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) -> 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((usize_param(p, "master").unwrap_or(m), usize_param(p, "layout").unwrap_or(l)))
|
||
}
|
||
|
||
fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (mi, li) = layout_target(s, p)?;
|
||
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()
|
||
}
|
||
|
||
fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (mi, li) = layout_target(s, p)?;
|
||
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()
|
||
}
|
||
|
||
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 = super::insert::add_shape(s, shape, true)?;
|
||
Ok(json!({"id": id}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#[test]
|
||
fn civil_dates() {
|
||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||
assert_eq!(super::civil(20_734), (2026, 10, 8));
|
||
}
|
||
}
|