From d556ff98b572c651fe856b791289fad3ad3b4a94 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:25:32 +0200 Subject: [PATCH] Fields: date fields show today's date in their own format (#36) Every datetime field showed the deck-wide fixed date text instead of a date: empty when no slide had a fixed date, and that text on every slide when one did. A fixed date is plain text in its slide's placeholder; a field is an automatic date, so it now shows the local date and time in the format of its type (ECMA-376 datetime1 to datetime13 as PowerPoint lists them for English, datetime and datetimeFigureOut the short date). Numeric dates follow the run's language (en-GB 09/10/2026, de-DE 09.10.2026...); formats with names stay English-only, so other languages keep the field's saved text, as do unknown types and builds without a clock (wasm). The saved text is kept on save. - text: datetime module (field types, formats, local clock via chrono, not on wasm); Fields::field_in passes the run's language. - render: RenderOpts::now injects the clock (tests, deterministic output); the PDF text layer uses the same fields. - engine: Header & Footer turns existing date placeholders into fixed text or back into a field; new date fields are saved with the date they show (local, not UTC). Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 36 ++++++ Cargo.toml | 1 + crates/engine/src/cmd/design.rs | 43 +++++-- crates/engine/src/cmd/insert.rs | 3 +- crates/engine/src/tests.rs | 28 +++++ crates/pptx/tests/roundtrip.rs | 19 +++ crates/render/src/lib.rs | 36 ++++-- crates/render/src/placed.rs | 3 +- crates/render/src/tests.rs | 45 +++++++ crates/text/Cargo.toml | 4 + crates/text/src/datetime.rs | 204 ++++++++++++++++++++++++++++++++ crates/text/src/lib.rs | 11 +- 12 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 crates/text/src/datetime.rs diff --git a/Cargo.lock b/Cargo.lock index 6347c97..e50d814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1105,6 +1116,7 @@ dependencies = [ name = "deckcraft-text" version = "0.4.0" dependencies = [ + "chrono", "deckcraft-color", "deckcraft-fonts", "deckcraft-geom", @@ -2070,6 +2082,30 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" diff --git a/Cargo.toml b/Cargo.toml index 29d4ecc..71f5b77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ rayon = "1" unicode-bidi = "0.3" unicode-segmentation = "1" unicode-linebreak = "0.1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } # text / rendering / formats skrifa = "0.47" diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index a8fd9a3..641ddff 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -307,6 +307,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { 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 = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; for i in list { @@ -372,6 +373,11 @@ 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 => { + if let Some(sh) = sl.shapes.get_mut(pos) { + set_date(sh, &hf.date_text); + } + } _ => {} } } @@ -381,19 +387,32 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { ok() } -/// Today's date as M/D/YYYY (no clock crate: computed from the system time). +/// 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 { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let days = (secs / 86_400) as i64; - let (y, m, d) = civil(days); - format!("{m}/{d}/{y}") - } - #[cfg(target_arch = "wasm32")] - { - String::new() - } + 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). diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index db5aaf4..2643ab1 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -564,7 +564,8 @@ fn slide_number(s: &mut Session, _p: &Value) -> Result { } fn date_time(s: &mut Session, p: &Value) -> Result { let f = str_param(p, "format").unwrap_or("datetime1").to_string(); - field_run(s, &f, "") + // Saved with the date it shows now (the text kept where the date can't be computed). + field_run(s, &f, &super::design::date_field_text(&f)) } fn symbol(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8a8e37e..9aefd0b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -426,6 +426,34 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +#[test] +fn header_footer_fixed_date_is_the_slides_own_text() { + use deckcraft_model::text::RunKind; + let mut s = session(); + let date = |s: &Session| { + let slide = s.doc().unwrap().current_slide().unwrap(); + slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).and_then(|x| x.text.clone()).unwrap() + }; + let kind = |s: &Session| date(s).paragraphs[0].runs[0].kind.clone(); + // Automatic: a date field, saved with today's date. + s.execute("design.headerFooter", &json!({"date": true})).unwrap(); + assert_eq!(kind(&s), RunKind::Field { field: "datetime1".into() }); + assert_eq!(date(&s).text().matches('/').count(), 2, "M/D/YYYY"); + // Fixed: plain text in the slide's own placeholder (other slides' fields aren't affected). + s.execute("design.headerFooter", &json!({"date": true, "dateText": "Launch day"})).unwrap(); + assert_eq!((kind(&s), date(&s).text()), (RunKind::Text, "Launch day".to_string())); + // Back to automatic. + s.execute("design.headerFooter", &json!({"date": true, "dateText": ""})).unwrap(); + assert!(matches!(kind(&s), RunKind::Field { .. })); + // A new date field is saved with the date it shows. + s.execute("text.edit", &json!({"id": s.doc().unwrap().current_slide().unwrap().shapes[0].id.0})).unwrap(); + s.execute("text.insert", &json!({"text": "Updated "})).unwrap(); + s.execute("insert.dateTime", &json!({"format": "datetime10"})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let run = title.paragraphs[0].runs.iter().find(|r| matches!(r.kind, RunKind::Field { .. })).cloned().unwrap(); + assert!(run.text.contains(':'), "H:mm {run:?}"); +} + /// (text, linked?) of each run in the first paragraph of the current slide's title. fn title_runs(s: &Session) -> Vec<(String, bool)> { let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0]; diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index b33f184..420a392 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -711,3 +711,22 @@ fn rtl_paragraphs_script_fonts_and_just_low_round_trip() { assert_eq!(para.props.align, Some(Align::JustLow)); assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط"); } + +#[test] +fn date_fields_keep_their_type_and_saved_text() { + // An automatic date is a field (its saved text is what readers without a clock show); a fixed + // date is plain text. + let mut p = Presentation::default(); + let mut auto = shape(&mut p, "Auto date", 10.0, 10.0, 200.0, 40.0); + let mut body = TextBody::from_text(""); + body.paragraphs[0].runs = + vec![Run { text: "March 3, 2025".into(), props: RunProps::default(), kind: RunKind::Field { field: "datetime4".into() } }]; + auto.text = Some(body); + let mut fixed = shape(&mut p, "Fixed date", 10.0, 60.0, 200.0, 40.0); + fixed.text = Some(TextBody::from_text("Launch day")); + Arc::make_mut(&mut p.slides[0]).shapes = vec![auto, fixed]; + let q = round(&p); + for name in ["Auto date", "Fixed date"] { + assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0bbdcfb..6dde577 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -25,6 +25,7 @@ use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::style::{Effects, Fill, Line}; use deckcraft_model::text::TextBody; use deckcraft_model::{Geom, PhType, Presentation, Shape, ShapeId, ShapeKind, Slide}; +use deckcraft_text::datetime::{self, DateTime}; use deckcraft_text::{Fields, Opts}; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; @@ -154,7 +155,10 @@ pub struct RenderOpts<'a> { pub size: Option<(u32, u32)>, /// Draw a background colour outside the slide (output larger than slide). pub clear: Option, + /// Show this text in every date field instead of the date. pub date_text: Option, + /// The local date and time date fields show (`None`: the system clock). + pub now: Option, } impl Default for RenderOpts<'_> { @@ -172,23 +176,39 @@ impl Default for RenderOpts<'_> { size: None, clear: None, date_text: None, + now: None, } } } struct SlideFields { num: u32, - date: String, + /// Text for every date field instead of the date ([`RenderOpts::date_text`]). + date: Option, footer: String, + /// The local date and time date fields show; `None`: they keep their saved text. + now: Option, +} + +impl SlideFields { + fn new(num: u32, footer: String, opts: &RenderOpts) -> Self { + SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) } + } } impl Fields for SlideFields { fn field(&self, kind: &str) -> Option { + self.field_in(kind, None) + } + + fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { if kind == "slidenum" { return Some(self.num.to_string()); } - if kind.starts_with("datetime") { - return Some(self.date.clone()); + if datetime::is_date_field(kind) { + // An automatic date: today's, in the format of its type. (A fixed date is plain text + // in the slide's placeholder, not a field.) + return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?)); } if kind == "footer" { return Some(self.footer.clone()); @@ -427,11 +447,7 @@ impl Renderer { } fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields { - SlideFields { - num: pres.slide_number(index), - date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()), - footer: pres.header_footer.footer_text.clone(), - } + SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts) } /// Background plus the master and layout graphics the slide shows. @@ -530,7 +546,7 @@ impl Renderer { let (w, h) = output_size(pres, opts); let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); - let f = Frame { pres, opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } }; + let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) }; if let Some(m) = pres.masters.get(mi) { match li.and_then(|i| m.layouts.get(i)) { Some(l) => { @@ -1145,7 +1161,7 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let opts = RenderOpts { scale, ..Default::default() }; - let f = Frame { pres, opts: &opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2()); RENDERER.with(|r| { let mut r = r.borrow_mut(); diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index ac084df..0a0d473 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -35,8 +35,7 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed { let mut out = Placed::default(); let Some(slide) = pres.slides.get(index) else { return out }; let Some(rctx) = Ctx::for_slide(pres, slide) else { return out }; - let fields = - SlideFields { num: pres.slide_number(index), date: pres.header_footer.date_text.clone(), footer: pres.header_footer.footer_text.clone() }; + let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default()); let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); if show_master { let mctx = Ctx::for_master(pres, rctx.master); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b07244a..ccb4472 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -273,6 +273,51 @@ fn morph_text_ends_match_the_slides() { assert!(ink > 50, "ink {ink}"); } +/// A text box holding one run of `kind` (a field type, or plain text for `None`) saved as `saved`. +fn field_shape(kind: Option<&str>, saved: &str) -> Shape { + let mut sh = text_shape(1, saved, 40.0, 40.0); + if let (Some(k), Some(run)) = (kind, sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut())) { + run.kind = deckcraft_model::text::RunKind::Field { field: k.into() }; + } + sh +} + +#[test] +fn date_fields_show_the_date_in_their_format() { + use deckcraft_text::datetime::DateTime; + let fri = DateTime::new(2026, 10, 9, 20, 5, 7); + let at = |p: &Presentation, now: DateTime| render_slide(p, 0, &RenderOpts { scale: 0.5, now: Some(now), ..Default::default() }); + let mut p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]); + let _ = at(&p, fri); // faces load lazily + // The field shows the date (it changes with the clock), not its saved text. + let a = at(&p, fri); + assert!(diff_px(&a, &at(&p, DateTime::new(2027, 3, 4, 0, 0, 0))) > 0); + let shown = deck_with(vec![field_shape(None, "10/9/2026")]); + assert_eq!(diff_px(&a, &at(&shown, fri)), 0, "datetime1 is M/D/YYYY"); + // Another slide's fixed date (a deck-wide Header & Footer text) doesn't replace it. + p.header_footer.date_text = "Fixed on another slide".into(); + assert_eq!(diff_px(&a, &at(&p, fri)), 0); + // Every type has its format; the run's language orders numeric dates. + let f = Renderer::fields(&p, 0, &RenderOpts { now: Some(fri), ..Default::default() }); + assert_eq!(f.field_in("datetime4", Some("en-US")).as_deref(), Some("October 9, 2026")); + assert_eq!(f.field_in("datetimeFigureOut", Some("en-GB")).as_deref(), Some("09/10/2026")); + assert_eq!(f.field("datetime13").as_deref(), Some("8:05:07 PM")); + assert_eq!(f.field("slidenum").as_deref(), Some("1")); + // A date the app can't compute (no clock, unknown type, other-language names) keeps the saved text. + assert_eq!(SlideFields { now: None, ..f }.field("datetime1"), None); + let unknown = deck_with(vec![field_shape(Some("datetime99"), "Saved text")]); + let plain = deck_with(vec![field_shape(None, "Saved text")]); + assert_eq!(diff_px(&at(&unknown, fri), &at(&plain, fri)), 0); + // An explicit override still wins. + let o = RenderOpts { date_text: Some("Today".into()), now: Some(fri), ..Default::default() }; + assert_eq!(Renderer::fields(&p, 0, &o).field("datetime2").as_deref(), Some("Today")); +} + +/// Channel values that differ noticeably between two images of the same size. +fn diff_px(a: &Image, b: &Image) -> usize { + a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count() +} + /// Two 8×8 frames, red then blue, 100 ms each. fn two_frame_gif() -> Vec { use image::{Delay, Frame, Rgba, RgbaImage}; diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 6061eb0..7930287 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -16,3 +16,7 @@ deckcraft-fonts = { workspace = true } unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } unicode-bidi = { workspace = true } + +# The local clock for date fields (no clock on wasm: fields keep their saved text). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +chrono = { workspace = true } diff --git a/crates/text/src/datetime.rs b/crates/text/src/datetime.rs new file mode 100644 index 0000000..8844f29 --- /dev/null +++ b/crates/text/src/datetime.rs @@ -0,0 +1,204 @@ +//! Date and time fields (`a:fld type="datetime…"`): the text each field type shows for a moment, +//! and the local clock. +//! +//! The field types and their formats follow ECMA-376 Part 1, §21.1.2.2.4 (`fld`): `datetime1` +//! to `datetime13`, written as in PowerPoint's English (United States) Date and Time list. +//! `datetime` and `datetimeFigureOut` show the language's short date. Numeric dates follow the +//! run's language; formats with month or day names, or AM/PM, are only computed for English, so +//! other languages keep the text the file was saved with. + +/// A local date and time: what the clock read, or a fixed moment (tests, deterministic output). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateTime { + pub year: i32, + /// 1–12. + pub month: u32, + /// 1–31. + pub day: u32, + /// 0–23. + pub hour: u32, + pub minute: u32, + pub second: u32, +} + +impl DateTime { + pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> Self { + DateTime { year, month, day, hour, minute, second } + } + + /// The local date and time now (the user's time zone); `None` where there is no clock (wasm). + pub fn now_local() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + use chrono::{Datelike, Timelike}; + let n = chrono::Local::now(); + Some(DateTime::new(n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second())) + } + #[cfg(target_arch = "wasm32")] + { + None + } + } + + /// A real calendar date and time of day. + fn is_valid(&self) -> bool { + let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0); + let days = match self.month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + (1..=9999).contains(&self.year) && (1..=days).contains(&self.day) && self.hour < 24 && self.minute < 60 && self.second < 61 + } + + /// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm). + fn weekday(&self) -> usize { + let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day)); + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + (days + 4).rem_euclid(7) as usize + } +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// Is field `kind` a date/time field? +pub fn is_date_field(kind: &str) -> bool { + kind.starts_with("datetime") +} + +/// 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. +pub fn field_text(kind: &str, lang: Option<&str>, now: DateTime) -> Option { + if !now.is_valid() { + return None; + } + let lang = lang.map(str::trim).filter(|l| !l.is_empty()).unwrap_or("en-US").to_ascii_lowercase(); + let english = lang == "en" || lang.starts_with("en-"); + let month = MONTHS.get(now.month.checked_sub(1)? as usize)?; + let mon = month.get(..3)?; + let day = DAYS.get(now.weekday())?; + let (d, y) = (now.day, now.year); + let yy = y.rem_euclid(100); + let h12 = match now.hour % 12 { + 0 => 12, + h => h, + }; + let ampm = if now.hour < 12 { "AM" } else { "PM" }; + let (mi, s) = (now.minute, now.second); + let short = short_date(&lang, now); + let named = |text: String| english.then_some(text); + match kind { + "datetime" | "datetimeFigureOut" | "datetime1" => Some(short), + "datetime2" => named(format!("{day}, {month} {d}, {y}")), + "datetime3" => named(format!("{d} {month} {y}")), + "datetime4" => named(format!("{month} {d}, {y}")), + "datetime5" => named(format!("{d}-{mon}-{yy:02}")), + "datetime6" => named(format!("{month} {yy:02}")), + "datetime7" => named(format!("{mon}-{yy:02}")), + "datetime8" => named(format!("{short} {h12}:{mi:02} {ampm}")), + "datetime9" => named(format!("{short} {h12}:{mi:02}:{s:02} {ampm}")), + "datetime10" => Some(format!("{}:{mi:02}", now.hour)), + "datetime11" => Some(format!("{}:{mi:02}:{s:02}", now.hour)), + "datetime12" => named(format!("{h12}:{mi:02} {ampm}")), + "datetime13" => named(format!("{h12}:{mi:02}:{s:02} {ampm}")), + _ => None, + } +} + +/// The short (numeric) date of language `lang` (lower case), US order unless the language is +/// known to write its dates another way. +fn short_date(lang: &str, t: DateTime) -> String { + let (y, m, d) = (t.year, t.month, t.day); + let primary = lang.split(['-', '_']).next().unwrap_or(""); + match lang { + "en-gb" | "en-au" | "en-nz" | "en-ie" | "en-in" => return format!("{d:02}/{m:02}/{y}"), + "en-ca" | "fr-ca" | "en-za" => return format!("{y}-{m:02}-{d:02}"), + _ => {} + } + match primary { + "fr" | "es" | "it" | "pt" | "el" | "ca" | "vi" => format!("{d:02}/{m:02}/{y}"), + "de" | "ru" | "pl" | "cs" | "sk" | "tr" | "fi" | "nb" | "nn" | "no" | "da" | "uk" | "ro" => format!("{d:02}.{m:02}.{y}"), + "nl" => format!("{d}-{m}-{y}"), + "ja" | "zh" => format!("{y}/{m}/{d}"), + "ko" | "sv" | "lt" => format!("{y}-{m:02}-{d:02}"), + _ => format!("{m}/{d}/{y}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FRI: DateTime = DateTime { year: 2026, month: 10, day: 9, hour: 20, minute: 5, second: 7 }; + + #[test] + fn english_formats_of_every_type() { + let f = |k: &str| field_text(k, Some("en-US"), FRI); + assert_eq!(f("datetime1").as_deref(), Some("10/9/2026")); + assert_eq!(f("datetime2").as_deref(), Some("Friday, October 9, 2026")); + assert_eq!(f("datetime3").as_deref(), Some("9 October 2026")); + assert_eq!(f("datetime4").as_deref(), Some("October 9, 2026")); + assert_eq!(f("datetime5").as_deref(), Some("9-Oct-26")); + assert_eq!(f("datetime6").as_deref(), Some("October 26")); + assert_eq!(f("datetime7").as_deref(), Some("Oct-26")); + assert_eq!(f("datetime8").as_deref(), Some("10/9/2026 8:05 PM")); + assert_eq!(f("datetime9").as_deref(), Some("10/9/2026 8:05:07 PM")); + assert_eq!(f("datetime10").as_deref(), Some("20:05")); + assert_eq!(f("datetime11").as_deref(), Some("20:05:07")); + assert_eq!(f("datetime12").as_deref(), Some("8:05 PM")); + assert_eq!(f("datetime13").as_deref(), Some("8:05:07 PM")); + assert_eq!(f("datetimeFigureOut").as_deref(), Some("10/9/2026")); + assert_eq!(f("datetime").as_deref(), Some("10/9/2026")); + // No language: US English. Midnight and noon are 12 on the clock. + assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 0, 0, 0)).as_deref(), Some("12:00 AM")); + assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 12, 30, 0)).as_deref(), Some("12:30 PM")); + assert_eq!(field_text("datetime2", None, DateTime::new(2000, 1, 1, 0, 0, 0)).as_deref(), Some("Saturday, January 1, 2000")); + } + + #[test] + fn language_orders_numeric_dates_and_keeps_names_english_only() { + assert_eq!(field_text("datetime1", Some("en-GB"), FRI).as_deref(), Some("09/10/2026")); + assert_eq!(field_text("datetimeFigureOut", Some("de-DE"), FRI).as_deref(), Some("09.10.2026")); + assert_eq!(field_text("datetime1", Some("ja-JP"), FRI).as_deref(), Some("2026/10/9")); + assert_eq!(field_text("datetime1", Some("sv-SE"), FRI).as_deref(), Some("2026-10-09")); + assert_eq!(field_text("datetime10", Some("fr-FR"), FRI).as_deref(), Some("20:05")); + // Month names in another language: not computed (the field keeps its saved text). + assert_eq!(field_text("datetime2", Some("de-DE"), FRI), None); + assert_eq!(field_text("datetime12", Some("fr-FR"), FRI), None); + assert_eq!(field_text("datetime4", Some("en-GB"), FRI).as_deref(), Some("October 9, 2026")); + } + + #[test] + fn unknown_types_and_impossible_dates_give_nothing() { + assert_eq!(field_text("slidenum", None, FRI), None); + assert_eq!(field_text("datetime14", None, FRI), None); + for bad in [ + DateTime::new(2026, 0, 9, 0, 0, 0), + DateTime::new(2026, 13, 9, 0, 0, 0), + DateTime::new(2026, 2, 29, 0, 0, 0), + DateTime::new(2026, 4, 31, 0, 0, 0), + DateTime::new(2026, 1, 1, 24, 0, 0), + DateTime::new(i32::MIN, 1, 1, 0, 0, 0), + DateTime::new(i32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX), + ] { + assert_eq!(field_text("datetime2", None, bad), None, "{bad:?}"); + } + assert!(is_date_field("datetimeFigureOut") && !is_date_field("slidenum")); + } + + #[test] + fn the_clock_reads_a_real_date() { + if let Some(now) = DateTime::now_local() { + assert!(field_text("datetime1", None, now).is_some(), "{now:?}"); + } + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7345ab5..225f4af 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -20,6 +20,7 @@ #![forbid(unsafe_code)] mod bidi; +pub mod datetime; use std::collections::HashMap; use std::sync::Arc; @@ -129,9 +130,15 @@ pub struct TextLayout { pub boundaries: Vec>, } -/// Values for fields (`slidenum`, `datetime…`, `footer`). +/// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text. pub trait Fields { fn field(&self, kind: &str) -> Option; + /// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are + /// written the language's way. + fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { + let _ = lang; + self.field(kind) + } } pub struct NoFields; @@ -421,7 +428,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, let t: String = match &run.kind { RunKind::Text => run.text.clone(), RunKind::Break => "\u{b}".into(), - RunKind::Field { field } => opts.fields.field(field).unwrap_or_else(|| run.text.clone()), + RunKind::Field { field } => opts.fields.field_in(field, rp.lang.as_deref()).unwrap_or_else(|| run.text.clone()), RunKind::Math { .. } => run.text.clone(), }; let si = styles.len();