Compare commits

..

8 Commits

Author SHA1 Message Date
Nicolás Hatcher
9effd8e4b5 FIX: adds create user_model_from_bytes 2025-06-03 11:49:09 +02:00
Nicolás Hatcher
be66af8e16 FIX: Adds to_bytes in the user API 2025-06-03 11:04:12 +02:00
Nicolás Hatcher
8c5fe019b8 FIX: Add source files for alpine 2025-06-02 21:38:48 +02:00
Nicolás Hatcher
7bf36959ca FIX: Python add load/from bytes 2025-06-02 21:27:47 +02:00
Nicolás Hatcher
abaeb3284a FIX: users :) 2025-06-02 21:27:47 +02:00
Nicolás Hatcher
7635cbe1d1 FIX: bump PyO3 version 2025-06-02 21:27:47 +02:00
Nicolás Hatcher
293303b59c UPDATE: Add PyUserModel for the Hackaton 2025-06-02 21:27:47 +02:00
Nicolás Hatcher
2a809e0bd0 UPDATE: bump versions 2025-06-02 21:27:47 +02:00
72 changed files with 3443 additions and 3443 deletions

View File

@@ -117,7 +117,7 @@ jobs:
MATURIN_REPOSITORY_URL: "https://test.pypi.org/legacy/" MATURIN_REPOSITORY_URL: "https://test.pypi.org/legacy/"
with: with:
command: upload command: upload
args: "--skip-existing **/*.whl" args: "--skip-existing **/*.whl **/*.tar.gz"
working-directory: bindings/python working-directory: bindings/python
publish-pypi: publish-pypi:
@@ -137,5 +137,5 @@ jobs:
MATURIN_REPOSITORY_URL: "https://upload.pypi.org/legacy/" MATURIN_REPOSITORY_URL: "https://upload.pypi.org/legacy/"
with: with:
command: upload command: upload
args: "--skip-existing **/*.whl" args: "--skip-existing **/*.whl **/*.tar.gz"
working-directory: bindings/python working-directory: bindings/python

62
Cargo.lock generated
View File

@@ -721,11 +721,10 @@ dependencies = [
[[package]] [[package]]
name = "pyo3" name = "pyo3"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fe09249128b3173d092de9523eaa75136bf7ba85e0d69eca241c7939c933cc" checksum = "f239d656363bcee73afef85277f1b281e8ac6212a1d42aa90e55b90ed43c47a4"
dependencies = [ dependencies = [
"cfg-if",
"indoc", "indoc",
"libc", "libc",
"memoffset", "memoffset",
@@ -739,9 +738,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-build-config" name = "pyo3-build-config"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7" checksum = "755ea671a1c34044fa165247aaf6f419ca39caa6003aee791a0df2713d8f1b6d"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"target-lexicon", "target-lexicon",
@@ -749,9 +748,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-ffi" name = "pyo3-ffi"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d" checksum = "fc95a2e67091e44791d4ea300ff744be5293f394f1bafd9f78c080814d35956e"
dependencies = [ dependencies = [
"libc", "libc",
"pyo3-build-config", "pyo3-build-config",
@@ -759,9 +758,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-macros" name = "pyo3-macros"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91871864b353fd5ffcb3f91f2f703a22a9797c91b9ab497b1acac7b07ae509c7" checksum = "a179641d1b93920829a62f15e87c0ed791b6c8db2271ba0fd7c2686090510214"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"pyo3-macros-backend", "pyo3-macros-backend",
@@ -771,9 +770,9 @@ dependencies = [
[[package]] [[package]]
name = "pyo3-macros-backend" name = "pyo3-macros-backend"
version = "0.23.4" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43abc3b80bc20f3facd86cd3c60beed58c3e2aa26213f3cda368de39c60a27e4" checksum = "9dff85ebcaab8c441b0e3f7ae40a6963ecea8a9f5e74f647e33fcf5ec9a1e89e"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@@ -784,8 +783,9 @@ dependencies = [
[[package]] [[package]]
name = "pyroncalc" name = "pyroncalc"
version = "0.5.0" version = "0.5.6"
dependencies = [ dependencies = [
"bitcode",
"ironcalc", "ironcalc",
"pyo3", "pyo3",
"serde", "serde",
@@ -872,12 +872,6 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
[[package]]
name = "rustversion"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.17" version = "1.0.17"
@@ -985,9 +979,9 @@ dependencies = [
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
@@ -1076,7 +1070,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm" name = "wasm"
version = "0.5.0" version = "0.5.3"
dependencies = [ dependencies = [
"ironcalc_base", "ironcalc_base",
"serde", "serde",
@@ -1087,24 +1081,23 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.100" version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro", "wasm-bindgen-macro",
] ]
[[package]] [[package]]
name = "wasm-bindgen-backend" name = "wasm-bindgen-backend"
version = "0.2.100" version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"log", "log",
"once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
@@ -1125,9 +1118,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.100" version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -1135,9 +1128,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.100" version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1148,12 +1141,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.100" version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
dependencies = [
"unicode-ident",
]
[[package]] [[package]]
name = "wasm-bindgen-test" name = "wasm-bindgen-test"

View File

@@ -159,7 +159,7 @@ impl Model {
// FIXME: I think when casting a number we should convert it to_precision(x, 15) // FIXME: I think when casting a number we should convert it to_precision(x, 15)
// See function Exact // See function Exact
match result { match result {
CalcResult::Number(f) => Ok(format!("{f}")), CalcResult::Number(f) => Ok(format!("{}", f)),
CalcResult::String(s) => Ok(s), CalcResult::String(s) => Ok(s),
CalcResult::Boolean(f) => { CalcResult::Boolean(f) => {
if f { if f {

View File

@@ -142,7 +142,7 @@ impl Lexer {
pub fn expect(&mut self, tk: TokenType) -> Result<()> { pub fn expect(&mut self, tk: TokenType) -> Result<()> {
let nt = self.next_token(); let nt = self.next_token();
if mem::discriminant(&nt) != mem::discriminant(&tk) { if mem::discriminant(&nt) != mem::discriminant(&tk) {
return Err(self.set_error(&format!("Error, expected {tk:?}"), self.position)); return Err(self.set_error(&format!("Error, expected {:?}", tk), self.position));
} }
Ok(()) Ok(())
} }
@@ -511,7 +511,7 @@ impl Lexer {
self.position = position; self.position = position;
chars.parse::<i32>().map_err(|_| LexerError { chars.parse::<i32>().map_err(|_| LexerError {
position, position,
message: format!("Failed to parse to int: {chars}"), message: format!("Failed to parse to int: {}", chars),
}) })
} }
@@ -572,7 +572,9 @@ impl Lexer {
} }
self.position = position; self.position = position;
match chars.parse::<f64>() { match chars.parse::<f64>() {
Err(_) => Err(self.set_error(&format!("Failed to parse to double: {chars}"), position)), Err(_) => {
Err(self.set_error(&format!("Failed to parse to double: {}", chars), position))
}
Ok(v) => Ok(v), Ok(v) => Ok(v),
} }
} }

View File

@@ -148,16 +148,15 @@ impl Lexer {
let row_left = match row_left.parse::<i32>() { let row_left = match row_left.parse::<i32>() {
Ok(n) => n, Ok(n) => n,
Err(_) => { Err(_) => {
return Err( return Err(self
self.set_error(&format!("Failed parsing row {row_left}"), position) .set_error(&format!("Failed parsing row {}", row_left), position))
)
} }
}; };
let row_right = match row_right.parse::<i32>() { let row_right = match row_right.parse::<i32>() {
Ok(n) => n, Ok(n) => n,
Err(_) => { Err(_) => {
return Err(self return Err(self
.set_error(&format!("Failed parsing row {row_right}"), position)) .set_error(&format!("Failed parsing row {}", row_right), position))
} }
}; };
if row_left > LAST_ROW { if row_left > LAST_ROW {

View File

@@ -828,7 +828,7 @@ impl Parser {
| TokenType::Percent => Node::ParseErrorKind { | TokenType::Percent => Node::ParseErrorKind {
formula: self.lexer.get_formula(), formula: self.lexer.get_formula(),
position: 0, position: 0,
message: format!("Unexpected token: '{next_token:?}'"), message: format!("Unexpected token: '{:?}'", next_token),
}, },
TokenType::LeftBracket => Node::ParseErrorKind { TokenType::LeftBracket => Node::ParseErrorKind {
formula: self.lexer.get_formula(), formula: self.lexer.get_formula(),

View File

@@ -53,24 +53,24 @@ fn move_function(name: &str, args: &Vec<Node>, move_context: &MoveContext) -> St
arguments = to_string_moved(el, move_context); arguments = to_string_moved(el, move_context);
} }
} }
format!("{name}({arguments})") format!("{}({})", name, arguments)
} }
pub(crate) fn to_string_array_node(node: &ArrayNode) -> String { pub(crate) fn to_string_array_node(node: &ArrayNode) -> String {
match node { match node {
ArrayNode::Boolean(value) => format!("{value}").to_ascii_uppercase(), ArrayNode::Boolean(value) => format!("{}", value).to_ascii_uppercase(),
ArrayNode::Number(number) => to_excel_precision_str(*number), ArrayNode::Number(number) => to_excel_precision_str(*number),
ArrayNode::String(value) => format!("\"{value}\""), ArrayNode::String(value) => format!("\"{}\"", value),
ArrayNode::Error(kind) => format!("{kind}"), ArrayNode::Error(kind) => format!("{}", kind),
} }
} }
fn to_string_moved(node: &Node, move_context: &MoveContext) -> String { fn to_string_moved(node: &Node, move_context: &MoveContext) -> String {
use self::Node::*; use self::Node::*;
match node { match node {
BooleanKind(value) => format!("{value}").to_ascii_uppercase(), BooleanKind(value) => format!("{}", value).to_ascii_uppercase(),
NumberKind(number) => to_excel_precision_str(*number), NumberKind(number) => to_excel_precision_str(*number),
StringKind(value) => format!("\"{value}\""), StringKind(value) => format!("\"{}\"", value),
ReferenceKind { ReferenceKind {
sheet_name, sheet_name,
sheet_index, sheet_index,
@@ -241,7 +241,7 @@ fn to_string_moved(node: &Node, move_context: &MoveContext) -> String {
full_row, full_row,
full_column, full_column,
); );
format!("{s1}:{s2}") format!("{}:{}", s1, s2)
} }
WrongReferenceKind { WrongReferenceKind {
sheet_name, sheet_name,
@@ -325,7 +325,7 @@ fn to_string_moved(node: &Node, move_context: &MoveContext) -> String {
full_row, full_row,
full_column, full_column,
); );
format!("{s1}:{s2}") format!("{}:{}", s1, s2)
} }
OpRangeKind { left, right } => format!( OpRangeKind { left, right } => format!(
"{}:{}", "{}:{}",
@@ -358,7 +358,7 @@ fn to_string_moved(node: &Node, move_context: &MoveContext) -> String {
} }
_ => to_string_moved(right, move_context), _ => to_string_moved(right, move_context),
}; };
format!("{x}{kind}{y}") format!("{}{}{}", x, kind, y)
} }
OpPowerKind { left, right } => format!( OpPowerKind { left, right } => format!(
"{}^{}", "{}^{}",
@@ -403,7 +403,7 @@ fn to_string_moved(node: &Node, move_context: &MoveContext) -> String {
} }
// Enclose the whole matrix in braces // Enclose the whole matrix in braces
format!("{{{matrix_string}}}") format!("{{{}}}", matrix_string)
} }
DefinedNameKind((name, ..)) => name.to_string(), DefinedNameKind((name, ..)) => name.to_string(),
TableNameKind(name) => name.to_string(), TableNameKind(name) => name.to_string(),
@@ -418,7 +418,7 @@ fn to_string_moved(node: &Node, move_context: &MoveContext) -> String {
OpUnary::Minus => format!("-{}", to_string_moved(right, move_context)), OpUnary::Minus => format!("-{}", to_string_moved(right, move_context)),
OpUnary::Percentage => format!("{}%", to_string_moved(right, move_context)), OpUnary::Percentage => format!("{}%", to_string_moved(right, move_context)),
}, },
ErrorKind(kind) => format!("{kind}"), ErrorKind(kind) => format!("{}", kind),
ParseErrorKind { ParseErrorKind {
formula, formula,
message: _, message: _,

View File

@@ -184,16 +184,16 @@ pub(crate) fn stringify_reference(
return "#REF!".to_string(); return "#REF!".to_string();
} }
let mut row_abs = if absolute_row { let mut row_abs = if absolute_row {
format!("${row}") format!("${}", row)
} else { } else {
format!("{row}") format!("{}", row)
}; };
let column = match crate::expressions::utils::number_to_column(column) { let column = match crate::expressions::utils::number_to_column(column) {
Some(s) => s, Some(s) => s,
None => return "#REF!".to_string(), None => return "#REF!".to_string(),
}; };
let mut col_abs = if absolute_column { let mut col_abs = if absolute_column {
format!("${column}") format!("${}", column)
} else { } else {
column column
}; };
@@ -208,27 +208,27 @@ pub(crate) fn stringify_reference(
format!("{}!{}{}", quote_name(name), col_abs, row_abs) format!("{}!{}{}", quote_name(name), col_abs, row_abs)
} }
None => { None => {
format!("{col_abs}{row_abs}") format!("{}{}", col_abs, row_abs)
} }
} }
} }
None => { None => {
let row_abs = if absolute_row { let row_abs = if absolute_row {
format!("R{row}") format!("R{}", row)
} else { } else {
format!("R[{row}]") format!("R[{}]", row)
}; };
let col_abs = if absolute_column { let col_abs = if absolute_column {
format!("C{column}") format!("C{}", column)
} else { } else {
format!("C[{column}]") format!("C[{}]", column)
}; };
match &sheet_name { match &sheet_name {
Some(name) => { Some(name) => {
format!("{}!{}{}", quote_name(name), row_abs, col_abs) format!("{}!{}{}", quote_name(name), row_abs, col_abs)
} }
None => { None => {
format!("{row_abs}{col_abs}") format!("{}{}", row_abs, col_abs)
} }
} }
} }
@@ -256,7 +256,7 @@ fn format_function(
arguments = stringify(el, context, displace_data, export_to_excel); arguments = stringify(el, context, displace_data, export_to_excel);
} }
} }
format!("{name}({arguments})") format!("{}({})", name, arguments)
} }
// There is just one representation in the AST (Abstract Syntax Tree) of a formula. // There is just one representation in the AST (Abstract Syntax Tree) of a formula.
@@ -292,9 +292,9 @@ fn stringify(
) -> String { ) -> String {
use self::Node::*; use self::Node::*;
match node { match node {
BooleanKind(value) => format!("{value}").to_ascii_uppercase(), BooleanKind(value) => format!("{}", value).to_ascii_uppercase(),
NumberKind(number) => to_excel_precision_str(*number), NumberKind(number) => to_excel_precision_str(*number),
StringKind(value) => format!("\"{value}\""), StringKind(value) => format!("\"{}\"", value),
WrongReferenceKind { WrongReferenceKind {
sheet_name, sheet_name,
column, column,
@@ -384,7 +384,7 @@ fn stringify(
full_row, full_row,
full_column, full_column,
); );
format!("{s1}:{s2}") format!("{}:{}", s1, s2)
} }
WrongRangeKind { WrongRangeKind {
sheet_name, sheet_name,
@@ -433,7 +433,7 @@ fn stringify(
full_row, full_row,
full_column, full_column,
); );
format!("{s1}:{s2}") format!("{}:{}", s1, s2)
} }
OpRangeKind { left, right } => format!( OpRangeKind { left, right } => format!(
"{}:{}", "{}:{}",
@@ -484,7 +484,7 @@ fn stringify(
), ),
_ => stringify(right, context, displace_data, export_to_excel), _ => stringify(right, context, displace_data, export_to_excel),
}; };
format!("{x}{kind}{y}") format!("{}{}{}", x, kind, y)
} }
OpPowerKind { left, right } => { OpPowerKind { left, right } => {
let x = match **left { let x = match **left {
@@ -547,7 +547,7 @@ fn stringify(
stringify(right, context, displace_data, export_to_excel) stringify(right, context, displace_data, export_to_excel)
), ),
}; };
format!("{x}^{y}") format!("{}^{}", x, y)
} }
InvalidFunctionKind { name, args } => { InvalidFunctionKind { name, args } => {
format_function(name, args, context, displace_data, export_to_excel) format_function(name, args, context, displace_data, export_to_excel)
@@ -582,7 +582,7 @@ fn stringify(
} }
matrix_string.push_str(&row_string); matrix_string.push_str(&row_string);
} }
format!("{{{matrix_string}}}") format!("{{{}}}", matrix_string)
} }
TableNameKind(value) => value.to_string(), TableNameKind(value) => value.to_string(),
DefinedNameKind((name, ..)) => name.to_string(), DefinedNameKind((name, ..)) => name.to_string(),
@@ -601,7 +601,7 @@ fn stringify(
) )
} }
}, },
ErrorKind(kind) => format!("{kind}"), ErrorKind(kind) => format!("{}", kind),
ParseErrorKind { ParseErrorKind {
formula, formula,
position: _, position: _,

View File

@@ -21,12 +21,14 @@ fn is_date_within_range(date: NaiveDate) -> bool {
pub fn from_excel_date(days: i64) -> Result<NaiveDate, String> { pub fn from_excel_date(days: i64) -> Result<NaiveDate, String> {
if days < MINIMUM_DATE_SERIAL_NUMBER as i64 { if days < MINIMUM_DATE_SERIAL_NUMBER as i64 {
return Err(format!( return Err(format!(
"Excel date must be greater than {MINIMUM_DATE_SERIAL_NUMBER}" "Excel date must be greater than {}",
MINIMUM_DATE_SERIAL_NUMBER
)); ));
}; };
if days > MAXIMUM_DATE_SERIAL_NUMBER as i64 { if days > MAXIMUM_DATE_SERIAL_NUMBER as i64 {
return Err(format!( return Err(format!(
"Excel date must be less than {MAXIMUM_DATE_SERIAL_NUMBER}" "Excel date must be less than {}",
MAXIMUM_DATE_SERIAL_NUMBER
)); ));
}; };
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]

View File

@@ -120,7 +120,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
// We should have different codepaths for general formatting and errors // We should have different codepaths for general formatting and errors
let value_abs = value.abs(); let value_abs = value.abs();
if (1.0e-8..1.0e+11).contains(&value_abs) { if (1.0e-8..1.0e+11).contains(&value_abs) {
let mut text = format!("{value:.9}"); let mut text = format!("{:.9}", value);
text = text.trim_end_matches('0').trim_end_matches('.').to_string(); text = text.trim_end_matches('0').trim_end_matches('.').to_string();
Formatted { Formatted {
text, text,
@@ -138,7 +138,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
let exponent = value_abs.log10().floor(); let exponent = value_abs.log10().floor();
value /= 10.0_f64.powf(exponent); value /= 10.0_f64.powf(exponent);
let sign = if exponent < 0.0 { '-' } else { '+' }; let sign = if exponent < 0.0 { '-' } else { '+' };
let s = format!("{value:.5}"); let s = format!("{:.5}", value);
Formatted { Formatted {
text: format!( text: format!(
"{}E{}{:02}", "{}E{}{:02}",
@@ -167,33 +167,33 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
for token in tokens { for token in tokens {
match token { match token {
TextToken::Literal(c) => { TextToken::Literal(c) => {
text = format!("{text}{c}"); text = format!("{}{}", text, c);
} }
TextToken::Text(t) => { TextToken::Text(t) => {
text = format!("{text}{t}"); text = format!("{}{}", text, t);
} }
TextToken::Ghost(_) => { TextToken::Ghost(_) => {
// we just leave a whitespace // we just leave a whitespace
// This is what the TEXT function does // This is what the TEXT function does
text = format!("{text} "); text = format!("{} ", text);
} }
TextToken::Spacer(_) => { TextToken::Spacer(_) => {
// we just leave a whitespace // we just leave a whitespace
// This is what the TEXT function does // This is what the TEXT function does
text = format!("{text} "); text = format!("{} ", text);
} }
TextToken::Raw => { TextToken::Raw => {
text = format!("{text}{value}"); text = format!("{}{}", text, value);
} }
TextToken::Digit(_) => {} TextToken::Digit(_) => {}
TextToken::Period => {} TextToken::Period => {}
TextToken::Day => { TextToken::Day => {
let day = date.day() as usize; let day = date.day() as usize;
text = format!("{text}{day}"); text = format!("{}{}", text, day);
} }
TextToken::DayPadded => { TextToken::DayPadded => {
let day = date.day() as usize; let day = date.day() as usize;
text = format!("{text}{day:02}"); text = format!("{}{:02}", text, day);
} }
TextToken::DayNameShort => { TextToken::DayNameShort => {
let mut day = date.weekday().number_from_monday() as usize; let mut day = date.weekday().number_from_monday() as usize;
@@ -211,11 +211,11 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
} }
TextToken::Month => { TextToken::Month => {
let month = date.month() as usize; let month = date.month() as usize;
text = format!("{text}{month}"); text = format!("{}{}", text, month);
} }
TextToken::MonthPadded => { TextToken::MonthPadded => {
let month = date.month() as usize; let month = date.month() as usize;
text = format!("{text}{month:02}"); text = format!("{}{:02}", text, month);
} }
TextToken::MonthNameShort => { TextToken::MonthNameShort => {
let month = date.month() as usize; let month = date.month() as usize;
@@ -228,7 +228,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
TextToken::MonthLetter => { TextToken::MonthLetter => {
let month = date.month() as usize; let month = date.month() as usize;
let months_letter = &locale.dates.months_letter[month - 1]; let months_letter = &locale.dates.months_letter[month - 1];
text = format!("{text}{months_letter}"); text = format!("{}{}", text, months_letter);
} }
TextToken::YearShort => { TextToken::YearShort => {
text = format!("{}{}", text, date.format("%y")); text = format!("{}{}", text, date.format("%y"));
@@ -247,7 +247,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
ParsePart::Number(p) => { ParsePart::Number(p) => {
let mut text = "".to_string(); let mut text = "".to_string();
if let Some(c) = p.currency { if let Some(c) = p.currency {
text = format!("{c}"); text = format!("{}", c);
} }
let tokens = &p.tokens; let tokens = &p.tokens;
value = value * 100.0_f64.powi(p.percent) / (1000.0_f64.powi(p.comma)); value = value * 100.0_f64.powi(p.percent) / (1000.0_f64.powi(p.comma));
@@ -295,26 +295,26 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
for token in tokens { for token in tokens {
match token { match token {
TextToken::Literal(c) => { TextToken::Literal(c) => {
text = format!("{text}{c}"); text = format!("{}{}", text, c);
} }
TextToken::Text(t) => { TextToken::Text(t) => {
text = format!("{text}{t}"); text = format!("{}{}", text, t);
} }
TextToken::Ghost(_) => { TextToken::Ghost(_) => {
// we just leave a whitespace // we just leave a whitespace
// This is what the TEXT function does // This is what the TEXT function does
text = format!("{text} "); text = format!("{} ", text);
} }
TextToken::Spacer(_) => { TextToken::Spacer(_) => {
// we just leave a whitespace // we just leave a whitespace
// This is what the TEXT function does // This is what the TEXT function does
text = format!("{text} "); text = format!("{} ", text);
} }
TextToken::Raw => { TextToken::Raw => {
text = format!("{text}{value}"); text = format!("{}{}", text, value);
} }
TextToken::Period => { TextToken::Period => {
text = format!("{text}{decimal_separator}"); text = format!("{}{}", text, decimal_separator);
} }
TextToken::Digit(digit) => { TextToken::Digit(digit) => {
if digit.number == 'i' { if digit.number == 'i' {
@@ -322,7 +322,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
let index = digit.index; let index = digit.index;
let number_index = ln - digit_count + index; let number_index = ln - digit_count + index;
if index == 0 && is_negative { if index == 0 && is_negative {
text = format!("-{text}"); text = format!("-{}", text);
} }
if ln <= digit_count { if ln <= digit_count {
// The number of digits is less or equal than the number of digit tokens // The number of digits is less or equal than the number of digit tokens
@@ -347,7 +347,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
} else { } else {
"" ""
}; };
text = format!("{text}{c}{sep}"); text = format!("{}{}{}", text, c, sep);
} }
digit_index += 1; digit_index += 1;
} else { } else {
@@ -373,18 +373,18 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
if index < fract_part.len() { if index < fract_part.len() {
text = format!("{}{}", text, fract_part[index]); text = format!("{}{}", text, fract_part[index]);
} else if digit.kind == '0' { } else if digit.kind == '0' {
text = format!("{text}0"); text = format!("{}0", text);
} else if digit.kind == '?' { } else if digit.kind == '?' {
text = format!("{text} "); text = format!("{} ", text);
} }
} else if digit.number == 'e' { } else if digit.number == 'e' {
// 3. Exponent part // 3. Exponent part
let index = digit.index; let index = digit.index;
if index == 0 { if index == 0 {
if exponent_is_negative { if exponent_is_negative {
text = format!("{text}E-"); text = format!("{}E-", text);
} else { } else {
text = format!("{text}E+"); text = format!("{}E+", text);
} }
} }
let number_index = l_exp - (p.exponent_digit_count - index); let number_index = l_exp - (p.exponent_digit_count - index);
@@ -400,7 +400,7 @@ pub fn format_number(value_original: f64, format: &str, locale: &Locale) -> Form
exponent_part[number_index as usize] exponent_part[number_index as usize]
}; };
text = format!("{text}{c}"); text = format!("{}{}", text, c);
} }
} else { } else {
for i in 0..number_index + 1 { for i in 0..number_index + 1 {
@@ -614,7 +614,7 @@ pub(crate) fn parse_formatted_number(
// check if it is a currency in currencies // check if it is a currency in currencies
for currency in currencies { for currency in currencies {
if let Some(p) = value.strip_prefix(&format!("-{currency}")) { if let Some(p) = value.strip_prefix(&format!("-{}", currency)) {
let (f, options) = parse_number(p.trim())?; let (f, options) = parse_number(p.trim())?;
if options.is_scientific { if options.is_scientific {
return Ok((f, Some(scientific_format.to_string()))); return Ok((f, Some(scientific_format.to_string())));

View File

@@ -333,7 +333,7 @@ impl Lexer {
} else if s == '-' { } else if s == '-' {
Token::ScientificMinus Token::ScientificMinus
} else { } else {
self.set_error(&format!("Unexpected char: {s}. Expected + or -")); self.set_error(&format!("Unexpected char: {}. Expected + or -", s));
Token::ILLEGAL Token::ILLEGAL
} }
} else { } else {
@@ -385,14 +385,14 @@ impl Lexer {
for c in "eneral".chars() { for c in "eneral".chars() {
let cc = self.read_next_char(); let cc = self.read_next_char();
if Some(c) != cc { if Some(c) != cc {
self.set_error(&format!("Unexpected character: {x}")); self.set_error(&format!("Unexpected character: {}", x));
return Token::ILLEGAL; return Token::ILLEGAL;
} }
} }
Token::General Token::General
} }
_ => { _ => {
self.set_error(&format!("Unexpected character: {x}")); self.set_error(&format!("Unexpected character: {}", x));
Token::ILLEGAL Token::ILLEGAL
} }
}, },

View File

@@ -46,18 +46,18 @@ impl fmt::Display for Complex {
// it is a bit weird what Excel does but it seems it uses general notation for // it is a bit weird what Excel does but it seems it uses general notation for
// numbers > 1e-20 and scientific notation for the rest // numbers > 1e-20 and scientific notation for the rest
let y_str = if y.abs() <= 9e-20 { let y_str = if y.abs() <= 9e-20 {
format!("{y:E}") format!("{:E}", y)
} else if y == 1.0 { } else if y == 1.0 {
"".to_string() "".to_string()
} else if y == -1.0 { } else if y == -1.0 {
"-".to_string() "-".to_string()
} else { } else {
format!("{y}") format!("{}", y)
}; };
let x_str = if x.abs() <= 9e-20 { let x_str = if x.abs() <= 9e-20 {
format!("{x:E}") format!("{:E}", x)
} else { } else {
format!("{x}") format!("{}", x)
}; };
if y == 0.0 && x == 0.0 { if y == 0.0 && x == 0.0 {
write!(f, "0") write!(f, "0")

View File

@@ -76,7 +76,7 @@ impl Model {
if value < 0 { if value < 0 {
CalcResult::String(format!("{:0width$X}", HEX_MAX + value, width = 9)) CalcResult::String(format!("{:0width$X}", HEX_MAX + value, width = 9))
} else { } else {
let result = format!("{value:X}"); let result = format!("{:X}", value);
if let Some(places) = places { if let Some(places) = places {
if places < result.len() as i32 { if places < result.len() as i32 {
return CalcResult::new_error( return CalcResult::new_error(
@@ -120,7 +120,7 @@ impl Model {
if value < 0 { if value < 0 {
CalcResult::String(format!("{:0width$o}", OCT_MAX + value, width = 9)) CalcResult::String(format!("{:0width$o}", OCT_MAX + value, width = 9))
} else { } else {
let result = format!("{value:o}"); let result = format!("{:o}", value);
if let Some(places) = places { if let Some(places) = places {
if places < result.len() as i32 { if places < result.len() as i32 {
return CalcResult::new_error( return CalcResult::new_error(
@@ -163,7 +163,7 @@ impl Model {
if value < 0 { if value < 0 {
value += 1024; value += 1024;
} }
let result = format!("{value:b}"); let result = format!("{:b}", value);
if let Some(places) = places { if let Some(places) = places {
if value_raw > 0.0 && places < result.len() as i32 { if value_raw > 0.0 && places < result.len() as i32 {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());
@@ -202,7 +202,7 @@ impl Model {
if value < 0 { if value < 0 {
value += HEX_MAX; value += HEX_MAX;
} }
let result = format!("{value:X}"); let result = format!("{:X}", value);
if let Some(places) = places { if let Some(places) = places {
if value_raw > 0.0 && places < result.len() as i32 { if value_raw > 0.0 && places < result.len() as i32 {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());
@@ -242,7 +242,7 @@ impl Model {
if value < 0 { if value < 0 {
value += OCT_MAX; value += OCT_MAX;
} }
let result = format!("{value:o}"); let result = format!("{:o}", value);
if let Some(places) = places { if let Some(places) = places {
if value_raw > 0.0 && places < result.len() as i32 { if value_raw > 0.0 && places < result.len() as i32 {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());
@@ -301,7 +301,7 @@ impl Model {
if value < 0 { if value < 0 {
value += 1024; value += 1024;
} }
let result = format!("{value:b}"); let result = format!("{:b}", value);
if let Some(places) = places { if let Some(places) = places {
if places <= 0 || (value > 0 && places < result.len() as i32) { if places <= 0 || (value > 0 && places < result.len() as i32) {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());
@@ -391,7 +391,7 @@ impl Model {
if value < 0 { if value < 0 {
value += OCT_MAX; value += OCT_MAX;
} }
let result = format!("{value:o}"); let result = format!("{:o}", value);
if let Some(places) = places { if let Some(places) = places {
if places <= 0 || (value > 0 && places < result.len() as i32) { if places <= 0 || (value > 0 && places < result.len() as i32) {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());
@@ -446,7 +446,7 @@ impl Model {
if value < 0 { if value < 0 {
value += 1024; value += 1024;
} }
let result = format!("{value:b}"); let result = format!("{:b}", value);
if let Some(places) = places { if let Some(places) = places {
if value < 512 && places < result.len() as i32 { if value < 512 && places < result.len() as i32 {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());
@@ -532,7 +532,7 @@ impl Model {
if value < 0 { if value < 0 {
value += HEX_MAX; value += HEX_MAX;
} }
let result = format!("{value:X}"); let result = format!("{:X}", value);
if let Some(places) = places { if let Some(places) = places {
if value < HEX_MAX_HALF && places < result.len() as i32 { if value < HEX_MAX_HALF && places < result.len() as i32 {
return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string()); return CalcResult::new_error(Error::NUM, cell, "Out of bounds".to_string());

View File

@@ -231,7 +231,7 @@ impl Model {
CalcResult::new_error( CalcResult::new_error(
Error::ERROR, Error::ERROR,
*cell, *cell,
format!("Invalid worksheet index: '{sheet}'"), format!("Invalid worksheet index: '{}'", sheet),
) )
})? })?
.dimension() .dimension()
@@ -245,7 +245,7 @@ impl Model {
CalcResult::new_error( CalcResult::new_error(
Error::ERROR, Error::ERROR,
*cell, *cell,
format!("Invalid worksheet index: '{sheet}'"), format!("Invalid worksheet index: '{}'", sheet),
) )
})? })?
.dimension() .dimension()

View File

@@ -1214,7 +1214,7 @@ mod tests {
} }
// We make a list with their functions names, but we escape ".": ERROR.TYPE => ERRORTYPE // We make a list with their functions names, but we escape ".": ERROR.TYPE => ERRORTYPE
let iter_list = Function::into_iter() let iter_list = Function::into_iter()
.map(|f| format!("{f}").replace('.', "")) .map(|f| format!("{}", f).replace('.', ""))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let len = iter_list.len(); let len = iter_list.len();

View File

@@ -55,14 +55,14 @@ impl Model {
let mut result = "".to_string(); let mut result = "".to_string();
for arg in args { for arg in args {
match self.evaluate_node_in_context(arg, cell) { match self.evaluate_node_in_context(arg, cell) {
CalcResult::String(value) => result = format!("{result}{value}"), CalcResult::String(value) => result = format!("{}{}", result, value),
CalcResult::Number(value) => result = format!("{result}{value}"), CalcResult::Number(value) => result = format!("{}{}", result, value),
CalcResult::EmptyCell | CalcResult::EmptyArg => {} CalcResult::EmptyCell | CalcResult::EmptyArg => {}
CalcResult::Boolean(value) => { CalcResult::Boolean(value) => {
if value { if value {
result = format!("{result}TRUE"); result = format!("{}TRUE", result);
} else { } else {
result = format!("{result}FALSE"); result = format!("{}FALSE", result);
} }
} }
error @ CalcResult::Error { .. } => return error, error @ CalcResult::Error { .. } => return error,
@@ -82,14 +82,16 @@ impl Model {
column, column,
}) { }) {
CalcResult::String(value) => { CalcResult::String(value) => {
result = format!("{result}{value}"); result = format!("{}{}", result, value);
}
CalcResult::Number(value) => {
result = format!("{}{}", result, value)
} }
CalcResult::Number(value) => result = format!("{result}{value}"),
CalcResult::Boolean(value) => { CalcResult::Boolean(value) => {
if value { if value {
result = format!("{result}TRUE"); result = format!("{}TRUE", result);
} else { } else {
result = format!("{result}FALSE"); result = format!("{}FALSE", result);
} }
} }
error @ CalcResult::Error { .. } => return error, error @ CalcResult::Error { .. } => return error,
@@ -280,7 +282,7 @@ impl Model {
pub(crate) fn fn_len(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_len(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 1 { if args.len() == 1 {
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -315,7 +317,7 @@ impl Model {
pub(crate) fn fn_trim(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_trim(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 1 { if args.len() == 1 {
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -350,7 +352,7 @@ impl Model {
pub(crate) fn fn_lower(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_lower(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 1 { if args.len() == 1 {
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -385,7 +387,7 @@ impl Model {
pub(crate) fn fn_unicode(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_unicode(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 1 { if args.len() == 1 {
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -439,7 +441,7 @@ impl Model {
pub(crate) fn fn_upper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { pub(crate) fn fn_upper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
if args.len() == 1 { if args.len() == 1 {
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -476,7 +478,7 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -558,7 +560,7 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {
@@ -640,7 +642,7 @@ impl Model {
return CalcResult::new_args_number_error(cell); return CalcResult::new_args_number_error(cell);
} }
let s = match self.evaluate_node_in_context(&args[0], cell) { let s = match self.evaluate_node_in_context(&args[0], cell) {
CalcResult::Number(v) => format!("{v}"), CalcResult::Number(v) => format!("{}", v),
CalcResult::String(v) => v, CalcResult::String(v) => v,
CalcResult::Boolean(b) => { CalcResult::Boolean(b) => {
if b { if b {

View File

@@ -110,7 +110,7 @@ pub(crate) fn from_wildcard_to_regex(
// And we have a valid Perl regex! (As Kim Kardashian said before me: "I know, right?") // And we have a valid Perl regex! (As Kim Kardashian said before me: "I know, right?")
if exact { if exact {
return regex::Regex::new(&format!("^{reg}$")); return regex::Regex::new(&format!("^{}$", reg));
} }
regex::Regex::new(reg) regex::Regex::new(reg)
} }

View File

@@ -39,6 +39,6 @@ static LANGUAGES: Lazy<HashMap<String, Language>> = Lazy::new(|| {
pub fn get_language(id: &str) -> Result<&Language, String> { pub fn get_language(id: &str) -> Result<&Language, String> {
let language = LANGUAGES let language = LANGUAGES
.get(id) .get(id)
.ok_or(format!("Language is not supported: '{id}'"))?; .ok_or(format!("Language is not supported: '{}'", id))?;
Ok(language) Ok(language)
} }

View File

@@ -106,15 +106,15 @@ pub struct Model {
pub(crate) shared_strings: HashMap<String, usize>, pub(crate) shared_strings: HashMap<String, usize>,
/// An instance of the parser /// An instance of the parser
pub(crate) parser: Parser, pub(crate) parser: Parser,
/// The list of cells with formulas that are evaluated or being evaluated /// The list of cells with formulas that are evaluated of being evaluated
pub(crate) cells: HashMap<(u32, i32, i32), CellState>, pub(crate) cells: HashMap<(u32, i32, i32), CellState>,
/// The locale of the model /// The locale of the model
pub(crate) locale: Locale, pub(crate) locale: Locale,
/// The language used /// Tha language used
pub(crate) language: Language, pub(crate) language: Language,
/// The timezone used to evaluate the model /// The timezone used to evaluate the model
pub(crate) tz: Tz, pub(crate) tz: Tz,
/// The view id. A view consists of a selected sheet and ranges. /// The view id. A view consist of a selected sheet and ranges.
pub(crate) view_id: u32, pub(crate) view_id: u32,
} }
@@ -215,7 +215,7 @@ impl Model {
_ => CalcResult::new_error( _ => CalcResult::new_error(
Error::ERROR, Error::ERROR,
cell, cell,
format!("Error with Implicit Intersection in cell {cell:?}"), format!("Error with Implicit Intersection in cell {:?}", cell),
), ),
}, },
_ => self.evaluate_node_in_context(node, cell), _ => self.evaluate_node_in_context(node, cell),
@@ -355,7 +355,7 @@ impl Model {
return s; return s;
} }
}; };
let result = format!("{l}{r}"); let result = format!("{}{}", l, r);
CalcResult::String(result) CalcResult::String(result)
} }
OpProductKind { kind, left, right } => match kind { OpProductKind { kind, left, right } => match kind {
@@ -375,7 +375,7 @@ impl Model {
} }
FunctionKind { kind, args } => self.evaluate_function(kind, args, cell), FunctionKind { kind, args } => self.evaluate_function(kind, args, cell),
InvalidFunctionKind { name, args: _ } => { InvalidFunctionKind { name, args: _ } => {
CalcResult::new_error(Error::ERROR, cell, format!("Invalid function: {name}")) CalcResult::new_error(Error::ERROR, cell, format!("Invalid function: {}", name))
} }
ArrayKind(s) => CalcResult::Array(s.to_owned()), ArrayKind(s) => CalcResult::Array(s.to_owned()),
DefinedNameKind((name, scope, _)) => { DefinedNameKind((name, scope, _)) => {
@@ -391,26 +391,26 @@ impl Model {
ParsedDefinedName::InvalidDefinedNameFormula => CalcResult::new_error( ParsedDefinedName::InvalidDefinedNameFormula => CalcResult::new_error(
Error::NAME, Error::NAME,
cell, cell,
format!("Defined name \"{name}\" is not a reference."), format!("Defined name \"{}\" is not a reference.", name),
), ),
} }
} else { } else {
CalcResult::new_error( CalcResult::new_error(
Error::NAME, Error::NAME,
cell, cell,
format!("Defined name \"{name}\" not found."), format!("Defined name \"{}\" not found.", name),
) )
} }
} }
TableNameKind(s) => CalcResult::new_error( TableNameKind(s) => CalcResult::new_error(
Error::NAME, Error::NAME,
cell, cell,
format!("table name \"{s}\" not supported."), format!("table name \"{}\" not supported.", s),
), ),
WrongVariableKind(s) => CalcResult::new_error( WrongVariableKind(s) => CalcResult::new_error(
Error::NAME, Error::NAME,
cell, cell,
format!("Variable name \"{s}\" not found."), format!("Variable name \"{}\" not found.", s),
), ),
CompareKind { kind, left, right } => { CompareKind { kind, left, right } => {
let l = self.evaluate_node_in_context(left, cell); let l = self.evaluate_node_in_context(left, cell);
@@ -487,7 +487,7 @@ impl Model {
} => CalcResult::new_error( } => CalcResult::new_error(
Error::ERROR, Error::ERROR,
cell, cell,
format!("Error parsing {formula}: {message}"), format!("Error parsing {}: {}", formula, message),
), ),
EmptyArgKind => CalcResult::EmptyArg, EmptyArgKind => CalcResult::EmptyArg,
ImplicitIntersection { ImplicitIntersection {
@@ -500,7 +500,7 @@ impl Model {
None => CalcResult::new_error( None => CalcResult::new_error(
Error::VALUE, Error::VALUE,
cell, cell,
format!("Error with Implicit Intersection in cell {cell:?}"), format!("Error with Implicit Intersection in cell {:?}", cell),
), ),
} }
} }
@@ -697,7 +697,7 @@ impl Model {
worksheet.color = Some(color.to_string()); worksheet.color = Some(color.to_string());
return Ok(()); return Ok(());
} }
Err(format!("Invalid color: {color}")) Err(format!("Invalid color: {}", color))
} }
/// Changes the visibility of a sheet /// Changes the visibility of a sheet
@@ -1027,7 +1027,7 @@ impl Model {
let source_sheet_name = self let source_sheet_name = self
.workbook .workbook
.worksheet(source.sheet) .worksheet(source.sheet)
.map_err(|e| format!("Could not find source worksheet: {e}"))? .map_err(|e| format!("Could not find source worksheet: {}", e))?
.get_name(); .get_name();
if source.sheet != area.sheet { if source.sheet != area.sheet {
return Err("Source and area are in different sheets".to_string()); return Err("Source and area are in different sheets".to_string());
@@ -1041,7 +1041,7 @@ impl Model {
let target_sheet_name = self let target_sheet_name = self
.workbook .workbook
.worksheet(target.sheet) .worksheet(target.sheet)
.map_err(|e| format!("Could not find target worksheet: {e}"))? .map_err(|e| format!("Could not find target worksheet: {}", e))?
.get_name(); .get_name();
if let Some(formula) = value.strip_prefix('=') { if let Some(formula) = value.strip_prefix('=') {
let cell_reference = CellReferenceRC { let cell_reference = CellReferenceRC {
@@ -1061,7 +1061,7 @@ impl Model {
column_delta: target.column - source.column, column_delta: target.column - source.column,
}, },
); );
Ok(format!("={formula_str}")) Ok(format!("={}", formula_str))
} else { } else {
Ok(value.to_string()) Ok(value.to_string())
} }
@@ -1538,7 +1538,7 @@ impl Model {
// If the formula fails to parse try adding a parenthesis // If the formula fails to parse try adding a parenthesis
// SUM(A1:A3 => SUM(A1:A3) // SUM(A1:A3 => SUM(A1:A3)
if let Node::ParseErrorKind { .. } = parsed_formula { if let Node::ParseErrorKind { .. } = parsed_formula {
let new_parsed_formula = self.parser.parse(&format!("{formula})"), &cell_reference); let new_parsed_formula = self.parser.parse(&format!("{})", formula), &cell_reference);
match new_parsed_formula { match new_parsed_formula {
Node::ParseErrorKind { .. } => {} Node::ParseErrorKind { .. } => {}
_ => parsed_formula = new_parsed_formula, _ => parsed_formula = new_parsed_formula,

View File

@@ -168,11 +168,11 @@ impl Model {
.get_worksheet_names() .get_worksheet_names()
.iter() .iter()
.map(|s| s.to_uppercase()) .map(|s| s.to_uppercase())
.any(|x| x == format!("{base_name_uppercase}{index}")) .any(|x| x == format!("{}{}", base_name_uppercase, index))
{ {
index += 1; index += 1;
} }
let sheet_name = format!("{base_name}{index}"); let sheet_name = format!("{}{}", base_name, index);
// Now we need a sheet_id // Now we need a sheet_id
let sheet_id = self.get_new_sheet_id(); let sheet_id = self.get_new_sheet_id();
let view_ids: Vec<&u32> = self.workbook.views.keys().collect(); let view_ids: Vec<&u32> = self.workbook.views.keys().collect();
@@ -192,7 +192,7 @@ impl Model {
sheet_id: Option<u32>, sheet_id: Option<u32>,
) -> Result<(), String> { ) -> Result<(), String> {
if !is_valid_sheet_name(sheet_name) { if !is_valid_sheet_name(sheet_name) {
return Err(format!("Invalid name for a sheet: '{sheet_name}'")); return Err(format!("Invalid name for a sheet: '{}'", sheet_name));
} }
if self if self
.workbook .workbook
@@ -234,7 +234,7 @@ impl Model {
if let Some(sheet_index) = self.get_sheet_index_by_name(old_name) { if let Some(sheet_index) = self.get_sheet_index_by_name(old_name) {
return self.rename_sheet_by_index(sheet_index, new_name); return self.rename_sheet_by_index(sheet_index, new_name);
} }
Err(format!("Could not find sheet {old_name}")) Err(format!("Could not find sheet {}", old_name))
} }
/// Renames a sheet and updates all existing references to that sheet. /// Renames a sheet and updates all existing references to that sheet.
@@ -248,10 +248,10 @@ impl Model {
new_name: &str, new_name: &str,
) -> Result<(), String> { ) -> Result<(), String> {
if !is_valid_sheet_name(new_name) { if !is_valid_sheet_name(new_name) {
return Err(format!("Invalid name for a sheet: '{new_name}'.")); return Err(format!("Invalid name for a sheet: '{}'.", new_name));
} }
if self.get_sheet_index_by_name(new_name).is_some() { if self.get_sheet_index_by_name(new_name).is_some() {
return Err(format!("Sheet already exists: '{new_name}'.")); return Err(format!("Sheet already exists: '{}'.", new_name));
} }
// Gets the new name and checks that a sheet with that index exists // Gets the new name and checks that a sheet with that index exists
let old_name = self.workbook.worksheet(sheet_index)?.get_name(); let old_name = self.workbook.worksheet(sheet_index)?.get_name();
@@ -362,14 +362,14 @@ impl Model {
}; };
let locale = match get_locale(locale_id) { let locale = match get_locale(locale_id) {
Ok(l) => l.clone(), Ok(l) => l.clone(),
Err(_) => return Err(format!("Invalid locale: {locale_id}")), Err(_) => return Err(format!("Invalid locale: {}", locale_id)),
}; };
let milliseconds = get_milliseconds_since_epoch(); let milliseconds = get_milliseconds_since_epoch();
let seconds = milliseconds / 1000; let seconds = milliseconds / 1000;
let dt = match DateTime::from_timestamp(seconds, 0) { let dt = match DateTime::from_timestamp(seconds, 0) {
Some(s) => s, Some(s) => s,
None => return Err(format!("Invalid timestamp: {milliseconds}")), None => return Err(format!("Invalid timestamp: {}", milliseconds)),
}; };
// "2020-08-06T21:20:53Z // "2020-08-06T21:20:53Z
let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(); let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
@@ -405,6 +405,7 @@ impl Model {
}, },
tables: HashMap::new(), tables: HashMap::new(),
views, views,
users: Vec::new(),
}; };
let parsed_formulas = Vec::new(); let parsed_formulas = Vec::new();
let worksheets = &workbook.worksheets; let worksheets = &workbook.worksheets;

View File

@@ -126,7 +126,7 @@ pub fn to_precision_str(value: f64, precision: usize) -> String {
let exponent = value.abs().log10().floor(); let exponent = value.abs().log10().floor();
let base = value / 10.0_f64.powf(exponent); let base = value / 10.0_f64.powf(exponent);
let base = format!("{0:.1$}", base, precision - 1); let base = format!("{0:.1$}", base, precision - 1);
let value = format!("{base}e{exponent}").parse::<f64>().unwrap_or({ let value = format!("{}e{}", base, exponent).parse::<f64>().unwrap_or({
// TODO: do this in a way that does not require a possible error // TODO: do this in a way that does not require a possible error
0.0 0.0
}); });

View File

@@ -154,7 +154,7 @@ impl Styles {
return Ok(cell_style.xf_id); return Ok(cell_style.xf_id);
} }
} }
Err(format!("Style '{style_name}' not found")) Err(format!("Style '{}' not found", style_name))
} }
pub fn create_named_style(&mut self, style_name: &str, style: &Style) -> Result<(), String> { pub fn create_named_style(&mut self, style_name: &str, style: &Style) -> Result<(), String> {

View File

@@ -174,7 +174,7 @@ fn fn_or_xor_no_arguments() {
println!("Testing function: {func}"); println!("Testing function: {func}");
let mut model = new_empty_model(); let mut model = new_empty_model();
model._set("A1", &format!("={func}()")); model._set("A1", &format!("={}()", func));
model.evaluate(); model.evaluate();
assert_eq!(model._get_text("A1"), *"#ERROR!"); assert_eq!(model._get_text("A1"), *"#ERROR!");
} }

View File

@@ -62,17 +62,3 @@ fn test_create_named_style() {
let style = model.get_style_for_cell(0, 1, 1).unwrap(); let style = model.get_style_for_cell(0, 1, 1).unwrap();
assert!(style.font.b); assert!(style.font.b);
} }
#[test]
fn empty_models_have_two_fills() {
let model = new_empty_model();
assert_eq!(model.workbook.styles.fills.len(), 2);
assert_eq!(
model.workbook.styles.fills[0].pattern_type,
"none".to_string()
);
assert_eq!(
model.workbook.styles.fills[1].pattern_type,
"gray125".to_string()
);
}

View File

@@ -50,7 +50,10 @@ fn check_borders(model: &UserModel) {
assert_eq!( assert_eq!(
Some(top_border), Some(top_border),
top_cell_style.border.bottom, top_cell_style.border.bottom,
"(Top). Sheet: {sheet}, row: {row}, column: {column}" "(Top). Sheet: {}, row: {}, column: {}",
sheet,
row,
column
); );
} }
} }
@@ -62,7 +65,10 @@ fn check_borders(model: &UserModel) {
assert_eq!( assert_eq!(
Some(right_border), Some(right_border),
right_cell_style.border.left, right_cell_style.border.left,
"(Right). Sheet: {sheet}, row: {row}, column: {column}" "(Right). Sheet: {}, row: {}, column: {}",
sheet,
row,
column
); );
} }
} }
@@ -74,7 +80,10 @@ fn check_borders(model: &UserModel) {
assert_eq!( assert_eq!(
Some(bottom_border), Some(bottom_border),
bottom_cell_style.border.top, bottom_cell_style.border.top,
"(Bottom). Sheet: {sheet}, row: {row}, column: {column}" "(Bottom). Sheet: {}, row: {}, column: {}",
sheet,
row,
column
); );
} }
} }
@@ -85,7 +94,10 @@ fn check_borders(model: &UserModel) {
assert_eq!( assert_eq!(
Some(left_border), Some(left_border),
left_cell_style.border.right, left_cell_style.border.right,
"(Left). Sheet: {sheet}, row: {row}, column: {column}" "(Left). Sheet: {}, row: {}, column: {}",
sheet,
row,
column
); );
} }
} }

View File

@@ -26,7 +26,7 @@ fn set_user_input_errors() {
#[test] #[test]
fn user_model_debug_message() { fn user_model_debug_message() {
let model = UserModel::new_empty("model", "en", "UTC").unwrap(); let model = UserModel::new_empty("model", "en", "UTC").unwrap();
let s = &format!("{model:?}"); let s = &format!("{:?}", model);
assert_eq!(s, "UserModel"); assert_eq!(s, "UserModel");
} }

View File

@@ -11,10 +11,11 @@ impl UserModel {
r##"{{ r##"{{
"item": {{ "item": {{
"style": "thin", "style": "thin",
"color": "{color}" "color": "{}"
}}, }},
"type": "All" "type": "All"
}}"## }}"##,
color
)) ))
.unwrap(); .unwrap();
let range = &Area { let range = &Area {
@@ -39,10 +40,11 @@ impl UserModel {
r##"{{ r##"{{
"item": {{ "item": {{
"style": "thin", "style": "thin",
"color": "{color}" "color": "{}"
}}, }},
"type": "{kind}" "type": "{}"
}}"## }}"##,
color, kind
)) ))
.unwrap(); .unwrap();
let range = &Area { let range = &Area {

View File

@@ -13,7 +13,7 @@ impl Model {
if cell.contains('!') { if cell.contains('!') {
self.parse_reference(cell).unwrap() self.parse_reference(cell).unwrap()
} else { } else {
self.parse_reference(&format!("Sheet1!{cell}")).unwrap() self.parse_reference(&format!("Sheet1!{}", cell)).unwrap()
} }
} }
pub fn _set(&mut self, cell: &str, value: &str) { pub fn _set(&mut self, cell: &str, value: &str) {

View File

@@ -39,6 +39,14 @@ pub struct WorkbookView {
pub window_height: i64, pub window_height: i64,
} }
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct WebUser {
pub id: String,
pub sheet: u32,
pub row: i32,
pub column: i32,
}
/// An internal representation of an IronCalc Workbook /// An internal representation of an IronCalc Workbook
#[derive(Encode, Decode, Debug, PartialEq, Clone)] #[derive(Encode, Decode, Debug, PartialEq, Clone)]
pub struct Workbook { pub struct Workbook {
@@ -51,6 +59,7 @@ pub struct Workbook {
pub metadata: Metadata, pub metadata: Metadata,
pub tables: HashMap<String, Table>, pub tables: HashMap<String, Table>,
pub views: HashMap<u32, WorkbookView>, pub views: HashMap<u32, WorkbookView>,
pub users: Vec<WebUser>
} }
/// A defined name. The `sheet_id` is the sheet index in case the name is local /// A defined name. The `sheet_id` is the sheet index in case the name is local
@@ -303,14 +312,7 @@ impl Default for Styles {
Styles { Styles {
num_fmts: vec![], num_fmts: vec![],
fonts: vec![Default::default()], fonts: vec![Default::default()],
fills: vec![ fills: vec![Default::default()],
Default::default(),
Fill {
pattern_type: "gray125".to_string(),
fg_color: None,
bg_color: None,
},
],
borders: vec![Default::default()], borders: vec![Default::default()],
cell_style_xfs: vec![Default::default()], cell_style_xfs: vec![Default::default()],
cell_xfs: vec![Default::default()], cell_xfs: vec![Default::default()],

View File

@@ -14,7 +14,7 @@ use crate::{
model::Model, model::Model,
types::{ types::{
Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState, Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, SheetState,
Style, VerticalAlignment, Style, VerticalAlignment, WebUser,
}, },
utils::is_valid_hex_color, utils::is_valid_hex_color,
}; };
@@ -293,6 +293,11 @@ impl UserModel {
self.model.workbook.name = name.to_string(); self.model.workbook.name = name.to_string();
} }
/// Set users
pub fn set_users(&mut self, users: &[WebUser]) {
self.model.workbook.users = users.to_vec();
}
/// Undoes last change if any, places the change in the redo list and evaluates the model if needed /// Undoes last change if any, places the change in the redo list and evaluates the model if needed
/// ///
/// See also: /// See also:
@@ -1487,10 +1492,10 @@ impl UserModel {
return Err(format!("Invalid row: '{first_row}'")); return Err(format!("Invalid row: '{first_row}'"));
} }
if !is_valid_column_number(last_column) { if !is_valid_column_number(last_column) {
return Err(format!("Invalid column: '{last_column}'")); return Err(format!("Invalid column: '{}'", last_column));
} }
if !is_valid_row(last_row) { if !is_valid_row(last_row) {
return Err(format!("Invalid row: '{last_row}'")); return Err(format!("Invalid row: '{}'", last_row));
} }
if !is_valid_row(to_column) { if !is_valid_row(to_column) {
@@ -1623,15 +1628,15 @@ impl UserModel {
text_row.push(text); text_row.push(text);
} }
wtr.write_record(text_row) wtr.write_record(text_row)
.map_err(|e| format!("Error while processing csv: {e}"))?; .map_err(|e| format!("Error while processing csv: {}", e))?;
data.insert(row, data_row); data.insert(row, data_row);
} }
let csv = String::from_utf8( let csv = String::from_utf8(
wtr.into_inner() wtr.into_inner()
.map_err(|e| format!("Processing error: '{e}'"))?, .map_err(|e| format!("Processing error: '{}'", e))?,
) )
.map_err(|e| format!("Error converting from utf8: '{e}'"))?; .map_err(|e| format!("Error converting from utf8: '{}'", e))?;
Ok(Clipboard { Ok(Clipboard {
csv, csv,
@@ -2391,7 +2396,7 @@ mod tests {
VerticalAlignment::Top, VerticalAlignment::Top,
]; ];
for a in all { for a in all {
assert_eq!(vertical(&format!("{a}")), Ok(a)); assert_eq!(vertical(&format!("{}", a)), Ok(a));
} }
} }
@@ -2408,7 +2413,7 @@ mod tests {
HorizontalAlignment::Right, HorizontalAlignment::Right,
]; ];
for a in all { for a in all {
assert_eq!(horizontal(&format!("{a}")), Ok(a)); assert_eq!(horizontal(&format!("{}", a)), Ok(a));
} }
} }
} }

View File

@@ -76,7 +76,7 @@ impl UserModel {
/// Sets the the selected sheet /// Sets the the selected sheet
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<(), String> { pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<(), String> {
if self.model.workbook.worksheet(sheet).is_err() { if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {sheet}")); return Err(format!("Invalid worksheet index {}", sheet));
} }
if let Some(view) = self.model.workbook.views.get_mut(&0) { if let Some(view) = self.model.workbook.views.get_mut(&0) {
view.sheet = sheet; view.sheet = sheet;
@@ -98,7 +98,7 @@ impl UserModel {
return Err(format!("Invalid row: '{row}'")); return Err(format!("Invalid row: '{row}'"));
} }
if self.model.workbook.worksheet(sheet).is_err() { if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {sheet}")); return Err(format!("Invalid worksheet index {}", sheet));
} }
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) { if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&0) { if let Some(view) = worksheet.views.get_mut(&0) {
@@ -138,7 +138,7 @@ impl UserModel {
return Err(format!("Invalid row: '{end_row}'")); return Err(format!("Invalid row: '{end_row}'"));
} }
if self.model.workbook.worksheet(sheet).is_err() { if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {sheet}")); return Err(format!("Invalid worksheet index {}", sheet));
} }
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) { if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&0) { if let Some(view) = worksheet.views.get_mut(&0) {
@@ -147,12 +147,14 @@ impl UserModel {
// The selected cells must be on one of the corners of the selected range: // The selected cells must be on one of the corners of the selected range:
if selected_row != start_row && selected_row != end_row { if selected_row != start_row && selected_row != end_row {
return Err(format!( return Err(format!(
"The selected cells is not in one of the corners. Row: '{selected_row}' and row range '({start_row}, {end_row})'" "The selected cells is not in one of the corners. Row: '{}' and row range '({}, {})'",
selected_row, start_row, end_row
)); ));
} }
if selected_column != start_column && selected_column != end_column { if selected_column != start_column && selected_column != end_column {
return Err(format!( return Err(format!(
"The selected cells is not in one of the corners. Column '{selected_column}' and column range '({start_column}, {end_column})'" "The selected cells is not in one of the corners. Column '{}' and column range '({}, {})'",
selected_column, start_column, end_column
)); ));
} }
view.range = [start_row, start_column, end_row, end_column]; view.range = [start_row, start_column, end_row, end_column];
@@ -305,7 +307,7 @@ impl UserModel {
return Err(format!("Invalid row: '{top_row}'")); return Err(format!("Invalid row: '{top_row}'"));
} }
if self.model.workbook.worksheet(sheet).is_err() { if self.model.workbook.worksheet(sheet).is_err() {
return Err(format!("Invalid worksheet index {sheet}")); return Err(format!("Invalid worksheet index {}", sheet));
} }
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) { if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
if let Some(view) = worksheet.views.get_mut(&0) { if let Some(view) = worksheet.views.get_mut(&0) {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "pyroncalc" name = "pyroncalc"
version = "0.5.0" version = "0.5.6"
edition = "2021" edition = "2021"
@@ -13,7 +13,8 @@ crate-type = ["cdylib"]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.5.0" } xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.5.0" }
pyo3 = { version = "0.23", features = ["extension-module"] } pyo3 = { version = "0.25", features = ["extension-module"] }
bitcode = "0.6.3"
[features] [features]

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "ironcalc" name = "ironcalc"
version = "0.5.0" version = "0.5.6"
description = "Create, edit and evaluate Excel spreadsheets" description = "Create, edit and evaluate Excel spreadsheets"
requires-python = ">=3.10" requires-python = ">=3.10"
keywords = [ keywords = [

View File

@@ -2,8 +2,8 @@ use pyo3::exceptions::PyException;
use pyo3::{create_exception, prelude::*, wrap_pyfunction}; use pyo3::{create_exception, prelude::*, wrap_pyfunction};
use types::{PySheetProperty, PyStyle}; use types::{PySheetProperty, PyStyle};
use xlsx::base::types::Style; use xlsx::base::types::{Style, Workbook};
use xlsx::base::Model; use xlsx::base::{Model, UserModel};
use xlsx::export::{save_to_icalc, save_to_xlsx}; use xlsx::export::{save_to_icalc, save_to_xlsx};
use xlsx::import; use xlsx::import;
@@ -14,6 +14,60 @@ use crate::types::PyCellType;
create_exception!(_ironcalc, WorkbookError, PyException); create_exception!(_ironcalc, WorkbookError, PyException);
#[pyclass]
pub struct PyUserModel {
/// The user model, which is a wrapper around the Model
pub model: UserModel,
}
#[pymethods]
impl PyUserModel {
/// Saves the user model to an xlsx file
pub fn save_to_xlsx(&self, file: &str) -> PyResult<()> {
let model = self.model.get_model();
save_to_xlsx(model, file).map_err(|e| WorkbookError::new_err(e.to_string()))
}
/// Saves the user model to file in the internal binary ic format
pub fn save_to_icalc(&self, file: &str) -> PyResult<()> {
let model = self.model.get_model();
save_to_icalc(model, file).map_err(|e| WorkbookError::new_err(e.to_string()))
}
pub fn apply_external_diffs(&mut self, external_diffs: &[u8]) -> PyResult<()> {
self.model
.apply_external_diffs(external_diffs)
.map_err(|e| WorkbookError::new_err(e.to_string()))
}
pub fn flush_send_queue(&mut self) -> Vec<u8> {
self.model.flush_send_queue()
}
pub fn set_user_input(
&mut self,
sheet: u32,
row: i32,
column: i32,
value: &str,
) -> PyResult<()> {
self.model
.set_user_input(sheet, row, column, value)
.map_err(|e| WorkbookError::new_err(e.to_string()))
}
pub fn get_formatted_cell_value(&self, sheet: u32, row: i32, column: i32) -> PyResult<String> {
self.model
.get_formatted_cell_value(sheet, row, column)
.map_err(|e| WorkbookError::new_err(e.to_string()))
}
pub fn to_bytes(&self) -> PyResult<Vec<u8>> {
let bytes = self.model.to_bytes();
Ok(bytes)
}
}
/// This is a model implementing the 'raw' API /// This is a model implementing the 'raw' API
#[pyclass] #[pyclass]
pub struct PyModel { pub struct PyModel {
@@ -32,6 +86,12 @@ impl PyModel {
save_to_icalc(&self.model, file).map_err(|e| WorkbookError::new_err(e.to_string())) save_to_icalc(&self.model, file).map_err(|e| WorkbookError::new_err(e.to_string()))
} }
/// To bytes
pub fn to_bytes(&self) -> PyResult<Vec<u8>> {
let bytes = self.model.to_bytes();
Ok(bytes)
}
/// Evaluates the workbook /// Evaluates the workbook
pub fn evaluate(&mut self) { pub fn evaluate(&mut self) {
self.model.evaluate() self.model.evaluate()
@@ -249,6 +309,15 @@ pub fn load_from_icalc(file_name: &str) -> PyResult<PyModel> {
Ok(PyModel { model }) Ok(PyModel { model })
} }
#[pyfunction]
pub fn load_from_bytes(bytes: &[u8]) -> PyResult<PyModel> {
let workbook: Workbook =
bitcode::decode(bytes).map_err(|e| WorkbookError::new_err(e.to_string()))?;
let model =
Model::from_workbook(workbook).map_err(|e| WorkbookError::new_err(e.to_string()))?;
Ok(PyModel { model })
}
/// Creates an empty model /// Creates an empty model
#[pyfunction] #[pyfunction]
pub fn create(name: &str, locale: &str, tz: &str) -> PyResult<PyModel> { pub fn create(name: &str, locale: &str, tz: &str) -> PyResult<PyModel> {
@@ -257,6 +326,43 @@ pub fn create(name: &str, locale: &str, tz: &str) -> PyResult<PyModel> {
Ok(PyModel { model }) Ok(PyModel { model })
} }
#[pyfunction]
pub fn create_user_model(name: &str, locale: &str, tz: &str) -> PyResult<PyUserModel> {
let model = UserModel::new_empty(name, locale, tz)
.map_err(|e| WorkbookError::new_err(e.to_string()))?;
Ok(PyUserModel { model })
}
#[pyfunction]
pub fn create_user_model_from_xlsx(
file_path: &str,
locale: &str,
tz: &str,
) -> PyResult<PyUserModel> {
let model = import::load_from_xlsx(file_path, locale, tz)
.map_err(|e| WorkbookError::new_err(e.to_string()))?;
let model = UserModel::from_model(model);
Ok(PyUserModel { model })
}
#[pyfunction]
pub fn create_user_model_from_icalc(file_name: &str) -> PyResult<PyUserModel> {
let model =
import::load_from_icalc(file_name).map_err(|e| WorkbookError::new_err(e.to_string()))?;
let model = UserModel::from_model(model);
Ok(PyUserModel { model })
}
#[pyfunction]
pub fn create_user_model_from_bytes(bytes: &[u8]) -> PyResult<PyUserModel> {
let workbook: Workbook =
bitcode::decode(bytes).map_err(|e| WorkbookError::new_err(e.to_string()))?;
let model =
Model::from_workbook(workbook).map_err(|e| WorkbookError::new_err(e.to_string()))?;
let user_model = UserModel::from_model(model);
Ok(PyUserModel { model: user_model })
}
#[pyfunction] #[pyfunction]
#[allow(clippy::panic)] #[allow(clippy::panic)]
pub fn test_panic() { pub fn test_panic() {
@@ -272,7 +378,14 @@ fn ironcalc(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(create, m)?)?; m.add_function(wrap_pyfunction!(create, m)?)?;
m.add_function(wrap_pyfunction!(load_from_xlsx, m)?)?; m.add_function(wrap_pyfunction!(load_from_xlsx, m)?)?;
m.add_function(wrap_pyfunction!(load_from_icalc, m)?)?; m.add_function(wrap_pyfunction!(load_from_icalc, m)?)?;
m.add_function(wrap_pyfunction!(load_from_bytes, m)?)?;
m.add_function(wrap_pyfunction!(test_panic, m)?)?; m.add_function(wrap_pyfunction!(test_panic, m)?)?;
// User model functions
m.add_function(wrap_pyfunction!(create_user_model, m)?)?;
m.add_function(wrap_pyfunction!(create_user_model_from_bytes, m)?)?;
m.add_function(wrap_pyfunction!(create_user_model_from_xlsx, m)?)?;
m.add_function(wrap_pyfunction!(create_user_model_from_icalc, m)?)?;
Ok(()) Ok(())
} }

View File

@@ -6,3 +6,24 @@ def test_simple():
model.evaluate() model.evaluate()
assert model.get_formatted_cell_value(0, 1, 1) == "3" assert model.get_formatted_cell_value(0, 1, 1) == "3"
bytes = model.to_bytes()
model2 = ic.load_from_bytes(bytes)
assert model2.get_formatted_cell_value(0, 1, 1) == "3"
def test_simple_user():
model = ic.create_user_model("model", "en", "UTC")
model.set_user_input(0, 1, 1, "=1+2")
model.set_user_input(0, 1, 2, "=A1+3")
assert model.get_formatted_cell_value(0, 1, 1) == "3"
assert model.get_formatted_cell_value(0, 1, 2) == "6"
diffs = model.flush_send_queue()
model2 = ic.create_user_model("model", "en", "UTC")
model2.apply_external_diffs(diffs)
assert model2.get_formatted_cell_value(0, 1, 1) == "3"
assert model2.get_formatted_cell_value(0, 1, 2) == "6"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wasm" name = "wasm"
version = "0.5.0" version = "0.5.3"
authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"]
description = "IronCalc Web bindings" description = "IronCalc Web bindings"
license = "MIT/Apache-2.0" license = "MIT/Apache-2.0"
@@ -16,7 +16,7 @@ crate-type = ["cdylib"]
# https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations
ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] } ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = "0.2.100" wasm-bindgen = "0.2.92"
serde-wasm-bindgen = "0.4" serde-wasm-bindgen = "0.4"
[dev-dependencies] [dev-dependencies]

View File

@@ -26,4 +26,4 @@ clean:
rm -rf pkg rm -rf pkg
rm -f types.js rm -f types.js
.PHONY: all lint clean tests .PHONY: all lint clean

View File

@@ -1,25 +1,263 @@
# Regrettably at the time of writing there is not a perfect way to
# generate the TypeScript types from Rust so we basically fix them manually
# Hopefully this will suffice for our needs and one day will be automatic
header = r""" header = r"""
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
""".strip() """.strip()
def fix_types(text: str): get_tokens_str = r"""
* @returns {any}
*/
export function getTokens(formula: string): any;
""".strip()
get_tokens_str_types = r"""
* @returns {MarkedToken[]}
*/
export function getTokens(formula: string): MarkedToken[];
""".strip()
update_style_str = r"""
/**
* @param {any} range
* @param {string} style_path
* @param {string} value
*/
updateRangeStyle(range: any, style_path: string, value: string): void;
""".strip()
update_style_str_types = r"""
/**
* @param {Area} range
* @param {string} style_path
* @param {string} value
*/
updateRangeStyle(range: Area, style_path: string, value: string): void;
""".strip()
properties = r"""
/**
* @returns {any}
*/
getWorksheetsProperties(): any;
""".strip()
properties_types = r"""
/**
* @returns {WorksheetProperties[]}
*/
getWorksheetsProperties(): WorksheetProperties[];
""".strip()
style = r"""
* @returns {any}
*/
getCellStyle(sheet: number, row: number, column: number): any;
""".strip()
style_types = r"""
* @returns {CellStyle}
*/
getCellStyle(sheet: number, row: number, column: number): CellStyle;
""".strip()
view = r"""
* @returns {any}
*/
getSelectedView(): any;
""".strip()
view_types = r"""
* @returns {CellStyle}
*/
getSelectedView(): SelectedView;
""".strip()
autofill_rows = r"""
/**
* @param {any} source_area
* @param {number} to_row
*/
autoFillRows(source_area: any, to_row: number): void;
"""
autofill_rows_types = r"""
/**
* @param {Area} source_area
* @param {number} to_row
*/
autoFillRows(source_area: Area, to_row: number): void;
"""
autofill_columns = r"""
/**
* @param {any} source_area
* @param {number} to_column
*/
autoFillColumns(source_area: any, to_column: number): void;
"""
autofill_columns_types = r"""
/**
* @param {Area} source_area
* @param {number} to_column
*/
autoFillColumns(source_area: Area, to_column: number): void;
"""
set_cell_style = r"""
/**
* @param {any} styles
*/
onPasteStyles(styles: any): void;
"""
set_cell_style_types = r"""
/**
* @param {CellStyle[][]} styles
*/
onPasteStyles(styles: CellStyle[][]): void;
"""
set_area_border = r"""
/**
* @param {any} area
* @param {any} border_area
*/
setAreaWithBorder(area: any, border_area: any): void;
"""
set_area_border_types = r"""
/**
* @param {Area} area
* @param {BorderArea} border_area
*/
setAreaWithBorder(area: Area, border_area: BorderArea): void;
"""
paste_csv_string = r"""
/**
* @param {any} area
* @param {string} csv
*/
pasteCsvText(area: any, csv: string): void;
"""
paste_csv_string_types = r"""
/**
* @param {Area} area
* @param {string} csv
*/
pasteCsvText(area: Area, csv: string): void;
"""
clipboard = r"""
/**
* @returns {any}
*/
copyToClipboard(): any;
"""
clipboard_types = r"""
/**
* @returns {Clipboard}
*/
copyToClipboard(): Clipboard;
"""
paste_from_clipboard = r"""
/**
* @param {number} source_sheet
* @param {any} source_range
* @param {any} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_sheet: number, source_range: any, clipboard: any, is_cut: boolean): void;
"""
paste_from_clipboard_types = r"""
/**
* @param {number} source_sheet
* @param {[number, number, number, number]} source_range
* @param {ClipboardData} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;
"""
defined_name_list = r"""
/**
* @returns {any}
*/
getDefinedNameList(): any;
"""
defined_name_list_types = r"""
/**
* @returns {DefinedName[]}
*/
getDefinedNameList(): DefinedName[];
"""
set_users = r"""
/**
* @param {any} users
*/
setUsers(users: any): void;
"""
set_users_types = r"""
/**
* @param {WebUser[]} users
*/
setUsers(users: WebUser[]): void;
"""
get_users = r"""
/**
* @returns {any}
*/
getUsers(): any;
}
"""
get_users_types = r"""
/**
* @returns {WebUser[]}
*/
getUsers(): WebUser[];
}
"""
def fix_types(text):
text = text.replace(get_tokens_str, get_tokens_str_types)
text = text.replace(update_style_str, update_style_str_types)
text = text.replace(properties, properties_types)
text = text.replace(style, style_types)
text = text.replace(view, view_types)
text = text.replace(autofill_rows, autofill_rows_types)
text = text.replace(autofill_columns, autofill_columns_types)
text = text.replace(set_cell_style, set_cell_style_types)
text = text.replace(set_area_border, set_area_border_types)
text = text.replace(paste_csv_string, paste_csv_string_types)
text = text.replace(clipboard, clipboard_types)
text = text.replace(paste_from_clipboard, paste_from_clipboard_types)
text = text.replace(defined_name_list, defined_name_list_types)
text = text.replace(set_users, set_users_types)
text = text.replace(get_users, get_users_types)
with open("types.ts") as f: with open("types.ts") as f:
types_str = f.read() types_str = f.read()
header_types = "{}\n\n{}".format(header, types_str) header_types = "{}\n\n{}".format(header, types_str)
text = text.replace(header, header_types) text = text.replace(header, header_types)
for line in text.splitlines(): if text.find("any") != -1:
line = line.lstrip() print("There are 'unfixed' types. Please check.")
# Skip internal methods
if line.startswith("readonly model_"):
continue
if line.find("any") != -1:
print("There are 'unfixed' public types. Please check.")
exit(1) exit(1)
return text return text
if __name__ == "__main__": if __name__ == "__main__":
types_file = "pkg/wasm.d.ts" types_file = "pkg/wasm.d.ts"
with open(types_file) as f: with open(types_file) as f:
@@ -37,3 +275,5 @@ if __name__ == "__main__":
with open(js_file, "wb") as f: with open(js_file, "wb") as f:
f.write(bytes("{}\n{}".format(text_js, text), "utf8")) f.write(bytes("{}\n{}".format(text_js, text), "utf8"))

View File

@@ -6,7 +6,7 @@ use wasm_bindgen::{
use ironcalc_base::{ use ironcalc_base::{
expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column},
types::{CellType, Style}, types::{CellType, Style, WebUser},
BorderArea, ClipboardData, UserModel as BaseModel, BorderArea, ClipboardData, UserModel as BaseModel,
}; };
@@ -16,7 +16,7 @@ fn to_js_error(error: String) -> JsError {
/// Return an array with a list of all the tokens from a formula /// Return an array with a list of all the tokens from a formula
/// This is used by the UI to color them according to a theme. /// This is used by the UI to color them according to a theme.
#[wasm_bindgen(js_name = "getTokens", unchecked_return_type = "MarkedToken[]")] #[wasm_bindgen(js_name = "getTokens")]
pub fn get_tokens(formula: &str) -> Result<JsValue, JsError> { pub fn get_tokens(formula: &str) -> Result<JsValue, JsError> {
let tokens = tokenizer(formula); let tokens = tokenizer(formula);
serde_wasm_bindgen::to_value(&tokens).map_err(JsError::from) serde_wasm_bindgen::to_value(&tokens).map_err(JsError::from)
@@ -338,7 +338,7 @@ impl Model {
#[wasm_bindgen(js_name = "updateRangeStyle")] #[wasm_bindgen(js_name = "updateRangeStyle")]
pub fn update_range_style( pub fn update_range_style(
&mut self, &mut self,
#[wasm_bindgen(unchecked_param_type = "Area")] range: JsValue, range: JsValue,
style_path: &str, style_path: &str,
value: &str, value: &str,
) -> Result<(), JsError> { ) -> Result<(), JsError> {
@@ -349,7 +349,7 @@ impl Model {
.map_err(to_js_error) .map_err(to_js_error)
} }
#[wasm_bindgen(js_name = "getCellStyle", unchecked_return_type = "CellStyle")] #[wasm_bindgen(js_name = "getCellStyle")]
pub fn get_cell_style( pub fn get_cell_style(
&mut self, &mut self,
sheet: u32, sheet: u32,
@@ -365,10 +365,7 @@ impl Model {
} }
#[wasm_bindgen(js_name = "onPasteStyles")] #[wasm_bindgen(js_name = "onPasteStyles")]
pub fn on_paste_styles( pub fn on_paste_styles(&mut self, styles: JsValue) -> Result<(), JsError> {
&mut self,
#[wasm_bindgen(unchecked_param_type = "CellStyle[][]")] styles: JsValue,
) -> Result<(), JsError> {
let styles: &Vec<Vec<Style>> = let styles: &Vec<Vec<Style>> =
&serde_wasm_bindgen::from_value(styles).map_err(|e| to_js_error(e.to_string()))?; &serde_wasm_bindgen::from_value(styles).map_err(|e| to_js_error(e.to_string()))?;
self.model.on_paste_styles(styles).map_err(to_js_error) self.model.on_paste_styles(styles).map_err(to_js_error)
@@ -394,10 +391,7 @@ impl Model {
// I don't _think_ serializing to JsValue can't fail // I don't _think_ serializing to JsValue can't fail
// FIXME: Remove this clippy directive // FIXME: Remove this clippy directive
#[wasm_bindgen( #[wasm_bindgen(js_name = "getWorksheetsProperties")]
js_name = "getWorksheetsProperties",
unchecked_return_type = "WorksheetProperties[]"
)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
pub fn get_worksheets_properties(&self) -> JsValue { pub fn get_worksheets_properties(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap() serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap()
@@ -416,7 +410,7 @@ impl Model {
// I don't _think_ serializing to JsValue can't fail // I don't _think_ serializing to JsValue can't fail
// FIXME: Remove this clippy directive // FIXME: Remove this clippy directive
#[wasm_bindgen(js_name = "getSelectedView", unchecked_return_type = "SelectedView")] #[wasm_bindgen(js_name = "getSelectedView")]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
pub fn get_selected_view(&self) -> JsValue { pub fn get_selected_view(&self) -> JsValue {
serde_wasm_bindgen::to_value(&self.model.get_selected_view()).unwrap() serde_wasm_bindgen::to_value(&self.model.get_selected_view()).unwrap()
@@ -475,11 +469,7 @@ impl Model {
} }
#[wasm_bindgen(js_name = "autoFillRows")] #[wasm_bindgen(js_name = "autoFillRows")]
pub fn auto_fill_rows( pub fn auto_fill_rows(&mut self, source_area: JsValue, to_row: i32) -> Result<(), JsError> {
&mut self,
#[wasm_bindgen(unchecked_param_type = "Area")] source_area: JsValue,
to_row: i32,
) -> Result<(), JsError> {
let area: Area = let area: Area =
serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?; serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?;
self.model self.model
@@ -490,7 +480,7 @@ impl Model {
#[wasm_bindgen(js_name = "autoFillColumns")] #[wasm_bindgen(js_name = "autoFillColumns")]
pub fn auto_fill_columns( pub fn auto_fill_columns(
&mut self, &mut self,
#[wasm_bindgen(unchecked_param_type = "Area")] source_area: JsValue, source_area: JsValue,
to_column: i32, to_column: i32,
) -> Result<(), JsError> { ) -> Result<(), JsError> {
let area: Area = let area: Area =
@@ -571,8 +561,8 @@ impl Model {
#[wasm_bindgen(js_name = "setAreaWithBorder")] #[wasm_bindgen(js_name = "setAreaWithBorder")]
pub fn set_area_with_border( pub fn set_area_with_border(
&mut self, &mut self,
#[wasm_bindgen(unchecked_param_type = "Area")] area: JsValue, area: JsValue,
#[wasm_bindgen(unchecked_param_type = "BorderArea")] border_area: JsValue, border_area: JsValue,
) -> Result<(), JsError> { ) -> Result<(), JsError> {
let range: Area = let range: Area =
serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?; serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?;
@@ -599,7 +589,7 @@ impl Model {
self.model.set_name(name); self.model.set_name(name);
} }
#[wasm_bindgen(js_name = "copyToClipboard", unchecked_return_type = "Clipboard")] #[wasm_bindgen(js_name = "copyToClipboard")]
pub fn copy_to_clipboard(&self) -> Result<JsValue, JsError> { pub fn copy_to_clipboard(&self) -> Result<JsValue, JsError> {
let data = self let data = self
.model .model
@@ -613,9 +603,8 @@ impl Model {
pub fn paste_from_clipboard( pub fn paste_from_clipboard(
&mut self, &mut self,
source_sheet: u32, source_sheet: u32,
#[wasm_bindgen(unchecked_param_type = "[number, number, number, number]")]
source_range: JsValue, source_range: JsValue,
#[wasm_bindgen(unchecked_param_type = "ClipboardData")] clipboard: JsValue, clipboard: JsValue,
is_cut: bool, is_cut: bool,
) -> Result<(), JsError> { ) -> Result<(), JsError> {
let source_range: (i32, i32, i32, i32) = let source_range: (i32, i32, i32, i32) =
@@ -628,11 +617,7 @@ impl Model {
} }
#[wasm_bindgen(js_name = "pasteCsvText")] #[wasm_bindgen(js_name = "pasteCsvText")]
pub fn paste_csv_string( pub fn paste_csv_string(&mut self, area: JsValue, csv: &str) -> Result<(), JsError> {
&mut self,
#[wasm_bindgen(unchecked_param_type = "Area")] area: JsValue,
csv: &str,
) -> Result<(), JsError> {
let range: Area = let range: Area =
serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?; serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?;
self.model self.model
@@ -640,10 +625,7 @@ impl Model {
.map_err(|e| to_js_error(e.to_string())) .map_err(|e| to_js_error(e.to_string()))
} }
#[wasm_bindgen( #[wasm_bindgen(js_name = "getDefinedNameList")]
js_name = "getDefinedNameList",
unchecked_return_type = "DefinedName[]"
)]
pub fn get_defined_name_list(&self) -> Result<JsValue, JsError> { pub fn get_defined_name_list(&self) -> Result<JsValue, JsError> {
let data: Vec<DefinedName> = self let data: Vec<DefinedName> = self
.model .model
@@ -690,4 +672,18 @@ impl Model {
.delete_defined_name(name, scope) .delete_defined_name(name, scope)
.map_err(|e| to_js_error(e.to_string())) .map_err(|e| to_js_error(e.to_string()))
} }
#[wasm_bindgen(js_name = "setUsers")]
pub fn set_users(&mut self, users: JsValue) -> Result<(), JsError> {
let users: Vec<WebUser> =
serde_wasm_bindgen::from_value(users).map_err(|e| to_js_error(e.to_string()))?;
self.model.set_users(&users);
Ok(())
}
#[wasm_bindgen(js_name = "getUsers")]
pub fn get_users(&self) -> Result<JsValue, JsError> {
let users = self.model.get_model().workbook.users.clone();
serde_wasm_bindgen::to_value(&users).map_err(|e| to_js_error(e.to_string()))
}
} }

View File

@@ -234,3 +234,10 @@ export interface DefinedName {
scope?: number; scope?: number;
formula: string; formula: string;
} }
export interface WebUser {
id: string;
sheet: number;
row: number;
column: number;
}

View File

@@ -2,7 +2,11 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [], addons: [
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
framework: { framework: {
name: "@storybook/react-vite", name: "@storybook/react-vite",
options: {}, options: {},

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ironcalc/workbook", "name": "@ironcalc/workbook",
"version": "0.3.2", "version": "0.5.5",
"type": "module", "type": "module",
"main": "./dist/ironcalc.js", "main": "./dist/ironcalc.js",
"module": "./dist/ironcalc.js", "module": "./dist/ironcalc.js",
@@ -17,27 +17,32 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@ironcalc/wasm": "file:../../bindings/wasm/pkg", "@ironcalc/wasm": "0.5.3",
"@mui/material": "^7.1.1", "@mui/material": "^6.4",
"@mui/system": "^7.1.1", "@mui/system": "^6.4",
"i18next": "^25.2.1", "i18next": "^23.11.1",
"lucide-react": "^0.513.0", "lucide-react": "^0.473.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-i18next": "^15.5.2" "react-i18next": "^15.4.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@storybook/react": "^9.0.5", "@chromatic-com/storybook": "^3.2.4",
"@storybook/react-vite": "^9.0.5", "@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-interactions": "^8.6.0",
"@storybook/blocks": "^8.6.0",
"@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.6.0",
"@storybook/test": "^8.6.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"react": "^19.1.0", "react": "^19.0.0",
"react-dom": "^19.1.0", "react-dom": "^19.0.0",
"storybook": "^9.0.5", "storybook": "^8.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.8.3", "typescript": "~5.6.2",
"vite": "^6.3.5", "vite": "^6.2.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vitest": "^3.2.2" "vitest": "^3.0.7"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0", "@types/react": "^18.0.0 || ^19.0.0",

View File

@@ -1,6 +1,6 @@
import "./index.css"; import "./index.css";
import type { Model } from "@ironcalc/wasm"; import type { Model } from "@ironcalc/wasm";
import { ThemeProvider } from "@mui/material"; import ThemeProvider from "@mui/material/styles/ThemeProvider";
import Workbook from "./components/Workbook/Workbook.tsx"; import Workbook from "./components/Workbook/Workbook.tsx";
import { WorkbookState } from "./components/workbookState.ts"; import { WorkbookState } from "./components/workbookState.ts";
import { theme } from "./theme.ts"; import { theme } from "./theme.ts";

View File

@@ -272,7 +272,6 @@ const ColorGridCol = styled.div`
const ColorSwatch = styled.button<{ $color: string }>` const ColorSwatch = styled.button<{ $color: string }>`
width: 16px; width: 16px;
height: 16px; height: 16px;
padding: 0px;
${({ $color }): string => { ${({ $color }): string => {
if ($color.toUpperCase() === "#FFFFFF") { if ($color.toUpperCase() === "#FFFFFF") {
return `border: 1px solid ${theme.palette.grey["300"]};`; return `border: 1px solid ${theme.palette.grey["300"]};`;

View File

@@ -114,7 +114,7 @@ const Editor = (options: EditorOptions) => {
} }
} }
if (type === cell.focus) { if (type === cell.focus) {
textareaRef.current?.focus({ preventScroll: true }); textareaRef.current?.focus();
} }
}); });

View File

@@ -48,7 +48,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
); );
const focusWorkbook = useCallback(() => { const focusWorkbook = useCallback(() => {
if (rootRef.current) { if (rootRef.current) {
rootRef.current.focus({ preventScroll: true }); rootRef.current.focus();
// HACK: We need to select something inside the root for onCopy to work // HACK: We need to select something inside the root for onCopy to work
const selection = window.getSelection(); const selection = window.getSelection();
if (selection) { if (selection) {

View File

@@ -24,7 +24,7 @@ import {
TOOLBAR_HEIGHT, TOOLBAR_HEIGHT,
} from "../constants"; } from "../constants";
import type { Cell } from "../types"; import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState"; import { AreaType, type WorkbookState } from "../workbookState";
import CellContextMenu from "./CellContextMenu"; import CellContextMenu from "./CellContextMenu";
import usePointer from "./usePointer"; import usePointer from "./usePointer";
@@ -59,6 +59,7 @@ const Worksheet = forwardRef(
const spacerElement = useRef<HTMLDivElement>(null); const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null); const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null); const areaOutline = useRef<HTMLDivElement>(null);
const cellOutlineHandle = useRef<HTMLDivElement>(null);
const extendToOutline = useRef<HTMLDivElement>(null); const extendToOutline = useRef<HTMLDivElement>(null);
const columnResizeGuide = useRef<HTMLDivElement>(null); const columnResizeGuide = useRef<HTMLDivElement>(null);
const rowResizeGuide = useRef<HTMLDivElement>(null); const rowResizeGuide = useRef<HTMLDivElement>(null);
@@ -84,6 +85,7 @@ const Worksheet = forwardRef(
const worksheetRef = worksheetElement.current; const worksheetRef = worksheetElement.current;
const outline = cellOutline.current; const outline = cellOutline.current;
const handle = cellOutlineHandle.current;
const area = areaOutline.current; const area = areaOutline.current;
const extendTo = extendToOutline.current; const extendTo = extendToOutline.current;
const editor = editorElement.current; const editor = editorElement.current;
@@ -95,6 +97,7 @@ const Worksheet = forwardRef(
!columnHeadersRef || !columnHeadersRef ||
!worksheetRef || !worksheetRef ||
!outline || !outline ||
!handle ||
!area || !area ||
!extendTo || !extendTo ||
!scrollElement.current || !scrollElement.current ||
@@ -115,6 +118,7 @@ const Worksheet = forwardRef(
rowGuide: rowGuideRef, rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef, columnHeaders: columnHeadersRef,
cellOutline: outline, cellOutline: outline,
cellOutlineHandle: handle,
areaOutline: area, areaOutline: area,
extendToOutline: extendTo, extendToOutline: extendTo,
editor: editor, editor: editor,
@@ -187,7 +191,8 @@ const Worksheet = forwardRef(
worksheetCanvas.current = canvas; worksheetCanvas.current = canvas;
}); });
const { onPointerMove, onPointerDown, onPointerUp } = usePointer({ const { onPointerMove, onPointerDown, onPointerHandleDown, onPointerUp } =
usePointer({
model, model,
workbookState, workbookState,
refresh, refresh,
@@ -251,6 +256,134 @@ const Worksheet = forwardRef(
} }
refresh(); refresh();
}, },
onExtendToCell: (cell) => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
// We are either extending by rows or by columns
// And we could be doing it in the positive direction (downwards or right)
// or the negative direction (upwards or left)
if (
row > rowEnd &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < row - rowEnd) ||
(column > columnEnd && column - columnEnd < row - rowEnd))
) {
// rows downwards
const area = {
type: AreaType.rowsDown,
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
row < rowStart &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < rowStart - row) ||
(column > columnEnd && column - columnEnd < rowStart - row))
) {
// rows upwards
const area = {
type: AreaType.rowsUp,
rowStart: row,
rowEnd: rowStart,
columnStart,
columnEnd,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
column > columnEnd &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < column - columnEnd) ||
(row > rowEnd && row - rowEnd < column - columnEnd))
) {
// columns right
const area = {
type: AreaType.columnsRight,
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
column < columnStart &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < columnStart - column) ||
(row > rowEnd && row - rowEnd < columnStart - column))
) {
// columns left
const area = {
type: AreaType.columnsLeft,
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
}
},
onExtendToEnd: () => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { sheet, range } = model.getSelectedView();
const extendedArea = workbookState.getExtendToArea();
if (!extendedArea) {
return;
}
const rowStart = Math.min(range[0], range[2]);
const height = Math.abs(range[2] - range[0]) + 1;
const width = Math.abs(range[3] - range[1]) + 1;
const columnStart = Math.min(range[1], range[3]);
const area = {
sheet,
row: rowStart,
column: columnStart,
width,
height,
};
switch (extendedArea.type) {
case AreaType.rowsDown:
model.autoFillRows(area, extendedArea.rowEnd);
break;
case AreaType.rowsUp: {
model.autoFillRows(area, extendedArea.rowStart);
break;
}
case AreaType.columnsRight: {
model.autoFillColumns(area, extendedArea.columnEnd);
break;
}
case AreaType.columnsLeft: {
model.autoFillColumns(area, extendedArea.columnStart);
break;
}
}
model.setSelectedRange(
Math.min(rowStart, extendedArea.rowStart),
Math.min(columnStart, extendedArea.columnStart),
Math.max(rowStart + height - 1, extendedArea.rowEnd),
Math.max(columnStart + width - 1, extendedArea.columnEnd),
);
workbookState.clearExtendToArea();
canvas.renderSheet();
},
canvasElement, canvasElement,
worksheetElement, worksheetElement,
worksheetCanvas, worksheetCanvas,
@@ -330,6 +463,10 @@ const Worksheet = forwardRef(
</EditorWrapper> </EditorWrapper>
<AreaOutline ref={areaOutline} /> <AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} /> <ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
ref={cellOutlineHandle}
onPointerDown={onPointerHandleDown}
/>
<ColumnResizeGuide ref={columnResizeGuide} /> <ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} /> <RowResizeGuide ref={rowResizeGuide} />
<ColumnHeaders ref={columnHeaders} /> <ColumnHeaders ref={columnHeaders} />
@@ -503,6 +640,15 @@ const CellOutline = styled("div")`
display: flex; display: flex;
`; `;
const CellOutlineHandle = styled("div")`
position: absolute;
width: 5px;
height: 5px;
background: ${outlineColor};
cursor: crosshair;
border-radius: 1px;
`;
const ExtendToOutline = styled("div")` const ExtendToOutline = styled("div")`
position: absolute; position: absolute;
border: 1px dashed ${outlineColor}; border: 1px dashed ${outlineColor};

View File

@@ -20,6 +20,8 @@ interface PointerSettings {
onAllSheetSelected: () => void; onAllSheetSelected: () => void;
onAreaSelecting: (cell: Cell) => void; onAreaSelecting: (cell: Cell) => void;
onAreaSelected: () => void; onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void;
onExtendToEnd: () => void;
model: Model; model: Model;
workbookState: WorkbookState; workbookState: WorkbookState;
refresh: () => void; refresh: () => void;
@@ -29,10 +31,12 @@ interface PointerEvents {
onPointerDown: (event: PointerEvent) => void; onPointerDown: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void; onPointerMove: (event: PointerEvent) => void;
onPointerUp: (event: PointerEvent) => void; onPointerUp: (event: PointerEvent) => void;
onPointerHandleDown: (event: PointerEvent) => void;
} }
const usePointer = (options: PointerSettings): PointerEvents => { const usePointer = (options: PointerSettings): PointerEvents => {
const isSelecting = useRef(false); const isSelecting = useRef(false);
const isExtending = useRef(false);
const isInsertingRef = useRef(false); const isInsertingRef = useRef(false);
const onPointerMove = useCallback( const onPointerMove = useCallback(
@@ -43,7 +47,9 @@ const usePointer = (options: PointerSettings): PointerEvents => {
return; return;
} }
if (!(isSelecting.current || isInsertingRef.current)) { if (
!(isSelecting.current || isExtending.current || isInsertingRef.current)
) {
return; return;
} }
const { canvasElement, model, worksheetCanvas } = options; const { canvasElement, model, worksheetCanvas } = options;
@@ -64,6 +70,8 @@ const usePointer = (options: PointerSettings): PointerEvents => {
if (isSelecting.current) { if (isSelecting.current) {
options.onAreaSelecting(cell); options.onAreaSelecting(cell);
} else if (isExtending.current) {
options.onExtendToCell(cell);
} else if (isInsertingRef.current) { } else if (isInsertingRef.current) {
const { refresh, workbookState } = options; const { refresh, workbookState } = options;
const editingCell = workbookState.getEditingCell(); const editingCell = workbookState.getEditingCell();
@@ -95,6 +103,11 @@ const usePointer = (options: PointerSettings): PointerEvents => {
isSelecting.current = false; isSelecting.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId); worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onAreaSelected(); options.onAreaSelected();
} else if (isExtending.current) {
const { worksheetElement } = options;
isExtending.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onExtendToEnd();
} else if (isInsertingRef.current) { } else if (isInsertingRef.current) {
const { worksheetElement } = options; const { worksheetElement } = options;
isInsertingRef.current = false; isInsertingRef.current = false;
@@ -107,14 +120,10 @@ const usePointer = (options: PointerSettings): PointerEvents => {
const onPointerDown = useCallback( const onPointerDown = useCallback(
(event: PointerEvent) => { (event: PointerEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.className === "column-resize-handle") { if (target !== null && target.className === "column-resize-handle") {
// we are resizing a column // we are resizing a column
return; return;
} }
if (target.className.includes("ironcalc-cell-handle")) {
// we are extending values
return;
}
let x = event.clientX; let x = event.clientX;
let y = event.clientY; let y = event.clientY;
const { const {
@@ -242,10 +251,26 @@ const usePointer = (options: PointerSettings): PointerEvents => {
[options], [options],
); );
const onPointerHandleDown = useCallback(
(event: PointerEvent) => {
const worksheetWrapper = options.worksheetElement.current;
// Silence the linter
if (!worksheetWrapper) {
return;
}
isExtending.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
},
[options],
);
return { return {
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onPointerHandleDown,
}; };
}; };

View File

@@ -1,211 +0,0 @@
import { AreaType } from "../workbookState";
import { LAST_COLUMN, LAST_ROW, outlineColor } from "./constants";
import type WorksheetCanvas from "./worksheetCanvas";
export function attachOutlineHandle(
worksheet: WorksheetCanvas,
): HTMLDivElement {
// There is *always* a parent
const parent = worksheet.canvas.parentElement as HTMLDivElement;
// Remove any existing cell outline handles
for (const handle of parent.querySelectorAll(".ironcalc-cell-handle")) {
handle.remove();
}
// Create a new cell outline handle
const cellOutlineHandle = document.createElement("div");
cellOutlineHandle.className = "ironcalc-cell-handle";
parent.appendChild(cellOutlineHandle);
worksheet.cellOutlineHandle = cellOutlineHandle;
Object.assign(cellOutlineHandle.style, {
position: "absolute",
width: "5px",
height: "5px",
background: outlineColor,
cursor: "crosshair",
borderRadius: "1px",
});
// cell handle events
const resizeHandleMove = (event: MouseEvent): void => {
const canvasRect = worksheet.canvas.getBoundingClientRect();
const x = event.clientX - canvasRect.x;
const y = event.clientY - canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) {
return;
}
const { row, column } = cell;
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = worksheet.model.getSelectedView();
// We are either extending by rows or by columns
// And we could be doing it in the positive direction (downwards or right)
// or the negative direction (upwards or left)
if (
row > rowEnd &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < row - rowEnd) ||
(column > columnEnd && column - columnEnd < row - rowEnd))
) {
// rows downwards
const area = {
type: AreaType.rowsDown,
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
row < rowStart &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < rowStart - row) ||
(column > columnEnd && column - columnEnd < rowStart - row))
) {
// rows upwards
const area = {
type: AreaType.rowsUp,
rowStart: row,
rowEnd: rowStart,
columnStart,
columnEnd,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
column > columnEnd &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < column - columnEnd) ||
(row > rowEnd && row - rowEnd < column - columnEnd))
) {
// columns right
const area = {
type: AreaType.columnsRight,
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
column < columnStart &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < columnStart - column) ||
(row > rowEnd && row - rowEnd < columnStart - column))
) {
// columns left
const area = {
type: AreaType.columnsLeft,
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
}
};
const resizeHandleUp = (_event: MouseEvent): void => {
document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("pointerup", resizeHandleUp);
const { sheet, range } = worksheet.model.getSelectedView();
const extendedArea = worksheet.workbookState.getExtendToArea();
if (!extendedArea) {
return;
}
const rowStart = Math.min(range[0], range[2]);
const height = Math.abs(range[2] - range[0]) + 1;
const width = Math.abs(range[3] - range[1]) + 1;
const columnStart = Math.min(range[1], range[3]);
const area = {
sheet,
row: rowStart,
column: columnStart,
width,
height,
};
switch (extendedArea.type) {
case AreaType.rowsDown:
worksheet.model.autoFillRows(area, extendedArea.rowEnd);
break;
case AreaType.rowsUp: {
worksheet.model.autoFillRows(area, extendedArea.rowStart);
break;
}
case AreaType.columnsRight: {
worksheet.model.autoFillColumns(area, extendedArea.columnEnd);
break;
}
case AreaType.columnsLeft: {
worksheet.model.autoFillColumns(area, extendedArea.columnStart);
break;
}
}
worksheet.model.setSelectedRange(
Math.min(rowStart, extendedArea.rowStart),
Math.min(columnStart, extendedArea.columnStart),
Math.max(rowStart + height - 1, extendedArea.rowEnd),
Math.max(columnStart + width - 1, extendedArea.columnEnd),
);
worksheet.workbookState.clearExtendToArea();
worksheet.renderSheet();
};
cellOutlineHandle.addEventListener("pointerdown", () => {
document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("pointerup", resizeHandleUp);
});
cellOutlineHandle.addEventListener("dblclick", (event) => {
// On double-click, we will auto-fill the rows below the selected cell
const [sheet, row, column] = worksheet.model.getSelectedCell();
let lastUsedRow = row + 1;
let testColumn = column - 1;
// The "test column" is the column to the left of the selected cell or the next column if the left one is empty
if (
testColumn < 1 ||
worksheet.model.getFormattedCellValue(sheet, row, column - 1) === ""
) {
testColumn = column + 1;
if (
testColumn > LAST_COLUMN ||
worksheet.model.getFormattedCellValue(sheet, row, testColumn) === ""
) {
return;
}
}
// Find the last used row in the "test column"
for (let r = row + 1; r <= LAST_ROW; r += 1) {
if (worksheet.model.getFormattedCellValue(sheet, r, testColumn) === "") {
break;
}
lastUsedRow = r;
}
const area = {
sheet,
row: row,
column: column,
width: 1,
height: 1,
};
worksheet.model.autoFillRows(area, lastUsedRow);
event.stopPropagation();
worksheet.renderSheet();
});
return cellOutlineHandle;
}

View File

@@ -1,63 +0,0 @@
// Get a 10% transparency of an hex color
export function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
/**
* Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping
* based on the specified canvas context, maximum width, and horizontal padding.
*
* - First, the text is split by newline characters so that explicit newlines are respected.
* - If wrapping is enabled, each line is further split into words and measured against the
* available width. Whenever adding an extra word would exceed
* this limit, a new line is started.
*
* @param text The text to split into lines.
* @param wrapText Whether to apply word-wrapping or just return text split by newlines.
* @param context The `CanvasRenderingContext2D` used for measuring text width.
* @param width The maximum width for each line.
* @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled.
*/
export function computeWrappedLines(
text: string,
wrapText: boolean,
context: CanvasRenderingContext2D,
width: number,
): string[] {
// Split the text into lines
const rawLines = text.split("\n");
if (!wrapText) {
// If there is no wrapping, return the raw lines
return rawLines;
}
const wrappedLines = [];
for (const line of rawLines) {
const words = line.split(" ");
let currentLine = words[0];
for (let i = 1; i < words.length; i += 1) {
const word = words[i];
const testLine = `${currentLine} ${word}`;
const textWidth = context.measureText(testLine).width;
if (textWidth < width) {
currentLine = testLine;
} else {
wrappedLines.push(currentLine);
currentLine = word;
}
}
wrappedLines.push(currentLine);
}
return wrappedLines;
}

View File

@@ -18,8 +18,13 @@ import {
headerTextColor, headerTextColor,
outlineColor, outlineColor,
} from "./constants"; } from "./constants";
import { attachOutlineHandle } from "./outlineHandle";
import { computeWrappedLines, hexToRGBA10Percent } from "./util"; export interface UserSelection {
userId: string;
color: string;
selection: [number, number, number, number, number]; // [sheet, rowStart, columnStart, rowEnd, columnEnd]
div: HTMLDivElement;
}
export interface CanvasSettings { export interface CanvasSettings {
model: Model; model: Model;
@@ -30,6 +35,7 @@ export interface CanvasSettings {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
cellOutline: HTMLDivElement; cellOutline: HTMLDivElement;
areaOutline: HTMLDivElement; areaOutline: HTMLDivElement;
cellOutlineHandle: HTMLDivElement;
extendToOutline: HTMLDivElement; extendToOutline: HTMLDivElement;
columnGuide: HTMLDivElement; columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement; rowGuide: HTMLDivElement;
@@ -54,6 +60,70 @@ export const defaultCellFontFamily = fonts.regular;
export const headerFontFamily = fonts.regular; export const headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3; export const frozenSeparatorWidth = 3;
// Get a 10% transparency of an hex color
function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
/**
* Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping
* based on the specified canvas context, maximum width, and horizontal padding.
*
* - First, the text is split by newline characters so that explicit newlines are respected.
* - If wrapping is enabled, each line is further split into words and measured against the
* available width. Whenever adding an extra word would exceed
* this limit, a new line is started.
*
* @param text The text to split into lines.
* @param wrapText Whether to apply word-wrapping or just return text split by newlines.
* @param context The `CanvasRenderingContext2D` used for measuring text width.
* @param width The maximum width for each line.
* @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled.
*/
function computeWrappedLines(
text: string,
wrapText: boolean,
context: CanvasRenderingContext2D,
width: number,
): string[] {
// Split the text into lines
const rawLines = text.split("\n");
if (!wrapText) {
// If there is no wrapping, return the raw lines
return rawLines;
}
const wrappedLines = [];
for (const line of rawLines) {
const words = line.split(" ");
let currentLine = words[0];
for (let i = 1; i < words.length; i += 1) {
const word = words[i];
const testLine = `${currentLine} ${word}`;
const textWidth = context.measureText(testLine).width;
if (textWidth < width) {
currentLine = testLine;
} else {
wrappedLines.push(currentLine);
currentLine = word;
}
}
wrappedLines.push(currentLine);
}
return wrappedLines;
}
export default class WorksheetCanvas { export default class WorksheetCanvas {
sheetWidth: number; sheetWidth: number;
@@ -106,6 +176,7 @@ export default class WorksheetCanvas {
this.refresh = options.refresh; this.refresh = options.refresh;
this.cellOutline = options.elements.cellOutline; this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle;
this.areaOutline = options.elements.areaOutline; this.areaOutline = options.elements.areaOutline;
this.extendToOutline = options.elements.extendToOutline; this.extendToOutline = options.elements.extendToOutline;
this.rowGuide = options.elements.rowGuide; this.rowGuide = options.elements.rowGuide;
@@ -115,7 +186,6 @@ export default class WorksheetCanvas {
this.onColumnWidthChanges = options.onColumnWidthChanges; this.onColumnWidthChanges = options.onColumnWidthChanges;
this.onRowHeightChanges = options.onRowHeightChanges; this.onRowHeightChanges = options.onRowHeightChanges;
this.resetHeaders(); this.resetHeaders();
this.cellOutlineHandle = attachOutlineHandle(this);
} }
setScrollPosition(scrollPosition: { left: number; top: number }): void { setScrollPosition(scrollPosition: { left: number; top: number }): void {
@@ -1181,6 +1251,33 @@ export default class WorksheetCanvas {
editor.style.height = `${height - 1}px`; editor.style.height = `${height - 1}px`;
} }
private drawUsersSelection(): void {
const users = this.model.getUsers();
for (const handle of document.querySelectorAll(
".user-selection-ironcalc",
))
handle.remove();
users.forEach((user, index) => {
const { sheet, row, column } = user;
if (sheet !== this.model.getSelectedSheet()) {
return;
}
const [x, y] = this.getCoordinatesByCell(row, column);
const width = this.getColumnWidth(sheet, column);
const height = this.getRowHeight(sheet, row);
const div = document.createElement("div");
const color = getColor(index + 1);
div.className = "user-selection-ironcalc";
div.style.left = `${x}px`;
div.style.top = `${y}px`;
div.style.width = `${width}px`;
div.style.height = `${height}px`;
div.style.border = `1px solid ${color}`;
div.style.position = "absolute";
this.canvas.parentElement?.appendChild(div);
});
}
private drawCellOutline(): void { private drawCellOutline(): void {
const { cellOutline, areaOutline, cellOutlineHandle } = this; const { cellOutline, areaOutline, cellOutlineHandle } = this;
if (this.workbookState.getEditingCell()) { if (this.workbookState.getEditingCell()) {
@@ -1532,6 +1629,7 @@ export default class WorksheetCanvas {
context.stroke(); context.stroke();
this.drawCellOutline(); this.drawCellOutline();
this.drawUsersSelection();
this.drawCellEditor(); this.drawCellEditor();
this.drawExtendToArea(); this.drawExtendToArea();
this.drawActiveRanges(topLeftCell, bottomRightCell); this.drawActiveRanges(topLeftCell, bottomRightCell);

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,19 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@ironcalc/workbook": "file:../../IronCalc/", "@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^7.1.1", "@mui/material": "^6.4",
"lucide-react": "^0.513.0", "lucide-react": "^0.473.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.1.0", "react": "^19.0.0",
"react-dom": "^19.1.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@types/react": "^19.1.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.8.3", "typescript": "~5.6.2",
"vite": "^6.3.5", "vite": "^6.0.5",
"vite-plugin-svgr": "^4.2.0" "vite-plugin-svgr": "^4.2.0"
} }
} }

View File

@@ -1,8 +1,7 @@
import "./App.css"; import "./App.css";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FileBar } from "./components/FileBar"; import { FileBar } from "./components/FileBar";
import LeftDrawer from "./components/LeftDrawer/LeftDrawer";
import { import {
get_documentation_model, get_documentation_model,
get_model, get_model,
@@ -11,8 +10,6 @@ import {
import { import {
createNewModel, createNewModel,
deleteSelectedModel, deleteSelectedModel,
getModelsMetadata,
getSelectedUuid,
loadModelFromStorageOrCreate, loadModelFromStorageOrCreate,
saveModelToStorage, saveModelToStorage,
saveSelectedModelInStorage, saveSelectedModelInStorage,
@@ -24,14 +21,6 @@ import { IronCalc, IronCalcIcon, Model, init } from "@ironcalc/workbook";
function App() { function App() {
const [model, setModel] = useState<Model | null>(null); const [model, setModel] = useState<Model | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [modelsMetadata, setModelsMetadata] = useState(getModelsMetadata());
const [selectedUuid, setSelectedUuid] = useState(getSelectedUuid());
const refreshModelsData = useCallback(() => {
setModelsMetadata(getModelsMetadata());
setSelectedUuid(getSelectedUuid());
}, []);
useEffect(() => { useEffect(() => {
async function start() { async function start() {
@@ -49,7 +38,6 @@ function App() {
const importedModel = Model.from_bytes(model_bytes); const importedModel = Model.from_bytes(model_bytes);
localStorage.removeItem("selected"); localStorage.removeItem("selected");
setModel(importedModel); setModel(importedModel);
refreshModelsData();
} catch (e) { } catch (e) {
alert("Model not found, or failed to load"); alert("Model not found, or failed to load");
} }
@@ -59,7 +47,6 @@ function App() {
const importedModel = Model.from_bytes(model_bytes); const importedModel = Model.from_bytes(model_bytes);
localStorage.removeItem("selected"); localStorage.removeItem("selected");
setModel(importedModel); setModel(importedModel);
refreshModelsData();
} catch (e) { } catch (e) {
alert("Example file not found, or failed to load"); alert("Example file not found, or failed to load");
} }
@@ -67,11 +54,10 @@ function App() {
// try to load from local storage // try to load from local storage
const newModel = loadModelFromStorageOrCreate(); const newModel = loadModelFromStorageOrCreate();
setModel(newModel); setModel(newModel);
refreshModelsData();
} }
} }
start(); start();
}, [refreshModelsData]); }, []);
if (!model) { if (!model) {
return ( return (
@@ -93,80 +79,48 @@ function App() {
// We could use context for model, but the problem is that it should initialized to null. // We could use context for model, but the problem is that it should initialized to null.
// Passing the property down makes sure it is always defined. // Passing the property down makes sure it is always defined.
// Handlers for model changes that also update our models state
const handleNewModel = () => {
const newModel = createNewModel();
setModel(newModel);
refreshModelsData();
};
const handleSetModel = (uuid: string) => {
const newModel = selectModelFromStorage(uuid);
if (newModel) {
setModel(newModel);
refreshModelsData();
}
};
const handleDeleteModel = () => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
refreshModelsData();
}
};
return ( return (
<AppContainer> <Wrapper>
<LeftDrawer
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
newModel={handleNewModel}
setModel={handleSetModel}
models={modelsMetadata}
selectedUuid={selectedUuid}
setDeleteDialogOpen={() => {}}
/>
<MainContent isDrawerOpen={isDrawerOpen}>
<FileBar <FileBar
model={model} model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => { onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
const blob = await uploadFile(arrayBuffer, fileName); const blob = await uploadFile(arrayBuffer, fileName);
const bytes = new Uint8Array(await blob.arrayBuffer()); const bytes = new Uint8Array(await blob.arrayBuffer());
const newModel = Model.from_bytes(bytes); const newModel = Model.from_bytes(bytes);
saveModelToStorage(newModel); saveModelToStorage(newModel);
setModel(newModel); setModel(newModel);
refreshModelsData();
}} }}
newModel={handleNewModel} newModel={() => {
setModel={handleSetModel} setModel(createNewModel());
onDelete={handleDeleteModel} }}
isDrawerOpen={isDrawerOpen} setModel={(uuid: string) => {
setIsDrawerOpen={setIsDrawerOpen} const newModel = selectModelFromStorage(uuid);
refreshModelsData={refreshModelsData} if (newModel) {
setModel(newModel);
}
}}
onDelete={() => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
}
}}
/> />
<IronCalc model={model} /> <IronCalc model={model} />
</MainContent> </Wrapper>
</AppContainer>
); );
} }
const AppContainer = styled("div")` const Wrapper = styled("div")`
display: flex; margin: 0px;
padding: 0px;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
overflow: hidden;
`;
const MainContent = styled("div")<{ isDrawerOpen: boolean }>`
margin-left: ${({ isDrawerOpen }) => (isDrawerOpen ? "0px" : "-264px")};
transition: margin-left 0.3s ease;
width: ${({ isDrawerOpen }) =>
isDrawerOpen ? "calc(100% - 264px)" : "100%"};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute;
`; `;
const Loading = styled("div")` const Loading = styled("div")`

View File

@@ -1,9 +1,8 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import type { Model } from "@ironcalc/workbook"; import type { Model } from "@ironcalc/workbook";
import { Button, IconButton } from "@mui/material"; import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { DesktopMenu, MobileMenu } from "./FileMenu"; import { FileMenu } from "./FileMenu";
import { ShareButton } from "./ShareButton"; import { ShareButton } from "./ShareButton";
import ShareWorkbookDialog from "./ShareWorkbookDialog"; import ShareWorkbookDialog from "./ShareWorkbookDialog";
import { WorkbookTitle } from "./WorkbookTitle"; import { WorkbookTitle } from "./WorkbookTitle";
@@ -30,15 +29,11 @@ export function FileBar(properties: {
setModel: (key: string) => void; setModel: (key: string) => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void; onDelete: () => void;
isDrawerOpen: boolean;
setIsDrawerOpen: (open: boolean) => void;
refreshModelsData: () => void; // Add this new prop
}) { }) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const spacerRef = useRef<HTMLDivElement>(null); const spacerRef = useRef<HTMLDivElement>(null);
const [maxTitleWidth, setMaxTitleWidth] = useState(0); const [maxTitleWidth, setMaxTitleWidth] = useState(0);
const width = useWindowWidth(); const width = useWindowWidth();
const fileButtonRef = useRef<HTMLButtonElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: We need to update the maxTitleWidth when the width changes // biome-ignore lint/correctness/useExhaustiveDependencies: We need to update the maxTitleWidth when the width changes
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -49,54 +44,34 @@ export function FileBar(properties: {
} }
}, [width]); }, [width]);
// Common handler functions for both menu types return (
const handleDownload = async () => { <FileBarWrapper>
<StyledDesktopLogo />
<StyledIronCalcIcon />
<Divider />
<FileMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={async () => {
const model = properties.model; const model = properties.model;
const bytes = model.toBytes(); const bytes = model.toBytes();
const fileName = model.getName(); const fileName = model.getName();
await downloadModel(bytes, fileName); await downloadModel(bytes, fileName);
}; }}
return (
<FileBarWrapper>
<DrawerButton
onClick={() => properties.setIsDrawerOpen(!properties.isDrawerOpen)}
disableRipple
>
{properties.isDrawerOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</DrawerButton>
<DesktopButtonsWrapper>
<DesktopMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={handleDownload}
onDelete={properties.onDelete} onDelete={properties.onDelete}
/> />
<FileBarButton <HelpButton
disableRipple
onClick={() => window.open("https://docs.ironcalc.com", "_blank")} onClick={() => window.open("https://docs.ironcalc.com", "_blank")}
> >
Help Help
</FileBarButton> </HelpButton>
</DesktopButtonsWrapper>
<MobileButtonsWrapper>
<MobileMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={handleDownload}
onDelete={properties.onDelete}
/>
</MobileButtonsWrapper>
<Spacer ref={spacerRef} />
<WorkbookTitleWrapper> <WorkbookTitleWrapper>
<WorkbookTitle <WorkbookTitle
name={properties.model.getName()} name={properties.model.getName()}
onNameChange={(name) => { onNameChange={(name) => {
properties.model.setName(name); properties.model.setName(name);
updateNameSelectedWorkbook(properties.model, name); updateNameSelectedWorkbook(properties.model, name);
properties.refreshModelsData();
}} }}
maxWidth={maxTitleWidth} maxWidth={maxTitleWidth}
/> />
@@ -116,8 +91,12 @@ export function FileBar(properties: {
); );
} }
// We want the workbook title to be exactly an the center of the page,
// so we need an absolute position
const WorkbookTitleWrapper = styled("div")` const WorkbookTitleWrapper = styled("div")`
position: relative; position: absolute;
left: 50%;
transform: translateX(-50%);
`; `;
// The "Spacer" component occupies as much space as possible between the menu and the share button // The "Spacer" component occupies as much space as possible between the menu and the share button
@@ -125,83 +104,51 @@ const Spacer = styled("div")`
flex-grow: 1; flex-grow: 1;
`; `;
const DrawerButton = styled(IconButton)` const StyledDesktopLogo = styled(IronCalcLogo)`
margin-left: 8px; width: 120px;
height: 32px; margin-left: 12px;
width: 32px; @media (max-width: 769px) {
display: none;
}
`;
const StyledIronCalcIcon = styled(IronCalcIcon)`
width: 36px;
margin-left: 10px;
@media (min-width: 769px) {
display: none;
}
`;
const HelpButton = styled("div")`
display: flex;
align-items: center;
font-size: 12px;
font-family: Inter;
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
svg { cursor: pointer;
stroke-width: 2px;
stroke: #757575;
width: 16px;
height: 16px;
}
&:hover { &:hover {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
&:active { `;
background-color: #e0e0e0;
} const Divider = styled("div")`
margin: 0px 8px 0px 16px;
height: 12px;
border-left: 1px solid #e0e0e0;
`; `;
// The container must be relative positioned so we can position the title absolutely // The container must be relative positioned so we can position the title absolutely
const FileBarWrapper = styled("div")` const FileBarWrapper = styled("div")`
position: relative; position: relative;
height: 60px; height: 60px;
min-height: 60px;
width: 100%; width: 100%;
background: #fff; background: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
justify-content: space-between; justify-content: space-between;
box-sizing: border-box;
`;
const DesktopButtonsWrapper = styled("div")`
display: flex;
gap: 4px;
margin-left: 8px;
@media (max-width: 600px) {
display: none;
}
`;
const MobileButtonsWrapper = styled("div")`
display: flex;
gap: 4px;
@media (min-width: 601px) {
display: none;
}
@media (max-width: 600px) {
display: flex;
}
`;
const FileBarButtonContainer = styled("div")`
position: relative;
display: inline-block;
`;
const FileBarButton = styled(Button)`
display: flex;
flex-direction: row;
align-items: center;
font-size: 12px;
height: 32px;
width: auto;
padding: 4px 8px;
font-weight: 400;
min-width: 0px;
text-transform: capitalize;
color: #333333;
&:hover {
background-color: #f2f2f2;
}
&:active {
background-color: #e0e0e0;
}
`; `;
const DialogContainer = styled("div")` const DialogContainer = styled("div")`

View File

@@ -1,165 +1,76 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Button, IconButton, Menu, MenuItem, Modal } from "@mui/material"; import { Menu, MenuItem, Modal } from "@mui/material";
import { import { Check, FileDown, FileUp, Plus, Trash2 } from "lucide-react";
ChevronRight,
EllipsisVertical,
FileDown,
FileUp,
Plus,
Trash2,
} from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import DeleteWorkbookDialog from "./DeleteWorkbookDialog"; import DeleteWorkbookDialog from "./DeleteWorkbookDialog";
import UploadFileDialog from "./UploadFileDialog"; import UploadFileDialog from "./UploadFileDialog";
import { getModelsMetadata, getSelectedUuid } from "./storage"; import { getModelsMetadata, getSelectedUuid } from "./storage";
export function DesktopMenu(props: {
newModel: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
}) {
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(
null as unknown as HTMLButtonElement,
);
return (
<>
<FileBarButton
onClick={(): void => setFileMenuOpen(!isFileMenuOpen)}
ref={anchorElement}
disableRipple
isOpen={isFileMenuOpen}
>
File
</FileBarButton>
<FileMenu
newModel={props.newModel}
setModel={props.setModel}
onDownload={props.onDownload}
onModelUpload={props.onModelUpload}
onDelete={props.onDelete}
isFileMenuOpen={isFileMenuOpen}
setFileMenuOpen={setFileMenuOpen}
setMobileMenuOpen={() => {}}
anchorElement={anchorElement}
/>
</>
);
}
export function MobileMenu(props: {
newModel: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
}) {
const [isMobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isFileMenuOpen, setFileMenuOpen] = useState(false);
const anchorElement = useRef<HTMLButtonElement>(
null as unknown as HTMLButtonElement,
);
const [fileMenuAnchorEl, setFileMenuAnchorEl] = useState<HTMLElement | null>(
null,
);
return (
<>
<MenuButton
onClick={(): void => setMobileMenuOpen(true)}
ref={anchorElement}
disableRipple
>
<EllipsisVertical />
</MenuButton>
<StyledMenu
open={isMobileMenuOpen}
onClose={(): void => setMobileMenuOpen(false)}
anchorEl={anchorElement.current}
>
<MenuItemWrapper
onClick={(event) => {
setFileMenuOpen(true);
setFileMenuAnchorEl(event.currentTarget);
}}
disableRipple
>
<MenuItemText>File</MenuItemText>
<ChevronRight />
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper
onClick={() => {
window.open("https://docs.ironcalc.com", "_blank");
setMobileMenuOpen(false);
}}
disableRipple
>
<MenuItemText>Help</MenuItemText>
</MenuItemWrapper>
</StyledMenu>
<FileMenu
newModel={props.newModel}
setModel={props.setModel}
onDownload={props.onDownload}
onModelUpload={props.onModelUpload}
onDelete={props.onDelete}
isFileMenuOpen={isFileMenuOpen}
setFileMenuOpen={setFileMenuOpen}
setMobileMenuOpen={setMobileMenuOpen}
anchorElement={anchorElement}
/>
</>
);
}
export function FileMenu(props: { export function FileMenu(props: {
newModel: () => void; newModel: () => void;
setModel: (key: string) => void; setModel: (key: string) => void;
onDownload: () => void; onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>; onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void; onDelete: () => void;
isFileMenuOpen: boolean;
setFileMenuOpen: (open: boolean) => void;
setMobileMenuOpen: (open: boolean) => void;
anchorElement: React.RefObject<HTMLButtonElement>;
}) { }) {
const [isMenuOpen, setMenuOpen] = useState(false);
const [isImportMenuOpen, setImportMenuOpen] = useState(false); const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const anchorElement = useRef<HTMLDivElement>(null);
const models = getModelsMetadata(); const models = getModelsMetadata();
const uuids = Object.keys(models);
const selectedUuid = getSelectedUuid(); const selectedUuid = getSelectedUuid();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const elements = [];
for (const uuid of uuids) {
elements.push(
<MenuItemWrapper
key={uuid}
onClick={() => {
props.setModel(uuid);
setMenuOpen(false);
}}
>
<CheckIndicator>
{uuid === selectedUuid ? <StyledCheck /> : ""}
</CheckIndicator>
<MenuItemText
style={{
maxWidth: "240px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{models[uuid]}
</MenuItemText>
</MenuItemWrapper>,
);
}
return ( return (
<> <>
<StyledMenu <FileMenuWrapper
open={props.isFileMenuOpen} onClick={(): void => setMenuOpen(true)}
onClose={(): void => props.setFileMenuOpen(false)} ref={anchorElement}
anchorEl={props.anchorElement.current} >
anchorOrigin={{ File
vertical: "bottom", </FileMenuWrapper>
horizontal: "left", <Menu
}} open={isMenuOpen}
transformOrigin={{ onClose={(): void => setMenuOpen(false)}
vertical: "top", anchorEl={anchorElement.current}
horizontal: "left", sx={{
}} "& .MuiPaper-root": { borderRadius: "8px", padding: "4px 0px" },
// To prevent closing parent menu when interacting with submenu "& .MuiList-root": { padding: "0" },
onMouseLeave={() => {
if (!isImportMenuOpen && !isDeleteDialogOpen) {
props.setFileMenuOpen(false);
}
}} }}
// anchorOrigin={properties.anchorOrigin}
> >
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
props.newModel(); props.newModel();
props.setFileMenuOpen(false); setMenuOpen(false);
props.setMobileMenuOpen(false);
}} }}
disableRipple
> >
<StyledPlus /> <StyledPlus />
<MenuItemText>New</MenuItemText> <MenuItemText>New</MenuItemText>
@@ -167,37 +78,30 @@ export function FileMenu(props: {
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
setImportMenuOpen(true); setImportMenuOpen(true);
props.setFileMenuOpen(false); setMenuOpen(false);
props.setMobileMenuOpen(false);
}} }}
disableRipple
> >
<StyledFileUp /> <StyledFileUp />
<MenuItemText>Import</MenuItemText> <MenuItemText>Import</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper>
onClick={() => {
props.onDownload();
props.setMobileMenuOpen(false);
}}
disableRipple
>
<StyledFileDown /> <StyledFileDown />
<MenuItemText>Download (.xlsx)</MenuItemText> <MenuItemText onClick={props.onDownload}>
Download (.xlsx)
</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper <MenuItemWrapper
onClick={() => { onClick={() => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
props.setFileMenuOpen(false); setMenuOpen(false);
props.setMobileMenuOpen(false);
}} }}
disableRipple
> >
<StyledTrash /> <StyledTrash />
<MenuItemText>Delete workbook</MenuItemText> <MenuItemText>Delete workbook</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
</StyledMenu> <MenuDivider />
{elements}
</Menu>
<Modal <Modal
open={isImportMenuOpen} open={isImportMenuOpen}
onClose={() => { onClose={() => {
@@ -229,46 +133,6 @@ export function FileMenu(props: {
); );
} }
const MenuButton = styled(IconButton)`
height: 32px;
width: 32px;
padding: 8px;
border-radius: 4px;
svg {
stroke-width: 2px;
stroke: #757575;
width: 16px;
height: 16px;
}
&:hover {
background-color: #f2f2f2;
}
&:active {
background-color: #e0e0e0;
}
`;
const FileBarButton = styled(Button)<{ isOpen: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
font-size: 12px;
height: 32px;
width: auto;
padding: 4px 8px;
font-weight: 400;
min-width: 0px;
text-transform: capitalize;
color: #333333;
background-color: ${({ isOpen }) => (isOpen ? "#f2f2f2" : "none")};
&:hover {
background-color: #f2f2f2;
}
&:active {
background-color: #e0e0e0;
}
`;
const StyledPlus = styled(Plus)` const StyledPlus = styled(Plus)`
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -297,6 +161,13 @@ const StyledTrash = styled(Trash2)`
padding-right: 10px; padding-right: 10px;
`; `;
const StyledCheck = styled(Check)`
width: 16px;
height: 16px;
color: #333333;
padding-right: 10px;
`;
const MenuDivider = styled("div")` const MenuDivider = styled("div")`
width: 100%; width: 100%;
margin: auto; margin: auto;
@@ -308,7 +179,6 @@ const MenuDivider = styled("div")`
const MenuItemText = styled("div")` const MenuItemText = styled("div")`
color: #000; color: #000;
font-size: 12px; font-size: 12px;
flex-grow: 1;
`; `;
const MenuItemWrapper = styled(MenuItem)` const MenuItemWrapper = styled(MenuItem)`
@@ -321,19 +191,23 @@ const MenuItemWrapper = styled(MenuItem)`
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 8px;
height: 32px; height: 32px;
min-height: 32px; `;
svg {
width: 16px; const FileMenuWrapper = styled("div")`
height: 16px; display: flex;
align-items: center;
font-size: 12px;
font-family: Inter;
padding: 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #f2f2f2;
} }
`; `;
const StyledMenu = styled(Menu)` const CheckIndicator = styled("span")`
.MuiPaper-root { display: flex;
border-radius: 8px; justify-content: center;
padding: 4px 0px; min-width: 26px;
},
.MuiList-root {
padding: 0;
},
`; `;

View File

@@ -1,384 +0,0 @@
import styled from "@emotion/styled";
import { IronCalcLogo } from "@ironcalc/workbook";
import { Avatar, Drawer, IconButton, Menu, MenuItem } from "@mui/material";
import {
EllipsisVertical,
FileDown,
FileSpreadsheet,
Plus,
Trash2,
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import UserMenu from "../UserMenu";
interface LeftDrawerProps {
open: boolean;
onClose: () => void;
newModel: () => void;
setModel: (key: string) => void;
models: { [key: string]: string };
selectedUuid: string | null;
}
const LeftDrawer: React.FC<LeftDrawerProps> = ({
open,
onClose,
newModel,
setModel,
models,
selectedUuid,
}) => {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [selectedWorkbookUuid, setSelectedWorkbookUuid] = useState<
string | null
>(null);
const [userMenuAnchorEl, setUserMenuAnchorEl] = useState<null | HTMLElement>(
null,
);
const handleMenuOpen = (
event: React.MouseEvent<HTMLButtonElement>,
uuid: string,
) => {
console.log("Menu open", uuid);
event.stopPropagation();
setSelectedWorkbookUuid(uuid);
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
setSelectedWorkbookUuid(null);
};
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchorEl(event.currentTarget);
};
const handleUserMenuClose = () => {
setUserMenuAnchorEl(null);
};
const elements = Object.keys(models)
.reverse()
.map((uuid) => {
const isMenuOpen = menuAnchorEl !== null && selectedWorkbookUuid === uuid;
return (
<WorkbookListItem
key={uuid}
onClick={() => {
setModel(uuid);
}}
selected={uuid === selectedUuid}
disableRipple
>
<StorageIndicator>
<FileSpreadsheet />
</StorageIndicator>
<WorkbookListText>{models[uuid]}</WorkbookListText>
<EllipsisButton
onClick={(e) => handleMenuOpen(e, uuid)}
disableRipple
isOpen={isMenuOpen}
>
<EllipsisVertical />
</EllipsisButton>
<StyledMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
MenuListProps={{
dense: true,
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
disablePortal
>
<MenuItemWrapper
onClick={() => {
handleMenuClose();
}}
disableRipple
>
<FileDown />
Download (.xlsx)
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper
selected={false}
onClick={() => {
handleMenuClose();
}}
disableRipple
>
<Trash2 size={16} />
Delete workbook
</MenuItemWrapper>
</StyledMenu>
</WorkbookListItem>
);
});
return (
<DrawerWrapper
variant="persistent"
anchor="left"
open={open}
onClose={onClose}
>
<DrawerHeader>
<StyledDesktopLogo />
<AddButton
onClick={() => {
newModel();
}}
>
<PlusIcon />
</AddButton>
</DrawerHeader>
<DrawerContent>
<DrawerContentTitle>Your workbooks</DrawerContentTitle>
{elements}
</DrawerContent>
<DrawerFooter>
<UserWrapper
disableRipple
onClick={handleUserMenuOpen}
selected={Boolean(userMenuAnchorEl)}
>
<StyledAvatar
alt="Nikola Tesla"
src="/path/to/avatar.jpg"
sx={{ bgcolor: "#f2994a", width: 24, height: 24 }}
/>
<Username>Nikola Tesla</Username>
</UserWrapper>
<UserMenu
anchorEl={userMenuAnchorEl}
onClose={handleUserMenuClose}
onPreferences={() => {
console.log("Preferences clicked");
handleUserMenuClose();
}}
onLogout={() => {
console.log("Logout clicked");
handleUserMenuClose();
}}
/>
</DrawerFooter>
</DrawerWrapper>
);
};
const DrawerWrapper = styled(Drawer)`
width: 264px;
height: 100%;
flex-shrink: 0;
font-family: "Inter", sans-serif;
.MuiDrawer-paper {
width: 264px;
background-color: #f5f5f5;
overflow: hidden;
border-right: 1px solid #e0e0e0;
}
`;
const DrawerHeader = styled("div")`
display: flex;
align-items: center;
padding: 12px 8px 12px 16px;
justify-content: space-between;
max-height: 60px;
min-height: 60px;
border-bottom: 1px solid #e0e0e0;
box-sizing: border-box;
`;
const StyledDesktopLogo = styled(IronCalcLogo)`
width: 120px;
height: 28px;
`;
const AddButton = styled(IconButton)`
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
height: 32px;
width: 32px;
border-radius: 4px;
margin-left: 10px;
color: #333333;
stroke-width: 2px;
&:hover {
background-color: #e0e0e0;
}
`;
const PlusIcon = styled(Plus)`
width: 16px;
height: 16px;
`;
const DrawerContent = styled("div")`
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px;
height: 100%;
overflow: scroll;
font-size: 12px;
`;
const DrawerContentTitle = styled("div")`
font-weight: 600;
color: #9e9e9e;
margin-bottom: 8px;
padding: 0px 8px;
`;
const StorageIndicator = styled("div")`
height: 16px;
width: 16px;
svg {
height: 16px;
width: 16px;
stroke: #9e9e9e;
}
`;
const EllipsisButton = styled(IconButton)<{ isOpen: boolean }>`
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
height: 24px;
width: 24px;
border-radius: 4px;
color: #333333;
stroke-width: 2px;
background-color: ${({ isOpen }) => (isOpen ? "#E0E0E0" : "none")};
opacity: ${({ isOpen }) => (isOpen ? "1" : "0.5")};
transition: opacity 0.3s, background-color 0.3s;
&:hover {
background: none;
opacity: 1;
}
&:active {
background: #bdbdbd;
opacity: 1;
}
`;
const WorkbookListItem = styled(MenuItem)<{ selected: boolean }>`
display: flex;
gap: 8px;
justify-content: flex-start;
font-size: 14px;
width: 100%;
min-width: 172px;
border-radius: 8px;
padding: 8px 4px 8px 8px;
height: 32px;
min-height: 32px;
transition: gap 0.5s;
background-color: ${({ selected }) =>
selected ? "#e0e0e0 !important" : "transparent"};
`;
const WorkbookListText = styled("div")`
color: #000;
font-size: 12px;
width: 100%;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledMenu = styled(Menu)`
.MuiPaper-root {
border-radius: 8px;
padding: 4px 0px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.01);
},
.MuiList-root {
padding: 0;
},
`;
const MenuDivider = styled("div")`
width: 100%;
margin: auto;
margin-top: 4px;
margin-bottom: 4px;
border-top: 1px solid #eeeeee;
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: flex-start;
font-size: 12px;
width: calc(100% - 8px);
min-width: 140px;
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
gap: 8px;
svg {
width: 16px;
height: 16px;
}
`;
const DrawerFooter = styled("div")`
display: none;
align-items: center;
padding: 12px;
justify-content: space-between;
max-height: 60px;
height: 60px;
border-top: 1px solid #e0e0e0;
box-sizing: border-box;
`;
const UserWrapper = styled(MenuItem)<{ selected: boolean }>`
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
padding: 8px;
border-radius: 8px;
max-width: 100%;
background-color: ${({ selected }) =>
selected ? "#e0e0e0 !important" : "transparent"};
`;
const StyledAvatar = styled(Avatar)`
font-size: 14px;
`;
const Username = styled("div")`
font-size: 12px;
flex-grow: 1;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
`;
export default LeftDrawer;

View File

@@ -1,90 +0,0 @@
import styled from "@emotion/styled";
import { Menu, MenuItem } from "@mui/material";
import { LogOut, Settings } from "lucide-react";
interface UserMenuProps {
anchorEl: null | HTMLElement;
onClose: () => void;
onPreferences: () => void;
onLogout: () => void;
}
const UserMenu: React.FC<UserMenuProps> = ({
anchorEl,
onClose,
onPreferences,
onLogout,
}) => {
return (
<StyledMenu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={onClose}
MenuListProps={{
dense: true,
}}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
<MenuItemWrapper onClick={onPreferences} sx={{ gap: 1, fontSize: 14 }}>
<Settings size={16} />
<MenuItemText>Preferences</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
<MenuItemWrapper onClick={onLogout} sx={{ gap: 1, fontSize: 14 }}>
<LogOut size={16} />
<MenuItemText>Log out</MenuItemText>
</MenuItemWrapper>
</StyledMenu>
);
};
const StyledMenu = styled(Menu)`
& .MuiPaper-root {
border-radius: 8px;
padding: 4px 0px;
margin-top: -4px;
margin-left: 4px;
}
& .MuiList-root {
padding: 0;
}
`;
const MenuItemText = styled("div")`
color: #000;
font-size: 12px;
flex-grow: 1;
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: flex-start;
font-size: 14px;
width: calc(100% - 8px);
min-width: 172px;
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
svg {
width: 16px;
height: 16px;
}
`;
const MenuDivider = styled("div")`
width: 100%;
margin: auto;
margin-top: 4px;
margin-bottom: 4px;
border-top: 1px solid #eeeeee;
`;
export default UserMenu;

View File

@@ -72,10 +72,10 @@ export function WorkbookTitle(properties: {
} }
const Container = styled("div")` const Container = styled("div")`
text-align: left; text-align: center;
padding: 6px 4px; padding: 8px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 700;
font-family: Inter; font-family: Inter;
`; `;
@@ -108,7 +108,7 @@ const TitleInput = styled("input")`
background-color: #f2f2f2; background-color: #f2f2f2;
} }
&:focus { &:focus {
outline: 1px solid grey; border: 1px solid grey;
} }
font-weight: inherit; font-weight: inherit;
font-family: inherit; font-family: inherit;

View File

@@ -9,14 +9,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
for row in 1..100 { for row in 1..100 {
for column in 1..100 { for column in 1..100 {
let value = row * column; let value = row * column;
model.set_user_input(0, row, column, format!("{value}"))?; model.set_user_input(0, row, column, format!("{}", value))?;
} }
} }
// Adds a new sheet // Adds a new sheet
model.add_sheet("Calculation")?; model.add_sheet("Calculation")?;
// column 100 is CV // column 100 is CV
let last_column = number_to_column(100).ok_or("Invalid column number")?; let last_column = number_to_column(100).ok_or("Invalid column number")?;
let formula = format!("=SUM(Sheet1!A1:{last_column}100)"); let formula = format!("=SUM(Sheet1!A1:{}100)", last_column);
model.set_user_input(1, 1, 1, formula)?; model.set_user_input(1, 1, 1, formula)?;
// evaluates // evaluates

View File

@@ -22,7 +22,7 @@ fn main() {
let file_name = &args[1]; let file_name = &args[1];
println!("Testing file: {file_name}"); println!("Testing file: {file_name}");
if let Err(message) = test_file(file_name) { if let Err(message) = test_file(file_name) {
println!("{message}"); println!("{}", message);
panic!("Model was evaluated inconsistently with XLSX data.") panic!("Model was evaluated inconsistently with XLSX data.")
} }

View File

@@ -176,7 +176,7 @@ pub(crate) fn compare_models(m1: &Model, m2: &Model) -> Result<(), String> {
diff.reason diff.reason
); );
} }
Err(format!("Models are different: {message}")) Err(format!("Models are different: {}", message))
} }
} }
Err(r) => Err(format!("Models are different: {}", r.message)), Err(r) => Err(format!("Models are different: {}", r.message)),

View File

@@ -19,9 +19,10 @@ pub(crate) fn get_app_xml(_: &Workbook) -> String {
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?> "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>
<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" \ <Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" \
xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\">\ xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\">\
<Application>{APPLICATION}</Application>\ <Application>{}</Application>\
<AppVersion>{APP_VERSION}</AppVersion>\ <AppVersion>{}</AppVersion>\
</Properties>" </Properties>",
APPLICATION, APP_VERSION
) )
} }
@@ -37,7 +38,12 @@ pub(crate) fn get_core_xml(workbook: &Workbook, milliseconds: i64) -> Result<Str
let seconds = milliseconds / 1000; let seconds = milliseconds / 1000;
let dt = match DateTime::from_timestamp(seconds, 0) { let dt = match DateTime::from_timestamp(seconds, 0) {
Some(s) => s, Some(s) => s,
None => return Err(XlsxError::Xml(format!("Invalid timestamp: {milliseconds}"))), None => {
return Err(XlsxError::Xml(format!(
"Invalid timestamp: {}",
milliseconds
)))
}
}; };
let last_modified = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(); let last_modified = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
Ok(format!( Ok(format!(
@@ -48,15 +54,16 @@ pub(crate) fn get_core_xml(workbook: &Workbook, milliseconds: i64) -> Result<Str
xmlns:dcmitype=\"http://purl.org/dc/dcmitype/\" \ xmlns:dcmitype=\"http://purl.org/dc/dcmitype/\" \
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"> \ xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"> \
<dc:title></dc:title><dc:subject></dc:subject>\ <dc:title></dc:title><dc:subject></dc:subject>\
<dc:creator>{creator}</dc:creator>\ <dc:creator>{}</dc:creator>\
<cp:keywords></cp:keywords>\ <cp:keywords></cp:keywords>\
<dc:description></dc:description>\ <dc:description></dc:description>\
<cp:lastModifiedBy>{last_modified_by}</cp:lastModifiedBy>\ <cp:lastModifiedBy>{}</cp:lastModifiedBy>\
<cp:revision></cp:revision>\ <cp:revision></cp:revision>\
<dcterms:created xsi:type=\"dcterms:W3CDTF\">{created}</dcterms:created>\ <dcterms:created xsi:type=\"dcterms:W3CDTF\">{}</dcterms:created>\
<dcterms:modified xsi:type=\"dcterms:W3CDTF\">{last_modified}</dcterms:modified>\ <dcterms:modified xsi:type=\"dcterms:W3CDTF\">{}</dcterms:modified>\
<cp:category></cp:category>\ <cp:category></cp:category>\
<cp:contentStatus></cp:contentStatus>\ <cp:contentStatus></cp:contentStatus>\
</cp:coreProperties>" </cp:coreProperties>",
creator, last_modified_by, created, last_modified
)) ))
} }

View File

@@ -59,7 +59,7 @@ fn get_content_types_xml(workbook: &Workbook) -> String {
pub fn save_to_xlsx(model: &Model, file_name: &str) -> Result<(), XlsxError> { pub fn save_to_xlsx(model: &Model, file_name: &str) -> Result<(), XlsxError> {
let file_path = std::path::Path::new(&file_name); let file_path = std::path::Path::new(&file_name);
if file_path.exists() { if file_path.exists() {
return Err(XlsxError::IO(format!("file {file_name} already exists"))); return Err(XlsxError::IO(format!("file {} already exists", file_name)));
} }
let file = fs::File::create(file_path).unwrap(); let file = fs::File::create(file_path).unwrap();
let writer = BufWriter::new(file); let writer = BufWriter::new(file);
@@ -140,7 +140,7 @@ pub fn save_xlsx_to_writer<W: Write + Seek>(model: &Model, writer: W) -> Result<
pub fn save_to_icalc(model: &Model, file_name: &str) -> Result<(), XlsxError> { pub fn save_to_icalc(model: &Model, file_name: &str) -> Result<(), XlsxError> {
let file_path = std::path::Path::new(&file_name); let file_path = std::path::Path::new(&file_name);
if file_path.exists() { if file_path.exists() {
return Err(XlsxError::IO(format!("file {file_name} already exists"))); return Err(XlsxError::IO(format!("file {} already exists", file_name)));
} }
let s = bitcode::encode(&model.workbook); let s = bitcode::encode(&model.workbook);
let mut file = fs::File::create(file_path)?; let mut file = fs::File::create(file_path)?;

View File

@@ -35,7 +35,7 @@ fn get_cell_style_attribute(s: i32) -> String {
if s == 0 { if s == 0 {
"".to_string() "".to_string()
} else { } else {
format!(" s=\"{s}\"") format!(" s=\"{}\"", s)
} }
} }

View File

@@ -110,6 +110,7 @@ fn load_xlsx_from_reader<R: Read + std::io::Seek>(
metadata, metadata,
tables, tables,
views, views,
users: Vec::new(),
}) })
} }
@@ -148,8 +149,8 @@ pub fn load_from_xlsx(file_name: &str, locale: &str, tz: &str) -> Result<Model,
/// Loads a [Model] from an `ic` file (a file in the IronCalc internal representation) /// Loads a [Model] from an `ic` file (a file in the IronCalc internal representation)
pub fn load_from_icalc(file_name: &str) -> Result<Model, XlsxError> { pub fn load_from_icalc(file_name: &str) -> Result<Model, XlsxError> {
let contents = fs::read(file_name) let contents = fs::read(file_name)
.map_err(|e| XlsxError::IO(format!("Could not extract workbook name: {e}")))?; .map_err(|e| XlsxError::IO(format!("Could not extract workbook name: {}", e)))?;
let workbook: Workbook = bitcode::decode(&contents) let workbook: Workbook = bitcode::decode(&contents)
.map_err(|e| XlsxError::IO(format!("Failed to decode file: {e}")))?; .map_err(|e| XlsxError::IO(format!("Failed to decode file: {}", e)))?;
Model::from_workbook(workbook).map_err(XlsxError::Workbook) Model::from_workbook(workbook).map_err(XlsxError::Workbook)
} }

View File

@@ -93,8 +93,7 @@ pub(super) fn load_styles<R: Read + std::io::Seek>(
let mut b = false; let mut b = false;
let mut i = false; let mut i = false;
let mut strike = false; let mut strike = false;
// Default color is black let mut color = Some("FFFFFF00".to_string());
let mut color = Some("#000000".to_string());
let mut family = 2; let mut family = 2;
let mut scheme = FontScheme::default(); let mut scheme = FontScheme::default();
for feature in font.children() { for feature in font.children() {
@@ -142,7 +141,7 @@ pub(super) fn load_styles<R: Read + std::io::Seek>(
} }
"charset" => {} "charset" => {}
_ => { _ => {
println!("Unexpected feature {feature:?}"); println!("Unexpected feature {:?}", feature);
} }
} }
} }

View File

@@ -21,7 +21,7 @@ where
{ {
let attr_name = attr_name.into(); let attr_name = attr_name.into();
node.attribute(attr_name) node.attribute(attr_name)
.ok_or_else(|| XlsxError::Xml(format!("Missing \"{attr_name:?}\" XML attribute"))) .ok_or_else(|| XlsxError::Xml(format!("Missing \"{:?}\" XML attribute", attr_name)))
} }
pub(super) fn get_value_or_default(node: &Node, tag_name: &str, default: &str) -> String { pub(super) fn get_value_or_default(node: &Node, tag_name: &str, default: &str) -> String {
@@ -64,7 +64,7 @@ pub(super) fn get_color(node: Node) -> Result<Option<String>, XlsxError> {
// A boolean value indicating the color is automatic and system color dependent. // A boolean value indicating the color is automatic and system color dependent.
Ok(None) Ok(None)
} else { } else {
println!("Unexpected color node {node:?}"); println!("Unexpected color node {:?}", node);
Ok(None) Ok(None)
} }
} }

View File

@@ -40,7 +40,7 @@ pub(super) fn load_workbook<R: Read + std::io::Seek>(
Some("visible") | None => SheetState::Visible, Some("visible") | None => SheetState::Visible,
Some("hidden") => SheetState::Hidden, Some("hidden") => SheetState::Hidden,
Some("veryHidden") => SheetState::VeryHidden, Some("veryHidden") => SheetState::VeryHidden,
Some(state) => return Err(XlsxError::Xml(format!("Unknown sheet state: {state}"))), Some(state) => return Err(XlsxError::Xml(format!("Unknown sheet state: {}", state))),
}; };
sheets.push(Sheet { sheets.push(Sheet {
name, name,

View File

@@ -81,7 +81,7 @@ fn parse_cell_reference(cell: &str) -> Result<(i32, i32), String> {
if let Some(r) = parse_reference_a1(cell) { if let Some(r) = parse_reference_a1(cell) {
Ok((r.row, r.column)) Ok((r.row, r.column))
} else { } else {
Err(format!("Invalid cell reference: '{cell}'")) Err(format!("Invalid cell reference: '{}'", cell))
} }
} }
@@ -91,17 +91,17 @@ fn parse_range(range: &str) -> Result<(i32, i32, i32, i32), String> {
if let Some(r) = parse_reference_a1(parts[0]) { if let Some(r) = parse_reference_a1(parts[0]) {
Ok((r.row, r.column, r.row, r.column)) Ok((r.row, r.column, r.row, r.column))
} else { } else {
Err(format!("Invalid range: '{range}'")) Err(format!("Invalid range: '{}'", range))
} }
} else if parts.len() == 2 { } else if parts.len() == 2 {
match (parse_reference_a1(parts[0]), parse_reference_a1(parts[1])) { match (parse_reference_a1(parts[0]), parse_reference_a1(parts[1])) {
(Some(left), Some(right)) => { (Some(left), Some(right)) => {
return Ok((left.row, left.column, right.row, right.column)); return Ok((left.row, left.column, right.row, right.column));
} }
_ => return Err(format!("Invalid range: '{range}'")), _ => return Err(format!("Invalid range: '{}'", range)),
} }
} else { } else {
return Err(format!("Invalid range: '{range}'")); return Err(format!("Invalid range: '{}'", range));
} }
} }
@@ -390,7 +390,7 @@ fn get_cell_from_excel(
} }
"d" => { "d" => {
// Not implemented // Not implemented
println!("Invalid type (d) in {sheet_name}!{cell_ref}"); println!("Invalid type (d) in {}!{}", sheet_name, cell_ref);
Cell::ErrorCell { Cell::ErrorCell {
ei: Error::NIMPL, ei: Error::NIMPL,
s: cell_style, s: cell_style,
@@ -398,7 +398,7 @@ fn get_cell_from_excel(
} }
"inlineStr" => { "inlineStr" => {
// Not implemented // Not implemented
println!("Invalid type (inlineStr) in {sheet_name}!{cell_ref}"); println!("Invalid type (inlineStr) in {}!{}", sheet_name, cell_ref);
Cell::ErrorCell { Cell::ErrorCell {
ei: Error::NIMPL, ei: Error::NIMPL,
s: cell_style, s: cell_style,
@@ -407,7 +407,10 @@ fn get_cell_from_excel(
"empty" => Cell::EmptyCell { s: cell_style }, "empty" => Cell::EmptyCell { s: cell_style },
_ => { _ => {
// error // error
println!("Unexpected type ({cell_type}) in {sheet_name}!{cell_ref}"); println!(
"Unexpected type ({}) in {}!{}",
cell_type, sheet_name, cell_ref
);
Cell::ErrorCell { Cell::ErrorCell {
ei: Error::ERROR, ei: Error::ERROR,
s: cell_style, s: cell_style,
@@ -441,15 +444,15 @@ fn get_cell_from_excel(
f: formula_index, f: formula_index,
ei: get_error_by_english_name(error_name).unwrap_or(Error::ERROR), ei: get_error_by_english_name(error_name).unwrap_or(Error::ERROR),
s: cell_style, s: cell_style,
o: format!("{sheet_name}!{cell_ref}"), o: format!("{}!{}", sheet_name, cell_ref),
m: cell_value.unwrap_or("#ERROR!").to_string(), m: cell_value.unwrap_or("#ERROR!").to_string(),
} }
} }
"s" => { "s" => {
// Not implemented // Not implemented
let o = format!("{sheet_name}!{cell_ref}"); let o = format!("{}!{}", sheet_name, cell_ref);
let m = Error::NIMPL.to_string(); let m = Error::NIMPL.to_string();
println!("Invalid type (s) in {sheet_name}!{cell_ref}"); println!("Invalid type (s) in {}!{}", sheet_name, cell_ref);
Cell::CellFormulaError { Cell::CellFormulaError {
f: formula_index, f: formula_index,
ei: Error::NIMPL, ei: Error::NIMPL,
@@ -468,8 +471,8 @@ fn get_cell_from_excel(
} }
"d" => { "d" => {
// Not implemented // Not implemented
println!("Invalid type (d) in {sheet_name}!{cell_ref}"); println!("Invalid type (d) in {}!{}", sheet_name, cell_ref);
let o = format!("{sheet_name}!{cell_ref}"); let o = format!("{}!{}", sheet_name, cell_ref);
let m = Error::NIMPL.to_string(); let m = Error::NIMPL.to_string();
Cell::CellFormulaError { Cell::CellFormulaError {
f: formula_index, f: formula_index,
@@ -481,9 +484,9 @@ fn get_cell_from_excel(
} }
"inlineStr" => { "inlineStr" => {
// Not implemented // Not implemented
let o = format!("{sheet_name}!{cell_ref}"); let o = format!("{}!{}", sheet_name, cell_ref);
let m = Error::NIMPL.to_string(); let m = Error::NIMPL.to_string();
println!("Invalid type (inlineStr) in {sheet_name}!{cell_ref}"); println!("Invalid type (inlineStr) in {}!{}", sheet_name, cell_ref);
Cell::CellFormulaError { Cell::CellFormulaError {
f: formula_index, f: formula_index,
ei: Error::NIMPL, ei: Error::NIMPL,
@@ -494,8 +497,11 @@ fn get_cell_from_excel(
} }
_ => { _ => {
// error // error
println!("Unexpected type ({cell_type}) in {sheet_name}!{cell_ref}"); println!(
let o = format!("{sheet_name}!{cell_ref}"); "Unexpected type ({}) in {}!{}",
cell_type, sheet_name, cell_ref
);
let o = format!("{}!{}", sheet_name, cell_ref);
let m = Error::ERROR.to_string(); let m = Error::ERROR.to_string();
Cell::CellFormulaError { Cell::CellFormulaError {
f: formula_index, f: formula_index,
@@ -880,7 +886,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
Some(_) => { Some(_) => {
// It's the mother cell. We do not use the ref attribute in IronCalc // It's the mother cell. We do not use the ref attribute in IronCalc
let formula = fs[0].text().unwrap_or("").to_string(); let formula = fs[0].text().unwrap_or("").to_string();
let context = format!("{sheet_name}!{cell_ref}"); let context = format!("{}!{}", sheet_name, cell_ref);
let formula = from_a1_to_rc( let formula = from_a1_to_rc(
formula, formula,
worksheets, worksheets,
@@ -943,7 +949,7 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
} }
// Its a cell with a simple formula // Its a cell with a simple formula
let formula = fs[0].text().unwrap_or("").to_string(); let formula = fs[0].text().unwrap_or("").to_string();
let context = format!("{sheet_name}!{cell_ref}"); let context = format!("{}!{}", sheet_name, cell_ref);
let formula = from_a1_to_rc( let formula = from_a1_to_rc(
formula, formula,
worksheets, worksheets,
@@ -962,7 +968,8 @@ pub(super) fn load_sheet<R: Read + std::io::Seek>(
} }
_ => { _ => {
return Err(XlsxError::Xml(format!( return Err(XlsxError::Xml(format!(
"Invalid formula type {formula_type:?}.", "Invalid formula type {:?}.",
formula_type,
))); )));
} }
} }

View File

@@ -350,11 +350,11 @@ fn test_xlsx() {
for file_path in entries { for file_path in entries {
let file_name_str = file_path.file_name().unwrap().to_str().unwrap(); let file_name_str = file_path.file_name().unwrap().to_str().unwrap();
let file_path_str = file_path.to_str().unwrap(); let file_path_str = file_path.to_str().unwrap();
println!("Testing file: {file_path_str}"); println!("Testing file: {}", file_path_str);
if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') { if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') {
if let Err(message) = test_file(file_path_str) { if let Err(message) = test_file(file_path_str) {
println!("Error with file: '{file_path_str}'"); println!("Error with file: '{file_path_str}'");
println!("{message}"); println!("{}", message);
is_error = true; is_error = true;
} }
let t = test_load_and_saving(file_path_str, &dir); let t = test_load_and_saving(file_path_str, &dir);
@@ -389,11 +389,11 @@ fn no_export() {
for file_path in entries { for file_path in entries {
let file_name_str = file_path.file_name().unwrap().to_str().unwrap(); let file_name_str = file_path.file_name().unwrap().to_str().unwrap();
let file_path_str = file_path.to_str().unwrap(); let file_path_str = file_path.to_str().unwrap();
println!("Testing file: {file_path_str}"); println!("Testing file: {}", file_path_str);
if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') { if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') {
if let Err(message) = test_file(file_path_str) { if let Err(message) = test_file(file_path_str) {
println!("Error with file: '{file_path_str}'"); println!("Error with file: '{file_path_str}'");
println!("{message}"); println!("{}", message);
is_error = true; is_error = true;
} }
} else { } else {
@@ -485,7 +485,7 @@ fn test_documentation_xlsx() {
// Numerically unstable // Numerically unstable
skip.push("TAN.xlsx"); skip.push("TAN.xlsx");
let skip: Vec<String> = skip.iter().map(|s| format!("tests/docs/{s}")).collect(); let skip: Vec<String> = skip.iter().map(|s| format!("tests/docs/{s}")).collect();
println!("{skip:?}"); println!("{:?}", skip);
// dumb counter to make sure we are actually testing the files // dumb counter to make sure we are actually testing the files
assert!(entries.len() > 7); assert!(entries.len() > 7);
let temp_folder = env::temp_dir(); let temp_folder = env::temp_dir();
@@ -497,13 +497,13 @@ fn test_documentation_xlsx() {
let file_name_str = file_path.file_name().unwrap().to_str().unwrap(); let file_name_str = file_path.file_name().unwrap().to_str().unwrap();
let file_path_str = file_path.to_str().unwrap(); let file_path_str = file_path.to_str().unwrap();
if skip.contains(&file_path_str.to_string()) { if skip.contains(&file_path_str.to_string()) {
println!("Skipping file: {file_path_str}"); println!("Skipping file: {}", file_path_str);
continue; continue;
} }
println!("Testing file: {file_path_str}"); println!("Testing file: {}", file_path_str);
if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') { if file_name_str.ends_with(".xlsx") && !file_name_str.starts_with('~') {
if let Err(message) = test_file(file_path_str) { if let Err(message) = test_file(file_path_str) {
println!("{message}"); println!("{}", message);
is_error = true; is_error = true;
} }
assert!(test_load_and_saving(file_path_str, &dir).is_ok()); assert!(test_load_and_saving(file_path_str, &dir).is_ok());