Header & Footer: the automatic date takes a format

`HeaderFooter::date_format` is documented as the `datetime1`…`datetime13`
format for automatic dates, and nothing read it: grep found one hit, the field's
own declaration. The Date placeholder was hardcoded to `datetime1`, so the
footer date was always the short date, and a `dateFormat` parameter was accepted
and silently dropped — the command returned Ok, so a caller could not tell.

Everything else was already there: `datetime::field_text` renders all thirteen
types and Insert ▸ Date & Time already offers them. Missing was the wiring from
a choice to the placeholder.

`design.headerFooter` now takes `dateFormat`, refuses anything outside the
thirteen rather than ignoring it, stores it, creates the placeholder with that
field type, and updates an existing one when the format changes — as it already
did for a changed `dateText`. A fixed date still wins as plain text, and going
back to automatic restores the remembered format.

`datetime::DATE_KINDS` is the canonical list; `dialogs.rs` uses it rather than
its own copy of the same thirteen strings, and the Header & Footer dialog gains
a Format dropdown listing each type by what it would show now, enabled while the
date is automatic.

Not here: reading a deck does not populate `date_format`, so the dialog shows
datetime1 preselected for a deck whose dates are something else until the user
picks. That is a pptx read-side change and wants its own PR.
This commit is contained in:
Hamza
2026-10-17 17:17:37 +05:00
parent 1dc69720db
commit afd6d3a3eb
3 changed files with 152 additions and 23 deletions
+113 -7
View File
@@ -821,6 +821,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
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;
}
@@ -836,6 +844,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
}
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 {
@@ -880,7 +889,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
},
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() } }],
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()
@@ -901,9 +914,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
sh.text = Some(TextBody::from_text(&hf.footer_text));
}
}
(Some(pos), true) if k == PhType::Date && date_given => {
(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);
set_date(sh, &hf.date_text, &hf.date_format);
}
}
_ => {}
@@ -917,13 +930,31 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
/// 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) {
/// 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)
}
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 has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
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 {
"" if has_field => return,
"" => (today(), RunKind::Field { field: "datetime1".into() }),
// 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),
};
@@ -1908,3 +1939,78 @@ mod browse_theme_tests {
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
}
}
#[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")
}
/// 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.
s.execute("design.headerFooter", &json!({"date": true, "dateText": "", "all": true})).unwrap();
assert_eq!(dates(&s), vec![(sample("datetime1"), Some("datetime1".into()))]);
// A format: the field type changes and the saved text is that format's.
s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime5", "all": true})).unwrap();
assert_eq!(dates(&s), vec![(sample("datetime5"), Some("datetime5".into()))]);
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.
s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime11", "all": true})).unwrap();
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
assert_ne!(sample("datetime11"), sample("datetime5"));
// Asking again changes nothing (and does not rewrite the run).
s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime11", "all": true})).unwrap();
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
// 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.
s.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true})).unwrap();
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
// 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");
assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]);
}
}
+19
View File
@@ -74,6 +74,25 @@ pub fn is_date_field(kind: &str) -> bool {
kind.starts_with("datetime")
}
/// The date and time field types PowerPoint's Header and Footer and Insert ▸ Date & Time offer,
/// in the order the dialogs list them. `datetime` and `datetimeFigureOut` are read as `datetime1`
/// (see [`field_text`]) but are not offered as a choice.
pub const DATE_KINDS: [&str; 13] = [
"datetime1",
"datetime2",
"datetime3",
"datetime4",
"datetime5",
"datetime6",
"datetime7",
"datetime8",
"datetime9",
"datetime10",
"datetime11",
"datetime12",
"datetime13",
];
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
/// the format needs names in a language other than English: the field keeps its saved text.
+20 -16
View File
@@ -1076,6 +1076,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
let date = check(ui, d, "Date and time", "date", hf.date);
field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text);
// The format applies to an automatic date; a fixed one is the text as typed.
let automatic = d.get("dateText", &hf.date_text).is_empty();
let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() };
let fmt = d.get("dateFormat", &fmt_now);
let rows = date_rows("en-US");
let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone());
ui.add_enabled_ui(date && automatic, |ui| {
ui.horizontal(|ui| {
ui.label(" Format:");
egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| {
for (kind, sample) in &rows {
if ui.selectable_label(fmt == *kind, sample).clicked() {
d.fields.insert("dateFormat".into(), (*kind).to_string());
}
}
});
});
});
let num_on = check(ui, d, "Slide number", "num", hf.slide_number);
let foot = check(ui, d, "Footer", "footer", hf.footer);
field(ui, d, " Footer text:", "footerText", &hf.footer_text);
@@ -1096,7 +1114,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
run(
app,
"design.headerFooter",
json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
json!({"date": date, "dateText": dt, "dateFormat": d.get("dateFormat", &fmt_now), "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
);
}
apply || apply_all || d.fields.contains_key("cancel")
@@ -2440,21 +2458,7 @@ const DATE_LANGS: &[(&str, &str)] = &[
("sv-SE", "Swedish"),
];
const DATE_KINDS: [&str; 13] = [
"datetime1",
"datetime2",
"datetime3",
"datetime4",
"datetime5",
"datetime6",
"datetime7",
"datetime8",
"datetime9",
"datetime10",
"datetime11",
"datetime12",
"datetime13",
];
use deckcraft_text::datetime::DATE_KINDS;
/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only;
/// other languages keep the numeric date and the 24-hour times.