mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-13 23:08:23 +00:00
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 <lloyd@stackworx.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
12 files changed
+406
-27
No files matched your search
Generated
+36
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -307,6 +307,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 {
|
||||
@@ -372,6 +373,11 @@ 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 => {
|
||||
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<Value> {
|
||||
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).
|
||||
|
||||
@@ -564,7 +564,8 @@ fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+26
-10
@@ -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<Rgba>,
|
||||
/// Show this text in every date field instead of the date.
|
||||
pub date_text: Option<String>,
|
||||
/// The local date and time date fields show (`None`: the system clock).
|
||||
pub now: Option<DateTime>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
footer: String,
|
||||
/// The local date and time date fields show; `None`: they keep their saved text.
|
||||
now: Option<DateTime>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
self.field_in(kind, None)
|
||||
}
|
||||
|
||||
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u8> {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
|
||||
@@ -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 }
|
||||
@@ -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<DateTime> {
|
||||
#[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<String> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<bool>>,
|
||||
}
|
||||
|
||||
/// 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<String>;
|
||||
/// 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<String> {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user