diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 835a4ba..e7402f4 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -866,6 +866,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Base => args_signature_scalars(arg_count, 2, 1), Function::Decimal => args_signature_scalars(arg_count, 2, 0), + Function::Roman => args_signature_scalars(arg_count, 1, 1), + Function::Arabic => args_signature_scalars(arg_count, 1, 0), } } @@ -1120,5 +1122,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Lcm => not_implemented(args), Function::Base => scalar_arguments(args), Function::Decimal => scalar_arguments(args), + Function::Roman => scalar_arguments(args), + Function::Arabic => scalar_arguments(args), } } diff --git a/base/src/functions/math_util.rs b/base/src/functions/math_util.rs new file mode 100644 index 0000000..f3b1e0b --- /dev/null +++ b/base/src/functions/math_util.rs @@ -0,0 +1,200 @@ +/// Parse Roman (classic or Excel variants) → number +pub fn from_roman(s: &str) -> Result { + if s.is_empty() { + return Err("empty numeral".into()); + } + fn val(c: char) -> Option { + Some(match c { + 'I' => 1, + 'V' => 5, + 'X' => 10, + 'L' => 50, + 'C' => 100, + 'D' => 500, + 'M' => 1000, + _ => return None, + }) + } + + // Accept the union of subtractive pairs used by the tables above (Excel-compatible). + fn allowed_subtractive(a: char, b: char) -> bool { + matches!( + (a, b), + // classic: + ('I','V')|('I','X')|('X','L')|('X','C')|('C','D')|('C','M') + // Excel forms: + |('V','L')|('L','D')|('L','M') // VL, LD, LM + |('X','D')|('X','M') // XD, XM + |('V','M') // VM + |('I','L')|('I','C')|('I','D')|('I','M') // IL, IC, ID, IM + |('V','D')|('V','C') // VD, VC + ) + } + + let chars: Vec = s.chars().map(|c| c.to_ascii_uppercase()).collect(); + let mut total = 0u32; + let mut i = 0usize; + + // Repetition rules similar to classic Romans: + // V, L, D cannot repeat; I, X, C, M max 3 in a row. + let mut last_char: Option = None; + let mut run_len = 0usize; + + while i < chars.len() { + let c = chars[i]; + let v = val(c).ok_or_else(|| format!("invalid character '{c}'"))?; + + if Some(c) == last_char { + run_len += 1; + match c { + 'V' | 'L' | 'D' => return Err(format!("invalid repetition of '{c}'")), + _ if run_len >= 3 => return Err(format!("invalid repetition of '{c}'")), + _ => {} + } + } else { + last_char = Some(c); + run_len = 0; + } + + if i + 1 < chars.len() { + let c2 = chars[i + 1]; + let v2 = val(c2).ok_or_else(|| format!("invalid character '{c2}'"))?; + if v < v2 { + if !allowed_subtractive(c, c2) { + return Err(format!("invalid subtractive pair '{c}{c2}'")); + } + // Disallow stacked subtractives like IIV, XXL: + if run_len > 0 { + return Err(format!("malformed numeral near position {i}")); + } + total += v2 - v; + i += 2; + last_char = None; + run_len = 0; + continue; + } + } + + total += v; + i += 1; + } + Ok(total) +} + +/// Classic Roman (strict) encoder used as a base for all forms. +fn to_roman(mut n: u32) -> Result { + if !(1..=3999).contains(&n) { + return Err("value out of range (must be 1..=3999)".into()); + } + + const MAP: &[(u32, &str)] = &[ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + + let mut out = String::with_capacity(15); + for &(val, sym) in MAP { + while n >= val { + out.push_str(sym); + n -= val; + } + if n == 0 { + break; + } + } + Ok(out) +} + +/// Excel/Google Sheets compatible ROMAN(number, [form]) encoder. +/// `form`: 0..=4 (0=Classic, 4=Simplified). +pub fn to_roman_with_form(n: u32, form: i32) -> Result { + let mut s = to_roman(n)?; + if form == 0 { + return Ok(s); + } + if !(0..=4).contains(&form) { + return Err("form must be between 0 and 4".into()); + } + + // Base rules (apply for all f >= 1) + let base_rules: &[(&str, &str)] = &[ + // C(D|M)XC -> L$1XL + ("CDXC", "LDXL"), + ("CMXC", "LMXL"), + // C(D|M)L -> L$1 + ("CDL", "LD"), + ("CML", "LM"), + // X(L|C)IX -> V$1IV + ("XLIX", "VLIV"), + ("XCIX", "VCIV"), + // X(L|C)V -> V$1 + ("XLV", "VL"), + ("XCV", "VC"), + ]; + + // Level 2 extra rules + let lvl2_rules: &[(&str, &str)] = &[ + // V(L|C)IV -> I$1 + ("VLIV", "IL"), + ("VCIV", "IC"), + // L(D|M)XL -> X$1 + ("LDXL", "XD"), + ("LMXL", "XM"), + // L(D|M)VL -> X$1V + ("LDVL", "XDV"), + ("LMVL", "XMV"), + // L(D|M)IL -> X$1IX + ("LDIL", "XDIX"), + ("LMIL", "XMIX"), + ]; + + // Level 3 extra rules + let lvl3_rules: &[(&str, &str)] = &[ + // X(D|M)V -> V$1 + ("XDV", "VD"), + ("XMV", "VM"), + // X(D|M)IX -> V$1IV + ("XDIX", "VDIV"), + ("XMIX", "VMIV"), + ]; + + // Level 4 extra rules + let lvl4_rules: &[(&str, &str)] = &[ + // V(D|M)IV -> I$1 + ("VDIV", "ID"), + ("VMIV", "IM"), + ]; + + // Helper to apply a batch of (from -> to) globally, in order. + fn apply_rules(mut t: String, rules: &[(&str, &str)]) -> String { + for (from, to) in rules { + if t.contains(from) { + t = t.replace(from, to); + } + } + t + } + + s = apply_rules(s, base_rules); + if form >= 2 { + s = apply_rules(s, lvl2_rules); + } + if form >= 3 { + s = apply_rules(s, lvl3_rules); + } + if form >= 4 { + s = apply_rules(s, lvl4_rules); + } + Ok(s) +} diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index 5be0521..1333a11 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -2,6 +2,7 @@ use crate::cast::NumberOrArray; use crate::constants::{LAST_COLUMN, LAST_ROW}; use crate::expressions::parser::ArrayNode; use crate::expressions::types::CellReferenceIndex; +use crate::functions::math_util::{from_roman, to_roman_with_form}; use crate::number_format::to_precision; use crate::single_number_fn; use crate::{ @@ -1376,4 +1377,92 @@ impl Model { } CalcResult::Number((x + random() * (y - x)).floor()) } + + pub(crate) fn fn_roman(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() || args.len() > 2 { + return CalcResult::new_args_number_error(cell); + } + let number = match self.get_number(&args[0], cell) { + Ok(f) => f.floor(), + Err(s) => return s, + }; + + if number == 0.0 { + return CalcResult::String(String::new()); + } + if !(0.0..=3999.0).contains(&number) { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Number must be between 0 and 3999".to_string(), + }; + } + let form = if args.len() == 2 { + let mut t = match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + }; + // If the value is a boolean TRUE/FALSE, convert to 0/4 + if t == 0 || t == 1 { + if let CalcResult::Boolean(b) = self.evaluate_node_in_context(&args[1], cell) { + if b { + // classic form + t = 0; + } else { + // simplified form + t = 4; + } + } + } + t + } else { + 0 + }; + if !(0..=4).contains(&form) { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Form must be between 0 and 4".to_string(), + }; + } + let roman_numeral = match to_roman_with_form(number as u32, form) { + Ok(s) => s, + Err(e) => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: format!("Could not convert to Roman numeral: {e}"), + } + } + }; + CalcResult::String(roman_numeral) + } + + pub(crate) fn fn_arabic(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let roman_numeral = match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::String(s) => s, + error @ CalcResult::Error { .. } => return error, + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Argument must be a text string".to_string(), + } + } + }; + if roman_numeral.is_empty() { + return CalcResult::Number(0.0); + } + match from_roman(&roman_numeral) { + Ok(value) => CalcResult::Number(value as f64), + Err(e) => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: format!("Invalid Roman numeral: {e}"), + }, + } + } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index a6506b1..3b4bc50 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -16,6 +16,7 @@ mod information; mod logical; mod lookup_and_reference; mod macros; +mod math_util; mod mathematical; mod statistical; mod subtotal; @@ -108,6 +109,8 @@ pub enum Function { Lcm, Base, Decimal, + Roman, + Arabic, // Information ErrorType, @@ -302,7 +305,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -551,6 +554,8 @@ impl Function { Function::Delta, Function::Gestep, Function::Subtotal, + Function::Roman, + Function::Arabic, ] .into_iter() } @@ -601,6 +606,7 @@ impl Function { Function::IsoCeiling => "_xlfn.ISO.CEILING".to_string(), Function::Base => "_xlfn.BASE".to_string(), Function::Decimal => "_xlfn.DECIMAL".to_string(), + Function::Arabic => "_xlfn.ARABIC".to_string(), _ => self.to_string(), } @@ -668,6 +674,8 @@ impl Function { "LCM" => Some(Function::Lcm), "BASE" | "_XLFN.BASE" => Some(Function::Base), "DECIMAL" | "_XLFN.DECIMAL" => Some(Function::Decimal), + "ROMAN" => Some(Function::Roman), + "ARABIC" | "_XLFN.ARABIC" => Some(Function::Arabic), "PI" => Some(Function::Pi), "ABS" => Some(Function::Abs), "SQRT" => Some(Function::Sqrt), @@ -1131,6 +1139,8 @@ impl fmt::Display for Function { Function::Lcm => write!(f, "LCM"), Function::Base => write!(f, "BASE"), Function::Decimal => write!(f, "DECIMAL"), + Function::Roman => write!(f, "ROMAN"), + Function::Arabic => write!(f, "ARABIC"), } } } @@ -1406,6 +1416,8 @@ impl Model { Function::Lcm => self.fn_lcm(args, cell), Function::Base => self.fn_base(args, cell), Function::Decimal => self.fn_decimal(args, cell), + Function::Roman => self.fn_roman(args, cell), + Function::Arabic => self.fn_arabic(args, cell), } } } diff --git a/xlsx/tests/calc_tests/ARABIC_ROMAN.xlsx b/xlsx/tests/calc_tests/ARABIC_ROMAN.xlsx new file mode 100644 index 0000000..a39f3bb Binary files /dev/null and b/xlsx/tests/calc_tests/ARABIC_ROMAN.xlsx differ