From 7f57826371fa47a048f1f44fec19cf53ef49727f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher=20Andr=C3=A9s?= Date: Sun, 2 Nov 2025 23:30:43 +0100 Subject: [PATCH] UPDATE: Implements BASE and DECIMAL (#504) --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mathematical.rs | 112 ++++++++++++++++++ base/src/functions/mod.rs | 31 ++--- xlsx/tests/calc_tests/BASE.xlsx | Bin 0 -> 10051 bytes 4 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 xlsx/tests/calc_tests/BASE.xlsx diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 23022a6..835a4ba 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -864,6 +864,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 1), Function::Gcd => vec![Signature::Vector; arg_count], Function::Lcm => vec![Signature::Vector; arg_count], + Function::Base => args_signature_scalars(arg_count, 2, 1), + Function::Decimal => args_signature_scalars(arg_count, 2, 0), } } @@ -1116,5 +1118,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Trunc => scalar_arguments(args), Function::Gcd => not_implemented(args), Function::Lcm => not_implemented(args), + Function::Base => scalar_arguments(args), + Function::Decimal => scalar_arguments(args), } } diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 91ad94e..5be0521 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -133,6 +133,118 @@ impl Model { CalcResult::Number(result) } + pub(crate) fn fn_base(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(2..=3).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + // number to convert + let mut value = match self.get_number(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + // radix + let radix = match self.get_number(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + // optional min_length + let min_length = if arg_count == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => { + if f < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Minimum length must be non-negative".to_string(), + }; + } + f.trunc() as usize + } + Err(s) => return s, + } + } else { + 0 + }; + + if !(2..=36).contains(&radix) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Radix must be between 2 and 36".to_string(), + }; + } + + // number must be >= 0 + if value < 0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Number must be non-negative".to_string(), + }; + } + + let mut buf = String::new(); + if value == 0 { + buf.push('0'); + } else { + while value > 0 { + let digit = (value % radix) as u8; + let ch = match digit { + 0..=9 => (b'0' + digit) as char, + 10..=35 => (b'A' + (digit - 10)) as char, + _ => unreachable!(), + }; + buf.push(ch); + value /= radix; + } + // we built it in reverse + buf = buf.chars().rev().collect(); + } + + // pad with leading zeros if needed + if buf.len() < min_length { + let mut padded = String::with_capacity(min_length); + for _ in 0..(min_length - buf.len()) { + padded.push('0'); + } + padded.push_str(&buf); + buf = padded; + } + + CalcResult::String(buf) + } + + pub(crate) fn fn_decimal(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let text = match self.get_string(&args[0], cell) { + Ok(s) => s, + Err(s) => return s, + }; + let radix = match self.get_number(&args[1], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + if !(2..=36).contains(&radix) { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Radix must be between 2 and 36".to_string(), + }; + } + match i64::from_str_radix(&text, radix as u32) { + Ok(n) => CalcResult::Number(n as f64), + Err(_) => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: format!("'{}' is not a valid number in base {}", text, radix), + }, + } + } + pub(crate) fn fn_gcd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.is_empty() { return CalcResult::new_args_number_error(cell); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3f13716..a6506b1 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -84,15 +84,12 @@ pub enum Function { Csch, Sec, Sech, - Exp, Fact, Factdouble, Sign, - Radians, Degrees, - Int, Even, Odd, @@ -107,9 +104,10 @@ pub enum Function { Quotient, Mround, Trunc, - Gcd, Lcm, + Base, + Decimal, // Information ErrorType, @@ -304,7 +302,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -366,6 +364,8 @@ impl Function { Function::Trunc, Function::Gcd, Function::Lcm, + Function::Base, + Function::Decimal, Function::Max, Function::Min, Function::Product, @@ -593,13 +593,14 @@ impl Function { Function::Sheet => "_xlfn.SHEET".to_string(), Function::Formulatext => "_xlfn.FORMULATEXT".to_string(), Function::Isoweeknum => "_xlfn.ISOWEEKNUM".to_string(), - Function::Ceiling => "_xlfn.CEILING".to_string(), Function::CeilingMath => "_xlfn.CEILING.MATH".to_string(), Function::CeilingPrecise => "_xlfn.CEILING.PRECISE".to_string(), Function::FloorMath => "_xlfn.FLOOR.MATH".to_string(), Function::FloorPrecise => "_xlfn.FLOOR.PRECISE".to_string(), Function::IsoCeiling => "_xlfn.ISO.CEILING".to_string(), + Function::Base => "_xlfn.BASE".to_string(), + Function::Decimal => "_xlfn.DECIMAL".to_string(), _ => self.to_string(), } @@ -623,23 +624,18 @@ impl Function { "SWITCH" | "_XLFN.SWITCH" => Some(Function::Switch), "TRUE" => Some(Function::True), "XOR" | "_XLFN.XOR" => Some(Function::Xor), - "SIN" => Some(Function::Sin), "COS" => Some(Function::Cos), "TAN" => Some(Function::Tan), - "ASIN" => Some(Function::Asin), "ACOS" => Some(Function::Acos), "ATAN" => Some(Function::Atan), - "SINH" => Some(Function::Sinh), "COSH" => Some(Function::Cosh), "TANH" => Some(Function::Tanh), - "ASINH" => Some(Function::Asinh), "ACOSH" => Some(Function::Acosh), "ATANH" => Some(Function::Atanh), - "ACOT" => Some(Function::Acot), "COTH" => Some(Function::Coth), "COT" => Some(Function::Cot), @@ -648,15 +644,12 @@ impl Function { "SEC" => Some(Function::Sec), "SECH" => Some(Function::Sech), "ACOTH" => Some(Function::Acoth), - "FACT" => Some(Function::Fact), "FACTDOUBLE" => Some(Function::Factdouble), "EXP" => Some(Function::Exp), "SIGN" => Some(Function::Sign), - "RADIANS" => Some(Function::Radians), "DEGREES" => Some(Function::Degrees), - "INT" => Some(Function::Int), "EVEN" => Some(Function::Even), "ODD" => Some(Function::Odd), @@ -671,21 +664,19 @@ impl Function { "QUOTIENT" => Some(Function::Quotient), "MROUND" => Some(Function::Mround), "TRUNC" => Some(Function::Trunc), - "GCD" => Some(Function::Gcd), "LCM" => Some(Function::Lcm), - + "BASE" | "_XLFN.BASE" => Some(Function::Base), + "DECIMAL" | "_XLFN.DECIMAL" => Some(Function::Decimal), "PI" => Some(Function::Pi), "ABS" => Some(Function::Abs), "SQRT" => Some(Function::Sqrt), "SQRTPI" => Some(Function::Sqrtpi), "POWER" => Some(Function::Power), "ATAN2" => Some(Function::Atan2), - "LN" => Some(Function::Ln), "LOG" => Some(Function::Log), "LOG10" => Some(Function::Log10), - "MAX" => Some(Function::Max), "MIN" => Some(Function::Min), "PRODUCT" => Some(Function::Product), @@ -1138,6 +1129,8 @@ impl fmt::Display for Function { Function::Trunc => write!(f, "TRUNC"), Function::Gcd => write!(f, "GCD"), Function::Lcm => write!(f, "LCM"), + Function::Base => write!(f, "BASE"), + Function::Decimal => write!(f, "DECIMAL"), } } } @@ -1411,6 +1404,8 @@ impl Model { Function::Trunc => self.fn_trunc(args, cell), Function::Gcd => self.fn_gcd(args, cell), Function::Lcm => self.fn_lcm(args, cell), + Function::Base => self.fn_base(args, cell), + Function::Decimal => self.fn_decimal(args, cell), } } } diff --git a/xlsx/tests/calc_tests/BASE.xlsx b/xlsx/tests/calc_tests/BASE.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f69fdd76c7e95404f6c0e8048de8980328d688d5 GIT binary patch literal 10051 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