mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 12:36:56 -06:00
337 lines
15 KiB
Rust
337 lines
15 KiB
Rust
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", ¶ms).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"));
|
||
}
|
||
}
|