pub mod check;
pub mod diagnose;
pub mod diagnoseworkbook;
pub mod diagnostics;
pub mod formula;
pub mod parser;
pub mod reader;
pub mod snapshot;
#[cfg(feature = "python")]
pub mod stream;
pub mod testworkbook;
pub mod vm;
pub use elixcee_types as types;
#[cfg(any(feature = "python", test))]
use vm::CellContent;
#[cfg(any(feature = "python", test))]
use vm::{FillEdit, StyleAttrEdit};
use vm::{Variant, Vm, WorksheetOrigin};
#[cfg(feature = "python")]
use pyo3::prelude::*;
#[cfg(feature = "python")]
use pyo3::types::{PyDict, PyList};
#[cfg(feature = "python")]
use vm::{ExcelError, serial_to_display};
#[cfg(feature = "python")]
#[pyclass(name = "ExcelError", from_py_object)]
#[derive(Clone, Debug)]
pub struct PyExcelError {
#[pyo3(get)]
pub code: String,
}
#[cfg(feature = "python")]
#[pymethods]
impl PyExcelError {
#[new]
fn new(code: String) -> Self {
PyExcelError { code }
}
fn __repr__(&self) -> String {
format!("ExcelError('{}')", self.code)
}
fn __str__(&self) -> String {
self.code.clone()
}
fn __eq__(&self, other: &PyExcelError) -> bool {
self.code == other.code
}
fn __hash__(&self) -> isize {
self.code.len() as isize
}
}
#[cfg(feature = "python")]
fn variant_to_py(py: Python<'_>, v: &Variant) -> Py<PyAny> {
match v {
Variant::Integer(n) => (*n).into_pyobject(py).unwrap().into_any().unbind(),
Variant::Float(f) => (*f).into_pyobject(py).unwrap().into_any().unbind(),
Variant::Str(s) => s.as_str().into_pyobject(py).unwrap().into_any().unbind(),
Variant::Boolean(b) => {
let borrowed = (*b).into_pyobject(py).unwrap();
<pyo3::Bound<'_, pyo3::types::PyBool> as Clone>::clone(&borrowed)
.unbind()
.into_any()
}
Variant::Date(s) => {
let (y, m, d) = crate::types::serial_to_ymd(*s);
pyo3::types::PyDate::new(py, y, m as u8, d as u8)
.map(|dt| dt.into_any().unbind())
.unwrap_or_else(|_| {
serial_to_display(*s)
.into_pyobject(py)
.unwrap()
.into_any()
.unbind()
})
}
Variant::Error(e) => PyExcelError {
code: e.as_str().to_string(),
}
.into_pyobject(py)
.unwrap()
.into_any()
.unbind(),
Variant::Empty | Variant::Null => py.None(),
Variant::Array(a) => {
let list =
pyo3::types::PyList::new(py, a.iter().map(|x| variant_to_py(py, x))).unwrap();
list.into_any().unbind()
}
Variant::VbaArray(a) => vba_array_to_py(py, a, &mut Vec::new()),
Variant::Record(m) => {
let dict = pyo3::types::PyDict::new(py);
for (k, v) in m {
dict.set_item(k, variant_to_py(py, v)).unwrap();
}
dict.into_any().unbind()
}
}
}
#[cfg(feature = "python")]
fn vba_array_to_py(py: Python<'_>, arr: &vm::VbaArray, prefix: &mut Vec<i64>) -> Py<PyAny> {
if prefix.len() == arr.bounds.len() {
let v = arr.get(prefix).expect("prefix built from arr's own bounds");
return variant_to_py(py, v);
}
let bound = arr.bounds[prefix.len()];
let mut items = Vec::new();
let mut i = bound.lower;
while i <= bound.upper {
prefix.push(i);
items.push(vba_array_to_py(py, arr, prefix));
prefix.pop();
i += 1;
}
pyo3::types::PyList::new(py, items)
.unwrap()
.into_any()
.unbind()
}
#[cfg(feature = "python")]
fn py_to_variant(obj: &Bound<'_, PyAny>) -> PyResult<Variant> {
if obj.is_none() {
return Ok(Variant::Empty);
}
if let Ok(b) = obj.extract::<bool>() {
return Ok(Variant::Boolean(b));
}
if let Ok(n) = obj.extract::<i64>() {
return Ok(Variant::Integer(n));
}
if let Ok(f) = obj.extract::<f64>() {
return Ok(Variant::Float(f));
}
if let Ok(s) = obj.extract::<String>() {
return Ok(Variant::Str(s));
}
if let Ok(e) = obj.extract::<PyExcelError>() {
return Ok(Variant::Error(match e.code.as_str() {
"#DIV/0!" => ExcelError::DivZero,
"#N/A" => ExcelError::NA,
"#VALUE!" => ExcelError::Value,
"#REF!" => ExcelError::Ref,
"#NAME?" => ExcelError::Name,
"#NUM!" => ExcelError::Num,
"#NULL!" => ExcelError::Null,
_ => ExcelError::Value,
}));
}
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"Unsupported cell value type",
))
}
#[cfg(feature = "python")]
fn normalize_color_hex(input: &str) -> PyResult<String> {
let s = input.trim().trim_start_matches('#');
let is_hex = !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit());
match s.len() {
6 if is_hex => Ok(format!("FF{}", s.to_uppercase())),
8 if is_hex => Ok(s.to_uppercase()),
_ => Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"color must be a 6-digit RGB or 8-digit ARGB hex string, got {input:?}"
))),
}
}
#[cfg(feature = "python")]
fn dict_str(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<String>> {
match dict.get_item(key)? {
Some(v) => Ok(Some(v.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("{key:?} must be a string"))
})?)),
None => Ok(None),
}
}
#[cfg(feature = "python")]
fn dict_bool(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<bool>> {
match dict.get_item(key)? {
Some(v) => Ok(Some(v.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("{key:?} must be a bool"))
})?)),
None => Ok(None),
}
}
#[cfg(feature = "python")]
fn dict_f64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<f64>> {
match dict.get_item(key)? {
Some(v) => Ok(Some(v.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("{key:?} must be a number"))
})?)),
None => Ok(None),
}
}
#[cfg(feature = "python")]
fn dict_u32(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<u32>> {
match dict.get_item(key)? {
Some(v) => Ok(Some(v.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
"{key:?} must be a non-negative integer"
))
})?)),
None => Ok(None),
}
}
#[cfg(feature = "python")]
fn dict_color(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<String>> {
match dict_str(dict, key)? {
Some(s) => Ok(Some(normalize_color_hex(&s)?)),
None => Ok(None),
}
}
#[cfg(feature = "python")]
fn extract_font_edit(dict: &Bound<'_, PyDict>) -> PyResult<reader::FontEdit> {
Ok(reader::FontEdit {
bold: dict_bool(dict, "bold")?,
italic: dict_bool(dict, "italic")?,
underline: dict_bool(dict, "underline")?,
strike: dict_bool(dict, "strike")?,
size: dict_f64(dict, "size")?,
color_argb: dict_color(dict, "color")?,
name: dict_str(dict, "name")?,
})
}
#[cfg(feature = "python")]
fn extract_fill_edit(dict: &Bound<'_, PyDict>) -> PyResult<FillEdit> {
let fill_type = dict_str(dict, "type")?.unwrap_or_else(|| "solid".to_string());
if fill_type != "solid" {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"fill['type'] {fill_type:?} not supported yet -- only \"solid\" is implemented \
(0.15.0-B); gradient/pattern fills are a future milestone"
)));
}
let color_argb = dict_color(dict, "color")?.ok_or_else(|| {
PyErr::new::<pyo3::exceptions::PyValueError, _>("fill requires a 'color'")
})?;
Ok(FillEdit { color_argb })
}
#[cfg(feature = "python")]
fn extract_border_side_edit(
dict: &Bound<'_, PyDict>,
side_name: &str,
) -> PyResult<Option<reader::BorderSideEdit>> {
let Some(side_val) = dict.get_item(side_name)? else {
return Ok(None);
};
let side_dict = side_val.cast::<PyDict>().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
"border[{side_name:?}] must be a dict"
))
})?;
Ok(Some(reader::BorderSideEdit {
style: dict_str(side_dict, "style")?,
color_argb: dict_color(side_dict, "color")?,
}))
}
#[cfg(feature = "python")]
fn extract_border_edit(dict: &Bound<'_, PyDict>) -> PyResult<reader::BorderEdit> {
Ok(reader::BorderEdit {
left: extract_border_side_edit(dict, "left")?,
right: extract_border_side_edit(dict, "right")?,
top: extract_border_side_edit(dict, "top")?,
bottom: extract_border_side_edit(dict, "bottom")?,
diagonal: extract_border_side_edit(dict, "diagonal")?,
})
}
#[cfg(feature = "python")]
fn extract_alignment_edit(dict: &Bound<'_, PyDict>) -> PyResult<reader::AlignmentEdit> {
Ok(reader::AlignmentEdit {
horizontal: dict_str(dict, "horizontal")?,
vertical: dict_str(dict, "vertical")?,
wrap_text: dict_bool(dict, "wrap_text")?,
indent: dict_u32(dict, "indent")?,
})
}
#[cfg(feature = "python")]
fn extract_protection_edit(dict: &Bound<'_, PyDict>) -> PyResult<reader::ProtectionEdit> {
Ok(reader::ProtectionEdit {
locked: dict_bool(dict, "locked")?,
hidden: dict_bool(dict, "hidden")?,
})
}
#[cfg_attr(not(feature = "python"), allow(dead_code))]
type RangeBounds = ((u32, u32), (u32, u32));
type StyleIndexMap = std::collections::HashMap<String, std::collections::HashMap<(u32, u32), u32>>;
type ColAttrsMap = std::collections::BTreeMap<(u32, u32), (bool, Option<f64>, Option<u32>)>;
#[cfg_attr(not(feature = "python"), allow(dead_code))]
fn validate_range_addr(addr: &str) -> Result<RangeBounds, String> {
if addr.contains(',') {
return Err(format!("multi-area address not supported: {addr:?}"));
}
let stripped = addr.replace('$', "");
let (start, end) = crate::types::parse_range_addr(&stripped)
.ok_or_else(|| format!("invalid range address: {addr:?}"))?;
if start.0 == 0 || start.1 == 0 || end.0 == 0 || end.1 == 0 {
return Err(format!(
"invalid range address (row/column must be >= 1): {addr:?}"
));
}
if start.0 > end.0 || start.1 > end.1 {
return Err(format!("reversed range address: {addr:?}"));
}
Ok((start, end))
}
#[cfg_attr(not(feature = "python"), allow(dead_code))]
fn parse_sqref_areas(sqref: &str) -> Result<Vec<RangeBounds>, String> {
const MAX_ROW: u32 = 1_048_576;
const MAX_COL: u32 = 16_384;
let areas: Result<Vec<RangeBounds>, String> =
sqref.split_whitespace().map(validate_range_addr).collect();
let areas = areas?;
if areas.is_empty() {
return Err(format!("sqref must not be empty: {sqref:?}"));
}
for &(_, (r2, c2)) in &areas {
if r2 > MAX_ROW || c2 > MAX_COL {
return Err(format!(
"sqref area exceeds sheet bounds (max row {MAX_ROW}, max col {MAX_COL}), got row {r2}, col {c2}"
));
}
}
Ok(areas)
}
#[cfg_attr(not(feature = "python"), allow(dead_code))]
fn check_grid_shape(expected: (u32, u32), row_lens: &[usize]) -> Result<(), String> {
if let Some(&first) = row_lens.first()
&& row_lens.iter().any(|&n| n != first)
{
return Err(format!(
"ragged input: row lengths must all be equal, got {row_lens:?}"
));
}
let (expected_rows, expected_cols) = (expected.0 as usize, expected.1 as usize);
let actual_rows = row_lens.len();
let actual_cols = row_lens.first().copied().unwrap_or(0);
if actual_rows != expected_rows || actual_cols != expected_cols {
return Err(format!(
"shape mismatch: range expects {expected_rows}x{expected_cols}, got {actual_rows}x{actual_cols}"
));
}
Ok(())
}
#[cfg(test)]
mod bulk_range_validation_tests {
use super::*;
#[test]
fn validate_range_addr_accepts_a_normal_range() {
assert_eq!(validate_range_addr("A1:C5").unwrap(), ((1, 1), (5, 3)));
}
#[test]
fn validate_range_addr_accepts_a_bare_single_cell() {
assert_eq!(validate_range_addr("B2").unwrap(), ((2, 2), (2, 2)));
}
#[test]
fn validate_range_addr_strips_dollar_signs() {
assert_eq!(validate_range_addr("$A$1:$C$5").unwrap(), ((1, 1), (5, 3)));
}
#[test]
fn validate_range_addr_rejects_multi_area() {
let err = validate_range_addr("A1:B2,D1:E2").unwrap_err();
assert!(err.contains("multi-area"), "{err:?}");
}
#[test]
fn validate_range_addr_rejects_malformed_input() {
assert!(validate_range_addr("!!").is_err());
assert!(validate_range_addr("").is_err());
}
#[test]
fn validate_range_addr_rejects_a_reversed_range() {
let err = validate_range_addr("C3:A1").unwrap_err();
assert!(err.contains("reversed"), "{err:?}");
}
#[test]
fn validate_range_addr_rejects_row_or_col_zero() {
assert!(validate_range_addr("A0").is_err());
assert!(validate_range_addr("A0:B1").is_err());
}
#[test]
fn check_grid_shape_accepts_an_exact_match() {
check_grid_shape((2, 3), &[3, 3]).unwrap();
}
#[test]
fn check_grid_shape_rejects_ragged_input() {
let err = check_grid_shape((2, 3), &[3, 2]).unwrap_err();
assert!(err.contains("ragged"), "{err:?}");
}
#[test]
fn check_grid_shape_rejects_a_shape_mismatch() {
let err = check_grid_shape((2, 3), &[2, 2]).unwrap_err();
assert!(err.contains("2x3"), "{err:?}");
assert!(err.contains("2x2"), "{err:?}");
}
#[test]
fn check_grid_shape_on_empty_input_does_not_panic() {
let err = check_grid_shape((2, 3), &[]).unwrap_err();
assert!(err.contains("0x0"), "{err:?}");
}
}
#[cfg(feature = "python")]
#[pyclass(name = "Vm")]
pub struct PyVm {
inner: Vm,
}
#[cfg(feature = "python")]
#[pymethods]
impl PyVm {
#[new]
#[pyo3(signature = (on_msgbox = "skip"))]
fn new(on_msgbox: &str) -> PyResult<Self> {
let mut vm = Vm::new();
vm.error_on_msgbox = on_msgbox == "error";
Ok(PyVm { inner: vm })
}
fn run(&mut self, vba_code: &str, macro_name: &str) -> PyResult<()> {
let prog = parser::parse(vba_code)
.map_err(|e| PyErr::new::<pyo3::exceptions::PySyntaxError, _>(e.to_string()))?;
self.inner
.run_sub(&prog, macro_name)
.map_err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>)
}
fn set_cell(&mut self, row: u32, col: u32, value: &Bound<'_, PyAny>) -> PyResult<()> {
let v = py_to_variant(value)?;
self.inner.cells_mut().insert(
(row, col),
CellContent {
formula: None,
value: v,
},
);
Ok(())
}
fn get_cell(&self, py: Python<'_>, row: u32, col: u32) -> Py<PyAny> {
variant_to_py(py, &self.inner.get_cell(row, col))
}
fn get_cell_number_format(&self, row: u32, col: u32) -> Option<&str> {
self.inner.get_cell_number_format(row, col)
}
fn cells(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let dict = PyDict::new(py);
for ((row, col), content) in self.inner.cells() {
if !matches!(content.value, Variant::Empty) {
let key = (*row, *col).into_pyobject(py)?.into_any().unbind();
dict.set_item(key, variant_to_py(py, &content.value))?;
}
}
Ok(dict.into_any().unbind())
}
fn variables(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let dict = PyDict::new(py);
for (name, value) in &self.inner.variables {
dict.set_item(name.as_str(), variant_to_py(py, value))?;
}
Ok(dict.into_any().unbind())
}
fn set_cell_formula(&mut self, row: u32, col: u32, formula: &str) -> PyResult<()> {
self.inner
.set_cell_formula(row, col, formula)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn recalculate(&mut self) -> PyResult<()> {
self.inner
.recalculate_all()
.map_err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>)
}
fn set_cell_formula_batch(&mut self, formulas: &Bound<'_, PyDict>) -> PyResult<()> {
for (key, val) in formulas.iter() {
let (row, col): (u32, u32) = key.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"keys must be (row, col) tuples of integers",
)
})?;
let formula: String = val.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>("values must be formula strings")
})?;
self.inner
.set_cell_formula(row, col, &formula)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
}
Ok(())
}
#[pyo3(signature = (name, index = None))]
fn set_sheet(&mut self, name: &str, index: Option<usize>) {
self.inner.ensure_sheet_at(name, index);
self.inner.active_sheet = name.to_lowercase();
}
fn delete_sheet(&mut self, name: &str) -> PyResult<()> {
self.inner
.delete_sheet(name)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn rename_sheet(&mut self, old_name: &str, new_name: &str) -> PyResult<()> {
self.inner
.rename_sheet(old_name, new_name)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn move_sheet(&mut self, name: &str, new_index: usize) -> PyResult<()> {
self.inner
.move_sheet(name, new_index)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn copy_sheet(&mut self, source_name: &str, new_name: &str) -> PyResult<()> {
self.inner
.copy_sheet(source_name, new_name)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn sheet_state(&self, name: &str) -> PyResult<String> {
self.inner
.sheet_state(name)
.map(|s| s.as_str().to_string())
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn defined_names(&self) -> PyResult<std::collections::HashMap<String, String>> {
self.inner
.defined_names()
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
fn active_sheet(&self) -> &str {
&self.inner.active_sheet
}
fn sheet_names(&self, py: Python<'_>) -> Py<PyAny> {
let names = self.inner.sheet_names();
names.into_pyobject(py).unwrap().into_any().unbind()
}
fn get_sheet(&self, py: Python<'_>, name: &str) -> PyResult<Py<PyAny>> {
let dict = PyDict::new(py);
if let Some(sheet) = self.inner.get_sheet_cells(name) {
for ((row, col), content) in sheet {
if !matches!(content.value, Variant::Empty) {
let key = (*row, *col).into_pyobject(py)?.into_any().unbind();
dict.set_item(key, variant_to_py(py, &content.value))?;
}
}
}
Ok(dict.into_any().unbind())
}
fn save_workbook(&self, path: &str) -> PyResult<()> {
save_workbook_impl(&self.inner, path).map_err(PyErr::new::<pyo3::exceptions::PyIOError, _>)
}
fn cells_df(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let pd = py.import("pandas").map_err(|_| {
PyErr::new::<pyo3::exceptions::PyImportError, _>(
"pandas is required for cells_df(); install it with: pip install pandas",
)
})?;
let cells = self.inner.cells();
if cells.is_empty() {
return pd
.getattr("DataFrame")?
.call0()
.map(|df| df.into_any().unbind());
}
let max_row = cells.keys().map(|(r, _)| *r).max().unwrap_or(1);
let max_col = cells.keys().map(|(_, c)| *c).max().unwrap_or(1);
let none = py.None();
let rows_list = pyo3::types::PyList::empty(py);
for r in 1..=max_row {
let row_list = pyo3::types::PyList::empty(py);
for c in 1..=max_col {
match cells.get(&(r, c)) {
Some(cell) if !matches!(cell.value, Variant::Empty) => {
row_list.append(variant_to_py(py, &cell.value))?;
}
_ => row_list.append(&none)?,
}
}
rows_list.append(row_list)?;
}
let col_index: Vec<u32> = (1..=max_col).collect();
let row_index: Vec<u32> = (1..=max_row).collect();
let kwargs = PyDict::new(py);
kwargs.set_item("columns", col_index)?;
kwargs.set_item("index", row_index)?;
pd.getattr("DataFrame")?
.call((rows_list,), Some(&kwargs))
.map(|df| df.into_any().unbind())
}
#[pyo3(signature = (addr, sheet = None))]
fn get_range(&self, py: Python<'_>, addr: &str, sheet: Option<&str>) -> PyResult<Py<PyAny>> {
let (start, end) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let grid = self.inner.read_rect(&key, start.0, start.1, end.0, end.1);
grid_to_py(py, &grid)
}
#[pyo3(signature = (addr, values, sheet = None))]
fn set_range(
&mut self,
addr: &str,
values: &Bound<'_, PyAny>,
sheet: Option<&str>,
) -> PyResult<()> {
let (start, end) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let mut grid: Vec<Vec<Variant>> = Vec::new();
let mut row_lens: Vec<usize> = Vec::new();
for row_obj in values.try_iter()? {
let mut row: Vec<Variant> = Vec::new();
for cell_obj in row_obj?.try_iter()? {
row.push(py_to_variant(&cell_obj?)?);
}
row_lens.push(row.len());
grid.push(row);
}
let expected = (end.0 - start.0 + 1, end.1 - start.1 + 1);
check_grid_shape(expected, &row_lens)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.write_rect(&key, start, &grid);
Ok(())
}
#[pyo3(signature = (values, sheet = None))]
fn append_row(&mut self, values: &Bound<'_, PyAny>, sheet: Option<&str>) -> PyResult<u32> {
let mut row: Vec<Variant> = Vec::new();
for item in values.try_iter()? {
row.push(py_to_variant(&item?)?);
}
if row.is_empty() {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"append_row: values must not be empty",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let target_row = self.inner.next_append_row(&key);
self.inner.write_rect(&key, (target_row, 1), &[row]);
Ok(target_row)
}
#[pyo3(signature = (min_row = 1, max_row = None, min_col = 1, max_col = None, sheet = None))]
#[allow(clippy::too_many_arguments)]
fn iter_rows(
&self,
py: Python<'_>,
min_row: u32,
max_row: Option<u32>,
min_col: u32,
max_col: Option<u32>,
sheet: Option<&str>,
) -> PyResult<Py<PyAny>> {
if min_row == 0 || min_col == 0 || max_row == Some(0) || max_col == Some(0) {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"row/column numbers must be >= 1",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let grid = self
.inner
.iter_rows_values(&key, min_row, max_row, min_col, max_col);
grid_to_py(py, &grid)
}
#[pyo3(signature = (min_row = 1, max_row = None, min_col = 1, max_col = None, sheet = None))]
#[allow(clippy::too_many_arguments)]
fn iter_cols(
&self,
py: Python<'_>,
min_row: u32,
max_row: Option<u32>,
min_col: u32,
max_col: Option<u32>,
sheet: Option<&str>,
) -> PyResult<Py<PyAny>> {
if min_row == 0 || min_col == 0 || max_row == Some(0) || max_col == Some(0) {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"row/column numbers must be >= 1",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let grid = self
.inner
.iter_cols_values(&key, min_row, max_row, min_col, max_col);
grid_to_py(py, &grid)
}
#[pyo3(signature = (sheet = None))]
fn max_row(&self, sheet: Option<&str>) -> PyResult<Option<u32>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self.inner.sheet_used_range(&key).map(|(_, (r2, _))| r2))
}
#[pyo3(signature = (sheet = None))]
fn max_column(&self, sheet: Option<&str>) -> PyResult<Option<u32>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self.inner.sheet_used_range(&key).map(|(_, (_, c2))| c2))
}
#[pyo3(signature = (sheet = None))]
fn calculate_dimension(&self, sheet: Option<&str>) -> PyResult<Option<String>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self
.inner
.sheet_used_range(&key)
.map(|((r1, c1), (r2, c2))| {
format!(
"{}{}:{}{}",
xlsx_col_letters(c1),
r1,
xlsx_col_letters(c2),
r2
)
}))
}
#[pyo3(signature = (idx, amount = 1, sheet = None))]
fn insert_rows(&mut self, idx: u32, amount: u32, sheet: Option<&str>) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
if idx == 0 || amount == 0 || idx > MAX_ROW || amount > MAX_ROW {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"idx and amount must be between 1 and 1_048_576",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.insert_rows_on_sheet(&key, idx, amount);
Ok(())
}
#[pyo3(signature = (idx, amount = 1, sheet = None))]
fn delete_rows(&mut self, idx: u32, amount: u32, sheet: Option<&str>) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
if idx == 0 || amount == 0 || idx > MAX_ROW || amount > MAX_ROW {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"idx and amount must be between 1 and 1_048_576",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.delete_rows_on_sheet(&key, idx, amount);
Ok(())
}
#[pyo3(signature = (idx, amount = 1, sheet = None))]
fn insert_cols(&mut self, idx: u32, amount: u32, sheet: Option<&str>) -> PyResult<()> {
const MAX_COL: u32 = 16_384;
if idx == 0 || amount == 0 || idx > MAX_COL || amount > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"idx and amount must be between 1 and 16_384",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.insert_cols_on_sheet(&key, idx, amount);
Ok(())
}
#[pyo3(signature = (idx, amount = 1, sheet = None))]
fn delete_cols(&mut self, idx: u32, amount: u32, sheet: Option<&str>) -> PyResult<()> {
const MAX_COL: u32 = 16_384;
if idx == 0 || amount == 0 || idx > MAX_COL || amount > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"idx and amount must be between 1 and 16_384",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.delete_cols_on_sheet(&key, idx, amount);
Ok(())
}
#[pyo3(signature = (sheet = None))]
fn merged_cells(&self, sheet: Option<&str>) -> PyResult<Vec<String>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self
.inner
.merged_ranges
.get(&key)
.map(|ranges| ranges.iter().map(merge_rect_to_a1).collect())
.unwrap_or_default())
}
#[pyo3(signature = (sheet = None))]
fn tables(&self, py: Python<'_>, sheet: Option<&str>) -> PyResult<Py<PyAny>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let list = PyList::empty(py);
for t in self.inner.tables.get(&key).into_iter().flatten() {
let dict = PyDict::new(py);
dict.set_item("name", &t.name)?;
dict.set_item("display_name", &t.display_name)?;
dict.set_item("ref", merge_rect_to_a1(&t.ref_range))?;
dict.set_item("header_row_count", t.header_row_count)?;
dict.set_item("totals_row_count", t.totals_row_count)?;
dict.set_item("totals_row_shown", t.totals_row_shown)?;
dict.set_item("style_name", t.style_name.as_deref())?;
dict.set_item(
"auto_filter_ref",
t.auto_filter_ref.as_ref().map(merge_rect_to_a1),
)?;
let filter_cols = PyList::empty(py);
for c in &t.autofilter_columns {
filter_cols.append(filter_column_to_pydict(py, c)?)?;
}
dict.set_item("autofilter_columns", filter_cols)?;
let cols = PyList::empty(py);
for c in &t.columns {
let cd = PyDict::new(py);
cd.set_item("id", c.id.as_deref())?;
cd.set_item("name", &c.name)?;
cd.set_item("totals_row_function", c.totals_row_function.as_deref())?;
cd.set_item("totals_row_label", c.totals_row_label.as_deref())?;
cd.set_item(
"calculated_column_formula",
c.calculated_column_formula.as_deref(),
)?;
cols.append(cd)?;
}
dict.set_item("columns", cols)?;
list.append(dict)?;
}
Ok(list.into_any().unbind())
}
#[pyo3(signature = (addr, sheet = None))]
fn merge_cells(&mut self, addr: &str, sheet: Option<&str>) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
const MAX_COL: u32 = 16_384;
let ((r1, c1), (r2, c2)) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
if r2 > MAX_ROW || c2 > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"range exceeds sheet bounds (max row {MAX_ROW}, max col {MAX_COL}), got row {r2}, col {c2}"
)));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.merge_cells(&key, r1, c1, r2, c2)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (addr, sheet = None))]
fn unmerge_cells(&mut self, addr: &str, sheet: Option<&str>) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
const MAX_COL: u32 = 16_384;
let ((r1, c1), (r2, c2)) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
if r2 > MAX_ROW || c2 > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"range exceeds sheet bounds (max row {MAX_ROW}, max col {MAX_COL}), got row {r2}, col {c2}"
)));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.unmerge_cells(&key, r1, c1, r2, c2)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (addr, rows = 0, cols = 0, sheet = None))]
fn move_range(
&mut self,
addr: &str,
rows: i64,
cols: i64,
sheet: Option<&str>,
) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
const MAX_COL: u32 = 16_384;
let ((r1, c1), (r2, c2)) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
if r2 > MAX_ROW || c2 > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"range exceeds sheet bounds (max row {MAX_ROW}, max col {MAX_COL}), got row {r2}, col {c2}"
)));
}
let dest_r1 = r1 as i64 + rows;
let dest_c1 = c1 as i64 + cols;
let dest_r2 = r2 as i64 + rows;
let dest_c2 = c2 as i64 + cols;
if dest_r1 < 1 || dest_c1 < 1 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"move destination is out of bounds (row/col must stay >= 1)".to_string(),
));
}
if dest_r2 > MAX_ROW as i64 || dest_c2 > MAX_COL as i64 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"move destination exceeds sheet bounds (max row {MAX_ROW}, max col {MAX_COL})"
)));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.move_range_on_sheet(
&key,
formula::MoveRect { r1, c1, r2, c2 },
dest_r1 as u32,
dest_c1 as u32,
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (addr, format_code, sheet = None))]
fn set_number_format(
&mut self,
addr: &str,
format_code: &str,
sheet: Option<&str>,
) -> PyResult<()> {
let ((r1, c1), (r2, c2)) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_number_format_on_sheet(&key, r1, c1, r2, c2, format_code);
Ok(())
}
#[pyo3(signature = (addr, font=None, fill=None, border=None, alignment=None, protection=None, named_style=None, sheet=None))]
#[allow(clippy::too_many_arguments)]
fn set_style(
&mut self,
addr: &str,
font: Option<&Bound<'_, PyDict>>,
fill: Option<&Bound<'_, PyDict>>,
border: Option<&Bound<'_, PyDict>>,
alignment: Option<&Bound<'_, PyDict>>,
protection: Option<&Bound<'_, PyDict>>,
named_style: Option<&str>,
sheet: Option<&str>,
) -> PyResult<()> {
if font.is_none()
&& fill.is_none()
&& border.is_none()
&& alignment.is_none()
&& protection.is_none()
&& named_style.is_none()
{
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"set_style requires at least one of font/fill/border/alignment/protection/named_style",
));
}
let ((r1, c1), (r2, c2)) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let edit = StyleAttrEdit {
font: font.map(extract_font_edit).transpose()?,
fill: fill.map(extract_fill_edit).transpose()?,
border: border.map(extract_border_edit).transpose()?,
alignment: alignment.map(extract_alignment_edit).transpose()?,
protection: protection.map(extract_protection_edit).transpose()?,
named_style: named_style.map(str::to_string),
};
self.inner.set_style_on_sheet(&key, r1, c1, r2, c2, &edit);
Ok(())
}
#[pyo3(signature = (row, font=None, fill=None, border=None, alignment=None, protection=None, named_style=None, sheet=None))]
#[allow(clippy::too_many_arguments)]
fn set_row_style(
&mut self,
row: u32,
font: Option<&Bound<'_, PyDict>>,
fill: Option<&Bound<'_, PyDict>>,
border: Option<&Bound<'_, PyDict>>,
alignment: Option<&Bound<'_, PyDict>>,
protection: Option<&Bound<'_, PyDict>>,
named_style: Option<&str>,
sheet: Option<&str>,
) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
if font.is_none()
&& fill.is_none()
&& border.is_none()
&& alignment.is_none()
&& protection.is_none()
&& named_style.is_none()
{
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"set_row_style requires at least one of font/fill/border/alignment/protection/named_style",
));
}
if row == 0 || row > MAX_ROW {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"row must be between 1 and 1_048_576",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let edit = StyleAttrEdit {
font: font.map(extract_font_edit).transpose()?,
fill: fill.map(extract_fill_edit).transpose()?,
border: border.map(extract_border_edit).transpose()?,
alignment: alignment.map(extract_alignment_edit).transpose()?,
protection: protection.map(extract_protection_edit).transpose()?,
named_style: named_style.map(str::to_string),
};
self.inner.set_row_style_on_sheet(&key, row, &edit);
Ok(())
}
#[pyo3(signature = (col, font=None, fill=None, border=None, alignment=None, protection=None, named_style=None, sheet=None))]
#[allow(clippy::too_many_arguments)]
fn set_column_style(
&mut self,
col: u32,
font: Option<&Bound<'_, PyDict>>,
fill: Option<&Bound<'_, PyDict>>,
border: Option<&Bound<'_, PyDict>>,
alignment: Option<&Bound<'_, PyDict>>,
protection: Option<&Bound<'_, PyDict>>,
named_style: Option<&str>,
sheet: Option<&str>,
) -> PyResult<()> {
const MAX_COL: u32 = 16_384;
if font.is_none()
&& fill.is_none()
&& border.is_none()
&& alignment.is_none()
&& protection.is_none()
&& named_style.is_none()
{
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"set_column_style requires at least one of font/fill/border/alignment/protection/named_style",
));
}
if col == 0 || col > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"col must be between 1 and 16_384",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let edit = StyleAttrEdit {
font: font.map(extract_font_edit).transpose()?,
fill: fill.map(extract_fill_edit).transpose()?,
border: border.map(extract_border_edit).transpose()?,
alignment: alignment.map(extract_alignment_edit).transpose()?,
protection: protection.map(extract_protection_edit).transpose()?,
named_style: named_style.map(str::to_string),
};
self.inner.set_column_style_on_sheet(&key, col, &edit);
Ok(())
}
#[pyo3(signature = (source, dest, sheet = None))]
fn copy_style(&mut self, source: &str, dest: &str, sheet: Option<&str>) -> PyResult<()> {
let (src_start, src_end) =
validate_range_addr(source).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
if src_start != src_end {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"copy_style's source must be exactly one cell",
));
}
let ((r1, c1), (r2, c2)) =
validate_range_addr(dest).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.copy_style_on_sheet(&key, src_start, r1, c1, r2, c2);
Ok(())
}
#[pyo3(signature = (name, sheet = None, display_name = None, r#ref = None, style_name = None, totals_row_shown = None, add_columns = None, remove_columns = None))]
#[allow(clippy::too_many_arguments)]
fn edit_table(
&mut self,
name: &str,
sheet: Option<&str>,
display_name: Option<&str>,
r#ref: Option<&str>,
style_name: Option<&str>,
totals_row_shown: Option<bool>,
add_columns: Option<Vec<String>>,
remove_columns: Option<Vec<String>>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let ref_range = r#ref
.map(validate_range_addr)
.transpose()
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.edit_table_on_sheet(
&key,
name,
display_name,
ref_range,
style_name,
totals_row_shown,
&add_columns.unwrap_or_default(),
&remove_columns.unwrap_or_default(),
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (r#ref, sheet = None, name = None, display_name = None, style_name = None))]
fn create_table(
&mut self,
r#ref: &str,
sheet: Option<&str>,
name: Option<&str>,
display_name: Option<&str>,
style_name: Option<&str>,
) -> PyResult<()> {
let ref_range =
validate_range_addr(r#ref).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.create_table_on_sheet(&key, ref_range, name, display_name, style_name)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (
sqref, validation_type, operator = None, formula1 = None, formula2 = None,
allow_blank = true, prompt_title = None, prompt = None, error_style = None,
error_title = None, error = None, sheet = None,
))]
#[allow(clippy::too_many_arguments)]
fn add_data_validation(
&mut self,
sqref: &str,
validation_type: &str,
operator: Option<&str>,
formula1: Option<&str>,
formula2: Option<&str>,
allow_blank: bool,
prompt_title: Option<&str>,
prompt: Option<&str>,
error_style: Option<&str>,
error_title: Option<&str>,
error: Option<&str>,
sheet: Option<&str>,
) -> PyResult<usize> {
let areas =
parse_sqref_areas(sqref).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let spec = reader::DataValidationSpec {
validation_type: validation_type.to_string(),
operator: operator.map(str::to_string),
formula1: formula1.map(str::to_string),
formula2: formula2.map(str::to_string),
allow_blank,
show_input_message: prompt_title.is_some() || prompt.is_some(),
prompt_title: prompt_title.map(str::to_string),
prompt: prompt.map(str::to_string),
show_error_message: true,
error_style: error_style.map(str::to_string),
error_title: error_title.map(str::to_string),
error: error.map(str::to_string),
};
Ok(self.inner.add_data_validation_on_sheet(&key, areas, spec))
}
#[pyo3(signature = (index, sheet = None))]
fn remove_data_validation(&mut self, index: usize, sheet: Option<&str>) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.remove_data_validation_on_sheet(&key, index)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (sheet = None))]
fn data_validations(&self, py: Python<'_>, sheet: Option<&str>) -> PyResult<Py<PyAny>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let list = PyList::empty(py);
for r in self.inner.data_validations.get(&key).into_iter().flatten() {
let dict = PyDict::new(py);
dict.set_item("validation_type", &r.validation_type)?;
dict.set_item("operator", r.operator.as_deref())?;
dict.set_item("formula1", r.formula1.as_deref())?;
dict.set_item("formula2", r.formula2.as_deref())?;
dict.set_item("allow_blank", r.allow_blank)?;
dict.set_item("prompt_title", r.prompt_title.as_deref())?;
dict.set_item("prompt", r.prompt.as_deref())?;
dict.set_item("error_style", r.error_style.as_deref())?;
dict.set_item("error_title", r.error_title.as_deref())?;
dict.set_item("error", r.error.as_deref())?;
dict.set_item(
"sqref",
r.sqref
.iter()
.map(|rect| format_sqref(std::slice::from_ref(rect)))
.collect::<Vec<_>>(),
)?;
list.append(dict)?;
}
Ok(list.into_any().unbind())
}
#[pyo3(signature = (r#ref, sheet = None))]
fn add_autofilter(&mut self, r#ref: &str, sheet: Option<&str>) -> PyResult<()> {
let ref_range =
validate_range_addr(r#ref).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.add_autofilter_on_sheet(&key, ref_range);
Ok(())
}
#[pyo3(signature = (sheet = None))]
fn remove_autofilter(&mut self, sheet: Option<&str>) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.remove_autofilter_on_sheet(&key);
Ok(())
}
#[pyo3(signature = (col_offset, values, sheet = None))]
fn set_equality_filter(
&mut self,
col_offset: u32,
values: Vec<String>,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_filter_column_on_sheet(&key, col_offset, reader::FilterCriteria::Values(values))
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (
col_offset, operator, value, and_ = true, operator2 = None, value2 = None,
sheet = None,
))]
#[allow(clippy::too_many_arguments)]
fn set_custom_filter(
&mut self,
col_offset: u32,
operator: &str,
value: &str,
and_: bool,
operator2: Option<&str>,
value2: Option<&str>,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_filter_column_on_sheet(
&key,
col_offset,
reader::FilterCriteria::Custom {
op1: operator.to_string(),
val1: value.to_string(),
and: and_,
op2: operator2.map(str::to_string),
val2: value2.map(str::to_string),
},
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (col_offset, sheet = None))]
fn set_blank_filter(&mut self, col_offset: u32, sheet: Option<&str>) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_filter_column_on_sheet(&key, col_offset, reader::FilterCriteria::Blank)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (col_offset, val, top = true, percent = false, sheet = None))]
fn set_top10_filter(
&mut self,
col_offset: u32,
val: f64,
top: bool,
percent: bool,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_filter_column_on_sheet(
&key,
col_offset,
reader::FilterCriteria::Top10 { top, percent, val },
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (
col_offset, year = None, month = None, day = None, hour = None, minute = None,
second = None, grouping = "day", sheet = None,
))]
#[allow(clippy::too_many_arguments)]
fn set_date_group_filter(
&mut self,
col_offset: u32,
year: Option<i32>,
month: Option<u32>,
day: Option<u32>,
hour: Option<u32>,
minute: Option<u32>,
second: Option<u32>,
grouping: &str,
sheet: Option<&str>,
) -> PyResult<()> {
if year.is_none()
&& month.is_none()
&& day.is_none()
&& hour.is_none()
&& minute.is_none()
&& second.is_none()
{
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"set_date_group_filter requires at least one of year/month/day/hour/minute/second",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let item = reader::DateGroupItem {
year,
month,
day,
hour,
minute,
second,
date_time_grouping: grouping.to_string(),
};
self.inner
.set_filter_column_on_sheet(
&key,
col_offset,
reader::FilterCriteria::DateGroup(vec![item]),
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (col_offset, sheet = None))]
fn clear_filter_column(&mut self, col_offset: u32, sheet: Option<&str>) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.clear_filter_column_on_sheet(&key, col_offset)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (table_name, col_offset, values, sheet = None))]
fn set_table_equality_filter(
&mut self,
table_name: &str,
col_offset: u32,
values: Vec<String>,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_table_filter_column_on_sheet(
&key,
table_name,
col_offset,
reader::FilterCriteria::Values(values),
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (
table_name, col_offset, operator, value, and_ = true, operator2 = None,
value2 = None, sheet = None,
))]
#[allow(clippy::too_many_arguments)]
fn set_table_custom_filter(
&mut self,
table_name: &str,
col_offset: u32,
operator: &str,
value: &str,
and_: bool,
operator2: Option<&str>,
value2: Option<&str>,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_table_filter_column_on_sheet(
&key,
table_name,
col_offset,
reader::FilterCriteria::Custom {
op1: operator.to_string(),
val1: value.to_string(),
and: and_,
op2: operator2.map(str::to_string),
val2: value2.map(str::to_string),
},
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (table_name, col_offset, sheet = None))]
fn set_table_blank_filter(
&mut self,
table_name: &str,
col_offset: u32,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_table_filter_column_on_sheet(
&key,
table_name,
col_offset,
reader::FilterCriteria::Blank,
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (table_name, col_offset, val, top = true, percent = false, sheet = None))]
#[allow(clippy::too_many_arguments)]
fn set_table_top10_filter(
&mut self,
table_name: &str,
col_offset: u32,
val: f64,
top: bool,
percent: bool,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.set_table_filter_column_on_sheet(
&key,
table_name,
col_offset,
reader::FilterCriteria::Top10 { top, percent, val },
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (
table_name, col_offset, year = None, month = None, day = None, hour = None,
minute = None, second = None, grouping = "day", sheet = None,
))]
#[allow(clippy::too_many_arguments)]
fn set_table_date_group_filter(
&mut self,
table_name: &str,
col_offset: u32,
year: Option<i32>,
month: Option<u32>,
day: Option<u32>,
hour: Option<u32>,
minute: Option<u32>,
second: Option<u32>,
grouping: &str,
sheet: Option<&str>,
) -> PyResult<()> {
if year.is_none()
&& month.is_none()
&& day.is_none()
&& hour.is_none()
&& minute.is_none()
&& second.is_none()
{
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"set_table_date_group_filter requires at least one of year/month/day/hour/minute/second",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let item = reader::DateGroupItem {
year,
month,
day,
hour,
minute,
second,
date_time_grouping: grouping.to_string(),
};
self.inner
.set_table_filter_column_on_sheet(
&key,
table_name,
col_offset,
reader::FilterCriteria::DateGroup(vec![item]),
)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (table_name, col_offset, sheet = None))]
fn clear_table_filter_column(
&mut self,
table_name: &str,
col_offset: u32,
sheet: Option<&str>,
) -> PyResult<()> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.clear_table_filter_column_on_sheet(&key, table_name, col_offset)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)
}
#[pyo3(signature = (sheet = None))]
fn autofilter(&self, py: Python<'_>, sheet: Option<&str>) -> PyResult<Py<PyAny>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
let Some(af) = self.inner.autofilters.get(&key) else {
return Ok(py.None());
};
let dict = PyDict::new(py);
dict.set_item("ref", merge_rect_to_a1(&af.ref_range))?;
let cols = PyList::empty(py);
for c in &af.columns {
cols.append(filter_column_to_pydict(py, c)?)?;
}
dict.set_item("columns", cols)?;
Ok(dict.into_any().unbind())
}
#[pyo3(signature = (sheet = None))]
fn hidden_rows(&self, sheet: Option<&str>) -> PyResult<Vec<u32>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self.inner.hidden_rows_on_sheet(&key))
}
#[pyo3(signature = (sheet = None))]
fn hidden_columns(&self, sheet: Option<&str>) -> PyResult<Vec<u32>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self.inner.hidden_columns_on_sheet(&key))
}
#[pyo3(signature = (row, hidden = true, sheet = None))]
fn set_row_hidden(&mut self, row: u32, hidden: bool, sheet: Option<&str>) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
if row == 0 || row > MAX_ROW {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"row must be between 1 and 1_048_576",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.set_row_hidden_on_sheet(&key, row, hidden);
Ok(())
}
#[pyo3(signature = (col, hidden = true, sheet = None))]
fn set_column_hidden(&mut self, col: u32, hidden: bool, sheet: Option<&str>) -> PyResult<()> {
const MAX_COL: u32 = 16_384;
if col == 0 || col > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"col must be between 1 and 16_384",
));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner.set_column_hidden_on_sheet(&key, col, hidden);
Ok(())
}
#[pyo3(signature = (row, sheet = None))]
fn row_height(&self, row: u32, sheet: Option<&str>) -> PyResult<Option<f64>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self.inner.row_height_on_sheet(&key, row))
}
#[pyo3(signature = (col, sheet = None))]
fn column_width(&self, col: u32, sheet: Option<&str>) -> PyResult<Option<f64>> {
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
Ok(self.inner.column_width_on_sheet(&key, col))
}
#[pyo3(signature = (addr, key_col, descending = false, header = false, sheet = None))]
#[allow(clippy::too_many_arguments)]
fn sort_range(
&mut self,
addr: &str,
key_col: u32,
descending: bool,
header: bool,
sheet: Option<&str>,
) -> PyResult<()> {
const MAX_ROW: u32 = 1_048_576;
const MAX_COL: u32 = 16_384;
let ((r1, c1), (r2, c2)) =
validate_range_addr(addr).map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
if r2 > MAX_ROW || c2 > MAX_COL {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"range exceeds sheet bounds (max row {MAX_ROW}, max col {MAX_COL}), got row {r2}, col {c2}"
)));
}
if key_col < c1 || key_col > c2 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"key_col {key_col} is outside the range's column span {c1}..={c2}"
)));
}
let key = self
.inner
.resolve_sheet_key(sheet)
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
self.inner
.sort_range_on_sheet(&key, r1, c1, r2, c2, key_col, descending, header);
Ok(())
}
}
#[cfg(feature = "python")]
fn grid_to_py(py: Python<'_>, grid: &[Vec<Variant>]) -> PyResult<Py<PyAny>> {
let rows = pyo3::types::PyList::empty(py);
for row in grid {
let py_row = pyo3::types::PyList::empty(py);
for v in row {
py_row.append(variant_to_py(py, v))?;
}
rows.append(py_row)?;
}
Ok(rows.into_any().unbind())
}
#[cfg(feature = "python")]
#[pyfunction]
#[pyo3(signature = (vba_code, macro_name, on_msgbox = "skip"))]
fn run_macro(
py: Python<'_>,
vba_code: &str,
macro_name: &str,
on_msgbox: &str,
) -> PyResult<Py<PyAny>> {
let mut vm = PyVm::new(on_msgbox)?;
vm.run(vba_code, macro_name)?;
vm.cells(py)
}
#[cfg(feature = "python")]
#[pyfunction]
#[pyo3(signature = (path, sheet = None, on_msgbox = "skip"))]
fn load_workbook(path: &str, sheet: Option<&str>, on_msgbox: &str) -> PyResult<PyVm> {
let sheets =
reader::read_workbook(path).map_err(PyErr::new::<pyo3::exceptions::PyIOError, _>)?;
if sheets.is_empty() {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"Workbook has no sheets",
));
}
let mut vm = Vm::new();
vm.error_on_msgbox = on_msgbox == "error";
vm.populate_from_sheets(sheets);
vm.loaded_workbook_path = Some(path.to_string());
if let Some(s) = sheet {
vm.set_active_sheet(&s.to_lowercase())
.map_err(PyErr::new::<pyo3::exceptions::PyValueError, _>)?;
}
Ok(PyVm { inner: vm })
}
#[cfg(feature = "python")]
#[pyfunction]
fn hello() -> &'static str {
"Hello from elixcee (Rust)!"
}
pub fn save_workbook(vm: &Vm, path: &str) -> Result<(), String> {
save_workbook_impl(vm, path)
}
#[cfg(feature = "python")]
#[pyfunction]
#[pyo3(signature = (path, sheet = None, include_row_numbers = false, max_rows = None, max_row_bytes = None, max_columns = None))]
fn open_stream(
path: &str,
sheet: Option<&str>,
include_row_numbers: bool,
max_rows: Option<usize>,
max_row_bytes: Option<usize>,
max_columns: Option<usize>,
) -> PyResult<stream::PyStreamReader> {
stream::stream_reader_from_path(
path,
sheet,
include_row_numbers,
max_rows,
max_row_bytes,
max_columns,
)
}
#[cfg(feature = "python")]
#[pyfunction]
#[pyo3(signature = (path, max_pending_bytes = None, max_rows = None, max_columns = None))]
fn create_stream(
path: &str,
max_pending_bytes: Option<usize>,
max_rows: Option<usize>,
max_columns: Option<usize>,
) -> PyResult<stream::PyStreamWriter> {
stream::stream_writer_from_path(path, max_pending_bytes, max_rows, max_columns)
}
fn save_workbook_impl(vm: &Vm, path: &str) -> Result<(), String> {
if path.to_lowercase().ends_with(".ods") {
return save_ods_impl(vm, path);
}
save_xlsx_impl(vm, path)
}
struct WorksheetOutputPlan {
sheet_key: String,
display_name: String,
sheet_id: String,
workbook_rel_id: String,
output_part_name: String,
output_rels_name: String,
is_existing: bool,
}
fn parse_sheet_part_number(name: &str) -> Option<u32> {
name.strip_prefix("xl/worksheets/sheet")?
.strip_suffix(".xml")?
.parse()
.ok()
}
fn parse_table_part_number(name: &str) -> Option<u32> {
name.strip_prefix("xl/tables/table")?
.strip_suffix(".xml")?
.parse()
.ok()
}
fn part_rels_name(part_name: &str) -> String {
match part_name.rsplit_once('/') {
Some((dir, file)) => format!("{dir}/_rels/{file}.rels"),
None => format!("_rels/{part_name}.rels"),
}
}
fn plan_worksheet_output(
sheet_names: &[String],
origins: &std::collections::HashMap<String, WorksheetOrigin>,
reserved_part_numbers: &[u32],
) -> Vec<WorksheetOutputPlan> {
let mut reserved: Vec<u32> = reserved_part_numbers.to_vec();
if reserved.is_empty() {
reserved.extend(
origins
.values()
.filter_map(|o| o.original_part_name.as_deref())
.filter_map(parse_sheet_part_number),
);
}
let mut next_fresh_part_n = reserved.into_iter().max().unwrap_or(0);
let max_original_id: u32 = sheet_names
.iter()
.filter_map(|name| origins.get(name))
.filter_map(|o| o.original_sheet_id.as_deref())
.filter_map(|id| id.parse::<u32>().ok())
.max()
.unwrap_or(0);
let mut next_fresh_id = max_original_id;
sheet_names
.iter()
.enumerate()
.map(|(i, sheet_key)| {
let origin = origins.get(sheet_key);
let output_part_name = match origin.and_then(|o| o.original_part_name.clone()) {
Some(part) => part,
None => {
next_fresh_part_n += 1;
format!("xl/worksheets/sheet{next_fresh_part_n}.xml")
}
};
let sheet_id = match origin.and_then(|o| o.original_sheet_id.clone()) {
Some(id) => id,
None => {
next_fresh_id += 1;
next_fresh_id.to_string()
}
};
let display_name = origin
.and_then(|o| o.original_display_name.clone())
.unwrap_or_else(|| sheet_key.clone());
WorksheetOutputPlan {
sheet_key: sheet_key.clone(),
display_name,
sheet_id,
workbook_rel_id: format!("rId{}", i + 1),
output_rels_name: part_rels_name(&output_part_name),
is_existing: origin.and_then(|o| o.original_part_name.as_ref()).is_some(),
output_part_name,
}
})
.collect()
}
fn rels_target_dir(rels_name: &str) -> &str {
match rels_name.rfind("/_rels/") {
Some(idx) => &rels_name[..idx + 1],
None => "",
}
}
fn direct_rel_targets(
raw_entries: &std::collections::HashMap<String, Vec<u8>>,
rels_name: &str,
) -> std::collections::HashSet<String> {
let Some(text) = raw_entries
.get(rels_name)
.and_then(|b| String::from_utf8(b.clone()).ok())
else {
return Default::default();
};
let base = rels_target_dir(rels_name);
reader::workbook_rels_decls(&text)
.into_iter()
.map(|(_, target)| normalize_part_path(&format!("{base}{target}")))
.collect()
}
fn reachable_closure(
raw_entries: &std::collections::HashMap<String, Vec<u8>>,
roots: impl IntoIterator<Item = String>,
exclude: &std::collections::HashSet<String>,
) -> std::collections::HashSet<String> {
let mut seen: std::collections::HashSet<String> =
roots.into_iter().filter(|p| !exclude.contains(p)).collect();
let mut queue: Vec<String> = seen.iter().cloned().collect();
while let Some(part) = queue.pop() {
let rels_name = part_rels_name(&part);
if raw_entries.contains_key(&rels_name) {
seen.insert(rels_name.clone());
for target in direct_rel_targets(raw_entries, &rels_name) {
if exclude.contains(&target) || seen.contains(&target) {
continue;
}
seen.insert(target.clone());
queue.push(target);
}
}
}
seen
}
fn deleted_sheet_prunable_parts(
raw_entries: &std::collections::HashMap<String, Vec<u8>>,
worksheet_origins: &std::collections::HashMap<String, WorksheetOrigin>,
sheet_order: &[String],
) -> std::collections::HashSet<String> {
let deleted_parts: std::collections::HashSet<String> = worksheet_origins
.iter()
.filter(|(key, _)| !sheet_order.iter().any(|s| s == *key))
.filter_map(|(_, origin)| origin.original_part_name.clone())
.collect();
if deleted_parts.is_empty() {
return Default::default();
}
let surviving_parts: std::collections::HashSet<String> = worksheet_origins
.iter()
.filter(|(key, _)| sheet_order.iter().any(|s| s == *key))
.filter_map(|(_, origin)| origin.original_part_name.clone())
.collect();
let mut elsewhere_roots: Vec<String> = surviving_parts.into_iter().collect();
elsewhere_roots.extend(direct_rel_targets(
raw_entries,
"xl/_rels/workbook.xml.rels",
));
elsewhere_roots.extend(direct_rel_targets(raw_entries, "_rels/.rels"));
let reachable_elsewhere = reachable_closure(raw_entries, elsewhere_roots, &deleted_parts);
let reachable_from_deleted = reachable_closure(
raw_entries,
deleted_parts.iter().cloned(),
&Default::default(),
);
reachable_from_deleted
.difference(&reachable_elsewhere)
.filter(|p| raw_entries.contains_key(p.as_str()))
.cloned()
.collect()
}
fn is_writer_owned_part(name: &str) -> bool {
matches!(
name,
"[Content_Types].xml"
| "_rels/.rels"
| "xl/workbook.xml"
| "xl/_rels/workbook.xml.rels"
| "xl/sharedStrings.xml"
| "xl/styles.xml"
) || (name.starts_with("xl/worksheets/")
&& name.ends_with(".xml")
&& !name["xl/worksheets/".len()..].contains('/'))
}
fn carry_over_rels(
raw_entries: &std::collections::HashMap<String, Vec<u8>>,
rels_part: &str,
target_base: &str,
passthrough: &[(String, Vec<u8>)],
skip_types: &[&str],
) -> Vec<(String, String)> {
let Some(rels_xml) = raw_entries
.get(rels_part)
.and_then(|b| String::from_utf8(b.clone()).ok())
else {
return Vec::new();
};
reader::workbook_rels_decls(&rels_xml)
.into_iter()
.filter(|(ty, _)| !skip_types.contains(&ty.as_str()))
.filter(|(_, target)| {
let resolved = normalize_part_path(&format!("{}{}", target_base, target));
passthrough.iter().any(|(name, _)| *name == resolved)
})
.collect()
}
fn rewrite_defined_names_xml(
container_xml: &str,
renames: &std::collections::HashMap<String, String>,
) -> Option<String> {
let elements = reader::extract_defined_name_elements(container_xml);
if elements.is_empty() {
return None;
}
let mut rebuilt = String::new();
for (el, (text_start, text_end)) in &elements {
let raw_value = &el[*text_start..*text_end];
let unescaped = reader::xml_unescape(raw_value);
match formula::rewrite_defined_name_for_renames(&unescaped, renames) {
formula::DefinedNameRewrite::Unchanged => rebuilt.push_str(el),
formula::DefinedNameRewrite::Rewritten(new_value) => {
rebuilt.push_str(&el[..*text_start]);
rebuilt.push_str(&xml_escape(&new_value));
rebuilt.push_str(&el[*text_end..]);
}
formula::DefinedNameRewrite::Drop => {}
}
}
if rebuilt.is_empty() {
None
} else {
Some(format!("<definedNames>{}</definedNames>", rebuilt))
}
}
fn save_xlsx_impl(vm: &Vm, path: &str) -> Result<(), String> {
use std::collections::HashMap;
use std::io::{Cursor, Write};
use zip::CompressionMethod;
use zip::write::ZipWriter;
let sheet_names = vm.sheet_order.clone();
let mut str_index: HashMap<String, usize> = HashMap::new();
let mut shared_strings: Vec<String> = Vec::new();
for sheet_name in &sheet_names {
if let Some(cells) = vm.get_sheet_cells(sheet_name) {
let mut sorted: Vec<_> = cells.keys().collect();
sorted.sort();
for key in sorted {
let s = match &cells[key].value {
Variant::Str(s) => s.as_str().to_string(),
_ => continue,
};
if !str_index.contains_key(&s) {
str_index.insert(s.clone(), shared_strings.len());
shared_strings.push(s);
}
}
}
}
let passthrough_source = vm.loaded_workbook_path.as_deref().filter(|p| {
let l = p.to_lowercase();
l.ends_with(".xlsx") || l.ends_with(".xlsm")
});
let is_xlsm_output = path.to_lowercase().ends_with(".xlsm");
let mut passthrough: Vec<(String, Vec<u8>)> = Vec::new();
let mut has_vba = false;
let mut carried_overrides: Vec<(String, String)> = Vec::new();
let mut carried_rels: Vec<(String, String)> = Vec::new();
let mut carried_root_rels: Vec<(String, String)> = Vec::new();
let mut passthrough_styles: Option<Vec<u8>> = None;
let mut sheet_source_xml: HashMap<String, String> = HashMap::new();
let mut workbook_source_xml: Option<String> = None;
let mut reserved_sheet_part_numbers: Vec<u32> = Vec::new();
let mut reserved_table_part_numbers: Vec<u32> = Vec::new();
if let Some(source_path) = passthrough_source {
let raw_entries = reader::read_raw_zip_entries(source_path)?;
has_vba = is_xlsm_output && raw_entries.keys().any(|n| n.starts_with("xl/vbaProject"));
passthrough_styles = raw_entries.get("xl/styles.xml").cloned();
workbook_source_xml = raw_entries
.get("xl/workbook.xml")
.and_then(|bytes| String::from_utf8(bytes.clone()).ok());
reserved_sheet_part_numbers = raw_entries
.keys()
.filter_map(|name| parse_sheet_part_number(name))
.collect();
reserved_table_part_numbers = raw_entries
.keys()
.filter_map(|name| parse_table_part_number(name))
.collect();
for (sheet_key, origin) in &vm.worksheet_origins {
if let Some(part) = &origin.original_part_name
&& let Some(bytes) = raw_entries.get(part)
&& let Ok(text) = String::from_utf8(bytes.clone())
{
sheet_source_xml.insert(sheet_key.clone(), text);
}
}
let (defaults, overrides) = raw_entries
.get("[Content_Types].xml")
.and_then(|b| String::from_utf8(b.clone()).ok())
.map(|xml| reader::content_type_decls(&xml))
.unwrap_or_default();
let prunable_parts =
deleted_sheet_prunable_parts(&raw_entries, &vm.worksheet_origins, &sheet_names);
let table_edits: std::collections::HashMap<&str, &[reader::TableEditOp]> = vm
.tables
.values()
.flatten()
.filter(|t| !t.pending_edits.is_empty() && !t.source_part.is_empty())
.map(|t| (t.source_part.as_str(), t.pending_edits.as_slice()))
.collect();
for (name, bytes) in &raw_entries {
if is_writer_owned_part(name) {
continue;
}
if !is_xlsm_output && name.starts_with("xl/vbaProject") {
continue;
}
if prunable_parts.contains(name) {
continue;
}
let bytes = match table_edits.get(name.as_str()) {
Some(edits) => {
let xml = String::from_utf8_lossy(bytes);
reader::apply_table_edits(&xml, edits).into_bytes()
}
None => bytes.clone(),
};
passthrough.push((name.clone(), bytes));
let part_name = format!("/{}", name);
let resolved = overrides
.iter()
.find(|(p, _)| p == &part_name)
.map(|(_, ct)| ct.clone())
.or_else(|| {
let ext = name.rsplit('.').next().unwrap_or("");
if ext == "xml" || ext == "rels" {
None
} else {
defaults
.iter()
.find(|(e, _)| e == ext)
.map(|(_, ct)| ct.clone())
}
})
.or_else(|| {
if name.starts_with("xl/vbaProject") {
Some("application/vnd.ms-office.vbaProject".to_string())
} else {
None
}
});
if let Some(ct) = resolved {
carried_overrides.push((part_name, ct));
}
}
carried_rels.extend(carry_over_rels(
&raw_entries,
"xl/_rels/workbook.xml.rels",
"xl/",
&passthrough,
&[
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
"http://schemas.microsoft.com/office/2006/relationships/vbaProject",
],
));
carried_root_rels.extend(carry_over_rels(
&raw_entries,
"_rels/.rels",
"",
&passthrough,
&["http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"],
));
passthrough.sort_by(|a, b| a.0.cmp(&b.0));
carried_overrides.sort_by(|a, b| a.0.cmp(&b.0));
carried_rels.sort_by(|a, b| a.1.cmp(&b.1));
carried_root_rels.sort_by(|a, b| a.1.cmp(&b.1));
}
let worksheet_plans = plan_worksheet_output(
&sheet_names,
&vm.worksheet_origins,
&reserved_sheet_part_numbers,
);
let originally_survived_rels: std::collections::HashSet<String> =
passthrough.iter().map(|(name, _)| name.clone()).collect();
let mut next_fresh_table_n = reserved_table_part_numbers.into_iter().max().unwrap_or(0);
let mut new_table_parts_by_sheet: HashMap<String, Vec<String>> = HashMap::new();
for plan in &worksheet_plans {
let Some(tables) = vm.tables.get(&plan.sheet_key) else {
continue;
};
for table in tables.iter().filter(|t| t.source_part.is_empty()) {
next_fresh_table_n += 1;
let table_part_name = format!("xl/tables/table{next_fresh_table_n}.xml");
passthrough.push((
table_part_name.clone(),
reader::render_table_xml(table, next_fresh_table_n).into_bytes(),
));
carried_overrides.push((
format!("/{table_part_name}"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml".to_string(),
));
let target = format!("../tables/table{next_fresh_table_n}.xml");
let existing_rels_idx = passthrough
.iter()
.position(|(name, _)| name == &plan.output_rels_name);
let existing_rels_xml =
existing_rels_idx.map(|i| String::from_utf8_lossy(&passthrough[i].1).into_owned());
let next_rid = existing_rels_xml
.as_deref()
.map(reader::relationship_ids)
.unwrap_or_default()
.iter()
.filter_map(|id| id.strip_prefix("rId").and_then(|n| n.parse::<u32>().ok()))
.max()
.unwrap_or(0)
+ 1;
let new_rel = format!(
"<Relationship Id=\"rId{next_rid}\" \
Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/table\" \
Target=\"{target}\"/>"
);
let new_rels_bytes = match &existing_rels_xml {
Some(xml) => reader::insert_before_close(xml, &new_rel).into_bytes(),
None => format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<Relationships xmlns=\"http://schemas.openxmlformats.org/",
"package/2006/relationships\">{}</Relationships>\n",
),
new_rel
)
.into_bytes(),
};
match existing_rels_idx {
Some(i) => passthrough[i].1 = new_rels_bytes,
None => passthrough.push((plan.output_rels_name.clone(), new_rels_bytes)),
}
new_table_parts_by_sheet
.entry(plan.sheet_key.clone())
.or_default()
.push(format!(
"<tablePart xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/\
2006/relationships\" r:id=\"rId{next_rid}\"/>"
));
}
}
passthrough.sort_by(|a, b| a.0.cmp(&b.0));
carried_overrides.sort_by(|a, b| a.0.cmp(&b.0));
let cursor = Cursor::new(Vec::<u8>::new());
let mut zip = ZipWriter::new(cursor);
let deflated =
zip::write::SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("[Content_Types].xml", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(
build_xlsx_content_types(&worksheet_plans, is_xlsm_output, &carried_overrides).as_bytes(),
)
.map_err(|e| e.to_string())?;
zip.start_file("_rels/.rels", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(build_xlsx_root_rels(&carried_root_rels).as_bytes())
.map_err(|e| e.to_string())?;
let workbook_root_attrs = workbook_source_xml
.as_deref()
.and_then(|xml| reader::extract_root_attrs(xml, "workbook"))
.and_then(|attrs| reader::ensure_r_prefix_bound(&attrs));
let workbook_pr = workbook_source_xml
.as_deref()
.and_then(|xml| reader::extract_raw_element(xml, "workbookPr"));
let book_views = workbook_source_xml
.as_deref()
.and_then(|xml| reader::extract_raw_element(xml, "bookViews"));
let calc_pr = workbook_source_xml
.as_deref()
.and_then(|xml| reader::extract_raw_element(xml, "calcPr"));
let ext_lst = workbook_source_xml
.as_deref()
.and_then(|xml| reader::extract_raw_element(xml, "extLst"));
let no_sheet_was_deleted = vm
.worksheet_origins
.keys()
.all(|original_key| vm.sheet_order.contains(original_key));
let defined_names_raw = if no_sheet_was_deleted && !vm.defined_names_may_be_stale {
workbook_source_xml
.as_deref()
.and_then(|xml| reader::extract_raw_element(xml, "definedNames"))
} else {
None
};
let defined_names = if vm.sheet_renames_since_load.is_empty() {
defined_names_raw
} else {
defined_names_raw
.as_deref()
.and_then(|xml| rewrite_defined_names_xml(xml, &vm.sheet_renames_since_load))
};
let workbook_fragments = OpaqueWorkbookFragments {
root_attrs: workbook_root_attrs.as_deref(),
workbook_pr: workbook_pr.as_deref(),
book_views: book_views.as_deref(),
defined_names: defined_names.as_deref(),
calc_pr: calc_pr.as_deref(),
ext_lst: ext_lst.as_deref(),
};
zip.start_file("xl/workbook.xml", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(build_xlsx_workbook(&worksheet_plans, &workbook_fragments).as_bytes())
.map_err(|e| e.to_string())?;
zip.start_file("xl/_rels/workbook.xml.rels", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(build_xlsx_workbook_rels(&worksheet_plans, has_vba, &carried_rels).as_bytes())
.map_err(|e| e.to_string())?;
let styles_source = passthrough_styles
.as_deref()
.unwrap_or_else(|| XLSX_STYLES.as_bytes());
let mut new_styles_bytes: Option<Vec<u8>> = None;
let mut effective_style_indices: Option<StyleIndexMap> = None;
if let Some((new_xml, indices)) = resolve_pending_number_formats(vm, styles_source) {
new_styles_bytes = Some(new_xml.into_bytes());
effective_style_indices = Some(indices);
}
{
let chained_source: &[u8] = new_styles_bytes.as_deref().unwrap_or(styles_source);
let chained_indices: &StyleIndexMap = effective_style_indices
.as_ref()
.unwrap_or(&vm.cell_style_indices);
if let Some((new_xml, indices)) =
resolve_pending_style_attrs(vm, chained_source, chained_indices)?
{
new_styles_bytes = Some(new_xml.into_bytes());
effective_style_indices = Some(indices);
}
}
{
let chained_indices: &StyleIndexMap = effective_style_indices
.as_ref()
.unwrap_or(&vm.cell_style_indices);
if let Some(indices) = resolve_pending_style_copies(vm, chained_indices) {
effective_style_indices = Some(indices);
}
}
let mut effective_row_styles: Option<RowStyleIndexMap> = None;
let mut effective_column_styles: Option<ColumnStyleRangeMap> = None;
{
let chained_source: &[u8] = new_styles_bytes.as_deref().unwrap_or(styles_source);
if let Some((new_xml, rows, cols)) = resolve_pending_row_column_styles(
vm,
chained_source,
&vm.row_styles,
&vm.column_styles,
)? {
new_styles_bytes = Some(new_xml.into_bytes());
effective_row_styles = Some(rows);
effective_column_styles = Some(cols);
}
}
for plan in &worksheet_plans {
let sheet_name = &plan.sheet_key;
let source_xml = sheet_source_xml.get(&sheet_name.to_lowercase());
let style_override = effective_style_indices
.as_ref()
.and_then(|m| m.get(&sheet_name.to_lowercase()));
let row_style_override = effective_row_styles
.as_ref()
.and_then(|m| m.get(&sheet_name.to_lowercase()));
let column_style_override = effective_column_styles
.as_ref()
.and_then(|m| m.get(&sheet_name.to_lowercase()))
.map(|v| v.as_slice());
let root_attrs = source_xml
.and_then(|xml| reader::extract_root_attrs(xml, "worksheet"))
.and_then(|attrs| reader::ensure_r_prefix_bound(&attrs));
let sheet_pr = source_xml.and_then(|xml| reader::extract_raw_element(xml, "sheetPr"));
let sheet_views = source_xml.and_then(|xml| reader::extract_raw_element(xml, "sheetViews"));
let sheet_format_pr =
source_xml.and_then(|xml| reader::extract_raw_element(xml, "sheetFormatPr"));
let auto_filter = source_xml.and_then(|xml| reader::extract_raw_element(xml, "autoFilter"));
let phonetic_pr = source_xml.and_then(|xml| reader::extract_raw_element(xml, "phoneticPr"));
let conditional_formatting = source_xml
.map(|xml| reader::extract_all_raw_elements(xml, "conditionalFormatting"))
.unwrap_or_default();
let data_validations =
source_xml.and_then(|xml| reader::extract_raw_element(xml, "dataValidations"));
let page_margins =
source_xml.and_then(|xml| reader::extract_raw_element(xml, "pageMargins"));
let page_setup = source_xml
.and_then(|xml| reader::extract_raw_element(xml, "pageSetup"))
.filter(|el| !reader::root_tag_has_rid(el));
let rels_survived =
plan.is_existing && originally_survived_rels.contains(&plan.output_rels_name);
let hyperlinks = source_xml
.map(|xml| reader::extract_hyperlinks(xml, rels_survived))
.unwrap_or_default();
let (existing_table_parts, drawing, legacy_drawing) = if rels_survived {
(
source_xml.and_then(|xml| reader::extract_raw_element(xml, "tableParts")),
source_xml.and_then(|xml| reader::extract_raw_element(xml, "drawing")),
source_xml.and_then(|xml| reader::extract_raw_element(xml, "legacyDrawing")),
)
} else {
(None, None, None)
};
let new_table_parts = new_table_parts_by_sheet
.get(&plan.sheet_key)
.map(|v| v.as_slice())
.unwrap_or_default();
let merged_table_parts = if new_table_parts.is_empty() {
existing_table_parts.clone()
} else {
let mut children = existing_table_parts
.as_deref()
.map(|x| reader::extract_records(x, "tableParts", "tablePart"))
.unwrap_or_default();
children.extend(new_table_parts.iter().cloned());
Some(format!(
"<tableParts count=\"{}\">{}</tableParts>",
children.len(),
children.concat()
))
};
let table_parts = merged_table_parts;
let fragments = OpaqueWorksheetFragments {
root_attrs: root_attrs.as_deref(),
sheet_pr: sheet_pr.as_deref(),
sheet_views: sheet_views.as_deref(),
sheet_format_pr: sheet_format_pr.as_deref(),
auto_filter: auto_filter.as_deref(),
phonetic_pr: phonetic_pr.as_deref(),
conditional_formatting: &conditional_formatting,
data_validations: data_validations.as_deref(),
hyperlinks: &hyperlinks,
page_margins: page_margins.as_deref(),
page_setup: page_setup.as_deref(),
table_parts: table_parts.as_deref(),
drawing: drawing.as_deref(),
legacy_drawing: legacy_drawing.as_deref(),
};
zip.start_file(plan.output_part_name.as_str(), deflated)
.map_err(|e| e.to_string())?;
zip.write_all(
build_xlsx_sheet(
vm,
sheet_name,
&str_index,
&fragments,
style_override,
row_style_override,
column_style_override,
)
.as_bytes(),
)
.map_err(|e| e.to_string())?;
}
zip.start_file("xl/sharedStrings.xml", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(build_xlsx_shared_strings(&shared_strings).as_bytes())
.map_err(|e| e.to_string())?;
zip.start_file("xl/styles.xml", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(new_styles_bytes.as_deref().unwrap_or(styles_source))
.map_err(|e| e.to_string())?;
for (name, bytes) in &passthrough {
zip.start_file(name.as_str(), deflated)
.map_err(|e| e.to_string())?;
zip.write_all(bytes).map_err(|e| e.to_string())?;
}
let data = zip.finish().map_err(|e| e.to_string())?.into_inner();
std::fs::write(path, data).map_err(|e| e.to_string())?;
Ok(())
}
fn build_xlsx_root_rels(carried_root_rels: &[(String, String)]) -> String {
let mut out = String::from(concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
"<Relationship Id=\"rId1\" ",
"Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" ",
"Target=\"xl/workbook.xml\"/>\n",
));
for (i, (ty, target)) in carried_root_rels.iter().enumerate() {
out.push_str(&format!(
"<Relationship Id=\"rId{}\" Type=\"{}\" Target=\"{}\"/>\n",
i + 2,
xml_escape(ty),
xml_escape(target)
));
}
out.push_str("</Relationships>\n");
out
}
const XLSX_STYLES: &str = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n",
"<fonts><font/></fonts>\n",
"<fills><fill><patternFill/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>\n",
"<borders><border/></borders>\n",
"<cellStyleXfs><xf/></cellStyleXfs>\n",
"<cellXfs><xf/></cellXfs>\n",
"<cellStyles><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>\n",
"</styleSheet>\n",
);
fn resolve_pending_number_formats(
vm: &Vm,
starting_styles: &[u8],
) -> Option<(String, StyleIndexMap)> {
if vm.pending_number_formats.values().all(|m| m.is_empty()) {
return None;
}
let xml = String::from_utf8_lossy(starting_styles).into_owned();
let cell_xfs_container = reader::extract_raw_element(&xml, "cellXfs");
let mut xfs = reader::extract_cell_xfs(&xml);
if xfs.is_empty() {
xfs.push("<xf/>".to_string());
}
let mut custom_formats = reader::custom_number_formats(&xml);
let numfmts_container = reader::extract_raw_element(&xml, "numFmts");
let mut new_numfmt_entries = String::new();
let mut effective = vm.cell_style_indices.clone();
for (sheet_key, edits) in &vm.pending_number_formats {
if edits.is_empty() {
continue;
}
let sheet_indices = effective.entry(sheet_key.clone()).or_default();
for (&(row, col), format_code) in edits {
let num_fmt_id = match reader::resolve_number_format_id(format_code, &custom_formats) {
reader::ResolvedNumFmt::Existing(id) => id,
reader::ResolvedNumFmt::New(id) => {
custom_formats.insert(id, format_code.clone());
new_numfmt_entries.push_str(&format!(
"<numFmt numFmtId=\"{id}\" formatCode=\"{}\"/>",
xml_escape(format_code)
));
id
}
};
let current_index = sheet_indices.get(&(row, col)).copied().unwrap_or(0) as usize;
let current_xf = xfs
.get(current_index)
.cloned()
.unwrap_or_else(|| "<xf/>".to_string());
let candidate = reader::with_num_fmt_id(¤t_xf, num_fmt_id);
let new_index = match xfs.iter().position(|xf| xf == &candidate) {
Some(i) => i as u32,
None => {
xfs.push(candidate);
(xfs.len() - 1) as u32
}
};
sheet_indices.insert((row, col), new_index);
}
}
let new_cell_xfs = format!(
"<cellXfs count=\"{}\">{}</cellXfs>",
xfs.len(),
xfs.concat()
);
let mut new_xml = match &cell_xfs_container {
Some(container) => xml.replacen(container.as_str(), &new_cell_xfs, 1),
None => xml.replacen("</styleSheet>", &format!("{new_cell_xfs}</styleSheet>"), 1),
};
if !new_numfmt_entries.is_empty() {
let final_count = custom_formats.len();
new_xml = match &numfmts_container {
Some(container) => {
let inner_start = container.find('>').map(|i| i + 1).unwrap_or(0);
let inner_end = container.rfind("</numFmts>").unwrap_or(container.len());
let inner = &container[inner_start..inner_end];
let rebuilt = format!(
"<numFmts count=\"{final_count}\">{inner}{new_numfmt_entries}</numFmts>"
);
new_xml.replacen(container.as_str(), &rebuilt, 1)
}
None => {
let block =
format!("<numFmts count=\"{final_count}\">{new_numfmt_entries}</numFmts>");
new_xml.replacen("<fonts", &format!("{block}<fonts"), 1)
}
};
}
Some((new_xml, effective))
}
fn extract_style_tables(xml: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
let mut fonts = reader::extract_records(xml, "fonts", "font");
if fonts.is_empty() {
fonts.push("<font/>".to_string());
}
let mut fills = reader::extract_records(xml, "fills", "fill");
if fills.is_empty() {
fills.push("<fill><patternFill patternType=\"none\"/></fill>".to_string());
}
let mut borders = reader::extract_records(xml, "borders", "border");
if borders.is_empty() {
borders.push("<border><left/><right/><top/><bottom/><diagonal/></border>".to_string());
}
let mut xfs = reader::extract_cell_xfs(xml);
if xfs.is_empty() {
xfs.push("<xf/>".to_string());
}
(fonts, fills, borders, xfs)
}
fn reserialize_style_tables(
xml: String,
fonts: &[String],
fills: &[String],
borders: &[String],
xfs: &[String],
) -> String {
let mut new_xml = xml;
let new_fonts = format!(
"<fonts count=\"{}\">{}</fonts>",
fonts.len(),
fonts.concat()
);
new_xml = match reader::extract_raw_element(&new_xml, "fonts") {
Some(old) => new_xml.replacen(old.as_str(), &new_fonts, 1),
None => new_xml.replacen("<fills", &format!("{new_fonts}<fills"), 1),
};
let new_fills = format!(
"<fills count=\"{}\">{}</fills>",
fills.len(),
fills.concat()
);
new_xml = match reader::extract_raw_element(&new_xml, "fills") {
Some(old) => new_xml.replacen(old.as_str(), &new_fills, 1),
None => new_xml.replacen("<borders", &format!("{new_fills}<borders"), 1),
};
let new_borders = format!(
"<borders count=\"{}\">{}</borders>",
borders.len(),
borders.concat()
);
new_xml = match reader::extract_raw_element(&new_xml, "borders") {
Some(old) => new_xml.replacen(old.as_str(), &new_borders, 1),
None => new_xml.replacen("<cellStyleXfs", &format!("{new_borders}<cellStyleXfs"), 1),
};
let new_cell_xfs = format!(
"<cellXfs count=\"{}\">{}</cellXfs>",
xfs.len(),
xfs.concat()
);
new_xml = match reader::extract_raw_element(&new_xml, "cellXfs") {
Some(old) => new_xml.replacen(old.as_str(), &new_cell_xfs, 1),
None => new_xml.replacen("</styleSheet>", &format!("{new_cell_xfs}</styleSheet>"), 1),
};
new_xml
}
#[allow(clippy::too_many_arguments)]
fn apply_style_edit_to_xf(
current_xf_index: usize,
edit: &vm::StyleAttrEdit,
xfs: &mut Vec<String>,
fonts: &mut Vec<String>,
fills: &mut Vec<String>,
borders: &mut Vec<String>,
cell_style_xfs: &[String],
xml: &str,
) -> Result<u32, String> {
let mut current_xf = xfs
.get(current_xf_index)
.cloned()
.unwrap_or_else(|| "<xf/>".to_string());
if let Some(name) = &edit.named_style {
let xf_id = reader::named_style_xf_id(xml, name)
.ok_or_else(|| format!("named style '{name}' not found in this file's <cellStyles>"))?;
let style_xf = cell_style_xfs
.get(xf_id as usize)
.cloned()
.unwrap_or_else(|| "<xf/>".to_string());
current_xf = reader::with_attr(&style_xf, "xfId", &xf_id.to_string());
}
if let Some(font_edit) = &edit.font {
let font_id = reader::span_attr_u32(¤t_xf, "fontId") as usize;
let current_font = fonts
.get(font_id)
.cloned()
.unwrap_or_else(|| "<font/>".to_string());
let candidate = reader::with_font_edit(¤t_font, font_edit);
let new_font_id = match fonts.iter().position(|f| f == &candidate) {
Some(i) => i as u32,
None => {
fonts.push(candidate);
(fonts.len() - 1) as u32
}
};
current_xf = reader::with_attr(¤t_xf, "fontId", &new_font_id.to_string());
current_xf = reader::with_attr(¤t_xf, "applyFont", "1");
}
if let Some(fill_edit) = &edit.fill {
let candidate = reader::build_solid_fill(&fill_edit.color_argb);
let new_fill_id = match fills.iter().position(|f| f == &candidate) {
Some(i) => i as u32,
None => {
fills.push(candidate);
(fills.len() - 1) as u32
}
};
current_xf = reader::with_attr(¤t_xf, "fillId", &new_fill_id.to_string());
current_xf = reader::with_attr(¤t_xf, "applyFill", "1");
}
if let Some(border_edit) = &edit.border {
let border_id = reader::span_attr_u32(¤t_xf, "borderId") as usize;
let current_border = borders.get(border_id).cloned().unwrap_or_else(|| {
"<border><left/><right/><top/><bottom/><diagonal/></border>".to_string()
});
let candidate = reader::with_border_edit(¤t_border, border_edit);
let new_border_id = match borders.iter().position(|b| b == &candidate) {
Some(i) => i as u32,
None => {
borders.push(candidate);
(borders.len() - 1) as u32
}
};
current_xf = reader::with_attr(¤t_xf, "borderId", &new_border_id.to_string());
current_xf = reader::with_attr(¤t_xf, "applyBorder", "1");
}
if let Some(alignment_edit) = &edit.alignment {
let new_alignment = reader::merged_alignment_span(¤t_xf, alignment_edit);
current_xf = reader::with_ordered_child(
¤t_xf,
"alignment",
&reader::XF_CHILD_ORDER,
Some(&new_alignment),
);
current_xf = reader::with_attr(¤t_xf, "applyAlignment", "1");
}
if let Some(protection_edit) = &edit.protection {
let new_protection = reader::merged_protection_span(¤t_xf, protection_edit);
current_xf = reader::with_ordered_child(
¤t_xf,
"protection",
&reader::XF_CHILD_ORDER,
Some(&new_protection),
);
current_xf = reader::with_attr(¤t_xf, "applyProtection", "1");
}
Ok(match xfs.iter().position(|xf| xf == ¤t_xf) {
Some(i) => i as u32,
None => {
xfs.push(current_xf);
(xfs.len() - 1) as u32
}
})
}
fn resolve_pending_style_attrs(
vm: &Vm,
starting_styles: &[u8],
starting_indices: &StyleIndexMap,
) -> Result<Option<(String, StyleIndexMap)>, String> {
if vm.pending_style_attrs.values().all(|m| m.is_empty()) {
return Ok(None);
}
let xml = String::from_utf8_lossy(starting_styles).into_owned();
let (mut fonts, mut fills, mut borders, mut xfs) = extract_style_tables(&xml);
let cell_style_xfs = reader::extract_records(&xml, "cellStyleXfs", "xf");
let mut effective = starting_indices.clone();
for (sheet_key, edits) in &vm.pending_style_attrs {
if edits.is_empty() {
continue;
}
let sheet_indices = effective.entry(sheet_key.clone()).or_default();
for (&(row, col), edit) in edits {
let current_index = sheet_indices.get(&(row, col)).copied().unwrap_or(0) as usize;
let new_index = apply_style_edit_to_xf(
current_index,
edit,
&mut xfs,
&mut fonts,
&mut fills,
&mut borders,
&cell_style_xfs,
&xml,
)?;
sheet_indices.insert((row, col), new_index);
}
}
let new_xml = reserialize_style_tables(xml, &fonts, &fills, &borders, &xfs);
Ok(Some((new_xml, effective)))
}
type RowStyleIndexMap = std::collections::HashMap<String, std::collections::HashMap<u32, u32>>;
type ColumnStyleRangeMap = std::collections::HashMap<String, Vec<(u32, u32, u32)>>;
fn resolve_pending_row_column_styles(
vm: &Vm,
starting_styles: &[u8],
starting_row_styles: &RowStyleIndexMap,
starting_column_styles: &ColumnStyleRangeMap,
) -> Result<Option<(String, RowStyleIndexMap, ColumnStyleRangeMap)>, String> {
if vm.pending_row_styles.values().all(|m| m.is_empty())
&& vm.pending_column_styles.values().all(|m| m.is_empty())
{
return Ok(None);
}
let xml = String::from_utf8_lossy(starting_styles).into_owned();
let (mut fonts, mut fills, mut borders, mut xfs) = extract_style_tables(&xml);
let cell_style_xfs = reader::extract_records(&xml, "cellStyleXfs", "xf");
let mut effective_rows = starting_row_styles.clone();
let mut effective_cols = starting_column_styles.clone();
for (sheet_key, edits) in &vm.pending_row_styles {
if edits.is_empty() {
continue;
}
let sheet_rows = effective_rows.entry(sheet_key.clone()).or_default();
for (&row, edit) in edits {
let current_index = sheet_rows.get(&row).copied().unwrap_or(0) as usize;
let new_index = apply_style_edit_to_xf(
current_index,
edit,
&mut xfs,
&mut fonts,
&mut fills,
&mut borders,
&cell_style_xfs,
&xml,
)?;
sheet_rows.insert(row, new_index);
}
}
for (sheet_key, edits) in &vm.pending_column_styles {
if edits.is_empty() {
continue;
}
let sheet_cols = effective_cols.entry(sheet_key.clone()).or_default();
for (&col, edit) in edits {
let current_index = sheet_cols
.iter()
.find(|&&(min, max, _)| min <= col && col <= max)
.map(|&(_, _, s)| s)
.unwrap_or(0) as usize;
let new_index = apply_style_edit_to_xf(
current_index,
edit,
&mut xfs,
&mut fonts,
&mut fills,
&mut borders,
&cell_style_xfs,
&xml,
)?;
let mut remaining: Vec<(u32, u32, u32)> = Vec::with_capacity(sheet_cols.len() + 1);
for &(min, max, style) in sheet_cols.iter() {
if col < min || col > max {
remaining.push((min, max, style));
continue;
}
if min < col {
remaining.push((min, col - 1, style));
}
if col < max {
remaining.push((col + 1, max, style));
}
}
remaining.push((col, col, new_index));
*sheet_cols = remaining;
}
}
let new_xml = reserialize_style_tables(xml, &fonts, &fills, &borders, &xfs);
Ok(Some((new_xml, effective_rows, effective_cols)))
}
fn resolve_pending_style_copies(
vm: &Vm,
starting_indices: &StyleIndexMap,
) -> Option<StyleIndexMap> {
if vm.pending_style_copies.values().all(|m| m.is_empty()) {
return None;
}
let mut effective = starting_indices.clone();
for (sheet_key, copies) in &vm.pending_style_copies {
if copies.is_empty() {
continue;
}
let source_snapshot = effective.get(sheet_key).cloned().unwrap_or_default();
let sheet_indices = effective.entry(sheet_key.clone()).or_default();
for (&dest, &src) in copies {
let src_index = source_snapshot.get(&src).copied().unwrap_or(0);
sheet_indices.insert(dest, src_index);
}
}
Some(effective)
}
fn build_xlsx_content_types(
worksheet_plans: &[WorksheetOutputPlan],
is_xlsm_output: bool,
carried_overrides: &[(String, String)],
) -> String {
let mut out = String::from(concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n",
"<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n",
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>\n",
));
let workbook_ct = if is_xlsm_output {
"application/vnd.ms-excel.sheet.macroEnabled.main+xml"
} else {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
};
out.push_str(&format!(
"<Override PartName=\"/xl/workbook.xml\" ContentType=\"{}\"/>\n",
workbook_ct
));
for plan in worksheet_plans {
out.push_str(&format!(
"<Override PartName=\"/{}\" \
ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>\n",
plan.output_part_name
));
}
out.push_str(concat!(
"<Override PartName=\"/xl/sharedStrings.xml\" ",
"ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml\"/>\n",
"<Override PartName=\"/xl/styles.xml\" ",
"ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n",
));
for (part_name, ct) in carried_overrides {
out.push_str(&format!(
"<Override PartName=\"{}\" ContentType=\"{}\"/>\n",
xml_escape(part_name),
xml_escape(ct)
));
}
out.push_str("</Types>\n");
out
}
#[derive(Default)]
struct OpaqueWorkbookFragments<'a> {
root_attrs: Option<&'a str>,
workbook_pr: Option<&'a str>,
book_views: Option<&'a str>,
defined_names: Option<&'a str>,
calc_pr: Option<&'a str>,
ext_lst: Option<&'a str>,
}
fn build_xlsx_workbook(
worksheet_plans: &[WorksheetOutputPlan],
fragments: &OpaqueWorkbookFragments,
) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
match fragments.root_attrs {
Some(attrs) => {
out.push_str("<workbook ");
out.push_str(attrs);
out.push_str(">\n");
}
None => out.push_str(concat!(
"<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ",
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
)),
}
for fragment in [fragments.workbook_pr, fragments.book_views]
.into_iter()
.flatten()
{
out.push_str(fragment);
out.push('\n');
}
out.push_str("<sheets>\n");
for plan in worksheet_plans {
out.push_str(&format!(
"<sheet name=\"{}\" sheetId=\"{}\" r:id=\"{}\"/>\n",
xml_escape(&plan.display_name),
plan.sheet_id,
plan.workbook_rel_id
));
}
out.push_str("</sheets>\n");
for fragment in [
fragments.defined_names,
fragments.calc_pr,
fragments.ext_lst,
]
.into_iter()
.flatten()
{
out.push_str(fragment);
out.push('\n');
}
out.push_str("</workbook>\n");
out
}
fn normalize_part_path(joined: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
for seg in joined.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
_ => parts.push(seg),
}
}
parts.join("/")
}
fn build_xlsx_workbook_rels(
worksheet_plans: &[WorksheetOutputPlan],
has_vba: bool,
carried_rels: &[(String, String)],
) -> String {
let mut out = String::from(concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n",
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
));
for plan in worksheet_plans {
let target = plan
.output_part_name
.strip_prefix("xl/")
.unwrap_or(&plan.output_part_name);
out.push_str(&format!(
"<Relationship Id=\"{}\" \
Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
Target=\"{}\"/>\n",
plan.workbook_rel_id, target
));
}
let ss_id = worksheet_plans.len() + 1;
let styles_id = worksheet_plans.len() + 2;
out.push_str(&format!(
"<Relationship Id=\"rId{}\" \
Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings\" \
Target=\"sharedStrings.xml\"/>\n",
ss_id
));
out.push_str(&format!(
"<Relationship Id=\"rId{}\" \
Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
Target=\"styles.xml\"/>\n",
styles_id
));
let mut next_id = worksheet_plans.len() + 3;
if has_vba {
out.push_str(&format!(
"<Relationship Id=\"rId{}\" \
Type=\"http://schemas.microsoft.com/office/2006/relationships/vbaProject\" \
Target=\"vbaProject.bin\"/>\n",
next_id
));
next_id += 1;
}
for (ty, target) in carried_rels {
out.push_str(&format!(
"<Relationship Id=\"rId{}\" Type=\"{}\" Target=\"{}\"/>\n",
next_id,
xml_escape(ty),
xml_escape(target)
));
next_id += 1;
}
out.push_str("</Relationships>\n");
out
}
#[derive(Default)]
struct OpaqueWorksheetFragments<'a> {
root_attrs: Option<&'a str>,
sheet_pr: Option<&'a str>,
sheet_views: Option<&'a str>,
sheet_format_pr: Option<&'a str>,
auto_filter: Option<&'a str>,
phonetic_pr: Option<&'a str>,
conditional_formatting: &'a [String],
data_validations: Option<&'a str>,
hyperlinks: &'a [String],
page_margins: Option<&'a str>,
page_setup: Option<&'a str>,
table_parts: Option<&'a str>,
drawing: Option<&'a str>,
legacy_drawing: Option<&'a str>,
}
fn build_xlsx_sheet(
vm: &Vm,
sheet_name: &str,
str_index: &std::collections::HashMap<String, usize>,
fragments: &OpaqueWorksheetFragments,
style_override: Option<&std::collections::HashMap<(u32, u32), u32>>,
row_style_override: Option<&std::collections::HashMap<u32, u32>>,
column_style_override: Option<&[(u32, u32, u32)]>,
) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
match fragments.root_attrs {
Some(attrs) => {
out.push_str("<worksheet ");
out.push_str(attrs);
out.push_str(">\n");
}
None => out.push_str(
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n",
),
}
for fragment in [
fragments.sheet_pr,
fragments.sheet_views,
fragments.sheet_format_pr,
]
.into_iter()
.flatten()
{
out.push_str(fragment);
out.push('\n');
}
let sheet_key = sheet_name.to_lowercase();
let style_indices = style_override.or_else(|| vm.cell_style_indices.get(&sheet_key));
let visibility = vm.sheet_visibility.get(&sheet_key);
let hidden_columns = visibility
.map(|v| v.hidden_columns.as_slice())
.unwrap_or(&[]);
let hidden_rows = visibility.map(|v| v.hidden_rows.as_slice()).unwrap_or(&[]);
let row_heights = vm.row_heights.get(&sheet_key);
let column_widths = vm.column_widths.get(&sheet_key);
let row_styles = row_style_override.or_else(|| vm.row_styles.get(&sheet_key));
let column_styles =
column_style_override.or_else(|| vm.column_styles.get(&sheet_key).map(|v| v.as_slice()));
let mut col_attrs: ColAttrsMap = std::collections::BTreeMap::new();
for iv in hidden_columns {
col_attrs.entry((iv.start, iv.end)).or_default().0 = true;
}
if let Some(widths) = column_widths {
for &(min, max, width) in widths {
col_attrs.entry((min, max)).or_default().1 = Some(width);
}
}
if let Some(styles) = column_styles {
for &(min, max, style) in styles {
col_attrs.entry((min, max)).or_default().2 = Some(style);
}
}
if !col_attrs.is_empty() {
out.push_str("<cols>\n");
for ((min, max), (hidden, width, style)) in col_attrs {
let width_attr = width
.map(|w| format!(" customWidth=\"1\" width=\"{w}\""))
.unwrap_or_default();
let style_attr = style.map(|s| format!(" style=\"{s}\"")).unwrap_or_default();
let hidden_attr = if hidden { " hidden=\"1\"" } else { "" };
out.push_str(&format!(
"<col min=\"{min}\" max=\"{max}\"{width_attr}{style_attr}{hidden_attr}/>\n"
));
}
out.push_str("</cols>\n");
}
out.push_str("<sheetData>\n");
if let Some(cells) = vm.get_sheet_cells(sheet_name) {
let mut by_row: std::collections::BTreeMap<u32, Vec<(u32, Option<&vm::CellContent>)>> =
std::collections::BTreeMap::new();
for (&(r, c), v) in cells.iter() {
if r > 0 && c > 0 {
by_row.entry(r).or_default().push((c, Some(v)));
}
}
if let Some(styles) = style_indices {
for &(r, c) in styles.keys() {
if r > 0 && c > 0 && !cells.contains_key(&(r, c)) {
by_row.entry(r).or_default().push((c, None));
}
}
}
for iv in hidden_rows {
for r in iv.start..=iv.end {
by_row.entry(r).or_default();
}
}
if let Some(heights) = row_heights {
for &r in heights.keys() {
by_row.entry(r).or_default();
}
}
if let Some(styles) = row_styles {
for &r in styles.keys() {
by_row.entry(r).or_default();
}
}
for (row, mut row_cells) in by_row {
row_cells.sort_by_key(|&(c, _)| c);
let row_hidden = hidden_rows
.iter()
.any(|iv| iv.start <= row && row <= iv.end);
let hidden_attr = if row_hidden { " hidden=\"1\"" } else { "" };
let height_attr = row_heights
.and_then(|m| m.get(&row))
.map(|ht| format!(" customHeight=\"1\" ht=\"{ht}\""))
.unwrap_or_default();
let style_attr = row_styles
.and_then(|m| m.get(&row))
.map(|s| format!(" s=\"{s}\" customFormat=\"1\""))
.unwrap_or_default();
out.push_str(&format!(
"<row r=\"{row}\"{height_attr}{style_attr}{hidden_attr}>\n"
));
for (c, content) in row_cells {
let cell_ref = format!("{}{}", xlsx_col_letters(c), row);
let style_idx = style_indices.and_then(|m| m.get(&(row, c)).copied());
match content {
Some(content) => {
if let Some(xml) = xlsx_cell_xml(
&cell_ref,
&content.value,
str_index,
style_idx,
content.formula.as_deref(),
) {
out.push_str(&xml);
out.push('\n');
}
}
None => {
if let Some(idx) = style_idx {
out.push_str(&format!("<c r=\"{cell_ref}\" s=\"{idx}\"/>\n"));
}
}
}
}
out.push_str("</row>\n");
}
}
out.push_str("</sheetData>\n");
let af_output = if vm.autofilters_touched.contains(&sheet_key) {
resolve_autofilter_for_sheet(vm, &sheet_key, fragments.auto_filter)
} else {
fragments.auto_filter.map(str::to_string)
};
if let Some(af) = af_output {
out.push_str(&af);
out.push('\n');
}
if let Some(merges) = vm.merged_ranges.get(&sheet_key)
&& !merges.is_empty()
{
out.push_str(&format!("<mergeCells count=\"{}\">\n", merges.len()));
for rect in merges {
out.push_str(&format!(
"<mergeCell ref=\"{}\"/>\n",
merge_rect_to_a1(rect)
));
}
out.push_str("</mergeCells>\n");
}
if let Some(pp) = fragments.phonetic_pr {
out.push_str(pp);
out.push('\n');
}
for cf in fragments.conditional_formatting {
out.push_str(cf);
out.push('\n');
}
let dv_output = if vm.data_validations_touched.contains(&sheet_key) {
resolve_data_validations_for_sheet(vm, &sheet_key, fragments.data_validations)
} else {
fragments.data_validations.map(str::to_string)
};
if let Some(dv) = dv_output {
out.push_str(&dv);
out.push('\n');
}
if !fragments.hyperlinks.is_empty() {
out.push_str("<hyperlinks>\n");
for hyperlink in fragments.hyperlinks {
out.push_str(hyperlink);
out.push('\n');
}
out.push_str("</hyperlinks>\n");
}
if let Some(pm) = fragments.page_margins {
out.push_str(pm);
out.push('\n');
}
if let Some(ps) = fragments.page_setup {
out.push_str(ps);
out.push('\n');
}
if let Some(d) = fragments.drawing {
out.push_str(d);
out.push('\n');
}
if let Some(ld) = fragments.legacy_drawing {
out.push_str(ld);
out.push('\n');
}
if let Some(tp) = fragments.table_parts {
out.push_str(tp);
out.push('\n');
}
out.push_str("</worksheet>\n");
out
}
fn xlsx_cell_xml(
cell_ref: &str,
v: &Variant,
str_index: &std::collections::HashMap<String, usize>,
style_idx: Option<u32>,
formula: Option<&str>,
) -> Option<String> {
let s_attr = style_idx
.map(|idx| format!(" s=\"{}\"", idx))
.unwrap_or_default();
let f_tag = formula
.map(|f| format!("<f>{}</f>", xml_escape(f.trim().trim_start_matches('='))))
.unwrap_or_default();
match v {
Variant::Integer(n) => Some(format!(
"<c r=\"{}\"{}>{}<v>{}</v></c>",
cell_ref, s_attr, f_tag, n
)),
Variant::Float(f) => Some(format!(
"<c r=\"{}\"{}>{}<v>{}</v></c>",
cell_ref, s_attr, f_tag, f
)),
Variant::Date(s) => Some(format!(
"<c r=\"{}\"{}>{}<v>{}</v></c>",
cell_ref, s_attr, f_tag, s
)),
Variant::Str(s) => {
let idx = str_index[s.as_str()];
Some(format!(
"<c r=\"{}\"{} t=\"s\">{}<v>{}</v></c>",
cell_ref, s_attr, f_tag, idx
))
}
Variant::Error(e) => Some(format!(
"<c r=\"{}\"{} t=\"e\">{}<v>{}</v></c>",
cell_ref,
s_attr,
f_tag,
e.as_str()
)),
Variant::Boolean(b) => Some(format!(
"<c r=\"{}\"{} t=\"b\">{}<v>{}</v></c>",
cell_ref,
s_attr,
f_tag,
if *b { 1 } else { 0 }
)),
Variant::Empty => formula.map(|_| format!("<c r=\"{}\"{}>{}</c>", cell_ref, s_attr, f_tag)),
Variant::Null | Variant::Array(_) | Variant::VbaArray(_) | Variant::Record(_) => None,
}
}
fn build_xlsx_shared_strings(strings: &[String]) -> String {
let count = strings.len();
let mut out = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<sst xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
count=\"{count}\" uniqueCount=\"{count}\">\n"
);
for s in strings {
if s.trim() != s.as_str() {
out.push_str(&format!(
"<si><t xml:space=\"preserve\">{}</t></si>\n",
xml_escape(s)
));
} else {
out.push_str(&format!("<si><t>{}</t></si>\n", xml_escape(s)));
}
}
out.push_str("</sst>\n");
out
}
#[cfg(test)]
mod shared_strings_tests {
use super::build_xlsx_shared_strings;
#[test]
fn marks_leading_or_trailing_whitespace_as_xml_space_preserve() {
let xml = build_xlsx_shared_strings(&[
"plain".to_string(),
" leading and trailing ".to_string(),
"trailing ".to_string(),
]);
assert!(xml.contains("<si><t>plain</t></si>"));
assert!(xml.contains("<si><t xml:space=\"preserve\"> leading and trailing </t></si>"));
assert!(xml.contains("<si><t xml:space=\"preserve\">trailing </t></si>"));
}
}
fn xlsx_col_letters(mut col: u32) -> String {
let mut bytes = Vec::new();
while col > 0 {
col -= 1;
bytes.push(b'A' + (col % 26) as u8);
col /= 26;
}
bytes.reverse();
String::from_utf8(bytes).unwrap()
}
fn merge_rect_to_a1(rect: &((u32, u32), (u32, u32))) -> String {
let ((r1, c1), (r2, c2)) = *rect;
format!(
"{}{}:{}{}",
xlsx_col_letters(c1),
r1,
xlsx_col_letters(c2),
r2
)
}
pub(crate) fn format_sqref(ranges: &[RangeBounds]) -> String {
ranges
.iter()
.map(|&((r1, c1), (r2, c2))| {
if (r1, c1) == (r2, c2) {
format!("{}{}", xlsx_col_letters(c1), r1)
} else {
merge_rect_to_a1(&((r1, c1), (r2, c2)))
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn build_data_validation_span(
spec: &reader::DataValidationSpec,
sqref: &[RangeBounds],
) -> String {
let mut attrs = format!(" type=\"{}\"", xml_escape(&spec.validation_type));
if let Some(op) = &spec.operator {
attrs.push_str(&format!(" operator=\"{}\"", xml_escape(op)));
}
if spec.allow_blank {
attrs.push_str(" allowBlank=\"1\"");
}
if spec.show_input_message {
attrs.push_str(" showInputMessage=\"1\"");
}
if spec.show_error_message {
attrs.push_str(" showErrorMessage=\"1\"");
}
if let Some(es) = &spec.error_style {
attrs.push_str(&format!(" errorStyle=\"{}\"", xml_escape(es)));
}
if let Some(t) = &spec.error_title {
attrs.push_str(&format!(" errorTitle=\"{}\"", xml_escape(t)));
}
if let Some(e) = &spec.error {
attrs.push_str(&format!(" error=\"{}\"", xml_escape(e)));
}
if let Some(t) = &spec.prompt_title {
attrs.push_str(&format!(" promptTitle=\"{}\"", xml_escape(t)));
}
if let Some(p) = &spec.prompt {
attrs.push_str(&format!(" prompt=\"{}\"", xml_escape(p)));
}
attrs.push_str(&format!(" sqref=\"{}\"", xml_escape(&format_sqref(sqref))));
let mut body = String::new();
if let Some(f1) = &spec.formula1 {
body.push_str(&format!("<formula1>{}</formula1>", xml_escape(f1)));
}
if let Some(f2) = &spec.formula2 {
body.push_str(&format!("<formula2>{}</formula2>", xml_escape(f2)));
}
if body.is_empty() {
format!("<dataValidation{attrs}/>")
} else {
format!("<dataValidation{attrs}>{body}</dataValidation>")
}
}
fn rebuild_counted_container(
original: Option<&str>,
container_tag: &str,
new_count: usize,
body: &str,
) -> String {
let attrs_only = original
.and_then(|span| {
let open_end = span.find('>')?;
span[..open_end].strip_prefix(&format!("<{container_tag}"))
})
.map(|raw| raw.trim_end().strip_suffix('/').unwrap_or(raw).to_string())
.unwrap_or_default();
let synthetic = format!("<{container_tag}{attrs_only}/>");
let with_count = reader::with_attr(&synthetic, "count", &new_count.to_string());
let open = with_count
.trim_end()
.strip_suffix("/>")
.unwrap_or(&with_count);
format!("{open}>{body}</{container_tag}>")
}
fn resolve_data_validations_for_sheet(
vm: &Vm,
sheet_key: &str,
original: Option<&str>,
) -> Option<String> {
let rules = vm.data_validations.get(sheet_key)?;
if rules.is_empty() {
return None;
}
let body: String = rules
.iter()
.map(|r| {
if r.dirty {
reader::with_attr(&r.raw_span, "sqref", &format_sqref(&r.sqref))
} else {
r.raw_span.clone()
}
})
.collect();
Some(rebuild_counted_container(
original,
"dataValidations",
rules.len(),
&body,
))
}
#[cfg(feature = "python")]
fn filter_column_to_pydict<'py>(
py: Python<'py>,
c: &reader::FilterColumn,
) -> PyResult<Bound<'py, PyDict>> {
let cd = PyDict::new(py);
cd.set_item("col_offset", c.col_offset)?;
match &c.criteria {
reader::FilterCriteria::Values(v) => {
cd.set_item("type", "values")?;
cd.set_item("values", v.clone())?;
}
reader::FilterCriteria::Custom {
op1,
val1,
and,
op2,
val2,
} => {
cd.set_item("type", "custom")?;
cd.set_item("operator", op1)?;
cd.set_item("value", val1)?;
cd.set_item("and_", *and)?;
cd.set_item("operator2", op2.as_deref())?;
cd.set_item("value2", val2.as_deref())?;
}
reader::FilterCriteria::Blank => {
cd.set_item("type", "blank")?;
}
reader::FilterCriteria::Top10 { top, percent, val } => {
cd.set_item("type", "top10")?;
cd.set_item("top", *top)?;
cd.set_item("percent", *percent)?;
cd.set_item("val", *val)?;
}
reader::FilterCriteria::DateGroup(items) => {
cd.set_item("type", "date_group")?;
let groups = PyList::empty(py);
for it in items {
let gd = PyDict::new(py);
gd.set_item("year", it.year)?;
gd.set_item("month", it.month)?;
gd.set_item("day", it.day)?;
gd.set_item("hour", it.hour)?;
gd.set_item("minute", it.minute)?;
gd.set_item("second", it.second)?;
gd.set_item("grouping", &it.date_time_grouping)?;
groups.append(gd)?;
}
cd.set_item("groups", groups)?;
}
}
Ok(cd)
}
fn build_filter_column_xml(fc: &reader::FilterColumn) -> String {
let mut attrs = format!(" colId=\"{}\"", fc.col_offset);
if fc.hidden_button {
attrs.push_str(" hiddenButton=\"1\"");
}
if !fc.show_button {
attrs.push_str(" showButton=\"0\"");
}
let body = match &fc.criteria {
reader::FilterCriteria::Values(vals) => {
let inner: String = vals
.iter()
.map(|v| format!("<filter val=\"{}\"/>", xml_escape(v)))
.collect();
format!("<filters>{inner}</filters>")
}
reader::FilterCriteria::Custom {
op1,
val1,
and,
op2,
val2,
} => {
let mut inner = format!(
"<customFilter operator=\"{}\" val=\"{}\"/>",
xml_escape(op1),
xml_escape(val1)
);
if let (Some(op2), Some(val2)) = (op2, val2) {
inner.push_str(&format!(
"<customFilter operator=\"{}\" val=\"{}\"/>",
xml_escape(op2),
xml_escape(val2)
));
}
format!(
"<customFilters and=\"{}\">{inner}</customFilters>",
if *and { "1" } else { "0" }
)
}
reader::FilterCriteria::Blank => "<filters blank=\"1\"/>".to_string(),
reader::FilterCriteria::Top10 { top, percent, val } => {
format!(
"<top10 top=\"{}\" percent=\"{}\" val=\"{}\"/>",
if *top { "1" } else { "0" },
if *percent { "1" } else { "0" },
val
)
}
reader::FilterCriteria::DateGroup(items) => {
let inner: String = items
.iter()
.map(|g| {
let mut a = String::new();
if let Some(y) = g.year {
a.push_str(&format!(" year=\"{y}\""));
}
if let Some(m) = g.month {
a.push_str(&format!(" month=\"{m}\""));
}
if let Some(d) = g.day {
a.push_str(&format!(" day=\"{d}\""));
}
if let Some(h) = g.hour {
a.push_str(&format!(" hour=\"{h}\""));
}
if let Some(mi) = g.minute {
a.push_str(&format!(" minute=\"{mi}\""));
}
if let Some(s) = g.second {
a.push_str(&format!(" second=\"{s}\""));
}
a.push_str(&format!(
" dateTimeGrouping=\"{}\"",
xml_escape(&g.date_time_grouping)
));
format!("<dateGroupItem{a}/>")
})
.collect();
format!("<filters calendarType=\"gregorian\">{inner}</filters>")
}
};
format!("<filterColumn{attrs}>{body}</filterColumn>")
}
fn resolve_autofilter_for_sheet(
vm: &Vm,
sheet_key: &str,
original: Option<&str>,
) -> Option<String> {
let af = vm.autofilters.get(sheet_key)?;
let body: String = af
.columns
.iter()
.map(|c| match (&c.raw_span, c.dirty) {
(Some(raw), false) => raw.clone(),
(Some(raw), true) => reader::with_attr(raw, "colId", &c.col_offset.to_string()),
(None, _) => build_filter_column_xml(c),
})
.collect();
Some(rebuild_autofilter_container(
original,
&merge_rect_to_a1(&af.ref_range),
&body,
))
}
fn rebuild_autofilter_container(original: Option<&str>, new_ref: &str, body: &str) -> String {
let attrs_only = original
.and_then(|span| {
let open_end = span.find('>')?;
span[..open_end].strip_prefix("<autoFilter")
})
.map(|raw| raw.trim_end().strip_suffix('/').unwrap_or(raw).to_string())
.unwrap_or_default();
let synthetic = format!("<autoFilter{attrs_only}/>");
let with_ref = reader::with_attr(&synthetic, "ref", new_ref);
let open = with_ref.trim_end().strip_suffix("/>").unwrap_or(&with_ref);
if body.is_empty() {
format!("{open}/>")
} else {
format!("{open}>{body}</autoFilter>")
}
}
fn save_ods_impl(vm: &Vm, path: &str) -> Result<(), String> {
use std::io::{Cursor, Write};
use zip::CompressionMethod;
use zip::write::ZipWriter;
let cursor = Cursor::new(Vec::<u8>::new());
let mut zip = ZipWriter::new(cursor);
let stored =
zip::write::SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
let deflated =
zip::write::SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("mimetype", stored)
.map_err(|e| e.to_string())?;
zip.write_all(b"application/vnd.oasis.opendocument.spreadsheet")
.map_err(|e| e.to_string())?;
let manifest = build_ods_manifest(vm);
zip.start_file("META-INF/manifest.xml", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(manifest.as_bytes())
.map_err(|e| e.to_string())?;
let content = build_ods_content(vm);
zip.start_file("content.xml", deflated)
.map_err(|e| e.to_string())?;
zip.write_all(content.as_bytes())
.map_err(|e| e.to_string())?;
let data = zip.finish().map_err(|e| e.to_string())?.into_inner();
std::fs::write(path, data).map_err(|e| e.to_string())?;
Ok(())
}
fn build_ods_manifest(_vm: &Vm) -> String {
let mut m = String::from(concat!(
r#"<?xml version="1.0" encoding="UTF-8"?>"#,
"\n",
r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">"#,
"\n",
r#" <manifest:file-entry manifest:media-type="application/vnd.oasis.opendocument.spreadsheet" manifest:version="1.2" manifest:full-path="/"/>"#,
"\n",
r#" <manifest:file-entry manifest:media-type="text/xml" manifest:full-path="content.xml"/>"#,
"\n",
));
m.push_str("</manifest:manifest>\n");
m
}
fn build_ods_content(vm: &Vm) -> String {
let mut out = String::from(concat!(
r#"<?xml version="1.0" encoding="UTF-8"?>"#,
"\n",
r#"<office:document-content"#,
r#" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0""#,
r#" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0""#,
r#" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0""#,
r#" office:version="1.2">"#,
"\n",
r#"<office:body><office:spreadsheet>"#,
"\n",
));
for sheet_name in vm.sheet_names() {
let escaped = xml_escape(&sheet_name);
out.push_str(&format!("<table:table table:name=\"{}\">\n", escaped));
if let Some(cells) = vm.get_sheet_cells(&sheet_name)
&& !cells.is_empty()
{
let max_row = cells.keys().map(|(r, _)| *r).max().unwrap_or(0);
let max_col = cells.keys().map(|(_, c)| *c).max().unwrap_or(0);
for r in 1..=max_row {
out.push_str("<table:table-row>");
for c in 1..=max_col {
let cell_xml = match cells.get(&(r, c)) {
None
| Some(vm::CellContent {
value: Variant::Empty,
..
}) => "<table:table-cell/>".to_string(),
Some(content) => ods_cell_xml(&content.value),
};
out.push_str(&cell_xml);
}
out.push_str("</table:table-row>\n");
}
}
out.push_str("</table:table>\n");
}
out.push_str("</office:spreadsheet></office:body>\n</office:document-content>\n");
out
}
fn ods_cell_xml(v: &Variant) -> String {
match v {
Variant::Integer(n) => format!(
r#"<table:table-cell office:value-type="float" office:value="{}"><text:p>{}</text:p></table:table-cell>"#,
n, n
),
Variant::Float(f) => format!(
r#"<table:table-cell office:value-type="float" office:value="{}"><text:p>{}</text:p></table:table-cell>"#,
f, f
),
Variant::Str(s) => format!(
r#"<table:table-cell office:value-type="string"><text:p>{}</text:p></table:table-cell>"#,
xml_escape(s)
),
Variant::Boolean(b) => {
let bv = if *b { "true" } else { "false" };
format!(
r#"<table:table-cell office:value-type="boolean" office:boolean-value="{}"><text:p>{}</text:p></table:table-cell>"#,
bv,
if *b { "TRUE" } else { "FALSE" }
)
}
Variant::Date(s) => format!(
r#"<table:table-cell office:value-type="float" office:value="{}"><text:p>{}</text:p></table:table-cell>"#,
s, s
),
Variant::Error(e) => format!(
r#"<table:table-cell office:value-type="string"><text:p>{}</text:p></table:table-cell>"#,
xml_escape(e.as_str())
),
Variant::Empty
| Variant::Null
| Variant::Array(_)
| Variant::VbaArray(_)
| Variant::Record(_) => "<table:table-cell/>".to_string(),
}
}
pub(crate) fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(feature = "python")]
#[pymodule]
mod elixcee {
#[pymodule_export]
use super::stream::{PyStreamReader, PyStreamWriter};
#[pymodule_export]
use super::{PyExcelError, PyVm, create_stream, hello, load_workbook, open_stream, run_macro};
}
#[cfg(test)]
mod tests {
use super::*;
use calamine::{Reader, Xlsx, open_workbook};
#[test]
#[allow(clippy::approx_constant)]
fn test_save_workbook_roundtrip() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(42),
},
);
vm.cells_mut().insert(
(2, 1),
CellContent {
formula: None,
value: Variant::Str("hello".into()),
},
);
vm.cells_mut().insert(
(3, 1),
CellContent {
formula: None,
value: Variant::Float(3.14),
},
);
vm.cells_mut().insert(
(4, 1),
CellContent {
formula: None,
value: Variant::Boolean(true),
},
);
let path = "/tmp/elixcee_test_roundtrip.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let mut wb: Xlsx<_> = open_workbook(path).expect("open should succeed");
let range = wb.worksheet_range("sheet1").expect("sheet1 should exist");
let cells: Vec<_> = range.cells().collect();
assert!(!cells.is_empty(), "saved file should have cells");
}
#[test]
fn rewrite_defined_names_xml_rewrites_only_the_renamed_qualifier() {
let xml = concat!(
"<definedNames>",
"<definedName name=\"MyRange\">Sheet1!$A$1:$A$3</definedName>",
"<definedName name=\"Other\">Sheet2!$B$1</definedName>",
"</definedNames>",
);
let renames: std::collections::HashMap<String, String> =
[("sheet1".to_string(), "Renamed".to_string())]
.into_iter()
.collect();
let out = rewrite_defined_names_xml(xml, &renames).unwrap();
assert!(out.contains("<definedName name=\"MyRange\">Renamed!$A$1:$A$3</definedName>"));
assert!(out.contains("<definedName name=\"Other\">Sheet2!$B$1</definedName>"));
}
#[test]
fn rewrite_defined_names_xml_preserves_print_titles_full_row_and_column() {
let xml = concat!(
"<definedNames>",
"<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"0\">",
"Sheet1!$1:$3,Sheet1!$A:$A</definedName>",
"</definedNames>",
);
let renames: std::collections::HashMap<String, String> =
[("sheet1".to_string(), "Data".to_string())]
.into_iter()
.collect();
let out = rewrite_defined_names_xml(xml, &renames).unwrap();
assert!(out.contains("Data!$1:$3,Data!$A:$A"));
}
#[test]
fn rewrite_defined_names_xml_unescapes_and_reescapes_a_formula_value() {
let xml = concat!(
"<definedNames>",
"<definedName name=\"X\">Sheet1!$A$1 & \"text\"</definedName>",
"</definedNames>",
);
let renames: std::collections::HashMap<String, String> =
[("sheet1".to_string(), "Renamed".to_string())]
.into_iter()
.collect();
let out = rewrite_defined_names_xml(xml, &renames).unwrap();
assert!(out.contains("Renamed!$A$1 & "text""));
}
#[test]
fn rewrite_defined_names_xml_drops_only_the_unconfirmable_name() {
let xml = concat!(
"<definedNames>",
"<definedName name=\"Dynamic\">OFFSET(Sheet1!$A$1,0,0,COUNTA(Sheet1!$A:$A),1)</definedName>",
"<definedName name=\"Plain\">Sheet1!$B$1</definedName>",
"</definedNames>",
);
let renames: std::collections::HashMap<String, String> =
[("sheet1".to_string(), "Renamed".to_string())]
.into_iter()
.collect();
let out = rewrite_defined_names_xml(xml, &renames).unwrap();
assert!(!out.contains("Dynamic"));
assert!(out.contains("<definedName name=\"Plain\">Renamed!$B$1</definedName>"));
}
#[test]
fn rewrite_defined_names_xml_returns_none_when_every_name_is_dropped() {
let xml = concat!(
"<definedNames>",
"<definedName name=\"Dynamic\">OFFSET(Sheet1!$A$1,0,0,COUNTA(Sheet1!$A:$A),1)</definedName>",
"</definedNames>",
);
let renames: std::collections::HashMap<String, String> =
[("sheet1".to_string(), "Renamed".to_string())]
.into_iter()
.collect();
assert_eq!(rewrite_defined_names_xml(xml, &renames), None);
}
#[test]
fn row_height_and_column_width_survive_a_save_and_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(5, 1),
CellContent {
formula: None,
value: Variant::Integer(1),
},
);
vm.row_heights
.entry("sheet1".to_string())
.or_default()
.insert(5, 30.5);
vm.row_heights
.entry("sheet1".to_string())
.or_default()
.insert(10, 20.0);
vm.sheet_visibility
.entry("sheet1".to_string())
.or_default()
.hidden_rows
.push(vm::Interval { start: 10, end: 10 });
vm.column_widths
.entry("sheet1".to_string())
.or_default()
.push((2, 2, 12.5));
let path = "/tmp/elixcee_test_row_height_column_width_roundtrip.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let mut reloaded = Vm::new();
reloaded
.load_workbook_file(path)
.expect("reload should succeed");
let key = reloaded.resolve_sheet_key(None).unwrap();
assert_eq!(reloaded.row_height_on_sheet(&key, 5), Some(30.5));
assert_eq!(reloaded.column_width_on_sheet(&key, 2), Some(12.5));
assert_eq!(reloaded.row_height_on_sheet(&key, 10), Some(20.0));
assert_eq!(reloaded.hidden_rows_on_sheet(&key), vec![10]);
assert_eq!(reloaded.row_height_on_sheet(&key, 1), None);
assert_eq!(reloaded.column_width_on_sheet(&key, 1), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn parse_table_part_number_extracts_n_and_rejects_a_non_matching_name() {
assert_eq!(parse_table_part_number("xl/tables/table3.xml"), Some(3));
assert_eq!(parse_table_part_number("xl/tables/table1.xml"), Some(1));
assert_eq!(parse_table_part_number("xl/worksheets/sheet1.xml"), None);
assert_eq!(parse_table_part_number("xl/tables/table.xml"), None);
}
fn zip_text(zip: &mut zip::ZipArchive<std::fs::File>, name: &str) -> String {
let mut s = String::new();
std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut s).unwrap();
s
}
#[test]
fn create_table_from_scratch_writes_all_four_linkage_artifacts_and_round_trips() {
let mut vm = Vm::new();
vm.write_rect(
"sheet1",
(1, 1),
&[
vec![
Variant::Str("Name".to_string()),
Variant::Str("Qty".to_string()),
],
vec![Variant::Str("Widget".to_string()), Variant::Integer(5)],
],
);
vm.create_table_on_sheet("sheet1", ((1, 1), (2, 2)), Some("Table1"), None, None)
.unwrap();
let path = "/tmp/elixcee_test_create_table_from_scratch.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let file = std::fs::File::open(path).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let sheet_part = zip
.file_names()
.find(|n| n.starts_with("xl/worksheets/sheet") && n.ends_with(".xml"))
.unwrap()
.to_string();
let table_xml = zip_text(&mut zip, "xl/tables/table1.xml");
assert!(table_xml.contains(r#"ref="A1:B2""#));
assert!(table_xml.contains(r#"name="Name""#));
assert!(table_xml.contains(r#"name="Qty""#));
assert!(table_xml.contains("<autoFilter"));
let rels_xml = zip_text(&mut zip, "xl/worksheets/_rels/sheet1.xml.rels");
assert!(rels_xml.contains(r#"Target="../tables/table1.xml""#));
assert!(rels_xml.contains(
"Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/table\""
));
let sheet_xml = zip_text(&mut zip, &sheet_part);
assert!(sheet_xml.contains("<tableParts"));
assert!(sheet_xml.contains("<tablePart"));
let content_types = zip_text(&mut zip, "[Content_Types].xml");
assert!(content_types.contains(
"PartName=\"/xl/tables/table1.xml\" \
ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml\""
));
let mut reloaded = Vm::new();
reloaded
.load_workbook_file(path)
.expect("reload should succeed");
let key = reloaded.resolve_sheet_key(None).unwrap();
let tables = reloaded
.tables
.get(&key)
.expect("table must survive reload");
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].display_name, "Table1");
assert_eq!(tables[0].ref_range, ((1, 1), (2, 2)));
let path2 = "/tmp/elixcee_test_create_table_from_scratch_2.xlsx";
save_workbook_impl(&reloaded, path2).expect("second save should succeed");
let mut reloaded2 = Vm::new();
reloaded2
.load_workbook_file(path2)
.expect("second reload should succeed");
let key2 = reloaded2.resolve_sheet_key(None).unwrap();
assert_eq!(reloaded2.tables.get(&key2).unwrap().len(), 1);
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path2);
}
#[test]
fn create_table_alongside_an_existing_table_merges_rather_than_replaces() {
let mut vm = Vm::new();
vm.write_rect("sheet1", (1, 1), &[vec![Variant::Str("A".to_string())]]);
vm.create_table_on_sheet("sheet1", ((1, 1), (1, 1)), Some("First"), None, None)
.unwrap();
let path = "/tmp/elixcee_test_create_table_first.xlsx";
save_workbook_impl(&vm, path).expect("first save should succeed");
let mut loaded = Vm::new();
loaded
.load_workbook_file(path)
.expect("reload should succeed");
let key = loaded.resolve_sheet_key(None).unwrap();
loaded.write_rect(&key, (1, 3), &[vec![Variant::Str("B".to_string())]]);
loaded
.create_table_on_sheet(&key, ((1, 3), (1, 3)), Some("Second"), None, None)
.unwrap();
let path2 = "/tmp/elixcee_test_create_table_second.xlsx";
save_workbook_impl(&loaded, path2).expect("second save should succeed");
let file = std::fs::File::open(path2).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
assert!(zip.by_name("xl/tables/table1.xml").is_ok());
assert!(zip.by_name("xl/tables/table2.xml").is_ok());
let rels_xml = zip_text(&mut zip, "xl/worksheets/_rels/sheet1.xml.rels");
assert!(rels_xml.contains("table1.xml"));
assert!(rels_xml.contains("table2.xml"));
assert_eq!(reader::relationship_ids(&rels_xml).len(), 2);
drop(zip);
let mut reloaded = Vm::new();
reloaded
.load_workbook_file(path2)
.expect("final reload should succeed");
let key2 = reloaded.resolve_sheet_key(None).unwrap();
let tables = reloaded.tables.get(&key2).unwrap();
assert_eq!(tables.len(), 2);
let names: Vec<&str> = tables.iter().map(|t| t.display_name.as_str()).collect();
assert!(names.contains(&"First"));
assert!(names.contains(&"Second"));
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path2);
}
#[test]
fn create_table_on_a_sheet_with_no_prior_relationships_does_not_falsely_revive_drawing_restoration()
{
let mut vm = Vm::new();
vm.write_rect("sheet1", (1, 1), &[vec![Variant::Str("A".to_string())]]);
vm.create_table_on_sheet("sheet1", ((1, 1), (1, 1)), Some("T"), None, None)
.unwrap();
let path = "/tmp/elixcee_test_create_table_no_prior_rels.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let file = std::fs::File::open(path).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let sheet_name = zip
.file_names()
.find(|n| n.starts_with("xl/worksheets/sheet") && n.ends_with(".xml"))
.unwrap()
.to_string();
let sheet_xml = zip_text(&mut zip, &sheet_name);
assert!(!sheet_xml.contains("<drawing"));
assert!(!sheet_xml.contains("<legacyDrawing"));
let _ = std::fs::remove_file(path);
}
#[test]
fn resolve_pending_number_formats_returns_none_with_no_pending_edits() {
let vm = Vm::new();
assert!(resolve_pending_number_formats(&vm, XLSX_STYLES.as_bytes()).is_none());
}
#[test]
fn resolve_pending_number_formats_reuses_a_builtin_id_against_a_from_scratch_stylesheet() {
let mut vm = Vm::new();
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "#,##0.00");
let (new_xml, effective) =
resolve_pending_number_formats(&vm, XLSX_STYLES.as_bytes()).expect("pending edit");
assert!(!new_xml.contains("<numFmts"));
assert!(new_xml.contains("numFmtId=\"4\""));
let idx = effective["sheet1"][&(1, 1)];
assert_ne!(idx, 0); }
#[test]
fn resolve_pending_number_formats_mints_a_new_custom_numfmt_when_no_source_has_none() {
let mut vm = Vm::new();
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "0.00\"kg\"");
let (new_xml, _) =
resolve_pending_number_formats(&vm, XLSX_STYLES.as_bytes()).expect("pending edit");
assert!(new_xml.contains("<numFmts count=\"1\">"));
assert!(new_xml.contains("numFmtId=\"164\" formatCode=\"0.00"kg"\""));
assert!(new_xml.contains("numFmtId=\"164\"")); assert!(new_xml.find("<numFmts").unwrap() < new_xml.find("<fonts").unwrap());
}
#[test]
fn resolve_pending_number_formats_appends_to_an_existing_numfmts_block_without_disturbing_it() {
let source = concat!(
"<styleSheet>",
"<numFmts count=\"1\"><numFmt numFmtId=\"164\" formatCode=\"0.00"kg"\"/></numFmts>",
"<fonts><font/></fonts><fills><fill/></fills><borders><border/></borders>",
"<cellStyleXfs><xf/></cellStyleXfs>",
"<cellXfs count=\"1\"><xf/></cellXfs>",
"</styleSheet>",
);
let mut vm = Vm::new();
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "0.00\"lb\"");
let (new_xml, _) =
resolve_pending_number_formats(&vm, source.as_bytes()).expect("pending edit");
assert!(new_xml.contains("numFmtId=\"164\" formatCode=\"0.00"kg"\""));
assert!(new_xml.contains("numFmtId=\"165\" formatCode=\"0.00"lb"\""));
assert!(new_xml.contains("<numFmts count=\"2\">"));
}
#[test]
fn resolve_pending_number_formats_reuses_an_existing_xf_dedup_not_a_duplicate() {
let mut vm = Vm::new();
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "#,##0.00");
vm.set_number_format_on_sheet("sheet1", 5, 5, 5, 5, "#,##0.00");
let (_, effective) =
resolve_pending_number_formats(&vm, XLSX_STYLES.as_bytes()).expect("pending edit");
assert_eq!(effective["sheet1"][&(1, 1)], effective["sheet1"][&(5, 5)]);
}
#[test]
fn resolve_pending_number_formats_never_mutates_the_index_of_an_untouched_cell() {
let mut vm = Vm::new();
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "0%");
let (_, effective) =
resolve_pending_number_formats(&vm, XLSX_STYLES.as_bytes()).expect("pending edit");
assert_ne!(effective["sheet1"][&(1, 1)], 0);
assert!(!effective.get("sheet1").unwrap().contains_key(&(2, 2)));
}
#[test]
fn set_number_format_on_a_from_scratch_vm_survives_a_save_and_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Float(1234.5),
},
);
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "#,##0.00");
let path = "/tmp/elixcee_test_set_number_format_from_scratch.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let mut reloaded = Vm::new();
reloaded
.load_workbook_file(path)
.expect("reload should succeed");
assert_eq!(reloaded.get_cell_number_format(1, 1), Some("#,##0.00"));
let _ = std::fs::remove_file(path);
}
#[test]
fn set_number_format_mints_a_custom_numfmt_that_survives_a_save_and_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Float(2.5),
},
);
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "0.00\"kg\"");
let path = "/tmp/elixcee_test_set_number_format_custom.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let mut reloaded = Vm::new();
reloaded
.load_workbook_file(path)
.expect("reload should succeed");
assert_eq!(reloaded.get_cell_number_format(1, 1), Some("0.00\"kg\""));
let _ = std::fs::remove_file(path);
}
#[test]
fn set_number_format_leaves_an_untouched_sibling_cell_unaffected_after_a_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Float(1.0),
},
);
vm.cells_mut().insert(
(2, 1),
CellContent {
formula: None,
value: Variant::Float(2.0),
},
);
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "0%");
let path = "/tmp/elixcee_test_set_number_format_sibling_unaffected.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let mut reloaded = Vm::new();
reloaded
.load_workbook_file(path)
.expect("reload should succeed");
assert_eq!(reloaded.get_cell_number_format(1, 1), Some("0%"));
assert_eq!(reloaded.get_cell_number_format(2, 1), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn set_number_format_get_cell_number_format_reflects_the_edit_before_any_save() {
let mut vm = Vm::new();
assert_eq!(vm.get_cell_number_format(1, 1), None);
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "0.00%");
assert_eq!(vm.get_cell_number_format(1, 1), Some("0.00%"));
}
fn style_attr_edit(edit: StyleAttrEdit) -> StyleAttrEdit {
edit
}
#[test]
fn resolve_pending_style_attrs_returns_none_with_no_pending_edits() {
let vm = Vm::new();
let indices = vm.cell_style_indices.clone();
assert!(
resolve_pending_style_attrs(&vm, XLSX_STYLES.as_bytes(), &indices)
.unwrap()
.is_none()
);
}
#[test]
fn resolve_pending_style_attrs_mints_a_new_font_preserving_other_properties() {
let source = concat!(
"<styleSheet>",
"<fonts count=\"1\"><font><sz val=\"11\"/><color rgb=\"FF112233\"/><name val=\"Calibri\"/></font></fonts>",
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>",
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
"<cellStyleXfs count=\"1\"><xf/></cellStyleXfs>",
"<cellXfs count=\"1\"><xf/></cellXfs>",
"</styleSheet>",
);
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (new_xml, effective) = resolve_pending_style_attrs(&vm, source.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
assert!(new_xml.contains("<b val=\"1\"/>"));
assert!(new_xml.contains("<color rgb=\"FF112233\"/>"));
assert!(new_xml.contains("<name val=\"Calibri\"/>"));
assert_ne!(effective["sheet1"][&(1, 1)], 0);
}
#[test]
fn resolve_pending_style_attrs_mints_a_solid_fill() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
fill: Some(FillEdit {
color_argb: "FF4472C4".to_string(),
}),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (new_xml, _) = resolve_pending_style_attrs(&vm, XLSX_STYLES.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
assert!(new_xml.contains("<fgColor rgb=\"FF4472C4\"/>"));
assert!(new_xml.contains("<bgColor indexed=\"64\"/>"));
}
#[test]
fn resolve_pending_style_attrs_reuses_a_font_dedup_not_a_duplicate() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
vm.set_style_on_sheet(
"sheet1",
5,
5,
5,
5,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (_, effective) = resolve_pending_style_attrs(&vm, XLSX_STYLES.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
assert_eq!(effective["sheet1"][&(1, 1)], effective["sheet1"][&(5, 5)]);
}
#[test]
fn resolve_pending_style_attrs_never_mutates_the_index_of_an_untouched_cell() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (_, effective) = resolve_pending_style_attrs(&vm, XLSX_STYLES.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
assert_ne!(effective["sheet1"][&(1, 1)], 0);
assert!(!effective.get("sheet1").unwrap().contains_key(&(2, 2)));
}
#[test]
fn resolve_pending_style_attrs_chains_onto_the_number_format_passs_own_output() {
let mut vm = Vm::new();
vm.set_number_format_on_sheet("sheet1", 1, 1, 1, 1, "#,##0.00");
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let (numfmt_xml, numfmt_indices) =
resolve_pending_number_formats(&vm, XLSX_STYLES.as_bytes()).expect("pending edit");
let (final_xml, final_indices) =
resolve_pending_style_attrs(&vm, numfmt_xml.as_bytes(), &numfmt_indices)
.unwrap()
.expect("pending edit");
let idx = final_indices["sheet1"][&(1, 1)] as usize;
let xfs = reader::extract_cell_xfs(&final_xml);
let xf = &xfs[idx];
assert!(xf.contains("numFmtId=\"4\""), "numFmtId lost: {xf}");
let font_id = reader::span_attr_u32(xf, "fontId") as usize;
let fonts = reader::extract_records(&final_xml, "fonts", "font");
assert!(
fonts[font_id].contains("<b val=\"1\"/>"),
"font edit lost: {}",
fonts[font_id]
);
}
#[test]
fn resolve_pending_style_attrs_alignment_merges_onto_a_real_fixture_shaped_xf() {
let source = concat!(
"<styleSheet>",
"<fonts count=\"1\"><font/></fonts>",
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>",
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
"<cellStyleXfs count=\"1\"><xf/></cellStyleXfs>",
"<cellXfs count=\"1\"><xf><alignment vertical=\"center\"/></xf></cellXfs>",
"</styleSheet>",
);
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
alignment: Some(reader::AlignmentEdit {
horizontal: Some("center".to_string()),
..Default::default()
}),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (new_xml, _) = resolve_pending_style_attrs(&vm, source.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
assert!(new_xml.contains("vertical=\"center\""));
assert!(new_xml.contains("horizontal=\"center\""));
}
const FIXTURE4_SHAPED_STYLES: &str = concat!(
"<styleSheet>",
"<fonts count=\"3\">",
"<font><sz val=\"12\"/><color theme=\"1\"/><name val=\"游ゴシック\"/></font>",
"<font><sz val=\"6\"/><name val=\"游ゴシック\"/></font>",
"<font><u/><sz val=\"12\"/><color theme=\"10\"/><name val=\"游ゴシック\"/></font>",
"</fonts>",
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>",
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
"<cellStyleXfs count=\"2\">",
"<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"><alignment vertical=\"center\"/></xf>",
"<xf numFmtId=\"0\" fontId=\"2\" fillId=\"0\" borderId=\"0\" applyNumberFormat=\"0\" \
applyFill=\"0\" applyBorder=\"0\" applyAlignment=\"0\" applyProtection=\"0\">\
<alignment vertical=\"center\"/></xf>",
"</cellStyleXfs>",
"<cellXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\">\
<alignment vertical=\"center\"/></xf></cellXfs>",
"<cellStyles count=\"2\">",
"<cellStyle name=\"ハイパーリンク\" xfId=\"1\" builtinId=\"8\"/>",
"<cellStyle name=\"標準\" xfId=\"0\" builtinId=\"0\"/>",
"</cellStyles>",
"</styleSheet>",
);
#[test]
fn resolve_pending_style_attrs_applies_a_real_fixture_shaped_named_style() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
named_style: Some("ハイパーリンク".to_string()),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (new_xml, effective) =
resolve_pending_style_attrs(&vm, FIXTURE4_SHAPED_STYLES.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
let idx = effective["sheet1"][&(1, 1)] as usize;
let xfs = reader::extract_cell_xfs(&new_xml);
let xf = &xfs[idx];
assert!(xf.contains("xfId=\"1\""), "xfId not set: {xf}");
assert_eq!(
reader::span_attr_u32(xf, "fontId"),
2,
"fontId not baked in: {xf}"
);
}
#[test]
fn resolve_pending_style_attrs_errors_on_an_unknown_named_style() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
named_style: Some("Bad".to_string()),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let err = resolve_pending_style_attrs(&vm, FIXTURE4_SHAPED_STYLES.as_bytes(), &indices)
.unwrap_err();
assert!(
err.contains("Bad"),
"error should name the missing style: {err}"
);
}
#[test]
fn resolve_pending_style_attrs_applies_named_style_before_other_fields_on_the_same_edit() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
named_style: Some("ハイパーリンク".to_string()),
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let indices = vm.cell_style_indices.clone();
let (new_xml, effective) =
resolve_pending_style_attrs(&vm, FIXTURE4_SHAPED_STYLES.as_bytes(), &indices)
.unwrap()
.expect("pending edit");
let idx = effective["sheet1"][&(1, 1)] as usize;
let xfs = reader::extract_cell_xfs(&new_xml);
let xf = &xfs[idx];
assert!(xf.contains("xfId=\"1\""), "named style lost: {xf}");
let font_id = reader::span_attr_u32(xf, "fontId") as usize;
let fonts = reader::extract_records(&new_xml, "fonts", "font");
assert!(fonts[font_id].contains("<b/>") || fonts[font_id].contains("<b val=\"1\"/>"));
assert!(
fonts[font_id].contains("<u/>"),
"underline lost: {}",
fonts[font_id]
);
assert!(
fonts[font_id].contains("theme=\"10\""),
"theme color lost: {}",
fonts[font_id]
);
}
#[test]
fn resolve_pending_style_copies_returns_none_with_no_pending_edits() {
let vm = Vm::new();
let indices = vm.cell_style_indices.clone();
assert!(resolve_pending_style_copies(&vm, &indices).is_none());
}
#[test]
fn resolve_pending_style_copies_points_dest_at_the_resolved_source_index() {
let mut vm = Vm::new();
vm.cell_style_indices.insert(
"sheet1".to_string(),
std::collections::HashMap::from([((1, 1), 7u32)]),
);
vm.copy_style_on_sheet("sheet1", (1, 1), 2, 2, 2, 2);
let indices = vm.cell_style_indices.clone();
let effective = resolve_pending_style_copies(&vm, &indices).expect("pending copy");
assert_eq!(effective["sheet1"][&(2, 2)], 7);
assert_eq!(effective["sheet1"][&(1, 1)], 7);
}
#[test]
fn resolve_pending_style_copies_picks_up_a_pending_edit_on_the_source_not_just_its_resolved_index()
{
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
vm.copy_style_on_sheet("sheet1", (1, 1), 2, 2, 2, 2);
let starting_indices = vm.cell_style_indices.clone();
let (_, style_attrs_effective) =
resolve_pending_style_attrs(&vm, XLSX_STYLES.as_bytes(), &starting_indices)
.unwrap()
.expect("pending style edit");
let final_effective =
resolve_pending_style_copies(&vm, &style_attrs_effective).expect("pending copy");
assert_eq!(
final_effective["sheet1"][&(2, 2)],
style_attrs_effective["sheet1"][&(1, 1)],
"copy_style should point at the source's NEWLY resolved (bold) index, not a stale one"
);
assert_ne!(final_effective["sheet1"][&(2, 2)], 0);
}
#[test]
fn resolve_pending_style_copies_never_mutates_an_untouched_cell() {
let mut vm = Vm::new();
vm.cell_style_indices.insert(
"sheet1".to_string(),
std::collections::HashMap::from([((1, 1), 7u32), ((3, 3), 9u32)]),
);
vm.copy_style_on_sheet("sheet1", (1, 1), 2, 2, 2, 2);
let indices = vm.cell_style_indices.clone();
let effective = resolve_pending_style_copies(&vm, &indices).expect("pending copy");
assert_eq!(effective["sheet1"][&(3, 3)], 9);
}
#[test]
fn resolve_pending_row_column_styles_returns_none_with_no_pending_edits() {
let vm = Vm::new();
assert!(
resolve_pending_row_column_styles(
&vm,
XLSX_STYLES.as_bytes(),
&vm.row_styles,
&vm.column_styles
)
.unwrap()
.is_none()
);
}
#[test]
fn resolve_pending_row_column_styles_resolves_a_row_style() {
let mut vm = Vm::new();
vm.set_row_style_on_sheet(
"sheet1",
3,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let (new_xml, rows, cols) = resolve_pending_row_column_styles(
&vm,
XLSX_STYLES.as_bytes(),
&vm.row_styles,
&vm.column_styles,
)
.unwrap()
.expect("pending row edit");
assert!(new_xml.contains("<b val=\"1\"/>"));
assert_ne!(rows["sheet1"][&3], 0);
assert!(cols.is_empty() || cols["sheet1"].is_empty());
}
#[test]
fn resolve_pending_row_column_styles_resolves_a_column_style() {
let mut vm = Vm::new();
vm.set_column_style_on_sheet(
"sheet1",
2,
&style_attr_edit(StyleAttrEdit {
fill: Some(FillEdit {
color_argb: "FF4472C4".to_string(),
}),
..Default::default()
}),
);
let (new_xml, _, cols) = resolve_pending_row_column_styles(
&vm,
XLSX_STYLES.as_bytes(),
&vm.row_styles,
&vm.column_styles,
)
.unwrap()
.expect("pending column edit");
assert!(new_xml.contains("<fgColor rgb=\"FF4472C4\"/>"));
assert_eq!(cols["sheet1"], vec![(2, 2, 1)]);
}
#[test]
fn resolve_pending_row_column_styles_splits_an_existing_column_range_when_setting_one_column() {
let mut vm = Vm::new();
vm.column_styles
.insert("sheet1".to_string(), vec![(1, 10, 3u32)]);
vm.set_column_style_on_sheet(
"sheet1",
5,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let (_, _, cols) = resolve_pending_row_column_styles(
&vm,
XLSX_STYLES.as_bytes(),
&vm.row_styles,
&vm.column_styles,
)
.unwrap()
.expect("pending column edit");
let mut ranges = cols["sheet1"].clone();
ranges.sort();
assert_eq!(ranges.len(), 3);
assert!(ranges.contains(&(1, 4, 3)));
assert!(ranges.contains(&(6, 10, 3)));
let touched = ranges
.iter()
.find(|&&(min, max, _)| min == 5 && max == 5)
.expect("a (5,5,_) singleton for the touched column");
assert_ne!(touched.2, 3);
}
#[test]
fn resolve_pending_row_column_styles_chains_onto_the_style_attrs_passs_own_output() {
let mut vm = Vm::new();
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
vm.set_row_style_on_sheet(
"sheet1",
9,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let starting_indices = vm.cell_style_indices.clone();
let (style_attrs_xml, style_attrs_effective) =
resolve_pending_style_attrs(&vm, XLSX_STYLES.as_bytes(), &starting_indices)
.unwrap()
.expect("pending style edit");
let (_, rows, _) = resolve_pending_row_column_styles(
&vm,
style_attrs_xml.as_bytes(),
&vm.row_styles,
&vm.column_styles,
)
.unwrap()
.expect("pending row edit");
assert_eq!(rows["sheet1"][&9], style_attrs_effective["sheet1"][&(1, 1)]);
}
#[test]
fn copy_style_on_a_from_scratch_vm_survives_a_save_and_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Str("styled".to_string()),
},
);
vm.cells_mut().insert(
(2, 2),
CellContent {
formula: None,
value: Variant::Str("plain".to_string()),
},
);
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
vm.copy_style_on_sheet("sheet1", (1, 1), 2, 2, 2, 2);
let path = "/tmp/elixcee_test_copy_style_from_scratch.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let bytes = std::fs::read(path).expect("read output");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("open zip");
let mut styles_xml = String::new();
std::io::Read::read_to_string(
&mut zip.by_name("xl/styles.xml").expect("styles.xml"),
&mut styles_xml,
)
.expect("read styles.xml");
assert!(styles_xml.contains("<b val=\"1\"/>"));
let mut sheet_xml = String::new();
std::io::Read::read_to_string(
&mut zip.by_name("xl/worksheets/sheet1.xml").expect("sheet1.xml"),
&mut sheet_xml,
)
.expect("read sheet1.xml");
let a1_style = sheet_xml
.split("r=\"A1\"")
.nth(1)
.and_then(|s| s.split('>').next())
.and_then(|s| s.split("s=\"").nth(1))
.and_then(|s| s.split('"').next())
.expect("A1 has an s= attribute");
let b2_style = sheet_xml
.split("r=\"B2\"")
.nth(1)
.and_then(|s| s.split('>').next())
.and_then(|s| s.split("s=\"").nth(1))
.and_then(|s| s.split('"').next())
.expect("B2 has an s= attribute");
assert_eq!(a1_style, b2_style);
assert_ne!(a1_style, "0");
let _ = std::fs::remove_file(path);
}
#[test]
fn set_style_on_a_from_scratch_vm_survives_a_save_and_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Str("hi".to_string()),
},
);
vm.set_style_on_sheet(
"sheet1",
1,
1,
1,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let path = "/tmp/elixcee_test_set_style_from_scratch.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let bytes = std::fs::read(path).expect("read output");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("open zip");
let mut styles_xml = String::new();
std::io::Read::read_to_string(
&mut zip.by_name("xl/styles.xml").expect("styles.xml"),
&mut styles_xml,
)
.expect("read styles.xml");
assert!(styles_xml.contains("<b val=\"1\"/>"));
let _ = std::fs::remove_file(path);
}
#[test]
fn set_row_style_and_set_column_style_survive_a_save_and_reload() {
let mut vm = Vm::new();
vm.set_row_style_on_sheet(
"sheet1",
3,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
vm.set_column_style_on_sheet(
"sheet1",
2,
&style_attr_edit(StyleAttrEdit {
fill: Some(FillEdit {
color_argb: "FF4472C4".to_string(),
}),
..Default::default()
}),
);
let path = "/tmp/elixcee_test_row_column_style_from_scratch.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let bytes = std::fs::read(path).expect("read output");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("open zip");
let mut styles_xml = String::new();
std::io::Read::read_to_string(
&mut zip.by_name("xl/styles.xml").expect("styles.xml"),
&mut styles_xml,
)
.expect("read styles.xml");
assert!(styles_xml.contains("<b val=\"1\"/>"));
assert!(styles_xml.contains("<fgColor rgb=\"FF4472C4\"/>"));
let mut sheet_xml = String::new();
std::io::Read::read_to_string(
&mut zip.by_name("xl/worksheets/sheet1.xml").expect("sheet1.xml"),
&mut sheet_xml,
)
.expect("read sheet1.xml");
assert!(sheet_xml.contains("customFormat=\"1\""));
assert!(sheet_xml.contains("<row r=\"3\""));
assert!(sheet_xml.contains("<col min=\"2\" max=\"2\""));
let _ = std::fs::remove_file(path);
}
#[test]
fn a_cells_own_set_style_wins_over_its_rows_default_style_on_reload() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(3, 1),
CellContent {
formula: None,
value: Variant::Str("own style".to_string()),
},
);
vm.set_style_on_sheet(
"sheet1",
3,
1,
3,
1,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
italic: Some(true),
..Default::default()
}),
..Default::default()
}),
);
vm.set_row_style_on_sheet(
"sheet1",
3,
&style_attr_edit(StyleAttrEdit {
font: Some(reader::FontEdit {
bold: Some(true),
..Default::default()
}),
..Default::default()
}),
);
let path = "/tmp/elixcee_test_row_style_vs_cell_style_precedence.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let bytes = std::fs::read(path).expect("read output");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("open zip");
let mut sheet_xml = String::new();
std::io::Read::read_to_string(
&mut zip.by_name("xl/worksheets/sheet1.xml").expect("sheet1.xml"),
&mut sheet_xml,
)
.expect("read sheet1.xml");
let row_style = sheet_xml
.split("<row r=\"3\"")
.nth(1)
.and_then(|s| s.split('>').next())
.and_then(|s| s.split("s=\"").nth(1))
.and_then(|s| s.split('"').next())
.expect("row 3 has an s= attribute")
.to_string();
let cell_style = sheet_xml
.split("r=\"A3\"")
.nth(1)
.and_then(|s| s.split('>').next())
.and_then(|s| s.split("s=\"").nth(1))
.and_then(|s| s.split('"').next())
.expect("A3 has an s= attribute");
assert_ne!(row_style, cell_style);
let _ = std::fs::remove_file(path);
}
#[test]
fn insert_rows_on_sheet_shifts_row_styles() {
let mut vm = Vm::new();
vm.row_styles.insert(
"sheet1".to_string(),
std::collections::HashMap::from([(5u32, 3u32)]),
);
vm.insert_rows_on_sheet("sheet1", 1, 2);
assert_eq!(vm.row_styles.get("sheet1").unwrap().get(&7), Some(&3));
assert!(!vm.row_styles.get("sheet1").unwrap().contains_key(&5));
}
#[test]
fn delete_rows_on_sheet_drops_a_row_style_inside_the_deleted_band() {
let mut vm = Vm::new();
vm.row_styles.insert(
"sheet1".to_string(),
std::collections::HashMap::from([(5u32, 3u32)]),
);
vm.delete_rows_on_sheet("sheet1", 4, 3);
assert!(vm.row_styles.get("sheet1").unwrap().is_empty());
}
#[test]
fn insert_cols_on_sheet_shifts_column_styles() {
let mut vm = Vm::new();
vm.column_styles
.insert("sheet1".to_string(), vec![(3, 5, 3u32)]);
vm.insert_cols_on_sheet("sheet1", 1, 2);
assert_eq!(vm.column_styles.get("sheet1").unwrap(), &vec![(5, 7, 3)]);
}
#[test]
fn rename_sheet_re_keys_row_styles_and_column_styles() {
let mut vm = Vm::new();
vm.row_styles.insert(
"sheet1".to_string(),
std::collections::HashMap::from([(3u32, 5u32)]),
);
vm.column_styles
.insert("sheet1".to_string(), vec![(1, 2, 7)]);
vm.pending_row_styles.insert(
"sheet1".to_string(),
std::collections::HashMap::from([(9u32, StyleAttrEdit::default())]),
);
vm.rename_sheet("Sheet1", "Renamed").unwrap();
assert!(!vm.row_styles.contains_key("sheet1"));
assert_eq!(vm.row_styles.get("renamed").unwrap().get(&3), Some(&5));
assert_eq!(vm.column_styles.get("renamed").unwrap(), &vec![(1, 2, 7)]);
assert!(
vm.pending_row_styles
.get("renamed")
.unwrap()
.contains_key(&9)
);
}
#[test]
fn remove_sheet_clears_row_styles_and_column_styles() {
let mut vm = Vm::new();
vm.ensure_sheet("Sheet2");
vm.row_styles.insert(
"sheet2".to_string(),
std::collections::HashMap::from([(3u32, 5u32)]),
);
vm.column_styles
.insert("sheet2".to_string(), vec![(1, 2, 7)]);
vm.delete_sheet("Sheet2").unwrap();
assert!(!vm.row_styles.contains_key("sheet2"));
assert!(!vm.column_styles.contains_key("sheet2"));
}
#[test]
fn test_save_ods_roundtrip() {
use calamine::{Reader, open_workbook_auto};
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(42),
},
);
vm.cells_mut().insert(
(1, 2),
CellContent {
formula: None,
value: Variant::Str("hello".into()),
},
);
vm.cells_mut().insert(
(2, 1),
CellContent {
formula: None,
value: Variant::Boolean(true),
},
);
let path = "/tmp/elixcee_test_ods.ods";
save_workbook_impl(&vm, path).expect("ODS save should succeed");
let mut wb = open_workbook_auto(path).expect("ODS open should succeed");
let range = wb.worksheet_range("sheet1").expect("sheet1 should exist");
let cells: Vec<_> = range.cells().collect();
assert!(!cells.is_empty(), "ODS file should have cells");
}
#[test]
fn test_save_workbook_multi_sheet() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(1),
},
);
vm.ensure_sheet("sheet2");
let prev = vm.active_sheet.clone();
vm.active_sheet = "sheet2".into();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(2),
},
);
vm.active_sheet = prev;
let path = "/tmp/elixcee_test_multisheet.xlsx";
save_workbook_impl(&vm, path).expect("save should succeed");
let mut wb: Xlsx<_> = open_workbook(path).expect("open should succeed");
assert!(wb.worksheet_range("sheet1").is_ok(), "sheet1 should exist");
assert!(wb.worksheet_range("sheet2").is_ok(), "sheet2 should exist");
}
#[test]
fn build_xlsx_workbook_preserves_original_sheet_ids_and_assigns_fresh_ones_for_new_sheets() {
let mut origins = std::collections::HashMap::new();
origins.insert(
"sheet1".to_string(),
WorksheetOrigin {
original_sheet_id: Some("7".to_string()),
original_workbook_rel_id: Some("rId3".to_string()),
original_part_name: Some("xl/worksheets/sheet2.xml".to_string()),
original_display_name: Some("Sheet1".to_string()),
},
);
origins.insert(
"sheet2".to_string(),
WorksheetOrigin {
original_sheet_id: Some("2".to_string()),
original_workbook_rel_id: None,
original_part_name: None,
original_display_name: None,
},
);
let plans = plan_worksheet_output(
&[
"sheet1".to_string(),
"sheet2".to_string(),
"newsheet".to_string(),
],
&origins,
&[],
);
let xml = build_xlsx_workbook(&plans, &OpaqueWorkbookFragments::default());
assert!(
xml.contains("<sheet name=\"Sheet1\" sheetId=\"7\" r:id=\"rId1\"/>"),
"expected sheet1 to keep its original sheetId 7 and original-case display name: {xml}"
);
assert!(
xml.contains("<sheet name=\"sheet2\" sheetId=\"2\" r:id=\"rId2\"/>"),
"expected sheet2 (no display name recorded) to fall back to its lookup key: {xml}"
);
let newsheet_id: u32 = xml
.lines()
.find(|l| l.contains("name=\"newsheet\""))
.and_then(|l| l.split("sheetId=\"").nth(1))
.and_then(|rest| rest.split('"').next())
.and_then(|id| id.parse().ok())
.expect("newsheet should have a numeric sheetId");
assert!(
newsheet_id > 7,
"fresh sheetId {newsheet_id} must not collide with the highest preserved original id (7)"
);
assert!(
xml.contains("r:id=\"rId3\""),
"newsheet's r:id must still be positional: {xml}"
);
assert_eq!(plans[0].output_part_name, "xl/worksheets/sheet2.xml");
assert!(plans[0].is_existing);
assert!(!plans[1].is_existing);
assert_ne!(plans[2].output_part_name, "xl/worksheets/sheet2.xml");
assert_ne!(plans[1].output_part_name, plans[2].output_part_name);
}
#[test]
fn build_xlsx_workbook_assigns_sequential_ids_when_no_sheet_has_a_known_origin() {
let origins = std::collections::HashMap::new();
let plans = plan_worksheet_output(&["a".to_string(), "b".to_string()], &origins, &[]);
let xml = build_xlsx_workbook(&plans, &OpaqueWorkbookFragments::default());
assert!(
xml.contains("<sheet name=\"a\" sheetId=\"1\" r:id=\"rId1\"/>"),
"{xml}"
);
assert!(
xml.contains("<sheet name=\"b\" sheetId=\"2\" r:id=\"rId2\"/>"),
"{xml}"
);
}
#[test]
fn merge_rect_to_a1_formats_a_multi_cell_range() {
assert_eq!(merge_rect_to_a1(&((1, 2), (1, 4))), "B1:D1");
}
#[test]
fn merge_rect_to_a1_formats_a_single_cell_range() {
assert_eq!(merge_rect_to_a1(&((3, 3), (3, 3))), "C3:C3");
}
}
#[cfg(test)]
mod diff_reader_tests {
use super::*;
use crate::reader::{SheetCell, read_workbook as rd};
use calamine::{Data, Reader, Xlsx, open_workbook, open_workbook_auto};
fn calamine_cell_to_variant(d: &Data) -> Option<Variant> {
match d {
Data::String(s) => Some(Variant::Str(s.clone())),
Data::Float(f) => {
if f.fract() == 0.0 && *f >= i64::MIN as f64 && *f <= i64::MAX as f64 {
Some(Variant::Integer(*f as i64))
} else {
Some(Variant::Float(*f))
}
}
Data::Bool(b) => Some(Variant::Boolean(*b)),
_ => None,
}
}
fn rd_cell_to_variant(c: &SheetCell) -> Variant {
match c {
SheetCell::Integer(n) => Variant::Integer(*n),
SheetCell::Float(f) => Variant::Float(*f),
SheetCell::Str(s) => Variant::Str(s.clone()),
SheetCell::Bool(b) => Variant::Boolean(*b),
SheetCell::Error(e) => Variant::Error(e.clone()),
}
}
fn calamine_xlsx_cells(
path: &str,
sheet: &str,
) -> std::collections::HashMap<(u32, u32), Variant> {
let mut wb: Xlsx<_> = open_workbook(path).unwrap();
let range = wb.worksheet_range(sheet).unwrap();
let (sr, sc) = range.start().unwrap_or((0, 0));
range
.cells()
.filter_map(|(r, c, d)| {
calamine_cell_to_variant(d).map(|v| ((r as u32 + sr + 1, c as u32 + sc + 1), v))
})
.collect()
}
fn rd_xlsx_cells(path: &str, sheet: &str) -> std::collections::HashMap<(u32, u32), Variant> {
rd(path)
.unwrap()
.into_iter()
.find(|s| s.name == sheet)
.unwrap()
.cells
.iter()
.map(|(&k, v)| (k, rd_cell_to_variant(v)))
.collect()
}
fn calamine_ods_cells(
path: &str,
sheet: &str,
) -> std::collections::HashMap<(u32, u32), Variant> {
let mut wb = open_workbook_auto(path).unwrap();
let range = wb.worksheet_range(sheet).unwrap();
let (sr, sc) = range.start().unwrap_or((0, 0));
range
.cells()
.filter_map(|(r, c, d)| {
calamine_cell_to_variant(d).map(|v| ((r as u32 + sr + 1, c as u32 + sc + 1), v))
})
.collect()
}
fn rd_ods_cells(path: &str, sheet: &str) -> std::collections::HashMap<(u32, u32), Variant> {
rd(path)
.unwrap()
.into_iter()
.find(|s| s.name == sheet)
.unwrap()
.cells
.iter()
.map(|(&k, v)| (k, rd_cell_to_variant(v)))
.collect()
}
#[test]
#[allow(clippy::approx_constant)]
fn diff_xlsx_all_types() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(42),
},
);
vm.cells_mut().insert(
(2, 1),
CellContent {
formula: None,
value: Variant::Str("hello".into()),
},
);
vm.cells_mut().insert(
(3, 1),
CellContent {
formula: None,
value: Variant::Float(3.14),
},
);
vm.cells_mut().insert(
(4, 1),
CellContent {
formula: None,
value: Variant::Boolean(true),
},
);
vm.cells_mut().insert(
(5, 1),
CellContent {
formula: None,
value: Variant::Str(" leading and trailing ".into()),
},
);
let path = "/tmp/elixcee_diff_xlsx.xlsx";
save_workbook_impl(&vm, path).unwrap();
let cal = calamine_xlsx_cells(path, "sheet1");
let mine = rd_xlsx_cells(path, "sheet1");
assert_eq!(cal, mine, "XLSX diff failed");
}
#[test]
fn diff_xlsx_multi_sheet() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(1),
},
);
vm.ensure_sheet("sheet2");
let prev = vm.active_sheet.clone();
vm.active_sheet = "sheet2".into();
vm.cells_mut().insert(
(2, 3),
CellContent {
formula: None,
value: Variant::Str("s2".into()),
},
);
vm.active_sheet = prev;
let path = "/tmp/elixcee_diff_multi.xlsx";
save_workbook_impl(&vm, path).unwrap();
for sheet in &["sheet1", "sheet2"] {
let cal = calamine_xlsx_cells(path, sheet);
let mine = rd_xlsx_cells(path, sheet);
assert_eq!(cal, mine, "XLSX multi-sheet diff failed for {}", sheet);
}
}
fn e2e_fixture(name: &str) -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/e2e")
.join(name)
.to_str()
.unwrap()
.to_string()
}
#[test]
fn diff_real_producer_xlsx() {
let path = e2e_fixture("source.xlsx");
let cal = calamine_xlsx_cells(&path, "source");
let mine = rd_xlsx_cells(&path, "source");
assert_eq!(cal, mine, "real-LibreOffice-produced XLSX diff failed");
assert_eq!(
mine.get(&(2, 4)),
Some(&Variant::Str("quote \" amp & lt < gt >".into())),
"named-entity decoding on a real producer's sharedStrings.xml"
);
assert_eq!(
mine.get(&(3, 4)),
Some(&Variant::Str("unicode: café ★ 日本語".into())),
"multi-run <si> (split across two <r><t> runs by a real producer) must concatenate"
);
assert!(
!mine.contains_key(&(4, 1)),
"row 4 is blank and dropped entirely from <sheetData> by the real producer"
);
assert_eq!(
mine.get(&(5, 4)),
Some(&Variant::Str("after-column-gap".into())),
"real content after 3 leading blank columns"
);
assert_eq!(
mine.get(&(9, 1)),
Some(&Variant::Str("Carol".into())),
"row numbering after 4 dropped blank rows (4,6,7,8) stays non-contiguous, not shifted"
);
}
#[test]
fn diff_real_producer_ods() {
let path = e2e_fixture("source.ods");
let cal = calamine_ods_cells(&path, "source");
let mine = rd_ods_cells(&path, "source");
assert_eq!(cal, mine, "real-LibreOffice-produced ODS diff failed");
assert_eq!(
mine.get(&(2, 4)),
Some(&Variant::Str("quote \" amp & lt < gt >".into()))
);
assert_eq!(
mine.get(&(5, 4)),
Some(&Variant::Str("after-column-gap".into())),
"table:number-columns-repeated=\"3\" followed by real content in the same row must not shift its column"
);
assert_eq!(
mine.get(&(9, 1)),
Some(&Variant::Str("Carol".into())),
"table:number-rows-repeated=\"3\" must advance the row counter by 3, not 1"
);
}
#[test]
fn diff_ods_all_types() {
let mut vm = Vm::new();
vm.cells_mut().insert(
(1, 1),
CellContent {
formula: None,
value: Variant::Integer(42),
},
);
vm.cells_mut().insert(
(1, 2),
CellContent {
formula: None,
value: Variant::Str("hello".into()),
},
);
vm.cells_mut().insert(
(2, 1),
CellContent {
formula: None,
value: Variant::Boolean(true),
},
);
vm.cells_mut().insert(
(3, 1),
CellContent {
formula: None,
value: Variant::Float(1.5),
},
);
vm.cells_mut().insert(
(4, 1),
CellContent {
formula: None,
value: Variant::Str(" padded ".into()),
},
);
let path = "/tmp/elixcee_diff_ods.ods";
save_workbook_impl(&vm, path).unwrap();
let cal = calamine_ods_cells(path, "sheet1");
let mine = rd_ods_cells(path, "sheet1");
assert_eq!(cal, mine, "ODS diff failed");
}
}