FIX: Use 1 as the first serial number corresponding to 1899-12-31

This commit is contained in:
Nicolás Hatcher
2025-01-01 11:39:35 +01:00
committed by Nicolás Hatcher Andrés
parent 564d4bac7a
commit cbda30f951
6 changed files with 131 additions and 126 deletions

View File

@@ -17,11 +17,8 @@ pub(crate) const LAST_ROW: i32 = 1_048_576;
// The 2 days offset is because of Excel 1900 bug // The 2 days offset is because of Excel 1900 bug
pub(crate) const EXCEL_DATE_BASE: i32 = 693_594; pub(crate) const EXCEL_DATE_BASE: i32 = 693_594;
// Excel can handle dates until the year 0000-01-01 // We do not support dates before 1899-12-31.
// However, it uses a different numbering scheme for dates pub(crate) const MINIMUM_DATE_SERIAL_NUMBER: i32 = 1;
// that are before 1900-01-01.
// So for now we will simply not support dates before 1900-01-01.
pub(crate) const MINIMUM_DATE_SERIAL_NUMBER: i32 = 2;
// Excel can handle dates until the year 9999-12-31 // Excel can handle dates until the year 9999-12-31
// 2958465 is the number of days from 1900-01-01 to 9999-12-31 // 2958465 is the number of days from 1900-01-01 to 9999-12-31

View File

@@ -18,10 +18,22 @@ fn is_date_within_range(date: NaiveDate) -> bool {
&& convert_to_serial_number(date) <= MAXIMUM_DATE_SERIAL_NUMBER && convert_to_serial_number(date) <= MAXIMUM_DATE_SERIAL_NUMBER
} }
pub fn from_excel_date(days: i64) -> NaiveDate { pub fn from_excel_date(days: i64) -> Result<NaiveDate, String> {
if days < MINIMUM_DATE_SERIAL_NUMBER as i64 {
return Err(format!(
"Excel date must be greater than {}",
MINIMUM_DATE_SERIAL_NUMBER
));
};
if days > MAXIMUM_DATE_SERIAL_NUMBER as i64 {
return Err(format!(
"Excel date must be less than {}",
MAXIMUM_DATE_SERIAL_NUMBER
));
};
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
let dt = NaiveDate::from_ymd_opt(1900, 1, 1).expect("problem with chrono::NaiveDate"); let dt = NaiveDate::from_ymd_opt(1900, 1, 1).expect("problem with chrono::NaiveDate");
dt + Duration::days(days - 2) Ok(dt + Duration::days(days - 2))
} }
pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result<i32, String> { pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result<i32, String> {
@@ -40,6 +52,10 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu
// This function applies that same logic to dates. And does it in the most compatible way as // This function applies that same logic to dates. And does it in the most compatible way as
// possible. // possible.
// Special case for the minimum date
if year == 1899 && month == 12 && day == 31 {
return Ok(MINIMUM_DATE_SERIAL_NUMBER);
}
let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else { let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else {
return Err("Out of range parameters for date".to_string()); return Err("Out of range parameters for date".to_string());
}; };
@@ -135,7 +151,7 @@ mod tests {
Ok(MAXIMUM_DATE_SERIAL_NUMBER), Ok(MAXIMUM_DATE_SERIAL_NUMBER),
); );
assert_eq!( assert_eq!(
permissive_date_to_serial_number(1, 1, 1900), permissive_date_to_serial_number(31, 12, 1899),
Ok(MINIMUM_DATE_SERIAL_NUMBER), Ok(MINIMUM_DATE_SERIAL_NUMBER),
); );
} }

View File

@@ -154,15 +154,16 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
ParsePart::Date(p) => { ParsePart::Date(p) => {
let tokens = &p.tokens; let tokens = &p.tokens;
let mut text = "".to_string(); let mut text = "".to_string();
if !(1.0..=2_958_465.0).contains(&value) { let date = match from_excel_date(value as i64) {
// 2_958_465 is 31 December 9999 Ok(d) => d,
Err(e) => {
return Formatted { return Formatted {
text: "#VALUE!".to_owned(), text: "#VALUE!".to_owned(),
color: None, color: None,
error: Some("Date negative or too long".to_owned()), error: Some(e),
};
} }
let date = from_excel_date(value as i64); }
};
for token in tokens { for token in tokens {
match token { match token {
TextToken::Literal(c) => { TextToken::Literal(c) => {

View File

@@ -4,6 +4,7 @@ use chrono::Months;
use chrono::Timelike; use chrono::Timelike;
use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER;
use crate::constants::MINIMUM_DATE_SERIAL_NUMBER;
use crate::expressions::types::CellReferenceIndex; use crate::expressions::types::CellReferenceIndex;
use crate::formatter::dates::date_to_serial_number; use crate::formatter::dates::date_to_serial_number;
use crate::formatter::dates::permissive_date_to_serial_number; use crate::formatter::dates::permissive_date_to_serial_number;
@@ -20,27 +21,19 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let serial_number = match self.get_number(&args[0], cell) { let serial_number = match self.get_number(&args[0], cell) {
Ok(c) => { Ok(c) => c.floor() as i64,
let t = c.floor() as i64;
if t < 0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Function DAY parameter 1 value is negative. It should be positive or zero.".to_string(),
};
}
t
}
Err(s) => return s, Err(s) => return s,
}; };
if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 { let date = match from_excel_date(serial_number) {
Ok(date) => date,
Err(_) => {
return CalcResult::Error { return CalcResult::Error {
error: Error::NUM, error: Error::NUM,
origin: cell, origin: cell,
message: "Function DAY parameter 1 value is too large.".to_string(), message: "Out of range parameters for date".to_string(),
};
} }
let date = from_excel_date(serial_number); }
};
let day = date.day() as f64; let day = date.day() as f64;
CalcResult::Number(day) CalcResult::Number(day)
} }
@@ -51,27 +44,19 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let serial_number = match self.get_number(&args[0], cell) { let serial_number = match self.get_number(&args[0], cell) {
Ok(c) => { Ok(c) => c.floor() as i64,
let t = c.floor() as i64;
if t < 0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Function MONTH parameter 1 value is negative. It should be positive or zero.".to_string(),
};
}
t
}
Err(s) => return s, Err(s) => return s,
}; };
if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 { let date = match from_excel_date(serial_number) {
Ok(date) => date,
Err(_) => {
return CalcResult::Error { return CalcResult::Error {
error: Error::NUM, error: Error::NUM,
origin: cell, origin: cell,
message: "Function DAY parameter 1 value is too large.".to_string(), message: "Out of range parameters for date".to_string(),
};
} }
let date = from_excel_date(serial_number); }
};
let month = date.month() as f64; let month = date.month() as f64;
CalcResult::Number(month) CalcResult::Number(month)
} }
@@ -95,6 +80,16 @@ impl Model {
} }
Err(s) => return s, Err(s) => return s,
}; };
let date = match from_excel_date(serial_number) {
Ok(date) => date,
Err(_) => {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Out of range parameters for date".to_string(),
}
}
};
if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 { if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 {
return CalcResult::Error { return CalcResult::Error {
error: Error::NUM, error: Error::NUM,
@@ -114,9 +109,9 @@ impl Model {
let months_abs = months.unsigned_abs(); let months_abs = months.unsigned_abs();
let native_date = if months > 0 { let native_date = if months > 0 {
from_excel_date(serial_number) + Months::new(months_abs) date + Months::new(months_abs)
} else { } else {
from_excel_date(serial_number) - Months::new(months_abs) date - Months::new(months_abs)
}; };
// Instead of calculating the end of month we compute the first day of the following month // Instead of calculating the end of month we compute the first day of the following month
@@ -187,27 +182,19 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let serial_number = match self.get_number(&args[0], cell) { let serial_number = match self.get_number(&args[0], cell) {
Ok(c) => { Ok(c) => c.floor() as i64,
let t = c.floor() as i64;
if t < 0 {
return CalcResult::Error {
error: Error::NUM,
origin: cell,
message: "Function YEAR parameter 1 value is negative. It should be positive or zero.".to_string(),
};
}
t
}
Err(s) => return s, Err(s) => return s,
}; };
if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 { let date = match from_excel_date(serial_number) {
Ok(date) => date,
Err(_) => {
return CalcResult::Error { return CalcResult::Error {
error: Error::NUM, error: Error::NUM,
origin: cell, origin: cell,
message: "Function DAY parameter 1 value is too large.".to_string(), message: "Out of range parameters for date".to_string(),
};
} }
let date = from_excel_date(serial_number); }
};
let year = date.year() as f64; let year = date.year() as f64;
CalcResult::Number(year) CalcResult::Number(year)
} }
@@ -219,19 +206,18 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let serial_number = match self.get_number(&args[0], cell) { let serial_number = match self.get_number(&args[0], cell) {
Ok(c) => { Ok(c) => c.floor() as i64,
let t = c.floor() as i64; Err(s) => return s,
if t < 0 { };
let date = match from_excel_date(serial_number) {
Ok(date) => date,
Err(_) => {
return CalcResult::Error { return CalcResult::Error {
error: Error::NUM, error: Error::NUM,
origin: cell, origin: cell,
message: "Parameter 1 value is negative. It should be positive or zero." message: "Out of range parameters for date".to_string(),
.to_string(),
};
} }
t
} }
Err(s) => return s,
}; };
let months = match self.get_number(&args[1], cell) { let months = match self.get_number(&args[1], cell) {
@@ -245,13 +231,13 @@ impl Model {
let months_abs = months.unsigned_abs(); let months_abs = months.unsigned_abs();
let native_date = if months > 0 { let native_date = if months > 0 {
from_excel_date(serial_number) + Months::new(months_abs) date + Months::new(months_abs)
} else { } else {
from_excel_date(serial_number) - Months::new(months_abs) date - Months::new(months_abs)
}; };
let serial_number = native_date.num_days_from_ce() - EXCEL_DATE_BASE; let serial_number = native_date.num_days_from_ce() - EXCEL_DATE_BASE;
if serial_number < 0 { if serial_number < MINIMUM_DATE_SERIAL_NUMBER {
return CalcResult::Error { return CalcResult::Error {
error: Error::NUM, error: Error::NUM,
origin: cell, origin: cell,

View File

@@ -2,7 +2,7 @@ use chrono::Datelike;
use crate::{ use crate::{
calc_result::CalcResult, calc_result::CalcResult,
constants::{LAST_COLUMN, LAST_ROW}, constants::{LAST_COLUMN, LAST_ROW, MAXIMUM_DATE_SERIAL_NUMBER, MINIMUM_DATE_SERIAL_NUMBER},
expressions::{parser::Node, token::Error, types::CellReferenceIndex}, expressions::{parser::Node, token::Error, types::CellReferenceIndex},
formatter::dates::from_excel_date, formatter::dates::from_excel_date,
model::Model, model::Model,
@@ -13,37 +13,38 @@ use super::financial_util::{compute_irr, compute_npv, compute_rate, compute_xirr
// See: // See:
// https://github.com/apache/openoffice/blob/c014b5f2b55cff8d4b0c952d5c16d62ecde09ca1/main/scaddins/source/analysis/financial.cxx // https://github.com/apache/openoffice/blob/c014b5f2b55cff8d4b0c952d5c16d62ecde09ca1/main/scaddins/source/analysis/financial.cxx
// FIXME: Is this enough? fn is_less_than_one_year(start_date: i64, end_date: i64) -> Result<bool, String> {
fn is_valid_date(date: f64) -> bool { let end = match from_excel_date(end_date) {
date > 0.0 Ok(s) => s,
} Err(s) => return Err(s),
};
fn is_less_than_one_year(start_date: i64, end_date: i64) -> bool { let start = match from_excel_date(start_date) {
Ok(s) => s,
Err(s) => return Err(s),
};
if end_date - start_date < 365 { if end_date - start_date < 365 {
return true; return Ok(true);
} }
let end = from_excel_date(end_date);
let start = from_excel_date(start_date);
let end_year = end.year(); let end_year = end.year();
let start_year = start.year(); let start_year = start.year();
if end_year == start_year { if end_year == start_year {
return true; return Ok(true);
} }
if end_year != start_year + 1 { if end_year != start_year + 1 {
return false; return Ok(false);
} }
let start_month = start.month(); let start_month = start.month();
let end_month = end.month(); let end_month = end.month();
if end_month < start_month { if end_month < start_month {
return true; return Ok(true);
} }
if end_month > start_month { if end_month > start_month {
return false; return Ok(false);
} }
// we are one year later same month // we are one year later same month
let start_day = start.day(); let start_day = start.day();
let end_day = end.day(); let end_day = end.day();
end_day <= start_day Ok(end_day <= start_day)
} }
fn compute_payment( fn compute_payment(
@@ -923,7 +924,9 @@ impl Model {
} }
let first_date = dates[0]; let first_date = dates[0];
for date in &dates { for date in &dates {
if !is_valid_date(*date) { if *date < MINIMUM_DATE_SERIAL_NUMBER as f64
|| *date > MAXIMUM_DATE_SERIAL_NUMBER as f64
{
// Excel docs claim that if any number in dates is not a valid date, // Excel docs claim that if any number in dates is not a valid date,
// XNPV returns the #VALUE! error value, but it seems to return #VALUE! // XNPV returns the #VALUE! error value, but it seems to return #VALUE!
return CalcResult::new_error( return CalcResult::new_error(
@@ -989,7 +992,9 @@ impl Model {
} }
let first_date = dates[0]; let first_date = dates[0];
for date in &dates { for date in &dates {
if !is_valid_date(*date) { if *date < MINIMUM_DATE_SERIAL_NUMBER as f64
|| *date > MAXIMUM_DATE_SERIAL_NUMBER as f64
{
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
cell, cell,
@@ -1373,9 +1378,10 @@ impl Model {
Ok(f) => f, Ok(f) => f,
Err(s) => return s, Err(s) => return s,
}; };
if !is_valid_date(settlement) || !is_valid_date(maturity) { let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) {
return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()); Ok(f) => f,
} Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
if settlement > maturity { if settlement > maturity {
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
@@ -1383,7 +1389,7 @@ impl Model {
"settlement should be <= maturity".to_string(), "settlement should be <= maturity".to_string(),
); );
} }
if !is_less_than_one_year(settlement as i64, maturity as i64) { if !less_than_one_year {
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
cell, cell,
@@ -1437,9 +1443,10 @@ impl Model {
Ok(f) => f, Ok(f) => f,
Err(s) => return s, Err(s) => return s,
}; };
if !is_valid_date(settlement) || !is_valid_date(maturity) { let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) {
return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()); Ok(f) => f,
} Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
if settlement > maturity { if settlement > maturity {
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
@@ -1447,7 +1454,7 @@ impl Model {
"settlement should be <= maturity".to_string(), "settlement should be <= maturity".to_string(),
); );
} }
if !is_less_than_one_year(settlement as i64, maturity as i64) { if !less_than_one_year {
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
cell, cell,
@@ -1487,9 +1494,10 @@ impl Model {
Ok(f) => f, Ok(f) => f,
Err(s) => return s, Err(s) => return s,
}; };
if !is_valid_date(settlement) || !is_valid_date(maturity) { let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) {
return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()); Ok(f) => f,
} Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()),
};
if settlement > maturity { if settlement > maturity {
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
@@ -1497,7 +1505,7 @@ impl Model {
"settlement should be <= maturity".to_string(), "settlement should be <= maturity".to_string(),
); );
} }
if !is_less_than_one_year(settlement as i64, maturity as i64) { if !less_than_one_year {
return CalcResult::new_error( return CalcResult::new_error(
Error::NUM, Error::NUM,
cell, cell,

View File

@@ -132,8 +132,7 @@ fn test_day_small_serial() {
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("A1"), *"#NUM!"); assert_eq!(model._get_text("A1"), *"#NUM!");
// This agrees with Google Docs and disagrees with Excel assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A2"), *"30");
// Excel thinks is Feb 29, 1900 // Excel thinks is Feb 29, 1900
assert_eq!(model._get_text("A3"), *"28"); assert_eq!(model._get_text("A3"), *"28");
@@ -153,8 +152,7 @@ fn test_month_small_serial() {
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("A1"), *"#NUM!"); assert_eq!(model._get_text("A1"), *"#NUM!");
// This agrees with Google Docs and disagrees with Excel assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A2"), *"12");
// We agree with Excel here (We are both in Feb) // We agree with Excel here (We are both in Feb)
assert_eq!(model._get_text("A3"), *"2"); assert_eq!(model._get_text("A3"), *"2");
@@ -174,8 +172,7 @@ fn test_year_small_serial() {
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("A1"), *"#NUM!"); assert_eq!(model._get_text("A1"), *"#NUM!");
// This agrees with Google Docs and disagrees with Excel assert_eq!(model._get_text("A2"), *"#NUM!");
assert_eq!(model._get_text("A2"), *"1899");
assert_eq!(model._get_text("A3"), *"1900"); assert_eq!(model._get_text("A3"), *"1900");