use crate::grid::ExportColumn;
use crate::i18n::NumberFormat;
use crate::time::{Date, LocalDateTime, Time};
use crate::value::{Currency, FieldKind, Value};
use rust_xlsxwriter::{ExcelDateTime, Format, Workbook, Worksheet, XlsxError};
use std::io::{Read, Seek, Write};
pub const MAX_XLSX_BYTES: usize = 8 * 1024 * 1024;
const MAX_UNPACKED: u64 = 64 * 1024 * 1024;
const MAX_PARTS: usize = 1_000;
const MAX_CELL_CHARS: usize = 32_767;
const MAX_COLUMNS: usize = 512;
const MAX_SHARED_STRINGS: u64 = 16 * 1024 * 1024;
struct Formats {
head: Format,
money: [Format; 3],
decimal: Format,
date: Format,
time: Format,
local: Format,
}
impl Formats {
fn new(locale: &str) -> Formats {
let first = NumberFormat::for_locale(locale).symbol_first;
let money = |c: Currency| {
let s = c.symbol();
let code = if first {
format!("\"{s}\"#,##0.00;-\"{s}\"#,##0.00")
} else {
format!("#,##0.00 \"{s}\";-#,##0.00 \"{s}\"")
};
Format::new().set_num_format(code)
};
let (order, sep, pad) = Date::pattern(locale);
let (d, m) = if pad { ("dd", "mm") } else { ("d", "m") };
let date = match order {
"dmy" => format!("{d}{sep}{m}{sep}yyyy"),
"mdy" => format!("{m}{sep}{d}{sep}yyyy"),
_ => "yyyy-mm-dd".to_string(),
};
let time = if Time::twelve_hour(locale) {
"h:mm:ss AM/PM"
} else {
"hh:mm:ss"
};
Formats {
head: Format::new().set_bold(),
money: [
money(Currency::Try),
money(Currency::Usd),
money(Currency::Eur),
],
decimal: Format::new().set_num_format("#,##0.00"),
local: Format::new().set_num_format(format!("{date} {time}")),
date: Format::new().set_num_format(date),
time: Format::new().set_num_format(time),
}
}
}
pub struct XlsxSheet {
workbook: Workbook,
sheet: Worksheet,
formats: Formats,
row: u32,
}
impl XlsxSheet {
pub fn new(columns: &[ExportColumn], locale: &str) -> Result<XlsxSheet, XlsxError> {
let mut workbook = Workbook::new();
let mut sheet = workbook.new_worksheet_with_constant_memory()?;
let formats = Formats::new(locale);
for (i, c) in columns.iter().enumerate() {
let col = i as u16;
if let Some(w) = c.width {
sheet.set_column_width_pixels(col, w)?;
}
sheet.write_string_with_format(0, col, clip(&c.label), &formats.head)?;
}
sheet.set_freeze_panes(1, 0)?;
Ok(XlsxSheet {
workbook,
sheet,
formats,
row: 1,
})
}
pub fn write_rows(&mut self, rows: &[Vec<Value>]) -> Result<(), XlsxError> {
for values in rows {
for (i, v) in values.iter().enumerate() {
self.write(i as u16, v)?;
}
self.row += 1;
}
Ok(())
}
fn write(&mut self, col: u16, value: &Value) -> Result<(), XlsxError> {
let (row, f, s) = (self.row, &self.formats, &mut self.sheet);
match value {
Value::Null => {}
Value::Bool(b) => {
s.write_boolean(row, col, *b)?;
}
Value::Int(n) => {
s.write_number(row, col, *n as f64)?;
}
Value::Decimal(x) => {
s.write_number_with_format(row, col, *x, &f.decimal)?;
}
Value::Number(d) => {
s.write_number(row, col, d.to_f64())?;
}
Value::Money(m) => {
let format = &f.money[m.currency as usize];
s.write_number_with_format(row, col, m.minor as f64 / 100.0, format)?;
}
Value::Text(t) => {
s.write_string(row, col, clip(t))?;
}
Value::Choice(c) => {
s.write_string(row, col, *c)?;
}
Value::Date(d) => {
s.write_datetime_with_format(row, col, excel_date(d)?, &f.date)?;
}
Value::Time(t) => {
let t = ExcelDateTime::from_hms(t.hour().into(), t.minute(), t.second())?;
s.write_datetime_with_format(row, col, t, &f.time)?;
}
Value::LocalDateTime(l) => {
let at = excel_date(&l.date)?.and_hms(
l.time.hour().into(),
l.time.minute(),
l.time.second(),
)?;
s.write_datetime_with_format(row, col, at, &f.local)?;
}
Value::DateTime(d) => {
let l = d.local(crate::time::TimeZone::default());
let at = excel_date(&l.date)?.and_hms(
l.time.hour().into(),
l.time.minute(),
l.time.second(),
)?;
s.write_datetime_with_format(row, col, at, &f.local)?;
}
}
Ok(())
}
pub fn rows(&self) -> u32 {
self.row - 1
}
pub fn finish<W: Write + Send>(mut self, out: W) -> Result<(), XlsxError> {
self.workbook.push_worksheet(self.sheet);
self.workbook.save_to_writer(out)
}
}
fn excel_date(d: &Date) -> Result<ExcelDateTime, XlsxError> {
ExcelDateTime::from_ymd(d.year().clamp(1900, 9999) as u16, d.month(), d.day())
}
fn clip(text: &str) -> &str {
match text.char_indices().nth(MAX_CELL_CHARS) {
Some((i, _)) => &text[..i],
None => text,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum XlsxReadError {
Unreadable,
TooLarge,
TooMany,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum XlsxCell {
Empty,
Text(String),
Number(f64),
Bool(bool),
Moment(u16, u8, u8, u8, u8, u8, bool),
}
pub(crate) fn read(
bytes: &[u8],
max_rows: usize,
max_columns: usize,
) -> Result<(Vec<String>, Vec<Vec<XlsxCell>>), XlsxReadError> {
if bytes.len() > MAX_XLSX_BYTES {
return Err(XlsxReadError::TooLarge);
}
check_unpacked(std::io::Cursor::new(bytes))?;
use calamine::Reader;
let mut book =
calamine::Xlsx::new(std::io::Cursor::new(bytes)).map_err(|_| XlsxReadError::Unreadable)?;
let first = book
.sheet_names()
.into_iter()
.next()
.ok_or(XlsxReadError::Unreadable)?;
let mut cells = book
.worksheet_cells_reader(&first)
.map_err(|_| XlsxReadError::Unreadable)?;
let mut rows: Vec<Vec<XlsxCell>> = Vec::new();
let mut current: Option<(u32, Vec<XlsxCell>)> = None;
let push = |rows: &mut Vec<Vec<XlsxCell>>, row: Vec<XlsxCell>| -> Result<(), XlsxReadError> {
if row
.iter()
.any(|c| *c != XlsxCell::Empty && *c != XlsxCell::Text(String::new()))
{
if rows.len() > max_rows {
return Err(XlsxReadError::TooMany);
}
rows.push(row);
}
Ok(())
};
while let Some(cell) = cells
.next_cell()
.map_err(|_| XlsxReadError::Unreadable)?
{
let (r, c) = cell.get_position();
if current
.as_ref()
.is_some_and(|(at, _)| *at != r)
{
let (_, row) = current.take().unwrap_or_default();
push(&mut rows, row)?;
if rows.len() == 1 {
let header = &mut rows[0];
while header
.last()
.is_some_and(|c| *c == XlsxCell::Empty)
{
header.pop();
}
if header.len() > max_columns {
let header = rows
.remove(0)
.into_iter()
.map(|c| text_of(&c))
.collect();
return Ok((header, Vec::new()));
}
}
}
let width = rows
.first()
.map_or(MAX_COLUMNS, Vec::len);
let row = &mut current
.get_or_insert_with(|| (r, Vec::new()))
.1;
let c = (c as usize).min(width);
if row.len() <= c {
row.resize(c + 1, XlsxCell::Empty);
}
let value = cell_of(cell.get_value());
if row[c] == XlsxCell::Empty {
row[c] = value;
}
}
if let Some((_, row)) = current {
push(&mut rows, row)?;
}
if rows.is_empty() {
return Ok((Vec::new(), rows));
}
let header = rows
.remove(0)
.into_iter()
.map(|c| text_of(&c))
.collect();
Ok((header, rows))
}
fn check_unpacked<R: Read + Seek>(reader: R) -> Result<(), XlsxReadError> {
let mut zip = zip::ZipArchive::new(reader).map_err(|_| XlsxReadError::Unreadable)?;
if zip.len() > MAX_PARTS {
return Err(XlsxReadError::TooLarge);
}
let mut total = 0u64;
for i in 0..zip.len() {
let part = zip
.by_index_raw(i)
.map_err(|_| XlsxReadError::Unreadable)?;
total = total.saturating_add(part.size());
let shared = part
.name()
.is_ok_and(|n| n.eq_ignore_ascii_case("xl/sharedStrings.xml"));
if total > MAX_UNPACKED || (shared && part.size() > MAX_SHARED_STRINGS) {
return Err(XlsxReadError::TooLarge);
}
}
Ok(())
}
fn cell_of(data: &calamine::DataRef<'_>) -> XlsxCell {
use calamine::DataRef as D;
match data {
D::Int(n) => XlsxCell::Number(*n as f64),
D::Float(x) => XlsxCell::Number(*x),
D::String(s) => XlsxCell::Text(s.clone()),
D::SharedString(s) => XlsxCell::Text((*s).to_string()),
D::Bool(b) => XlsxCell::Bool(*b),
D::DateTime(d) if d.is_datetime() => {
let (y, mo, da, h, mi, s, _) = d.to_ymd_hms_milli();
XlsxCell::Moment(y, mo, da, h, mi, s, d.as_f64() < 1.0)
}
D::DateTime(d) => XlsxCell::Number(d.as_f64()),
D::DateTimeIso(s) | D::DurationIso(s) => XlsxCell::Text(s.clone()),
D::Error(e) => XlsxCell::Text(format!("#{e:?}")),
D::Empty => XlsxCell::Empty,
}
}
fn text_of(cell: &XlsxCell) -> String {
match cell {
XlsxCell::Empty => String::new(),
XlsxCell::Text(s) => s.clone(),
XlsxCell::Number(x) => x.to_string(),
XlsxCell::Bool(b) => b.to_string(),
XlsxCell::Moment(y, mo, d, h, mi, s, _) => {
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}")
}
}
}
pub(crate) fn cell_text(cell: &XlsxCell, kind: &FieldKind, locale: &str) -> String {
match (cell, kind) {
(XlsxCell::Number(x), FieldKind::Money) => {
let decimal = NumberFormat::for_locale(locale).decimal;
format!("{x:.2}").replace('.', &decimal.to_string())
}
(XlsxCell::Number(x), _) if x.fract() == 0.0 && x.abs() < 1e15 => format!("{}", *x as i64),
(XlsxCell::Number(x), _) => {
let decimal = NumberFormat::for_locale(locale).decimal;
x.to_string()
.replace('.', &decimal.to_string())
}
(XlsxCell::Moment(y, mo, d, ..), FieldKind::Date) => format!("{y:04}-{mo:02}-{d:02}"),
(XlsxCell::Moment(.., h, mi, s, _), FieldKind::Time) => format!("{h:02}:{mi:02}:{s:02}"),
(XlsxCell::Moment(y, mo, d, h, mi, s, false), FieldKind::Text)
if *h == 0 && *mi == 0 && *s == 0 =>
{
Date::new((*y).into(), *mo, *d)
.map(|d| d.format(locale))
.unwrap_or_default()
}
(XlsxCell::Moment(y, mo, d, h, mi, s, _), FieldKind::Text) => {
match (Date::new((*y).into(), *mo, *d), Time::new(*h, *mi, *s)) {
(Some(d), Some(t)) => LocalDateTime::new(d, t).format(locale),
_ => String::new(),
}
}
(c, _) => text_of(c),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cells_become_the_text_a_form_reads() {
let n = XlsxCell::Number(1234.5);
assert_eq!(cell_text(&n, &FieldKind::Money, "tr"), "1234,50");
assert_eq!(cell_text(&n, &FieldKind::Money, "en"), "1234.50");
assert_eq!(
cell_text(&XlsxCell::Number(42.0), &FieldKind::Integer, "tr"),
"42"
);
assert_eq!(
cell_text(&XlsxCell::Number(42.0), &FieldKind::Text, "tr"),
"42"
);
let m = XlsxCell::Moment(2026, 3, 9, 14, 30, 0, false);
assert_eq!(cell_text(&m, &FieldKind::Date, "tr"), "2026-03-09");
assert_eq!(
cell_text(&m, &FieldKind::DateTime, "tr"),
"2026-03-09T14:30:00"
);
assert_eq!(cell_text(&m, &FieldKind::Time, "tr"), "14:30:00");
assert_eq!(
cell_text(&XlsxCell::Bool(true), &FieldKind::Bool, "tr"),
"true"
);
}
#[test]
fn long_text_is_clipped_to_what_a_cell_holds() {
let long = "ş".repeat(MAX_CELL_CHARS + 5);
assert_eq!(clip(&long).chars().count(), MAX_CELL_CHARS);
assert_eq!(clip("abc"), "abc");
}
}