use datagrid_core::{
AggregateKind, CellAlign, CellFormat, ExportAggregate, ExportColumn, ExportRow, ExportTable,
Value,
};
use rust_xlsxwriter::{ExcelDateTime, Format, FormatAlign, FormatBorder, Workbook, Worksheet};
use std::fmt;
const MAX_CELL_CHARS: usize = 32_767;
const MAX_OUTLINE_LEVELS: usize = 7;
const WIDTH_SAMPLE_ROWS: usize = 1_000;
const WIDTH_RANGE: (f64, f64) = (8.0, 60.0);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct XlsxOptions {
pub sheet_name: String,
pub header: bool,
pub autofilter: bool,
pub auto_width: bool,
pub outline: bool,
}
impl Default for XlsxOptions {
fn default() -> Self {
Self {
sheet_name: "Export".to_owned(),
header: true,
autofilter: true,
auto_width: true,
outline: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct XlsxError {
message: String,
}
impl fmt::Display for XlsxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for XlsxError {}
impl From<rust_xlsxwriter::XlsxError> for XlsxError {
fn from(error: rust_xlsxwriter::XlsxError) -> Self {
Self {
message: error.to_string(),
}
}
}
pub fn to_xlsx(table: &ExportTable, options: &XlsxOptions) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
sheet.set_name(sheet_name(&options.sheet_name))?;
write_sheet(sheet, table, options)?;
Ok(workbook.save_to_buffer()?)
}
fn sheet_name(wanted: &str) -> String {
let cleaned: String = wanted
.chars()
.map(|c| if "[]:*?/\\".contains(c) { '_' } else { c })
.collect();
let trimmed = cleaned.trim_matches('\'');
let name: String = trimmed.chars().take(31).collect();
if name.trim().is_empty() {
"Sheet1".to_owned()
} else if name.eq_ignore_ascii_case("history") {
"History_".to_owned()
} else {
name
}
}
struct Style {
value: Format,
bold: Format,
date: Format,
date_time: Format,
count: Format,
custom: Format,
}
impl Style {
fn new(column: &ExportColumn, symbol_after: bool) -> Self {
let align = match column.align {
CellAlign::Start => None,
CellAlign::Center => Some(FormatAlign::Center),
CellAlign::End => Some(FormatAlign::Right),
};
let with = |number: Option<&str>| {
let mut format = Format::new();
if let Some(align) = align {
format = format.set_align(align);
}
if let Some(number) = number {
format = format.set_num_format(number);
}
format
};
let number = number_format(&column.format, symbol_after);
let value = with(number.as_deref());
let is_date = matches!(
column.format,
CellFormat::Date | CellFormat::DateTime | CellFormat::DatePattern(_)
) && number.is_some();
let (date, date_time) = if is_date {
(value.clone(), value.clone())
} else {
(with(Some("yyyy-mm-dd")), with(Some("yyyy-mm-dd hh:mm:ss")))
};
Self {
bold: value.clone().set_bold(),
count: with(Some("0")).set_bold(),
custom: Format::new().set_bold(),
value,
date,
date_time,
}
}
}
fn number_format(format: &CellFormat, symbol_after: bool) -> Option<String> {
let decimals = |count: u8| {
let count = usize::from(count.min(30));
if count == 0 {
String::new()
} else {
format!(".{}", "0".repeat(count))
}
};
match format {
CellFormat::Plain => None,
CellFormat::Number { decimals: count } => Some(format!("#,##0{}", decimals(*count))),
CellFormat::Currency {
symbol,
decimals: count,
} => {
let body = format!("#,##0{}", decimals(*count));
let symbol = format!("\"{}\"", symbol.replace('"', ""));
Some(if symbol_after {
format!("{body} {symbol}")
} else {
format!("{symbol}{body}")
})
}
CellFormat::Percent { decimals: count } => Some(format!("0{}%", decimals(*count))),
CellFormat::Date => Some("yyyy-mm-dd".to_owned()),
CellFormat::DateTime => Some("yyyy-mm-dd hh:mm".to_owned()),
CellFormat::DatePattern(pattern) => date_pattern(pattern),
}
}
fn date_pattern(pattern: &str) -> Option<String> {
let mut out = String::new();
let mut chars = pattern.chars();
while let Some(c) = chars.next() {
if c != '%' {
if c.is_alphanumeric() {
out.push('\\');
}
out.push(c);
continue;
}
out.push_str(match chars.next()? {
'Y' => "yyyy",
'y' => "yy",
'm' => "mm",
'd' => "dd",
'e' => "d",
'B' => "mmmm",
'b' => "mmm",
'A' => "dddd",
'a' => "ddd",
'H' => "hh",
'M' => "mm",
'S' => "ss",
'F' => "yyyy-mm-dd",
'T' => "hh:mm:ss",
'%' => "%",
_ => return None,
});
}
Some(out)
}
fn symbol_after(table: &ExportTable, index: usize, symbol: &str) -> bool {
table
.rows
.iter()
.find_map(|row| match row {
ExportRow::Data(cells) => cells.get(index).filter(|cell| !cell.text.is_empty()),
_ => None,
})
.is_some_and(|cell| cell.text.trim_end().ends_with(symbol))
}
fn column(index: usize) -> Result<u16, XlsxError> {
u16::try_from(index).map_err(|_| XlsxError {
message: format!("column {} is past the last column of a sheet", index + 1),
})
}
fn clip(text: &str) -> String {
text.chars().take(MAX_CELL_CHARS).collect()
}
fn exact_float(number: i64) -> Option<f64> {
const LIMIT: i64 = 1 << 53;
#[allow(clippy::cast_precision_loss)]
(-LIMIT..=LIMIT).contains(&number).then_some(number as f64)
}
fn excel_date(value: &Value) -> Option<ExcelDateTime> {
let parts = value.date_parts()?;
let day =
ExcelDateTime::from_ymd(u16::try_from(parts.year).ok()?, parts.month, parts.day).ok()?;
match parts.time {
None => Some(day),
Some((hour, minute, second)) => day.and_hms(u16::from(hour), minute, second).ok(),
}
}
fn write_value(
sheet: &mut Worksheet,
row: u32,
col: u16,
value: &Value,
shown: &str,
style: &Style,
) -> Result<(), XlsxError> {
if let Some(parts) = value.date_parts() {
match excel_date(value) {
Some(date) => {
let format = if parts.time.is_some() {
&style.date_time
} else {
&style.date
};
sheet.write_datetime_with_format(row, col, &date, format)?;
}
None => {
sheet.write_string_with_format(row, col, clip(shown), &style.value)?;
}
}
return Ok(());
}
match value {
Value::Int(number) => match exact_float(*number) {
Some(float) => {
sheet.write_number_with_format(row, col, float, &style.value)?;
}
None => {
sheet.write_string_with_format(row, col, clip(shown), &style.value)?;
}
},
Value::Float(number) if number.is_finite() => {
sheet.write_number_with_format(row, col, *number, &style.value)?;
}
Value::Bool(flag) => {
sheet.write_boolean_with_format(row, col, *flag, &style.value)?;
}
Value::Text(text) => {
sheet.write_string_with_format(row, col, clip(text), &style.value)?;
}
_ => {
sheet.write_string_with_format(row, col, clip(shown), &style.value)?;
}
}
Ok(())
}
fn aggregate_text(aggregates: &[ExportAggregate]) -> String {
aggregates
.iter()
.map(|aggregate| format!("{} {}", aggregate.name, aggregate.text))
.collect::<Vec<_>>()
.join("; ")
}
fn write_aggregate(
sheet: &mut Worksheet,
row: u32,
col: u16,
aggregate: &ExportAggregate,
style: &Style,
) -> Result<(), XlsxError> {
let format = match aggregate.kind {
AggregateKind::Count => &style.count,
AggregateKind::Custom(_) => &style.custom,
_ => &style.bold,
};
let number = match &aggregate.value {
Some(Value::Int(number)) => exact_float(*number),
Some(Value::Float(number)) if number.is_finite() => Some(*number),
_ => None,
};
match number {
Some(number) => sheet.write_number_with_format(row, col, number, format)?,
None => sheet.write_string_with_format(
row,
col,
clip(&format!("{} {}", aggregate.name, aggregate.text)),
&style.bold,
)?,
};
Ok(())
}
type Lead<'a> = (&'a str, &'a Format, &'a str);
fn write_aggregates(
sheet: &mut Worksheet,
row: u32,
columns: &[Vec<ExportAggregate>],
lead: Option<Lead<'_>>,
styles: &[Style],
) -> Result<(), XlsxError> {
for (index, (aggregates, style)) in columns.iter().zip(styles).enumerate() {
let col = column(index)?;
if index == 0 && (lead.is_some() || !aggregates.is_empty()) {
let aggregated = aggregate_text(aggregates);
let (text, format) = match lead {
Some((lead, format, _)) if aggregated.is_empty() => (lead.to_owned(), format),
Some((lead, format, joiner)) => (format!("{lead}{joiner}{aggregated}"), format),
None => (aggregated, &style.bold),
};
sheet.write_string_with_format(row, col, clip(&text), format)?;
continue;
}
match aggregates.as_slice() {
[] => {}
[one] => write_aggregate(sheet, row, col, one, style)?,
_ => {
sheet.write_string_with_format(
row,
col,
clip(&aggregate_text(aggregates)),
&style.bold,
)?;
}
}
}
Ok(())
}
fn close_groups(
open: &mut Vec<(usize, u32)>,
level: usize,
last: u32,
spans: &mut Vec<(u32, u32)>,
) {
while let Some(&(opened_at, first)) = open.last() {
if opened_at < level {
break;
}
open.pop();
if first <= last && opened_at < MAX_OUTLINE_LEVELS {
spans.push((first, last));
}
}
}
fn write_header(
sheet: &mut Worksheet,
table: &ExportTable,
heading: &Format,
) -> Result<u32, XlsxError> {
let width = table.columns.len();
let levels = table
.columns
.iter()
.map(|column| column.group_path.len())
.max()
.unwrap_or(0);
let mut row: u32 = 0;
for level in 0..levels {
let mut runs: Vec<(usize, usize, &str)> = Vec::new();
for (index, spec) in table.columns.iter().enumerate() {
let Some(label) = spec.group_path.get(level) else {
continue;
};
let same_group = |previous: &(usize, usize, &str)| {
previous.1 + 1 == index
&& table
.columns
.get(previous.0)
.and_then(|first| first.group_path.get(..=level))
== spec.group_path.get(..=level)
};
match runs.last_mut() {
Some(previous) if same_group(previous) => previous.1 = index,
_ => runs.push((index, index, label)),
}
}
for (first, last, label) in runs {
if last > first {
sheet.merge_range(
row,
column(first)?,
row,
column(last)?,
&clip(label),
heading,
)?;
} else {
sheet.write_string_with_format(row, column(first)?, clip(label), heading)?;
}
}
row += 1;
}
for (index, spec) in table.columns.iter().enumerate() {
sheet.write_string_with_format(row, column(index)?, clip(&spec.label), heading)?;
}
if width > 0 {
sheet.set_freeze_panes(row + 1, 0)?;
}
Ok(row)
}
fn write_sheet(
sheet: &mut Worksheet,
table: &ExportTable,
options: &XlsxOptions,
) -> Result<(), XlsxError> {
let width = table.columns.len();
let styles: Vec<Style> = table
.columns
.iter()
.enumerate()
.map(|(index, column)| {
let after = match &column.format {
CellFormat::Currency { symbol, .. } => symbol_after(table, index, symbol),
_ => false,
};
Style::new(column, after)
})
.collect();
let mut widths: Vec<usize> = table
.columns
.iter()
.map(|column| column.label.chars().count())
.collect();
let bold = Format::new().set_bold();
let heading = bold.clone().set_border_bottom(FormatBorder::Thin);
let (label_row, mut row) = if options.header {
let label_row = write_header(sheet, table, &heading)?;
(label_row, label_row + 1)
} else {
(0, 0)
};
let first_data_row = row;
let mut open: Vec<(usize, u32)> = Vec::new();
let mut spans: Vec<(u32, u32)> = Vec::new();
let mut only_data = true;
let mut sampled = 0;
for table_row in &table.rows {
match table_row {
ExportRow::Data(cells) => {
for (index, (cell, style)) in cells.iter().zip(&styles).enumerate() {
if let Some(value) = &cell.value {
write_value(sheet, row, column(index)?, value, &cell.text, style)?;
}
if sampled < WIDTH_SAMPLE_ROWS
&& let Some(slot) = widths.get_mut(index)
{
*slot = (*slot).max(cell.text.chars().count());
}
}
sampled += 1;
row += 1;
}
ExportRow::GroupHeader {
level,
caption,
expanded,
aggregates,
..
} => {
only_data = false;
if options.outline {
close_groups(&mut open, *level, row.saturating_sub(1), &mut spans);
}
let indent = u8::try_from(*level).unwrap_or(u8::MAX);
let lead = bold.clone().set_indent(indent);
if aggregates.is_empty() {
sheet.write_string_with_format(row, 0, clip(caption), &lead)?;
} else {
write_aggregates(
sheet,
row,
aggregates,
Some((caption, &lead, "; ")),
&styles,
)?;
}
if options.outline && *expanded {
open.push((*level, row + 1));
}
row += 1;
}
ExportRow::Aggregates {
level,
label,
cells,
} => {
only_data = false;
if level.is_none() && options.outline {
close_groups(&mut open, 0, row.saturating_sub(1), &mut spans);
}
let lead = label.as_deref().map(|label| (label, &bold, ": "));
write_aggregates(sheet, row, cells, lead, &styles)?;
if let (Some(level), true) = (level, options.outline) {
close_groups(&mut open, *level, row, &mut spans);
}
row += 1;
}
_ => {}
}
}
if options.outline {
close_groups(&mut open, 0, row.saturating_sub(1), &mut spans);
}
if !spans.is_empty() {
sheet.group_symbols_above(true);
for (first, last) in spans {
sheet.group_rows(first, last)?;
}
}
if options.autofilter && options.header && only_data && row > first_data_row && width > 0 {
sheet.autofilter(label_row, 0, row - 1, column(width - 1)?)?;
}
if options.auto_width {
for (index, chars) in widths.into_iter().enumerate() {
#[allow(clippy::cast_precision_loss)]
let wanted = (chars as f64 + 2.0).clamp(WIDTH_RANGE.0, WIDTH_RANGE.1);
sheet.set_column_width(column(index)?, wanted)?;
}
}
Ok(())
}