diff --git a/base/src/formatter/format.rs b/base/src/formatter/format.rs index 582f1f7..cfa7d01 100644 --- a/base/src/formatter/format.rs +++ b/base/src/formatter/format.rs @@ -154,16 +154,11 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form ParsePart::Date(p) => { let tokens = &p.tokens; let mut text = "".to_string(); - let date = match from_excel_date(value as i64) { - Ok(d) => d, - Err(e) => { - return Formatted { - text: "#VALUE!".to_owned(), - color: None, - error: Some(e), - } - } - }; + let time_fract = value.fract(); + let hours = (time_fract * 24.0).floor(); + let minutes = ((time_fract * 24.0 - hours) * 60.0).floor(); + let seconds = ((((time_fract * 24.0 - hours) * 60.0) - minutes) * 60.0).round(); + let date = from_excel_date(value as i64).ok(); for token in tokens { match token { TextToken::Literal(c) => { @@ -187,15 +182,44 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form } TextToken::Digit(_) => {} TextToken::Period => {} - TextToken::Day => { - let day = date.day() as usize; - text = format!("{text}{day}"); - } + TextToken::Day => match date { + Some(date) => { + let day = date.day() as usize; + text = format!("{text}{day}"); + } + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }, TextToken::DayPadded => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let day = date.day() as usize; text = format!("{text}{day:02}"); } TextToken::DayNameShort => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let mut day = date.weekday().number_from_monday() as usize; if day == 7 { day = 0; @@ -203,6 +227,16 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form text = format!("{}{}", text, &locale.dates.day_names_short[day]); } TextToken::DayName => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let mut day = date.weekday().number_from_monday() as usize; if day == 7 { day = 0; @@ -210,32 +244,144 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form text = format!("{}{}", text, &locale.dates.day_names[day]); } TextToken::Month => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let month = date.month() as usize; text = format!("{text}{month}"); } TextToken::MonthPadded => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let month = date.month() as usize; text = format!("{text}{month:02}"); } TextToken::MonthNameShort => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let month = date.month() as usize; text = format!("{}{}", text, &locale.dates.months_short[month - 1]); } TextToken::MonthName => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let month = date.month() as usize; text = format!("{}{}", text, &locale.dates.months[month - 1]); } TextToken::MonthLetter => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; let month = date.month() as usize; let months_letter = &locale.dates.months_letter[month - 1]; text = format!("{text}{months_letter}"); } TextToken::YearShort => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; text = format!("{}{}", text, date.format("%y")); } TextToken::Year => { + let date = match date { + Some(d) => d, + None => { + return Formatted { + text: "#VALUE!".to_owned(), + color: None, + error: Some(format!("Invalid date value: '{value}'")), + } + } + }; text = format!("{}{}", text, date.year()); } + TextToken::Hour => { + let mut hour = hours as i32; + if p.use_ampm { + if hour == 0 { + hour = 12; + } else if hour > 12 { + hour -= 12; + } + } + text = format!("{text}{hour}"); + } + TextToken::HourPadded => { + let mut hour = hours as i32; + if p.use_ampm { + if hour == 0 { + hour = 12; + } else if hour > 12 { + hour -= 12; + } + } + text = format!("{text}{hour:02}"); + } + TextToken::Second => { + let second = seconds as i32; + text = format!("{text}{second}"); + } + TextToken::SecondPadded => { + let second = seconds as i32; + text = format!("{text}{second:02}"); + } + TextToken::AMPM => { + let ampm = if hours < 12.0 { "AM" } else { "PM" }; + text = format!("{text}{ampm}"); + } + TextToken::Minute => { + let minute = minutes as i32; + text = format!("{text}{minute}"); + } + TextToken::MinutePadded => { + let minute = minutes as i32; + text = format!("{text}{minute:02}"); + } } } Formatted { @@ -422,6 +568,13 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form TextToken::MonthLetter => {} TextToken::YearShort => {} TextToken::Year => {} + TextToken::Hour => {} + TextToken::HourPadded => {} + TextToken::Minute => {} + TextToken::MinutePadded => {} + TextToken::Second => {} + TextToken::SecondPadded => {} + TextToken::AMPM => {} } } Formatted { diff --git a/base/src/formatter/lexer.rs b/base/src/formatter/lexer.rs index 2cddd22..e8c2118 100644 --- a/base/src/formatter/lexer.rs +++ b/base/src/formatter/lexer.rs @@ -26,19 +26,23 @@ pub enum Token { Scientific, // E+ ScientificMinus, // E- General, // General - // Dates + // Dates and time Day, // d DayPadded, // dd DayNameShort, // ddd DayName, // dddd+ - Month, // m - MonthPadded, // mm + Month, // m (or minute) + MonthPadded, // mm (or minute padded) MonthNameShort, // mmm MonthName, // mmmm or mmmmmm+ MonthLetter, // mmmmm YearShort, // y or yy Year, // yyy+ - // TODO: Hours Minutes and Seconds + Hour, // h + HourPadded, // hh + Second, // s + SecondPadded, // ss + AMPM, // AM/PM (or A/P) ILLEGAL, EOF, } @@ -361,8 +365,8 @@ impl Lexer { self.read_next_char(); } match m { - 1 => Token::Month, - 2 => Token::MonthPadded, + 1 => Token::Month, // (or minute) + 2 => Token::MonthPadded, // (or minute padded) 3 => Token::MonthNameShort, 4 => Token::MonthName, 5 => Token::MonthLetter, @@ -381,6 +385,63 @@ impl Lexer { Token::Year } } + 'h' => { + let mut h = 1; + while let Some('h') = self.peek_char() { + h += 1; + self.read_next_char(); + } + if h == 1 { + Token::Hour + } else if h == 2 { + Token::HourPadded + } else { + self.set_error("Unexpected character after 'h'"); + Token::ILLEGAL + } + } + 's' => { + let mut s = 1; + while let Some('s') = self.peek_char() { + s += 1; + self.read_next_char(); + } + if s == 1 { + Token::Second + } else if s == 2 { + Token::SecondPadded + } else { + self.set_error("Unexpected character after 's'"); + Token::ILLEGAL + } + } + 'A' | 'a' => { + if let Some('M') | Some('m') = self.peek_char() { + self.read_next_char(); + } else { + self.set_error("Unexpected character after 'A'"); + return Token::ILLEGAL; + } + if let Some('/') = self.peek_char() { + self.read_next_char(); + } else { + self.set_error("Unexpected character after 'AM'"); + return Token::ILLEGAL; + } + if let Some('P') | Some('p') = self.peek_char() { + self.read_next_char(); + } else { + self.set_error("Unexpected character after 'AM'"); + return Token::ILLEGAL; + } + if let Some('M') | Some('m') = self.peek_char() { + self.read_next_char(); + } else { + self.set_error("Unexpected character after 'AMP'"); + return Token::ILLEGAL; + } + Token::AMPM + } 'g' | 'G' => { for c in "eneral".chars() { let cc = self.read_next_char(); diff --git a/base/src/formatter/parser.rs b/base/src/formatter/parser.rs index 9b44d94..b35e749 100644 --- a/base/src/formatter/parser.rs +++ b/base/src/formatter/parser.rs @@ -27,6 +27,13 @@ pub enum TextToken { MonthLetter, YearShort, Year, + Hour, + HourPadded, + Minute, + MinutePadded, + Second, + SecondPadded, + AMPM, } pub struct NumberPart { pub color: Option, @@ -45,6 +52,7 @@ pub struct NumberPart { pub struct DatePart { pub color: Option, + pub use_ampm: bool, pub tokens: Vec, } @@ -101,6 +109,7 @@ impl Parser { let mut digit_count = 0; let mut precision = 0; let mut is_date = false; + let mut use_ampm = false; let mut is_number = false; let mut found_decimal_dot = false; let mut use_thousands = false; @@ -116,6 +125,7 @@ impl Parser { let mut number = 'i'; let mut index = 0; let mut currency = None; + let mut is_time = false; while token != Token::EOF && token != Token::Separator { let next_token = self.lexer.next_token(); @@ -200,6 +210,9 @@ impl Parser { index += 1; } Token::Literal(value) => { + if value == ':' { + is_time = true; + } tokens.push(TextToken::Literal(value)); } Token::Text(value) => { @@ -236,12 +249,22 @@ impl Parser { tokens.push(TextToken::MonthName); } Token::Month => { - is_date = true; - tokens.push(TextToken::Month); + if is_time { + // minute + tokens.push(TextToken::Minute); + } else { + is_date = true; + tokens.push(TextToken::Month); + } } Token::MonthPadded => { - is_date = true; - tokens.push(TextToken::MonthPadded); + if is_time { + // minute padded + tokens.push(TextToken::MinutePadded); + } else { + is_date = true; + tokens.push(TextToken::MonthPadded); + } } Token::MonthLetter => { is_date = true; @@ -255,6 +278,32 @@ impl Parser { is_date = true; tokens.push(TextToken::Year); } + Token::Hour => { + is_date = true; + is_time = true; + tokens.push(TextToken::Hour); + } + Token::HourPadded => { + is_date = true; + is_time = true; + tokens.push(TextToken::HourPadded); + } + Token::Second => { + is_date = true; + is_time = true; + tokens.push(TextToken::Second); + } + Token::SecondPadded => { + is_date = true; + is_time = true; + tokens.push(TextToken::SecondPadded); + } + + Token::AMPM => { + is_date = true; + use_ampm = true; + tokens.push(TextToken::AMPM); + } Token::Scientific => { if !is_scientific { index = 0; @@ -282,7 +331,11 @@ impl Parser { if is_number { return ParsePart::Error(ErrorPart {}); } - ParsePart::Date(DatePart { color, tokens }) + ParsePart::Date(DatePart { + color, + use_ampm, + tokens, + }) } else { ParsePart::Number(NumberPart { color, diff --git a/base/src/formatter/test/mod.rs b/base/src/formatter/test/mod.rs index 12c6271..71dae14 100644 --- a/base/src/formatter/test/mod.rs +++ b/base/src/formatter/test/mod.rs @@ -1,2 +1,3 @@ mod test_general; mod test_parse_formatted_number; +mod test_time; diff --git a/base/src/formatter/test/test_time.rs b/base/src/formatter/test/test_time.rs new file mode 100644 index 0000000..d6ce486 --- /dev/null +++ b/base/src/formatter/test/test_time.rs @@ -0,0 +1,32 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + formatter::format::format_number, + locale::{get_locale, Locale}, +}; + +fn get_default_locale() -> &'static Locale { + get_locale("en").unwrap() +} + +#[test] +fn simple_test() { + let locale = get_default_locale(); + let format = "h:mm AM/PM"; + let value = 16.001_423_611_111_11; // =1/86400 => 12:02 AM + let formatted = format_number(value, format, locale); + assert_eq!(formatted.text, "12:02 AM"); +} + +#[test] +fn padded_vs_unpadded() { + let locale = get_default_locale(); + let padded_format = "hh:mm:ss AM/PM"; + let unpadded_format = "h:m:s AM/PM"; + let value = 0.25351851851851853; // => 6:05:04 AM (21904/(24*60*60)) where 21904 = 6 * 3600 + 5*60 + 4 + let formatted = format_number(value, padded_format, locale); + assert_eq!(formatted.text, "06:05:04 AM"); + + let formatted = format_number(value, unpadded_format, locale); + assert_eq!(formatted.text, "6:5:4 AM"); +}