From afd6d3a3eb392fec0bab486bb6717ad4227cf6bc Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 17 Oct 2026 17:17:37 +0500 Subject: [PATCH] Header & Footer: the automatic date takes a format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- crates/engine/src/cmd/design.rs | 120 ++++++++++++++++++++++++++++++-- crates/text/src/datetime.rs | 19 +++++ crates/ui-egui/src/dialogs.rs | 36 +++++----- 3 files changed, 152 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 03468f37..0bd1d82c 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -821,6 +821,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { 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 { } 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 = 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 { }, 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 { 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 { /// 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)> { + 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()))]); + } +} diff --git a/crates/text/src/datetime.rs b/crates/text/src/datetime.rs index 8844f299..abdb317c 100644 --- a/crates/text/src/datetime.rs +++ b/crates/text/src/datetime.rs @@ -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. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 576a2297..0696da79 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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.