diff --git a/Cargo.lock b/Cargo.lock index de36e3e3e..fb68e237b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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.6.4" @@ -134,6 +149,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + [[package]] name = "bytemuck" version = "1.14.0" @@ -196,7 +217,10 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ + "android-tzdata", + "iana-time-zone", "num-traits", + "windows-targets 0.48.5", ] [[package]] @@ -273,6 +297,12 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + [[package]] name = "cpufeatures" version = "0.2.11" @@ -984,6 +1014,29 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +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.4.0" @@ -1115,6 +1168,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -2037,6 +2099,60 @@ 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.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.39", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + [[package]] name = "webpki-roots" version = "0.25.2" @@ -2080,6 +2196,15 @@ 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.5", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 72a0ffe36..3c701678c 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["dicom"] readme = "README.md" [dependencies] -chrono = { version = "0.4.31", default-features = false, features = ["std"] } +chrono = { version = "0.4.31", default-features = false, features = ["std", "clock"] } itertools = "0.12" num-traits = "0.2.12" safe-transmute = "0.11.0" diff --git a/core/src/header.rs b/core/src/header.rs index 97f4f43b7..bcc254b68 100644 --- a/core/src/header.rs +++ b/core/src/header.rs @@ -6,7 +6,6 @@ use crate::value::{ CastValueError, ConvertValueError, DataSetSequence, DicomDate, DicomDateTime, DicomTime, InMemFragment, PrimitiveValue, Value, C, }; -use chrono::FixedOffset; use num_traits::NumCast; use snafu::{ensure, Backtrace, Snafu}; use std::borrow::Cow; @@ -503,11 +502,8 @@ where /// /// Returns an error if the value is not primitive. /// - pub fn to_datetime( - &self, - default_offset: FixedOffset, - ) -> Result { - self.value().to_datetime(default_offset) + pub fn to_datetime(&self) -> Result { + self.value().to_datetime() } /// Retrieve and convert the primitive value into a sequence of date-times. @@ -517,11 +513,8 @@ where /// /// Returns an error if the value is not primitive. /// - pub fn to_multi_datetime( - &self, - default_offset: FixedOffset, - ) -> Result, ConvertValueError> { - self.value().to_multi_datetime(default_offset) + pub fn to_multi_datetime(&self) -> Result, ConvertValueError> { + self.value().to_multi_datetime() } /// Retrieve the items stored in a sequence value. diff --git a/core/src/prelude.rs b/core/src/prelude.rs index 74d6673a2..cc37e3218 100644 --- a/core/src/prelude.rs +++ b/core/src/prelude.rs @@ -10,3 +10,4 @@ pub use crate::{dicom_value, DataElement, DicomValue, Tag, VR}; pub use crate::{header::HasLength as _, DataDictionary as _}; +pub use crate::value::{AsRange as _, DicomDate, DicomTime, DicomDateTime}; diff --git a/core/src/value/deserialize.rs b/core/src/value/deserialize.rs index 5f5ceed4d..38ceb40af 100644 --- a/core/src/value/deserialize.rs +++ b/core/src/value/deserialize.rs @@ -330,13 +330,17 @@ where }) } -/** Retrieve a `chrono::DateTime` from the given text, while assuming the given UTC offset. -* If a date/time component is missing, the operation fails. -* Presence of the second fraction component `.FFFFFF` is mandatory with at - least one digit accuracy `.F` while missing digits default to zero. -* For DateTime with missing components, or if exact second fraction accuracy needs to be preserved, - use `parse_datetime_partial`. -*/ +/// Retrieve a `chrono::DateTime` from the given text, while assuming the given UTC offset. +/// +/// If a date/time component is missing, the operation fails. +/// Presence of the second fraction component `.FFFFFF` is mandatory with at +/// least one digit accuracy `.F` while missing digits default to zero. +/// +/// [`parse_datetime_partial`] should be preferred, +/// because it is more flexible and resilient to missing components. +/// See also the implementation of [`FromStr`](std::str::FromStr) +/// for [`DicomDateTime`]. +#[deprecated(since = "0.7.0", note = "Use `parse_datetime_partial()` then `to_precise_datetime()`")] pub fn parse_datetime(buf: &[u8], dt_utc_offset: FixedOffset) -> Result> { let date = parse_date(buf)?; let buf = &buf[8..]; @@ -374,12 +378,36 @@ pub fn parse_datetime(buf: &[u8], dt_utc_offset: FixedOffset) -> Result Result { +/// Decode the text from the byte slice into a [`DicomDateTime`] value, +/// which allows for missing Date / Time components. +/// +/// This is the underlying implementation of [`FromStr`](std::str::FromStr) +/// for `DicomDateTime`. +/// +/// # Example +/// +/// ``` +/// # use dicom_core::value::deserialize::parse_datetime_partial; +/// use dicom_core::value::{DicomDate, DicomDateTime, DicomTime, PreciseDateTime}; +/// use chrono::Datelike; +/// +/// let input = "20240201123456.000305"; +/// let dt = parse_datetime_partial(input.as_bytes())?; +/// assert_eq!( +/// dt, +/// DicomDateTime::from_date_and_time( +/// DicomDate::from_ymd(2024, 2, 1).unwrap(), +/// DicomTime::from_hms_micro(12, 34, 56, 305).unwrap(), +/// )? +/// ); +/// // reinterpret as a chrono date time (with or without time zone) +/// let dt: PreciseDateTime = dt.to_precise_datetime()?; +/// // get just the date, for example +/// let date = dt.to_naive_date(); +/// assert_eq!(date.year(), 2024); +/// # Ok::<_, Box>(()) +/// ``` +pub fn parse_datetime_partial(buf: &[u8]) -> Result { let (date, rest) = parse_date_partial(buf)?; let (time, buf) = match parse_time_partial(rest) { @@ -387,8 +415,8 @@ pub fn parse_datetime_partial(buf: &[u8], dt_utc_offset: FixedOffset) -> Result< Err(_) => (None, rest), }; - let offset = match buf.len() { - 0 => dt_utc_offset, + let time_zone = match buf.len() { + 0 => None, len if len > 4 => { let tz_sign = buf[0]; let buf = &buf[1..]; @@ -398,13 +426,17 @@ pub fn parse_datetime_partial(buf: &[u8], dt_utc_offset: FixedOffset) -> Result< match tz_sign { b'+' => { check_component(DateComponent::UtcEast, &s).context(InvalidComponentSnafu)?; - FixedOffset::east_opt(s as i32) - .context(SecsOutOfBoundsSnafu { secs: s as i32 })? + Some( + FixedOffset::east_opt(s as i32) + .context(SecsOutOfBoundsSnafu { secs: s as i32 })?, + ) } b'-' => { check_component(DateComponent::UtcWest, &s).context(InvalidComponentSnafu)?; - FixedOffset::west_opt(s as i32) - .context(SecsOutOfBoundsSnafu { secs: s as i32 })? + Some( + FixedOffset::west_opt(s as i32) + .context(SecsOutOfBoundsSnafu { secs: s as i32 })?, + ) } c => return InvalidTimeZoneSignTokenSnafu { value: c }.fail(), } @@ -412,18 +444,23 @@ pub fn parse_datetime_partial(buf: &[u8], dt_utc_offset: FixedOffset) -> Result< _ => return UnexpectedEndOfElementSnafu.fail(), }; - match time { - Some(tm) => { - DicomDateTime::from_date_and_time(date, tm, offset).context(InvalidDateTimeSnafu) - } - None => Ok(DicomDateTime::from_date(date, offset)), + match time_zone { + Some(time_zone) => match time { + Some(tm) => DicomDateTime::from_date_and_time_with_time_zone(date, tm, time_zone) + .context(InvalidDateTimeSnafu), + None => Ok(DicomDateTime::from_date_with_time_zone(date, time_zone)), + }, + None => match time { + Some(tm) => DicomDateTime::from_date_and_time(date, tm).context(InvalidDateTimeSnafu), + None => Ok(DicomDateTime::from_date(date)), + }, } } #[cfg(test)] mod tests { use super::*; - use chrono::{FixedOffset, NaiveDate, NaiveTime, TimeZone}; + use chrono::{FixedOffset, NaiveDate, NaiveTime}; #[test] fn test_parse_date() { @@ -777,217 +814,40 @@ mod tests { }) )); } - #[test] - fn test_parse_datetime() { - let default_offset = FixedOffset::east_opt(0).unwrap(); - assert_eq!( - parse_datetime(b"20171130101010.204", default_offset).unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 204_000).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"19440229101010.1", default_offset).unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(1944, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 100_000).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"19450228101010.999999", default_offset).unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(1945, 2, 28).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 999_999).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"20171130101010.564204-1001", default_offset).unwrap(), - FixedOffset::west_opt(10 * 3600 + 1 * 60) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 564_204).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"20171130101010.564204-1001abcd", default_offset).unwrap(), - FixedOffset::west_opt(10 * 3600 + 1 * 60) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 564_204).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"20171130101010.2-1100", default_offset).unwrap(), - FixedOffset::west_opt(11 * 3600) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 200_000).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"20171130101010.0-1100", default_offset).unwrap(), - FixedOffset::west_opt(11 * 3600) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 0).unwrap() - )) - .unwrap() - ); - assert_eq!( - parse_datetime(b"20180101093059", default_offset).unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .with_ymd_and_hms(2018, 1, 1, 9, 30, 59) - .unwrap() - ); - assert!(matches!( - parse_datetime(b"201801010930", default_offset), - Err(Error::IncompleteValue { - component: DateComponent::Second, - .. - }) - )); - assert!(matches!( - parse_datetime(b"2018010109", default_offset), - Err(Error::IncompleteValue { - component: DateComponent::Minute, - .. - }) - )); - assert!(matches!( - parse_datetime(b"20180101", default_offset), - Err(Error::UnexpectedEndOfElement { .. }) - )); - assert!(matches!( - parse_datetime(b"201801", default_offset), - Err(Error::IncompleteValue { - component: DateComponent::Day, - .. - }) - )); - assert!(matches!( - parse_datetime(b"1526", default_offset), - Err(Error::IncompleteValue { - component: DateComponent::Month, - .. - }) - )); - - let dt = parse_datetime(b"20171130101010.204+0100", default_offset).unwrap(); - assert_eq!( - dt, - FixedOffset::east_opt(3600) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 204_000).unwrap() - )) - .unwrap() - ); - assert_eq!( - format!("{:?}", dt), - "2017-11-30T10:10:10.204+01:00".to_string() - ); - - let dt = parse_datetime(b"20171130101010.204+0535", default_offset).unwrap(); - assert_eq!( - dt, - FixedOffset::east_opt(5 * 3600 + 35 * 60) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2017, 11, 30).unwrap(), - NaiveTime::from_hms_micro_opt(10, 10, 10, 204_000).unwrap() - )) - .unwrap() - ); - assert_eq!( - format!("{:?}", dt), - "2017-11-30T10:10:10.204+05:35".to_string() - ); - assert_eq!( - parse_datetime(b"20140505120101.204+0535", default_offset).unwrap(), - FixedOffset::east_opt(5 * 3600 + 35 * 60) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2014, 5, 5).unwrap(), - NaiveTime::from_hms_micro_opt(12, 1, 1, 204_000).unwrap() - )) - .unwrap() - ); - - assert!(parse_datetime(b"", default_offset).is_err()); - assert!(parse_datetime(&[0x00_u8; 8], default_offset).is_err()); - assert!(parse_datetime(&[0xFF_u8; 8], default_offset).is_err()); - assert!(parse_datetime(&[b'0'; 8], default_offset).is_err()); - assert!(parse_datetime(&[b' '; 8], default_offset).is_err()); - assert!(parse_datetime(b"nope", default_offset).is_err()); - assert!(parse_datetime(b"2015dec", default_offset).is_err()); - assert!(parse_datetime(b"20151231162945.", default_offset).is_err()); - assert!(parse_datetime(b"20151130161445+", default_offset).is_err()); - assert!(parse_datetime(b"20151130161445+----", default_offset).is_err()); - assert!(parse_datetime(b"20151130161445. ", default_offset).is_err()); - assert!(parse_datetime(b"20151130161445. +0000", default_offset).is_err()); - assert!(parse_datetime(b"20100423164000.001+3", default_offset).is_err()); - assert!(parse_datetime(b"200809112945*1000", default_offset).is_err()); - assert!(parse_datetime(b"20171130101010.204+1", default_offset).is_err()); - assert!(parse_datetime(b"20171130101010.204+01", default_offset).is_err()); - assert!(parse_datetime(b"20171130101010.204+011", default_offset).is_err()); - } #[test] fn test_parse_datetime_partial() { - let default_offset = FixedOffset::east_opt(0).unwrap(); assert_eq!( - parse_datetime_partial(b"20171130101010.204", default_offset).unwrap(), + parse_datetime_partial(b"20171130101010.204").unwrap(), DicomDateTime::from_date_and_time( DicomDate::from_ymd(2017, 11, 30).unwrap(), DicomTime::from_hmsf(10, 10, 10, 204, 3).unwrap(), - default_offset ) .unwrap() ); assert_eq!( - parse_datetime_partial(b"20171130101010", default_offset).unwrap(), + parse_datetime_partial(b"20171130101010").unwrap(), DicomDateTime::from_date_and_time( DicomDate::from_ymd(2017, 11, 30).unwrap(), - DicomTime::from_hms(10, 10, 10).unwrap(), - default_offset + DicomTime::from_hms(10, 10, 10).unwrap() ) .unwrap() ); assert_eq!( - parse_datetime_partial(b"2017113023", default_offset).unwrap(), + parse_datetime_partial(b"2017113023").unwrap(), DicomDateTime::from_date_and_time( DicomDate::from_ymd(2017, 11, 30).unwrap(), - DicomTime::from_h(23).unwrap(), - default_offset + DicomTime::from_h(23).unwrap() ) .unwrap() ); assert_eq!( - parse_datetime_partial(b"201711", default_offset).unwrap(), - DicomDateTime::from_date(DicomDate::from_ym(2017, 11).unwrap(), default_offset) + parse_datetime_partial(b"201711").unwrap(), + DicomDateTime::from_date(DicomDate::from_ym(2017, 11).unwrap()) ); assert_eq!( - parse_datetime_partial(b"20171130101010.204+0535", default_offset).unwrap(), - DicomDateTime::from_date_and_time( + parse_datetime_partial(b"20171130101010.204+0535").unwrap(), + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2017, 11, 30).unwrap(), DicomTime::from_hmsf(10, 10, 10, 204, 3).unwrap(), FixedOffset::east_opt(5 * 3600 + 35 * 60).unwrap() @@ -995,8 +855,8 @@ mod tests { .unwrap() ); assert_eq!( - parse_datetime_partial(b"20171130101010+0535", default_offset).unwrap(), - DicomDateTime::from_date_and_time( + parse_datetime_partial(b"20171130101010+0535").unwrap(), + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2017, 11, 30).unwrap(), DicomTime::from_hms(10, 10, 10).unwrap(), FixedOffset::east_opt(5 * 3600 + 35 * 60).unwrap() @@ -1004,8 +864,8 @@ mod tests { .unwrap() ); assert_eq!( - parse_datetime_partial(b"2017113010+0535", default_offset).unwrap(), - DicomDateTime::from_date_and_time( + parse_datetime_partial(b"2017113010+0535").unwrap(), + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2017, 11, 30).unwrap(), DicomTime::from_h(10).unwrap(), FixedOffset::east_opt(5 * 3600 + 35 * 60).unwrap() @@ -1013,22 +873,22 @@ mod tests { .unwrap() ); assert_eq!( - parse_datetime_partial(b"20171130-0135", default_offset).unwrap(), - DicomDateTime::from_date( + parse_datetime_partial(b"20171130-0135").unwrap(), + DicomDateTime::from_date_with_time_zone( DicomDate::from_ymd(2017, 11, 30).unwrap(), FixedOffset::west_opt(1 * 3600 + 35 * 60).unwrap() ) ); assert_eq!( - parse_datetime_partial(b"201711-0135", default_offset).unwrap(), - DicomDateTime::from_date( + parse_datetime_partial(b"201711-0135").unwrap(), + DicomDateTime::from_date_with_time_zone( DicomDate::from_ym(2017, 11).unwrap(), FixedOffset::west_opt(1 * 3600 + 35 * 60).unwrap() ) ); assert_eq!( - parse_datetime_partial(b"2017-0135", default_offset).unwrap(), - DicomDateTime::from_date( + parse_datetime_partial(b"2017-0135").unwrap(), + DicomDateTime::from_date_with_time_zone( DicomDate::from_y(2017).unwrap(), FixedOffset::west_opt(1 * 3600 + 35 * 60).unwrap() ) @@ -1036,37 +896,37 @@ mod tests { // West UTC offset out of range assert!(matches!( - parse_datetime_partial(b"20200101-1201", default_offset), + parse_datetime_partial(b"20200101-1201"), Err(Error::InvalidComponent { .. }) )); // East UTC offset out of range assert!(matches!( - parse_datetime_partial(b"20200101+1401", default_offset), + parse_datetime_partial(b"20200101+1401"), Err(Error::InvalidComponent { .. }) )); assert!(matches!( - parse_datetime_partial(b"xxxx0229101010.204", default_offset), + parse_datetime_partial(b"xxxx0229101010.204"), Err(Error::InvalidNumberToken { .. }) )); - assert!(parse_datetime_partial(b"", default_offset).is_err()); - assert!(parse_datetime_partial(&[0x00_u8; 8], default_offset).is_err()); - assert!(parse_datetime_partial(&[0xFF_u8; 8], default_offset).is_err()); - assert!(parse_datetime_partial(&[b'0'; 8], default_offset).is_err()); - assert!(parse_datetime_partial(&[b' '; 8], default_offset).is_err()); - assert!(parse_datetime_partial(b"nope", default_offset).is_err()); - assert!(parse_datetime_partial(b"2015dec", default_offset).is_err()); - assert!(parse_datetime_partial(b"20151231162945.", default_offset).is_err()); - assert!(parse_datetime_partial(b"20151130161445+", default_offset).is_err()); - assert!(parse_datetime_partial(b"20151130161445+----", default_offset).is_err()); - assert!(parse_datetime_partial(b"20151130161445. ", default_offset).is_err()); - assert!(parse_datetime_partial(b"20151130161445. +0000", default_offset).is_err()); - assert!(parse_datetime_partial(b"20100423164000.001+3", default_offset).is_err()); - assert!(parse_datetime_partial(b"200809112945*1000", default_offset).is_err()); - assert!(parse_datetime_partial(b"20171130101010.204+1", default_offset).is_err()); - assert!(parse_datetime_partial(b"20171130101010.204+01", default_offset).is_err()); - assert!(parse_datetime_partial(b"20171130101010.204+011", default_offset).is_err()); + assert!(parse_datetime_partial(b"").is_err()); + assert!(parse_datetime_partial(&[0x00_u8; 8]).is_err()); + assert!(parse_datetime_partial(&[0xFF_u8; 8]).is_err()); + assert!(parse_datetime_partial(&[b'0'; 8]).is_err()); + assert!(parse_datetime_partial(&[b' '; 8]).is_err()); + assert!(parse_datetime_partial(b"nope").is_err()); + assert!(parse_datetime_partial(b"2015dec").is_err()); + assert!(parse_datetime_partial(b"20151231162945.").is_err()); + assert!(parse_datetime_partial(b"20151130161445+").is_err()); + assert!(parse_datetime_partial(b"20151130161445+----").is_err()); + assert!(parse_datetime_partial(b"20151130161445. ").is_err()); + assert!(parse_datetime_partial(b"20151130161445. +0000").is_err()); + assert!(parse_datetime_partial(b"20100423164000.001+3").is_err()); + assert!(parse_datetime_partial(b"200809112945*1000").is_err()); + assert!(parse_datetime_partial(b"20171130101010.204+1").is_err()); + assert!(parse_datetime_partial(b"20171130101010.204+01").is_err()); + assert!(parse_datetime_partial(b"20171130101010.204+011").is_err()); } } diff --git a/core/src/value/mod.rs b/core/src/value/mod.rs index 29fcbca9b..8423b4dc2 100644 --- a/core/src/value/mod.rs +++ b/core/src/value/mod.rs @@ -14,7 +14,7 @@ pub mod range; pub mod serialize; pub use self::deserialize::Error as DeserializeError; -pub use self::partial::{DicomDate, DicomDateTime, DicomTime}; +pub use self::partial::{DicomDate, DicomDateTime, DicomTime, PreciseDateTime}; pub use self::person_name::PersonName; pub use self::range::{AsRange, DateRange, DateTimeRange, TimeRange}; @@ -23,9 +23,6 @@ pub use self::primitive::{ ValueType, }; -/// re-exported from chrono -use chrono::FixedOffset; - /// An aggregation of one or more elements in a value. pub type C = SmallVec<[T; 2]>; @@ -252,7 +249,7 @@ impl Value { /// Shorten this value by removing trailing elements /// to fit the given limit. - /// + /// /// On primitive values, /// elements are counted by the number of individual value items /// (note that bytes in a [`PrimitiveValue::U8`] @@ -578,12 +575,9 @@ where /// If the value is a primitive, it will be converted into /// a `DateTime` as described in [`PrimitiveValue::to_datetime`]. /// - pub fn to_datetime( - &self, - default_offset: FixedOffset, - ) -> Result { + pub fn to_datetime(&self) -> Result { match self { - Value::Primitive(v) => v.to_datetime(default_offset), + Value::Primitive(v) => v.to_datetime(), _ => Err(ConvertValueError { requested: "DicomDateTime", original: self.value_type(), @@ -597,12 +591,9 @@ where /// If the value is a primitive, it will be converted into /// a vector of `DicomDateTime` as described in [`PrimitiveValue::to_multi_datetime`]. /// - pub fn to_multi_datetime( - &self, - default_offset: FixedOffset, - ) -> Result, ConvertValueError> { + pub fn to_multi_datetime(&self) -> Result, ConvertValueError> { match self { - Value::Primitive(v) => v.to_multi_datetime(default_offset), + Value::Primitive(v) => v.to_multi_datetime(), _ => Err(ConvertValueError { requested: "DicomDateTime", original: self.value_type(), @@ -648,12 +639,9 @@ where /// If the value is a primitive, it will be converted into /// a `DateTimeRange` as described in [`PrimitiveValue::to_datetime_range`]. /// - pub fn to_datetime_range( - &self, - offset: FixedOffset, - ) -> Result { + pub fn to_datetime_range(&self) -> Result { match self { - Value::Primitive(v) => v.to_datetime_range(offset), + Value::Primitive(v) => v.to_datetime_range(), _ => Err(ConvertValueError { requested: "DateTimeRange", original: self.value_type(), @@ -673,9 +661,7 @@ where } } - /// Retrieves the primitive value as a [`PersonName`][1]. - /// - /// [1]: super::value::person_name::PersonName + /// Retrieves the primitive value as a [`PersonName`]. pub fn to_person_name(&self) -> Result, ConvertValueError> { match self { Value::Primitive(v) => v.to_person_name(), @@ -1043,7 +1029,7 @@ impl

PixelFragmentSequence

{ /// Shorten this sequence by removing trailing fragments /// to fit the given limit. - /// + /// /// Note that this operations does not affect the basic offset table. #[inline] pub fn truncate(&mut self, limit: usize) { diff --git a/core/src/value/partial.rs b/core/src/value/partial.rs index 6e60a90e8..209980f4b 100644 --- a/core/src/value/partial.rs +++ b/core/src/value/partial.rs @@ -1,7 +1,7 @@ //! Handling of partial precision of Date, Time and DateTime values. -use crate::value::range::AsRange; -use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, NaiveTime, Timelike}; +use crate::value::AsRange; +use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use snafu::{Backtrace, ResultExt, Snafu}; use std::convert::{TryFrom, TryInto}; use std::fmt; @@ -59,19 +59,29 @@ type Result = std::result::Result; /// Represents components of Date, Time and DateTime values. #[derive(Debug, PartialEq, Copy, Clone, Eq, Hash, PartialOrd, Ord)] pub enum DateComponent { + // year precision Year, + // month precision Month, + // day precision Day, + // hour precision Hour, + // minute precision Minute, + // second precision Second, + // millisecond precision Millisecond, + // microsecond (full second fraction) Fraction, + // West UTC time-zone offset UtcWest, + // East UTC time-zone offset UtcEast, } -/// Represents a Dicom Date value with a partial precision, +/// Represents a Dicom date (DA) value with a partial precision, /// where some date components may be missing. /// /// Unlike [chrono::NaiveDate], it does not allow for negative years. @@ -105,7 +115,7 @@ pub enum DateComponent { #[derive(Clone, Copy, PartialEq)] pub struct DicomDate(DicomDateImpl); -/// Represents a Dicom Time value with a partial precision, +/// Represents a Dicom time (TM) value with a partial precision, /// where some time components may be missing. /// /// Unlike [chrono::NaiveTime], this implemenation has only 6 digit precision @@ -173,42 +183,46 @@ enum DicomTimeImpl { Fraction(u8, u8, u8, u32, u8), } -/// Represents a Dicom DateTime value with a partial precision, +/// Represents a Dicom date-time (DT) value with a partial precision, /// where some date or time components may be missing. /// -/// `DicomDateTime` is always internally represented by a [DicomDate] -/// and optionally by a [DicomTime]. +/// `DicomDateTime` is always internally represented by a [DicomDate]. +/// The [DicomTime] and a timezone [FixedOffset] values are optional. /// -/// It implements [AsRange] trait and also holds a [FixedOffset] value, from which corresponding -/// [datetime][DateTime] values can be retrieved. +/// It implements [AsRange] trait, +/// which serves to retrieve a [`PreciseDateTime`] +/// from values with missing components. /// # Example /// ``` /// # use std::error::Error; /// # use std::convert::TryFrom; /// use chrono::{DateTime, FixedOffset, TimeZone, NaiveDateTime, NaiveDate, NaiveTime}; -/// use dicom_core::value::{DicomDate, DicomTime, DicomDateTime, AsRange}; +/// use dicom_core::value::{DicomDate, DicomTime, DicomDateTime, AsRange, PreciseDateTime}; /// # fn main() -> Result<(), Box> { /// /// let offset = FixedOffset::east_opt(3600).unwrap(); /// -/// // the least precise date-time value possible is a 'YYYY' -/// let dt = DicomDateTime::from_date( +/// // lets create the least precise date-time value possible 'YYYY' and make it time-zone aware +/// let dt = DicomDateTime::from_date_with_time_zone( /// DicomDate::from_y(2020)?, /// offset /// ); +/// // the earliest possible value is output as a [PreciseDateTime] /// assert_eq!( -/// Some(dt.earliest()?), +/// dt.earliest()?, +/// PreciseDateTime::TimeZone( /// offset.from_local_datetime(&NaiveDateTime::new( /// NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(), /// NaiveTime::from_hms_opt(0, 0, 0).unwrap() -/// )).single() +/// )).single().unwrap()) /// ); /// assert_eq!( -/// Some(dt.latest()?), +/// dt.latest()?, +/// PreciseDateTime::TimeZone( /// offset.from_local_datetime(&NaiveDateTime::new( /// NaiveDate::from_ymd_opt(2020, 12, 31).unwrap(), /// NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() -/// )).single() +/// )).single().unwrap()) /// ); /// /// let chrono_datetime = offset.from_local_datetime(&NaiveDateTime::new( @@ -228,7 +242,7 @@ enum DicomTimeImpl { pub struct DicomDateTime { date: DicomDate, time: Option, - offset: FixedOffset, + time_zone: Option, } /** @@ -317,6 +331,15 @@ impl DicomDate { DicomDate(DicomDateImpl::Day(_, _, d)) => Some(d), } } + + /** Retrieves the last fully precise `DateComponent` of the value */ + pub(crate) fn precision(&self) -> DateComponent { + match self { + DicomDate(DicomDateImpl::Year(..)) => DateComponent::Year, + DicomDate(DicomDateImpl::Month(..)) => DateComponent::Month, + DicomDate(DicomDateImpl::Day(..)) => DateComponent::Day, + } + } } impl TryFrom<&NaiveDate> for DicomDate { @@ -504,6 +527,16 @@ impl DicomTime { frac_precision, ))) } + + /** Retrieves the last fully precise `DateComponent` of the value */ + pub(crate) fn precision(&self) -> DateComponent { + match self { + DicomTime(DicomTimeImpl::Hour(..)) => DateComponent::Hour, + DicomTime(DicomTimeImpl::Minute(..)) => DateComponent::Minute, + DicomTime(DicomTimeImpl::Second(..)) => DateComponent::Second, + DicomTime(DicomTimeImpl::Fraction(..)) => DateComponent::Fraction, + } + } } impl TryFrom<&NaiveTime> for DicomTime { @@ -576,30 +609,60 @@ impl fmt::Debug for DicomTime { impl DicomDateTime { /** - * Constructs a new `DicomDateTime` from a `DicomDate` and a given `FixedOffset`. + * Constructs a new `DicomDateTime` from a `DicomDate` and a timezone `FixedOffset`. */ - pub fn from_date(date: DicomDate, offset: FixedOffset) -> DicomDateTime { + pub fn from_date_with_time_zone(date: DicomDate, time_zone: FixedOffset) -> DicomDateTime { DicomDateTime { date, time: None, - offset, + time_zone: Some(time_zone), + } + } + + /** + * Constructs a new `DicomDateTime` from a `DicomDate` . + */ + pub fn from_date(date: DicomDate) -> DicomDateTime { + DicomDateTime { + date, + time: None, + time_zone: None, + } + } + + /** + * Constructs a new `DicomDateTime` from a `DicomDate` and a `DicomTime`, + * providing that `DicomDate` is precise. + */ + pub fn from_date_and_time(date: DicomDate, time: DicomTime) -> Result { + if date.is_precise() { + Ok(DicomDateTime { + date, + time: Some(time), + time_zone: None, + }) + } else { + DateTimeFromPartialsSnafu { + value: date.precision(), + } + .fail() } } /** - * Constructs a new `DicomDateTime` from a `DicomDate`, `DicomTime` and a given `FixedOffset`, + * Constructs a new `DicomDateTime` from a `DicomDate`, `DicomTime` and a timezone `FixedOffset`, * providing that `DicomDate` is precise. */ - pub fn from_date_and_time( + pub fn from_date_and_time_with_time_zone( date: DicomDate, time: DicomTime, - offset: FixedOffset, + time_zone: FixedOffset, ) -> Result { if date.is_precise() { Ok(DicomDateTime { date, time: Some(time), - offset, + time_zone: Some(time_zone), }) } else { DateTimeFromPartialsSnafu { @@ -609,20 +672,29 @@ impl DicomDateTime { } } - /** Retrieves a refrence to the internal date value */ + /** Retrieves a reference to the internal date value */ pub fn date(&self) -> &DicomDate { &self.date } - /** Retrieves a refrence to the internal time value, if present */ + /** Retrieves a reference to the internal time value, if present */ pub fn time(&self) -> Option<&DicomTime> { self.time.as_ref() } - /** Retrieves a refrence to the internal offset value */ - pub fn offset(&self) -> &FixedOffset { - &self.offset + /** Retrieves a reference to the internal time-zone value, if present */ + pub fn time_zone(&self) -> Option<&FixedOffset> { + self.time_zone.as_ref() } + + /** Returns true, if the `DicomDateTime` contains a time-zone */ + pub fn has_time_zone(&self) -> bool { + self.time_zone.is_some() + } + + /** Retrieves a reference to the internal offset value */ + #[deprecated(since = "0.7.0", note = "Use `time_zone` instead")] + pub fn offset(&self) {} } impl TryFrom<&DateTime> for DicomDateTime { @@ -660,7 +732,7 @@ impl TryFrom<&DateTime> for DicomDateTime { (second, microsecond) }; - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(year, month, day)?, DicomTime::from_hms_micro(hour, minute, second, microsecond)?, *dt.offset(), @@ -668,17 +740,59 @@ impl TryFrom<&DateTime> for DicomDateTime { } } +impl TryFrom<&NaiveDateTime> for DicomDateTime { + type Error = Error; + fn try_from(dt: &NaiveDateTime) -> Result { + let year: u16 = dt.year().try_into().context(ConversionSnafu { + value: dt.year().to_string(), + component: DateComponent::Year, + })?; + let month: u8 = dt.month().try_into().context(ConversionSnafu { + value: dt.month().to_string(), + component: DateComponent::Month, + })?; + let day: u8 = dt.day().try_into().context(ConversionSnafu { + value: dt.day().to_string(), + component: DateComponent::Day, + })?; + let hour: u8 = dt.hour().try_into().context(ConversionSnafu { + value: dt.hour().to_string(), + component: DateComponent::Hour, + })?; + let minute: u8 = dt.minute().try_into().context(ConversionSnafu { + value: dt.minute().to_string(), + component: DateComponent::Minute, + })?; + let second: u8 = dt.second().try_into().context(ConversionSnafu { + value: dt.second().to_string(), + component: DateComponent::Second, + })?; + let microsecond = dt.nanosecond() / 1000; + // leap second correction: convert (59, 1_000_000 + x) to (60, x) + let (second, microsecond) = if microsecond >= 1_000_000 && second == 59 { + (60, microsecond - 1_000_000) + } else { + (second, microsecond) + }; + + DicomDateTime::from_date_and_time( + DicomDate::from_ymd(year, month, day)?, + DicomTime::from_hms_micro(hour, minute, second, microsecond)?, + ) + } +} + impl fmt::Display for DicomDateTime { fn fmt(&self, frm: &mut fmt::Formatter<'_>) -> fmt::Result { - // as DicomDateTime always contains a FixedOffset, it will always be written, - // even if it is zero. - // For absolute consistency between deserialized and serialized date-times, - // DicomDateTime would have to contain Some(FixedOffset)/None if none was parsed. - // storing an Option is useless, since a FixedOffset has to be available - // for conversion into chrono values match self.time { - None => write!(frm, "{} {}", self.date, self.offset), - Some(time) => write!(frm, "{} {} {}", self.date, time, self.offset), + None => match self.time_zone { + Some(offset) => write!(frm, "{} {}", self.date, offset), + None => write!(frm, "{}", self.date), + }, + Some(time) => match self.time_zone { + Some(offset) => write!(frm, "{} {} {}", self.date, time, offset), + None => write!(frm, "{} {}", self.date, time), + }, } } } @@ -686,48 +800,23 @@ impl fmt::Display for DicomDateTime { impl fmt::Debug for DicomDateTime { fn fmt(&self, frm: &mut fmt::Formatter<'_>) -> fmt::Result { match self.time { - None => write!(frm, "{:?} {:?}", self.date, self.offset), - Some(time) => write!(frm, "{:?} {:?} {}", self.date, time, self.offset), - } - } -} - -/** - * This trait is implemented by partial precision - * Date, Time and DateTime structures. - * Trait method returns the last fully precise `DateComponent` of the structure. - */ -pub trait Precision { - fn precision(&self) -> DateComponent; -} - -impl Precision for DicomDate { - fn precision(&self) -> DateComponent { - match self { - DicomDate(DicomDateImpl::Year(..)) => DateComponent::Year, - DicomDate(DicomDateImpl::Month(..)) => DateComponent::Month, - DicomDate(DicomDateImpl::Day(..)) => DateComponent::Day, + None => match self.time_zone { + Some(offset) => write!(frm, "{:?} {}", self.date, offset), + None => write!(frm, "{:?}", self.date), + }, + Some(time) => match self.time_zone { + Some(offset) => write!(frm, "{:?} {:?} {}", self.date, time, offset), + None => write!(frm, "{:?} {:?}", self.date, time), + }, } } } -impl Precision for DicomTime { - fn precision(&self) -> DateComponent { - match self { - DicomTime(DicomTimeImpl::Hour(..)) => DateComponent::Hour, - DicomTime(DicomTimeImpl::Minute(..)) => DateComponent::Minute, - DicomTime(DicomTimeImpl::Second(..)) => DateComponent::Second, - DicomTime(DicomTimeImpl::Fraction(..)) => DateComponent::Fraction, - } - } -} +impl std::str::FromStr for DicomDateTime { + type Err = crate::value::DeserializeError; -impl Precision for DicomDateTime { - fn precision(&self) -> DateComponent { - match self.time { - Some(time) => time.precision(), - None => self.date.precision(), - } + fn from_str(s: &str) -> Result { + crate::value::deserialize::parse_datetime_partial(s.as_bytes()) } } @@ -776,23 +865,130 @@ impl DicomDateTime { */ pub fn to_encoded(&self) -> String { match self.time { - Some(time) => format!( - "{}{}{}", - self.date.to_encoded(), - time.to_encoded(), - self.offset.to_string().replace(':', "") - ), - None => format!( - "{}{}", - self.date.to_encoded(), - self.offset.to_string().replace(':', "") - ), + Some(time) => match self.time_zone { + Some(offset) => format!( + "{}{}{}", + self.date.to_encoded(), + time.to_encoded(), + offset.to_string().replace(':', "") + ), + None => format!("{}{}", self.date.to_encoded(), time.to_encoded()), + }, + None => match self.time_zone { + Some(offset) => format!( + "{}{}", + self.date.to_encoded(), + offset.to_string().replace(':', "") + ), + None => self.date.to_encoded().to_string(), + }, + } + } +} + +/// An encapsulated date-time value which is precise to the microsecond +/// and can either be time-zone aware or time-zone naive. +/// +/// It is usually the outcome of converting a precise +/// [DICOM date-time value](DicomDateTime) +/// to a [chrono] date-time value. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub enum PreciseDateTime { + /// Naive date-time, with no time zone + Naive(NaiveDateTime), + /// Date-time with a time zone defined by a fixed offset + TimeZone(DateTime), +} + +impl PreciseDateTime { + /// Retrieves a reference to a [`chrono::DateTime`][chrono::DateTime] + /// if the result is time-zone aware. + pub fn as_datetime(&self) -> Option<&DateTime> { + match self { + PreciseDateTime::Naive(..) => None, + PreciseDateTime::TimeZone(value) => Some(value), + } + } + + /// Retrieves a reference to a [`chrono::NaiveDateTime`] + /// only if the result is time-zone naive. + pub fn as_naive_datetime(&self) -> Option<&NaiveDateTime> { + match self { + PreciseDateTime::Naive(value) => Some(value), + PreciseDateTime::TimeZone(..) => None, + } + } + + /// Moves out a [`chrono::DateTime`](chrono::DateTime) + /// if the result is time-zone aware. + pub fn into_datetime(self) -> Option> { + match self { + PreciseDateTime::Naive(..) => None, + PreciseDateTime::TimeZone(value) => Some(value), } } + + /// Moves out a [`chrono::NaiveDateTime`] + /// only if the result is time-zone naive. + pub fn into_naive_datetime(self) -> Option { + match self { + PreciseDateTime::Naive(value) => Some(value), + PreciseDateTime::TimeZone(..) => None, + } + } + + /// Retrieves the time-zone naive date component + /// of the precise date-time value. + /// + /// # Panics + /// + /// The time-zone aware variant uses `DateTime`, + /// which internally stores the date and time in UTC with a `NaiveDateTime`. + /// This method will panic if the offset from UTC would push the local date + /// outside of the representable range of a `NaiveDate`. + pub fn to_naive_date(&self) -> NaiveDate { + match self { + PreciseDateTime::Naive(value) => value.date(), + PreciseDateTime::TimeZone(value) => value.date_naive(), + } + } + + /// Retrieves the time component of the precise date-time value. + pub fn to_naive_time(&self) -> NaiveTime { + match self { + PreciseDateTime::Naive(value) => value.time(), + PreciseDateTime::TimeZone(value) => value.time(), + } + } + + /// Returns `true` if the result is time-zone aware. + #[inline] + pub fn has_time_zone(&self) -> bool { + matches!(self, PreciseDateTime::TimeZone(..)) + } +} + +/// The partial ordering for `PreciseDateTime` +/// is defined by the partial ordering of matching variants +/// (`Naive` with `Naive`, `TimeZone` with `TimeZone`). +/// +/// Any other comparison cannot be defined, +/// and therefore will always return `None`. +impl PartialOrd for PreciseDateTime { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (PreciseDateTime::Naive(a), PreciseDateTime::Naive(b)) => a.partial_cmp(b), + (PreciseDateTime::TimeZone(a), PreciseDateTime::TimeZone(b)) => a.partial_cmp(b), + _ => None, + } + + } } #[cfg(test)] mod tests { + use crate::value::range::AsRange; + use super::*; use chrono::{NaiveDateTime, TimeZone}; @@ -802,6 +998,9 @@ mod tests { DicomDate::from_ymd(1944, 2, 29).unwrap(), DicomDate(DicomDateImpl::Day(1944, 2, 29)) ); + + // cheap precision check, but date is invalid + assert!(DicomDate::from_ymd(1945, 2, 29).unwrap().is_precise()); assert_eq!( DicomDate::from_ym(1944, 2).unwrap(), DicomDate(DicomDateImpl::Month(1944, 2)) @@ -881,6 +1080,13 @@ mod tests { DicomTime::from_h(1).unwrap(), DicomTime(DicomTimeImpl::Hour(1)) ); + // cheap precision checks + assert!(DicomTime::from_hms_micro(9, 1, 1, 123456) + .unwrap() + .is_precise()); + assert!(!DicomTime::from_hms_milli(9, 1, 1, 123) + .unwrap() + .is_precise()); assert_eq!( DicomTime::from_hms_milli(9, 1, 1, 123) @@ -1027,42 +1233,47 @@ mod tests { fn test_dicom_datetime() { let default_offset = FixedOffset::east_opt(0).unwrap(); assert_eq!( - DicomDateTime::from_date(DicomDate::from_ymd(2020, 2, 29).unwrap(), default_offset), + DicomDateTime::from_date_with_time_zone( + DicomDate::from_ymd(2020, 2, 29).unwrap(), + default_offset + ), DicomDateTime { date: DicomDate::from_ymd(2020, 2, 29).unwrap(), time: None, - offset: default_offset + time_zone: Some(default_offset) } ); assert_eq!( - DicomDateTime::from_date(DicomDate::from_ym(2020, 2).unwrap(), default_offset) + DicomDateTime::from_date(DicomDate::from_ym(2020, 2).unwrap()) .earliest() .unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 1).unwrap(), - NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() - )) - .unwrap() + PreciseDateTime::Naive(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 1).unwrap(), + NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() + )) ); assert_eq!( - DicomDateTime::from_date(DicomDate::from_ym(2020, 2).unwrap(), default_offset) - .latest() - .unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() - )) - .unwrap() + DicomDateTime::from_date_with_time_zone( + DicomDate::from_ym(2020, 2).unwrap(), + default_offset + ) + .latest() + .unwrap(), + PreciseDateTime::TimeZone( + FixedOffset::east_opt(0) + .unwrap() + .from_local_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() + )) + .unwrap() + ) ); assert_eq!( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2020, 2, 29).unwrap(), DicomTime::from_hmsf(23, 59, 59, 10, 2).unwrap(), default_offset @@ -1070,16 +1281,18 @@ mod tests { .unwrap() .earliest() .unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(23, 59, 59, 100_000).unwrap() - )) - .unwrap() + PreciseDateTime::TimeZone( + FixedOffset::east_opt(0) + .unwrap() + .from_local_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(23, 59, 59, 100_000).unwrap() + )) + .unwrap() + ) ); assert_eq!( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2020, 2, 29).unwrap(), DicomTime::from_hmsf(23, 59, 59, 10, 2).unwrap(), default_offset @@ -1087,13 +1300,15 @@ mod tests { .unwrap() .latest() .unwrap(), - FixedOffset::east_opt(0) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(23, 59, 59, 109_999).unwrap() - )) - .unwrap() + PreciseDateTime::TimeZone( + FixedOffset::east_opt(0) + .unwrap() + .from_local_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(23, 59, 59, 109_999).unwrap() + )) + .unwrap() + ) ); assert_eq!( @@ -1110,7 +1325,7 @@ mod tests { DicomDateTime { date: DicomDate::from_ymd(2020, 2, 29).unwrap(), time: Some(DicomTime::from_hms_micro(23, 59, 59, 999_999).unwrap()), - offset: default_offset + time_zone: Some(default_offset) } ); @@ -1128,7 +1343,7 @@ mod tests { DicomDateTime { date: DicomDate::from_ymd(2020, 2, 29).unwrap(), time: Some(DicomTime::from_hms_micro(23, 59, 59, 0).unwrap()), - offset: default_offset + time_zone: Some(default_offset) } ); @@ -1147,18 +1362,21 @@ mod tests { DicomDateTime { date: DicomDate::from_ymd(2023, 12, 31).unwrap(), time: Some(DicomTime::from_hms_micro(23, 59, 60, 0).unwrap()), - offset: default_offset + time_zone: Some(default_offset) } ); assert!(matches!( - DicomDateTime::from_date(DicomDate::from_ymd(2021, 2, 29).unwrap(), default_offset) - .earliest(), + DicomDateTime::from_date_with_time_zone( + DicomDate::from_ymd(2021, 2, 29).unwrap(), + default_offset + ) + .earliest(), Err(crate::value::range::Error::InvalidDate { .. }) )); assert!(matches!( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ym(2020, 2).unwrap(), DicomTime::from_hms_milli(23, 59, 59, 999).unwrap(), default_offset @@ -1169,7 +1387,7 @@ mod tests { }) )); assert!(matches!( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_y(1).unwrap(), DicomTime::from_hms_micro(23, 59, 59, 10).unwrap(), default_offset @@ -1181,7 +1399,7 @@ mod tests { )); assert!(matches!( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2000, 1, 1).unwrap(), DicomTime::from_hms_milli(23, 59, 59, 10).unwrap(), default_offset @@ -1190,5 +1408,23 @@ mod tests { .exact(), Err(crate::value::range::Error::ImpreciseValue { .. }) )); + + // simple precision checks + assert!( + DicomDateTime::from_date_and_time( + DicomDate::from_ymd(2000, 1, 1).unwrap(), + DicomTime::from_hms_milli(23, 59, 59, 10).unwrap() + ) + .unwrap() + .is_precise() + == false + ); + + assert!(DicomDateTime::from_date_and_time( + DicomDate::from_ymd(2000, 1, 1).unwrap(), + DicomTime::from_hms_micro(23, 59, 59, 654_321).unwrap() + ) + .unwrap() + .is_precise()); } } diff --git a/core/src/value/primitive.rs b/core/src/value/primitive.rs index 3cd9c3968..723101fd8 100644 --- a/core/src/value/primitive.rs +++ b/core/src/value/primitive.rs @@ -2,12 +2,11 @@ //! //! See [`PrimitiveValue`](./enum.PrimitiveValue.html). -use super::DicomValueType; +use super::{AsRange, DicomValueType}; use crate::header::{HasLength, Length, Tag}; -use crate::value::partial::{DateComponent, DicomDate, DicomDateTime, DicomTime, Precision}; +use crate::value::partial::{DateComponent, DicomDate, DicomDateTime, DicomTime}; use crate::value::person_name::PersonName; -use crate::value::range::{DateRange, DateTimeRange, TimeRange}; -use chrono::FixedOffset; +use crate::value::range::{AmbiguousDtRangeParser, DateRange, DateTimeRange, TimeRange}; use itertools::Itertools; use num_traits::NumCast; use safe_transmute::to_bytes::transmute_to_bytes; @@ -183,7 +182,7 @@ impl std::error::Error for ConvertValueError { pub type Result = std::result::Result; // Re-exported from chrono -pub use chrono::{DateTime, NaiveDate, NaiveTime}; +pub use chrono::{NaiveDate, NaiveTime}; /// An aggregation of one or more elements in a value. pub type C = SmallVec<[T; 2]>; @@ -516,9 +515,10 @@ impl PrimitiveValue { Some(time) => PrimitiveValue::tm_byte_len(time), None => 0, } - + 5 - // always return length of UTC offset, as current impl Display for DicomDateTime - // always writes the offset, even if it is zero + + match datetime.has_time_zone() { + true => 5, + false => 0, + } } /// Convert the primitive value into a string representation. @@ -1907,6 +1907,10 @@ impl PrimitiveValue { /// Retrieve a single `chrono::NaiveDate` from this value. /// + /// Please note, that this is a shortcut to obtain a usable date from a primitive value. + /// As per standard, the stored value might not be precise. It is highly recommended to + /// use [`.to_date()`](PrimitiveValue::to_date) as the only way to obtain dates. + /// /// If the value is already represented as a precise `DicomDate`, it is converted /// to a `NaiveDate` value. It fails for imprecise values. /// If the value is a string or sequence of strings, @@ -1995,6 +1999,10 @@ impl PrimitiveValue { /// Retrieve the full sequence of `chrono::NaiveDate`s from this value. /// + /// Please note, that this is a shortcut to obtain usable dates from a primitive value. + /// As per standard, the stored values might not be precise. It is highly recommended to + /// use [`.to_multi_date()`](PrimitiveValue::to_multi_date) as the only way to obtain dates. + /// /// If the value is already represented as a sequence of precise `DicomDate` values, /// it is converted. It fails for imprecise values. /// If the value is a string or sequence of strings, @@ -2250,6 +2258,10 @@ impl PrimitiveValue { /// Retrieve a single `chrono::NaiveTime` from this value. /// + /// Please note, that this is a shortcut to obtain a usable time from a primitive value. + /// As per standard, the stored value might not be precise. It is highly recommended to + /// use [`.to_time()`](PrimitiveValue::to_time) as the only way to obtain times. + /// /// If the value is represented as a precise `DicomTime`, /// it is converted to a `NaiveTime`. /// It fails for imprecise values, @@ -2261,9 +2273,6 @@ impl PrimitiveValue { /// first interpreted as an ASCII character string. /// Otherwise, the operation fails. /// - /// Users are advised that this method requires at least 1 out of 6 digits of the second - /// fraction .F to be present. Otherwise, the operation fails. - /// /// Partial precision times are handled by `DicomTime`, /// which can be retrieved by [`.to_time()`](PrimitiveValue::to_time). /// @@ -2336,6 +2345,10 @@ impl PrimitiveValue { /// Retrieve the full sequence of `chrono::NaiveTime`s from this value. /// + /// Please note, that this is a shortcut to obtain a usable time from a primitive value. + /// As per standard, the stored values might not be precise. It is highly recommended to + /// use [`.to_multi_time()`](PrimitiveValue::to_multi_time) as the only way to obtain times. + /// /// If the value is already represented as a sequence of precise `DicomTime` values, /// it is converted to a sequence of `NaiveTime` values. It fails for imprecise values. /// If the value is a string or sequence of strings, @@ -2627,227 +2640,10 @@ impl PrimitiveValue { } } - /// Retrieve a single `chrono::DateTime` from this value. - /// - /// If the value is already represented as a precise `DicomDateTime`, - /// it is converted to `chrono::DateTime`. Imprecise values fail. - /// If the value is a string or sequence of strings, - /// the first string is decoded to obtain a date-time, - /// potentially failing if the string does not represent a valid time. - /// If the value in its textual form does not present a time zone, - /// `default_offset` is used. - /// If the value is a sequence of U8 bytes, the bytes are - /// first interpreted as an ASCII character string. - /// Otherwise, the operation fails. - /// - /// Users of this method are advised to retrieve - /// the default time zone offset - /// from the same source of the DICOM value. - /// - /// Users are advised that this method requires at least 1 out of 6 digits of the second - /// fraction .F to be present. Otherwise, the operation fails. - /// - /// Partial precision date-times are handled by `DicomDateTime`, - /// which can be retrieved by [`.to_datetime()`](PrimitiveValue::to_datetime). - /// - /// # Example - /// - /// ``` - /// # use dicom_core::value::{C, PrimitiveValue, DicomDateTime, DicomDate, DicomTime}; - /// # use smallvec::smallvec; - /// # use chrono::{DateTime, FixedOffset, TimeZone}; - /// # use std::error::Error; - /// # fn main() -> Result<(), Box> { - /// let default_offset = FixedOffset::east(0); - /// - /// // full accuracy `DicomDateTime` can be converted - /// assert_eq!( - /// PrimitiveValue::from( - /// DicomDateTime::from_date_and_time( - /// DicomDate::from_ymd(2012, 12, 21)?, - /// DicomTime::from_hms_micro(9, 30, 1, 1)?, - /// default_offset - /// )? - /// ).to_chrono_datetime(default_offset)?, - /// FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 1) - /// , - /// ); - /// - /// assert_eq!( - /// PrimitiveValue::from("20121221093001.1") - /// .to_chrono_datetime(default_offset).ok(), - /// Some(FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 100_000) - /// ), - /// ); - /// # Ok(()) - /// # } - /// ``` - pub fn to_chrono_datetime( - &self, - default_offset: FixedOffset, - ) -> Result, ConvertValueError> { - match self { - PrimitiveValue::DateTime(v) if !v.is_empty() => v[0] - .to_chrono_datetime() - .context(ParseDateTimeRangeSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }), - PrimitiveValue::Str(s) => { - super::deserialize::parse_datetime(s.trim_end().as_bytes(), default_offset) - .context(ParseDateTimeSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }) - } - PrimitiveValue::Strs(s) => super::deserialize::parse_datetime( - s.first().map(|s| s.trim_end().as_bytes()).unwrap_or(&[]), - default_offset, - ) - .context(ParseDateTimeSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }), - PrimitiveValue::U8(bytes) => { - super::deserialize::parse_datetime(trim_last_whitespace(bytes), default_offset) - .context(ParseDateTimeSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }) - } - _ => Err(ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: None, - }), - } - } - - /// Retrieve the full sequence of `chrono::DateTime`s from this value. - /// - /// If the value is already represented as a sequence of precise `DicomDateTime` values, - /// it is converted to a sequence of `chrono::DateTime` values. Imprecise values fail. - /// If the value is a string or sequence of strings, - /// the strings are decoded to obtain a date, potentially failing if - /// any of the strings does not represent a valid date. - /// If the value is a sequence of U8 bytes, the bytes are - /// first interpreted as an ASCII character string, - /// then as a backslash-separated list of date-times. - /// Otherwise, the operation fails. - /// - /// Users are advised that this method requires at least 1 out of 6 digits of the second - /// fraction .F to be present. Otherwise, the operation fails. - /// - /// Partial precision date-times are handled by `DicomDateTime`, - /// which can be retrieved by [`.to_multi_datetime()`](PrimitiveValue::to_multi_datetime). - /// - /// # Example - /// - /// ``` - /// # use dicom_core::value::{C, PrimitiveValue, DicomDate, DicomTime, DicomDateTime}; - /// # use smallvec::smallvec; - /// # use chrono::{FixedOffset, TimeZone}; - /// # fn main() -> Result<(), Box> { - /// let default_offset = FixedOffset::east(0); - /// - /// // full accuracy `DicomDateTime` can be converted - /// assert_eq!( - /// PrimitiveValue::from( - /// DicomDateTime::from_date_and_time( - /// DicomDate::from_ymd(2012, 12, 21)?, - /// DicomTime::from_hms_micro(9, 30, 1, 123_456)?, - /// default_offset - /// )? - /// ).to_multi_chrono_datetime(default_offset)?, - /// vec![FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 123_456) - /// ], - /// ); - /// - /// assert_eq!( - /// PrimitiveValue::Strs(smallvec![ - /// "20121221093001.123".to_string(), - /// "20180102100123.123456".to_string(), - /// ]).to_multi_chrono_datetime(default_offset).ok(), - /// Some(vec![ - /// FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 123_000), - /// FixedOffset::east(0) - /// .ymd(2018, 1, 2) - /// .and_hms_micro(10, 1, 23, 123_456) - /// ]), - /// ); - /// # Ok(()) - /// # } - /// ``` - pub fn to_multi_chrono_datetime( - &self, - default_offset: FixedOffset, - ) -> Result>, ConvertValueError> { - match self { - PrimitiveValue::DateTime(v) if !v.is_empty() => v - .into_iter() - .map(|dt| dt.to_chrono_datetime()) - .collect::, _>>() - .context(ParseDateTimeRangeSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }), - PrimitiveValue::Str(s) => { - super::deserialize::parse_datetime(s.trim_end().as_bytes(), default_offset) - .map(|date| vec![date]) - .context(ParseDateSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }) - } - PrimitiveValue::Strs(s) => s - .into_iter() - .map(|s| { - super::deserialize::parse_datetime(s.trim_end().as_bytes(), default_offset) - }) - .collect::, _>>() - .context(ParseDateSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }), - PrimitiveValue::U8(bytes) => trim_last_whitespace(bytes) - .split(|c| *c == b'\\') - .map(|s| super::deserialize::parse_datetime(s, default_offset)) - .collect::, _>>() - .context(ParseDateSnafu) - .map_err(|err| ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: Some(err), - }), - _ => Err(ConvertValueError { - requested: "DateTime", - original: self.value_type(), - cause: None, - }), - } - } + #[deprecated(since = "0.7.0", note = "Use `to_datetime` instead")] + pub fn to_chrono_datetime(&self) {} + #[deprecated(since = "0.7.0", note = "Use `to_multi_datetime` instead")] + pub fn to_multi_chrono_datetime(&self) {} /// Retrieve a single `DicomDateTime` from this value. /// @@ -2869,64 +2665,68 @@ impl PrimitiveValue { /// ``` /// # use dicom_core::value::{C, PrimitiveValue}; /// # use smallvec::smallvec; - /// # use chrono::{DateTime, FixedOffset, TimeZone}; + /// # use chrono::{DateTime, FixedOffset, TimeZone, NaiveDateTime, NaiveDate, NaiveTime}; /// # use std::error::Error; - /// use dicom_core::value::{DicomDateTime, AsRange, DateTimeRange}; + /// use dicom_core::value::{DicomDateTime, AsRange, DateTimeRange, PreciseDateTime}; /// /// # fn main() -> Result<(), Box> { - /// let default_offset = FixedOffset::east(0); /// - /// let dt_value = PrimitiveValue::from("20121221093001.1").to_datetime(default_offset)?; + /// // let's parse a date-time text value with 0.1 second precision without a time-zone. + /// let dt_value = PrimitiveValue::from("20121221093001.1").to_datetime()?; /// /// assert_eq!( /// dt_value.earliest()?, - /// FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 100_000) + /// PreciseDateTime::Naive(NaiveDateTime::new( + /// NaiveDate::from_ymd_opt(2012, 12, 21).unwrap(), + /// NaiveTime::from_hms_micro_opt(9, 30, 1, 100_000).unwrap() + /// )) /// ); /// assert_eq!( /// dt_value.latest()?, - /// FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 199_999) + /// PreciseDateTime::Naive(NaiveDateTime::new( + /// NaiveDate::from_ymd_opt(2012, 12, 21).unwrap(), + /// NaiveTime::from_hms_micro_opt(9, 30, 1, 199_999).unwrap() + /// )) /// ); /// - /// let dt_value = PrimitiveValue::from("20121221093001.123456").to_datetime(default_offset)?; + /// let default_offset = FixedOffset::east_opt(3600).unwrap(); + /// // let's parse a date-time text value with full precision with a time-zone east +01:00. + /// let dt_value = PrimitiveValue::from("20121221093001.123456+0100").to_datetime()?; /// /// // date-time has all components /// assert_eq!(dt_value.is_precise(), true); /// - /// assert!(dt_value.exact().is_ok()); - /// - /// // .to_chrono_datetime() only works for a precise value /// assert_eq!( - /// dt_value.to_chrono_datetime()?, - /// dt_value.exact()? + /// dt_value.exact()?, + /// PreciseDateTime::TimeZone( + /// default_offset + /// .ymd_opt(2012, 12, 21).unwrap() + /// .and_hms_micro_opt(9, 30, 1, 123_456).unwrap() + /// ) + /// /// ); /// /// // ranges are inclusive, for a precise value, two identical values are returned /// assert_eq!( /// dt_value.range()?, - /// DateTimeRange::from_start_to_end( - /// FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 123_456), - /// FixedOffset::east(0) - /// .ymd(2012, 12, 21) - /// .and_hms_micro(9, 30, 1, 123_456))? + /// DateTimeRange::from_start_to_end_with_time_zone( + /// FixedOffset::east_opt(3600).unwrap() + /// .ymd_opt(2012, 12, 21).unwrap() + /// .and_hms_micro_opt(9, 30, 1, 123_456).unwrap(), + /// FixedOffset::east_opt(3600).unwrap() + /// .ymd_opt(2012, 12, 21).unwrap() + /// .and_hms_micro_opt(9, 30, 1, 123_456).unwrap() + /// )? /// /// ); /// # Ok(()) /// # } /// ``` - pub fn to_datetime( - &self, - default_offset: FixedOffset, - ) -> Result { + pub fn to_datetime(&self) -> Result { match self { PrimitiveValue::DateTime(v) if !v.is_empty() => Ok(v[0]), PrimitiveValue::Str(s) => { - super::deserialize::parse_datetime_partial(s.trim_end().as_bytes(), default_offset) + super::deserialize::parse_datetime_partial(s.trim_end().as_bytes()) .context(ParseDateTimeSnafu) .map_err(|err| ConvertValueError { requested: "DicomDateTime", @@ -2936,17 +2736,6 @@ impl PrimitiveValue { } PrimitiveValue::Strs(s) => super::deserialize::parse_datetime_partial( s.first().map(|s| s.trim_end().as_bytes()).unwrap_or(&[]), - default_offset, - ) - .context(ParseDateTimeSnafu) - .map_err(|err| ConvertValueError { - requested: "DicomDateTime", - original: self.value_type(), - cause: Some(err), - }), - PrimitiveValue::U8(bytes) => super::deserialize::parse_datetime_partial( - trim_last_whitespace(bytes), - default_offset, ) .context(ParseDateTimeSnafu) .map_err(|err| ConvertValueError { @@ -2954,6 +2743,15 @@ impl PrimitiveValue { original: self.value_type(), cause: Some(err), }), + PrimitiveValue::U8(bytes) => { + super::deserialize::parse_datetime_partial(trim_last_whitespace(bytes)) + .context(ParseDateTimeSnafu) + .map_err(|err| ConvertValueError { + requested: "DicomDateTime", + original: self.value_type(), + cause: Some(err), + }) + } _ => Err(ConvertValueError { requested: "DicomDateTime", original: self.value_type(), @@ -2964,14 +2762,11 @@ impl PrimitiveValue { /// Retrieve the full sequence of `DicomDateTime`s from this value. /// - pub fn to_multi_datetime( - &self, - default_offset: FixedOffset, - ) -> Result, ConvertValueError> { + pub fn to_multi_datetime(&self) -> Result, ConvertValueError> { match self { PrimitiveValue::DateTime(v) => Ok(v.to_vec()), PrimitiveValue::Str(s) => { - super::deserialize::parse_datetime_partial(s.trim_end().as_bytes(), default_offset) + super::deserialize::parse_datetime_partial(s.trim_end().as_bytes()) .map(|date| vec![date]) .context(ParseDateSnafu) .map_err(|err| ConvertValueError { @@ -2982,12 +2777,7 @@ impl PrimitiveValue { } PrimitiveValue::Strs(s) => s .into_iter() - .map(|s| { - super::deserialize::parse_datetime_partial( - s.trim_end().as_bytes(), - default_offset, - ) - }) + .map(|s| super::deserialize::parse_datetime_partial(s.trim_end().as_bytes())) .collect::, _>>() .context(ParseDateSnafu) .map_err(|err| ConvertValueError { @@ -2997,7 +2787,7 @@ impl PrimitiveValue { }), PrimitiveValue::U8(bytes) => trim_last_whitespace(bytes) .split(|c| *c == b'\\') - .map(|s| super::deserialize::parse_datetime_partial(s, default_offset)) + .map(super::deserialize::parse_datetime_partial) .collect::, _>>() .context(ParseDateSnafu) .map_err(|err| ConvertValueError { @@ -3014,7 +2804,7 @@ impl PrimitiveValue { } /// Retrieve a single `DateRange` from this value. /// - /// If the value is already represented as a `DicomDate`, it is converted into `DateRange` - todo. + /// If the value is already represented as a `DicomDate`, it is converted into `DateRange`. /// If the value is a string or sequence of strings, /// the first string is decoded to obtain a `DateRange`, potentially failing if the /// string does not represent a valid `DateRange`. @@ -3051,6 +2841,14 @@ impl PrimitiveValue { /// ``` pub fn to_date_range(&self) -> Result { match self { + PrimitiveValue::Date(da) if !da.is_empty() => da[0] + .range() + .context(ParseDateRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "DateRange", + original: self.value_type(), + cause: Some(err), + }), PrimitiveValue::Str(s) => super::range::parse_date_range(s.trim_end().as_bytes()) .context(ParseDateRangeSnafu) .map_err(|err| ConvertValueError { @@ -3086,7 +2884,7 @@ impl PrimitiveValue { /// Retrieve a single `TimeRange` from this value. /// - /// If the value is already represented as a `DicomTime`, it is converted into `TimeRange` - todo. + /// If the value is already represented as a `DicomTime`, it is converted into a `TimeRange`. /// If the value is a string or sequence of strings, /// the first string is decoded to obtain a `TimeRange`, potentially failing if the /// string does not represent a valid `DateRange`. @@ -3126,6 +2924,14 @@ impl PrimitiveValue { /// ``` pub fn to_time_range(&self) -> Result { match self { + PrimitiveValue::Time(t) if !t.is_empty() => t[0] + .range() + .context(ParseTimeRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "TimeRange", + original: self.value_type(), + cause: Some(err), + }), PrimitiveValue::Str(s) => super::range::parse_time_range(s.trim_end().as_bytes()) .context(ParseTimeRangeSnafu) .map_err(|err| ConvertValueError { @@ -3172,46 +2978,180 @@ impl PrimitiveValue { /// /// ``` /// # use dicom_core::value::{C, PrimitiveValue}; - /// use chrono::{DateTime, FixedOffset, TimeZone}; + /// use chrono::{DateTime, NaiveDate, NaiveTime, NaiveDateTime, FixedOffset, TimeZone, Local}; /// # use std::error::Error; - /// use dicom_core::value::{DateTimeRange}; + /// use dicom_core::value::{DateTimeRange, PreciseDateTime}; /// /// # fn main() -> Result<(), Box> { /// - /// let offset = FixedOffset::east(3600); - /// - /// let dt_range = PrimitiveValue::from("19920101153020.123+0500-1993").to_datetime_range(offset)?; + /// // let's parse a text representation of a date-time range, where the lower bound is a microsecond + /// // precision value with a time-zone (east +05:00) and the upper bound is a minimum precision value + /// // with a time-zone + /// let dt_range = PrimitiveValue::from("19920101153020.123+0500-1993+0300").to_datetime_range()?; /// - /// // default offset override with parsed value + /// // lower bound of range is parsed into a PreciseDateTimeResult::TimeZone variant /// assert_eq!( /// dt_range.start(), - /// Some(&FixedOffset::east(5*3600).ymd(1992, 1, 1) - /// .and_hms_micro(15, 30, 20, 123_000) + /// Some(PreciseDateTime::TimeZone( + /// FixedOffset::east_opt(5*3600).unwrap().ymd_opt(1992, 1, 1).unwrap() + /// .and_hms_micro_opt(15, 30, 20, 123_000).unwrap() + /// ) /// ) /// ); /// - /// // null components default to latest possible + /// // upper bound of range is parsed into a PreciseDateTimeResult::TimeZone variant /// assert_eq!( /// dt_range.end(), - /// Some(&offset.ymd(1993, 12, 31) - /// .and_hms_micro(23, 59, 59, 999_999) + /// Some(PreciseDateTime::TimeZone( + /// FixedOffset::east_opt(3*3600).unwrap().ymd_opt(1993, 12, 31).unwrap() + /// .and_hms_micro_opt(23, 59, 59, 999_999).unwrap() + /// ) /// ) /// ); /// - /// let range_from = PrimitiveValue::from("2012-").to_datetime_range(offset)?; + /// let lower = PrimitiveValue::from("2012-").to_datetime_range()?; + /// + /// // range has no upper bound + /// assert!(lower.end().is_none()); + /// + /// // One time-zone in a range is missing + /// let dt_range = PrimitiveValue::from("1992+0500-1993").to_datetime_range()?; + /// + /// // It will be replaced with the local clock time-zone offset + /// // This can be customized with [to_datetime_range_custom()] + /// assert_eq!( + /// dt_range, + /// DateTimeRange::TimeZone{ + /// start: Some(FixedOffset::east_opt(5*3600).unwrap() + /// .ymd_opt(1992, 1, 1).unwrap() + /// .and_hms_micro_opt(0, 0, 0, 0).unwrap() + /// ), + /// end: Some(Local::now().offset() + /// .ymd_opt(1993, 12, 31).unwrap() + /// .and_hms_micro_opt(23, 59, 59, 999_999).unwrap() + /// ) + /// } + /// ); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn to_datetime_range(&self) -> Result { + match self { + PrimitiveValue::DateTime(dt) if !dt.is_empty() => dt[0] + .range() + .context(ParseDateTimeRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "DateTimeRange", + original: self.value_type(), + cause: Some(err), + }), + PrimitiveValue::Str(s) => super::range::parse_datetime_range(s.trim_end().as_bytes()) + .context(ParseDateTimeRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "DateTimeRange", + original: self.value_type(), + cause: Some(err), + }), + PrimitiveValue::Strs(s) => super::range::parse_datetime_range( + s.first().map(|s| s.trim_end().as_bytes()).unwrap_or(&[]), + ) + .context(ParseDateTimeRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "DateTimeRange", + original: self.value_type(), + cause: Some(err), + }), + PrimitiveValue::U8(bytes) => { + super::range::parse_datetime_range(trim_last_whitespace(bytes)) + .context(ParseDateTimeRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "DateTimeRange", + original: self.value_type(), + cause: Some(err), + }) + } + _ => Err(ConvertValueError { + requested: "DateTimeRange", + original: self.value_type(), + cause: None, + }), + } + } + + /// Retrieve a single `DateTimeRange` from this value. + /// + /// Use a custom ambiguous date-time range parser. + /// + /// For full description see [PrimitiveValue::to_datetime_range] and [AmbiguousDtRangeParser]. + /// # Example + /// + /// ``` + /// # use dicom_core::value::{C, PrimitiveValue}; + /// # use std::error::Error; + /// use dicom_core::value::range::{AmbiguousDtRangeParser, ToKnownTimeZone, IgnoreTimeZone, FailOnAmbiguousRange, DateTimeRange}; + /// use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; + /// # fn main() -> Result<(), Box> { + /// + /// // The upper bound time-zone is missing + /// // the default behavior in this case is to use the local clock time-zone. + /// // But we want to use the known (parsed) time-zone from the lower bound instead. + /// let dt_range = PrimitiveValue::from("1992+0500-1993") + /// .to_datetime_range_custom::()?; + /// + /// // values are in the same time-zone + /// assert_eq!( + /// dt_range.start().unwrap() + /// .as_datetime().unwrap() + /// .offset(), + /// dt_range.end().unwrap() + /// .as_datetime().unwrap() + /// .offset() + /// ); + /// + /// // ignore parsed time-zone, retrieve a time-zone naive range + /// let naive_range = PrimitiveValue::from("1992+0599-1993") + /// .to_datetime_range_custom::()?; + /// + /// assert_eq!( + /// naive_range, + /// DateTimeRange::from_start_to_end( + /// NaiveDateTime::new( + /// NaiveDate::from_ymd_opt(1992, 1, 1).unwrap(), + /// NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() + /// ), + /// NaiveDateTime::new( + /// NaiveDate::from_ymd_opt(1993, 12, 31).unwrap(), + /// NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() + /// ) + /// ).unwrap() + /// ); + /// + /// // always fail upon parsing an ambiguous DT range + /// assert!( + /// PrimitiveValue::from("1992+0599-1993") + /// .to_datetime_range_custom::().is_err() + /// ); + /// /// - /// assert!(range_from.end().is_none()); /// /// # Ok(()) /// # } /// ``` - pub fn to_datetime_range( + pub fn to_datetime_range_custom( &self, - offset: FixedOffset, ) -> Result { match self { + PrimitiveValue::DateTime(dt) if !dt.is_empty() => dt[0] + .range() + .context(ParseDateTimeRangeSnafu) + .map_err(|err| ConvertValueError { + requested: "DateTimeRange", + original: self.value_type(), + cause: Some(err), + }), PrimitiveValue::Str(s) => { - super::range::parse_datetime_range(s.trim_end().as_bytes(), offset) + super::range::parse_datetime_range_custom::(s.trim_end().as_bytes()) .context(ParseDateTimeRangeSnafu) .map_err(|err| ConvertValueError { requested: "DateTimeRange", @@ -3219,9 +3159,8 @@ impl PrimitiveValue { cause: Some(err), }) } - PrimitiveValue::Strs(s) => super::range::parse_datetime_range( + PrimitiveValue::Strs(s) => super::range::parse_datetime_range_custom::( s.first().map(|s| s.trim_end().as_bytes()).unwrap_or(&[]), - offset, ) .context(ParseDateTimeRangeSnafu) .map_err(|err| ConvertValueError { @@ -3230,7 +3169,7 @@ impl PrimitiveValue { cause: Some(err), }), PrimitiveValue::U8(bytes) => { - super::range::parse_datetime_range(trim_last_whitespace(bytes), offset) + super::range::parse_datetime_range_custom::(trim_last_whitespace(bytes)) .context(ParseDateTimeRangeSnafu) .map_err(|err| ConvertValueError { requested: "DateTimeRange", @@ -4098,7 +4037,7 @@ impl PrimitiveValue { /// Shorten this value by removing trailing elements /// to fit the given limit. - /// + /// /// Elements are counted by the number of individual value items /// (note that bytes in a [`PrimitiveValue::U8`] /// are treated as individual items). @@ -4121,8 +4060,7 @@ impl PrimitiveValue { /// ``` pub fn truncate(&mut self, limit: usize) { match self { - PrimitiveValue::Empty | - PrimitiveValue::Str(_) => { /* no-op */ }, + PrimitiveValue::Empty | PrimitiveValue::Str(_) => { /* no-op */ } PrimitiveValue::Strs(l) => l.truncate(limit), PrimitiveValue::Tags(l) => l.truncate(limit), PrimitiveValue::U8(l) => l.truncate(limit), @@ -4372,7 +4310,7 @@ mod tests { use crate::value::partial::{DicomDate, DicomDateTime, DicomTime}; use crate::value::range::{DateRange, DateTimeRange, TimeRange}; use crate::value::{PrimitiveValue, ValueType}; - use chrono::{FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; + use chrono::{FixedOffset, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; use smallvec::smallvec; #[test] @@ -4838,146 +4776,76 @@ mod tests { )); } - #[test] - fn primitive_value_to_chrono_datetime() { - let this_datetime = FixedOffset::east_opt(1) - .unwrap() - .with_ymd_and_hms(2012, 12, 21, 11, 9, 26) - .unwrap(); - let this_datetime_frac = FixedOffset::east_opt(1) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2012, 12, 21).unwrap(), - NaiveTime::from_hms_micro_opt(11, 9, 26, 380_000).unwrap(), - )) - .unwrap(); - - // from text (Str) - fraction is mandatory even if zero - assert_eq!( - dicom_value!(Str, "20121221110926.0") - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()) - .unwrap(), - this_datetime, - ); - // from text with fraction of a second + padding - assert_eq!( - PrimitiveValue::from("20121221110926.38 ") - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()) - .unwrap(), - this_datetime_frac, - ); - // from text (Strs) - fraction is mandatory even if zero - assert_eq!( - dicom_value!(Strs, ["20121221110926.0"]) - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()) - .unwrap(), - this_datetime, - ); - // from text (Strs) with fraction of a second + padding - assert_eq!( - dicom_value!(Strs, ["20121221110926.38 "]) - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()) - .unwrap(), - this_datetime_frac, - ); - // from bytes with fraction of a second + padding - assert_eq!( - PrimitiveValue::from(&b"20121221110926.38 "[..]) - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()) - .unwrap(), - this_datetime_frac, - ); - - // without fraction of a second - let this_datetime = FixedOffset::east_opt(1) - .unwrap() - .with_ymd_and_hms(2012, 12, 21, 11, 9, 26) - .unwrap(); - assert_eq!( - dicom_value!(Str, "20121221110926") - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()) - .unwrap(), - this_datetime, - ); - - // without seconds - assert!(matches!( - PrimitiveValue::from("201212211109") - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()), - Err(ConvertValueError { - requested: "DateTime", - original: ValueType::Str, - .. - }) - )); - - // not a datetime - assert!(matches!( - PrimitiveValue::from("Smith^John") - .to_chrono_datetime(FixedOffset::east_opt(1).unwrap()), - Err(ConvertValueError { - requested: "DateTime", - original: ValueType::Str, - .. - }) - )); - } - #[test] fn primitive_value_to_dicom_datetime() { let offset = FixedOffset::east_opt(1).unwrap(); - // try from chrono::DateTime + // try from chrono::DateTime assert_eq!( PrimitiveValue::from( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2012, 12, 21).unwrap(), DicomTime::from_hms_micro(11, 9, 26, 000123).unwrap(), offset ) .unwrap() ) - .to_datetime(offset) + .to_datetime() .unwrap(), - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2012, 12, 21).unwrap(), DicomTime::from_hms_micro(11, 9, 26, 000123).unwrap(), offset ) .unwrap() ); + // try from chrono::NaiveDateTime + assert_eq!( + PrimitiveValue::from( + DicomDateTime::from_date_and_time( + DicomDate::from_ymd(2012, 12, 21).unwrap(), + DicomTime::from_hms_micro(11, 9, 26, 000123).unwrap() + ) + .unwrap() + ) + .to_datetime() + .unwrap(), + DicomDateTime::from_date_and_time( + DicomDate::from_ymd(2012, 12, 21).unwrap(), + DicomTime::from_hms_micro(11, 9, 26, 000123).unwrap() + ) + .unwrap() + ); // from text (Str) - minimum allowed is a YYYY assert_eq!( - dicom_value!(Str, "2012").to_datetime(offset).unwrap(), - DicomDateTime::from_date(DicomDate::from_y(2012).unwrap(), offset) + dicom_value!(Str, "2012").to_datetime().unwrap(), + DicomDateTime::from_date(DicomDate::from_y(2012).unwrap()) ); // from text with fraction of a second + padding assert_eq!( PrimitiveValue::from("20121221110926.38 ") - .to_datetime(offset) + .to_datetime() .unwrap(), DicomDateTime::from_date_and_time( DicomDate::from_ymd(2012, 12, 21).unwrap(), - DicomTime::from_hmsf(11, 9, 26, 38, 2).unwrap(), - offset + DicomTime::from_hmsf(11, 9, 26, 38, 2).unwrap() ) .unwrap() ); // from text (Strs) with fraction of a second + padding assert_eq!( dicom_value!(Strs, ["20121221110926.38 "]) - .to_datetime(offset) + .to_datetime() .unwrap(), DicomDateTime::from_date_and_time( DicomDate::from_ymd(2012, 12, 21).unwrap(), - DicomTime::from_hmsf(11, 9, 26, 38, 2).unwrap(), - offset + DicomTime::from_hmsf(11, 9, 26, 38, 2).unwrap() ) .unwrap() ); // not a dicom_datetime assert!(matches!( - PrimitiveValue::from("Smith^John").to_datetime(offset), + PrimitiveValue::from("Smith^John").to_datetime(), Err(ConvertValueError { requested: "DicomDateTime", original: ValueType::Str, @@ -4988,28 +4856,26 @@ mod tests { #[test] fn primitive_value_to_multi_dicom_datetime() { - let offset = FixedOffset::east_opt(1).unwrap(); // from text (Strs) assert_eq!( dicom_value!( Strs, ["20121221110926.38 ", "1992", "19901010-0500", "1990+0501"] ) - .to_multi_datetime(offset) + .to_multi_datetime() .unwrap(), vec!( DicomDateTime::from_date_and_time( DicomDate::from_ymd(2012, 12, 21).unwrap(), - DicomTime::from_hmsf(11, 9, 26, 38, 2).unwrap(), - offset + DicomTime::from_hmsf(11, 9, 26, 38, 2).unwrap() ) .unwrap(), - DicomDateTime::from_date(DicomDate::from_y(1992).unwrap(), offset), - DicomDateTime::from_date( + DicomDateTime::from_date(DicomDate::from_y(1992).unwrap()), + DicomDateTime::from_date_with_time_zone( DicomDate::from_ymd(1990, 10, 10).unwrap(), FixedOffset::west_opt(5 * 3600).unwrap() ), - DicomDateTime::from_date( + DicomDateTime::from_date_with_time_zone( DicomDate::from_y(1990).unwrap(), FixedOffset::east_opt(5 * 3600 + 60).unwrap() ) @@ -5042,39 +4908,48 @@ mod tests { #[test] fn primitive_value_to_datetime_range() { - let offset = FixedOffset::west_opt(3600).unwrap(); - assert_eq!( dicom_value!(Str, "202002-20210228153012.123") - .to_datetime_range(offset) + .to_datetime_range() .unwrap(), DateTimeRange::from_start_to_end( - offset.with_ymd_and_hms(2020, 2, 1, 0, 0, 0).unwrap(), - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2021, 2, 28).unwrap(), - NaiveTime::from_hms_micro_opt(15, 30, 12, 123_999).unwrap() - )) - .unwrap() + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap() + ), + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 2, 28).unwrap(), + NaiveTime::from_hms_micro_opt(15, 30, 12, 123_999).unwrap() + ) ) .unwrap() ); - // East UTC offset gets parsed + // East UTC offset gets parsed and the missing lower bound time-zone + // will be the local clock time-zone offset assert_eq!( PrimitiveValue::from(&b"2020-2030+0800"[..]) - .to_datetime_range(offset) + .to_datetime_range() .unwrap(), - DateTimeRange::from_start_to_end( - offset.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), - FixedOffset::east_opt(8 * 3600) - .unwrap() - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(), - NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() - )) - .unwrap() - ) - .unwrap() + DateTimeRange::TimeZone { + start: Some( + Local::now() + .offset() + .from_local_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap() + )) + .unwrap() + ), + end: Some( + FixedOffset::east_opt(8 * 3600) + .unwrap() + .from_local_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(), + NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() + )) + .unwrap() + ) + } ); } @@ -5138,7 +5013,7 @@ mod tests { // b"20121221093001+0100 " let offset = FixedOffset::east_opt(1 * 3600).unwrap(); let val = PrimitiveValue::from( - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2012, 12, 21).unwrap(), DicomTime::from_hms(9, 30, 1).unwrap(), offset, @@ -5146,6 +5021,17 @@ mod tests { .unwrap(), ); assert_eq!(val.calculate_byte_len(), 20); + + // single date-time without time zone, no second fragment + // b"20121221093001 " + let val = PrimitiveValue::from( + DicomDateTime::from_date_and_time( + DicomDate::from_ymd(2012, 12, 21).unwrap(), + DicomTime::from_hms(9, 30, 1).unwrap(), + ) + .unwrap(), + ); + assert_eq!(val.calculate_byte_len(), 14); } #[test] diff --git a/core/src/value/range.rs b/core/src/value/range.rs index 902d5b408..87bbe9fe1 100644 --- a/core/src/value/range.rs +++ b/core/src/value/range.rs @@ -1,13 +1,15 @@ //! Handling of date, time, date-time ranges. Needed for range matching. //! Parsing into ranges happens via partial precision structures (DicomDate, DicomTime, //! DicomDatime) so ranges can handle null components in date, time, date-time values. -use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; +use chrono::{ + DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, +}; use snafu::{Backtrace, OptionExt, ResultExt, Snafu}; use crate::value::deserialize::{ parse_date_partial, parse_datetime_partial, parse_time_partial, Error as DeserializeError, }; -use crate::value::partial::{DateComponent, DicomDate, DicomDateTime, DicomTime, Precision}; +use crate::value::partial::{DicomDate, DicomDateTime, DicomTime, PreciseDateTime}; #[derive(Debug, Snafu)] #[non_exhaustive] @@ -29,8 +31,12 @@ pub enum Error { NoRangeSeparator { backtrace: Backtrace }, #[snafu(display("Date-time range can contain 1-3 '-' characters, {} were found", value))] SeparatorCount { value: usize, backtrace: Backtrace }, - #[snafu(display("Invalid date-time"))] - InvalidDateTime { backtrace: Backtrace }, + #[snafu(display("Converting a time-zone naive value '{naive}' to a time-zone '{offset}' leads to invalid date-time or ambiguous results."))] + InvalidDateTime { + naive: NaiveDateTime, + offset: FixedOffset, + backtrace: Backtrace, + }, #[snafu(display( "Cannot convert from an imprecise value. This value represents a date / time range" ))] @@ -57,16 +63,32 @@ pub enum Error { f: u32, backtrace: Backtrace, }, + #[snafu(display("Use 'to_precise_datetime' to retrieve a precise value from a date-time"))] + ToPreciseDateTime { backtrace: Backtrace }, + #[snafu(display( + "Parsing a date-time range from '{start}' to '{end}' with only one time-zone '{time_zone} value, second time-zone is missing.'" + ))] + AmbiguousDtRange { + start: NaiveDateTime, + end: NaiveDateTime, + time_zone: FixedOffset, + backtrace: Backtrace, + }, } type Result = std::result::Result; -/// The DICOM protocol accepts date / time values with null components. +/// The DICOM protocol accepts date (DA) / time (TM) / date-time (DT) values with null components. /// -/// Imprecise values are to be handled as date / time ranges. +/// Imprecise values are to be handled as ranges. /// /// This trait is implemented by date / time structures with partial precision. -/// If the date / time structure is not precise, it is up to the user to call one of these -/// methods to retrieve a suitable [`chrono`] value. +/// +/// [AsRange::is_precise()] method will check if the given value has full precision. If so, it can be +/// converted with [AsRange::exact()] to a precise value. If not, [AsRange::range()] will yield a +/// date / time / date-time range. +/// +/// Please note that precision does not equal validity. A precise 'YYYYMMDD' [DicomDate] can still +/// fail to produce a valid [chrono::NaiveDate] /// /// # Examples /// @@ -75,7 +97,7 @@ type Result = std::result::Result; /// # use smallvec::smallvec; /// # use std::error::Error; /// use chrono::{NaiveDate, NaiveTime}; -/// use dicom_core::value::{AsRange, DicomDate, DicomTime, TimeRange}; +/// use dicom_core::value::{AsRange, DicomDate, DicomTime, DateRange, TimeRange}; /// # fn main() -> Result<(), Box> { /// /// let dicom_date = DicomDate::from_ym(2010,1)?; @@ -98,14 +120,51 @@ type Result = std::result::Result; /// // only a time with 6 digits second fraction is considered precise /// assert!(dicom_time.exact().is_err()); /// +/// let primitive = PrimitiveValue::from("199402"); +/// +/// // This is the fastest way to get to a useful date value, but it fails not only for invalid +/// // dates but for imprecise ones as well. +/// assert!(primitive.to_naive_date().is_err()); +/// +/// // Take intermediate steps: +/// +/// // Retrieve a DicomDate. +/// // The parser now checks for basic year and month value ranges here. +/// // But, it would not detect invalid dates like 30th of february etc. +/// let dicom_date : DicomDate = primitive.to_date()?; +/// +/// // as we have a valid DicomDate value, let's check if it's precise. +/// if dicom_date.is_precise(){ +/// // no components are missing, we can proceed by calling .exact() +/// // which calls the `chrono` library +/// let precise_date: NaiveDate = dicom_date.exact()?; +/// } +/// else{ +/// // day / month are missing, no need to call the expensive .exact() method - it will fail +/// // retrieve the earliest possible value directly from DicomDate +/// let earliest: NaiveDate = dicom_date.earliest()?; +/// +/// // or convert the date to a date range instead +/// let date_range: DateRange = dicom_date.range()?; +/// +/// if let Some(start) = date_range.start(){ +/// // the range has a given lower date bound +/// } +/// +/// } +/// /// # Ok(()) /// # } /// ``` -pub trait AsRange: Precision { - type Item: PartialEq + PartialOrd; +pub trait AsRange { + type PreciseValue: PartialEq + PartialOrd; type Range; - /// Returns a corresponding `chrono` value, if the partial precision structure has full accuracy. - fn exact(&self) -> Result { + + /// returns true if value has all possible date / time components + fn is_precise(&self) -> bool; + + /// Returns a corresponding precise value, if the partial precision structure has full accuracy. + fn exact(&self) -> Result { if self.is_precise() { Ok(self.earliest()?) } else { @@ -113,32 +172,28 @@ pub trait AsRange: Precision { } } - /// Returns the earliest possible `chrono` value from a partial precision structure. + /// Returns the earliest possible value from a partial precision structure. /// Missing components default to 1 (days, months) or 0 (hours, minutes, ...) /// If structure contains invalid combination of `DateComponent`s, it fails. - fn earliest(&self) -> Result; + fn earliest(&self) -> Result; - /// Returns the latest possible `chrono` value from a partial precision structure. + /// Returns the latest possible value from a partial precision structure. /// If structure contains invalid combination of `DateComponent`s, it fails. - fn latest(&self) -> Result; + fn latest(&self) -> Result; /// Returns a tuple of the earliest and latest possible value from a partial precision structure. fn range(&self) -> Result; - - /// Returns `true` if partial precision structure has the maximum possible accuracy. - /// For fraction of a second, the full 6 digits are required for the value to be precise. - fn is_precise(&self) -> bool { - let e = self.earliest(); - let l = self.latest(); - - e.is_ok() && l.is_ok() && e.ok() == l.ok() - } } impl AsRange for DicomDate { - type Item = NaiveDate; + type PreciseValue = NaiveDate; type Range = DateRange; - fn earliest(&self) -> Result { + + fn is_precise(&self) -> bool { + self.day().is_some() + } + + fn earliest(&self) -> Result { let (y, m, d) = { ( *self.year() as i32, @@ -149,7 +204,7 @@ impl AsRange for DicomDate { NaiveDate::from_ymd_opt(y, m, d).context(InvalidDateSnafu { y, m, d }) } - fn latest(&self) -> Result { + fn latest(&self) -> Result { let (y, m, d) = ( self.year(), self.month().unwrap_or(&12), @@ -194,7 +249,7 @@ impl AsRange for DicomDate { }) } - fn range(&self) -> Result { + fn range(&self) -> Result { let start = self.earliest()?; let end = self.latest()?; DateRange::from_start_to_end(start, end) @@ -202,9 +257,14 @@ impl AsRange for DicomDate { } impl AsRange for DicomTime { - type Item = NaiveTime; + type PreciseValue = NaiveTime; type Range = TimeRange; - fn earliest(&self) -> Result { + + fn is_precise(&self) -> bool { + matches!(self.fraction_and_precision(), Some((_fr_, precision)) if precision == &6) + } + + fn earliest(&self) -> Result { let (h, m, s, f) = ( self.hour(), self.minute().unwrap_or(&0), @@ -224,7 +284,7 @@ impl AsRange for DicomTime { }, ) } - fn latest(&self) -> Result { + fn latest(&self) -> Result { let (h, m, s, f) = ( self.hour(), self.minute().unwrap_or(&59), @@ -245,7 +305,7 @@ impl AsRange for DicomTime { }, ) } - fn range(&self) -> Result { + fn range(&self) -> Result { let start = self.earliest()?; let end = self.latest()?; TimeRange::from_start_to_end(start, end) @@ -253,9 +313,17 @@ impl AsRange for DicomTime { } impl AsRange for DicomDateTime { - type Item = DateTime; + type PreciseValue = PreciseDateTime; type Range = DateTimeRange; - fn earliest(&self) -> Result> { + + fn is_precise(&self) -> bool { + match self.time() { + Some(dicom_time) => dicom_time.is_precise(), + None => false, + } + } + + fn earliest(&self) -> Result { let date = self.date().earliest()?; let time = match self.time() { Some(time) => time.earliest()?, @@ -266,13 +334,21 @@ impl AsRange for DicomDateTime { })?, }; - self.offset() - .from_local_datetime(&NaiveDateTime::new(date, time)) - .single() - .context(InvalidDateTimeSnafu) + match self.time_zone() { + Some(offset) => Ok(PreciseDateTime::TimeZone( + offset + .from_local_datetime(&NaiveDateTime::new(date, time)) + .single() + .context(InvalidDateTimeSnafu { + naive: NaiveDateTime::new(date, time), + offset: *offset, + })?, + )), + None => Ok(PreciseDateTime::Naive(NaiveDateTime::new(date, time))), + } } - fn latest(&self) -> Result> { + fn latest(&self) -> Result { let date = self.date().latest()?; let time = match self.time() { Some(time) => time.latest()?, @@ -285,15 +361,34 @@ impl AsRange for DicomDateTime { }, )?, }; - self.offset() - .from_local_datetime(&NaiveDateTime::new(date, time)) - .single() - .context(InvalidDateTimeSnafu) + + match self.time_zone() { + Some(offset) => Ok(PreciseDateTime::TimeZone( + offset + .from_local_datetime(&NaiveDateTime::new(date, time)) + .single() + .context(InvalidDateTimeSnafu { + naive: NaiveDateTime::new(date, time), + offset: *offset, + })?, + )), + None => Ok(PreciseDateTime::Naive(NaiveDateTime::new(date, time))), + } } - fn range(&self) -> Result { + fn range(&self) -> Result { let start = self.earliest()?; let end = self.latest()?; - DateTimeRange::from_start_to_end(start, end) + + match (start, end) { + (PreciseDateTime::Naive(start), PreciseDateTime::Naive(end)) => { + DateTimeRange::from_start_to_end(start, end) + } + (PreciseDateTime::TimeZone(start), PreciseDateTime::TimeZone(end)) => { + DateTimeRange::from_start_to_end_with_time_zone(start, end) + } + + _ => unreachable!(), + } } } @@ -311,19 +406,26 @@ impl DicomTime { /// /// Missing second fraction defaults to zero. pub fn to_naive_time(self) -> Result { - match self.precision() { - DateComponent::Second | DateComponent::Fraction => self.earliest(), - _ => ImpreciseValueSnafu.fail(), + if self.second().is_some() { + self.earliest() + } else { + ImpreciseValueSnafu.fail() } } } impl DicomDateTime { - /// Retrieves a `chrono::DateTime` if value is precise. - pub fn to_chrono_datetime(self) -> Result> { - // tweak here, if full DicomTime precision req. proves impractical + /// Retrieves a [PreciseDateTime] from a date-time value. + /// If the date-time value is not precise or the conversion leads to ambiguous results, + /// it fails. + pub fn to_precise_datetime(&self) -> Result { self.exact() } + + #[deprecated(since = "0.7.0", note = "Use `to_precise_date_time()`")] + pub fn to_chrono_datetime(self) -> Result> { + ToPreciseDateTimeSnafu.fail() + } } /// Represents a date range as two [`Option`] values. @@ -360,8 +462,10 @@ pub struct TimeRange { start: Option, end: Option, } -/// Represents a date-time range as two [`Option>`] values. +/// Represents a date-time range, that can either be time-zone naive or time-zone aware. It is stored as two [`Option>`] or +/// two [`Option`] values. /// [None] means no upper or no lower bound for range is present. +/// /// # Example /// ``` /// # use std::error::Error; @@ -371,7 +475,7 @@ pub struct TimeRange { /// /// let offset = FixedOffset::west_opt(3600).unwrap(); /// -/// let dtr = DateTimeRange::from_start_to_end( +/// let dtr = DateTimeRange::from_start_to_end_with_time_zone( /// offset.from_local_datetime(&NaiveDateTime::new( /// NaiveDate::from_ymd_opt(2000, 5, 6).unwrap(), /// NaiveTime::from_hms_opt(15, 0, 0).unwrap() @@ -384,13 +488,21 @@ pub struct TimeRange { /// /// assert!(dtr.start().is_some()); /// assert!(dtr.end().is_some()); -/// # Ok(()) +/// # Ok(()) /// # } /// ``` #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] -pub struct DateTimeRange { - start: Option>, - end: Option>, +pub enum DateTimeRange { + /// DateTime range without time-zone information + Naive { + start: Option, + end: Option, + }, + /// DateTime range with time-zone information + TimeZone { + start: Option>, + end: Option>, + }, } impl DateRange { @@ -486,9 +598,9 @@ impl TimeRange { } impl DateTimeRange { - /// Constructs a new `DateTimeRange` from two `chrono::DateTime` values + /// Constructs a new time-zone aware `DateTimeRange` from two `chrono::DateTime` values /// monotonically ordered in time. - pub fn from_start_to_end( + pub fn from_start_to_end_with_time_zone( start: DateTime, end: DateTime, ) -> Result { @@ -499,47 +611,85 @@ impl DateTimeRange { } .fail() } else { - Ok(DateTimeRange { + Ok(DateTimeRange::TimeZone { start: Some(start), end: Some(end), }) } } - /// Constructs a new `DateTimeRange` beginning with a `chrono::DateTime` value + /// Constructs a new time-zone naive `DateTimeRange` from two `chrono::NaiveDateTime` values + /// monotonically ordered in time. + pub fn from_start_to_end(start: NaiveDateTime, end: NaiveDateTime) -> Result { + if start > end { + RangeInversionSnafu { + start: start.to_string(), + end: end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::Naive { + start: Some(start), + end: Some(end), + }) + } + } + + /// Constructs a new time-zone aware `DateTimeRange` beginning with a `chrono::DateTime` value + /// and no upper limit. + pub fn from_start_with_time_zone(start: DateTime) -> DateTimeRange { + DateTimeRange::TimeZone { + start: Some(start), + end: None, + } + } + + /// Constructs a new time-zone naive `DateTimeRange` beginning with a `chrono::NaiveDateTime` value /// and no upper limit. - pub fn from_start(start: DateTime) -> DateTimeRange { - DateTimeRange { + pub fn from_start(start: NaiveDateTime) -> DateTimeRange { + DateTimeRange::Naive { start: Some(start), end: None, } } - /// Constructs a new `DateTimeRange` with no lower limit, ending with a `chrono::DateTime` value. - pub fn from_end(end: DateTime) -> DateTimeRange { - DateTimeRange { + /// Constructs a new time-zone aware `DateTimeRange` with no lower limit, ending with a `chrono::DateTime` value. + pub fn from_end_with_time_zone(end: DateTime) -> DateTimeRange { + DateTimeRange::TimeZone { start: None, end: Some(end), } } - /// Returns a reference to the lower bound of the range. - pub fn start(&self) -> Option<&DateTime> { - self.start.as_ref() + /// Constructs a new time-zone naive `DateTimeRange` with no lower limit, ending with a `chrono::NaiveDateTime` value. + pub fn from_end(end: NaiveDateTime) -> DateTimeRange { + DateTimeRange::Naive { + start: None, + end: Some(end), + } } - /// Returns a reference to the upper bound of the range. - pub fn end(&self) -> Option<&DateTime> { - self.end.as_ref() + /// Returns the lower bound of the range, if present. + pub fn start(&self) -> Option { + match self { + DateTimeRange::Naive { start, .. } => start.map(PreciseDateTime::Naive), + DateTimeRange::TimeZone { start, .. } => start.map(PreciseDateTime::TimeZone), + } + } + + /// Returns the upper bound of the range, if present. + pub fn end(&self) -> Option { + match self { + DateTimeRange::Naive { start: _, end } => end.map(PreciseDateTime::Naive), + DateTimeRange::TimeZone { start: _, end } => end.map(PreciseDateTime::TimeZone), + } } /// For combined datetime range matching, /// this method constructs a `DateTimeRange` from a `DateRange` and a `TimeRange`. - pub fn from_date_and_time_range( - dr: DateRange, - tr: TimeRange, - offset: FixedOffset, - ) -> Result { + /// As 'DateRange' and 'TimeRange' are always time-zone unaware, the resulting DateTimeRange + /// will always be time-zone unaware. + pub fn from_date_and_time_range(dr: DateRange, tr: TimeRange) -> Result { let start_date = dr.start(); let end_date = dr.end(); @@ -564,29 +714,15 @@ impl DateTimeRange { match start_date { Some(sd) => match end_date { Some(ed) => Ok(DateTimeRange::from_start_to_end( - offset - .from_local_datetime(&NaiveDateTime::new(*sd, start_time)) - .single() - .context(InvalidDateTimeSnafu)?, - offset - .from_local_datetime(&NaiveDateTime::new(*ed, end_time)) - .single() - .context(InvalidDateTimeSnafu)?, + NaiveDateTime::new(*sd, start_time), + NaiveDateTime::new(*ed, end_time), )?), - None => Ok(DateTimeRange::from_start( - offset - .from_local_datetime(&NaiveDateTime::new(*sd, start_time)) - .single() - .context(InvalidDateTimeSnafu)?, - )), + None => Ok(DateTimeRange::from_start(NaiveDateTime::new( + *sd, start_time, + ))), }, None => match end_date { - Some(ed) => Ok(DateTimeRange::from_end( - offset - .from_local_datetime(&NaiveDateTime::new(*ed, end_time)) - .single() - .context(InvalidDateTimeSnafu)?, - )), + Some(ed) => Ok(DateTimeRange::from_end(NaiveDateTime::new(*ed, end_time))), None => panic!("Impossible combination of two None values for a date range."), }, } @@ -663,16 +799,269 @@ pub fn parse_time_range(buf: &[u8]) -> Result { } } +/// The DICOM standard allows for parsing a date-time range +/// in which one DT value provides time-zone information +/// but the other one does not. +/// An example of this is the value `19750101-19800101+0200`. +/// +/// In such cases, the missing time-zone can be interpreted as the local time-zone +/// the time-zone provided by the upper bound, or something else altogether. +/// +/// This trait is implemented by parsers handling the aforementioned situation. +/// For concrete implementations, see: +/// - [`ToLocalTimeZone`] (the default implementation) +/// - [`ToKnownTimeZone`] +/// - [`FailOnAmbiguousRange`] +/// - [`IgnoreTimeZone`] +pub trait AmbiguousDtRangeParser { + /// Retrieve a [DateTimeRange] if the lower range bound is missing a time-zone + fn parse_with_ambiguous_start( + ambiguous_start: NaiveDateTime, + end: DateTime, + ) -> Result; + /// Retrieve a [DateTimeRange] if the upper range bound is missing a time-zone + fn parse_with_ambiguous_end( + start: DateTime, + ambiguous_end: NaiveDateTime, + ) -> Result; +} + +/// For the missing time-zone, +/// use time-zone information of the local system clock. +/// Retrieves a [DateTimeRange::TimeZone]. +/// +/// This is the default behavior of the parser, +/// which helps attain compliance with the standard +/// as per [DICOM PS3.5 6.2](https://dicom.nema.org/medical/dicom/2023e/output/chtml/part05/sect_6.2.html): +/// +/// > A Date Time Value without the optional suffix +/// > is interpreted to be in the local time zone of the application creating the Data Element, +/// > unless explicitly specified by the Timezone Offset From UTC (0008,0201). +#[derive(Debug)] +pub struct ToLocalTimeZone; + +/// Use time-zone information from the time-zone aware value. +/// Retrieves a [DateTimeRange::TimeZone]. +#[derive(Debug)] +pub struct ToKnownTimeZone; + +/// Fail on an attempt to parse an ambiguous date-time range. +#[derive(Debug)] +pub struct FailOnAmbiguousRange; + +/// Discard known (parsed) time-zone information. +/// Retrieves a [DateTimeRange::Naive]. +#[derive(Debug)] +pub struct IgnoreTimeZone; + +impl AmbiguousDtRangeParser for ToKnownTimeZone { + fn parse_with_ambiguous_start( + ambiguous_start: NaiveDateTime, + end: DateTime, + ) -> Result { + let start = end + .offset() + .from_local_datetime(&ambiguous_start) + .single() + .context(InvalidDateTimeSnafu { + naive: ambiguous_start, + offset: *end.offset(), + })?; + if start > end { + RangeInversionSnafu { + start: ambiguous_start.to_string(), + end: end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::TimeZone { + start: Some(start), + end: Some(end), + }) + } + } + fn parse_with_ambiguous_end( + start: DateTime, + ambiguous_end: NaiveDateTime, + ) -> Result { + let end = start + .offset() + .from_local_datetime(&ambiguous_end) + .single() + .context(InvalidDateTimeSnafu { + naive: ambiguous_end, + offset: *start.offset(), + })?; + if start > end { + RangeInversionSnafu { + start: start.to_string(), + end: ambiguous_end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::TimeZone { + start: Some(start), + end: Some(end), + }) + } + } +} + +impl AmbiguousDtRangeParser for FailOnAmbiguousRange { + fn parse_with_ambiguous_end( + start: DateTime, + end: NaiveDateTime, + ) -> Result { + let time_zone = *start.offset(); + let start = start.naive_local(); + AmbiguousDtRangeSnafu { + start, + end, + time_zone, + } + .fail() + } + fn parse_with_ambiguous_start( + start: NaiveDateTime, + end: DateTime, + ) -> Result { + let time_zone = *end.offset(); + let end = end.naive_local(); + AmbiguousDtRangeSnafu { + start, + end, + time_zone, + } + .fail() + } +} + +impl AmbiguousDtRangeParser for ToLocalTimeZone { + fn parse_with_ambiguous_start( + ambiguous_start: NaiveDateTime, + end: DateTime, + ) -> Result { + let start = Local::now() + .offset() + .from_local_datetime(&ambiguous_start) + .single() + .context(InvalidDateTimeSnafu { + naive: ambiguous_start, + offset: *end.offset(), + })?; + if start > end { + RangeInversionSnafu { + start: ambiguous_start.to_string(), + end: end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::TimeZone { + start: Some(start), + end: Some(end), + }) + } + } + fn parse_with_ambiguous_end( + start: DateTime, + ambiguous_end: NaiveDateTime, + ) -> Result { + let end = Local::now() + .offset() + .from_local_datetime(&ambiguous_end) + .single() + .context(InvalidDateTimeSnafu { + naive: ambiguous_end, + offset: *start.offset(), + })?; + if start > end { + RangeInversionSnafu { + start: start.to_string(), + end: ambiguous_end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::TimeZone { + start: Some(start), + end: Some(end), + }) + } + } +} + +impl AmbiguousDtRangeParser for IgnoreTimeZone { + fn parse_with_ambiguous_start( + ambiguous_start: NaiveDateTime, + end: DateTime, + ) -> Result { + let end = end.naive_local(); + if ambiguous_start > end { + RangeInversionSnafu { + start: ambiguous_start.to_string(), + end: end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::Naive { + start: Some(ambiguous_start), + end: Some(end), + }) + } + } + fn parse_with_ambiguous_end( + start: DateTime, + ambiguous_end: NaiveDateTime, + ) -> Result { + let start = start.naive_local(); + if start > ambiguous_end { + RangeInversionSnafu { + start: start.to_string(), + end: ambiguous_end.to_string(), + } + .fail() + } else { + Ok(DateTimeRange::Naive { + start: Some(start), + end: Some(ambiguous_end), + }) + } + } +} + /// Looks for a range separator '-'. /// Returns a `DateTimeRange`. +/// +/// If the parser encounters two date-time values, where one is time-zone aware and the other is not, +/// it will use the local time-zone offset and use it instead of the missing time-zone. +/// +/// This is the default behavior of the parser, +/// which helps attain compliance with the standard +/// as per [DICOM PS3.5 6.2](https://dicom.nema.org/medical/dicom/2023e/output/chtml/part05/sect_6.2.html): +/// +/// > A Date Time Value without the optional suffix +/// > is interpreted to be in the local time zone of the application creating the Data Element, +/// > unless explicitly specified by the Timezone Offset From UTC (0008,0201). +/// +/// To customize this behavior, please use [parse_datetime_range_custom()]. +/// /// Users are advised, that for very specific inputs, inconsistent behavior can occur. /// This behavior can only be produced when all of the following is true: -/// - two very short date-times in the form of YYYY are presented -/// - both YYYY values can be exchanged for a valid west UTC offset, meaning year <= 1200 -/// - only one west UTC offset is presented. -/// In such cases, two '-' characters are present and the parser will favor the first one, +/// - two very short date-times in the form of YYYY are presented (YYYY-YYYY) +/// - both YYYY values can be exchanged for a valid west UTC offset, meaning year <= 1200 e.g. (1000-1100) +/// - only one west UTC offset is presented. e.g. (1000-1100-0100) +/// In such cases, two '-' characters are present and the parser will favor the first one as a range separator, /// if it produces a valid `DateTimeRange`. Otherwise, it tries the second one. -pub fn parse_datetime_range(buf: &[u8], dt_utc_offset: FixedOffset) -> Result { +pub fn parse_datetime_range(buf: &[u8]) -> Result { + parse_datetime_range_impl::(buf) +} + +/// Same as [parse_datetime_range()] but allows for custom handling of ambiguous Date-time ranges. +/// See [AmbiguousDtRangeParser]. +pub fn parse_datetime_range_custom(buf: &[u8]) -> Result { + parse_datetime_range_impl::(buf) +} + +pub fn parse_datetime_range_impl(buf: &[u8]) -> Result { // minimum length of one valid DicomDateTime (YYYY) and one '-' separator if buf.len() < 5 { return UnexpectedEndOfElementSnafu.fail(); @@ -681,19 +1070,24 @@ pub fn parse_datetime_range(buf: &[u8], dt_utc_offset: FixedOffset) -> Result Ok(DateTimeRange::from_end(end)), + PreciseDateTime::TimeZone(end_tz) => { + Ok(DateTimeRange::from_end_with_time_zone(end_tz)) + } + } } else if buf[buf.len() - 1] == b'-' { // ends with separator, range is Some-None let buf = &buf[0..(buf.len() - 1)]; - Ok(DateTimeRange::from_start( - parse_datetime_partial(buf, dt_utc_offset) - .context(ParseSnafu)? - .earliest()?, - )) + match parse_datetime_partial(buf) + .context(ParseSnafu)? + .earliest()? + { + PreciseDateTime::Naive(start) => Ok(DateTimeRange::from_start(start)), + PreciseDateTime::TimeZone(start_tz) => { + Ok(DateTimeRange::from_start_with_time_zone(start_tz)) + } + } } else { // range must be Some-Some, now, count number of dashes and get their indexes let dashes: Vec = buf @@ -711,14 +1105,33 @@ pub fn parse_datetime_range(buf: &[u8], dt_utc_offset: FixedOffset) -> Result { //create a result here, to check for range inversion - let dtr = DateTimeRange::from_start_to_end(s.earliest()?, e.latest()?); + let dtr = match (s.earliest()?, e.latest()?) { + ( + PreciseDateTime::Naive(start), + PreciseDateTime::Naive(end), + ) => DateTimeRange::from_start_to_end(start, end), + ( + PreciseDateTime::TimeZone(start), + PreciseDateTime::TimeZone(end), + ) => DateTimeRange::from_start_to_end_with_time_zone(start, end), + ( + // lower bound time-zone was missing + PreciseDateTime::Naive(start), + PreciseDateTime::TimeZone(end), + ) => T::parse_with_ambiguous_start(start, end), + ( + PreciseDateTime::TimeZone(start), + // upper bound time-zone was missing + PreciseDateTime::Naive(end), + ) => T::parse_with_ambiguous_end(start, end), + }; match dtr { Ok(val) => return Ok(val), Err(_) => dashes[1], @@ -733,14 +1146,28 @@ pub fn parse_datetime_range(buf: &[u8], dt_utc_offset: FixedOffset) -> Result { + DateTimeRange::from_start_to_end(start, end) + } + (PreciseDateTime::TimeZone(start), PreciseDateTime::TimeZone(end)) => { + DateTimeRange::from_start_to_end_with_time_zone(start, end) + } + // lower bound time-zone was missing + (PreciseDateTime::Naive(start), PreciseDateTime::TimeZone(end)) => { + T::parse_with_ambiguous_start(start, end) + } + // upper bound time-zone was missing + (PreciseDateTime::TimeZone(start), PreciseDateTime::Naive(end)) => { + T::parse_with_ambiguous_end(start, end) + } + } } } @@ -825,11 +1252,11 @@ mod tests { } #[test] - fn test_datetime_range() { + fn test_datetime_range_with_time_zone() { let offset = FixedOffset::west_opt(3600).unwrap(); assert_eq!( - DateTimeRange::from_start( + DateTimeRange::from_start_with_time_zone( offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), @@ -838,17 +1265,17 @@ mod tests { .unwrap() ) .start(), - Some( - &offset + Some(PreciseDateTime::TimeZone( + offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() )) .unwrap() - ) + )) ); assert_eq!( - DateTimeRange::from_end( + DateTimeRange::from_end_with_time_zone( offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), @@ -857,17 +1284,17 @@ mod tests { .unwrap() ) .end(), - Some( - &offset + Some(PreciseDateTime::TimeZone( + offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() )) .unwrap() - ) + )) ); assert_eq!( - DateTimeRange::from_start_to_end( + DateTimeRange::from_start_to_end_with_time_zone( offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), @@ -883,17 +1310,17 @@ mod tests { ) .unwrap() .start(), - Some( - &offset + Some(PreciseDateTime::TimeZone( + offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() )) .unwrap() - ) + )) ); assert_eq!( - DateTimeRange::from_start_to_end( + DateTimeRange::from_start_to_end_with_time_zone( offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), @@ -909,17 +1336,17 @@ mod tests { ) .unwrap() .end(), - Some( - &offset + Some(PreciseDateTime::TimeZone( + offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), NaiveTime::from_hms_micro_opt(1, 1, 1, 5).unwrap() )) .unwrap() - ) + )) ); assert!(matches!( - DateTimeRange::from_start_to_end( + DateTimeRange::from_start_to_end_with_time_zone( offset .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), @@ -941,6 +1368,85 @@ mod tests { )); } + #[test] + fn test_datetime_range_naive() { + assert_eq!( + DateTimeRange::from_start(NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + )) + .start(), + Some(PreciseDateTime::Naive(NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + ))) + ); + assert_eq!( + DateTimeRange::from_end(NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + )) + .end(), + Some(PreciseDateTime::Naive(NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + ))) + ); + assert_eq!( + DateTimeRange::from_start_to_end( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + ), + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 5).unwrap() + ) + ) + .unwrap() + .start(), + Some(PreciseDateTime::Naive(NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + ))) + ); + assert_eq!( + DateTimeRange::from_start_to_end( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + ), + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 5).unwrap() + ) + ) + .unwrap() + .end(), + Some(PreciseDateTime::Naive(NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 5).unwrap() + ))) + ); + assert!(matches!( + DateTimeRange::from_start_to_end( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 5).unwrap() + ), + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1990, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(1, 1, 1, 1).unwrap() + ) + ) + , + Err(Error::RangeInversion { + start, end ,.. }) + if start == "1990-01-01 01:01:01.000005" && + end == "1990-01-01 01:01:01.000001" + )); + } + #[test] fn test_parse_date_range() { assert_eq!( @@ -1052,109 +1558,80 @@ mod tests { #[test] fn test_parse_datetime_range() { - let offset = FixedOffset::west_opt(3600).unwrap(); assert_eq!( - parse_datetime_range(b"-20200229153420.123456", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"-20200229153420.123456").ok(), + Some(DateTimeRange::Naive { start: None, - end: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(15, 34, 20, 123_456).unwrap() - )) - .unwrap() - ) + end: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(15, 34, 20, 123_456).unwrap() + )) }) ); assert_eq!( - parse_datetime_range(b"-20200229153420.123", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"-20200229153420.123").ok(), + Some(DateTimeRange::Naive { start: None, - end: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(15, 34, 20, 123_999).unwrap() - )) - .unwrap() - ) + end: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(15, 34, 20, 123_999).unwrap() + )) }) ); assert_eq!( - parse_datetime_range(b"-20200229153420", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"-20200229153420").ok(), + Some(DateTimeRange::Naive { start: None, - end: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(15, 34, 20, 999_999).unwrap() - )) - .unwrap() - ) + end: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(15, 34, 20, 999_999).unwrap() + )) }) ); assert_eq!( - parse_datetime_range(b"-2020022915", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"-2020022915").ok(), + Some(DateTimeRange::Naive { start: None, - end: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(15, 59, 59, 999_999).unwrap() - )) - .unwrap() - ) + end: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(15, 59, 59, 999_999).unwrap() + )) }) ); assert_eq!( - parse_datetime_range(b"-202002", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"-202002").ok(), + Some(DateTimeRange::Naive { start: None, - end: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), - NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() - )) - .unwrap() - ) + end: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2020, 2, 29).unwrap(), + NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() + )) }) ); assert_eq!( - parse_datetime_range(b"0002-", offset).ok(), - Some(DateTimeRange { - start: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2, 1, 1).unwrap(), - NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() - )) - .unwrap() - ), + parse_datetime_range(b"0002-").ok(), + Some(DateTimeRange::Naive { + start: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2, 1, 1).unwrap(), + NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() + )), end: None }) ); assert_eq!( - parse_datetime_range(b"00021231-", offset).ok(), - Some(DateTimeRange { - start: Some( - offset - .from_local_datetime(&NaiveDateTime::new( - NaiveDate::from_ymd_opt(2, 12, 31).unwrap(), - NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() - )) - .unwrap() - ), + parse_datetime_range(b"00021231-").ok(), + Some(DateTimeRange::Naive { + start: Some(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2, 12, 31).unwrap(), + NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() + )), end: None }) ); // two 'east' UTC offsets get parsed assert_eq!( - parse_datetime_range(b"19900101+0500-1999+1400", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"19900101+0500-1999+1400").ok(), + Some(DateTimeRange::TimeZone { start: Some( FixedOffset::east_opt(5 * 3600) .unwrap() @@ -1175,10 +1652,10 @@ mod tests { ) }) ); - // two 'west' UTC offsets get parsed + // two 'west' Time zone offsets get parsed assert_eq!( - parse_datetime_range(b"19900101-0500-1999-1200", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"19900101-0500-1999-1200").ok(), + Some(DateTimeRange::TimeZone { start: Some( FixedOffset::west_opt(5 * 3600) .unwrap() @@ -1199,10 +1676,10 @@ mod tests { ) }) ); - // 'east' and 'west' UTC offsets get parsed + // 'east' and 'west' Time zone offsets get parsed assert_eq!( - parse_datetime_range(b"19900101+1400-1999-1200", offset).ok(), - Some(DateTimeRange { + parse_datetime_range(b"19900101+1400-1999-1200").ok(), + Some(DateTimeRange::TimeZone { start: Some( FixedOffset::east_opt(14 * 3600) .unwrap() @@ -1223,10 +1700,11 @@ mod tests { ) }) ); - // one 'west' UTC offsets gets parsed, offset cannot be mistaken for a date-time + // one 'west' Time zone offset gets parsed, offset cannot be mistaken for a date-time + // the missing Time zone offset will be replaced with local clock time-zone offset (default behavior) assert_eq!( - parse_datetime_range(b"19900101-1200-1999", offset).unwrap(), - DateTimeRange { + parse_datetime_range(b"19900101-1200-1999").unwrap(), + DateTimeRange::TimeZone { start: Some( FixedOffset::west_opt(12 * 3600) .unwrap() @@ -1237,7 +1715,8 @@ mod tests { .unwrap() ), end: Some( - offset + Local::now() + .offset() .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(1999, 12, 31).unwrap(), NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap() @@ -1246,13 +1725,15 @@ mod tests { ) } ); - // '0500' can either be a valid west UTC offset on left side, or a valid datime on the right side - // Now, the first dash is considered to be a separator. + // '0500' can either be a valid west UTC offset on the lower bound, or a valid date-time on the upper bound + // Now, the first dash is considered to be a range separator, so the lower bound time-zone offset is missing + // and will be considered to be the local clock time-zone offset. assert_eq!( - parse_datetime_range(b"0050-0500-1000", offset).unwrap(), - DateTimeRange { + parse_datetime_range(b"0050-0500-1000").unwrap(), + DateTimeRange::TimeZone { start: Some( - offset + Local::now() + .offset() .from_local_datetime(&NaiveDateTime::new( NaiveDate::from_ymd_opt(50, 1, 1).unwrap(), NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap() @@ -1272,12 +1753,12 @@ mod tests { ); // sequence with more than 3 dashes '-' is refused. assert!(matches!( - parse_datetime_range(b"0001-00021231-2021-0100-0100", offset), + parse_datetime_range(b"0001-00021231-2021-0100-0100"), Err(Error::SeparatorCount { .. }) )); // any sequence without a dash '-' is refused. assert!(matches!( - parse_datetime_range(b"00021231+0500", offset), + parse_datetime_range(b"00021231+0500"), Err(Error::NoRangeSeparator { .. }) )); } diff --git a/core/src/value/serialize.rs b/core/src/value/serialize.rs index fa99d382d..154417148 100644 --- a/core/src/value/serialize.rs +++ b/core/src/value/serialize.rs @@ -76,26 +76,23 @@ mod test { #[test] fn test_encode_datetime() { let mut data = vec![]; - let offset = FixedOffset::east_opt(0).unwrap(); let bytes = encode_datetime( &mut data, DicomDateTime::from_date_and_time( DicomDate::from_ymd(1985, 12, 31).unwrap(), - DicomTime::from_hms_micro(23, 59, 48, 123_456).unwrap(), - offset, + DicomTime::from_hms_micro(23, 59, 48, 123_456).unwrap() ) .unwrap(), ) .unwrap(); - // even zero offset gets encoded into string value - assert_eq!(from_utf8(&data).unwrap(), "19851231235948.123456+0000"); - assert_eq!(bytes, 26); + assert_eq!(from_utf8(&data).unwrap(), "19851231235948.123456"); + assert_eq!(bytes, 21); let mut data = vec![]; let offset = FixedOffset::east_opt(3600).unwrap(); let bytes = encode_datetime( &mut data, - DicomDateTime::from_date_and_time( + DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2018, 12, 24).unwrap(), DicomTime::from_h(4).unwrap(), offset, diff --git a/dump/src/lib.rs b/dump/src/lib.rs index 724d6dbcf..e97a85a11 100644 --- a/dump/src/lib.rs +++ b/dump/src/lib.rs @@ -812,7 +812,7 @@ fn value_summary( } } (Strs(values), VR::DT) => { - match value.to_multi_datetime(dicom_core::chrono::FixedOffset::east_opt(0).unwrap()) { + match value.to_multi_datetime() { Ok(values) => { // print as reformatted date DumpValue::DateTime(format_value_list(values, max_characters, false)) diff --git a/object/src/mem.rs b/object/src/mem.rs index fc0a2d179..fc32f6404 100644 --- a/object/src/mem.rs +++ b/object/src/mem.rs @@ -1937,7 +1937,7 @@ mod tests { obj.put(instance_number); // add a date time - let dt = DicomDateTime::from_date_and_time( + let dt = DicomDateTime::from_date_and_time_with_time_zone( DicomDate::from_ymd(2022, 11, 22).unwrap(), DicomTime::from_hms(18, 09, 35).unwrap(), FixedOffset::east_opt(3600).unwrap(), diff --git a/parser/src/stateful/decode.rs b/parser/src/stateful/decode.rs index bef304dbf..9096c5e9c 100644 --- a/parser/src/stateful/decode.rs +++ b/parser/src/stateful/decode.rs @@ -2,7 +2,6 @@ //! which also supports text decoding. use crate::util::n_times; -use chrono::FixedOffset; use dicom_core::header::{DataElementHeader, HasLength, Length, SequenceItemHeader, Tag, VR}; use dicom_core::value::deserialize::{ parse_date_partial, parse_datetime_partial, parse_time_partial, @@ -230,7 +229,6 @@ pub struct StatefulDecoder { decoder: D, basic: BD, text: TC, - dt_utc_offset: FixedOffset, buffer: Vec, /// the assumed position of the reader source position: u64, @@ -291,7 +289,6 @@ where basic: LittleEndianBasicDecoder, decoder: ExplicitVRLittleEndianDecoder::default(), text: DefaultCharacterSetCodec, - dt_utc_offset: FixedOffset::east_opt(0).unwrap(), buffer: Vec::with_capacity(PARSER_BUFFER_CAPACITY), position: 0, } @@ -322,7 +319,6 @@ where basic, decoder, text, - dt_utc_offset: FixedOffset::east_opt(0).unwrap(), buffer: Vec::with_capacity(PARSER_BUFFER_CAPACITY), position, } @@ -588,7 +584,7 @@ where let vec: Result<_> = buf .split(|b| *b == b'\\') .map(|part| { - parse_datetime_partial(part, self.dt_utc_offset).context(DeserializeValueSnafu { + parse_datetime_partial(part).context(DeserializeValueSnafu { position: self.position, }) })