Excel renders numeric cells with the General format in the shortest form, so database values like 0.3500000 exported from decimal columns displayed as 0.35 and the declared scale looked truncated (#8225). Pre-register one 0.0...0 number format per fractional-digit count (up to 15) plus right/left alignment variants after the six fixed styles, and switch fractional numeric cells to the matching format so Excel keeps the decimals visible. Exponent-notation values and scales beyond 15 decimals keep the previous General rendering. Refs: #8225
2278 lines
96 KiB
Rust
2278 lines
96 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::fmt::Write as FmtWrite;
|
|
use std::io::{Cursor, Seek, Write};
|
|
|
|
use crate::temporal_format::{excel_temporal_serial, ExcelTemporalKind};
|
|
|
|
pub(crate) const XLSX_MAX_DATA_ROWS: usize = 1_048_575;
|
|
const XLSX_DATE_STYLE: usize = 2;
|
|
const XLSX_DATETIME_STYLE: usize = 3;
|
|
const NUMERIC_RIGHT_ALIGN_STYLE: usize = 4;
|
|
const NUMERIC_LEFT_ALIGN_STYLE: usize = 5;
|
|
// Excel renders numeric cells with the General format in the shortest form, so
|
|
// database scale like "0.3500000" displays as "0.35" even though the full value
|
|
// is stored. Pre-registered number formats (one per fractional-digit count up
|
|
// to MAX_NUMERIC_SCALE_FORMAT) keep the declared decimals visible (#8225).
|
|
// Styles are appended after the six fixed entries, two per scale (right/left
|
|
// alignment), so the indices stay stable for the streaming writer.
|
|
const MAX_NUMERIC_SCALE_FORMAT: usize = 15;
|
|
const NUMERIC_SCALE_STYLE_BASE: usize = 6;
|
|
const SCALE_NUMFMT_ID_BASE: usize = 166;
|
|
|
|
fn numeric_scale_style(scale: usize, right_align: bool) -> Option<usize> {
|
|
if scale == 0 || scale > MAX_NUMERIC_SCALE_FORMAT {
|
|
return None;
|
|
}
|
|
Some(NUMERIC_SCALE_STYLE_BASE + (scale - 1) * 2 + usize::from(!right_align))
|
|
}
|
|
|
|
/// Fractional digit count of a plain (non-exponent) decimal string. Values in
|
|
/// exponent notation return 0: a fixed "0.00"-style format would round tiny
|
|
/// magnitudes to zero, so they keep the General format's scientific display.
|
|
fn decimal_fraction_digits(value: &str) -> usize {
|
|
let trimmed = value.trim();
|
|
if trimmed.contains(['e', 'E']) {
|
|
return 0;
|
|
}
|
|
trimmed.split_once('.').map_or(0, |(_, fraction)| fraction.chars().filter(|ch| ch.is_ascii_digit()).count())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct XlsxWorksheetData {
|
|
pub sheet_name: Option<String>,
|
|
pub columns: Vec<String>,
|
|
#[serde(default)]
|
|
pub column_types: Vec<String>,
|
|
#[serde(default)]
|
|
pub column_comments: Vec<Option<String>>,
|
|
pub rows: Vec<Vec<Value>>,
|
|
#[serde(default)]
|
|
pub numeric_column_right_align: bool,
|
|
}
|
|
|
|
fn normalize_sheet_name(input: Option<&str>) -> String {
|
|
let base = input.unwrap_or("Sheet1");
|
|
let name: String = base
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'[' | ']' | ':' | '*' | '?' | '/' | '\\' => ' ',
|
|
_ => ch,
|
|
})
|
|
.collect::<String>()
|
|
.trim()
|
|
.to_string();
|
|
let fallback = if name.is_empty() { "Sheet1" } else { &name };
|
|
fallback.chars().take(31).collect()
|
|
}
|
|
|
|
/// Allocates unique sheet names for data sheets, avoiding conflicts with
|
|
/// pre-reserved trailing sheet names. Data sheets are named sequentially:
|
|
/// "Result", "Result (2)", "Result (3)", etc.
|
|
struct SheetNameAllocator {
|
|
/// The original unsuffixed base name (e.g. "Result"), stored so that
|
|
/// [`allocate_next`] always derives continuation names from the original
|
|
/// name rather than from an already-de-duplicated first name.
|
|
base: String,
|
|
data_names: Vec<String>,
|
|
trailing_names: Vec<String>,
|
|
}
|
|
|
|
impl SheetNameAllocator {
|
|
fn new(data_sheet_name: Option<&str>, trailing_sheets: &[XlsxWorksheetData]) -> Self {
|
|
// Pre-reserve trailing sheet names (normalized, deduplicated) so data
|
|
// sheet names never collide with them, and no two trailing sheets share
|
|
// the same name.
|
|
let mut trailing_names: Vec<String> = Vec::with_capacity(trailing_sheets.len());
|
|
for (index, sheet) in trailing_sheets.iter().enumerate() {
|
|
let base = normalize_sheet_name(sheet.sheet_name.as_deref().or(Some(&format!("Sheet{}", index + 1))));
|
|
trailing_names.push(make_unique_name(&base, &trailing_names));
|
|
}
|
|
|
|
let base = normalize_sheet_name(data_sheet_name);
|
|
// Allocate the first data sheet name, avoiding trailing names.
|
|
let reserved: Vec<String> = trailing_names.clone();
|
|
let first = make_unique_name(&base, &reserved);
|
|
let data_names = vec![first];
|
|
|
|
Self { base, data_names, trailing_names }
|
|
}
|
|
|
|
/// Allocate the next data sheet name, avoiding all previously allocated
|
|
/// names (both data and trailing). Uses the stored original base name so
|
|
/// that continuation names are always "base (2)", "base (3)", ... even
|
|
/// when the first data name was de-duplicated.
|
|
fn allocate_next(&mut self) {
|
|
let mut reserved: Vec<String> = self.trailing_names.clone();
|
|
reserved.extend(self.data_names.iter().cloned());
|
|
let next = make_unique_name(&self.base, &reserved);
|
|
self.data_names.push(next);
|
|
}
|
|
|
|
/// Ordered list: data sheet names first, then trailing sheet names.
|
|
fn all_names(&self) -> Vec<String> {
|
|
let mut names = self.data_names.clone();
|
|
names.extend(self.trailing_names.clone());
|
|
names
|
|
}
|
|
}
|
|
|
|
fn make_unique_name(base: &str, reserved: &[String]) -> String {
|
|
let mut candidate = base.to_string();
|
|
let mut suffix = 2;
|
|
while reserved.iter().any(|name| name == &candidate) {
|
|
let suffix_text = format!(" ({suffix})");
|
|
let max_base_len = 31usize.saturating_sub(suffix_text.chars().count());
|
|
if max_base_len > 0 {
|
|
candidate = format!("{}{}", base.chars().take(max_base_len).collect::<String>(), suffix_text);
|
|
} else {
|
|
candidate = suffix_text.clone();
|
|
}
|
|
suffix += 1;
|
|
}
|
|
candidate
|
|
}
|
|
|
|
/// Streaming XLSX writer that incrementally writes rows to a ZIP-backed
|
|
/// workbook. This avoids accumulating all rows in memory before building the
|
|
/// final file, drastically reducing peak memory for large exports.
|
|
///
|
|
/// When the per-sheet data row count reaches [`XLSX_MAX_DATA_ROWS`], the writer
|
|
/// automatically closes the current sheet and starts a new one, repeating
|
|
/// column headers, frozen panes, and column widths. Trailing sheets (e.g. SQL)
|
|
/// are written after all data sheets in [`finish`].
|
|
pub struct StreamingXlsxWriter<W: Write + Seek> {
|
|
zip: zip::ZipWriter<W>,
|
|
columns: Vec<String>,
|
|
column_types: Vec<String>,
|
|
next_row_number: usize,
|
|
current_data_rows: usize,
|
|
max_data_rows_per_sheet: usize,
|
|
/// Track the sheet number within the ZIP (1-based). Increments when a new
|
|
/// data sheet or trailing sheet is started.
|
|
current_sheet_number: usize,
|
|
sheet_name_allocator: SheetNameAllocator,
|
|
trailing_sheets: Vec<XlsxWorksheetData>,
|
|
width_cache: Vec<usize>,
|
|
column_comments: Vec<Option<String>>,
|
|
date_time_format: Option<String>,
|
|
numeric_right_align: bool,
|
|
auto_filter: bool,
|
|
row_buffer: String,
|
|
}
|
|
|
|
/// Estimate column widths from header names only (used by the streaming path
|
|
/// where full row data is not available up-front). Each width is clamped to
|
|
/// [10, 60] to stay within reasonable bounds.
|
|
fn estimate_header_widths(columns: &[String], column_comments: &[Option<String>]) -> Vec<usize> {
|
|
columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, col)| {
|
|
let header_text = column_comments.get(index).and_then(|c| c.as_deref()).unwrap_or(col.as_str());
|
|
(header_text.chars().count() + 2).clamp(10, 60)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build the `<cols>` XML fragment from a width slice.
|
|
fn cols_xml(widths: &[usize]) -> String {
|
|
widths
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, width)| {
|
|
format!("<col min=\"{}\" max=\"{}\" width=\"{}\" customWidth=\"1\"/>", index + 1, index + 1, width)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Resolve the effective header text: prefer a non-empty column comment, fall
|
|
/// back to the original column name.
|
|
fn effective_header<'a>(column: &'a str, comment: Option<&'a str>) -> &'a str {
|
|
comment.filter(|c| !c.is_empty()).unwrap_or(column)
|
|
}
|
|
|
|
/// Build a single `<row>` XML fragment for the header row (row 1).
|
|
pub(crate) fn header_row_xml(columns: &[String], column_comments: &[Option<String>]) -> String {
|
|
format!(
|
|
"<row r=\"1\">{}</row>",
|
|
columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, col)| {
|
|
let header = effective_header(col, column_comments.get(index).and_then(|c| c.as_deref()));
|
|
cell_xml(Some(&Value::String(header.to_string())), 0, index, Some(1))
|
|
})
|
|
.collect::<String>()
|
|
)
|
|
}
|
|
|
|
fn push_data_row_xml(
|
|
output: &mut String,
|
|
row_number: usize,
|
|
columns: &[String],
|
|
column_types: &[String],
|
|
row: &[Value],
|
|
date_time_format: Option<&str>,
|
|
numeric_right_align: bool,
|
|
) {
|
|
write!(output, "<row r=\"{row_number}\">").expect("writing XLSX XML into a String cannot fail");
|
|
for (col_index, _) in columns.iter().enumerate() {
|
|
let col_type = column_types.get(col_index);
|
|
let align_style = numeric_column_style(col_type, numeric_right_align);
|
|
push_typed_cell_xml(
|
|
output,
|
|
row.get(col_index),
|
|
col_type,
|
|
row_number - 1,
|
|
col_index,
|
|
align_style,
|
|
date_time_format,
|
|
);
|
|
}
|
|
output.push_str("</row>");
|
|
}
|
|
|
|
/// Shared ZIP entry options for all XLSX parts. XLSX is a ZIP of XML, and the
|
|
/// `inlineStr` cell encoding is highly repetitive, so Deflate typically shrinks
|
|
/// the file several-fold over `Stored` (matching what Excel/Navicat produce).
|
|
fn xlsx_zip_options() -> zip::write::SimpleFileOptions {
|
|
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
|
|
}
|
|
|
|
fn write_zip_entry<W: Write + Seek>(zip: &mut zip::ZipWriter<W>, path: &str, content: &str) -> Result<(), String> {
|
|
zip.start_file(path, xlsx_zip_options()).map_err(|err| err.to_string())?;
|
|
zip.write_all(content.as_bytes()).map_err(|err| err.to_string())
|
|
}
|
|
|
|
/// Start a new streaming XLSX workbook. The ZIP skeleton, worksheet header,
|
|
/// column widths (estimated from header names) and the header row are written
|
|
/// immediately. Callers then feed data rows via [`StreamingXlsxWriter::write_row`]
|
|
/// and finalize with [`StreamingXlsxWriter::finish`].
|
|
#[cfg(test)]
|
|
pub(crate) fn start_streaming_xlsx_workbook<W: Write + Seek>(
|
|
writer: W,
|
|
sheet_name: Option<&str>,
|
|
columns: &[String],
|
|
column_types: &[String],
|
|
) -> Result<StreamingXlsxWriter<W>, String> {
|
|
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], &[], None, false, true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets<W: Write + Seek>(
|
|
writer: W,
|
|
sheet_name: Option<&str>,
|
|
columns: &[String],
|
|
column_types: &[String],
|
|
trailing_sheets: &[XlsxWorksheetData],
|
|
) -> Result<StreamingXlsxWriter<W>, String> {
|
|
start_streaming_xlsx_workbook_with_options(
|
|
writer,
|
|
sheet_name,
|
|
columns,
|
|
column_types,
|
|
&[],
|
|
trailing_sheets,
|
|
None,
|
|
false,
|
|
true,
|
|
)
|
|
}
|
|
|
|
/// Start a new streaming XLSX workbook with full options. Metadata files
|
|
/// ([Content_Types].xml, workbook.xml, styles.xml, etc.) are deferred to
|
|
/// [`StreamingXlsxWriter::finish`] so that the final sheet count is known
|
|
/// — this supports automatic multi-sheet splitting.
|
|
fn start_xlsx_writer_inner<W: Write + Seek>(
|
|
writer: W,
|
|
sheet_name: Option<&str>,
|
|
columns: &[String],
|
|
column_types: &[String],
|
|
column_comments: &[Option<String>],
|
|
trailing_sheets: &[XlsxWorksheetData],
|
|
date_time_format: Option<&str>,
|
|
numeric_right_align: bool,
|
|
auto_filter: bool,
|
|
max_data_rows_per_sheet: usize,
|
|
) -> Result<StreamingXlsxWriter<W>, String> {
|
|
let width_cache = estimate_header_widths(columns, column_comments);
|
|
let sheet_name_allocator = SheetNameAllocator::new(sheet_name, trailing_sheets);
|
|
|
|
let mut zip = zip::ZipWriter::new(writer);
|
|
|
|
// Start sheet1 immediately. Metadata files are written in finish().
|
|
let options = xlsx_zip_options();
|
|
zip.start_file("xl/worksheets/sheet1.xml", options).map_err(|err| err.to_string())?;
|
|
|
|
let sheet_header = format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
|
"<sheetViews><sheetView workbookViewId=\"0\">",
|
|
"<pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>",
|
|
"</sheetView></sheetViews>",
|
|
"<sheetFormatPr defaultRowHeight=\"15\"/>",
|
|
"<cols>{cols}</cols>",
|
|
"<sheetData>"
|
|
),
|
|
cols = cols_xml(&width_cache),
|
|
);
|
|
zip.write_all(sheet_header.as_bytes()).map_err(|err| err.to_string())?;
|
|
zip.write_all(header_row_xml(columns, column_comments).as_bytes()).map_err(|err| err.to_string())?;
|
|
|
|
Ok(StreamingXlsxWriter {
|
|
zip,
|
|
columns: columns.to_vec(),
|
|
column_types: column_types.to_vec(),
|
|
next_row_number: 2,
|
|
current_data_rows: 0,
|
|
max_data_rows_per_sheet,
|
|
current_sheet_number: 1,
|
|
sheet_name_allocator,
|
|
trailing_sheets: trailing_sheets.to_vec(),
|
|
width_cache,
|
|
column_comments: column_comments.to_vec(),
|
|
date_time_format: date_time_format.map(str::to_string),
|
|
numeric_right_align,
|
|
auto_filter,
|
|
row_buffer: String::with_capacity(columns.len().saturating_mul(48)),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
|
|
writer: W,
|
|
sheet_name: Option<&str>,
|
|
columns: &[String],
|
|
column_types: &[String],
|
|
column_comments: &[Option<String>],
|
|
trailing_sheets: &[XlsxWorksheetData],
|
|
date_time_format: Option<&str>,
|
|
numeric_right_align: bool,
|
|
auto_filter: bool,
|
|
) -> Result<StreamingXlsxWriter<W>, String> {
|
|
start_xlsx_writer_inner(
|
|
writer,
|
|
sheet_name,
|
|
columns,
|
|
column_types,
|
|
column_comments,
|
|
trailing_sheets,
|
|
date_time_format,
|
|
numeric_right_align,
|
|
auto_filter,
|
|
XLSX_MAX_DATA_ROWS,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn start_streaming_xlsx_workbook_with_max_rows<W: Write + Seek>(
|
|
writer: W,
|
|
sheet_name: Option<&str>,
|
|
columns: &[String],
|
|
column_types: &[String],
|
|
trailing_sheets: &[XlsxWorksheetData],
|
|
max_data_rows_per_sheet: usize,
|
|
) -> Result<StreamingXlsxWriter<W>, String> {
|
|
start_xlsx_writer_inner(
|
|
writer,
|
|
sheet_name,
|
|
columns,
|
|
column_types,
|
|
&[],
|
|
trailing_sheets,
|
|
None,
|
|
false,
|
|
true,
|
|
max_data_rows_per_sheet,
|
|
)
|
|
}
|
|
|
|
impl<W: Write + Seek> StreamingXlsxWriter<W> {
|
|
/// Append a single data row to the current worksheet. If the current sheet
|
|
/// has reached [`self.max_data_rows_per_sheet`] data rows, this method
|
|
/// automatically closes the sheet and opens a new one before writing the row.
|
|
pub fn write_row(&mut self, row: &[Value]) -> Result<(), String> {
|
|
if self.current_data_rows >= self.max_data_rows_per_sheet {
|
|
self.finish_current_sheet()?;
|
|
self.start_next_data_sheet()?;
|
|
}
|
|
self.row_buffer.clear();
|
|
push_data_row_xml(
|
|
&mut self.row_buffer,
|
|
self.next_row_number,
|
|
&self.columns,
|
|
&self.column_types,
|
|
row,
|
|
self.date_time_format.as_deref(),
|
|
self.numeric_right_align,
|
|
);
|
|
self.zip.write_all(self.row_buffer.as_bytes()).map_err(|err| err.to_string())?;
|
|
self.next_row_number += 1;
|
|
self.current_data_rows += 1;
|
|
Ok(())
|
|
}
|
|
|
|
/// Close the currently open data sheet XML, optionally writing `<autoFilter>`.
|
|
fn finish_current_sheet(&mut self) -> Result<(), String> {
|
|
let row_count = self.next_row_number.saturating_sub(1);
|
|
let range = sheet_range(self.columns.len(), row_count);
|
|
let auto_filter = if self.auto_filter { format!("<autoFilter ref=\"{range}\"/>") } else { String::new() };
|
|
self.zip.write_all(format!("</sheetData>{auto_filter}</worksheet>").as_bytes()).map_err(|err| err.to_string())
|
|
}
|
|
|
|
/// Start a new data sheet, reusing the same header row, column widths and
|
|
/// frozen pane from the first sheet.
|
|
fn start_next_data_sheet(&mut self) -> Result<(), String> {
|
|
self.sheet_name_allocator.allocate_next();
|
|
self.current_sheet_number += 1;
|
|
|
|
let options = xlsx_zip_options();
|
|
self.zip
|
|
.start_file(format!("xl/worksheets/sheet{}.xml", self.current_sheet_number), options)
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
let sheet_header = format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
|
"<sheetViews><sheetView workbookViewId=\"0\">",
|
|
"<pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>",
|
|
"</sheetView></sheetViews>",
|
|
"<sheetFormatPr defaultRowHeight=\"15\"/>",
|
|
"<cols>{cols}</cols>",
|
|
"<sheetData>"
|
|
),
|
|
cols = cols_xml(&self.width_cache),
|
|
);
|
|
self.zip.write_all(sheet_header.as_bytes()).map_err(|err| err.to_string())?;
|
|
self.zip
|
|
.write_all(header_row_xml(&self.columns, &self.column_comments).as_bytes())
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
// Reset row counters for the new sheet.
|
|
self.next_row_number = 2;
|
|
self.current_data_rows = 0;
|
|
Ok(())
|
|
}
|
|
|
|
/// Finalize the workbook: close the current data sheet, write trailing
|
|
/// sheets, write metadata files, and close the ZIP archive. Returns the
|
|
/// underlying writer so callers can flush / close it as needed.
|
|
pub fn finish(mut self) -> Result<W, String> {
|
|
// 1. Close the current data sheet.
|
|
self.finish_current_sheet()?;
|
|
|
|
// 2. Write trailing sheets (e.g. SQL) after all data sheets.
|
|
for sheet in &self.trailing_sheets {
|
|
self.current_sheet_number += 1;
|
|
self.zip
|
|
.start_file(format!("xl/worksheets/sheet{}.xml", self.current_sheet_number), xlsx_zip_options())
|
|
.map_err(|err| err.to_string())?;
|
|
let segment = WorksheetSegment {
|
|
name: sheet.sheet_name.clone(),
|
|
columns: &sheet.columns,
|
|
column_types: &sheet.column_types,
|
|
column_comments: &sheet.column_comments,
|
|
rows: &sheet.rows,
|
|
numeric_column_right_align: sheet.numeric_column_right_align,
|
|
};
|
|
write_worksheet_xml(&mut self.zip, &segment, self.auto_filter, self.date_time_format.as_deref())?;
|
|
}
|
|
|
|
// 3. Write metadata files. These appear AFTER sheet data in the ZIP
|
|
// stream, but ZIP readers use the central directory to locate entries
|
|
// by name, so the physical ordering is irrelevant.
|
|
let sheet_names = self.sheet_name_allocator.all_names();
|
|
let total_sheet_count = sheet_names.len();
|
|
write_zip_entry(&mut self.zip, "[Content_Types].xml", &content_types_xml_for_sheet_count(total_sheet_count))?;
|
|
write_zip_entry(&mut self.zip, "_rels/.rels", root_rels_xml())?;
|
|
write_zip_entry(&mut self.zip, "xl/workbook.xml", &workbook_xml_for_sheets(&sheet_names))?;
|
|
write_zip_entry(
|
|
&mut self.zip,
|
|
"xl/_rels/workbook.xml.rels",
|
|
&workbook_rels_xml_for_sheet_count(total_sheet_count),
|
|
)?;
|
|
write_zip_entry(&mut self.zip, "xl/styles.xml", &styles_xml(self.date_time_format.as_deref()))?;
|
|
|
|
// 4. Finalize the ZIP.
|
|
self.zip.finish().map_err(|err| err.to_string())
|
|
}
|
|
}
|
|
|
|
/// Convenience wrapper that finalizes a streaming workbook.
|
|
pub(crate) fn finish_streaming_xlsx_workbook<W: Write + Seek>(writer: StreamingXlsxWriter<W>) -> Result<W, String> {
|
|
writer.finish()
|
|
}
|
|
|
|
fn escape_xml(value: &str) -> String {
|
|
let mut result = String::with_capacity(value.len());
|
|
push_xml_escaped(&mut result, value);
|
|
result
|
|
}
|
|
|
|
fn push_xml_escaped(result: &mut String, value: &str) {
|
|
for ch in value.chars() {
|
|
let code = ch as u32;
|
|
if code != 9 && code != 10 && code != 13 && code < 32 {
|
|
continue;
|
|
}
|
|
match ch {
|
|
'&' => result.push_str("&"),
|
|
'<' => result.push_str("<"),
|
|
'>' => result.push_str(">"),
|
|
'"' => result.push_str("""),
|
|
_ => result.push(ch),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn column_name(index: usize) -> String {
|
|
let mut out = String::with_capacity(4);
|
|
push_column_name(&mut out, index);
|
|
out
|
|
}
|
|
|
|
fn push_column_name(out: &mut String, index: usize) {
|
|
let mut buffer = [0u8; 16];
|
|
let mut cursor = buffer.len();
|
|
let mut n = index + 1;
|
|
while n > 0 {
|
|
let rem = (n - 1) % 26;
|
|
cursor -= 1;
|
|
buffer[cursor] = b'A' + rem as u8;
|
|
n = (n - 1) / 26;
|
|
}
|
|
out.push_str(std::str::from_utf8(&buffer[cursor..]).expect("XLSX column names are ASCII"));
|
|
}
|
|
|
|
fn cell_ref(row_index: usize, col_index: usize) -> String {
|
|
let mut reference = String::with_capacity(8);
|
|
push_cell_ref(&mut reference, row_index, col_index);
|
|
reference
|
|
}
|
|
|
|
fn push_cell_ref(out: &mut String, row_index: usize, col_index: usize) {
|
|
push_column_name(out, col_index);
|
|
write!(out, "{}", row_index + 1).expect("writing XLSX cell references into a String cannot fail");
|
|
}
|
|
|
|
fn sheet_range(column_count: usize, row_count: usize) -> String {
|
|
if column_count == 0 || row_count == 0 {
|
|
return "A1".to_string();
|
|
}
|
|
format!("A1:{}{}", column_name(column_count - 1), row_count)
|
|
}
|
|
|
|
fn value_text(value: Option<&Value>) -> String {
|
|
match value {
|
|
Some(Value::Null) | None => String::new(),
|
|
Some(Value::Bool(v)) => v.to_string(),
|
|
Some(Value::Number(n)) => n.to_string(),
|
|
Some(Value::String(s)) => s.clone(),
|
|
Some(other) => other.to_string(),
|
|
}
|
|
}
|
|
|
|
fn estimate_column_widths(columns: &[String], column_comments: &[Option<String>], rows: &[Vec<Value>]) -> Vec<usize> {
|
|
columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(col_index, col)| {
|
|
let header_text = effective_header(col, column_comments.get(col_index).and_then(|c| c.as_deref()));
|
|
let max_len = std::iter::once(header_text.chars().count().min(60))
|
|
.chain(rows.iter().take(100).map(|row| value_text(row.get(col_index)).chars().count().min(60)))
|
|
.fold(8usize, usize::max);
|
|
(max_len + 2).clamp(10, 60)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Option<usize>) -> String {
|
|
let reference = cell_ref(row_index, col_index);
|
|
let style_attr = style.map_or(String::new(), |s| format!(" s=\"{s}\""));
|
|
match value {
|
|
Some(Value::Null) | None => format!("<c r=\"{reference}\"{style_attr}/>"),
|
|
Some(Value::Bool(v)) => {
|
|
let bool_v = if *v { 1 } else { 0 };
|
|
format!("<c r=\"{reference}\" t=\"b\"{style_attr}><v>{bool_v}</v></c>")
|
|
}
|
|
Some(Value::Number(n)) => {
|
|
if n.as_f64().is_some_and(|f| f.is_finite()) {
|
|
format!("<c r=\"{reference}\"{style_attr}><v>{}</v></c>", n)
|
|
} else {
|
|
format!(
|
|
"<c r=\"{reference}\" t=\"inlineStr\"{style_attr}><is><t>{}</t></is></c>",
|
|
escape_xml(&n.to_string())
|
|
)
|
|
}
|
|
}
|
|
Some(Value::String(s)) => {
|
|
format!("<c r=\"{reference}\" t=\"inlineStr\"{style_attr}><is><t>{}</t></is></c>", escape_xml(s))
|
|
}
|
|
Some(other) => format!(
|
|
"<c r=\"{reference}\" t=\"inlineStr\"{style_attr}><is><t>{}</t></is></c>",
|
|
escape_xml(&other.to_string())
|
|
),
|
|
}
|
|
}
|
|
|
|
fn push_cell_style(output: &mut String, style: Option<usize>) {
|
|
if let Some(style) = style {
|
|
write!(output, " s=\"{style}\"").expect("writing XLSX cell styles into a String cannot fail");
|
|
}
|
|
}
|
|
|
|
fn push_cell_xml(output: &mut String, value: Option<&Value>, row_index: usize, col_index: usize, style: Option<usize>) {
|
|
output.push_str("<c r=\"");
|
|
push_cell_ref(output, row_index, col_index);
|
|
output.push('"');
|
|
match value {
|
|
Some(Value::Null) | None => {
|
|
push_cell_style(output, style);
|
|
output.push_str("/>");
|
|
}
|
|
Some(Value::Bool(value)) => {
|
|
let bool_value = if *value { 1 } else { 0 };
|
|
output.push_str(" t=\"b\"");
|
|
push_cell_style(output, style);
|
|
write!(output, "><v>{bool_value}</v></c>").expect("writing XLSX booleans into a String cannot fail");
|
|
}
|
|
Some(Value::Number(value)) => {
|
|
if value.as_f64().is_some_and(|number| number.is_finite()) {
|
|
push_cell_style(output, style);
|
|
write!(output, "><v>{value}</v></c>").expect("writing XLSX numbers into a String cannot fail");
|
|
} else {
|
|
output.push_str(" t=\"inlineStr\"");
|
|
push_cell_style(output, style);
|
|
output.push_str("><is><t>");
|
|
push_xml_escaped(output, &value.to_string());
|
|
output.push_str("</t></is></c>");
|
|
}
|
|
}
|
|
Some(Value::String(value)) => {
|
|
output.push_str(" t=\"inlineStr\"");
|
|
push_cell_style(output, style);
|
|
output.push_str("><is><t>");
|
|
push_xml_escaped(output, value);
|
|
output.push_str("</t></is></c>");
|
|
}
|
|
Some(value) => {
|
|
output.push_str(" t=\"inlineStr\"");
|
|
push_cell_style(output, style);
|
|
output.push_str("><is><t>");
|
|
push_xml_escaped(output, &value.to_string());
|
|
output.push_str("</t></is></c>");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_numeric_column_type(column_type: Option<&String>) -> bool {
|
|
let mut normalized = column_type.map(|value| value.trim().to_ascii_lowercase()).unwrap_or_default();
|
|
while normalized.ends_with(')') {
|
|
let Some(open_index) = normalized.find('(') else {
|
|
break;
|
|
};
|
|
if !matches!(normalized[..open_index].trim(), "nullable" | "lowcardinality") {
|
|
break;
|
|
}
|
|
normalized = normalized[open_index + 1..normalized.len() - 1].trim().to_string();
|
|
}
|
|
let base = normalized.split(['(', ' ', '[']).next().unwrap_or_default();
|
|
matches!(
|
|
base,
|
|
"tinyint"
|
|
| "smallint"
|
|
| "mediumint"
|
|
| "int"
|
|
| "integer"
|
|
| "bigint"
|
|
| "serial"
|
|
| "smallserial"
|
|
| "bigserial"
|
|
| "int2"
|
|
| "int4"
|
|
| "int8"
|
|
| "int1"
|
|
| "int16"
|
|
| "int32"
|
|
| "int64"
|
|
| "int128"
|
|
| "int256"
|
|
| "intn"
|
|
| "uint"
|
|
| "uint8"
|
|
| "uint16"
|
|
| "uint32"
|
|
| "uint64"
|
|
| "uint128"
|
|
| "uint256"
|
|
| "float"
|
|
| "float4"
|
|
| "float8"
|
|
| "float16"
|
|
| "float32"
|
|
| "float64"
|
|
| "floatn"
|
|
| "real"
|
|
| "double"
|
|
| "decimal"
|
|
| "decimal32"
|
|
| "decimal64"
|
|
| "decimal128"
|
|
| "decimal256"
|
|
| "decimaln"
|
|
| "numeric"
|
|
| "numericn"
|
|
| "number"
|
|
| "dec"
|
|
| "fixed"
|
|
| "money"
|
|
| "money4"
|
|
| "moneyn"
|
|
| "smallmoney"
|
|
| "smallmoneyn"
|
|
| "binary_float"
|
|
| "binary_double"
|
|
)
|
|
}
|
|
|
|
fn numeric_column_style(column_type: Option<&String>, enabled: bool) -> Option<usize> {
|
|
if !is_numeric_column_type(column_type) {
|
|
return None;
|
|
}
|
|
if enabled {
|
|
Some(NUMERIC_RIGHT_ALIGN_STYLE)
|
|
} else {
|
|
Some(NUMERIC_LEFT_ALIGN_STYLE)
|
|
}
|
|
}
|
|
|
|
fn safe_excel_number(value: &str) -> Option<&str> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() || trimmed.parse::<f64>().ok().is_none_or(|number| !number.is_finite()) {
|
|
return None;
|
|
}
|
|
// Fractional trailing zeros preserve database scale but add no numeric
|
|
// precision. Keep values with more than 15 actual digits as text.
|
|
let significand = trimmed.split(['e', 'E']).next().unwrap_or(trimmed);
|
|
let (integer, fraction) = significand.split_once('.').unwrap_or((significand, ""));
|
|
let significant_digits = integer
|
|
.chars()
|
|
.chain(fraction.trim_end_matches('0').chars())
|
|
.filter(|ch| ch.is_ascii_digit())
|
|
.skip_while(|ch| *ch == '0')
|
|
.count();
|
|
(significant_digits <= 15).then_some(trimmed)
|
|
}
|
|
|
|
fn push_typed_cell_xml(
|
|
output: &mut String,
|
|
value: Option<&Value>,
|
|
column_type: Option<&String>,
|
|
row_index: usize,
|
|
col_index: usize,
|
|
style: Option<usize>,
|
|
date_time_format: Option<&str>,
|
|
) {
|
|
if let Some(Value::String(value)) = value {
|
|
if let Some((serial, temporal_kind)) =
|
|
excel_temporal_serial(value, column_type.map(String::as_str), date_time_format)
|
|
{
|
|
let style = match temporal_kind {
|
|
ExcelTemporalKind::Date => XLSX_DATE_STYLE,
|
|
ExcelTemporalKind::DateTime => XLSX_DATETIME_STYLE,
|
|
};
|
|
output.push_str("<c r=\"");
|
|
push_cell_ref(output, row_index, col_index);
|
|
write!(output, "\" s=\"{style}\"><v>{serial}</v></c>")
|
|
.expect("writing XLSX temporal cells into a String cannot fail");
|
|
return;
|
|
}
|
|
}
|
|
if is_numeric_column_type(column_type) {
|
|
if let Some(Value::String(value)) = value {
|
|
if let Some(number) = safe_excel_number(value) {
|
|
// Excel's General format drops database scale ("0.3500000"
|
|
// renders as "0.35"), so numeric cells carrying a fractional
|
|
// part switch to the matching pre-registered scale format.
|
|
let scale_style =
|
|
numeric_scale_style(decimal_fraction_digits(number), style == Some(NUMERIC_RIGHT_ALIGN_STYLE));
|
|
output.push_str("<c r=\"");
|
|
push_cell_ref(output, row_index, col_index);
|
|
output.push('"');
|
|
push_cell_style(output, scale_style.or(style));
|
|
write!(output, "><v>{number}</v></c>").expect("writing XLSX numeric cells into a String cannot fail");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
push_cell_xml(output, value, row_index, col_index, style);
|
|
}
|
|
|
|
fn write_worksheet_xml<W: Write>(
|
|
writer: &mut W,
|
|
segment: &WorksheetSegment,
|
|
auto_filter: bool,
|
|
date_time_format: Option<&str>,
|
|
) -> Result<(), String> {
|
|
let total_rows = segment.rows.len() + 1;
|
|
let range = sheet_range(segment.columns.len(), total_rows);
|
|
let widths = estimate_column_widths(segment.columns, segment.column_comments, segment.rows);
|
|
|
|
writer
|
|
.write_all(
|
|
format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
|
"<dimension ref=\"{range}\"/>",
|
|
"<sheetViews><sheetView workbookViewId=\"0\"><pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/></sheetView></sheetViews>",
|
|
"<sheetFormatPr defaultRowHeight=\"15\"/>",
|
|
"<cols>{cols}</cols>",
|
|
"<sheetData>"
|
|
),
|
|
range = range,
|
|
cols = cols_xml(&widths),
|
|
)
|
|
.as_bytes(),
|
|
)
|
|
.map_err(|err| err.to_string())?;
|
|
writer
|
|
.write_all(header_row_xml(segment.columns, segment.column_comments).as_bytes())
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
let mut row_buffer = String::with_capacity(segment.columns.len().saturating_mul(48));
|
|
for (row_index, row) in segment.rows.iter().enumerate() {
|
|
let excel_row = row_index + 2;
|
|
row_buffer.clear();
|
|
push_data_row_xml(
|
|
&mut row_buffer,
|
|
excel_row,
|
|
segment.columns,
|
|
segment.column_types,
|
|
row,
|
|
date_time_format,
|
|
segment.numeric_column_right_align,
|
|
);
|
|
writer.write_all(row_buffer.as_bytes()).map_err(|err| err.to_string())?;
|
|
}
|
|
|
|
let auto_filter = if auto_filter { format!("<autoFilter ref=\"{range}\"/>") } else { String::new() };
|
|
writer.write_all(format!("</sheetData>{auto_filter}</worksheet>").as_bytes()).map_err(|err| err.to_string())
|
|
}
|
|
|
|
fn content_types_xml_for_sheet_count(sheet_count: usize) -> String {
|
|
let worksheet_overrides = (1..=sheet_count)
|
|
.map(|index| {
|
|
format!(
|
|
"<Override PartName=\"/xl/worksheets/sheet{index}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>"
|
|
)
|
|
})
|
|
.collect::<String>();
|
|
format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">",
|
|
"<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>",
|
|
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>",
|
|
"<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>",
|
|
"{}",
|
|
"<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>",
|
|
"</Types>"
|
|
),
|
|
worksheet_overrides
|
|
)
|
|
}
|
|
|
|
fn root_rels_xml() -> &'static str {
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
|
"<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>",
|
|
"</Relationships>"
|
|
)
|
|
}
|
|
|
|
fn workbook_xml_for_sheets(sheet_names: &[String]) -> String {
|
|
let sheets = sheet_names
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, sheet_name)| {
|
|
let sheet_id = index + 1;
|
|
format!("<sheet name=\"{}\" sheetId=\"{sheet_id}\" r:id=\"rId{sheet_id}\"/>", escape_xml(sheet_name))
|
|
})
|
|
.collect::<String>();
|
|
format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">",
|
|
"<sheets>{}</sheets>",
|
|
"</workbook>"
|
|
),
|
|
sheets
|
|
)
|
|
}
|
|
|
|
fn workbook_rels_xml_for_sheet_count(sheet_count: usize) -> String {
|
|
let worksheet_rels = (1..=sheet_count)
|
|
.map(|index| {
|
|
format!(
|
|
"<Relationship Id=\"rId{index}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet{index}.xml\"/>"
|
|
)
|
|
})
|
|
.collect::<String>();
|
|
let styles_id = sheet_count + 1;
|
|
format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
|
"{}",
|
|
"<Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>",
|
|
"</Relationships>"
|
|
),
|
|
worksheet_rels, styles_id
|
|
)
|
|
}
|
|
|
|
fn dayjs_to_excel_number_format(pattern: &str) -> Option<(String, bool, bool)> {
|
|
let pattern = pattern.trim();
|
|
if pattern.is_empty() || pattern.len() > 100 || pattern.contains('%') {
|
|
return None;
|
|
}
|
|
let tokens = [
|
|
("YYYY", "yyyy", true, false),
|
|
("SSS", "000", false, true),
|
|
("ZZ", "", false, true),
|
|
("MM", "mm", true, false),
|
|
("DD", "dd", true, false),
|
|
("HH", "hh", false, true),
|
|
("mm", "mm", false, true),
|
|
("ss", "ss", false, true),
|
|
("M", "m", true, false),
|
|
("D", "d", true, false),
|
|
("H", "h", false, true),
|
|
("m", "m", false, true),
|
|
("s", "s", false, true),
|
|
("Z", "", false, true),
|
|
];
|
|
let mut output = String::with_capacity(pattern.len());
|
|
let mut has_date = false;
|
|
let mut has_time = false;
|
|
let mut index = 0;
|
|
while index < pattern.len() {
|
|
let remaining = &pattern[index..];
|
|
if remaining.starts_with('[') {
|
|
let close = remaining.find(']')?;
|
|
let literal = &remaining[1..close];
|
|
output.push('"');
|
|
output.push_str(&literal.replace('"', "\"\""));
|
|
output.push('"');
|
|
index += close + 1;
|
|
continue;
|
|
}
|
|
if let Some((token, replacement, is_date, is_time)) =
|
|
tokens.iter().find(|(token, ..)| remaining.starts_with(token))
|
|
{
|
|
if replacement.is_empty() {
|
|
return None;
|
|
}
|
|
output.push_str(replacement);
|
|
has_date |= *is_date;
|
|
has_time |= *is_time;
|
|
index += token.len();
|
|
continue;
|
|
}
|
|
let character = remaining.chars().next()?;
|
|
if character.is_ascii_alphabetic() {
|
|
// Keep the accepted Day.js token set aligned with temporal_format.rs;
|
|
// unsupported tokens must not silently alter the exported display.
|
|
return None;
|
|
}
|
|
output.push(character);
|
|
index += character.len_utf8();
|
|
}
|
|
(has_date && !output.is_empty()).then_some((output, has_date, has_time))
|
|
}
|
|
|
|
fn styles_xml(date_time_format: Option<&str>) -> String {
|
|
let default_date = "yyyy-mm-dd".to_string();
|
|
let default_datetime = "yyyy-mm-dd hh:mm:ss".to_string();
|
|
let (date_format, datetime_format) = date_time_format
|
|
.and_then(dayjs_to_excel_number_format)
|
|
.map(|(format, has_date, has_time)| {
|
|
if has_time {
|
|
(default_date.clone(), format)
|
|
} else if has_date {
|
|
(format.clone(), format)
|
|
} else {
|
|
(default_date.clone(), default_datetime.clone())
|
|
}
|
|
})
|
|
.unwrap_or((default_date, default_datetime));
|
|
// One fixed "0.0…0" format per fractional-digit count plus right/left xfs
|
|
// per format, appended after the six fixed styles (see numeric_scale_style).
|
|
let mut scale_numfmts = String::new();
|
|
let mut scale_xfs = String::new();
|
|
for scale in 1..=MAX_NUMERIC_SCALE_FORMAT {
|
|
let format_code = format!("0.{}", "0".repeat(scale));
|
|
scale_numfmts.push_str(&format!(
|
|
"<numFmt numFmtId=\"{}\" formatCode=\"{}\"/>",
|
|
SCALE_NUMFMT_ID_BASE + scale - 1,
|
|
format_code
|
|
));
|
|
for (alignment, _) in [("right", true), ("left", false)] {
|
|
scale_xfs.push_str(&format!(
|
|
"<xf numFmtId=\"{}\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\" applyAlignment=\"1\"><alignment horizontal=\"{alignment}\"/></xf>",
|
|
SCALE_NUMFMT_ID_BASE + scale - 1
|
|
));
|
|
}
|
|
}
|
|
format!(
|
|
concat!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
|
"<numFmts count=\"{}\"><numFmt numFmtId=\"164\" formatCode=\"{}\"/><numFmt numFmtId=\"165\" formatCode=\"{}\"/>{}</numFmts>",
|
|
"<fonts count=\"2\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font><font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font></fonts>",
|
|
"<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>",
|
|
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
|
|
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>",
|
|
"<cellXfs count=\"{}\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyAlignment=\"1\"><alignment horizontal=\"right\"/></xf><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyAlignment=\"1\"><alignment horizontal=\"left\"/></xf>{}</cellXfs>",
|
|
"<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>",
|
|
"</styleSheet>"
|
|
),
|
|
2 + MAX_NUMERIC_SCALE_FORMAT,
|
|
escape_xml(&date_format),
|
|
escape_xml(&datetime_format),
|
|
scale_numfmts,
|
|
6 + MAX_NUMERIC_SCALE_FORMAT * 2,
|
|
scale_xfs
|
|
)
|
|
}
|
|
|
|
/// A borrow-only view of a worksheet's schema plus a row range, produced by
|
|
/// [`split_sheets_for_max_rows`] and consumed by [`write_worksheet_xml`]. Rows are
|
|
/// referenced as slices of the original [`XlsxWorksheetData`] rather than
|
|
/// deep-copied, so splitting an oversized worksheet into multiple sheets does
|
|
/// not duplicate cell data in memory.
|
|
struct WorksheetSegment<'a> {
|
|
/// Sheet name before cross-sheet deduplication. `None` signals a missing
|
|
/// name, which [`normalize_unique_sheet_names`] falls back on with
|
|
/// "Sheet{index}" like the original data.
|
|
name: Option<String>,
|
|
columns: &'a [String],
|
|
column_types: &'a [String],
|
|
column_comments: &'a [Option<String>],
|
|
rows: &'a [Vec<Value>],
|
|
numeric_column_right_align: bool,
|
|
}
|
|
|
|
fn normalize_unique_sheet_names(segments: &[WorksheetSegment]) -> Vec<String> {
|
|
let mut names = Vec::with_capacity(segments.len());
|
|
for (index, segment) in segments.iter().enumerate() {
|
|
let base = normalize_sheet_name(segment.name.as_deref().or(Some(&format!("Sheet{}", index + 1))));
|
|
let mut candidate = base.clone();
|
|
let mut suffix = 2;
|
|
while names.iter().any(|name| name == &candidate) {
|
|
let suffix_text = format!(" ({suffix})");
|
|
let max_base_len = 31usize.saturating_sub(suffix_text.chars().count());
|
|
candidate = format!("{}{}", base.chars().take(max_base_len).collect::<String>(), suffix_text);
|
|
suffix += 1;
|
|
}
|
|
names.push(candidate);
|
|
}
|
|
names
|
|
}
|
|
|
|
/// Split any worksheet whose `rows.len() > max_data_rows_per_sheet` into
|
|
/// multiple segments, each borrowing the columns / column_types /
|
|
/// column_comments / numeric_column_right_align of the original sheet. The
|
|
/// first chunk keeps the original sheet name; subsequent chunks get
|
|
/// "name (2)", "name (3)", etc. Sheets that do not overflow are passed through
|
|
/// as a single segment. No row data is copied.
|
|
fn split_sheets_for_max_rows<'a>(
|
|
sheets: &'a [XlsxWorksheetData],
|
|
max_data_rows_per_sheet: usize,
|
|
) -> Vec<WorksheetSegment<'a>> {
|
|
// `slice::chunks` panics on a zero chunk size. No caller passes 0 today
|
|
// (production uses XLSX_MAX_DATA_ROWS), but this is pub(crate)-reachable
|
|
// through build_xlsx_workbook_multi_with_max_rows, so guard defensively.
|
|
let max_data_rows_per_sheet = max_data_rows_per_sheet.max(1);
|
|
let mut expanded = Vec::with_capacity(sheets.len());
|
|
for sheet in sheets {
|
|
if sheet.rows.len() <= max_data_rows_per_sheet {
|
|
expanded.push(WorksheetSegment {
|
|
name: sheet.sheet_name.clone(),
|
|
columns: &sheet.columns,
|
|
column_types: &sheet.column_types,
|
|
column_comments: &sheet.column_comments,
|
|
rows: &sheet.rows,
|
|
numeric_column_right_align: sheet.numeric_column_right_align,
|
|
});
|
|
continue;
|
|
}
|
|
let base_name = normalize_sheet_name(sheet.sheet_name.as_deref());
|
|
for (chunk_index, chunk) in sheet.rows.chunks(max_data_rows_per_sheet).enumerate() {
|
|
let chunk_name = if chunk_index == 0 {
|
|
base_name.clone()
|
|
} else {
|
|
let suffix = chunk_index + 1;
|
|
let suffix_text = format!(" ({suffix})");
|
|
let max_base_len = 31usize.saturating_sub(suffix_text.chars().count());
|
|
if max_base_len > 0 {
|
|
format!("{}{}", base_name.chars().take(max_base_len).collect::<String>(), suffix_text)
|
|
} else {
|
|
suffix_text.clone()
|
|
}
|
|
};
|
|
expanded.push(WorksheetSegment {
|
|
name: Some(chunk_name),
|
|
columns: &sheet.columns,
|
|
column_types: &sheet.column_types,
|
|
column_comments: &sheet.column_comments,
|
|
rows: chunk,
|
|
numeric_column_right_align: sheet.numeric_column_right_align,
|
|
});
|
|
}
|
|
}
|
|
expanded
|
|
}
|
|
|
|
pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result<Vec<u8>, String> {
|
|
build_xlsx_workbook_with_auto_filter(data, true, None)
|
|
}
|
|
|
|
pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result<Vec<u8>, String> {
|
|
build_xlsx_workbook_multi_with_auto_filter(sheets, true, None)
|
|
}
|
|
|
|
pub fn build_xlsx_workbook_with_auto_filter(
|
|
data: &XlsxWorksheetData,
|
|
auto_filter: bool,
|
|
date_time_format: Option<&str>,
|
|
) -> Result<Vec<u8>, String> {
|
|
build_xlsx_workbook_multi_with_auto_filter(std::slice::from_ref(data), auto_filter, date_time_format)
|
|
}
|
|
|
|
pub fn build_xlsx_workbook_multi_with_auto_filter(
|
|
sheets: &[XlsxWorksheetData],
|
|
auto_filter: bool,
|
|
date_time_format: Option<&str>,
|
|
) -> Result<Vec<u8>, String> {
|
|
if auto_filter {
|
|
return build_xlsx_workbook_multi_with_max_rows(sheets, XLSX_MAX_DATA_ROWS, date_time_format);
|
|
}
|
|
build_xlsx_workbook_multi_with_max_rows_and_auto_filter(sheets, XLSX_MAX_DATA_ROWS, auto_filter, date_time_format)
|
|
}
|
|
|
|
/// Build an in-memory XLSX workbook with an explicit per-sheet data-row limit.
|
|
/// Any worksheet whose data rows exceed `max_data_rows_per_sheet` is split into
|
|
/// multiple worksheets (each with a header row, column widths, frozen pane,
|
|
/// and autoFilter).
|
|
pub(crate) fn build_xlsx_workbook_multi_with_max_rows(
|
|
sheets: &[XlsxWorksheetData],
|
|
max_data_rows_per_sheet: usize,
|
|
date_time_format: Option<&str>,
|
|
) -> Result<Vec<u8>, String> {
|
|
build_xlsx_workbook_multi_with_max_rows_and_auto_filter(sheets, max_data_rows_per_sheet, true, date_time_format)
|
|
}
|
|
|
|
fn build_xlsx_workbook_multi_with_max_rows_and_auto_filter(
|
|
sheets: &[XlsxWorksheetData],
|
|
max_data_rows_per_sheet: usize,
|
|
auto_filter: bool,
|
|
date_time_format: Option<&str>,
|
|
) -> Result<Vec<u8>, String> {
|
|
if sheets.is_empty() {
|
|
return Err("At least one worksheet is required".to_string());
|
|
}
|
|
let segments = split_sheets_for_max_rows(sheets, max_data_rows_per_sheet);
|
|
let sheet_names = normalize_unique_sheet_names(&segments);
|
|
let files = vec![
|
|
("[Content_Types].xml", content_types_xml_for_sheet_count(segments.len())),
|
|
("_rels/.rels", root_rels_xml().to_string()),
|
|
("xl/workbook.xml", workbook_xml_for_sheets(&sheet_names)),
|
|
("xl/_rels/workbook.xml.rels", workbook_rels_xml_for_sheet_count(segments.len())),
|
|
("xl/styles.xml", styles_xml(date_time_format)),
|
|
];
|
|
|
|
let cursor = Cursor::new(Vec::<u8>::new());
|
|
let mut zip = zip::ZipWriter::new(cursor);
|
|
let options = xlsx_zip_options();
|
|
|
|
for (path, content) in files {
|
|
zip.start_file(path, options).map_err(|err| err.to_string())?;
|
|
zip.write_all(content.as_bytes()).map_err(|err| err.to_string())?;
|
|
}
|
|
for (index, segment) in segments.iter().enumerate() {
|
|
zip.start_file(format!("xl/worksheets/sheet{}.xml", index + 1), options).map_err(|err| err.to_string())?;
|
|
write_worksheet_xml(&mut zip, segment, auto_filter, date_time_format)?;
|
|
}
|
|
|
|
let output = zip.finish().map_err(|err| err.to_string())?;
|
|
Ok(output.into_inner())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
build_xlsx_workbook, build_xlsx_workbook_multi, build_xlsx_workbook_multi_with_auto_filter,
|
|
build_xlsx_workbook_multi_with_max_rows, build_xlsx_workbook_with_auto_filter, is_numeric_column_type,
|
|
start_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_max_rows,
|
|
start_streaming_xlsx_workbook_with_options, start_streaming_xlsx_workbook_with_trailing_sheets,
|
|
write_worksheet_xml, WorksheetSegment, XlsxWorksheetData,
|
|
};
|
|
use calamine::{open_workbook_auto, Reader};
|
|
use serde_json::{json, Value};
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
|
|
#[derive(Default)]
|
|
struct WriteStats {
|
|
bytes_written: usize,
|
|
largest_write: usize,
|
|
write_calls: usize,
|
|
}
|
|
|
|
impl Write for WriteStats {
|
|
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
|
self.bytes_written += buffer.len();
|
|
self.largest_write = self.largest_write.max(buffer.len());
|
|
self.write_calls += 1;
|
|
Ok(buffer.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Read and decompress a single entry from an in-memory XLSX (ZIP) buffer.
|
|
fn read_zip_entry(bytes: &[u8], path: &str) -> String {
|
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).expect("open xlsx as zip archive");
|
|
let mut entry = archive.by_name(path).unwrap_or_else(|_| panic!("missing zip entry: {path}"));
|
|
let mut content = String::new();
|
|
entry.read_to_string(&mut content).expect("read zip entry");
|
|
content
|
|
}
|
|
|
|
/// Assert every entry in the XLSX (ZIP) buffer is Deflate-compressed, which
|
|
/// is what keeps exported workbooks small (see `xlsx_zip_options`).
|
|
fn assert_all_entries_deflated(bytes: &[u8]) {
|
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).expect("open xlsx as zip archive");
|
|
for index in 0..archive.len() {
|
|
let entry = archive.by_index(index).expect("zip entry");
|
|
assert_eq!(
|
|
entry.compression(),
|
|
zip::CompressionMethod::Deflated,
|
|
"entry {} should be Deflate-compressed",
|
|
entry.name()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_xlsx_zip_with_sheet_data() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Users".to_string()),
|
|
columns: vec!["id".to_string(), "name".to_string(), "active".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!(1), json!("Ada & Bob"), json!(true)], vec![json!(2), json!(null), json!(false)]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
// ZIP magic bytes.
|
|
assert_eq!(workbook[0], 0x50);
|
|
assert_eq!(workbook[1], 0x4b);
|
|
|
|
// Entries are stored compressed; assert on their decompressed contents.
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
let workbook_xml = read_zip_entry(&workbook, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"Users\""));
|
|
assert!(sheet.contains("<c r=\"A2\"><v>1</v></c>"));
|
|
assert!(sheet.contains("Ada & Bob"));
|
|
assert!(sheet.contains("<c r=\"C2\" t=\"b\"><v>1</v></c>"));
|
|
assert_all_entries_deflated(&workbook);
|
|
}
|
|
|
|
#[test]
|
|
fn omits_auto_filter_when_disabled() {
|
|
let worksheet = XlsxWorksheetData {
|
|
sheet_name: Some("Users".to_string()),
|
|
columns: vec!["id".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!(1)]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
let workbook = build_xlsx_workbook_multi_with_auto_filter(&[worksheet], false, None).expect("build workbook");
|
|
|
|
assert!(!read_zip_entry(&workbook, "xl/worksheets/sheet1.xml").contains("<autoFilter"));
|
|
}
|
|
|
|
#[test]
|
|
fn writes_safe_numeric_strings_as_numbers_for_numeric_columns() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Amounts".to_string()),
|
|
columns: vec!["quantity".to_string(), "amount".to_string(), "code".to_string()],
|
|
column_types: vec!["decimal(10,5)".to_string(), "numeric".to_string(), "varchar".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("1.00000"), json!("2800.000000"), json!("00123")]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"15\"><v>1.00000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"B2\" s=\"17\"><v>2800.000000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>00123</t></is></c>"));
|
|
}
|
|
|
|
#[test]
|
|
fn applies_scale_preserving_number_formats_to_fractional_numeric_cells() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Scale formats".to_string()),
|
|
columns: vec!["qty".to_string(), "cost".to_string(), "exp".to_string()],
|
|
column_types: vec!["numeric(18,4)".to_string(), "decimal(10,7)".to_string(), "double".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("5.0000"), json!("0.3500000"), json!("1.23E-5")]],
|
|
numeric_column_right_align: true,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let styles = read_zip_entry(&workbook, "xl/styles.xml");
|
|
assert!(styles.contains("numFmtId=\"169\" formatCode=\"0.0000\""));
|
|
assert!(styles.contains("numFmtId=\"172\" formatCode=\"0.0000000\""));
|
|
assert!(styles.contains("<cellXfs count=\"36\">"));
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"12\"><v>5.0000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"B2\" s=\"18\"><v>0.3500000</v></c>"));
|
|
// Exponent-notation values keep the General format's scientific display.
|
|
assert!(sheet.contains("<c r=\"C2\" s=\"4\"><v>1.23E-5</v></c>"));
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_integral_and_left_aligned_numeric_cells_on_plain_styles() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Left align".to_string()),
|
|
columns: vec!["qty".to_string(), "count".to_string()],
|
|
column_types: vec!["numeric(18,4)".to_string(), "int".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("5.0000"), json!("7")]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"13\"><v>5.0000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"B2\" s=\"5\"><v>7</v></c>"));
|
|
}
|
|
|
|
#[test]
|
|
fn caps_scale_formats_beyond_fifteen_decimals() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Deep scale".to_string()),
|
|
columns: vec!["value".to_string()],
|
|
column_types: vec!["numeric(38,20)".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("0.00000000000000000001")]],
|
|
numeric_column_right_align: true,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"4\"><v>0.00000000000000000001</v></c>"));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_fractional_trailing_zeros_when_checking_excel_numeric_precision() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Numeric precision".to_string()),
|
|
columns: vec![
|
|
"reported".to_string(),
|
|
"negative_boundary".to_string(),
|
|
"safe_boundary".to_string(),
|
|
"unsafe_integer".to_string(),
|
|
"precise_fraction".to_string(),
|
|
"fallback".to_string(),
|
|
],
|
|
column_types: vec!["numeric".to_string(); 6],
|
|
column_comments: vec![],
|
|
rows: vec![vec![
|
|
json!("-100000.0000000000"),
|
|
json!("-999999999999999.0000"),
|
|
json!("123456789012345.0000000000"),
|
|
json!("1234567890123456.0000"),
|
|
json!("100000.0000000001"),
|
|
json!("not-a-number"),
|
|
]],
|
|
numeric_column_right_align: true,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"24\"><v>-100000.0000000000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"B2\" s=\"12\"><v>-999999999999999.0000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"C2\" s=\"24\"><v>123456789012345.0000000000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"D2\" t=\"inlineStr\" s=\"4\"><is><t>1234567890123456.0000</t></is></c>"));
|
|
assert!(sheet.contains("<c r=\"E2\" t=\"inlineStr\" s=\"4\"><is><t>100000.0000000001</t></is></c>"));
|
|
assert!(sheet.contains("<c r=\"F2\" t=\"inlineStr\" s=\"4\"><is><t>not-a-number</t></is></c>"));
|
|
}
|
|
|
|
#[test]
|
|
fn writes_temporal_columns_as_excel_dates_without_retyping_other_values() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Typed values".to_string()),
|
|
columns: vec![
|
|
"day".to_string(),
|
|
"created_at".to_string(),
|
|
"label".to_string(),
|
|
"invalid_day".to_string(),
|
|
"amount".to_string(),
|
|
"zoned_at".to_string(),
|
|
],
|
|
column_types: vec![
|
|
"date".to_string(),
|
|
"timestamp without time zone".to_string(),
|
|
"text".to_string(),
|
|
"date".to_string(),
|
|
"numeric".to_string(),
|
|
"timestamp with time zone".to_string(),
|
|
],
|
|
column_comments: vec![],
|
|
rows: vec![vec![
|
|
json!("2024-02-25"),
|
|
json!("2024-02-25 13:02:15"),
|
|
json!("2024-02-25"),
|
|
json!("not-a-date"),
|
|
json!("2800.000000"),
|
|
json!("2024-02-25T13:02:15+08:00"),
|
|
]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
let styles = read_zip_entry(&workbook, "xl/styles.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"2\"><v>45347</v></c>"));
|
|
assert!(sheet.contains("<c r=\"B2\" s=\"3\"><v>45347.543229166666</v></c>"));
|
|
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>2024-02-25</t></is></c>"));
|
|
assert!(sheet.contains("<c r=\"D2\" t=\"inlineStr\"><is><t>not-a-date</t></is></c>"));
|
|
assert!(sheet.contains("<c r=\"E2\" s=\"17\"><v>2800.000000</v></c>"));
|
|
assert!(sheet.contains("<c r=\"F2\" t=\"inlineStr\"><is><t>2024-02-25T13:02:15+08:00</t></is></c>"));
|
|
assert!(styles.contains("numFmtId=\"164\" formatCode=\"yyyy-mm-dd\""));
|
|
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss\""));
|
|
}
|
|
|
|
#[test]
|
|
fn in_memory_temporal_cells_keep_the_configured_excel_display_format() {
|
|
// The current-page export builds the whole workbook in memory instead of
|
|
// streaming it. It used to hardcode `styles_xml(None)`, so a millisecond
|
|
// pattern produced serials that carried the fraction but a numFmt that
|
|
// displayed only whole seconds.
|
|
let data = XlsxWorksheetData {
|
|
sheet_name: Some("Temporal".to_string()),
|
|
columns: vec!["ts".to_string()],
|
|
column_types: vec!["datetime(3)".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("2026-07-25 13:02:15.456")]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
|
|
let workbook =
|
|
build_xlsx_workbook_with_auto_filter(&data, true, Some("YYYY-MM-DD HH:mm:ss.SSS")).expect("build workbook");
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
let styles = read_zip_entry(&workbook, "xl/styles.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"3\"><v>46228.54323444444</v></c>"), "sheet={sheet}");
|
|
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss.000\""), "styles={styles}");
|
|
|
|
// No configured pattern keeps the historical default.
|
|
let workbook = build_xlsx_workbook_with_auto_filter(&data, true, None).expect("build workbook");
|
|
let styles = read_zip_entry(&workbook, "xl/styles.xml");
|
|
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss\""), "styles={styles}");
|
|
}
|
|
|
|
#[test]
|
|
fn writes_mysql_57_numeric_strings_as_numeric_cells() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("MySQL 5.7".to_string()),
|
|
columns: vec![
|
|
"id".to_string(),
|
|
"nullable_int".to_string(),
|
|
"tinyint_value".to_string(),
|
|
"unsigned_int_value".to_string(),
|
|
"bigint_safe".to_string(),
|
|
"float_value".to_string(),
|
|
"double_value".to_string(),
|
|
"decimal_value".to_string(),
|
|
],
|
|
column_types: vec![
|
|
"bigint".to_string(),
|
|
"int(11)".to_string(),
|
|
"tinyint(4)".to_string(),
|
|
"int(10) unsigned".to_string(),
|
|
"bigint(20)".to_string(),
|
|
"float".to_string(),
|
|
"double".to_string(),
|
|
"decimal(18,6)".to_string(),
|
|
],
|
|
column_comments: vec![],
|
|
rows: vec![vec![
|
|
json!("2"),
|
|
json!("42"),
|
|
json!("-7"),
|
|
json!("4000000000"),
|
|
json!("123456789012345"),
|
|
json!("123.5"),
|
|
json!("987654.321"),
|
|
json!("2800.000000"),
|
|
]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
for (reference, value, style) in [
|
|
("A2", "2", "5"),
|
|
("B2", "42", "5"),
|
|
("C2", "-7", "5"),
|
|
("D2", "4000000000", "5"),
|
|
("E2", "123456789012345", "5"),
|
|
("F2", "123.5", "7"),
|
|
("G2", "987654.321", "11"),
|
|
("H2", "2800.000000", "17"),
|
|
] {
|
|
assert!(sheet.contains(&format!("<c r=\"{reference}\" s=\"{style}\"><v>{value}</v></c>")), "sheet={sheet}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_high_precision_numeric_strings_as_text() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Precision".to_string()),
|
|
columns: vec!["large_id".to_string(), "precise_amount".to_string()],
|
|
column_types: vec!["bigint".to_string(), "decimal(30,10)".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("9223372036854775807"), json!("123456789012345.6789000000")]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("t=\"inlineStr\"") && sheet.contains("9223372036854775807"));
|
|
assert!(sheet.contains("123456789012345.6789000000"));
|
|
}
|
|
|
|
#[test]
|
|
fn sanitizes_invalid_sheet_name() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("bad/name:with*chars?and-a-very-long-tail".to_string()),
|
|
columns: vec!["value".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("ok")]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
let workbook_xml = read_zip_entry(&workbook, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"bad name with chars and-a-very-\""));
|
|
}
|
|
|
|
#[test]
|
|
fn builds_multi_sheet_xlsx_workbook() {
|
|
let path = std::env::temp_dir().join(format!("dbx-multi-sheet-test-{}.xlsx", uuid::Uuid::new_v4()));
|
|
let workbook = build_xlsx_workbook_multi(&[
|
|
XlsxWorksheetData {
|
|
sheet_name: Some("Result 1".to_string()),
|
|
columns: vec!["id".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!(1)]],
|
|
numeric_column_right_align: false,
|
|
},
|
|
XlsxWorksheetData {
|
|
sheet_name: Some("Result 2".to_string()),
|
|
columns: vec!["name".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("Ada")]],
|
|
numeric_column_right_align: false,
|
|
},
|
|
])
|
|
.expect("build multi-sheet workbook");
|
|
fs::write(&path, workbook).expect("write temp workbook");
|
|
|
|
let mut workbook = open_workbook_auto(&path).expect("open workbook");
|
|
let names = workbook.sheet_names().to_vec();
|
|
assert_eq!(names, vec!["Result 1".to_string(), "Result 2".to_string()]);
|
|
let first = workbook.worksheet_range("Result 1").expect("read first worksheet");
|
|
let second = workbook.worksheet_range("Result 2").expect("read second worksheet");
|
|
assert_eq!(first.get_value((1, 0)).expect("first row"), &calamine::Data::Float(1.0));
|
|
assert_eq!(second.get_value((1, 0)).expect("second row"), &calamine::Data::String("Ada".to_string()));
|
|
let _ = fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn streams_xlsx_rows_to_a_readable_workbook() {
|
|
let path = std::env::temp_dir().join(format!("dbx-stream-test-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let mut writer =
|
|
start_streaming_xlsx_workbook(file, Some("Streamed"), &["id".to_string(), "name".to_string()], &[])
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!(1), json!("Ada")]).expect("write row");
|
|
writer.write_row(&[json!(2), json!("Bob")]).expect("write row");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
|
|
let mut workbook = open_workbook_auto(&path).expect("open workbook");
|
|
let range = workbook.worksheet_range("Streamed").expect("read worksheet");
|
|
assert_eq!(range.get_value((0, 0)).expect("header"), &calamine::Data::String("id".to_string()));
|
|
assert_eq!(range.get_value((1, 0)).expect("row1"), &calamine::Data::Float(1.0));
|
|
assert_eq!(range.get_value((2, 1)).expect("row2"), &calamine::Data::String("Bob".to_string()));
|
|
let _ = fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn streams_mysql_numeric_strings_as_numeric_cells() {
|
|
let path = std::env::temp_dir().join(format!("dbx-mysql-stream-test-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let columns = ["nullable_int".to_string(), "float_value".to_string(), "decimal_value".to_string()];
|
|
let column_types = ["int(11)".to_string(), "float".to_string(), "decimal(18,6)".to_string()];
|
|
let mut writer = start_streaming_xlsx_workbook(file, Some("MySQL Stream"), &columns, &column_types)
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!("42"), json!("123.5"), json!("2800.000000")]).expect("write row");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
|
|
let bytes = fs::read(&path).expect("read workbook");
|
|
let sheet = read_zip_entry(&bytes, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"5\"><v>42</v></c>"));
|
|
assert!(sheet.contains("<c r=\"B2\" s=\"7\"><v>123.5</v></c>"));
|
|
assert!(sheet.contains("<c r=\"C2\" s=\"17\"><v>2800.000000</v></c>"));
|
|
let _ = fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_temporal_cells_keep_the_configured_excel_display_format() {
|
|
let path = std::env::temp_dir().join(format!("dbx-temporal-stream-test-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let columns = ["created_at".to_string()];
|
|
let column_types = ["timestamp without time zone".to_string()];
|
|
let mut writer = start_streaming_xlsx_workbook_with_options(
|
|
file,
|
|
Some("Temporal"),
|
|
&columns,
|
|
&column_types,
|
|
&[],
|
|
&[],
|
|
Some("YYYY/MM/DD HH:mm:ss.SSS"),
|
|
false,
|
|
true,
|
|
)
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!("2024/02/25 13:02:15.125")]).expect("write temporal row");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
|
|
let bytes = fs::read(&path).expect("read workbook");
|
|
let sheet = read_zip_entry(&bytes, "xl/worksheets/sheet1.xml");
|
|
let styles = read_zip_entry(&bytes, "xl/styles.xml");
|
|
assert!(sheet.contains("<c r=\"A2\" s=\"3\"><v>"), "sheet={sheet}");
|
|
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy/mm/dd hh:mm:ss.000\""));
|
|
let _ = fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn streams_xlsx_rows_with_a_trailing_sql_worksheet() {
|
|
let path = std::env::temp_dir().join(format!("dbx-stream-sql-test-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let sql_sheet = XlsxWorksheetData {
|
|
sheet_name: Some("SQL".to_string()),
|
|
columns: vec!["SQL".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("SELECT id, name FROM users")]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
let mut writer = start_streaming_xlsx_workbook_with_trailing_sheets(
|
|
file,
|
|
Some("Result"),
|
|
&["id".to_string(), "name".to_string()],
|
|
&[],
|
|
&[sql_sheet],
|
|
)
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!(1), json!("Ada")]).expect("write row");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
|
|
let mut workbook = open_workbook_auto(&path).expect("open workbook");
|
|
assert_eq!(workbook.sheet_names(), &["Result".to_string(), "SQL".to_string()]);
|
|
let result = workbook.worksheet_range("Result").expect("read result worksheet");
|
|
let sql = workbook.worksheet_range("SQL").expect("read sql worksheet");
|
|
assert_eq!(result.get_value((1, 0)), Some(&calamine::Data::Float(1.0)));
|
|
assert_eq!(sql.get_value((1, 0)), Some(&calamine::Data::String("SELECT id, name FROM users".to_string())));
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_right_align_enabled_applies_style_4() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Aligned".to_string()),
|
|
columns: vec!["amount".to_string(), "label".to_string()],
|
|
column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!(1.5), json!("row")]],
|
|
numeric_column_right_align: true,
|
|
})
|
|
.expect("build workbook");
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains(r#"<c r="A2" s="4"><v>1.5</v></c>"#), "sheet={sheet}");
|
|
// Text column B should NOT have right-align style s="4"
|
|
assert!(!sheet.contains(r#"<c r="B2" s="4""#));
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_right_align_disabled_applies_left_align_style_5() {
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Disabled".to_string()),
|
|
columns: vec!["amount".to_string(), "label".to_string()],
|
|
column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!(1.5), json!("row")]],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
// Numeric column A should have left-align style (s="5"), not right-align (s="4"),
|
|
// to override Excel's default right alignment for number cells.
|
|
assert!(sheet.contains(r#"<c r="A2" s="5"><v>1.5</v></c>"#), "sheet={sheet}");
|
|
assert!(!sheet.contains(r#"s="4""#));
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_right_align_applies_across_database_numeric_types() {
|
|
// Ensures the Rust classifier covers the same cross-database numeric
|
|
// types as the frontend isNumericColumnType (ClickHouse wide integers,
|
|
// Oracle/Dameng binary floats, SQL Server internal type names, etc.).
|
|
let column_types = vec![
|
|
"Int16".to_string(),
|
|
"Int32".to_string(),
|
|
"Int64".to_string(),
|
|
"Int128".to_string(),
|
|
"UInt256".to_string(),
|
|
"Decimal128(18, 2)".to_string(),
|
|
"Float16".to_string(),
|
|
"BINARY_FLOAT".to_string(),
|
|
"BINARY_DOUBLE".to_string(),
|
|
"decimaln".to_string(),
|
|
"numericn".to_string(),
|
|
"intn".to_string(),
|
|
"floatn".to_string(),
|
|
"moneyn".to_string(),
|
|
"smallmoneyn".to_string(),
|
|
"varchar(50)".to_string(),
|
|
];
|
|
let row: Vec<Value> = column_types.iter().map(|_| json!(1)).collect::<Vec<_>>();
|
|
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("CrossDb".to_string()),
|
|
columns: column_types.iter().map(|t| t.to_lowercase()).collect(),
|
|
column_types: column_types.clone(),
|
|
column_comments: vec![],
|
|
rows: vec![row],
|
|
numeric_column_right_align: true,
|
|
})
|
|
.expect("build workbook");
|
|
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
|
for (index, column_type) in column_types.iter().take(column_types.len() - 1).enumerate() {
|
|
let col_letter = (b'A' + index as u8) as char;
|
|
let cell = format!(r#"<c r="{col_letter}2" s="4"><v>1</v></c>"#);
|
|
assert!(sheet.contains(&cell), "missing right-align style for {column_type} (cell={cell})");
|
|
}
|
|
// Text column (last) must not receive the numeric right-align style.
|
|
let last_letter = (b'A' + column_types.len() as u8 - 1) as char;
|
|
assert!(!sheet.contains(&format!(r#"<c r="{last_letter}2" s="4""#)));
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_type_classifier_matches_shared_backend_fixtures() {
|
|
let fixtures: Value =
|
|
serde_json::from_str(include_str!("../../../tests/fixtures/data-grid-numeric-column-types.json"))
|
|
.expect("parse numeric column type fixtures");
|
|
for fixture in fixtures["numeric"].as_array().expect("numeric fixtures") {
|
|
let column_type = fixture["type"].as_str().expect("numeric fixture type").to_string();
|
|
assert!(is_numeric_column_type(Some(&column_type)), "expected numeric backend type: {column_type}");
|
|
}
|
|
for fixture in fixtures["nonNumeric"].as_array().expect("non-numeric fixtures") {
|
|
let column_type = fixture["type"].as_str().expect("non-numeric fixture type").to_string();
|
|
assert!(!is_numeric_column_type(Some(&column_type)), "expected non-numeric backend type: {column_type}");
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Multi-sheet splitting tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn build_multi_sheet_xlsx(rows: &[Vec<Value>], max_rows: usize) -> Vec<u8> {
|
|
let path = std::env::temp_dir().join(format!("dbx-multi-split-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let mut writer = start_streaming_xlsx_workbook_with_max_rows(
|
|
file,
|
|
Some("Result"),
|
|
&["id".to_string(), "name".to_string()],
|
|
&[],
|
|
&[],
|
|
max_rows,
|
|
)
|
|
.expect("start workbook");
|
|
for row in rows {
|
|
writer.write_row(row).expect("write row");
|
|
}
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
let data = fs::read(&path).expect("read workbook");
|
|
let _ = fs::remove_file(&path);
|
|
data
|
|
}
|
|
|
|
#[test]
|
|
fn splits_rows_across_multiple_sheets() {
|
|
// 5 rows, max 2 per sheet -> 3 data sheets
|
|
let rows: Vec<Vec<Value>> = (1..=5).map(|i| vec![json!(i), json!(format!("row_{i}"))]).collect();
|
|
let data = build_multi_sheet_xlsx(&rows, 2);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"Result\""));
|
|
assert!(workbook_xml.contains("name=\"Result (2)\""));
|
|
assert!(workbook_xml.contains("name=\"Result (3)\""));
|
|
|
|
// Verify three data sheets exist
|
|
let sheet1 = read_zip_entry(&data, "xl/worksheets/sheet1.xml");
|
|
let sheet2 = read_zip_entry(&data, "xl/worksheets/sheet2.xml");
|
|
let sheet3 = read_zip_entry(&data, "xl/worksheets/sheet3.xml");
|
|
|
|
// Each sheet has header row; rows are numbered starting at 1
|
|
assert!(sheet1.contains("row_1") && sheet1.contains("row_2"));
|
|
assert!(sheet2.contains("row_3") && sheet2.contains("row_4"));
|
|
assert!(sheet3.contains("row_5"));
|
|
|
|
// Each sheet has exactly the header + up to 2 data rows
|
|
assert_eq!(sheet1.matches("<row r=\"").count(), 3); // header + 2 rows
|
|
assert_eq!(sheet2.matches("<row r=\"").count(), 3); // header + 2 rows
|
|
assert_eq!(sheet3.matches("<row r=\"").count(), 2); // header + 1 row
|
|
}
|
|
|
|
#[test]
|
|
fn single_sheet_when_under_limit() {
|
|
let rows: Vec<Vec<Value>> = vec![vec![json!(1), json!("Ada")]];
|
|
let data = build_multi_sheet_xlsx(&rows, 2);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"Result\""));
|
|
assert!(!workbook_xml.contains("Result (2)"));
|
|
|
|
// Only one sheet exists.
|
|
assert!(read_zip_entry(&data, "xl/worksheets/sheet1.xml").contains("Ada"));
|
|
// sheet2 should not exist.
|
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)).expect("open xlsx");
|
|
assert!(archive.by_name("xl/worksheets/sheet2.xml").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_sheet_placed_after_data_sheets() {
|
|
let path = std::env::temp_dir().join(format!("dbx-trailing-split-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let sql_sheet = XlsxWorksheetData {
|
|
sheet_name: Some("SQL".to_string()),
|
|
columns: vec!["SQL".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("SELECT 1")]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
let mut writer = start_streaming_xlsx_workbook_with_max_rows(
|
|
file,
|
|
Some("Result"),
|
|
&["id".to_string(), "name".to_string()],
|
|
&[],
|
|
&[sql_sheet],
|
|
2,
|
|
)
|
|
.expect("start workbook");
|
|
// Write 5 rows -> 3 data sheets + 1 trailing = 4 total sheets
|
|
for i in 1..=5 {
|
|
writer.write_row(&[json!(i), json!(format!("row_{i}"))]).expect("write row");
|
|
}
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
let data = fs::read(&path).expect("read workbook");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
// SQL sheet should appear last
|
|
assert!(workbook_xml.contains("name=\"Result\""));
|
|
assert!(workbook_xml.contains("name=\"Result (2)\""));
|
|
assert!(workbook_xml.contains("name=\"Result (3)\""));
|
|
assert!(workbook_xml.contains("name=\"SQL\""));
|
|
|
|
// SQL is the 4th sheet (sheet4.xml)
|
|
let sql_sheet = read_zip_entry(&data, "xl/worksheets/sheet4.xml");
|
|
assert!(sql_sheet.contains("SELECT 1"));
|
|
|
|
// All ZIP entries should be Deflate-compressed.
|
|
assert_all_entries_deflated(&data);
|
|
}
|
|
|
|
#[test]
|
|
fn varchar_leading_zeros_preserved_across_sheets() {
|
|
let path = std::env::temp_dir().join(format!("dbx-leading-zeros-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let mut writer = start_streaming_xlsx_workbook_with_max_rows(
|
|
file,
|
|
Some("Codes"),
|
|
&["code".to_string()],
|
|
&["varchar".to_string()],
|
|
&[],
|
|
1,
|
|
)
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!("00123")]).expect("write row on sheet 1");
|
|
writer.write_row(&[json!("04567")]).expect("write row on sheet 2");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
let data = fs::read(&path).expect("read workbook");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
// Both values must use inlineStr (not numeric `<v>`) to preserve leading zeros.
|
|
let sheet2 = read_zip_entry(&data, "xl/worksheets/sheet2.xml");
|
|
assert!(sheet2.contains("t=\"inlineStr\""), "sheet2 should use inlineStr for varchar: {sheet2}");
|
|
assert!(sheet2.contains("04567"), "sheet2 should contain 04567: {sheet2}");
|
|
}
|
|
|
|
#[test]
|
|
fn exactly_at_limit_does_not_create_extra_sheet() {
|
|
// max 2 rows, write exactly 2 rows -> 1 data sheet
|
|
let rows: Vec<Vec<Value>> = vec![vec![json!(1), json!("A")], vec![json!(2), json!("B")]];
|
|
let data = build_multi_sheet_xlsx(&rows, 2);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(!workbook_xml.contains("Result (2)"));
|
|
assert!(read_zip_entry(&data, "xl/worksheets/sheet1.xml").contains("A"));
|
|
}
|
|
|
|
#[test]
|
|
fn deduplicates_identically_named_trailing_sheets() {
|
|
let path = std::env::temp_dir().join(format!("dbx-trailing-dedup-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let sql_sheet_a = XlsxWorksheetData {
|
|
sheet_name: Some("SQL".to_string()),
|
|
columns: vec!["SQL".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("SELECT 1")]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
let sql_sheet_b = XlsxWorksheetData {
|
|
sheet_name: Some("SQL".to_string()),
|
|
columns: vec!["SQL".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("SELECT 2")]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
let mut writer = start_streaming_xlsx_workbook_with_max_rows(
|
|
file,
|
|
Some("Result"),
|
|
&["id".to_string()],
|
|
&[],
|
|
&[sql_sheet_a, sql_sheet_b],
|
|
100,
|
|
)
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!(42)]).expect("write row");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
let data = fs::read(&path).expect("read workbook");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"SQL\""), "workbook: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"SQL (2)\""), "workbook: {workbook_xml}");
|
|
// No duplicate names — each appears exactly once.
|
|
assert_eq!(workbook_xml.matches("name=\"SQL\"").count(), 1, "only one sheet named \"SQL\": {workbook_xml}");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_continuation_names_use_original_base_when_first_data_name_collides_with_trailing() {
|
|
// Regression: allocate_next used to derive the base from the first
|
|
// *allocated* data name, which could already carry a de-dup suffix.
|
|
// When the data sheet name ("SQL") collides with a trailing sheet
|
|
// named "SQL", the first data name becomes "SQL (2)". The second
|
|
// data name should be "SQL (3)" — NOT "SQL (2) (2)".
|
|
let path = std::env::temp_dir().join(format!("dbx-collision-base-{}.xlsx", uuid::Uuid::new_v4()));
|
|
{
|
|
let file = fs::File::create(&path).expect("create temp xlsx");
|
|
let sql_sheet = XlsxWorksheetData {
|
|
sheet_name: Some("SQL".to_string()),
|
|
columns: vec!["SQL".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![vec![json!("SELECT 1")]],
|
|
numeric_column_right_align: false,
|
|
};
|
|
let mut writer = start_streaming_xlsx_workbook_with_max_rows(
|
|
file,
|
|
Some("SQL"),
|
|
&["id".to_string()],
|
|
&[],
|
|
&[sql_sheet],
|
|
1, // force split: 2 data rows → 2 data sheets
|
|
)
|
|
.expect("start workbook");
|
|
writer.write_row(&[json!(1)]).expect("write row 1");
|
|
writer.write_row(&[json!(2)]).expect("write row 2");
|
|
drop(writer.finish().expect("finish workbook"));
|
|
}
|
|
let data = fs::read(&path).expect("read workbook");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
// Trailing "SQL" stays; data sheets: "SQL (2)", "SQL (3)" (NOT "SQL (2) (2)").
|
|
assert!(workbook_xml.contains("name=\"SQL\""), "trailing SQL sheet must exist: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"SQL (2)\""), "first data sheet must be SQL (2): {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"SQL (3)\""), "second data sheet must be SQL (3): {workbook_xml}");
|
|
assert!(!workbook_xml.contains("SQL (2) (2)"), "must not have nested de-dup suffix: {workbook_xml}");
|
|
// Exactly 3 sheets total: tail, data1, data2.
|
|
assert_eq!(workbook_xml.matches("name=\"").count(), 3, "expected exactly 3 sheets: {workbook_xml}");
|
|
}
|
|
|
|
#[test]
|
|
fn build_xlsx_workbook_splits_rows_over_limit() {
|
|
// 5 data rows, max 2 per sheet → 3 sheets: "Result", "Result (2)", "Result (3)".
|
|
let rows: Vec<Vec<Value>> = (1..=5).map(|i| vec![json!(i), json!(format!("row_{i}"))]).collect();
|
|
let data = build_xlsx_workbook_multi_with_max_rows(
|
|
&[XlsxWorksheetData {
|
|
sheet_name: Some("Result".to_string()),
|
|
columns: vec!["id".to_string(), "name".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows,
|
|
numeric_column_right_align: false,
|
|
}],
|
|
2,
|
|
None,
|
|
)
|
|
.expect("build workbook");
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"Result\""), "workbook: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"Result (2)\""), "workbook: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"Result (3)\""), "workbook: {workbook_xml}");
|
|
// Exactly 3 sheet entries.
|
|
assert_eq!(workbook_xml.matches("name=\"").count(), 3, "expected 3 sheets: {workbook_xml}");
|
|
|
|
let sheet1 = read_zip_entry(&data, "xl/worksheets/sheet1.xml");
|
|
let sheet2 = read_zip_entry(&data, "xl/worksheets/sheet2.xml");
|
|
let sheet3 = read_zip_entry(&data, "xl/worksheets/sheet3.xml");
|
|
|
|
// Each sheet has header row; data rows are split consecutively.
|
|
assert!(sheet1.contains("row_1") && sheet1.contains("row_2"));
|
|
assert!(!sheet1.contains("row_3"));
|
|
assert!(sheet2.contains("row_3") && sheet2.contains("row_4"));
|
|
assert!(!sheet2.contains("row_1") && !sheet2.contains("row_5"));
|
|
assert!(sheet3.contains("row_5"));
|
|
assert!(!sheet3.contains("row_1"));
|
|
// 1 header + up to 2 data rows per sheet.
|
|
let row_elems = |xml: &str| xml.matches("<row r=\"").count();
|
|
assert_eq!(row_elems(&sheet1), 3, "sheet1: {sheet1}");
|
|
assert_eq!(row_elems(&sheet2), 3, "sheet2: {sheet2}");
|
|
assert_eq!(row_elems(&sheet3), 2, "sheet3: {sheet3}");
|
|
}
|
|
|
|
#[test]
|
|
fn large_in_memory_worksheet_is_written_in_bounded_chunks() {
|
|
let rows: Vec<Vec<Value>> = (1..=25_000)
|
|
.map(|index| {
|
|
vec![
|
|
json!(index),
|
|
json!(format!("user_{index}")),
|
|
json!(format!("user_{index}@example.com")),
|
|
json!(index % 2 == 0),
|
|
json!(format!("note-{index:06}-{}", "x".repeat(128))),
|
|
]
|
|
})
|
|
.collect();
|
|
let worksheet = XlsxWorksheetData {
|
|
sheet_name: Some("Users".to_string()),
|
|
columns: vec![
|
|
"id".to_string(),
|
|
"name".to_string(),
|
|
"email".to_string(),
|
|
"active".to_string(),
|
|
"notes".to_string(),
|
|
],
|
|
column_types: vec![
|
|
"integer".to_string(),
|
|
"text".to_string(),
|
|
"text".to_string(),
|
|
"boolean".to_string(),
|
|
"text".to_string(),
|
|
],
|
|
column_comments: vec![],
|
|
rows,
|
|
numeric_column_right_align: true,
|
|
};
|
|
let segment = WorksheetSegment {
|
|
name: worksheet.sheet_name.clone(),
|
|
columns: &worksheet.columns,
|
|
column_types: &worksheet.column_types,
|
|
column_comments: &worksheet.column_comments,
|
|
rows: &worksheet.rows,
|
|
numeric_column_right_align: worksheet.numeric_column_right_align,
|
|
};
|
|
let mut stats = WriteStats::default();
|
|
|
|
write_worksheet_xml(&mut stats, &segment, true, None).expect("write large worksheet");
|
|
|
|
assert!(stats.bytes_written > 10_000_000, "expected realistic worksheet size, got {}", stats.bytes_written);
|
|
assert!(
|
|
stats.largest_write < 16 * 1024,
|
|
"worksheet should be streamed row-by-row, largest write was {}",
|
|
stats.largest_write
|
|
);
|
|
assert!(stats.write_calls >= worksheet.rows.len(), "expected at least one bounded write per row");
|
|
}
|
|
|
|
#[test]
|
|
fn build_xlsx_workbook_multi_splits_only_overflowing_sheets() {
|
|
// Sheet A: 7 rows, max 3 → "A", "A (2)", "A (3)".
|
|
// Sheet B: 2 rows, max 3 → stays "B". Total 4 sheets.
|
|
let sheet_a = XlsxWorksheetData {
|
|
sheet_name: Some("A".to_string()),
|
|
columns: vec!["val".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: (0..7).map(|i| vec![json!(i)]).collect(),
|
|
numeric_column_right_align: false,
|
|
};
|
|
let sheet_b = XlsxWorksheetData {
|
|
sheet_name: Some("B".to_string()),
|
|
columns: vec!["val".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: (100..102).map(|i| vec![json!(i)]).collect(),
|
|
numeric_column_right_align: false,
|
|
};
|
|
let data = build_xlsx_workbook_multi_with_max_rows(&[sheet_a, sheet_b], 3, None).expect("build workbook");
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"A\""), "workbook: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"A (2)\""), "workbook: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"A (3)\""), "workbook: {workbook_xml}");
|
|
assert!(workbook_xml.contains("name=\"B\""), "workbook: {workbook_xml}");
|
|
assert_eq!(workbook_xml.matches("name=\"").count(), 4, "expected 4 sheets: {workbook_xml}");
|
|
|
|
// Sheet B should be the 4th entry (after A's 3 chunks).
|
|
let sheet_b_xml = read_zip_entry(&data, "xl/worksheets/sheet4.xml");
|
|
assert!(sheet_b_xml.contains(">100<"), "sheet B must contain 100: {sheet_b_xml}");
|
|
assert!(sheet_b_xml.contains(">101<"), "sheet B must contain 101: {sheet_b_xml}");
|
|
|
|
// A's chunks: sheet1 (rows 0-2), sheet2 (rows 3-5), sheet3 (row 6).
|
|
let sheet1 = read_zip_entry(&data, "xl/worksheets/sheet1.xml");
|
|
assert_eq!(sheet1.matches("<row r=\"").count(), 4); // header + 3 rows
|
|
let sheet3 = read_zip_entry(&data, "xl/worksheets/sheet3.xml");
|
|
assert_eq!(sheet3.matches("<row r=\"").count(), 2); // header + 1 row
|
|
}
|
|
|
|
#[test]
|
|
fn build_xlsx_workbook_under_limit_single_sheet() {
|
|
let rows: Vec<Vec<Value>> = vec![vec![json!(1), json!("Ada")]];
|
|
let data = build_xlsx_workbook_multi_with_max_rows(
|
|
&[XlsxWorksheetData {
|
|
sheet_name: Some("MySheet".to_string()),
|
|
columns: vec!["id".to_string(), "name".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows,
|
|
numeric_column_right_align: false,
|
|
}],
|
|
100,
|
|
None,
|
|
)
|
|
.expect("build workbook");
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"MySheet\""), "workbook: {workbook_xml}");
|
|
assert!(!workbook_xml.contains("MySheet (2)"), "should not split: {workbook_xml}");
|
|
assert_eq!(workbook_xml.matches("name=\"").count(), 1, "expected exactly 1 sheet: {workbook_xml}");
|
|
|
|
let sheet = read_zip_entry(&data, "xl/worksheets/sheet1.xml");
|
|
assert!(sheet.contains("Ada"), "sheet should contain data: {sheet}");
|
|
// sheet2 must not exist.
|
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)).expect("open xlsx");
|
|
assert!(archive.by_name("xl/worksheets/sheet2.xml").is_err(), "sheet2 must not exist");
|
|
}
|
|
|
|
#[test]
|
|
fn build_xlsx_workbook_zero_rows_produces_header_only_sheet() {
|
|
let data = build_xlsx_workbook(&XlsxWorksheetData {
|
|
sheet_name: Some("Empty".to_string()),
|
|
columns: vec!["id".to_string(), "name".to_string()],
|
|
column_types: vec![],
|
|
column_comments: vec![],
|
|
rows: vec![],
|
|
numeric_column_right_align: false,
|
|
})
|
|
.expect("build workbook");
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"Empty\""), "workbook: {workbook_xml}");
|
|
let sheet = read_zip_entry(&data, "xl/worksheets/sheet1.xml");
|
|
// Header row present, no data rows.
|
|
assert!(sheet.contains("id"), "header must contain 'id': {sheet}");
|
|
assert!(sheet.contains("name"), "header must contain 'name': {sheet}");
|
|
// Exactly one <row> element (header row).
|
|
let row_count = sheet.matches("<row r=\"").count();
|
|
assert_eq!(row_count, 1, "expected 1 row (header only), got {row_count}: {sheet}");
|
|
}
|
|
|
|
#[test]
|
|
fn one_over_limit_creates_new_sheet() {
|
|
// max 2 rows, write 3 rows -> 2 data sheets
|
|
let rows: Vec<Vec<Value>> = (1..=3).map(|i| vec![json!(i), json!(format!("row_{i}"))]).collect();
|
|
let data = build_multi_sheet_xlsx(&rows, 2);
|
|
|
|
let workbook_xml = read_zip_entry(&data, "xl/workbook.xml");
|
|
assert!(workbook_xml.contains("name=\"Result\""));
|
|
assert!(workbook_xml.contains("name=\"Result (2)\""));
|
|
|
|
let sheet2 = read_zip_entry(&data, "xl/worksheets/sheet2.xml");
|
|
assert!(sheet2.contains("row_3"), "row 3 should be on sheet 2: {sheet2}");
|
|
}
|
|
|
|
#[test]
|
|
fn reusable_cell_buffer_matches_reference_xml() {
|
|
let values = [Value::Null, json!(true), json!(42.5), json!("a<&\"b"), json!({"key": "value"})];
|
|
for (index, value) in values.iter().enumerate() {
|
|
for style in [None, Some(1), Some(4)] {
|
|
let expected = super::cell_xml(Some(value), index, index + 1, style);
|
|
let mut actual = String::new();
|
|
super::push_cell_xml(&mut actual, Some(value), index, index + 1, style);
|
|
assert_eq!(actual, expected, "value={value:?}, style={style:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|