UPDATE: Dump of initial files
This commit is contained in:
34
xlsx/src/bin/test.rs
Normal file
34
xlsx/src/bin/test.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Tests an Excel xlsx file.
|
||||
//! Returns a list of differences in json format.
|
||||
//! Saves an IronCalc version
|
||||
//! This is primary for QA internal testing and will be superseded by an official
|
||||
//! IronCalc CLI.
|
||||
//!
|
||||
//! Usage: test file.xlsx
|
||||
|
||||
use std::path;
|
||||
|
||||
use ironcalc::{compare::test_file, export::save_to_xlsx, import::load_model_from_xlsx};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
if args.len() != 2 {
|
||||
panic!("Usage: {} <file.xlsx>", args[0]);
|
||||
}
|
||||
// first test the file
|
||||
let file_name = &args[1];
|
||||
println!("Testing file: {file_name}");
|
||||
if let Err(message) = test_file(file_name) {
|
||||
println!("{}", message);
|
||||
panic!("Model was evaluated inconsistently with XLSX data.")
|
||||
}
|
||||
|
||||
// save a copy my_xlsx_file.xlsx => my_xlsx_file.output.xlsx
|
||||
let file_path = path::Path::new(file_name);
|
||||
let base_name = file_path.file_stem().unwrap().to_str().unwrap();
|
||||
let output_file_name = &format!("{base_name}.output.xlsx");
|
||||
let mut model = load_model_from_xlsx(file_name, "en", "UTC").unwrap();
|
||||
model.evaluate();
|
||||
println!("Saving result as: {output_file_name}. Please open with Excel and test.");
|
||||
save_to_xlsx(&model, output_file_name).unwrap();
|
||||
}
|
||||
206
xlsx/src/compare.rs
Normal file
206
xlsx/src/compare.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ironcalc_base::cell::CellValue;
|
||||
use ironcalc_base::types::*;
|
||||
use ironcalc_base::{expressions::utils::number_to_column, model::Model};
|
||||
|
||||
use crate::export::save_to_xlsx;
|
||||
use crate::import::load_model_from_xlsx;
|
||||
|
||||
pub struct CompareError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
type CompareResult<T> = std::result::Result<T, CompareError>;
|
||||
|
||||
pub struct Diff {
|
||||
pub sheet_name: String,
|
||||
pub row: i32,
|
||||
pub column: i32,
|
||||
pub value1: Cell,
|
||||
pub value2: Cell,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// TODO use f64::EPSILON
|
||||
const EPS: f64 = 5e-8;
|
||||
// const EPS: f64 = f64::EPSILON;
|
||||
|
||||
fn numbers_are_close(x: f64, y: f64, eps: f64) -> bool {
|
||||
let norm = (x * x + y * y).sqrt();
|
||||
if norm == 0.0 {
|
||||
return true;
|
||||
}
|
||||
let d = f64::abs(x - y);
|
||||
if d < eps {
|
||||
return true;
|
||||
}
|
||||
d / norm < eps
|
||||
}
|
||||
/// Compares two Models in the internal representation and returns a list of differences
|
||||
pub fn compare(model1: &Model, model2: &Model) -> CompareResult<Vec<Diff>> {
|
||||
let ws1 = model1.workbook.get_worksheet_names();
|
||||
let ws2 = model2.workbook.get_worksheet_names();
|
||||
if ws1.len() != ws2.len() {
|
||||
return Err(CompareError {
|
||||
message: "Different number of sheets".to_string(),
|
||||
});
|
||||
}
|
||||
let eps = if let Ok(CellValue::Number(v)) = model1.get_cell_value_by_ref("METADATA!A1") {
|
||||
v
|
||||
} else {
|
||||
EPS
|
||||
};
|
||||
let mut diffs = Vec::new();
|
||||
let cells = model1.get_all_cells();
|
||||
for cell in cells {
|
||||
let sheet = cell.index;
|
||||
let row = cell.row;
|
||||
let column = cell.column;
|
||||
let cell1 = &model1
|
||||
.workbook
|
||||
.worksheet(sheet)
|
||||
.unwrap()
|
||||
.cell(row, column)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let cell2 = &model2
|
||||
.workbook
|
||||
.worksheet(sheet)
|
||||
.unwrap()
|
||||
.cell(row, column)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match (cell1, cell2) {
|
||||
(Cell::EmptyCell { .. }, Cell::EmptyCell { .. }) => {}
|
||||
(Cell::NumberCell { .. }, Cell::NumberCell { .. }) => {}
|
||||
(Cell::BooleanCell { .. }, Cell::BooleanCell { .. }) => {}
|
||||
(Cell::ErrorCell { .. }, Cell::ErrorCell { .. }) => {}
|
||||
(Cell::SharedString { .. }, Cell::SharedString { .. }) => {}
|
||||
(
|
||||
Cell::CellFormulaNumber { v: value1, .. },
|
||||
Cell::CellFormulaNumber { v: value2, .. },
|
||||
) => {
|
||||
if !numbers_are_close(*value1, *value2, eps) {
|
||||
diffs.push(Diff {
|
||||
sheet_name: ws1[cell.index as usize].clone(),
|
||||
row,
|
||||
column,
|
||||
value1: cell1.clone(),
|
||||
value2: cell2.clone(),
|
||||
reason: "Numbers are different".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
(
|
||||
Cell::CellFormulaString { v: value1, .. },
|
||||
Cell::CellFormulaString { v: value2, .. },
|
||||
) => {
|
||||
// FIXME: We should compare the actual value, not just the index
|
||||
if value1 != value2 {
|
||||
diffs.push(Diff {
|
||||
sheet_name: ws1[cell.index as usize].clone(),
|
||||
row,
|
||||
column,
|
||||
value1: cell1.clone(),
|
||||
value2: cell2.clone(),
|
||||
reason: "Strings are different".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
(
|
||||
Cell::CellFormulaBoolean { v: value1, .. },
|
||||
Cell::CellFormulaBoolean { v: value2, .. },
|
||||
) => {
|
||||
// FIXME: We should compare the actual value, not just the index
|
||||
if value1 != value2 {
|
||||
diffs.push(Diff {
|
||||
sheet_name: ws1[cell.index as usize].clone(),
|
||||
row,
|
||||
column,
|
||||
value1: cell1.clone(),
|
||||
value2: cell2.clone(),
|
||||
reason: "Booleans are different".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
(
|
||||
Cell::CellFormulaError { ei: index1, .. },
|
||||
Cell::CellFormulaError { ei: index2, .. },
|
||||
) => {
|
||||
// FIXME: We should compare the actual value, not just the index
|
||||
if index1 != index2 {
|
||||
diffs.push(Diff {
|
||||
sheet_name: ws1[cell.index as usize].clone(),
|
||||
row,
|
||||
column,
|
||||
value1: cell1.clone(),
|
||||
value2: cell2.clone(),
|
||||
reason: "Errors are different".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
(_, _) => {
|
||||
diffs.push(Diff {
|
||||
sheet_name: ws1[cell.index as usize].clone(),
|
||||
row,
|
||||
column,
|
||||
value1: cell1.clone(),
|
||||
value2: cell2.clone(),
|
||||
reason: "Types are different".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
pub(crate) fn compare_models(m1: &Model, m2: &Model) -> Result<(), String> {
|
||||
match compare(m1, m2) {
|
||||
Ok(diffs) => {
|
||||
if diffs.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let mut message = "".to_string();
|
||||
for diff in diffs {
|
||||
message = format!(
|
||||
"{}\n.Diff: {}!{}{}, value1: {}, value2 {}\n {}",
|
||||
message,
|
||||
diff.sheet_name,
|
||||
number_to_column(diff.column).unwrap(),
|
||||
diff.row,
|
||||
serde_json::to_string(&diff.value1).unwrap(),
|
||||
serde_json::to_string(&diff.value2).unwrap(),
|
||||
diff.reason
|
||||
);
|
||||
}
|
||||
Err(format!("Models are different: {}", message))
|
||||
}
|
||||
}
|
||||
Err(r) => Err(format!("Models are different: {}", r.message)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that file in file_path produces the same results in Excel and in IronCalc.
|
||||
pub fn test_file(file_path: &str) -> Result<(), String> {
|
||||
let model1 = load_model_from_xlsx(file_path, "en", "UTC").unwrap();
|
||||
let mut model2 = load_model_from_xlsx(file_path, "en", "UTC").unwrap();
|
||||
model2.evaluate();
|
||||
compare_models(&model1, &model2)
|
||||
}
|
||||
|
||||
/// Tests that file in file_path can be converted to xlsx and read again
|
||||
pub fn test_load_and_saving(file_path: &str, temp_dir_name: &Path) -> Result<(), String> {
|
||||
let model1 = load_model_from_xlsx(file_path, "en", "UTC").unwrap();
|
||||
|
||||
let base_name = Path::new(file_path).file_name().unwrap().to_str().unwrap();
|
||||
|
||||
let temp_path_buff = temp_dir_name.join(base_name);
|
||||
let temp_file_path = &format!("{}.xlsx", temp_path_buff.to_str().unwrap());
|
||||
// test can save
|
||||
save_to_xlsx(&model1, temp_file_path).unwrap();
|
||||
// test can open
|
||||
let mut model2 = load_model_from_xlsx(temp_file_path, "en", "UTC").unwrap();
|
||||
model2.evaluate();
|
||||
compare_models(&model1, &model2)
|
||||
}
|
||||
89
xlsx/src/error.rs
Normal file
89
xlsx/src/error.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use std::io;
|
||||
use std::num::{ParseFloatError, ParseIntError};
|
||||
use thiserror::Error;
|
||||
use zip::result::ZipError;
|
||||
|
||||
#[derive(Error, Debug, PartialEq, Eq)]
|
||||
pub enum XlsxError {
|
||||
#[error("I/O Error: {0}")]
|
||||
IO(String),
|
||||
#[error("Zip Error: {0}")]
|
||||
Zip(String),
|
||||
#[error("XML Error: {0}")]
|
||||
Xml(String),
|
||||
#[error("{0}")]
|
||||
Workbook(String),
|
||||
#[error("Evaluation Error: {}", .0.join("; "))]
|
||||
Evaluation(Vec<String>),
|
||||
#[error("Comparison Error: {0}")]
|
||||
Comparison(String),
|
||||
#[error("Not Implemented Error: {0}")]
|
||||
NotImplemented(String),
|
||||
}
|
||||
|
||||
impl From<io::Error> for XlsxError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
XlsxError::IO(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ZipError> for XlsxError {
|
||||
fn from(error: ZipError) -> Self {
|
||||
XlsxError::Zip(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseIntError> for XlsxError {
|
||||
fn from(error: ParseIntError) -> Self {
|
||||
XlsxError::Xml(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseFloatError> for XlsxError {
|
||||
fn from(error: ParseFloatError) -> Self {
|
||||
XlsxError::Xml(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<roxmltree::Error> for XlsxError {
|
||||
fn from(error: roxmltree::Error) -> Self {
|
||||
XlsxError::Xml(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl XlsxError {
|
||||
pub fn user_message(&self) -> String {
|
||||
match &self {
|
||||
XlsxError::IO(_) | XlsxError::Workbook(_) => self.to_string(),
|
||||
XlsxError::Zip(_) | XlsxError::Xml(_) => {
|
||||
"IronCalc can only open workbooks created by Microsoft Excel. \
|
||||
Can you open this file with Excel, save it to a new file, \
|
||||
and then open that new file with IronCalc? If you've already tried this, \
|
||||
then send this workbook to support@ironcalc.com and our engineering team \
|
||||
will work with you to fix the issue."
|
||||
.to_string()
|
||||
}
|
||||
XlsxError::NotImplemented(error) => format!(
|
||||
"IronCalc cannot open this workbook due to the following unsupported features: \
|
||||
{error}. You can either re-implement these parts of your workbook using features \
|
||||
supported by IronCalc, or you can send this workbook to support@ironcalc.com \
|
||||
and our engineering team will work with you to fix the issue.",
|
||||
),
|
||||
XlsxError::Evaluation(errors) => format!(
|
||||
"IronCalc could not evaluate this workbook without errors. This may indicate a bug or missing feature \
|
||||
in the IronCalc spreadsheet calculation engine. Please contact support@ironcalc.com, share the entirety \
|
||||
of this error message and the relevant workbook, and we will work with you to resolve the issue. \
|
||||
Detailed error message:\n{}",
|
||||
errors.join("\n")
|
||||
),
|
||||
XlsxError::Comparison(error) => format!(
|
||||
"IronCalc produces different results when evaluating the workbook \
|
||||
than those already present in the workbook. This may indicate a bug or missing \
|
||||
feature in the IronCalc spreadsheet calculation engine. Please contact \
|
||||
support@ironcalc.com, share the entirety of this error message and the relevant \
|
||||
workbook, and we will work with you to resolve the issue. \
|
||||
Detailed error message:\n{error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
6
xlsx/src/export/_rels.rs
Normal file
6
xlsx/src/export/_rels.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use ironcalc_base::types::Workbook;
|
||||
|
||||
pub(crate) fn get_dot_rels(_: &Workbook) -> String {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#.to_owned()
|
||||
}
|
||||
69
xlsx/src/export/doc_props.rs
Normal file
69
xlsx/src/export/doc_props.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use ironcalc_base::{
|
||||
new_empty::{APPLICATION, APP_VERSION, IRONCALC_USER},
|
||||
types::Workbook,
|
||||
};
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
// Application-Defined File Properties part
|
||||
pub(crate) fn get_app_xml(_: &Workbook) -> String {
|
||||
// contains application name and version
|
||||
|
||||
// The next few are not needed:
|
||||
// security. It is password protected (not implemented)
|
||||
// Scale
|
||||
// Titles of parts
|
||||
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>
|
||||
<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" \
|
||||
xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\">\
|
||||
<Application>{}</Application>\
|
||||
<AppVersion>{}</AppVersion>\
|
||||
</Properties>",
|
||||
APPLICATION, APP_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
// Core File Properties part
|
||||
pub(crate) fn get_core_xml(workbook: &Workbook, milliseconds: i64) -> Result<String, XlsxError> {
|
||||
// contains the name of the creator, last modified and date
|
||||
let metadata = &workbook.metadata;
|
||||
let creator = metadata.creator.to_string();
|
||||
let last_modified_by = IRONCALC_USER.to_string();
|
||||
let created = metadata.created.to_string();
|
||||
// FIXME add now
|
||||
|
||||
let seconds = milliseconds / 1000;
|
||||
let dt = match NaiveDateTime::from_timestamp_opt(seconds, 0) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(XlsxError::Xml(format!(
|
||||
"Invalid timestamp: {}",
|
||||
milliseconds
|
||||
)))
|
||||
}
|
||||
};
|
||||
let last_modified = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
Ok(format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>
|
||||
<cp:coreProperties \
|
||||
xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" \
|
||||
xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" \
|
||||
xmlns:dcmitype=\"http://purl.org/dc/dcmitype/\" \
|
||||
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"> \
|
||||
<dc:title></dc:title><dc:subject></dc:subject>\
|
||||
<dc:creator>{}</dc:creator>\
|
||||
<cp:keywords></cp:keywords>\
|
||||
<dc:description></dc:description>\
|
||||
<cp:lastModifiedBy>{}</cp:lastModifiedBy>\
|
||||
<cp:revision></cp:revision>\
|
||||
<dcterms:created xsi:type=\"dcterms:W3CDTF\">{}</dcterms:created>\
|
||||
<dcterms:modified xsi:type=\"dcterms:W3CDTF\">{}</dcterms:modified>\
|
||||
<cp:category></cp:category>\
|
||||
<cp:contentStatus></cp:contentStatus>\
|
||||
</cp:coreProperties>",
|
||||
creator, last_modified_by, created, last_modified
|
||||
))
|
||||
}
|
||||
99
xlsx/src/export/escape.rs
Normal file
99
xlsx/src/export/escape.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
// Taken from :
|
||||
|
||||
// https://docs.rs/xml-rs/latest/src/xml/escape.rs.html#1-125
|
||||
|
||||
//! Contains functions for performing XML special characters escaping.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
enum Value {
|
||||
Char(char),
|
||||
Str(&'static str),
|
||||
}
|
||||
|
||||
fn escape_char(c: char) -> Value {
|
||||
match c {
|
||||
'<' => Value::Str("<"),
|
||||
'>' => Value::Str(">"),
|
||||
'"' => Value::Str("""),
|
||||
'\'' => Value::Str("'"),
|
||||
'&' => Value::Str("&"),
|
||||
'\n' => Value::Str("
"),
|
||||
'\r' => Value::Str("
"),
|
||||
_ => Value::Char(c),
|
||||
}
|
||||
}
|
||||
|
||||
enum Process<'a> {
|
||||
Borrowed(&'a str),
|
||||
Owned(String),
|
||||
}
|
||||
|
||||
impl<'a> Process<'a> {
|
||||
fn process(&mut self, (i, next): (usize, Value)) {
|
||||
match next {
|
||||
Value::Str(s) => match *self {
|
||||
Process::Owned(ref mut o) => o.push_str(s),
|
||||
Process::Borrowed(b) => {
|
||||
let mut r = String::with_capacity(b.len() + s.len());
|
||||
r.push_str(&b[..i]);
|
||||
r.push_str(s);
|
||||
*self = Process::Owned(r);
|
||||
}
|
||||
},
|
||||
Value::Char(c) => match *self {
|
||||
Process::Borrowed(_) => {}
|
||||
Process::Owned(ref mut o) => o.push(c),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_result(self) -> Cow<'a, str> {
|
||||
match self {
|
||||
Process::Borrowed(b) => Cow::Borrowed(b),
|
||||
Process::Owned(o) => Cow::Owned(o),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Extend<(usize, Value)> for Process<'a> {
|
||||
fn extend<I: IntoIterator<Item = (usize, Value)>>(&mut self, it: I) {
|
||||
for v in it.into_iter() {
|
||||
self.process(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs escaping of common XML characters inside an attribute value.
|
||||
///
|
||||
/// This function replaces several important markup characters with their
|
||||
/// entity equivalents:
|
||||
///
|
||||
/// * `<` → `<`
|
||||
/// * `>` → `>`
|
||||
/// * `"` → `"`
|
||||
/// * `'` → `'`
|
||||
/// * `&` → `&`
|
||||
///
|
||||
/// The resulting string is safe to use inside XML attribute values.
|
||||
///
|
||||
/// Does not perform allocations if the given string does not contain escapable characters.
|
||||
pub fn escape_xml(s: &str) -> Cow<str> {
|
||||
let mut p = Process::Borrowed(s);
|
||||
p.extend(s.char_indices().map(|(ind, c)| (ind, escape_char(c))));
|
||||
p.into_result()
|
||||
}
|
||||
|
||||
// A simpler function that allocates memory for each replacement
|
||||
// fn escape_xml(value: &str) -> String {
|
||||
// value
|
||||
// .replace('&', "&")
|
||||
// .replace('<', "<")
|
||||
// .replace('>', ">")
|
||||
// .replace('"', """)
|
||||
// .replace('\'', "'")
|
||||
// }
|
||||
|
||||
// See also:
|
||||
// https://docs.rs/shell-escape/0.1.5/src/shell_escape/lib.rs.html#17-23
|
||||
// https://aaronerhardt.github.io/docs/relm4/src/quick_xml/escapei.rs.html#69-106
|
||||
138
xlsx/src/export/mod.rs
Normal file
138
xlsx/src/export/mod.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
mod _rels;
|
||||
mod doc_props;
|
||||
mod escape;
|
||||
mod shared_strings;
|
||||
mod styles;
|
||||
mod workbook;
|
||||
mod workbook_xml_rels;
|
||||
mod worksheets;
|
||||
mod xml_constants;
|
||||
|
||||
use std::io::BufWriter;
|
||||
use std::{
|
||||
fs,
|
||||
io::{Seek, Write},
|
||||
};
|
||||
|
||||
use ironcalc_base::expressions::utils::number_to_column;
|
||||
use ironcalc_base::model::{get_milliseconds_since_epoch, Model};
|
||||
use ironcalc_base::types::Workbook;
|
||||
|
||||
use self::xml_constants::XML_DECLARATION;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
fn get_content_types_xml(workbook: &Workbook) -> String {
|
||||
// A list of all files in the zip
|
||||
let mut content = vec![
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">"#.to_string(),
|
||||
r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#.to_string(),
|
||||
r#"<Default Extension="xml" ContentType="application/xml"/>"#.to_string(),
|
||||
r#"<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>"#.to_string(),
|
||||
];
|
||||
for worksheet in 0..workbook.worksheets.len() {
|
||||
let sheet = format!(
|
||||
r#"<Override PartName="/xl/worksheets/sheet{}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#,
|
||||
worksheet + 1
|
||||
);
|
||||
content.push(sheet);
|
||||
}
|
||||
// we skip the theme and calcChain
|
||||
// r#"<Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>"#,
|
||||
// r#"<Override PartName="/xl/calcChain.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml"/>"#,
|
||||
content.extend([
|
||||
r#"<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#.to_string(),
|
||||
r#"<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>"#.to_string(),
|
||||
r#"<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>"#.to_string(),
|
||||
r#"<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>"#.to_string(),
|
||||
r#"</Types>"#.to_string(),
|
||||
]);
|
||||
format!("{XML_DECLARATION}\n{}", content.join(""))
|
||||
}
|
||||
|
||||
/// Exports a model to an xlsx file
|
||||
pub fn save_to_xlsx(model: &Model, file_name: &str) -> Result<(), XlsxError> {
|
||||
let file_path = std::path::Path::new(&file_name);
|
||||
if file_path.exists() {
|
||||
return Err(XlsxError::IO(format!("file {} already exists", file_name)));
|
||||
}
|
||||
let file = fs::File::create(file_path).unwrap();
|
||||
let writer = BufWriter::new(file);
|
||||
save_xlsx_to_writer(model, writer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_xlsx_to_writer<W: Write + Seek>(model: &Model, writer: W) -> Result<W, XlsxError> {
|
||||
let workbook = &model.workbook;
|
||||
let mut zip = zip::ZipWriter::new(writer);
|
||||
|
||||
let options =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
// root folder
|
||||
zip.start_file("[Content_Types].xml", options)?;
|
||||
zip.write_all(get_content_types_xml(workbook).as_bytes())?;
|
||||
|
||||
zip.add_directory("docProps", options)?;
|
||||
zip.start_file("docProps/app.xml", options)?;
|
||||
zip.write_all(doc_props::get_app_xml(workbook).as_bytes())?;
|
||||
zip.start_file("docProps/core.xml", options)?;
|
||||
let milliseconds = get_milliseconds_since_epoch();
|
||||
zip.write_all(doc_props::get_core_xml(workbook, milliseconds)?.as_bytes())?;
|
||||
|
||||
// Package-relationship item
|
||||
zip.add_directory("_rels", options)?;
|
||||
zip.start_file("_rels/.rels", options)?;
|
||||
zip.write_all(_rels::get_dot_rels(workbook).as_bytes())?;
|
||||
|
||||
zip.add_directory("xl", options)?;
|
||||
zip.start_file("xl/sharedStrings.xml", options)?;
|
||||
zip.write_all(shared_strings::get_shared_strings_xml(workbook).as_bytes())?;
|
||||
zip.start_file("xl/styles.xml", options)?;
|
||||
zip.write_all(styles::get_styles_xml(workbook).as_bytes())?;
|
||||
zip.start_file("xl/workbook.xml", options)?;
|
||||
zip.write_all(workbook::get_workbook_xml(workbook).as_bytes())?;
|
||||
|
||||
zip.add_directory("xl/_rels", options)?;
|
||||
zip.start_file("xl/_rels/workbook.xml.rels", options)?;
|
||||
zip.write_all(workbook_xml_rels::get_workbook_xml_rels(workbook).as_bytes())?;
|
||||
|
||||
zip.add_directory("xl/worksheets", options)?;
|
||||
for (sheet_index, worksheet) in workbook.worksheets.iter().enumerate() {
|
||||
let id = sheet_index + 1;
|
||||
zip.start_file(&format!("xl/worksheets/sheet{id}.xml"), options)?;
|
||||
let dimension = model
|
||||
.workbook
|
||||
.worksheet(sheet_index as u32)
|
||||
.unwrap()
|
||||
.dimension();
|
||||
let column_min_str = number_to_column(dimension.min_column).unwrap();
|
||||
let column_max_str = number_to_column(dimension.max_column).unwrap();
|
||||
let min_row = dimension.min_row;
|
||||
let max_row = dimension.max_row;
|
||||
let sheet_dimension_str = &format!("{column_min_str}{min_row}:{column_max_str}{max_row}");
|
||||
zip.write_all(
|
||||
worksheets::get_worksheet_xml(
|
||||
worksheet,
|
||||
&model.parsed_formulas[sheet_index],
|
||||
sheet_dimension_str,
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let writer = zip.finish()?;
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
/// Exports an internal representation of a workbook into an equivalent IronCalc json format
|
||||
pub fn save_to_json(workbook: Workbook, output: &str) {
|
||||
let s = serde_json::to_string(&workbook).unwrap();
|
||||
let file_path = std::path::Path::new(output);
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(s.as_bytes()).unwrap();
|
||||
}
|
||||
16
xlsx/src/export/shared_strings.rs
Normal file
16
xlsx/src/export/shared_strings.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use ironcalc_base::types::Workbook;
|
||||
|
||||
use super::{escape::escape_xml, xml_constants::XML_DECLARATION};
|
||||
|
||||
pub(crate) fn get_shared_strings_xml(model: &Workbook) -> String {
|
||||
let mut shared_strings: Vec<String> = vec![];
|
||||
let count = &model.shared_strings.len();
|
||||
let unique_count = &model.shared_strings.len();
|
||||
for shared_string in &model.shared_strings {
|
||||
shared_strings.push(format!("<si><t>{}</t></si>", escape_xml(shared_string)));
|
||||
}
|
||||
format!("{}\n\
|
||||
<sst xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" count=\"{count}\" uniqueCount=\"{unique_count}\">\
|
||||
{}\
|
||||
</sst>", XML_DECLARATION, shared_strings.join(""))
|
||||
}
|
||||
282
xlsx/src/export/styles.rs
Normal file
282
xlsx/src/export/styles.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
use ironcalc_base::types::{
|
||||
Alignment, BorderItem, HorizontalAlignment, Styles, VerticalAlignment, Workbook,
|
||||
};
|
||||
|
||||
use super::{escape::escape_xml, xml_constants::XML_DECLARATION};
|
||||
|
||||
fn get_fonts_xml(styles: &Styles) -> String {
|
||||
let fonts = &styles.fonts;
|
||||
let mut fonts_str: Vec<String> = vec![];
|
||||
for font in fonts {
|
||||
let size = format!("<sz val=\"{}\"/>", font.sz);
|
||||
let color = if let Some(some_color) = &font.color {
|
||||
format!("<color rgb=\"FF{}\"/>", some_color.trim_start_matches('#'))
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let name = format!("<name val=\"{}\"/>", escape_xml(&font.name));
|
||||
let bold = if font.b { "<b/>" } else { "" };
|
||||
let italic = if font.i { "<i/>" } else { "" };
|
||||
let underline = if font.u { "<u/>" } else { "" };
|
||||
let strike = if font.strike { "<strike/>" } else { "" };
|
||||
let family = format!("<family val=\"{}\"/>", font.family);
|
||||
let scheme = format!("<scheme val=\"{}\"/>", font.scheme);
|
||||
fonts_str.push(format!(
|
||||
"<font>\
|
||||
{size}\
|
||||
{color}\
|
||||
{name}\
|
||||
{bold}\
|
||||
{italic}\
|
||||
{underline}\
|
||||
{strike}\
|
||||
{family}\
|
||||
{scheme}\
|
||||
</font>"
|
||||
));
|
||||
}
|
||||
let font_count = fonts.len();
|
||||
format!(
|
||||
"<fonts count=\"{font_count}\">{}</fonts>",
|
||||
fonts_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_color_xml(color: &Option<String>, name: &str) -> String {
|
||||
// We blindly append FF at the beginning of these RGB color to make it ARGB
|
||||
if let Some(some_color) = color {
|
||||
format!("<{name} rgb=\"FF{}\"/>", some_color.trim_start_matches('#'))
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_fills_xml(styles: &Styles) -> String {
|
||||
let fills = &styles.fills;
|
||||
let mut fills_str: Vec<String> = vec![];
|
||||
for fill in fills {
|
||||
let pattern_type = &fill.pattern_type;
|
||||
let fg_color = get_color_xml(&fill.fg_color, "fgColor");
|
||||
let bg_color = get_color_xml(&fill.bg_color, "bgColor");
|
||||
fills_str.push(format!(
|
||||
"<fill><patternFill patternType=\"{pattern_type}\">{fg_color}{bg_color}</patternFill></fill>"
|
||||
));
|
||||
}
|
||||
let fill_count = fills.len();
|
||||
format!(
|
||||
"<fills count=\"{fill_count}\">{}</fills>",
|
||||
fills_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_border_xml(border: &Option<BorderItem>, name: &str) -> String {
|
||||
if let Some(border_item) = border {
|
||||
let color = get_color_xml(&border_item.color, "color");
|
||||
return format!("<{name} style=\"{}\">{color}</{name}>", border_item.style);
|
||||
}
|
||||
format!("<{name}/>")
|
||||
}
|
||||
|
||||
fn get_borders_xml(styles: &Styles) -> String {
|
||||
let borders = &styles.borders;
|
||||
let mut borders_str: Vec<String> = vec![];
|
||||
let border_count = borders.len();
|
||||
for border in borders {
|
||||
// TODO: diagonal_up/diagonal_down?
|
||||
let border_left = get_border_xml(&border.left, "left");
|
||||
let border_right = get_border_xml(&border.right, "right");
|
||||
let border_top = get_border_xml(&border.top, "top");
|
||||
let border_bottom = get_border_xml(&border.bottom, "bottom");
|
||||
let border_diagonal = get_border_xml(&border.diagonal, "diagonal");
|
||||
borders_str.push(format!(
|
||||
"<border>{border_left}{border_right}{border_top}{border_bottom}{border_diagonal}</border>"
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"<borders count=\"{border_count}\">{}</borders>",
|
||||
borders_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
// <numFmts count="1">
|
||||
// <numFmt numFmtId="164" formatCode="##,#00;[Blue]\-\-#,##0"/>
|
||||
// </numFmts>
|
||||
fn get_cell_number_formats_xml(styles: &Styles) -> String {
|
||||
let num_fmts = &styles.num_fmts;
|
||||
let mut num_fmts_str: Vec<String> = vec![];
|
||||
let num_fmt_count = num_fmts.len();
|
||||
for num_fmt in num_fmts {
|
||||
let num_fmt_id = num_fmt.num_fmt_id;
|
||||
let format_code = &num_fmt.format_code;
|
||||
let format_code = escape_xml(format_code);
|
||||
num_fmts_str.push(format!(
|
||||
"<numFmt numFmtId=\"{num_fmt_id}\" formatCode=\"{format_code}\"/>"
|
||||
));
|
||||
}
|
||||
if num_fmt_count == 0 {
|
||||
return "".to_string();
|
||||
}
|
||||
format!(
|
||||
"<numFmts count=\"{num_fmt_count}\">{}</numFmts>",
|
||||
num_fmts_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_alignment(alignment: &Alignment) -> String {
|
||||
let wrap_text = if alignment.wrap_text {
|
||||
" wrapText=\"1\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let horizontal = if alignment.horizontal != HorizontalAlignment::default() {
|
||||
format!(" horizontal=\"{}\"", alignment.horizontal)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let vertical = if alignment.vertical != VerticalAlignment::default() {
|
||||
format!(" vertical=\"{}\"", alignment.vertical)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
format!("<alignment{wrap_text}{horizontal}{vertical}/>")
|
||||
}
|
||||
|
||||
fn get_cell_style_xfs_xml(styles: &Styles) -> String {
|
||||
let cell_style_xfs = &styles.cell_style_xfs;
|
||||
let mut cell_style_str: Vec<String> = vec![];
|
||||
for cell_style_xf in cell_style_xfs {
|
||||
let border_id = cell_style_xf.border_id;
|
||||
let fill_id = cell_style_xf.fill_id;
|
||||
let font_id = cell_style_xf.font_id;
|
||||
let num_fmt_id = cell_style_xf.num_fmt_id;
|
||||
let apply_alignment_str = if cell_style_xf.apply_alignment {
|
||||
r#" applyAlignment="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let apply_font_str = if cell_style_xf.apply_font {
|
||||
r#" applyFont="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let apply_fill_str = if cell_style_xf.apply_fill {
|
||||
r#" applyFill="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
cell_style_str.push(format!(
|
||||
"<xf \
|
||||
borderId=\"{border_id}\" \
|
||||
fillId=\"{fill_id}\" \
|
||||
fontId=\"{font_id}\" \
|
||||
numFmtId=\"{num_fmt_id}\"\
|
||||
{apply_alignment_str}\
|
||||
{apply_font_str}\
|
||||
{apply_fill_str}/>"
|
||||
));
|
||||
}
|
||||
let style_count = cell_style_xfs.len();
|
||||
format!(
|
||||
"<cellStyleXfs count=\"{style_count}\">{}</cellStyleXfs>",
|
||||
cell_style_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_cell_xfs_xml(styles: &Styles) -> String {
|
||||
let cell_xfs = &styles.cell_xfs;
|
||||
let mut cell_xfs_str: Vec<String> = vec![];
|
||||
for cell_xf in cell_xfs {
|
||||
let xf_id = cell_xf.xf_id;
|
||||
let border_id = cell_xf.border_id;
|
||||
let fill_id = cell_xf.fill_id;
|
||||
let font_id = cell_xf.font_id;
|
||||
let num_fmt_id = cell_xf.num_fmt_id;
|
||||
let quote_prefix_str = if cell_xf.quote_prefix {
|
||||
r#" quotePrefix="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let apply_alignment_str = if cell_xf.apply_alignment {
|
||||
r#" applyAlignment="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let apply_font_str = if cell_xf.apply_font {
|
||||
r#" applyFont="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let apply_fill_str = if cell_xf.apply_fill {
|
||||
r#" applyFill="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let properties = format!(
|
||||
"xfId=\"{xf_id}\" \
|
||||
borderId=\"{border_id}\" \
|
||||
fillId=\"{fill_id}\" \
|
||||
fontId=\"{font_id}\" \
|
||||
numFmtId=\"{num_fmt_id}\"\
|
||||
{quote_prefix_str}\
|
||||
{apply_alignment_str}\
|
||||
{apply_font_str}\
|
||||
{apply_fill_str}"
|
||||
);
|
||||
if let Some(alignment) = &cell_xf.alignment {
|
||||
let alignment = get_alignment(alignment);
|
||||
cell_xfs_str.push(format!("<xf {properties}>{alignment}</xf>"));
|
||||
} else {
|
||||
cell_xfs_str.push(format!("<xf {properties}/>"));
|
||||
}
|
||||
}
|
||||
let style_count = cell_xfs.len();
|
||||
format!(
|
||||
"<cellXfs count=\"{style_count}\">{}</cellXfs>",
|
||||
cell_xfs_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
// <cellStyle xfId="0" name="Normal" builtinId="0"/>
|
||||
fn get_cell_styles_xml(styles: &Styles) -> String {
|
||||
let cell_styles = &styles.cell_styles;
|
||||
let mut cell_styles_str: Vec<String> = vec![];
|
||||
for cell_style in cell_styles {
|
||||
let xf_id = cell_style.xf_id;
|
||||
let name = &cell_style.name;
|
||||
let name = escape_xml(name);
|
||||
let builtin_id = cell_style.builtin_id;
|
||||
cell_styles_str.push(format!(
|
||||
"<cellStyle xfId=\"{xf_id}\" name=\"{name}\" builtinId=\"{builtin_id}\"/>"
|
||||
));
|
||||
}
|
||||
let style_count = cell_styles.len();
|
||||
format!(
|
||||
"<cellStyles count=\"{style_count}\">{}</cellStyles>",
|
||||
cell_styles_str.join("")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn get_styles_xml(model: &Workbook) -> String {
|
||||
let styles = &model.styles;
|
||||
let fonts = get_fonts_xml(styles);
|
||||
let fills = get_fills_xml(styles);
|
||||
let borders = get_borders_xml(styles);
|
||||
let number_formats = get_cell_number_formats_xml(styles);
|
||||
let cell_style_xfs = get_cell_style_xfs_xml(styles);
|
||||
let cell_xfs = get_cell_xfs_xml(styles);
|
||||
let cell_styles = get_cell_styles_xml(styles);
|
||||
|
||||
format!(
|
||||
"{XML_DECLARATION}
|
||||
<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
|
||||
{number_formats}\
|
||||
{fonts}\
|
||||
{fills}\
|
||||
{borders}\
|
||||
{cell_style_xfs}\
|
||||
{cell_xfs}\
|
||||
{cell_styles}\
|
||||
<dxfs count=\"0\"/>\
|
||||
</styleSheet>"
|
||||
)
|
||||
}
|
||||
2
xlsx/src/export/test/mod.rs
Normal file
2
xlsx/src/export/test/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod test_escape;
|
||||
mod test_export;
|
||||
25
xlsx/src/export/test/test_escape.rs
Normal file
25
xlsx/src/export/test/test_escape.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use crate::export::escape::escape_xml;
|
||||
|
||||
#[test]
|
||||
fn test_escape_xml() {
|
||||
assert_eq!(escape_xml("all good"), "all good");
|
||||
assert_eq!(escape_xml("3 < 4"), "3 < 4");
|
||||
assert_eq!(escape_xml("3 > 4"), "3 > 4");
|
||||
assert_eq!(escape_xml("3 & 4"), "3 & 4");
|
||||
assert_eq!(escape_xml("3 && 4"), "3 && 4");
|
||||
assert_eq!(escape_xml("3 \"literal\" 4"), "3 "literal" 4");
|
||||
assert_eq!(
|
||||
escape_xml("I don't 'know'"),
|
||||
"I don't 'know'"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_xml("This is <>&\"' say"),
|
||||
"This is <>&"' say"
|
||||
);
|
||||
}
|
||||
|
||||
// '&' => "&"
|
||||
// '<' "<")
|
||||
// '>' => ">"
|
||||
// '"' => """
|
||||
// '\'' => "'"
|
||||
134
xlsx/src/export/test/test_export.rs
Normal file
134
xlsx/src/export/test/test_export.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::fs;
|
||||
|
||||
use ironcalc_base::model::Model;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
use crate::{export::save_to_xlsx, import::load_model_from_xlsx};
|
||||
|
||||
pub fn new_empty_model() -> Model {
|
||||
Model::new_empty("model", "en", "UTC").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_values() {
|
||||
let mut model = new_empty_model();
|
||||
// numbers
|
||||
model.set_user_input(0, 1, 1, "123.456".to_string());
|
||||
// strings
|
||||
model.set_user_input(0, 2, 1, "Hello world!".to_string());
|
||||
model.set_user_input(0, 3, 1, "Hello world!".to_string());
|
||||
model.set_user_input(0, 4, 1, "你好世界!".to_string());
|
||||
// booleans
|
||||
model.set_user_input(0, 5, 1, "TRUE".to_string());
|
||||
model.set_user_input(0, 6, 1, "FALSE".to_string());
|
||||
// errors
|
||||
model.set_user_input(0, 7, 1, "#VALUE!".to_string());
|
||||
|
||||
// noop
|
||||
model.evaluate();
|
||||
|
||||
let temp_file_name = "temp_file_test_values.xlsx";
|
||||
save_to_xlsx(&model, temp_file_name).unwrap();
|
||||
|
||||
let model = load_model_from_xlsx(temp_file_name, "en", "UTC").unwrap();
|
||||
assert_eq!(model.formatted_cell_value(0, 1, 1).unwrap(), "123.456");
|
||||
assert_eq!(model.formatted_cell_value(0, 2, 1).unwrap(), "Hello world!");
|
||||
assert_eq!(model.formatted_cell_value(0, 3, 1).unwrap(), "Hello world!");
|
||||
assert_eq!(model.formatted_cell_value(0, 4, 1).unwrap(), "你好世界!");
|
||||
assert_eq!(model.formatted_cell_value(0, 5, 1).unwrap(), "TRUE");
|
||||
assert_eq!(model.formatted_cell_value(0, 6, 1).unwrap(), "FALSE");
|
||||
assert_eq!(model.formatted_cell_value(0, 7, 1).unwrap(), "#VALUE!");
|
||||
|
||||
fs::remove_file(temp_file_name).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formulas() {
|
||||
let mut model = new_empty_model();
|
||||
model.set_user_input(0, 1, 1, "5.5".to_string());
|
||||
model.set_user_input(0, 2, 1, "6.5".to_string());
|
||||
model.set_user_input(0, 3, 1, "7.5".to_string());
|
||||
|
||||
model.set_user_input(0, 1, 2, "=A1*2".to_string());
|
||||
model.set_user_input(0, 2, 2, "=A2*2".to_string());
|
||||
model.set_user_input(0, 3, 2, "=A3*2".to_string());
|
||||
model.set_user_input(0, 4, 2, "=SUM(A1:B3)".to_string());
|
||||
|
||||
model.evaluate();
|
||||
let temp_file_name = "temp_file_test_formulas.xlsx";
|
||||
save_to_xlsx(&model, temp_file_name).unwrap();
|
||||
|
||||
let model = load_model_from_xlsx(temp_file_name, "en", "UTC").unwrap();
|
||||
assert_eq!(model.formatted_cell_value(0, 1, 2).unwrap(), "11");
|
||||
assert_eq!(model.formatted_cell_value(0, 2, 2).unwrap(), "13");
|
||||
assert_eq!(model.formatted_cell_value(0, 3, 2).unwrap(), "15");
|
||||
assert_eq!(model.formatted_cell_value(0, 4, 2).unwrap(), "58.5");
|
||||
fs::remove_file(temp_file_name).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sheets() {
|
||||
let mut model = new_empty_model();
|
||||
model.add_sheet("With space").unwrap();
|
||||
// xml escaped
|
||||
model.add_sheet("Tango & Cash").unwrap();
|
||||
model.add_sheet("你好世界").unwrap();
|
||||
|
||||
// noop
|
||||
model.evaluate();
|
||||
|
||||
let temp_file_name = "temp_file_test_sheets.xlsx";
|
||||
save_to_xlsx(&model, temp_file_name).unwrap();
|
||||
|
||||
let model = load_model_from_xlsx(temp_file_name, "en", "UTC").unwrap();
|
||||
assert_eq!(
|
||||
model.workbook.get_worksheet_names(),
|
||||
vec!["Sheet1", "With space", "Tango & Cash", "你好世界"]
|
||||
);
|
||||
fs::remove_file(temp_file_name).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_named_styles() {
|
||||
let mut model = new_empty_model();
|
||||
model.set_user_input(0, 1, 1, "5.5".to_string());
|
||||
let mut style = model.get_style_for_cell(0, 1, 1);
|
||||
style.font.b = true;
|
||||
style.font.i = true;
|
||||
assert!(model.set_cell_style(0, 1, 1, &style).is_ok());
|
||||
let bold_style_index = model.get_cell_style_index(0, 1, 1);
|
||||
let e = model
|
||||
.workbook
|
||||
.styles
|
||||
.add_named_cell_style("bold & italics", bold_style_index);
|
||||
assert!(e.is_ok());
|
||||
|
||||
// noop
|
||||
model.evaluate();
|
||||
|
||||
let temp_file_name = "temp_file_test_named_styles.xlsx";
|
||||
save_to_xlsx(&model, temp_file_name).unwrap();
|
||||
|
||||
let model = load_model_from_xlsx(temp_file_name, "en", "UTC").unwrap();
|
||||
assert!(model
|
||||
.workbook
|
||||
.styles
|
||||
.get_style_index_by_name("bold & italics")
|
||||
.is_ok());
|
||||
fs::remove_file(temp_file_name).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_file() {
|
||||
let file_name = "existing_file.xlsx";
|
||||
fs::File::create(file_name).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
save_to_xlsx(&new_empty_model(), file_name),
|
||||
Err(XlsxError::IO(
|
||||
"file existing_file.xlsx already exists".to_string()
|
||||
)),
|
||||
);
|
||||
|
||||
fs::remove_file(file_name).unwrap();
|
||||
}
|
||||
91
xlsx/src/export/workbook.rs
Normal file
91
xlsx/src/export/workbook.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||
|
||||
//! A workbook is composed of workbook-level properties and a collection of 1 or more sheets.
|
||||
//! The workbook part and corresponding properties comprise data
|
||||
//! used to set application and workbook-level operational state. The workbook also serves to bind all the sheets
|
||||
//! and child elements into an organized single file. The workbook XML attributes and elements include information
|
||||
//! about what application last saved the file, where and how the windows of the workbook were positioned, and
|
||||
//! an enumeration of the worksheets in the workbook.
|
||||
//! This is the XML for the smallest possible (blank) workbook:
|
||||
//!
|
||||
//! <workbook>
|
||||
//! <sheets>
|
||||
//! <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||
//! </sheets>
|
||||
//! </workbook>
|
||||
//!
|
||||
//! Note that this workbook has a single sheet, named Sheet1. An Id for the sheet is required, and a relationship Id
|
||||
//! pointing to the location of the sheet definition is also required.
|
||||
//!
|
||||
//!
|
||||
//!
|
||||
//! The most important objet of this part is a collection of all the sheets and all the defined names
|
||||
//! of the workbook.
|
||||
//!
|
||||
//! It also may hold state properties like the selected tab
|
||||
|
||||
//! # bookViews
|
||||
//!
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ironcalc_base::types::{SheetState, Workbook};
|
||||
|
||||
use super::escape::escape_xml;
|
||||
use super::xml_constants::XML_DECLARATION;
|
||||
|
||||
pub(crate) fn get_workbook_xml(workbook: &Workbook) -> String {
|
||||
// sheets
|
||||
// <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
|
||||
let mut sheets_str: Vec<String> = vec![];
|
||||
let mut sheet_id_to_sheet_index: HashMap<u32, u32> = HashMap::new();
|
||||
for (sheet_index, worksheet) in workbook.worksheets.iter().enumerate() {
|
||||
let name = &worksheet.name;
|
||||
let name = escape_xml(name);
|
||||
let sheet_id = worksheet.sheet_id;
|
||||
let state_str = match &worksheet.state {
|
||||
SheetState::Visible => "",
|
||||
SheetState::Hidden => " state=\"hidden\"",
|
||||
SheetState::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
|
||||
sheets_str.push(format!(
|
||||
"<sheet name=\"{name}\" sheetId=\"{sheet_id}\" r:id=\"rId{}\"{state_str}/>",
|
||||
sheet_index + 1
|
||||
));
|
||||
sheet_id_to_sheet_index.insert(sheet_id, sheet_index as u32);
|
||||
}
|
||||
|
||||
// defined names
|
||||
// <definedName localSheetId="4" name="answer">shared!$G$5</definedName>
|
||||
// <definedName name="numbers">Sheet1!$A$16:$A$18</definedName>
|
||||
let mut defined_names_str: Vec<String> = vec![];
|
||||
for defined_name in &workbook.defined_names {
|
||||
let name = &defined_name.name;
|
||||
let name = escape_xml(name);
|
||||
let local_sheet_id = if let Some(sheet_id) = defined_name.sheet_id {
|
||||
// In Excel the localSheetId is actually the index of the sheet.
|
||||
let excel_local_sheet_id = sheet_id_to_sheet_index.get(&sheet_id).unwrap();
|
||||
format!(" localSheetId=\"{excel_local_sheet_id}\"")
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let formula = escape_xml(&defined_name.formula);
|
||||
defined_names_str.push(format!(
|
||||
"<definedName name=\"{name}\"{local_sheet_id}>{formula}</definedName>"
|
||||
))
|
||||
}
|
||||
|
||||
let sheets = sheets_str.join("");
|
||||
let defined_names = defined_names_str.join("");
|
||||
format!("{XML_DECLARATION}\n\
|
||||
<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\
|
||||
<sheets>\
|
||||
{sheets}\
|
||||
</sheets>\
|
||||
<definedNames>\
|
||||
{defined_names}\
|
||||
</definedNames>\
|
||||
<calcPr/>\
|
||||
</workbook>")
|
||||
}
|
||||
25
xlsx/src/export/workbook_xml_rels.rs
Normal file
25
xlsx/src/export/workbook_xml_rels.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use ironcalc_base::types::Workbook;
|
||||
|
||||
use super::xml_constants::{XML_DECLARATION, XML_WORKSHEET};
|
||||
|
||||
pub(crate) fn get_workbook_xml_rels(workbook: &Workbook) -> String {
|
||||
let mut relationships_str: Vec<String> = vec![];
|
||||
let worksheet_count = workbook.worksheets.len() + 1;
|
||||
for id in 1..worksheet_count {
|
||||
relationships_str.push(format!(
|
||||
"<Relationship Id=\"rId{id}\" Type=\"{XML_WORKSHEET}\" Target=\"worksheets/sheet{id}.xml\"/>"
|
||||
));
|
||||
}
|
||||
let mut id = worksheet_count;
|
||||
relationships_str.push(
|
||||
format!("<Relationship Id=\"rId{id}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>")
|
||||
);
|
||||
id += 1;
|
||||
relationships_str.push(
|
||||
format!("<Relationship Id=\"rId{id}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings\" Target=\"sharedStrings.xml\"/>")
|
||||
);
|
||||
format!(
|
||||
"{XML_DECLARATION}\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}</Relationships>",
|
||||
relationships_str.join("")
|
||||
)
|
||||
}
|
||||
267
xlsx/src/export/worksheets.rs
Normal file
267
xlsx/src/export/worksheets.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
//! # A note on shared formulas
|
||||
//! Although both Excel and IronCalc uses shared formulas they are used in a slightly different way that cannot be mapped 1-1
|
||||
//! In IronCalc _all_ formulas are shared and there is a list of shared formulas much like there is a list of shared strings.
|
||||
//! In Excel the situation in more nuanced. A shared formula is shared amongst a rage of cells.
|
||||
//! The top left cell would be the "mother" cell that would contain the shared formula:
|
||||
//! <c r="F4" t="str">
|
||||
//! <f t="shared" ref="F4:F8" si="42">A4+C4</f>
|
||||
//! <v>123</v>
|
||||
//! </c>
|
||||
//! Cells in the range F4:F8 will then link to that formula like so:
|
||||
//! <c r="F6">
|
||||
//! <f t="shared" si="42"/>
|
||||
//! <v>1</v>
|
||||
//! </c>
|
||||
//! Formula in F6 would then be 'A6+C6'
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use ironcalc_base::{
|
||||
expressions::{
|
||||
parser::{stringify::to_excel_string, Node},
|
||||
types::CellReferenceRC,
|
||||
utils::number_to_column,
|
||||
},
|
||||
types::{Cell, Worksheet},
|
||||
};
|
||||
|
||||
use super::{escape::escape_xml, xml_constants::XML_DECLARATION};
|
||||
|
||||
fn get_cell_style_attribute(s: i32) -> String {
|
||||
if s == 0 {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!(" s=\"{}\"", s)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_formula_attribute(
|
||||
sheet_name: String,
|
||||
row: i32,
|
||||
column: i32,
|
||||
parsed_formula: &Node,
|
||||
) -> String {
|
||||
let cell_ref = CellReferenceRC {
|
||||
sheet: sheet_name,
|
||||
row,
|
||||
column,
|
||||
};
|
||||
let formula = &to_excel_string(parsed_formula, &cell_ref);
|
||||
escape_xml(formula).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn get_worksheet_xml(
|
||||
worksheet: &Worksheet,
|
||||
parsed_formulas: &[Node],
|
||||
dimension: &str,
|
||||
) -> String {
|
||||
let mut sheet_data_str: Vec<String> = vec![];
|
||||
let mut cols_str: Vec<String> = vec![];
|
||||
|
||||
for col in &worksheet.cols {
|
||||
// <col min="4" max="4" width="12" customWidth="1"/>
|
||||
let min = col.min;
|
||||
let max = col.max;
|
||||
let width = col.width;
|
||||
let custom_width = i32::from(col.custom_width);
|
||||
let column_style = match col.style {
|
||||
Some(s) => format!(" style=\"{s}\""),
|
||||
None => "".to_string(),
|
||||
};
|
||||
cols_str.push(format!(
|
||||
"<col min=\"{min}\" max=\"{max}\" width=\"{width}\" customWidth=\"{custom_width}\"{column_style}/>"
|
||||
));
|
||||
}
|
||||
|
||||
// this is a bit of an overkill. A dictionary of the row styles by row_index
|
||||
let mut row_style_dict = HashMap::new();
|
||||
for row in &worksheet.rows {
|
||||
// {
|
||||
// "height": 13,
|
||||
// "r": 7,
|
||||
// "custom_format": false,
|
||||
// "custom_height": true,
|
||||
// "s": 0
|
||||
// "hidden": false,
|
||||
// },
|
||||
row_style_dict.insert(row.r, row.clone());
|
||||
}
|
||||
|
||||
for (row_index, row_data) in worksheet.sheet_data.iter().sorted_by_key(|x| x.0) {
|
||||
let mut row_data_str: Vec<String> = vec![];
|
||||
for (column_index, cell) in row_data.iter().sorted_by_key(|x| x.0) {
|
||||
let column_name = number_to_column(*column_index).unwrap();
|
||||
let cell_name = format!("{column_name}{row_index}");
|
||||
match cell {
|
||||
Cell::EmptyCell { s } => {
|
||||
// they only hold the style
|
||||
let style = get_cell_style_attribute(*s);
|
||||
row_data_str.push(format!("<c r=\"{cell_name}\"{style}/>"));
|
||||
}
|
||||
Cell::BooleanCell { v, s } => {
|
||||
// <c r="A8" t="b" s="1">
|
||||
// <v>1</v>
|
||||
// </c>
|
||||
let b = i32::from(*v);
|
||||
let style = get_cell_style_attribute(*s);
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\" t=\"b\"{style}><v>{b}</v></c>"
|
||||
));
|
||||
}
|
||||
Cell::NumberCell { v, s } => {
|
||||
// Normally the type number is left out. Example:
|
||||
// <c r="C6" s="1">
|
||||
// <v>3</v>
|
||||
// </c>
|
||||
let style = get_cell_style_attribute(*s);
|
||||
row_data_str.push(format!("<c r=\"{cell_name}\"{style}><v>{v}</v></c>"));
|
||||
}
|
||||
Cell::ErrorCell { ei, s } => {
|
||||
let style = get_cell_style_attribute(*s);
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\" t=\"e\"{style}><v>{ei}</v></c>"
|
||||
));
|
||||
}
|
||||
Cell::SharedString { si, s } => {
|
||||
// Example:
|
||||
// <c r="A1" s="1" t="s">
|
||||
// <v>5</v>
|
||||
// </c>
|
||||
// Cell on A1 contains a string (t="s") of style="1". The string is the 6th in the list of shared strings
|
||||
let style = get_cell_style_attribute(*s);
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\" t=\"s\"{style}><v>{si}</v></c>"
|
||||
));
|
||||
}
|
||||
Cell::CellFormula { f: _, s: _ } => {
|
||||
panic!("Model needs to be evaluated before saving!");
|
||||
}
|
||||
Cell::CellFormulaBoolean { f, v, s } => {
|
||||
// <c r="A4" t="b" s="3">
|
||||
// <f>ISTEXT(A5)</f>
|
||||
// <v>1</v>
|
||||
// </c>
|
||||
let style = get_cell_style_attribute(*s);
|
||||
|
||||
let formula = get_formula_attribute(
|
||||
worksheet.get_name(),
|
||||
*row_index,
|
||||
*column_index,
|
||||
&parsed_formulas[*f as usize],
|
||||
);
|
||||
|
||||
let b = i32::from(*v);
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\" t=\"b\"{style}><f>{formula}</f><v>{b}</v></c>"
|
||||
));
|
||||
}
|
||||
Cell::CellFormulaNumber { f, v, s } => {
|
||||
// Note again type is skipped
|
||||
// <c r="C4" s="3">
|
||||
// <f>A5+C3</f>
|
||||
// <v>123</v>
|
||||
// </c>
|
||||
|
||||
let formula = get_formula_attribute(
|
||||
worksheet.get_name(),
|
||||
*row_index,
|
||||
*column_index,
|
||||
&parsed_formulas[*f as usize],
|
||||
);
|
||||
let style = get_cell_style_attribute(*s);
|
||||
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\"{style}><f>{formula}</f><v>{v}</v></c>"
|
||||
));
|
||||
}
|
||||
Cell::CellFormulaString { f, v, s } => {
|
||||
// <c r="C6" t="str" s="5">
|
||||
// <f>CONCATENATE(A1, A2)</f>
|
||||
// <v>Hello world!</v>
|
||||
// </c>
|
||||
let formula = get_formula_attribute(
|
||||
worksheet.get_name(),
|
||||
*row_index,
|
||||
*column_index,
|
||||
&parsed_formulas[*f as usize],
|
||||
);
|
||||
let style = get_cell_style_attribute(*s);
|
||||
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\" t=\"str\"{style}><f>{formula}</f><v>{v}</v></c>"
|
||||
));
|
||||
}
|
||||
Cell::CellFormulaError {
|
||||
f,
|
||||
ei,
|
||||
s,
|
||||
o: _,
|
||||
m: _,
|
||||
} => {
|
||||
// <c r="C6" t="e" s="4">
|
||||
// <f>A1/A3<f/>
|
||||
// <v>#DIV/0!</v>
|
||||
// </c>
|
||||
let formula = get_formula_attribute(
|
||||
worksheet.get_name(),
|
||||
*row_index,
|
||||
*column_index,
|
||||
&parsed_formulas[*f as usize],
|
||||
);
|
||||
let style = get_cell_style_attribute(*s);
|
||||
row_data_str.push(format!(
|
||||
"<c r=\"{cell_name}\" t=\"e\"{style}><f>{formula}</f><v>{ei}</v></c>"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let row_style_str = match row_style_dict.get(row_index) {
|
||||
Some(row_style) => {
|
||||
let hidden_str = if row_style.hidden {
|
||||
r#" hidden="1""#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
r#" s="{}" ht="{}" customHeight="{}" customFormat="{}"{}"#,
|
||||
row_style.s,
|
||||
row_style.height,
|
||||
i32::from(row_style.custom_height),
|
||||
i32::from(row_style.custom_format),
|
||||
hidden_str,
|
||||
)
|
||||
}
|
||||
None => "".to_string(),
|
||||
};
|
||||
sheet_data_str.push(format!(
|
||||
"<row r=\"{row_index}\"{row_style_str}>{}</row>",
|
||||
row_data_str.join("")
|
||||
))
|
||||
}
|
||||
let sheet_data = sheet_data_str.join("");
|
||||
let cols = cols_str.join("");
|
||||
let cols = if cols.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!("<cols>{cols}</cols>")
|
||||
};
|
||||
|
||||
format!(
|
||||
"{XML_DECLARATION}
|
||||
<worksheet \
|
||||
xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
|
||||
xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\
|
||||
<dimension ref=\"{dimension}\"/>\
|
||||
<sheetViews>\
|
||||
<sheetView workbookViewId=\"0\">\
|
||||
<selection activeCell=\"A1\" sqref=\"A1\"/>\
|
||||
</sheetView>\
|
||||
</sheetViews>\
|
||||
{cols}\
|
||||
<sheetData>\
|
||||
{sheet_data}\
|
||||
</sheetData>\
|
||||
</worksheet>"
|
||||
)
|
||||
}
|
||||
5
xlsx/src/export/xml_constants.rs
Normal file
5
xlsx/src/export/xml_constants.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub(crate) const XML_DECLARATION: &str =
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
|
||||
|
||||
pub(crate) const XML_WORKSHEET: &str =
|
||||
r#"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"#;
|
||||
257
xlsx/src/import/colors.rs
Normal file
257
xlsx/src/import/colors.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use core::cmp::max;
|
||||
use core::cmp::min;
|
||||
|
||||
// https://gist.github.com/emanuel-sanabria-developer/5793377
|
||||
// https://github.com/ClosedXML/ClosedXML/wiki/Excel-Indexed-Colors
|
||||
|
||||
// Warning: Excel uses a weird normalization for HSL colors (0, 255)
|
||||
// We use a more standard one but our HSL numbers will not coincide with Excel's
|
||||
|
||||
pub(crate) fn hex_to_rgb(h: &str) -> [i32; 3] {
|
||||
let r = i32::from_str_radix(&h[1..3], 16).unwrap();
|
||||
let g = i32::from_str_radix(&h[3..5], 16).unwrap();
|
||||
let b = i32::from_str_radix(&h[5..7], 16).unwrap();
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
pub(crate) fn rgb_to_hex(rgb: [i32; 3]) -> String {
|
||||
format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
pub(crate) fn rgb_to_hsl(rgb: [i32; 3]) -> [i32; 3] {
|
||||
let r = rgb[0];
|
||||
let g = rgb[1];
|
||||
let b = rgb[2];
|
||||
let red = r as f64 / 255.0;
|
||||
let green = g as f64 / 255.0;
|
||||
let blue = b as f64 / 255.0;
|
||||
let max_color = max(max(r, g), b);
|
||||
let min_color = min(min(r, g), b);
|
||||
let chroma = (max_color - min_color) as f64 / 255.0;
|
||||
if chroma == 0.0 {
|
||||
return [0, 0, (red * 100.0).round() as i32];
|
||||
}
|
||||
|
||||
let hue;
|
||||
let luminosity = (max_color + min_color) as f64 / (255.0 * 2.0);
|
||||
let saturation = if luminosity > 0.5 {
|
||||
0.5 * chroma / (1.0 - luminosity)
|
||||
} else {
|
||||
0.5 * chroma / luminosity
|
||||
};
|
||||
if max_color == r {
|
||||
if green >= blue {
|
||||
hue = 60.0 * (green - blue) / chroma;
|
||||
} else {
|
||||
hue = ((green - blue) / chroma + 6.0) * 60.0;
|
||||
}
|
||||
} else if max_color == g {
|
||||
hue = ((blue - red) / chroma + 2.0) * 60.0;
|
||||
} else {
|
||||
hue = ((red - green) / chroma + 4.0) * 60.0;
|
||||
}
|
||||
let hue = hue.round() as i32;
|
||||
let saturation = (saturation * 100.0).round() as i32;
|
||||
let luminosity = (luminosity * 100.0).round() as i32;
|
||||
[hue, saturation, luminosity]
|
||||
}
|
||||
|
||||
fn hue_to_rgb(p: f64, q: f64, t: f64) -> f64 {
|
||||
let mut c = t;
|
||||
if c < 0.0 {
|
||||
c += 1.0;
|
||||
}
|
||||
if c > 1.0 {
|
||||
c -= 1.0;
|
||||
}
|
||||
if c < 1.0 / 6.0 {
|
||||
return p + (q - p) * 6.0 * t;
|
||||
};
|
||||
if c < 0.5 {
|
||||
return q;
|
||||
};
|
||||
if c < 2.0 / 3.0 {
|
||||
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
};
|
||||
p
|
||||
}
|
||||
|
||||
pub(crate) fn hsl_to_rgb(hsl: [i32; 3]) -> [i32; 3] {
|
||||
let hue = (hsl[0] as f64) / 360.0;
|
||||
let saturation = (hsl[1] as f64) / 100.0;
|
||||
let luminosity = (hsl[2] as f64) / 100.0;
|
||||
let red;
|
||||
let green;
|
||||
let blue;
|
||||
|
||||
if saturation == 0.0 {
|
||||
// achromatic
|
||||
red = luminosity * 255.0;
|
||||
green = luminosity * 255.0;
|
||||
blue = luminosity * 255.0;
|
||||
} else {
|
||||
let q = if luminosity < 0.5 {
|
||||
luminosity * (1.0 + saturation)
|
||||
} else {
|
||||
luminosity + saturation - luminosity * saturation
|
||||
};
|
||||
let p = 2.0 * luminosity - q;
|
||||
red = 255.0 * hue_to_rgb(p, q, hue + 1.0 / 3.0);
|
||||
green = 255.0 * hue_to_rgb(p, q, hue);
|
||||
blue = 255.0 * hue_to_rgb(p, q, hue - 1.0 / 3.0);
|
||||
}
|
||||
[
|
||||
red.round() as i32,
|
||||
green.round() as i32,
|
||||
blue.round() as i32,
|
||||
]
|
||||
}
|
||||
|
||||
/* 18.8.3 bgColor tint algorithm */
|
||||
fn hex_with_tint_to_rgb(hex: &str, tint: f64) -> String {
|
||||
if tint == 0.0 {
|
||||
return hex.to_string();
|
||||
}
|
||||
let mut hsl = rgb_to_hsl(hex_to_rgb(hex));
|
||||
let l = hsl[2] as f64;
|
||||
if tint < 0.0 {
|
||||
// Lum’ = Lum * (1.0 + tint)
|
||||
hsl[2] = (l * (1.0 + tint)).round() as i32;
|
||||
} else {
|
||||
// HLSMAX here would be 100, for Excel 255
|
||||
// Lum‘ = Lum * (1.0-tint) + (HLSMAX – HLSMAX * (1.0-tint))
|
||||
hsl[2] = (l + (100.0 - l) * tint).round() as i32;
|
||||
};
|
||||
rgb_to_hex(hsl_to_rgb(hsl))
|
||||
}
|
||||
|
||||
pub fn get_themed_color(theme: i32, tint: f64) -> String {
|
||||
let color_theme = [
|
||||
"#FFFFFF", "#000000", // "window",
|
||||
"#E7E6E6", "#44546A", "#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47",
|
||||
"#0563C1", "#954F72",
|
||||
];
|
||||
hex_with_tint_to_rgb(color_theme[theme as usize], tint)
|
||||
}
|
||||
|
||||
pub fn get_indexed_color(index: i32) -> String {
|
||||
let color_list = [
|
||||
"#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
|
||||
"#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
|
||||
"#800000", "#008000", "#000080", "#808000", "#800080", "#008080", "#C0C0C0", "#808080",
|
||||
"#9999FF", "#993366", "#FFFFCC", "#CCFFFF", "#660066", "#FF8080", "#0066CC", "#CCCCFF",
|
||||
"#000080", "#FF00FF", "#FFFF00", "#00FFFF", "#800080", "#800000", "#008080", "#0000FF",
|
||||
"#00CCFF", "#CCFFFF", "#CCFFCC", "#FFFF99", "#99CCFF", "#FF99CC", "#CC99FF", "#FFCC99",
|
||||
"#3366FF", "#33CCCC", "#99CC00", "#FFCC00", "#FF9900", "#FF6600", "#666699", "#969696",
|
||||
"#003366", "#339966", "#003300", "#333300", "#993300", "#993366", "#333399",
|
||||
"#333333",
|
||||
// 64, Transparent)
|
||||
];
|
||||
if index > 63 {
|
||||
return color_list[0].to_string();
|
||||
}
|
||||
color_list[index as usize].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::import::colors::*;
|
||||
|
||||
#[test]
|
||||
fn test_known_colors() {
|
||||
let color1 = get_themed_color(0, -0.05);
|
||||
assert_eq!(color1, "#F2F2F2");
|
||||
|
||||
let color2 = get_themed_color(5, -0.25);
|
||||
// Excel returns "#C65911" (rounding error)
|
||||
assert_eq!(color2, "#C55911");
|
||||
|
||||
let color3 = get_themed_color(4, 0.6);
|
||||
// Excel returns "#b4c6e7" (rounding error)
|
||||
assert_eq!(color3, "#B5C8E8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rgb_hex() {
|
||||
struct ColorTest {
|
||||
hex: String,
|
||||
rgb: [i32; 3],
|
||||
hsl: [i32; 3],
|
||||
}
|
||||
let color_tests = [
|
||||
ColorTest {
|
||||
hex: "#FFFFFF".to_string(),
|
||||
rgb: [255, 255, 255],
|
||||
hsl: [0, 0, 100],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#000000".to_string(),
|
||||
rgb: [0, 0, 0],
|
||||
hsl: [0, 0, 0],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#44546A".to_string(),
|
||||
rgb: [68, 84, 106],
|
||||
hsl: [215, 22, 34],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#E7E6E6".to_string(),
|
||||
rgb: [231, 230, 230],
|
||||
hsl: [0, 2, 90],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#4472C4".to_string(),
|
||||
rgb: [68, 114, 196],
|
||||
hsl: [218, 52, 52],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#ED7D31".to_string(),
|
||||
rgb: [237, 125, 49],
|
||||
hsl: [24, 84, 56],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#A5A5A5".to_string(),
|
||||
rgb: [165, 165, 165],
|
||||
hsl: [0, 0, 65],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#FFC000".to_string(),
|
||||
rgb: [255, 192, 0],
|
||||
hsl: [45, 100, 50],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#5B9BD5".to_string(),
|
||||
rgb: [91, 155, 213],
|
||||
hsl: [209, 59, 60],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#70AD47".to_string(),
|
||||
rgb: [112, 173, 71],
|
||||
hsl: [96, 42, 48],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#0563C1".to_string(),
|
||||
rgb: [5, 99, 193],
|
||||
hsl: [210, 95, 39],
|
||||
},
|
||||
ColorTest {
|
||||
hex: "#954F72".to_string(),
|
||||
rgb: [149, 79, 114],
|
||||
hsl: [330, 31, 45],
|
||||
},
|
||||
];
|
||||
for color in color_tests.iter() {
|
||||
let rgb = color.rgb;
|
||||
let hsl = color.hsl;
|
||||
assert_eq!(rgb, hex_to_rgb(&color.hex));
|
||||
assert_eq!(hsl, rgb_to_hsl(rgb));
|
||||
assert_eq!(rgb_to_hex(rgb), color.hex);
|
||||
// The round trip has rounding errors
|
||||
// FIXME: We could also hardcode the hsl21 in the testcase
|
||||
let rgb2 = hsl_to_rgb(hsl);
|
||||
let diff =
|
||||
(rgb2[0] - rgb[0]).abs() + (rgb2[1] - rgb[1]).abs() + (rgb2[2] - rgb[2]).abs();
|
||||
assert!(diff < 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
xlsx/src/import/metadata.rs
Normal file
81
xlsx/src/import/metadata.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::io::Read;
|
||||
|
||||
use ironcalc_base::types::Metadata;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use super::util::get_value_or_default;
|
||||
|
||||
struct AppData {
|
||||
application: String,
|
||||
app_version: String,
|
||||
}
|
||||
|
||||
struct CoreData {
|
||||
creator: String,
|
||||
last_modified_by: String,
|
||||
created: String,
|
||||
last_modified: String,
|
||||
}
|
||||
|
||||
fn load_core<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
) -> Result<CoreData, XlsxError> {
|
||||
let mut file = archive.by_name("docProps/core.xml")?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let core_data = doc
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?;
|
||||
// Note the namespace should be "http://purl.org/dc/elements/1.1/"
|
||||
let creator = get_value_or_default(&core_data, "creator", "Anonymous User");
|
||||
// Note namespace is "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
let last_modified_by = get_value_or_default(&core_data, "lastModifiedBy", "Anonymous User");
|
||||
// In these two cases the namespace is "http://purl.org/dc/terms/"
|
||||
let created = get_value_or_default(&core_data, "created", "");
|
||||
let last_modified = get_value_or_default(&core_data, "modified", "");
|
||||
|
||||
Ok(CoreData {
|
||||
creator,
|
||||
last_modified_by,
|
||||
created,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_app<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
) -> Result<AppData, XlsxError> {
|
||||
let mut file = archive.by_name("docProps/app.xml")?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let app_data = doc
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?;
|
||||
|
||||
let application = get_value_or_default(&app_data, "Application", "Unknown application");
|
||||
let app_version = get_value_or_default(&app_data, "AppVersion", "");
|
||||
Ok(AppData {
|
||||
application,
|
||||
app_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn load_metadata<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
) -> Result<Metadata, XlsxError> {
|
||||
let app_data = load_app(archive)?;
|
||||
let core_data = load_core(archive)?;
|
||||
Ok(Metadata {
|
||||
application: app_data.application,
|
||||
app_version: app_data.app_version,
|
||||
creator: core_data.creator,
|
||||
last_modified_by: core_data.last_modified_by,
|
||||
created: core_data.created,
|
||||
last_modified: core_data.last_modified,
|
||||
})
|
||||
}
|
||||
124
xlsx/src/import/mod.rs
Normal file
124
xlsx/src/import/mod.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
mod colors;
|
||||
mod metadata;
|
||||
mod shared_strings;
|
||||
mod styles;
|
||||
mod tables;
|
||||
mod util;
|
||||
mod workbook;
|
||||
mod worksheets;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{BufReader, Read},
|
||||
};
|
||||
|
||||
use roxmltree::Node;
|
||||
|
||||
use ironcalc_base::{
|
||||
model::Model,
|
||||
types::{Metadata, Workbook, WorkbookSettings},
|
||||
};
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use shared_strings::read_shared_strings;
|
||||
|
||||
use metadata::load_metadata;
|
||||
use styles::load_styles;
|
||||
use util::get_attribute;
|
||||
use workbook::load_workbook;
|
||||
use worksheets::{load_sheets, Relationship};
|
||||
|
||||
fn load_relationships<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<HashMap<String, Relationship>, XlsxError> {
|
||||
let mut file = archive.by_name("xl/_rels/workbook.xml.rels")?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let nodes: Vec<Node> = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("Relationship"))
|
||||
.collect();
|
||||
let mut rels = HashMap::new();
|
||||
for node in nodes {
|
||||
rels.insert(
|
||||
get_attribute(&node, "Id")?.to_string(),
|
||||
Relationship {
|
||||
rel_type: get_attribute(&node, "Type")?.to_string(),
|
||||
target: get_attribute(&node, "Target")?.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(rels)
|
||||
}
|
||||
|
||||
fn load_xlsx_from_reader<R: Read + std::io::Seek>(
|
||||
name: String,
|
||||
reader: R,
|
||||
locale: &str,
|
||||
tz: &str,
|
||||
) -> Result<Workbook, XlsxError> {
|
||||
let mut archive = zip::ZipArchive::new(reader)?;
|
||||
|
||||
let mut shared_strings = read_shared_strings(&mut archive)?;
|
||||
let workbook = load_workbook(&mut archive)?;
|
||||
let rels = load_relationships(&mut archive)?;
|
||||
let mut tables = HashMap::new();
|
||||
let worksheets = load_sheets(
|
||||
&mut archive,
|
||||
&rels,
|
||||
&workbook,
|
||||
&mut tables,
|
||||
&mut shared_strings,
|
||||
)?;
|
||||
let styles = load_styles(&mut archive)?;
|
||||
let metadata = match load_metadata(&mut archive) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => {
|
||||
// In case there is no metadata, add some
|
||||
Metadata {
|
||||
application: "Unknown application".to_string(),
|
||||
app_version: "".to_string(),
|
||||
creator: "".to_string(),
|
||||
last_modified_by: "".to_string(),
|
||||
created: "".to_string(),
|
||||
last_modified: "".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Workbook {
|
||||
shared_strings,
|
||||
defined_names: workbook.defined_names,
|
||||
worksheets,
|
||||
styles,
|
||||
name,
|
||||
settings: WorkbookSettings {
|
||||
tz: tz.to_string(),
|
||||
locale: locale.to_string(),
|
||||
},
|
||||
metadata,
|
||||
tables,
|
||||
})
|
||||
}
|
||||
|
||||
// Public methods
|
||||
|
||||
/// Imports a file from disk into an internal representation
|
||||
pub fn load_from_excel(file_name: &str, locale: &str, tz: &str) -> Result<Workbook, XlsxError> {
|
||||
let file_path = std::path::Path::new(file_name);
|
||||
let file = fs::File::open(file_path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let name = file_path
|
||||
.file_stem()
|
||||
.ok_or_else(|| XlsxError::IO("Could not extract workbook name".to_string()))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
load_xlsx_from_reader(name, reader, locale, tz)
|
||||
}
|
||||
|
||||
pub fn load_model_from_xlsx(file_name: &str, locale: &str, tz: &str) -> Result<Model, XlsxError> {
|
||||
let workbook = load_from_excel(file_name, locale, tz)?;
|
||||
Model::from_workbook(workbook).map_err(XlsxError::Workbook)
|
||||
}
|
||||
80
xlsx/src/import/shared_strings.rs
Normal file
80
xlsx/src/import/shared_strings.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::io::Read;
|
||||
|
||||
use roxmltree::Node;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
/// Reads the list of shared strings in an Excel workbook
|
||||
/// Note than in IronCalc we lose _internal_ styling of a string
|
||||
/// See Section 18.4
|
||||
pub(crate) fn read_shared_strings<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
) -> Result<Vec<String>, XlsxError> {
|
||||
match archive.by_name("xl/sharedStrings.xml") {
|
||||
Ok(mut file) => {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
read_shared_strings_from_string(&text)
|
||||
}
|
||||
Err(_e) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_shared_strings_from_string(text: &str) -> Result<Vec<String>, XlsxError> {
|
||||
let doc = roxmltree::Document::parse(text)?;
|
||||
let mut shared_strings = Vec::new();
|
||||
let nodes: Vec<Node> = doc.descendants().filter(|n| n.has_tag_name("si")).collect();
|
||||
for node in nodes {
|
||||
let text = node
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("t"))
|
||||
.map(|n| n.text().unwrap_or("").to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
shared_strings.push(text);
|
||||
}
|
||||
Ok(shared_strings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_shared_strings() {
|
||||
let xml_string = r#"
|
||||
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="3" uniqueCount="3">
|
||||
<si>
|
||||
<t>A string</t>
|
||||
</si>
|
||||
<si>
|
||||
<t>A second String</t>
|
||||
</si>
|
||||
<si>
|
||||
<r>
|
||||
<t>Hello</t>
|
||||
</r>
|
||||
<r>
|
||||
<rPr>
|
||||
<b/>
|
||||
<sz val="11"/>
|
||||
<color rgb="FFFF0000"/>
|
||||
<rFont val="Calibri"/>
|
||||
<family val="2"/>
|
||||
<scheme val="minor"/>
|
||||
</rPr>
|
||||
<t xml:space="preserve"> World</t>
|
||||
</r>
|
||||
</si>
|
||||
</sst>"#;
|
||||
let shared_strings = read_shared_strings_from_string(xml_string.trim()).unwrap();
|
||||
assert_eq!(
|
||||
shared_strings,
|
||||
[
|
||||
"A string".to_string(),
|
||||
"A second String".to_string(),
|
||||
"Hello World".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
386
xlsx/src/import/styles.rs
Normal file
386
xlsx/src/import/styles.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
use std::{collections::HashMap, io::Read};
|
||||
|
||||
use ironcalc_base::types::{
|
||||
Alignment, Border, BorderItem, BorderStyle, CellStyleXfs, CellStyles, CellXfs, Fill, Font,
|
||||
FontScheme, HorizontalAlignment, NumFmt, Styles, VerticalAlignment,
|
||||
};
|
||||
use roxmltree::Node;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use super::util::{get_attribute, get_bool, get_bool_false, get_color, get_number};
|
||||
|
||||
fn get_border(node: Node, name: &str) -> Result<Option<BorderItem>, XlsxError> {
|
||||
let style;
|
||||
let color;
|
||||
let border_nodes = node
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name(name))
|
||||
.collect::<Vec<Node>>();
|
||||
if border_nodes.len() == 1 {
|
||||
let border = border_nodes[0];
|
||||
style = match border.attribute("style") {
|
||||
Some("thin") => BorderStyle::Thin,
|
||||
Some("medium") => BorderStyle::Medium,
|
||||
Some("thick") => BorderStyle::Thick,
|
||||
Some("double") => BorderStyle::Double,
|
||||
Some("slantdashdot") => BorderStyle::SlantDashDot,
|
||||
Some("mediumdashed") => BorderStyle::MediumDashed,
|
||||
Some("mediumdashdot") => BorderStyle::MediumDashDot,
|
||||
Some("mediumdashdotdot") => BorderStyle::MediumDashDotDot,
|
||||
// TODO: Should we fail in this case or set the border to None?
|
||||
Some(_) => BorderStyle::Thin,
|
||||
None => {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let color_node = border
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("color"))
|
||||
.collect::<Vec<Node>>();
|
||||
if color_node.len() == 1 {
|
||||
color = get_color(color_node[0])?;
|
||||
} else {
|
||||
color = None;
|
||||
}
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(BorderItem { style, color }))
|
||||
}
|
||||
|
||||
pub(super) fn load_styles<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
) -> Result<Styles, XlsxError> {
|
||||
let mut file = archive.by_name("xl/styles.xml")?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let style_sheet = doc
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?;
|
||||
|
||||
let mut num_fmts = Vec::new();
|
||||
let num_fmts_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("numFmts"))
|
||||
.collect::<Vec<Node>>();
|
||||
if num_fmts_nodes.len() == 1 {
|
||||
for num_fmt in num_fmts_nodes[0].children() {
|
||||
let num_fmt_id = get_number(num_fmt, "numFmtId");
|
||||
let format_code = num_fmt.attribute("formatCode").unwrap_or("").to_string();
|
||||
num_fmts.push(NumFmt {
|
||||
num_fmt_id,
|
||||
format_code,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut fonts = Vec::new();
|
||||
let font_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("fonts"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
for font in font_nodes.children() {
|
||||
let mut sz = 11;
|
||||
let mut name = "Calibri".to_string();
|
||||
// NOTE: In Excel you can have simple underline or double underline
|
||||
// In IronCalc convert double underline to simple
|
||||
// This in excel is u with a value of "double"
|
||||
let mut u = false;
|
||||
let mut b = false;
|
||||
let mut i = false;
|
||||
let mut strike = false;
|
||||
let mut color = Some("FFFFFF00".to_string());
|
||||
let mut family = 2;
|
||||
let mut scheme = FontScheme::default();
|
||||
for feature in font.children() {
|
||||
match feature.tag_name().name() {
|
||||
"sz" => {
|
||||
sz = feature
|
||||
.attribute("val")
|
||||
.unwrap_or("11")
|
||||
.parse::<i32>()
|
||||
.unwrap_or(11);
|
||||
}
|
||||
"color" => {
|
||||
color = get_color(feature)?;
|
||||
}
|
||||
"u" => {
|
||||
u = true;
|
||||
}
|
||||
"b" => {
|
||||
b = true;
|
||||
}
|
||||
"i" => {
|
||||
i = true;
|
||||
}
|
||||
"strike" => {
|
||||
strike = true;
|
||||
}
|
||||
"name" => name = feature.attribute("val").unwrap_or("Calibri").to_string(),
|
||||
// If there is a theme the font scheme and family overrides other properties like the name
|
||||
"family" => {
|
||||
family = feature
|
||||
.attribute("val")
|
||||
.unwrap_or("2")
|
||||
.parse::<i32>()
|
||||
.unwrap_or(2);
|
||||
}
|
||||
"scheme" => {
|
||||
scheme = match feature.attribute("val") {
|
||||
None => FontScheme::default(),
|
||||
Some("minor") => FontScheme::Minor,
|
||||
Some("major") => FontScheme::Major,
|
||||
Some("none") => FontScheme::None,
|
||||
// TODO: Should we fail?
|
||||
Some(_) => FontScheme::default(),
|
||||
}
|
||||
}
|
||||
"charset" => {}
|
||||
_ => {
|
||||
println!("Unexpected feature {:?}", feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
fonts.push(Font {
|
||||
strike,
|
||||
u,
|
||||
b,
|
||||
i,
|
||||
sz,
|
||||
color,
|
||||
name,
|
||||
family,
|
||||
scheme,
|
||||
});
|
||||
}
|
||||
|
||||
let mut fills = Vec::new();
|
||||
let fill_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("fills"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
for fill in fill_nodes.children() {
|
||||
let pattern_fill = fill
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("patternFill"))
|
||||
.collect::<Vec<Node>>();
|
||||
if pattern_fill.len() != 1 {
|
||||
// safety belt
|
||||
// Some fills do not have a patternFill, but they have gradientFill
|
||||
fills.push(Fill {
|
||||
pattern_type: "solid".to_string(),
|
||||
fg_color: None,
|
||||
bg_color: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let pattern_fill = pattern_fill[0];
|
||||
|
||||
let pattern_type = pattern_fill
|
||||
.attribute("patternType")
|
||||
.unwrap_or("none")
|
||||
.to_string();
|
||||
let mut fg_color = None;
|
||||
let mut bg_color = None;
|
||||
for feature in pattern_fill.children() {
|
||||
match feature.tag_name().name() {
|
||||
"fgColor" => {
|
||||
fg_color = get_color(feature)?;
|
||||
}
|
||||
"bgColor" => {
|
||||
bg_color = get_color(feature)?;
|
||||
}
|
||||
_ => {
|
||||
println!("Unexpected pattern");
|
||||
dbg!(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
fills.push(Fill {
|
||||
pattern_type,
|
||||
fg_color,
|
||||
bg_color,
|
||||
})
|
||||
}
|
||||
|
||||
let mut borders = Vec::new();
|
||||
let border_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("borders"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
for border in border_nodes.children() {
|
||||
let diagonal_up = get_bool_false(border, "diagonal_up");
|
||||
let diagonal_down = get_bool_false(border, "diagonal_down");
|
||||
let left = get_border(border, "left")?;
|
||||
let right = get_border(border, "right")?;
|
||||
let top = get_border(border, "top")?;
|
||||
let bottom = get_border(border, "bottom")?;
|
||||
let diagonal = get_border(border, "diagonal")?;
|
||||
borders.push(Border {
|
||||
diagonal_up,
|
||||
diagonal_down,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
diagonal,
|
||||
});
|
||||
}
|
||||
|
||||
let mut cell_style_xfs = Vec::new();
|
||||
let cell_style_xfs_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("cellStyleXfs"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
for xfs in cell_style_xfs_nodes.children() {
|
||||
let num_fmt_id = get_number(xfs, "numFmtId");
|
||||
let font_id = get_number(xfs, "fontId");
|
||||
let fill_id = get_number(xfs, "fillId");
|
||||
let border_id = get_number(xfs, "borderId");
|
||||
let apply_number_format = get_bool(xfs, "applyNumberFormat");
|
||||
let apply_border = get_bool(xfs, "applyBorder");
|
||||
let apply_alignment = get_bool(xfs, "applyAlignment");
|
||||
let apply_protection = get_bool(xfs, "applyProtection");
|
||||
let apply_font = get_bool(xfs, "applyFont");
|
||||
let apply_fill = get_bool(xfs, "applyFill");
|
||||
|
||||
cell_style_xfs.push(CellStyleXfs {
|
||||
num_fmt_id,
|
||||
font_id,
|
||||
fill_id,
|
||||
border_id,
|
||||
apply_number_format,
|
||||
apply_border,
|
||||
apply_alignment,
|
||||
apply_protection,
|
||||
apply_font,
|
||||
apply_fill,
|
||||
});
|
||||
}
|
||||
|
||||
let mut cell_styles = Vec::new();
|
||||
let mut style_names = HashMap::new();
|
||||
let cell_style_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("cellStyles"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
for cell_style in cell_style_nodes.children() {
|
||||
let name = get_attribute(&cell_style, "name")?.to_string();
|
||||
let xf_id = get_number(cell_style, "xfId");
|
||||
let builtin_id = get_number(cell_style, "builtinId");
|
||||
style_names.insert(xf_id, name.clone());
|
||||
cell_styles.push(CellStyles {
|
||||
name,
|
||||
xf_id,
|
||||
builtin_id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut cell_xfs = Vec::new();
|
||||
let cell_xfs_nodes = style_sheet
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("cellXfs"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
for xfs in cell_xfs_nodes.children() {
|
||||
let xf_id = get_attribute(&xfs, "xfId")?.parse::<i32>()?;
|
||||
let num_fmt_id = get_number(xfs, "numFmtId");
|
||||
let font_id = get_number(xfs, "fontId");
|
||||
let fill_id = get_number(xfs, "fillId");
|
||||
let border_id = get_number(xfs, "borderId");
|
||||
let apply_number_format = get_bool_false(xfs, "applyNumberFormat");
|
||||
let apply_border = get_bool_false(xfs, "applyBorder");
|
||||
let apply_alignment = get_bool_false(xfs, "applyAlignment");
|
||||
let apply_protection = get_bool_false(xfs, "applyProtection");
|
||||
let apply_font = get_bool_false(xfs, "applyFont");
|
||||
let apply_fill = get_bool_false(xfs, "applyFill");
|
||||
let quote_prefix = get_bool_false(xfs, "quotePrefix");
|
||||
|
||||
// TODO: Pivot Tables
|
||||
// let pivotButton = get_bool(xfs, "pivotButton");
|
||||
|
||||
let alignment_nodes = xfs
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("alignment"))
|
||||
.collect::<Vec<Node>>();
|
||||
let alignment = if alignment_nodes.len() == 1 {
|
||||
let alignment_node = alignment_nodes[0];
|
||||
let wrap_text = get_bool_false(alignment_node, "wrapText");
|
||||
|
||||
let horizontal = match alignment_node.attribute("horizontal") {
|
||||
Some("center") => HorizontalAlignment::Center,
|
||||
Some("centerContinuous") => HorizontalAlignment::CenterContinuous,
|
||||
Some("distributed") => HorizontalAlignment::Distributed,
|
||||
Some("fill") => HorizontalAlignment::Fill,
|
||||
Some("general") => HorizontalAlignment::General,
|
||||
Some("justify") => HorizontalAlignment::Justify,
|
||||
Some("left") => HorizontalAlignment::Left,
|
||||
Some("right") => HorizontalAlignment::Right,
|
||||
// TODO: Should we fail in this case or set the alignment to default?
|
||||
Some(_) => HorizontalAlignment::default(),
|
||||
None => HorizontalAlignment::default(),
|
||||
};
|
||||
|
||||
let vertical = match alignment_node.attribute("vertical") {
|
||||
Some("bottom") => VerticalAlignment::Bottom,
|
||||
Some("center") => VerticalAlignment::Center,
|
||||
Some("distributed") => VerticalAlignment::Distributed,
|
||||
Some("justify") => VerticalAlignment::Justify,
|
||||
Some("top") => VerticalAlignment::Top,
|
||||
// TODO: Should we fail in this case or set the alignment to default?
|
||||
Some(_) => VerticalAlignment::default(),
|
||||
None => VerticalAlignment::default(),
|
||||
};
|
||||
|
||||
Some(Alignment {
|
||||
horizontal,
|
||||
vertical,
|
||||
wrap_text,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
cell_xfs.push(CellXfs {
|
||||
xf_id,
|
||||
num_fmt_id,
|
||||
font_id,
|
||||
fill_id,
|
||||
border_id,
|
||||
apply_number_format,
|
||||
apply_border,
|
||||
apply_alignment,
|
||||
apply_protection,
|
||||
apply_font,
|
||||
apply_fill,
|
||||
quote_prefix,
|
||||
alignment,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO
|
||||
// let mut dxfs = Vec::new();
|
||||
// let mut tableStyles = Vec::new();
|
||||
// let mut colors = Vec::new();
|
||||
// <colors>
|
||||
// <mruColors>
|
||||
// <color rgb="FFB1BB4D"/>
|
||||
// <color rgb="FFFF99CC"/>
|
||||
// <color rgb="FF6C56DC"/>
|
||||
// <color rgb="FFFF66CC"/>
|
||||
// </mruColors>
|
||||
// </colors>
|
||||
|
||||
Ok(Styles {
|
||||
num_fmts,
|
||||
fonts,
|
||||
fills,
|
||||
borders,
|
||||
cell_style_xfs,
|
||||
cell_xfs,
|
||||
cell_styles,
|
||||
})
|
||||
}
|
||||
215
xlsx/src/import/tables.rs
Normal file
215
xlsx/src/import/tables.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use std::io::Read;
|
||||
|
||||
use ironcalc_base::types::{Table, TableColumn, TableStyleInfo};
|
||||
use roxmltree::Node;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use super::util::{get_bool, get_bool_false};
|
||||
|
||||
// <table name="Table" displayName="Table" totalsRowCount ref="A1:D6">
|
||||
// <autoFilter ref="A1:D6">
|
||||
// <filterColumn colId="0">
|
||||
// <customFilters><customFilter operator="greaterThan" val=20></customFilter></customFilters>
|
||||
// </filterColumn>
|
||||
// </autoFilter>
|
||||
// <tableColumns count="5">
|
||||
// <tableColumn name="Monday" totalsRowFunction="sum" />
|
||||
// ...
|
||||
// </tableColumns>
|
||||
// <tableStyleInfo name="TableStyle5"/>
|
||||
// </table>
|
||||
|
||||
/// Reads a table in an Excel workbook
|
||||
pub(crate) fn load_table<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
path: &str,
|
||||
sheet_name: &str,
|
||||
) -> Result<Table, XlsxError> {
|
||||
let mut file = archive.by_name(path)?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let document = roxmltree::Document::parse(&text)?;
|
||||
|
||||
// table
|
||||
let table = document
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?;
|
||||
|
||||
// Name and display name are normally the same and are unique in a workbook
|
||||
// They also need to be different from any defined name
|
||||
let name = table
|
||||
.attribute("name")
|
||||
.expect("Missing table name")
|
||||
.to_string();
|
||||
|
||||
let display_name = table
|
||||
.attribute("name")
|
||||
.expect("Missing table display name")
|
||||
.to_string();
|
||||
|
||||
// Range of the table, including the totals if any and headers.
|
||||
let reference = table
|
||||
.attribute("ref")
|
||||
.expect("Missing table ref")
|
||||
.to_string();
|
||||
|
||||
// Either 0 or 1, indicates if the table has a formula for totals at the bottom of the table
|
||||
let totals_row_count = match table.attribute("totalsRowCount") {
|
||||
Some(s) => s.parse::<u32>().expect("Invalid totalsRowCount"),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
// Either 0 or 1, indicates if the table has headers at the top of the table
|
||||
let header_row_count = match table.attribute("headerRowCount") {
|
||||
Some(s) => s.parse::<u32>().expect("Invalid headerRowCount"),
|
||||
None => 1,
|
||||
};
|
||||
|
||||
// style index of the header row of the table
|
||||
let header_row_dxf_id = if let Some(index_str) = table.attribute("headerRowDxfId") {
|
||||
match index_str.parse::<u32>() {
|
||||
Ok(i) => Some(i),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// style index of the header row of the table
|
||||
let data_dxf_id = if let Some(index_str) = table.attribute("headerRowDxfId") {
|
||||
match index_str.parse::<u32>() {
|
||||
Ok(i) => Some(i),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// style index of the totals row of the table
|
||||
let totals_row_dxf_id = if let Some(index_str) = table.attribute("totalsRowDxfId") {
|
||||
match index_str.parse::<u32>() {
|
||||
Ok(i) => Some(i),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Missing in Calc: styles can also be defined via a name:
|
||||
// headerRowCellStyle, dataCellStyle, totalsRowCellStyle
|
||||
|
||||
// Missing in Calc: styles can also be applied to the borders:
|
||||
// headerRowBorderDxfId, tableBorderDxfId, totalsRowBorderDxfId
|
||||
|
||||
// TODO: Conformant implementations should panic if header_row_dxf_id or data_dxf_id are out of bounds.
|
||||
|
||||
// Note that filters are non dynamic
|
||||
// The only thing important for us is whether or not it has filters
|
||||
let auto_filter = table
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("autoFilter"))
|
||||
.collect::<Vec<Node>>();
|
||||
|
||||
let has_filters = if let Some(filter) = auto_filter.get(0) {
|
||||
filter.children().count() > 0
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// tableColumn
|
||||
let table_column = table
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("tableColumn"))
|
||||
.collect::<Vec<Node>>();
|
||||
let mut columns = Vec::new();
|
||||
for table_column in table_column {
|
||||
let column_name = table_column.attribute("name").expect("Missing column name");
|
||||
let id = table_column.attribute("id").expect("Missing column id");
|
||||
let id = id.parse::<u32>().expect("Invalid id");
|
||||
|
||||
// style index of the header row of the table
|
||||
let header_row_dxf_id = if let Some(index_str) = table_column.attribute("headerRowDxfId") {
|
||||
match index_str.parse::<u32>() {
|
||||
Ok(i) => Some(i),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// style index of the header row of the table column
|
||||
let data_dxf_id = if let Some(index_str) = table_column.attribute("headerRowDxfId") {
|
||||
match index_str.parse::<u32>() {
|
||||
Ok(i) => Some(i),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// style index of the totals row of the table column
|
||||
let totals_row_dxf_id = if let Some(index_str) = table_column.attribute("totalsRowDxfId") {
|
||||
match index_str.parse::<u32>() {
|
||||
Ok(i) => Some(i),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// NOTE: Same as before, we should panic if indices to differential formatting records are out of bounds
|
||||
// Missing in Calc: styles can also be defined via a name:
|
||||
// headerRowCellStyle, dataCellStyle, totalsRowCellStyle
|
||||
|
||||
columns.push(TableColumn {
|
||||
id,
|
||||
name: column_name.to_string(),
|
||||
totals_row_label: None,
|
||||
header_row_dxf_id,
|
||||
data_dxf_id,
|
||||
totals_row_function: None,
|
||||
totals_row_dxf_id,
|
||||
});
|
||||
}
|
||||
|
||||
// tableInfo
|
||||
let table_info = table
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("tableInfo"))
|
||||
.collect::<Vec<Node>>();
|
||||
let style_info = match table_info.get(0) {
|
||||
Some(node) => {
|
||||
let name = node.attribute("name").map(|s| s.to_string());
|
||||
TableStyleInfo {
|
||||
name,
|
||||
show_first_column: get_bool_false(*node, "showFirstColumn"),
|
||||
show_last_column: get_bool_false(*node, "showLastColumn"),
|
||||
show_row_stripes: get_bool(*node, "showRowStripes"),
|
||||
show_column_stripes: get_bool_false(*node, "showColumnStripes"),
|
||||
}
|
||||
}
|
||||
None => TableStyleInfo {
|
||||
name: None,
|
||||
show_first_column: false,
|
||||
show_last_column: false,
|
||||
show_row_stripes: true,
|
||||
show_column_stripes: false,
|
||||
},
|
||||
};
|
||||
Ok(Table {
|
||||
name,
|
||||
display_name,
|
||||
reference,
|
||||
totals_row_count,
|
||||
header_row_count,
|
||||
header_row_dxf_id,
|
||||
data_dxf_id,
|
||||
totals_row_dxf_id,
|
||||
columns,
|
||||
style_info,
|
||||
has_filters,
|
||||
sheet_name: sheet_name.to_string(),
|
||||
})
|
||||
}
|
||||
78
xlsx/src/import/util.rs
Normal file
78
xlsx/src/import/util.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use colors::{get_indexed_color, get_themed_color};
|
||||
use roxmltree::{ExpandedName, Node};
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use super::colors;
|
||||
|
||||
pub(crate) fn get_number(node: Node, s: &str) -> i32 {
|
||||
node.attribute(s).unwrap_or("0").parse::<i32>().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn get_attribute<'a, 'n, 'm, N>(
|
||||
node: &'a Node,
|
||||
attr_name: N,
|
||||
) -> Result<&'a str, XlsxError>
|
||||
where
|
||||
N: Into<ExpandedName<'n, 'm>>,
|
||||
{
|
||||
let attr_name = attr_name.into();
|
||||
node.attribute(attr_name)
|
||||
.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 {
|
||||
let application_nodes = node
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name(tag_name))
|
||||
.collect::<Vec<Node>>();
|
||||
if application_nodes.len() == 1 {
|
||||
application_nodes[0].text().unwrap_or(default).to_string()
|
||||
} else {
|
||||
default.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_color(node: Node) -> Result<Option<String>, XlsxError> {
|
||||
// 18.3.1.15 color (Data Bar Color)
|
||||
if node.has_attribute("rgb") {
|
||||
let mut val = node.attribute("rgb").unwrap().to_string();
|
||||
// FIXME the two first values is normally the alpha.
|
||||
if val.len() == 8 {
|
||||
val = format!("#{}", &val[2..8]);
|
||||
}
|
||||
Ok(Some(val))
|
||||
} else if node.has_attribute("indexed") {
|
||||
let index = node.attribute("indexed").unwrap().parse::<i32>()?;
|
||||
let rgb = get_indexed_color(index);
|
||||
Ok(Some(rgb))
|
||||
// Color::Indexed(val)
|
||||
} else if node.has_attribute("theme") {
|
||||
let theme = node.attribute("theme").unwrap().parse::<i32>()?;
|
||||
let tint = match node.attribute("tint") {
|
||||
Some(t) => t.parse::<f64>().unwrap_or(0.0),
|
||||
None => 0.0,
|
||||
};
|
||||
let rgb = get_themed_color(theme, tint);
|
||||
Ok(Some(rgb))
|
||||
// Color::Theme { theme, tint }
|
||||
} else if node.has_attribute("auto") {
|
||||
// TODO: Is this correct?
|
||||
// A boolean value indicating the color is automatic and system color dependent.
|
||||
Ok(None)
|
||||
} else {
|
||||
println!("Unexpected color node {:?}", node);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_bool(node: Node, s: &str) -> bool {
|
||||
// defaults to true
|
||||
!matches!(node.attribute(s), Some("0"))
|
||||
}
|
||||
|
||||
pub(super) fn get_bool_false(node: Node, s: &str) -> bool {
|
||||
// defaults to false
|
||||
matches!(node.attribute(s), Some("1"))
|
||||
}
|
||||
79
xlsx/src/import/workbook.rs
Normal file
79
xlsx/src/import/workbook.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::io::Read;
|
||||
|
||||
use ironcalc_base::types::{DefinedName, SheetState};
|
||||
use roxmltree::Node;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use super::{
|
||||
util::get_attribute,
|
||||
worksheets::{Sheet, WorkbookXML},
|
||||
};
|
||||
|
||||
pub(super) fn load_workbook<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
) -> Result<WorkbookXML, XlsxError> {
|
||||
let mut file = archive.by_name("xl/workbook.xml")?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let mut defined_names = Vec::new();
|
||||
let mut sheets = Vec::new();
|
||||
// Get the sheets
|
||||
let sheet_nodes: Vec<Node> = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("sheet"))
|
||||
.collect();
|
||||
for sheet in sheet_nodes {
|
||||
let name = get_attribute(&sheet, "name")?.to_string();
|
||||
let sheet_id = get_attribute(&sheet, "sheetId")?.to_string();
|
||||
let sheet_id = sheet_id.parse::<u32>()?;
|
||||
let id = get_attribute(
|
||||
&sheet,
|
||||
(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"id",
|
||||
),
|
||||
)?
|
||||
.to_string();
|
||||
let state = match sheet.attribute("state") {
|
||||
Some("visible") | None => SheetState::Visible,
|
||||
Some("hidden") => SheetState::Hidden,
|
||||
Some("veryHidden") => SheetState::VeryHidden,
|
||||
Some(state) => return Err(XlsxError::Xml(format!("Unknown sheet state: {}", state))),
|
||||
};
|
||||
sheets.push(Sheet {
|
||||
name,
|
||||
sheet_id,
|
||||
id,
|
||||
state,
|
||||
});
|
||||
}
|
||||
// Get the defined names
|
||||
let name_nodes: Vec<Node> = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("definedName"))
|
||||
.collect();
|
||||
for node in name_nodes {
|
||||
let name = get_attribute(&node, "name")?.to_string();
|
||||
let formula = node.text().unwrap_or("").to_string();
|
||||
// NOTE: In Excel the `localSheetId` is just the index of the worksheet and unrelated to the sheetId
|
||||
let sheet_id = match node.attribute("localSheetId") {
|
||||
Some(s) => {
|
||||
let index = s.parse::<usize>()?;
|
||||
Some(sheets[index].sheet_id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
defined_names.push(DefinedName {
|
||||
name,
|
||||
formula,
|
||||
sheet_id,
|
||||
})
|
||||
}
|
||||
// read the relationships file
|
||||
Ok(WorkbookXML {
|
||||
worksheets: sheets,
|
||||
defined_names,
|
||||
})
|
||||
}
|
||||
925
xlsx/src/import/worksheets.rs
Normal file
925
xlsx/src/import/worksheets.rs
Normal file
@@ -0,0 +1,925 @@
|
||||
use std::{collections::HashMap, io::Read, num::ParseIntError};
|
||||
|
||||
use ironcalc_base::{
|
||||
expressions::{
|
||||
parser::{stringify::to_rc_format, Parser},
|
||||
token::{get_error_by_english_name, Error},
|
||||
types::CellReferenceRC,
|
||||
utils::column_to_number,
|
||||
},
|
||||
types::{Cell, Col, Comment, DefinedName, Row, SheetData, SheetState, Table, Worksheet},
|
||||
};
|
||||
use roxmltree::Node;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::error::XlsxError;
|
||||
|
||||
use super::{
|
||||
tables::load_table,
|
||||
util::{get_attribute, get_color, get_number},
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub(crate) struct Sheet {
|
||||
pub(crate) name: String,
|
||||
pub(crate) sheet_id: u32,
|
||||
pub(crate) id: String,
|
||||
pub(crate) state: SheetState,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub(crate) struct WorkbookXML {
|
||||
pub(crate) worksheets: Vec<Sheet>,
|
||||
pub(crate) defined_names: Vec<DefinedName>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub(crate) struct Relationship {
|
||||
pub(crate) target: String,
|
||||
pub(crate) rel_type: String,
|
||||
}
|
||||
|
||||
fn get_column_from_ref(s: &str) -> String {
|
||||
let cs = s.chars();
|
||||
let mut column = Vec::<char>::new();
|
||||
for c in cs {
|
||||
if !c.is_ascii_digit() {
|
||||
column.push(c);
|
||||
}
|
||||
}
|
||||
column.into_iter().collect()
|
||||
}
|
||||
|
||||
fn load_dimension(ws: Node) -> String {
|
||||
// <dimension ref="A1:O18"/>
|
||||
let application_nodes = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("dimension"))
|
||||
.collect::<Vec<Node>>();
|
||||
if application_nodes.len() == 1 {
|
||||
application_nodes[0]
|
||||
.attribute("ref")
|
||||
.unwrap_or("A1")
|
||||
.to_string()
|
||||
} else {
|
||||
"A1".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_columns(ws: Node) -> Result<Vec<Col>, XlsxError> {
|
||||
// cols
|
||||
// <cols>
|
||||
// <col min="5" max="5" width="38.26953125" customWidth="1"/>
|
||||
// <col min="6" max="6" width="9.1796875" style="1"/>
|
||||
// <col min="8" max="8" width="4" customWidth="1"/>
|
||||
// </cols>
|
||||
let mut cols = Vec::new();
|
||||
let columns = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("cols"))
|
||||
.collect::<Vec<Node>>();
|
||||
if columns.len() == 1 {
|
||||
for col in columns[0].children() {
|
||||
let min = get_attribute(&col, "min")?;
|
||||
let min = min.parse::<i32>()?;
|
||||
let max = get_attribute(&col, "max")?;
|
||||
let max = max.parse::<i32>()?;
|
||||
let width = get_attribute(&col, "width")?;
|
||||
let width = width.parse::<f64>()?;
|
||||
let custom_width = matches!(col.attribute("customWidth"), Some("1"));
|
||||
let style = col
|
||||
.attribute("style")
|
||||
.map(|s| s.parse::<i32>().unwrap_or(0));
|
||||
cols.push(Col {
|
||||
min,
|
||||
max,
|
||||
width,
|
||||
custom_width,
|
||||
style,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(cols)
|
||||
}
|
||||
|
||||
fn load_merge_cells(ws: Node) -> Result<Vec<String>, XlsxError> {
|
||||
// 18.3.1.55 Merge Cells
|
||||
// <mergeCells count="1">
|
||||
// <mergeCell ref="K7:L10"/>
|
||||
// </mergeCells>
|
||||
let mut merge_cells = Vec::new();
|
||||
let merge_cells_nodes = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("mergeCells"))
|
||||
.collect::<Vec<Node>>();
|
||||
if merge_cells_nodes.len() == 1 {
|
||||
for merge_cell in merge_cells_nodes[0].children() {
|
||||
let reference = get_attribute(&merge_cell, "ref")?.to_string();
|
||||
merge_cells.push(reference);
|
||||
}
|
||||
}
|
||||
Ok(merge_cells)
|
||||
}
|
||||
|
||||
fn load_sheet_color(ws: Node) -> Result<Option<String>, XlsxError> {
|
||||
// <sheetPr>
|
||||
// <tabColor theme="5" tint="-0.249977111117893"/>
|
||||
// </sheetPr>
|
||||
let mut color = None;
|
||||
let sheet_pr = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("sheetPr"))
|
||||
.collect::<Vec<Node>>();
|
||||
if sheet_pr.len() == 1 {
|
||||
let tabs = sheet_pr[0]
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("tabColor"))
|
||||
.collect::<Vec<Node>>();
|
||||
if tabs.len() == 1 {
|
||||
color = get_color(tabs[0])?;
|
||||
}
|
||||
}
|
||||
Ok(color)
|
||||
}
|
||||
|
||||
fn load_comments<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
path: &str,
|
||||
) -> Result<Vec<Comment>, XlsxError> {
|
||||
let mut comments = Vec::new();
|
||||
let mut file = archive.by_name(path)?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let ws = doc
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?;
|
||||
let comment_list = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("commentList"))
|
||||
.collect::<Vec<Node>>();
|
||||
if comment_list.len() == 1 {
|
||||
for comment in comment_list[0].children() {
|
||||
let text = comment
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("t"))
|
||||
.map(|n| n.text().unwrap().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
let cell_ref = get_attribute(&comment, "ref")?.to_string();
|
||||
// TODO: Read author_name from the list of authors
|
||||
let author_name = "".to_string();
|
||||
comments.push(Comment {
|
||||
text,
|
||||
author_name,
|
||||
author_id: None,
|
||||
cell_ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(comments)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, PartialEq, Eq)]
|
||||
enum ParseReferenceError {
|
||||
#[error("RowError: {0}")]
|
||||
RowError(ParseIntError),
|
||||
#[error("ColumnError: {0}")]
|
||||
ColumnError(String),
|
||||
}
|
||||
|
||||
// This parses Sheet1!AS23 into sheet, column and row
|
||||
// FIXME: This is buggy. Does not check that is a valid sheet name
|
||||
// There is a similar named function in ironcalc_base. We probably should fix both at the same time.
|
||||
// NB: Maybe use regexes for this?
|
||||
fn parse_reference(s: &str) -> Result<CellReferenceRC, ParseReferenceError> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut sheet_name = "".to_string();
|
||||
let mut column = "".to_string();
|
||||
let mut row = "".to_string();
|
||||
let mut state = "sheet"; // "sheet", "col", "row"
|
||||
for &byte in bytes {
|
||||
match state {
|
||||
"sheet" => {
|
||||
if byte == b'!' {
|
||||
state = "col"
|
||||
} else {
|
||||
sheet_name.push(byte as char);
|
||||
}
|
||||
}
|
||||
"col" => {
|
||||
if byte.is_ascii_alphabetic() {
|
||||
column.push(byte as char);
|
||||
} else {
|
||||
state = "row";
|
||||
row.push(byte as char);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
row.push(byte as char);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CellReferenceRC {
|
||||
sheet: sheet_name,
|
||||
row: row.parse::<i32>().map_err(ParseReferenceError::RowError)?,
|
||||
column: column_to_number(&column).map_err(ParseReferenceError::ColumnError)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_a1_to_rc(
|
||||
formula: String,
|
||||
worksheets: &[String],
|
||||
context: String,
|
||||
tables: HashMap<String, Table>,
|
||||
) -> Result<String, XlsxError> {
|
||||
let mut parser = Parser::new(worksheets.to_owned(), tables);
|
||||
let cell_reference =
|
||||
parse_reference(&context).map_err(|error| XlsxError::Xml(error.to_string()))?;
|
||||
let t = parser.parse(&formula, &Some(cell_reference));
|
||||
Ok(to_rc_format(&t))
|
||||
}
|
||||
|
||||
fn get_formula_index(formula: &str, shared_formulas: &[String]) -> Option<i32> {
|
||||
for (index, f) in shared_formulas.iter().enumerate() {
|
||||
if f == formula {
|
||||
return Some(index as i32);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// FIXME
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn get_cell_from_excel(
|
||||
cell_value: Option<&str>,
|
||||
value_metadata: Option<&str>,
|
||||
cell_type: &str,
|
||||
cell_style: i32,
|
||||
formula_index: i32,
|
||||
sheet_name: &str,
|
||||
cell_ref: &str,
|
||||
shared_strings: &mut Vec<String>,
|
||||
) -> Cell {
|
||||
// Possible cell types:
|
||||
// 18.18.11 ST_CellType (Cell Type)
|
||||
// b (Boolean)
|
||||
// d (Date)
|
||||
// e (Error)
|
||||
// inlineStr (Inline String)
|
||||
// n (Number)
|
||||
// s (Shared String)
|
||||
// str (String)
|
||||
|
||||
if formula_index == -1 {
|
||||
match cell_type {
|
||||
"b" => Cell::BooleanCell {
|
||||
v: cell_value == Some("1"),
|
||||
s: cell_style,
|
||||
},
|
||||
"n" => Cell::NumberCell {
|
||||
v: cell_value.unwrap_or("0").parse::<f64>().unwrap_or(0.0),
|
||||
s: cell_style,
|
||||
},
|
||||
"e" => {
|
||||
// For compatibility reasons Excel does not put the value #SPILL! but adds it as a metadata
|
||||
// Older engines would just import #VALUE!
|
||||
let mut error_name = cell_value.unwrap_or("#ERROR!");
|
||||
if error_name == "#VALUE!" && value_metadata.is_some() {
|
||||
error_name = match value_metadata {
|
||||
Some("1") => "#CALC!",
|
||||
Some("2") => "#SPILL!",
|
||||
_ => error_name,
|
||||
}
|
||||
}
|
||||
Cell::ErrorCell {
|
||||
ei: get_error_by_english_name(error_name).unwrap_or(Error::ERROR),
|
||||
s: cell_style,
|
||||
}
|
||||
}
|
||||
"s" => Cell::SharedString {
|
||||
si: cell_value.unwrap_or("0").parse::<i32>().unwrap_or(0),
|
||||
s: cell_style,
|
||||
},
|
||||
"str" => {
|
||||
let s = cell_value.unwrap_or("");
|
||||
let si = if let Some(i) = shared_strings.iter().position(|r| r == s) {
|
||||
i
|
||||
} else {
|
||||
shared_strings.push(s.to_string());
|
||||
shared_strings.len() - 1
|
||||
} as i32;
|
||||
|
||||
Cell::SharedString { si, s: cell_style }
|
||||
}
|
||||
"d" => {
|
||||
// Not implemented
|
||||
println!("Invalid type (d) in {}!{}", sheet_name, cell_ref);
|
||||
Cell::ErrorCell {
|
||||
ei: Error::NIMPL,
|
||||
s: cell_style,
|
||||
}
|
||||
}
|
||||
"inlineStr" => {
|
||||
// Not implemented
|
||||
println!("Invalid type (inlineStr) in {}!{}", sheet_name, cell_ref);
|
||||
Cell::ErrorCell {
|
||||
ei: Error::NIMPL,
|
||||
s: cell_style,
|
||||
}
|
||||
}
|
||||
"empty" => Cell::EmptyCell { s: cell_style },
|
||||
_ => {
|
||||
// error
|
||||
println!(
|
||||
"Unexpected type ({}) in {}!{}",
|
||||
cell_type, sheet_name, cell_ref
|
||||
);
|
||||
Cell::ErrorCell {
|
||||
ei: Error::ERROR,
|
||||
s: cell_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match cell_type {
|
||||
"b" => Cell::CellFormulaBoolean {
|
||||
f: formula_index,
|
||||
v: cell_value == Some("1"),
|
||||
s: cell_style,
|
||||
},
|
||||
"n" => Cell::CellFormulaNumber {
|
||||
f: formula_index,
|
||||
v: cell_value.unwrap_or("0").parse::<f64>().unwrap_or(0.0),
|
||||
s: cell_style,
|
||||
},
|
||||
"e" => {
|
||||
// For compatibility reasons Excel does not put the value #SPILL! but adds it as a metadata
|
||||
// Older engines would just import #VALUE!
|
||||
let mut error_name = cell_value.unwrap_or("#ERROR!");
|
||||
if error_name == "#VALUE!" && value_metadata.is_some() {
|
||||
error_name = match value_metadata {
|
||||
Some("1") => "#CALC!",
|
||||
Some("2") => "#SPILL!",
|
||||
_ => error_name,
|
||||
}
|
||||
}
|
||||
Cell::CellFormulaError {
|
||||
f: formula_index,
|
||||
ei: get_error_by_english_name(error_name).unwrap_or(Error::ERROR),
|
||||
s: cell_style,
|
||||
o: format!("{}!{}", sheet_name, cell_ref),
|
||||
m: cell_value.unwrap_or("#ERROR!").to_string(),
|
||||
}
|
||||
}
|
||||
"s" => {
|
||||
// Not implemented
|
||||
let o = format!("{}!{}", sheet_name, cell_ref);
|
||||
let m = Error::NIMPL.to_string();
|
||||
println!("Invalid type (s) in {}!{}", sheet_name, cell_ref);
|
||||
Cell::CellFormulaError {
|
||||
f: formula_index,
|
||||
ei: Error::NIMPL,
|
||||
s: cell_style,
|
||||
o,
|
||||
m,
|
||||
}
|
||||
}
|
||||
"str" => {
|
||||
// In Excel and in IronCalc all strings in cells result of a formula are *not* shared strings.
|
||||
Cell::CellFormulaString {
|
||||
f: formula_index,
|
||||
v: cell_value.unwrap_or("").to_string(),
|
||||
s: cell_style,
|
||||
}
|
||||
}
|
||||
"d" => {
|
||||
// Not implemented
|
||||
println!("Invalid type (d) in {}!{}", sheet_name, cell_ref);
|
||||
let o = format!("{}!{}", sheet_name, cell_ref);
|
||||
let m = Error::NIMPL.to_string();
|
||||
Cell::CellFormulaError {
|
||||
f: formula_index,
|
||||
ei: Error::NIMPL,
|
||||
s: cell_style,
|
||||
o,
|
||||
m,
|
||||
}
|
||||
}
|
||||
"inlineStr" => {
|
||||
// Not implemented
|
||||
let o = format!("{}!{}", sheet_name, cell_ref);
|
||||
let m = Error::NIMPL.to_string();
|
||||
println!("Invalid type (inlineStr) in {}!{}", sheet_name, cell_ref);
|
||||
Cell::CellFormulaError {
|
||||
f: formula_index,
|
||||
ei: Error::NIMPL,
|
||||
s: cell_style,
|
||||
o,
|
||||
m,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// error
|
||||
println!(
|
||||
"Unexpected type ({}) in {}!{}",
|
||||
cell_type, sheet_name, cell_ref
|
||||
);
|
||||
let o = format!("{}!{}", sheet_name, cell_ref);
|
||||
let m = Error::ERROR.to_string();
|
||||
Cell::CellFormulaError {
|
||||
f: formula_index,
|
||||
ei: Error::ERROR,
|
||||
s: cell_style,
|
||||
o,
|
||||
m,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_sheet_rels<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
path: &str,
|
||||
tables: &mut HashMap<String, Table>,
|
||||
sheet_name: &str,
|
||||
) -> Result<Vec<Comment>, XlsxError> {
|
||||
// ...xl/worksheets/sheet6.xml -> xl/worksheets/_rels/sheet6.xml.rels
|
||||
let mut comments = Vec::new();
|
||||
let v: Vec<&str> = path.split("/worksheets/").collect();
|
||||
let mut path = v[0].to_string();
|
||||
path.push_str("/worksheets/_rels/");
|
||||
path.push_str(v[1]);
|
||||
path.push_str(".rels");
|
||||
let file = archive.by_name(&path);
|
||||
if file.is_err() {
|
||||
return Ok(comments);
|
||||
}
|
||||
let mut text = String::new();
|
||||
file.unwrap().read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
|
||||
let rels = doc
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?
|
||||
.children()
|
||||
.collect::<Vec<Node>>();
|
||||
for rel in rels {
|
||||
let t = get_attribute(&rel, "Type")?.to_string();
|
||||
if t.ends_with("comments") {
|
||||
let mut target = get_attribute(&rel, "Target")?.to_string();
|
||||
// Target="../comments1.xlsx"
|
||||
target.replace_range(..2, v[0]);
|
||||
comments = load_comments(archive, &target)?;
|
||||
} else if t.ends_with("table") {
|
||||
let mut target = get_attribute(&rel, "Target")?.to_string();
|
||||
|
||||
let path = if let Some(p) = target.strip_prefix('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
// Target="../table1.xlsx"
|
||||
target.replace_range(..2, v[0]);
|
||||
target
|
||||
};
|
||||
|
||||
let table = load_table(archive, &path, sheet_name)?;
|
||||
tables.insert(table.name.clone(), table);
|
||||
}
|
||||
}
|
||||
Ok(comments)
|
||||
}
|
||||
|
||||
fn get_frozen_rows_and_columns(ws: Node) -> (i32, i32) {
|
||||
// <sheetViews>
|
||||
// <sheetView workbookViewId="0">
|
||||
// <selection activeCell="E10" sqref="E10"/>
|
||||
// </sheetView>
|
||||
// </sheetViews>
|
||||
// <sheetFormatPr defaultRowHeight="14.5" x14ac:dyDescent="0.35"/>
|
||||
|
||||
// If we have frozen rows and columns:
|
||||
|
||||
// <sheetView tabSelected="1" workbookViewId="0">
|
||||
// <pane xSplit="3" ySplit="2" topLeftCell="D3" activePane="bottomRight" state="frozen"/>
|
||||
// <selection pane="topRight" activeCell="D1" sqref="D1"/>
|
||||
// <selection pane="bottomLeft" activeCell="A3" sqref="A3"/>
|
||||
// <selection pane="bottomRight" activeCell="K16" sqref="K16"/>
|
||||
// </sheetView>
|
||||
|
||||
// 18.18.52 ST_Pane (Pane Types)
|
||||
// bottomLeft, bottomRight, topLeft, topRight
|
||||
|
||||
// NB: bottomLeft is used when only rows are frozen, etc
|
||||
// Calc ignores all those.
|
||||
|
||||
let mut frozen_rows = 0;
|
||||
let mut frozen_columns = 0;
|
||||
|
||||
// In Calc there can only be one sheetView
|
||||
let sheet_views = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("sheetViews"))
|
||||
.collect::<Vec<Node>>();
|
||||
|
||||
if sheet_views.len() != 1 {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let sheet_view = sheet_views[0]
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("sheetView"))
|
||||
.collect::<Vec<Node>>();
|
||||
|
||||
if sheet_view.len() != 1 {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let pane = sheet_view[0]
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("pane"))
|
||||
.collect::<Vec<Node>>();
|
||||
|
||||
// 18.18.53 ST_PaneState (Pane State)
|
||||
// frozen, frozenSplit, split
|
||||
if pane.len() == 1 && pane[0].attribute("state").unwrap_or("split") == "frozen" {
|
||||
// TODO: Should we assert that topLeft is consistent?
|
||||
// let top_left_cell = pane[0].attribute("topLeftCell").unwrap_or("A1").to_string();
|
||||
|
||||
frozen_columns = get_number(pane[0], "xSplit");
|
||||
frozen_rows = get_number(pane[0], "ySplit");
|
||||
}
|
||||
(frozen_rows, frozen_columns)
|
||||
}
|
||||
|
||||
pub(super) struct SheetSettings {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub state: SheetState,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
pub(super) fn load_sheet<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
path: &str,
|
||||
settings: SheetSettings,
|
||||
worksheets: &[String],
|
||||
tables: &HashMap<String, Table>,
|
||||
shared_strings: &mut Vec<String>,
|
||||
) -> Result<Worksheet, XlsxError> {
|
||||
let sheet_name = &settings.name;
|
||||
let sheet_id = settings.id;
|
||||
let state = &settings.state;
|
||||
|
||||
let mut file = archive.by_name(path)?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
let ws = doc
|
||||
.root()
|
||||
.first_child()
|
||||
.ok_or_else(|| XlsxError::Xml("Corrupt XML structure".to_string()))?;
|
||||
let mut shared_formulas = Vec::new();
|
||||
|
||||
let dimension = load_dimension(ws);
|
||||
|
||||
let (frozen_rows, frozen_columns) = get_frozen_rows_and_columns(ws);
|
||||
|
||||
let cols = load_columns(ws)?;
|
||||
let color = load_sheet_color(ws)?;
|
||||
|
||||
// sheetData
|
||||
// <row r="1" spans="1:15" x14ac:dyDescent="0.35">
|
||||
// <c r="A1" t="s">
|
||||
// <v>0</v>
|
||||
// </c>
|
||||
// <c r="D1">
|
||||
// <f>C1+1</f>
|
||||
// </c>
|
||||
// </row>
|
||||
|
||||
// holds the row heights
|
||||
let mut rows = Vec::new();
|
||||
let mut sheet_data = SheetData::new();
|
||||
let sheet_data_nodes = ws
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name("sheetData"))
|
||||
.collect::<Vec<Node>>()[0];
|
||||
|
||||
let default_row_height = 14.5;
|
||||
|
||||
// holds a map from the formula index in Excel to the index in IronCalc
|
||||
let mut index_map = HashMap::new();
|
||||
for row in sheet_data_nodes.children() {
|
||||
// This is the row number 1-indexed
|
||||
let row_index = get_attribute(&row, "r")?.parse::<i32>()?;
|
||||
// `spans` is not used in IronCalc at the moment (it's an optimization)
|
||||
// let spans = row.attribute("spans");
|
||||
// This is the height of the row
|
||||
let has_height_attribute;
|
||||
let height = match row.attribute("ht") {
|
||||
Some(s) => {
|
||||
has_height_attribute = true;
|
||||
s.parse::<f64>().unwrap_or(default_row_height)
|
||||
}
|
||||
None => {
|
||||
has_height_attribute = false;
|
||||
default_row_height
|
||||
}
|
||||
};
|
||||
let custom_height = matches!(row.attribute("customHeight"), Some("1"));
|
||||
// The height of the row is always the visible height of the row
|
||||
// If custom_height is false that means the height was calculated automatically:
|
||||
// for example because a cell has many lines or a larger font
|
||||
|
||||
let row_style = match row.attribute("s") {
|
||||
Some(s) => s.parse::<i32>().unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let custom_format = matches!(row.attribute("customFormat"), Some("1"));
|
||||
let hidden = matches!(row.attribute("hidden"), Some("1"));
|
||||
|
||||
if custom_height || custom_format || row_style != 0 || has_height_attribute || hidden {
|
||||
rows.push(Row {
|
||||
r: row_index,
|
||||
height,
|
||||
s: row_style,
|
||||
custom_height,
|
||||
custom_format,
|
||||
hidden,
|
||||
});
|
||||
}
|
||||
|
||||
// Unused attributes:
|
||||
// * thickBot, thickTop, ph, collapsed, outlineLevel
|
||||
|
||||
let mut data_row = HashMap::new();
|
||||
|
||||
// 18.3.1.4 c (Cell)
|
||||
// Child Elements:
|
||||
// * v: Cell value
|
||||
// * is: Rich Text Inline (not used in IronCalc)
|
||||
// * f: Formula
|
||||
// Attributes:
|
||||
// r: reference. A1 style
|
||||
// s: style index
|
||||
// t: cell type
|
||||
// Unused attributes
|
||||
// cm (cell metadata), ph (Show Phonetic), vm (value metadata)
|
||||
for cell in row.children() {
|
||||
let cell_ref = get_attribute(&cell, "r")?;
|
||||
let column_letter = get_column_from_ref(cell_ref);
|
||||
let column = column_to_number(column_letter.as_str()).map_err(XlsxError::Xml)?;
|
||||
|
||||
let value_metadata = cell.attribute("vm");
|
||||
|
||||
// We check the value "v" child.
|
||||
let vs: Vec<Node> = cell.children().filter(|n| n.has_tag_name("v")).collect();
|
||||
let cell_value = if vs.len() == 1 {
|
||||
Some(vs[0].text().unwrap_or(""))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// type, the default type being "n" for number
|
||||
// If the cell does not have a value is an empty cell
|
||||
let cell_type = match cell.attribute("t") {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
if cell_value.is_none() {
|
||||
"empty"
|
||||
} else {
|
||||
"n"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// style index, the default style is 0
|
||||
let cell_style = match cell.attribute("s") {
|
||||
Some(s) => s.parse::<i32>().unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
// Check for formula
|
||||
// In Excel some formulas are shared and some are not, but in IronCalc all formulas are shared
|
||||
// A cell with a "non-shared" formula is like:
|
||||
// <c r="E3">
|
||||
// <f>C2+1</f>
|
||||
// <v>3</v>
|
||||
// </c>
|
||||
// A cell with a shared formula will be either a "mother" cell:
|
||||
// <c r="D2">
|
||||
// <f t="shared" ref="D2:D3" si="0">C2+1</f>
|
||||
// <v>3</v>
|
||||
// </c>
|
||||
// Or a "daughter" cell:
|
||||
// <c r="D3">
|
||||
// <f t="shared" si="0"/>
|
||||
// <v>4</v>
|
||||
// </c>
|
||||
// In IronCalc two cells have the same formula iff the R1C1 representation is the same
|
||||
// TODO: This algorithm could end up with "repeated" shared formulas
|
||||
// We could solve that with a second transversal.
|
||||
let fs: Vec<Node> = cell.children().filter(|n| n.has_tag_name("f")).collect();
|
||||
let mut formula_index = -1;
|
||||
if fs.len() == 1 {
|
||||
// formula types:
|
||||
// 18.18.6 ST_CellFormulaType (Formula Type)
|
||||
// array (Array Formula) Formula is an array formula.
|
||||
// dataTable (Table Formula) Formula is a data table formula.
|
||||
// normal (Normal) Formula is a regular cell formula. (Default)
|
||||
// shared (Shared Formula) Formula is part of a shared formula.
|
||||
let formula_type = fs[0].attribute("t").unwrap_or("normal");
|
||||
match formula_type {
|
||||
"shared" => {
|
||||
// We have a shared formula
|
||||
let si = get_attribute(&fs[0], "si")?;
|
||||
let si = si.parse::<i32>()?;
|
||||
match fs[0].attribute("ref") {
|
||||
Some(_) => {
|
||||
// It's the mother cell. We do not use the ref attribute in IronCalc
|
||||
let formula = fs[0].text().unwrap_or("").to_string();
|
||||
let context = format!("{}!{}", sheet_name, cell_ref);
|
||||
let formula =
|
||||
from_a1_to_rc(formula, worksheets, context, tables.clone())?;
|
||||
match index_map.get(&si) {
|
||||
Some(index) => {
|
||||
// The index for that formula already exists meaning we bumped into a daughter cell first
|
||||
// TODO: Worth assert the content is a placeholder?
|
||||
formula_index = *index;
|
||||
shared_formulas.insert(formula_index as usize, formula);
|
||||
}
|
||||
None => {
|
||||
// We haven't met any of the daughter cells
|
||||
match get_formula_index(&formula, &shared_formulas) {
|
||||
// The formula is already present, use that index
|
||||
Some(index) => {
|
||||
formula_index = index;
|
||||
}
|
||||
None => {
|
||||
shared_formulas.push(formula);
|
||||
formula_index = shared_formulas.len() as i32 - 1;
|
||||
}
|
||||
};
|
||||
index_map.insert(si, formula_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// It's a daughter cell
|
||||
match index_map.get(&si) {
|
||||
Some(index) => {
|
||||
formula_index = *index;
|
||||
}
|
||||
None => {
|
||||
// Haven't bumped into the mother cell yet. We insert a placeholder.
|
||||
// Note that it is perfectly possible that the formula of the mother cell
|
||||
// is already in the set of array formulas. This will lead to the above mention duplicity.
|
||||
// This is not a problem
|
||||
let placeholder = "".to_string();
|
||||
shared_formulas.push(placeholder);
|
||||
formula_index = shared_formulas.len() as i32 - 1;
|
||||
index_map.insert(si, formula_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"array" => {
|
||||
return Err(XlsxError::NotImplemented("array formulas".to_string()));
|
||||
}
|
||||
"dataTable" => {
|
||||
return Err(XlsxError::NotImplemented("data table formulas".to_string()));
|
||||
}
|
||||
"normal" => {
|
||||
// Its a cell with a simple formula
|
||||
let formula = fs[0].text().unwrap_or("").to_string();
|
||||
let context = format!("{}!{}", sheet_name, cell_ref);
|
||||
let formula = from_a1_to_rc(formula, worksheets, context, tables.clone())?;
|
||||
|
||||
match get_formula_index(&formula, &shared_formulas) {
|
||||
Some(index) => formula_index = index,
|
||||
None => {
|
||||
shared_formulas.push(formula);
|
||||
formula_index = shared_formulas.len() as i32 - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(XlsxError::Xml(format!(
|
||||
"Invalid formula type {:?}.",
|
||||
formula_type,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let cell = get_cell_from_excel(
|
||||
cell_value,
|
||||
value_metadata,
|
||||
cell_type,
|
||||
cell_style,
|
||||
formula_index,
|
||||
sheet_name,
|
||||
cell_ref,
|
||||
shared_strings,
|
||||
);
|
||||
data_row.insert(column, cell);
|
||||
}
|
||||
sheet_data.insert(row_index, data_row);
|
||||
}
|
||||
|
||||
let merge_cells = load_merge_cells(ws)?;
|
||||
|
||||
// Conditional Formatting
|
||||
// <conditionalFormatting sqref="B1:B9">
|
||||
// <cfRule type="colorScale" priority="1">
|
||||
// <colorScale>
|
||||
// <cfvo type="min"/>
|
||||
// <cfvo type="max"/>
|
||||
// <color rgb="FFF8696B"/>
|
||||
// <color rgb="FFFCFCFF"/>
|
||||
// </colorScale>
|
||||
// </cfRule>
|
||||
// </conditionalFormatting>
|
||||
// pageSetup
|
||||
// <pageSetup orientation="portrait" r:id="rId1"/>
|
||||
|
||||
Ok(Worksheet {
|
||||
dimension,
|
||||
cols,
|
||||
rows,
|
||||
shared_formulas,
|
||||
sheet_data,
|
||||
name: sheet_name.to_string(),
|
||||
sheet_id,
|
||||
state: state.to_owned(),
|
||||
color,
|
||||
merge_cells,
|
||||
comments: settings.comments,
|
||||
frozen_rows,
|
||||
frozen_columns,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn load_sheets<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::read::ZipArchive<R>,
|
||||
rels: &HashMap<String, Relationship>,
|
||||
workbook: &WorkbookXML,
|
||||
tables: &mut HashMap<String, Table>,
|
||||
shared_strings: &mut Vec<String>,
|
||||
) -> Result<Vec<Worksheet>, XlsxError> {
|
||||
// load comments and tables
|
||||
let mut comments = HashMap::new();
|
||||
for sheet in &workbook.worksheets {
|
||||
let rel = &rels[&sheet.id];
|
||||
if rel.rel_type.ends_with("worksheet") {
|
||||
let path = &rel.target;
|
||||
let path = if let Some(p) = path.strip_prefix('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("xl/{path}")
|
||||
};
|
||||
comments.insert(
|
||||
&sheet.id,
|
||||
load_sheet_rels(archive, &path, tables, &sheet.name)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// load all sheets
|
||||
let worksheets: &Vec<String> = &workbook.worksheets.iter().map(|s| s.name.clone()).collect();
|
||||
let mut sheets = Vec::new();
|
||||
for sheet in &workbook.worksheets {
|
||||
let sheet_name = &sheet.name;
|
||||
let rel_id = &sheet.id;
|
||||
let state = &sheet.state;
|
||||
let rel = &rels[rel_id];
|
||||
if rel.rel_type.ends_with("worksheet") {
|
||||
let path = &rel.target;
|
||||
let path = if let Some(p) = path.strip_prefix('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("xl/{path}")
|
||||
};
|
||||
let settings = SheetSettings {
|
||||
name: sheet_name.to_string(),
|
||||
id: sheet.sheet_id,
|
||||
state: state.clone(),
|
||||
comments: comments.get(rel_id).expect("").to_vec(),
|
||||
};
|
||||
sheets.push(load_sheet(
|
||||
archive,
|
||||
&path,
|
||||
settings,
|
||||
worksheets,
|
||||
tables,
|
||||
shared_strings,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
Ok(sheets)
|
||||
}
|
||||
61
xlsx/src/lib.rs
Normal file
61
xlsx/src/lib.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! This cate reads an xlsx file and transforms it into an internal representation ([`Model`]).
|
||||
//! An `xlsx` is a zip file containing a set of folders and `xml` files. The IronCalc json structure mimics the relevant parts of the Excel zip.
|
||||
//! Although the xlsx structure is quite complicated, it's essentials regarding the spreadsheet technology are easier to grasp.
|
||||
//!
|
||||
//! The simplest workbook folder structure might look like this:
|
||||
//!
|
||||
//! ```text
|
||||
//! docProps
|
||||
//! app.xml
|
||||
//! core.xml
|
||||
//!
|
||||
//! _rels
|
||||
//! .rels
|
||||
//!
|
||||
//! xl
|
||||
//! _rels
|
||||
//! workbook.xml.rels
|
||||
//! theme
|
||||
//! theme1.xml
|
||||
//! worksheets
|
||||
//! sheet1.xml
|
||||
//! calcChain.xml
|
||||
//! styles.xml
|
||||
//! workbook.xml
|
||||
//! sharedStrings.xml
|
||||
//!
|
||||
//! [Content_Types].xml
|
||||
//! ```
|
||||
//!
|
||||
//! Note that more complicated workbooks will have many more files and folders.
|
||||
//! For instance charts, pivot tables, comments, tables,...
|
||||
//!
|
||||
//! The relevant json structure in IronCalc will be:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "name": "Workbook1",
|
||||
//! "defined_names": [],
|
||||
//! "shared_strings": [],
|
||||
//! "worksheets": [],
|
||||
//! "styles": {
|
||||
//! "num_fmts": [],
|
||||
//! "fonts": [],
|
||||
//! "fills": [],
|
||||
//! "borders": [],
|
||||
//! "cell_style_xfs": [],
|
||||
//! "cell_styles" : [],
|
||||
//! "cell_xfs": []
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Note that there is not a 1-1 correspondence but there is a close resemblance.
|
||||
//!
|
||||
//! [`Model`]: ../ironcalc/struct.Model.html
|
||||
|
||||
pub mod compare;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub use ironcalc_base as base;
|
||||
Reference in New Issue
Block a user