From 752817ae74607ca23ec0aad51824ddca66faa7a8 Mon Sep 17 00:00:00 2001 From: Laurenz Stampfl <47084093+LaurenzV@users.noreply.github.com> Date: Tue, 23 May 2023 10:41:20 +0200 Subject: [PATCH] Add support for date & time handling (#435) --- Cargo.lock | 160 +++++++----------------- Cargo.toml | 1 + cli/Cargo.toml | 2 +- cli/src/main.rs | 29 ++++- docs/src/html.rs | 5 + docs/src/lib.rs | 1 + docs/src/reference/types.md | 141 ++++++++++++++++++++++ library/Cargo.toml | 1 + library/src/compute/construct.rs | 173 +++++++++++++++++++++++++- library/src/compute/mod.rs | 1 + src/eval/datetime.rs | 201 +++++++++++++++++++++++++++++++ src/eval/methods.rs | 25 ++++ src/eval/mod.rs | 2 + src/lib.rs | 8 +- tests/src/benches.rs | 6 +- tests/src/tests.rs | 6 +- tests/typ/compute/construct.typ | 86 +++++++++++++ 17 files changed, 721 insertions(+), 127 deletions(-) create mode 100644 src/eval/datetime.rs diff --git a/Cargo.lock b/Cargo.lock index b902eacd3..53d9b8474 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,15 +29,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "0.3.2" @@ -193,12 +184,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "bumpalo" -version = "3.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6ed94e98ecff0c12dd1b04c15ec0d7d9458ca8fe806cea6f12954efe74c63b" - [[package]] name = "bytemuck" version = "1.13.1" @@ -247,10 +232,8 @@ version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ - "iana-time-zone", "num-integer", "num-traits", - "winapi", ] [[package]] @@ -381,12 +364,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" - [[package]] name = "crc32fast" version = "1.3.2" @@ -780,29 +757,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "iana-time-zone" -version = "0.1.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows", -] - -[[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 = "idna" version = "0.3.0" @@ -995,15 +949,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" -[[package]] -name = "js-sys" -version = "0.3.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" -dependencies = [ - "wasm-bindgen", -] - [[package]] name = "kqueue" version = "1.0.7" @@ -1239,6 +1184,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "numerals" version = "0.1.4" @@ -1996,6 +1950,35 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3403384eaacbca9923fa06940178ac13e4edb725486d70e8e15881d0c836cc" +dependencies = [ + "itoa", + "libc", + "num_threads", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" +dependencies = [ + "time-core", +] + [[package]] name = "tiny-skia" version = "0.9.1" @@ -2198,6 +2181,7 @@ dependencies = [ "stacker", "subsetter", "svg2pdf", + "time", "tiny-skia", "tracing", "ttf-parser", @@ -2216,7 +2200,6 @@ name = "typst-cli" version = "0.4.0" dependencies = [ "atty", - "chrono", "clap 4.2.7", "clap_complete", "clap_mangen", @@ -2232,6 +2215,7 @@ dependencies = [ "same-file", "siphasher", "tempfile", + "time", "tracing", "tracing-error", "tracing-flame", @@ -2281,6 +2265,7 @@ dependencies = [ "serde_yaml", "smallvec", "syntect", + "time", "toml", "tracing", "ttf-parser", @@ -2535,60 +2520,6 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "wasm-bindgen" -version = "0.2.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.16", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.16", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" - [[package]] name = "weezl" version = "0.1.7" @@ -2635,15 +2566,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.0", -] - [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 5fd1538be..42a8973c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ unicode-segmentation = "1" unscanny = "0.1" usvg = { version = "0.32", default-features = false, features = ["text"] } xmp-writer = "0.1" +time = { version = "0.3.20", features = ["std", "formatting"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] stacker = "0.1.15" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index fc0f7ddd3..c811db2fd 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -23,7 +23,6 @@ doc = false typst = { path = ".." } typst-library = { path = "../library" } atty = "0.2" -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.2.4", features = ["derive", "env"] } codespan-reporting = "0.11" comemo = "0.3" @@ -37,6 +36,7 @@ open = "4.0.2" same-file = "1" siphasher = "0.3" tempfile = "3.5.0" +time = { version = "0.3.20", features = ["formatting", "local-offset", "macros"] } tracing = "0.1.37" tracing-error = "0.2" tracing-flame = "0.2.0" diff --git a/cli/src/main.rs b/cli/src/main.rs index 408fe2f27..f2fcec0c5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::fs::{self, File}; use std::hash::Hash; use std::io::{self, Write}; +use std::ops::Add; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -21,8 +22,10 @@ use once_cell::unsync::OnceCell; use same_file::{is_same_file, Handle}; use siphasher::sip128::{Hasher128, SipHasher13}; use termcolor::{ColorChoice, StandardStream, WriteColor}; +use time::macros::format_description; +use time::Duration; use typst::diag::{FileError, FileResult, SourceError, StrResult}; -use typst::eval::Library; +use typst::eval::{Datetime, Library}; use typst::font::{Font, FontBook, FontInfo, FontVariant}; use typst::syntax::{Source, SourceId}; use typst::util::{Buffer, PathExt}; @@ -291,8 +294,8 @@ fn status(command: &CompileSettings, status: Status) -> io::Result<()> { let esc = 27 as char; let input = command.input.display(); let output = command.output.display(); - let time = chrono::offset::Local::now(); - let timestamp = time.format("%H:%M:%S"); + let time = time::OffsetDateTime::now_local().unwrap(); + let timestamp = time.format(format_description!("[hour]:[minute]:[second]")).unwrap(); let message = status.message(); let color = status.color(); @@ -427,6 +430,7 @@ struct SystemWorld { hashes: RefCell>>, paths: RefCell>, sources: FrozenVec>, + current_date: Cell>, main: SourceId, } @@ -457,6 +461,7 @@ impl SystemWorld { hashes: RefCell::default(), paths: RefCell::default(), sources: FrozenVec::new(), + current_date: Cell::new(None), main: SourceId::detached(), } } @@ -511,6 +516,23 @@ impl World for SystemWorld { .get_or_init(|| read(path).map(Buffer::from)) .clone() } + + fn today(&self, offset: Option) -> Option { + if self.current_date.get().is_none() { + let datetime = match offset { + None => time::OffsetDateTime::now_local().ok()?, + Some(o) => time::OffsetDateTime::now_utc().add(Duration::hours(o)), + }; + + self.current_date.set(Some(Datetime::from_ymd( + datetime.year(), + datetime.month().try_into().ok()?, + datetime.day(), + )?)) + } + + self.current_date.get() + } } impl SystemWorld { @@ -572,6 +594,7 @@ impl SystemWorld { self.sources.as_mut().clear(); self.hashes.borrow_mut().clear(); self.paths.borrow_mut().clear(); + self.current_date.set(None); } } diff --git a/docs/src/html.rs b/docs/src/html.rs index a3db63936..e0b524ce0 100644 --- a/docs/src/html.rs +++ b/docs/src/html.rs @@ -5,6 +5,7 @@ use md::escape::escape_html; use pulldown_cmark as md; use typed_arena::Arena; use typst::diag::FileResult; +use typst::eval::Datetime; use typst::font::{Font, FontBook}; use typst::geom::{Point, Size}; use typst::syntax::{Source, SourceId}; @@ -489,4 +490,8 @@ impl World for DocWorld { .contents() .into()) } + + fn today(&self, _: Option) -> Option { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } } diff --git a/docs/src/lib.rs b/docs/src/lib.rs index af786cd1f..4cb47283f 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -931,6 +931,7 @@ const TYPE_ORDER: &[&str] = &[ "relative length", "fraction", "color", + "datetime", "string", "regex", "label", diff --git a/docs/src/reference/types.md b/docs/src/reference/types.md index 2fceb2b3c..13ca87204 100644 --- a/docs/src/reference/types.md +++ b/docs/src/reference/types.md @@ -174,6 +174,147 @@ Produces the negative of the color. - returns: color +# Datetime +Represents a date, a time, or a combination of both. Can be created by either +specifying a custom datetime using the [`datetime`]($func/datetime) function or +getting the current date with [`datetime.today`]($func/datetime.today). + +## Example +```example +#let date = datetime( + year: 2020, + month: 10, + day: 4, +) + +#date.display() \ +#date.display("y:[year repr:last_two]") + +#let time = datetime( + hour: 18, + minute: 2, + second: 23, +) + +#time.display() \ +#time.display("h:[hour repr:12][period]") +``` + +## Format +You can specify a customized formatting using the `display` method. +The format of a datetime is specified by providing +_components_ with a specified number of _modifiers_. A component represents a +certain part of the datetime that you want to display, and with the help of +modifiers you can define how you want to display that component. In order to +display a component, you wrap the name of the component in square brackets +(e.g. `[year]` will display the year). In order to add modifiers, +you add a space after the component name followed by the name of the modifier, +a colon and the value of the modifier (e.g. `[month repr:short]` will display +the short representation of the month). + +The possible combination of components and their respective modifiers is as +follows: + +* `year`: Displays the year of the datetime. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the year + is padded. + * `repr` Can be either `full` in which case the full year is displayed or + `last_two` in which case only the last two digits are displayed. + * `sign`: Can be either `automatic` or `mandatory`. Specifies when the sign + should be displayed. +* `month`: Displays the month of the datetime. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the month + is padded. + * `repr`: Can be either `numerical`, `long` or `short`. Specifies if the month + should be displayed as a number or a word. Unfortunately, when choosing the + word representation, it can currently only display the English version. In + the future, it is planned to support localization. +* `day`: Displays the day of the datetime. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the day + is padded. +* `week_number`: Displays the week number of the datetime. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the week + number is padded. + * `repr`: Can be either `ISO`, `sunday` or `monday`. In the case of `ISO`, + week numbers are between 1 and 53, while the other ones are between 0 + and 53. +* `weekday`: Displays the weekday of the date. + * `repr` Can be either `long`, `short`, `sunday` or `monday`. In the case of + `long` and `short`, the corresponding English name will be displayed (same + as for the month, other languages are currently not supported). In the case + of `sunday` and `monday`, the numerical value will be displayed (assuming + Sunday and Monday as the first day of the week, respectively). + * `one_indexed`: Can be either `true` or `false`. Defines whether the + numerical representation of the week starts with 0 or 1. +* `hour`: Displays the hour of the date. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the hour + is padded. + * `repr`: Can be either `24` or `12`. Changes whether the hour is displayed in + the 24-hour or 12-hour format. +* `period`: The AM/PM part of the hour + * `case`: Can be `lower` to display it in lower case and `upper` to display it + in upper case. +* `minute`: Displays the minute of the date. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the minute + is padded. +* `second`: Displays the second of the date. + * `padding`: Can be either `zero`, `space` or `none`. Specifies how the second + is padded. + +Keep in mind that not always all components can be used. For example, if +you create a new datetime with `#datetime(year: 2023, month: 10, day: 13)`, it +will be stored as a plain date internally, meaning that you cannot use +components such as `hour` or `minute`, which would only work on datetimes +that have a specified time. + +## Methods +### display() +Displays the datetime in a certain way. Depending on whether you have defined +just a date, a time or both, the default format will be different. +If you specified a date, it will be `[year]-[month]-[day]`. If you specified a +time, it will be `[hour]:[minute]:[second]`. In the case of a datetime, it will +be `[year]-[month]-[day] [hour]:[minute]:[second]`. + +- pattern: string (positional) + The format used to display the datetime. +- returns: string + +### year() +Returns the year of the datetime, if it exists. Otherwise, it returns `none`. + +- returns: integer or none + +### month() +Returns the month of the datetime, if it exists. Otherwise, it returns `none`. + +- returns: integer or none + +### weekday() +Returns the weekday of the datetime as a number starting with 1 from Monday, if +it exists. Otherwise, it returns `none`. + +- returns: integer or none + +### day() +Returns the day of the datetime, if it exists. Otherwise, it returns `none`. + +- returns: integer or none + +### hour() +Returns the hour of the datetime, if it exists. Otherwise, it returns `none`. + +- returns: integer or none + +### minute() +Returns the minute of the datetime, if it exists. Otherwise, it returns `none`. + +- returns: integer or none + +### second() +Returns the second of the datetime, if it exists. Otherwise, it returns `none`. + +- returns: integer or none + # Symbol A Unicode symbol. diff --git a/library/Cargo.toml b/library/Cargo.toml index 335a60019..02dedd3e0 100644 --- a/library/Cargo.toml +++ b/library/Cargo.toml @@ -35,6 +35,7 @@ serde_json = "1" serde_yaml = "0.8" smallvec = "1.10" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "regex-fancy"] } +time = { version = "0.3.20", features = ["formatting"] } toml = { version = "0.7.3", default-features = false, features = ["parse"] } tracing = "0.1.37" ttf-parser = "0.18.1" diff --git a/library/src/compute/construct.rs b/library/src/compute/construct.rs index 74e96b5db..4ff9040b0 100644 --- a/library/src/compute/construct.rs +++ b/library/src/compute/construct.rs @@ -1,7 +1,9 @@ use std::num::NonZeroI64; use std::str::FromStr; -use typst::eval::Regex; +use time::{Month, PrimitiveDateTime}; + +use typst::eval::{Datetime, Dynamic, Regex}; use crate::prelude::*; @@ -179,6 +181,175 @@ cast_from_value! { }, } +/// Create a new datetime. +/// +/// You can specify the [datetime]($type/datetime) using a year, month, day, +/// hour, minute, and second. +/// +/// ## Example +/// ```example +/// #datetime( +/// year: 2012, +/// month: 8, +/// day: 3, +/// ).display() +/// ``` +/// +/// ## Format +/// _Note_: Depending on which components of the datetime you specify, Typst +/// will store it in one of the following three ways: +/// * If you specify year, month and day, Typst will store just a date. +/// * If you specify hour, minute and second, Typst will store just a time. +/// * If you specify all of year, month, day, hour, minute and second, Typst +/// will store a full datetime. +/// +/// Depending on how it is stored, the [`display`]($type/datetime.display) +/// method will choose a different formatting by default. +/// +/// Display: Datetime +/// Category: construct +/// Returns: datetime +#[func] +#[scope( + scope.define("today", datetime_today); + scope +)] +pub fn datetime( + /// The year of the datetime. + #[named] + year: Option, + /// The month of the datetime. + #[named] + month: Option, + /// The day of the datetime. + #[named] + day: Option, + /// The hour of the datetime. + #[named] + hour: Option, + /// The minute of the datetime. + #[named] + minute: Option, + /// The second of the datetime. + #[named] + second: Option, +) -> Value { + let time = match (hour, minute, second) { + (Some(hour), Some(minute), Some(second)) => { + match time::Time::from_hms(hour.0, minute.0, second.0) { + Ok(time) => Some(time), + Err(_) => bail!(args.span, "time is invalid"), + } + } + (None, None, None) => None, + _ => bail!(args.span, "time is incomplete"), + }; + + let date = match (year, month, day) { + (Some(year), Some(month), Some(day)) => { + match time::Date::from_calendar_date(year.0, month.0, day.0) { + Ok(date) => Some(date), + Err(_) => bail!(args.span, "date is invalid"), + } + } + (None, None, None) => None, + _ => bail!(args.span, "date is incomplete"), + }; + + match (date, time) { + (Some(date), Some(time)) => Value::Dyn(Dynamic::new(Datetime::Datetime( + PrimitiveDateTime::new(date, time), + ))), + (Some(date), None) => Value::Dyn(Dynamic::new(Datetime::Date(date))), + (None, Some(time)) => Value::Dyn(Dynamic::new(Datetime::Time(time))), + (None, None) => { + bail!(args.span, "at least one of date or time must be fully specified") + } + } +} + +struct YearComponent(i32); +struct MonthComponent(Month); +struct DayComponent(u8); +struct HourComponent(u8); +struct MinuteComponent(u8); +struct SecondComponent(u8); + +cast_from_value!( + YearComponent, + v: i64 => match i32::try_from(v) { + Ok(n) => Self(n), + _ => Err("year is invalid")? + } +); + +cast_from_value!( + MonthComponent, + v: i64 => match u8::try_from(v).ok().and_then(|n1| Month::try_from(n1).ok()).map(Self) { + Some(m) => m, + _ => Err("month is invalid")? + } +); + +cast_from_value!( + DayComponent, + v: i64 => match u8::try_from(v) { + Ok(n) => Self(n), + _ => Err("day is invalid")? + } +); + +cast_from_value!( + HourComponent, + v: i64 => match u8::try_from(v) { + Ok(n) => Self(n), + _ => Err("hour is invalid")? + } +); + +cast_from_value!( + MinuteComponent, + v: i64 => match u8::try_from(v) { + Ok(n) => Self(n), + _ => Err("minute is invalid")? + } +); + +cast_from_value!( + SecondComponent, + v: i64 => match u8::try_from(v) { + Ok(n) => Self(n), + _ => Err("second is invalid")? + } +); + +/// Returns the current date. +/// +/// ## Example +/// ```example +/// Today's date is +/// #datetime.today().display(). +/// ``` +/// +/// Display: Today +/// Category: construct +/// Returns: datetime +#[func] +pub fn datetime_today( + /// An offset to apply to the current UTC date. If set to `{auto}`, the + /// offset will be the local offset. + #[named] + #[default] + offset: Smart, +) -> Value { + let current_date = match vm.vt.world.today(offset.as_custom()) { + Some(d) => d, + None => bail!(args.span, "unable to get the current date"), + }; + + Value::Dyn(Dynamic::new(current_date)) +} + /// Create a CMYK color. /// /// This is useful if you want to target a specific printer. The conversion diff --git a/library/src/compute/mod.rs b/library/src/compute/mod.rs index 8ebae48e6..90730e02e 100644 --- a/library/src/compute/mod.rs +++ b/library/src/compute/mod.rs @@ -23,6 +23,7 @@ pub(super) fn define(global: &mut Scope) { global.define("luma", luma); global.define("rgb", rgb); global.define("cmyk", cmyk); + global.define("datetime", datetime); global.define("symbol", symbol); global.define("str", str); global.define("label", label); diff --git a/src/eval/datetime.rs b/src/eval/datetime.rs new file mode 100644 index 000000000..47574bae9 --- /dev/null +++ b/src/eval/datetime.rs @@ -0,0 +1,201 @@ +use std::fmt; +use std::fmt::{Debug, Formatter}; +use std::hash::Hash; + +use ecow::{eco_format, EcoString, EcoVec}; +use time::error::{Format, InvalidFormatDescription}; +use time::{format_description, PrimitiveDateTime}; + +use crate::eval::cast_from_value; +use crate::util::pretty_array_like; + +/// A datetime object that represents either a date, a time or a combination of +/// both. +#[derive(Clone, Copy, PartialEq, Hash)] +pub enum Datetime { + /// Representation as a date. + Date(time::Date), + /// Representation as a time. + Datetime(time::PrimitiveDateTime), + /// Representation as a combination of date and time. + Time(time::Time), +} + +impl Datetime { + /// Display the date and/or time in a certain format. + pub fn display(&self, pattern: Option) -> Result { + let pattern = pattern.as_ref().map(EcoString::as_str).unwrap_or(match self { + Datetime::Date(_) => "[year]-[month]-[day]", + Datetime::Time(_) => "[hour]:[minute]:[second]", + Datetime::Datetime(_) => "[year]-[month]-[day] [hour]:[minute]:[second]", + }); + + let format = format_description::parse(pattern) + .map_err(format_time_invalid_format_description_error)?; + + let formatted_result = match self { + Datetime::Date(date) => date.format(&format), + Datetime::Time(time) => time.format(&format), + Datetime::Datetime(datetime) => datetime.format(&format), + } + .map(EcoString::from); + + formatted_result.map_err(format_time_format_error) + } + + /// Return the year of the datetime, if existing. + pub fn year(&self) -> Option { + match self { + Datetime::Date(date) => Some(date.year()), + Datetime::Time(_) => None, + Datetime::Datetime(datetime) => Some(datetime.year()), + } + } + + /// Return the month of the datetime, if existing. + pub fn month(&self) -> Option { + match self { + Datetime::Date(date) => Some(date.month().into()), + Datetime::Time(_) => None, + Datetime::Datetime(datetime) => Some(datetime.month().into()), + } + } + + /// Return the weekday of the datetime, if existing. + pub fn weekday(&self) -> Option { + match self { + Datetime::Date(date) => Some(date.weekday().number_from_monday()), + Datetime::Time(_) => None, + Datetime::Datetime(datetime) => Some(datetime.weekday().number_from_monday()), + } + } + + /// Return the day of the datetime, if existing. + pub fn day(&self) -> Option { + match self { + Datetime::Date(date) => Some(date.day()), + Datetime::Time(_) => None, + Datetime::Datetime(datetime) => Some(datetime.day()), + } + } + + /// Return the hour of the datetime, if existing. + pub fn hour(&self) -> Option { + match self { + Datetime::Date(_) => None, + Datetime::Time(time) => Some(time.hour()), + Datetime::Datetime(datetime) => Some(datetime.hour()), + } + } + + /// Return the minute of the datetime, if existing. + pub fn minute(&self) -> Option { + match self { + Datetime::Date(_) => None, + Datetime::Time(time) => Some(time.minute()), + Datetime::Datetime(datetime) => Some(datetime.minute()), + } + } + + /// Return the second of the datetime, if existing. + pub fn second(&self) -> Option { + match self { + Datetime::Date(_) => None, + Datetime::Time(time) => Some(time.second()), + Datetime::Datetime(datetime) => Some(datetime.second()), + } + } + + /// Create a datetime from year, month, and day. + pub fn from_ymd(year: i32, month: u8, day: u8) -> Option { + Some(Datetime::Date( + time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day) + .ok()?, + )) + } + + /// Create a datetime from hour, minute, and second. + pub fn from_hms(hour: u8, minute: u8, second: u8) -> Option { + Some(Datetime::Time(time::Time::from_hms(hour, minute, second).ok()?)) + } + + /// Create a datetime from day and time. + pub fn from_ymd_hms( + year: i32, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + ) -> Option { + let date = + time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day) + .ok()?; + let time = time::Time::from_hms(hour, minute, second).ok()?; + Some(Datetime::Datetime(PrimitiveDateTime::new(date, time))) + } +} + +impl Debug for Datetime { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let year = self.year().map(|y| eco_format!("year: {y}")); + let month = self.month().map(|m| eco_format!("month: {m}")); + let day = self.day().map(|d| eco_format!("day: {d}")); + let hour = self.hour().map(|h| eco_format!("hour: {h}")); + let minute = self.minute().map(|m| eco_format!("minute: {m}")); + let second = self.second().map(|s| eco_format!("second: {s}")); + let filtered = [year, month, day, hour, minute, second] + .into_iter() + .flatten() + .collect::>(); + + write!(f, "datetime{}", &pretty_array_like(&filtered, false)) + } +} + +cast_from_value! { + Datetime: "datetime", +} + +/// Format the `Format` error of the time crate in an appropriate way. +fn format_time_format_error(error: Format) -> EcoString { + match error { + Format::InvalidComponent(name) => eco_format!("invalid component '{}'", name), + _ => "failed to format datetime in the requested format".into(), + } +} + +/// Format the `InvalidFormatDescription` error of the time crate in an +/// appropriate way. +fn format_time_invalid_format_description_error( + error: InvalidFormatDescription, +) -> EcoString { + match error { + InvalidFormatDescription::UnclosedOpeningBracket { index, .. } => { + eco_format!("missing closing bracket for bracket at index {}", index) + } + InvalidFormatDescription::InvalidComponentName { name, index, .. } => { + eco_format!("invalid component name '{}' at index {}", name, index) + } + InvalidFormatDescription::InvalidModifier { value, index, .. } => { + eco_format!("invalid modifier '{}' at index {}", value, index) + } + InvalidFormatDescription::Expected { what, index, .. } => { + eco_format!("expected {} at index {}", what, index) + } + InvalidFormatDescription::MissingComponentName { index, .. } => { + eco_format!("expected component name at index {}", index) + } + InvalidFormatDescription::MissingRequiredModifier { name, index, .. } => { + eco_format!( + "missing required modifier {} for component at index {}", + name, + index + ) + } + InvalidFormatDescription::NotSupported { context, what, index, .. } => { + eco_format!("{} is not supported in {} at index {}", what, context, index) + } + _ => "failed to parse datetime format".into(), + } +} diff --git a/src/eval/methods.rs b/src/eval/methods.rs index 8d042a5ca..420845435 100644 --- a/src/eval/methods.rs +++ b/src/eval/methods.rs @@ -4,6 +4,7 @@ use ecow::EcoString; use super::{Args, Str, Value, Vm}; use crate::diag::{At, SourceResult}; +use crate::eval::Datetime; use crate::model::{Location, Selector}; use crate::syntax::Span; @@ -185,6 +186,30 @@ pub fn call( } _ => return missing(), } + } else if let Some(&datetime) = dynamic.downcast::() { + match method { + "display" => datetime.display(args.eat()?).at(args.span)?.into(), + "year" => { + datetime.year().map_or(Value::None, |y| Value::Int(y.into())) + } + "month" => { + datetime.month().map_or(Value::None, |m| Value::Int(m.into())) + } + "weekday" => { + datetime.weekday().map_or(Value::None, |w| Value::Int(w.into())) + } + "day" => datetime.day().map_or(Value::None, |d| Value::Int(d.into())), + "hour" => { + datetime.hour().map_or(Value::None, |h| Value::Int(h.into())) + } + "minute" => { + datetime.minute().map_or(Value::None, |m| Value::Int(m.into())) + } + "second" => { + datetime.second().map_or(Value::None, |s| Value::Int(s.into())) + } + _ => return missing(), + } } else { return (vm.items.library_method)(vm, &dynamic, method, args, span); } diff --git a/src/eval/mod.rs b/src/eval/mod.rs index a91bea10b..2499ae22a 100644 --- a/src/eval/mod.rs +++ b/src/eval/mod.rs @@ -13,6 +13,7 @@ mod str; #[macro_use] mod value; mod args; +mod datetime; mod func; mod methods; mod module; @@ -26,6 +27,7 @@ pub use once_cell::sync::Lazy; pub use self::args::*; pub use self::array::*; pub use self::cast::*; +pub use self::datetime::*; pub use self::dict::*; pub use self::func::*; pub use self::library::*; diff --git a/src/lib.rs b/src/lib.rs index c88784669..646f3922d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,7 +58,7 @@ use comemo::{Prehashed, Track, TrackedMut}; use crate::diag::{FileResult, SourceResult}; use crate::doc::Document; -use crate::eval::{Library, Route, Tracer}; +use crate::eval::{Datetime, Library, Route, Tracer}; use crate::font::{Font, FontBook}; use crate::syntax::{Source, SourceId}; use crate::util::Buffer; @@ -116,4 +116,10 @@ pub trait World { /// Try to access a file at a path. fn file(&self, path: &Path) -> FileResult; + + /// Get the current date. + /// + /// If no offset is specified, the local date should be chosen. Otherwise, + /// the UTC date should be chosen with the corresponding offset in hours. + fn today(&self, offset: Option) -> Option; } diff --git a/tests/src/benches.rs b/tests/src/benches.rs index 9e9b98d07..aeddcaf92 100644 --- a/tests/src/benches.rs +++ b/tests/src/benches.rs @@ -3,7 +3,7 @@ use std::path::Path; use comemo::{Prehashed, Track, Tracked}; use iai::{black_box, main, Iai}; use typst::diag::{FileError, FileResult}; -use typst::eval::Library; +use typst::eval::{Datetime, Library}; use typst::font::{Font, FontBook}; use typst::geom::Color; use typst::syntax::{Source, SourceId}; @@ -147,4 +147,8 @@ impl World for BenchWorld { fn file(&self, path: &Path) -> FileResult { Err(FileError::NotFound(path.into())) } + + fn today(&self, _: Option) -> Option { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } } diff --git a/tests/src/tests.rs b/tests/src/tests.rs index f295869fd..c2d0cc686 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -22,7 +22,7 @@ use walkdir::WalkDir; use typst::diag::{bail, FileError, FileResult}; use typst::doc::{Document, Frame, FrameItem, Meta}; -use typst::eval::{func, Library, Value}; +use typst::eval::{func, Datetime, Library, Value}; use typst::font::{Font, FontBook}; use typst::geom::{Abs, Color, RgbaColor, Sides, Smart}; use typst::syntax::{Source, SourceId, Span, SyntaxNode}; @@ -296,6 +296,10 @@ impl World for TestWorld { .get_or_init(|| read(path).map(Buffer::from)) .clone() } + + fn today(&self, _: Option) -> Option { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } } impl TestWorld { diff --git a/tests/typ/compute/construct.typ b/tests/typ/compute/construct.typ index ee0a24b07..9c05a6d07 100644 --- a/tests/typ/compute/construct.typ +++ b/tests/typ/compute/construct.typ @@ -73,3 +73,89 @@ --- #assert(range(2, 5) == (2, 3, 4)) + +--- +// Test displaying of dates. +#test(datetime(year: 2023, month: 4, day: 29).display(), "2023-04-29") +#test(datetime(year: 2023, month: 4, day: 29).display("[year]"), "2023") +#test( + datetime(year: 2023, month: 4, day: 29) + .display("[year repr:last_two]"), + "23", +) +#test( + datetime(year: 2023, month: 4, day: 29) + .display("[year] [month repr:long] [day] [week_number] [weekday]"), + "2023 April 29 17 Saturday", +) + +// Test displaying of times +#test(datetime(hour: 14, minute: 26, second: 50).display(), "14:26:50") +#test(datetime(hour: 14, minute: 26, second: 50).display("[hour]"), "14") +#test( + datetime(hour: 14, minute: 26, second: 50) + .display("[hour repr:12 padding:none]"), + "2", +) +#test( + datetime(hour: 14, minute: 26, second: 50) + .display("[hour], [minute], [second]"), "14, 26, 50", +) + +// Test displaying of datetimes +#test( + datetime(year: 2023, month: 4, day: 29, hour: 14, minute: 26, second: 50).display(), + "2023-04-29 14:26:50", +) + +// Test getting the year/month/day etc. of a datetime +#let d = datetime(year: 2023, month: 4, day: 29, hour: 14, minute: 26, second: 50) +#test(d.year(), 2023) +#test(d.month(), 4) +#test(d.weekday(), 6) +#test(d.day(), 29) +#test(d.hour(), 14) +#test(d.minute(), 26) +#test(d.second(), 50) + +#let e = datetime(year: 2023, month: 4, day: 29) +#test(e.hour(), none) +#test(e.minute(), none) +#test(e.second(), none) + +// Test today +#test(datetime.today().display(), "1970-01-01") +#test(datetime.today(offset: auto).display(), "1970-01-01") +#test(datetime.today(offset: 2).display(), "1970-01-01") + +--- +// Error: 10-12 at least one of date or time must be fully specified +#datetime() + +--- +// Error: 10-42 time is invalid +#datetime(hour: 25, minute: 0, second: 0) + +--- +// Error: 10-41 date is invalid +#datetime(year: 2000, month: 2, day: 30) + +--- +// Error: 26-35 missing closing bracket for bracket at index 0 +#datetime.today().display("[year") + +--- +// Error: 26-39 invalid component name 'nothing' at index 1 +#datetime.today().display("[nothing]") + +--- +// Error: 26-51 invalid modifier 'wrong' at index 6 +#datetime.today().display("[year wrong:last_two]") + +--- +// Error: 26-34 expected component name at index 2 +#datetime.today().display(" []") + +--- +// Error: 26-36 failed to format datetime in the requested format +#datetime.today().display("[hour]")