use crate::workbook::{check, check_abi_version};
use crate::{
Error, WriteOptions, XlColumn, XlColumnSpec, XlTable, XL_FORMAT_AUTO, XL_FORMAT_CSV,
XL_FORMAT_XLS, XL_FORMAT_XLSB, XL_FORMAT_XLSX, XL_INVALID_ARGUMENT, XL_T_BOOL, XL_T_DATE,
XL_T_F64, XL_T_I64, XL_T_STRING, XL_T_TIME, XL_T_TIMESTAMP,
};
use std::os::raw::c_void;
pub enum ColumnData<'a> {
Str {
offsets: &'a [i32],
data: &'a [u8],
},
I64(&'a [i64]),
F64(&'a [f64]),
Bool(&'a [u8]),
Date(&'a [i32]),
Time(&'a [i64]),
Timestamp(&'a [i64]),
}
impl ColumnData<'_> {
#[must_use]
pub fn len(&self) -> i64 {
match self {
ColumnData::Str { offsets, .. } => (offsets.len().max(1) - 1) as i64,
ColumnData::I64(values) | ColumnData::Time(values) | ColumnData::Timestamp(values) => {
values.len() as i64
}
ColumnData::F64(values) => values.len() as i64,
ColumnData::Bool(values) => values.len() as i64,
ColumnData::Date(values) => values.len() as i64,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn xl_type(&self) -> i32 {
match self {
ColumnData::Str { .. } => XL_T_STRING,
ColumnData::I64(_) => XL_T_I64,
ColumnData::F64(_) => XL_T_F64,
ColumnData::Bool(_) => XL_T_BOOL,
ColumnData::Date(_) => XL_T_DATE,
ColumnData::Time(_) => XL_T_TIME,
ColumnData::Timestamp(_) => XL_T_TIMESTAMP,
}
}
fn pointers(&self) -> (*const c_void, *const u8, i64) {
match self {
ColumnData::Str { offsets, data } => (
offsets.as_ptr().cast::<c_void>(),
data.as_ptr(),
data.len() as i64,
),
ColumnData::I64(v) | ColumnData::Time(v) | ColumnData::Timestamp(v) => {
(v.as_ptr().cast::<c_void>(), std::ptr::null(), 0)
}
ColumnData::F64(v) => (v.as_ptr().cast::<c_void>(), std::ptr::null(), 0),
ColumnData::Bool(v) => (v.as_ptr().cast::<c_void>(), std::ptr::null(), 0),
ColumnData::Date(v) => (v.as_ptr().cast::<c_void>(), std::ptr::null(), 0),
}
}
}
pub struct Column<'a> {
pub name: Option<&'a str>,
pub data: ColumnData<'a>,
pub validity: Option<&'a [u8]>,
}
impl Column<'_> {
fn to_raw(&self) -> XlColumn {
let (values, data, data_len) = self.data.pointers();
XlColumn {
r#type: self.data.xl_type(),
length: self.data.len(),
values,
validity: self.validity.map_or(std::ptr::null(), <[u8]>::as_ptr),
data,
data_len,
}
}
}
#[must_use]
pub fn format_from_path(path: &str) -> i32 {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let Some((_, extension)) = name.rsplit_once('.') else {
return XL_FORMAT_AUTO;
};
match extension.to_ascii_lowercase().as_str() {
"xlsx" => XL_FORMAT_XLSX,
"xlsb" => XL_FORMAT_XLSB,
"xls" => XL_FORMAT_XLS,
"csv" => XL_FORMAT_CSV,
_ => XL_FORMAT_AUTO,
}
}
fn invalid(message: String) -> Error {
Error::from_status(XL_INVALID_ARGUMENT, message)
}
fn validate(columns: &[Column<'_>]) -> Result<i64, Error> {
let Some(first) = columns.first() else {
return Err(invalid(
"write_columns needs at least one column.".to_string(),
));
};
let rows = first.data.len();
let has_header = first.name.is_some();
for (index, column) in columns.iter().enumerate() {
if column.data.len() != rows {
return Err(invalid(format!(
"every column must have the same length; column 0 has {rows} rows but column \
{index} has {}",
column.data.len()
)));
}
if column.name.is_some() != has_header {
return Err(invalid(format!(
"every column must have a name, or none may - xl_write_typed cannot write a \
partial header row (column {index})"
)));
}
if let Some(bitmap) = column.validity {
let needed = (rows as usize).div_ceil(8);
if bitmap.len() < needed {
return Err(invalid(format!(
"the validity bitmap is {} bytes, but {rows} rows need {needed} (column \
{index})",
bitmap.len()
)));
}
}
if let ColumnData::Str { offsets, data } = &column.data {
if data.len() > i32::MAX as usize {
return Err(invalid(format!(
"the string blob is larger than 2 GiB, which int32 offsets cannot address \
(column {index})"
)));
}
if offsets.len() as i64 != rows + 1 {
return Err(invalid(format!(
"a string column needs {} offsets for {rows} rows; column {index} has {}",
rows + 1,
offsets.len()
)));
}
}
}
Ok(rows)
}
pub fn write_columns(
path: &str,
format: i32,
columns: &[Column<'_>],
options: Option<&WriteOptions>,
) -> Result<(), Error> {
check_abi_version()?;
let row_count = validate(columns)?;
let names: Vec<*const u8> = columns
.iter()
.map(|c| c.name.map_or(std::ptr::null(), str::as_ptr))
.collect();
let name_lens: Vec<i32> = columns
.iter()
.map(|c| c.name.map_or(0, |n| n.len() as i32))
.collect();
let specs: Vec<XlColumnSpec> = columns
.iter()
.enumerate()
.map(|(index, column)| XlColumnSpec {
names: &names[index],
name_lens: &name_lens[index],
name_count: i32::from(column.name.is_some()),
index: 0,
r#type: column.data.xl_type(),
nullable: 0,
})
.collect();
let raw_columns: Vec<XlColumn> = columns.iter().map(Column::to_raw).collect();
let table = XlTable {
column_count: columns.len() as i32,
row_count,
columns: raw_columns.as_ptr().cast_mut(),
};
let raw_options = options.map(WriteOptions::to_raw);
let options_ptr = crate::options::ptr_or_null(&raw_options);
let status = unsafe {
crate::xl_write_typed(
path.as_ptr(),
path.len() as i32,
format,
specs.as_ptr(),
&table,
options_ptr,
)
};
check(status)
}
pub fn write_columns_to_memory(
format: i32,
columns: &[Column<'_>],
options: Option<&WriteOptions>,
) -> Result<Vec<u8>, Error> {
check_abi_version()?;
let row_count = validate(columns)?;
let names: Vec<*const u8> = columns
.iter()
.map(|c| c.name.map_or(std::ptr::null(), str::as_ptr))
.collect();
let name_lens: Vec<i32> = columns
.iter()
.map(|c| c.name.map_or(0, |n| n.len() as i32))
.collect();
let specs: Vec<XlColumnSpec> = columns
.iter()
.enumerate()
.map(|(index, column)| XlColumnSpec {
names: &names[index],
name_lens: &name_lens[index],
name_count: i32::from(column.name.is_some()),
index: 0,
r#type: column.data.xl_type(),
nullable: 0,
})
.collect();
let raw_columns: Vec<XlColumn> = columns.iter().map(Column::to_raw).collect();
let table = XlTable {
column_count: columns.len() as i32,
row_count,
columns: raw_columns.as_ptr().cast_mut(),
};
let raw_options = options.map(WriteOptions::to_raw);
let options_ptr = crate::options::ptr_or_null(&raw_options);
let mut buffer = crate::XlBuffer {
data: std::ptr::null_mut(),
len: 0,
};
let status = unsafe {
crate::xl_write_typed_to_memory(format, specs.as_ptr(), &table, options_ptr, &mut buffer)
};
check(status)?;
Ok(crate::workbook::buffer_to_vec(buffer))
}
pub enum OwnedColumnData {
Str { offsets: Vec<i32>, data: Vec<u8> },
I64(Vec<i64>),
F64(Vec<f64>),
Bool(Vec<u8>),
Date(Vec<i32>),
Time(Vec<i64>),
Timestamp(Vec<i64>),
}
pub struct OwnedColumn {
pub name: Option<&'static str>,
pub data: OwnedColumnData,
pub validity: Option<Vec<u8>>,
}
impl OwnedColumn {
#[must_use]
pub fn as_column(&self) -> Column<'_> {
let data = match &self.data {
OwnedColumnData::Str { offsets, data } => ColumnData::Str { offsets, data },
OwnedColumnData::I64(v) => ColumnData::I64(v),
OwnedColumnData::F64(v) => ColumnData::F64(v),
OwnedColumnData::Bool(v) => ColumnData::Bool(v),
OwnedColumnData::Date(v) => ColumnData::Date(v),
OwnedColumnData::Time(v) => ColumnData::Time(v),
OwnedColumnData::Timestamp(v) => ColumnData::Timestamp(v),
};
Column {
name: self.name,
data,
validity: self.validity.as_deref(),
}
}
}
fn offset_ceiling_exceeded(current: usize, added: usize) -> bool {
current.saturating_add(added) > i32::MAX as usize
}
pub fn push_str(offsets: &mut Vec<i32>, data: &mut Vec<u8>, value: &str) -> Result<(), Error> {
if offset_ceiling_exceeded(data.len(), value.len()) {
return Err(invalid(
"a string column exceeds 2 GiB, which int32 offsets cannot address.".to_string(),
));
}
data.extend_from_slice(value.as_bytes());
offsets.push(data.len() as i32);
Ok(())
}
pub fn set_valid(validity: &mut [u8], row: usize) {
validity[row / 8] |= 1 << (row % 8);
}
pub trait ExcelWriter: Sized {
fn to_columns(rows: &[Self]) -> Result<Vec<OwnedColumn>, Error>;
}
pub fn write_sheet<T: ExcelWriter>(
path: &str,
format: i32,
rows: &[T],
options: Option<&WriteOptions>,
) -> Result<(), Error> {
let owned = T::to_columns(rows)?;
let borrowed: Vec<Column<'_>> = owned.iter().map(OwnedColumn::as_column).collect();
write_columns(path, format, &borrowed, options)
}
pub fn write_sheet_to_memory<T: ExcelWriter>(
format: i32,
rows: &[T],
options: Option<&WriteOptions>,
) -> Result<Vec<u8>, Error> {
let owned = T::to_columns(rows)?;
let borrowed: Vec<Column<'_>> = owned.iter().map(OwnedColumn::as_column).collect();
write_columns_to_memory(format, &borrowed, options)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_is_inferred_from_the_extension() {
assert_eq!(format_from_path("out.xlsx"), XL_FORMAT_XLSX);
assert_eq!(format_from_path("out.XLSB"), XL_FORMAT_XLSB);
assert_eq!(format_from_path("out.xls"), XL_FORMAT_XLS);
assert_eq!(format_from_path("out.csv"), XL_FORMAT_CSV);
assert_eq!(format_from_path("out.txt"), XL_FORMAT_AUTO);
assert_eq!(format_from_path("out"), XL_FORMAT_AUTO);
assert_eq!(format_from_path("v1.2/report"), XL_FORMAT_AUTO);
}
#[test]
fn validate_rejects_a_short_validity_bitmap() {
let values = [1i64, 2, 3, 4, 5, 6, 7, 8, 9];
let bitmap = [0u8]; let columns = [Column {
name: Some("a"),
data: ColumnData::I64(&values),
validity: Some(&bitmap),
}];
let error = validate(&columns).expect_err("a 1-byte bitmap cannot cover 9 rows");
assert_eq!(error.code(), XL_INVALID_ARGUMENT);
}
#[test]
fn validate_accepts_an_exactly_sized_validity_bitmap() {
let values = [1i64; 8];
let bitmap = [0xFFu8];
let columns = [Column {
name: Some("a"),
data: ColumnData::I64(&values),
validity: Some(&bitmap),
}];
assert_eq!(validate(&columns).expect("8 rows fit in 1 byte"), 8);
}
#[test]
fn validate_rejects_a_string_column_with_the_wrong_offset_count() {
let offsets = [0i32, 3];
let columns = [Column {
name: Some("a"),
data: ColumnData::Str {
offsets: &offsets,
data: b"abc",
},
validity: None,
}];
assert_eq!(validate(&columns).expect("1 row, 2 offsets"), 1);
let bad = [0i32, 3, 6, 9];
let mixed = [
Column {
name: Some("a"),
data: ColumnData::I64(&[1, 2]),
validity: None,
},
Column {
name: Some("b"),
data: ColumnData::Str {
offsets: &bad,
data: b"abcdefghi",
},
validity: None,
},
];
assert!(
validate(&mixed).is_err(),
"3 rows next to a 2-row column must be rejected"
);
}
#[test]
fn push_str_appends_bytes_and_one_offset_per_value() {
let mut offsets = vec![0i32];
let mut data = Vec::new();
push_str(&mut offsets, &mut data, "uma").expect("a short string must fit");
push_str(&mut offsets, &mut data, "").expect("an empty string must fit");
push_str(&mut offsets, &mut data, "duas").expect("a short string must fit");
assert_eq!(offsets, vec![0, 3, 3, 7]);
assert_eq!(data, b"umaduas");
}
#[test]
fn the_offset_ceiling_is_exceeded_exactly_at_int32_max() {
let ceiling = i32::MAX as usize;
assert!(!offset_ceiling_exceeded(0, 0));
assert!(
!offset_ceiling_exceeded(ceiling - 1, 1),
"landing exactly on i32::MAX fits"
);
assert!(
offset_ceiling_exceeded(ceiling, 1),
"one byte past i32::MAX does not"
);
assert!(offset_ceiling_exceeded(ceiling - 1, 2));
}
#[test]
fn set_valid_sets_the_lsb_first_bit_for_a_row() {
let mut bitmap = vec![0u8; 2];
set_valid(&mut bitmap, 0);
set_valid(&mut bitmap, 2);
set_valid(&mut bitmap, 9);
assert_eq!(bitmap, vec![0b0000_0101, 0b0000_0010]);
}
}