use super::shared_strings::SharedStrings;
use crate::error::Result;
use std::fs::{self, File};
use std::io::{BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use zip::write::{SimpleFileOptions, ZipWriter};
use zip::CompressionMethod;
pub struct UltraLowMemoryWorkbook {
final_path: PathBuf,
temp_dir: tempfile::TempDir,
worksheets: Vec<String>,
worksheet_count: u32,
current_writer: Option<BufWriter<File>>,
current_row: u32,
max_col: u32,
xml_buffer: Vec<u8>,
compression_level: u32,
shared_strings: SharedStrings,
}
impl UltraLowMemoryWorkbook {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
Self::with_compression(path, 6)
}
pub fn with_compression<P: AsRef<Path>>(path: P, compression_level: u32) -> Result<Self> {
let final_path = path.as_ref().to_path_buf();
let file_stem = final_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("workbook");
let temp_dir = tempfile::Builder::new()
.prefix(&format!("excelstream_{}_", file_stem))
.tempdir()?;
fs::create_dir_all(temp_dir.path().join("xl/worksheets"))?;
fs::create_dir_all(temp_dir.path().join("xl/_rels"))?;
fs::create_dir_all(temp_dir.path().join("xl/theme"))?;
fs::create_dir_all(temp_dir.path().join("docProps"))?;
fs::create_dir_all(temp_dir.path().join("_rels"))?;
Ok(UltraLowMemoryWorkbook {
final_path,
temp_dir,
worksheets: Vec::new(),
worksheet_count: 0,
current_writer: None,
current_row: 0,
max_col: 0,
xml_buffer: Vec::with_capacity(4096),
compression_level: compression_level.min(9),
shared_strings: SharedStrings::new(),
})
}
pub fn add_worksheet(&mut self, name: &str) -> Result<()> {
if let Some(mut writer) = self.current_writer.take() {
writer.write_all(b"</sheetData></worksheet>")?;
writer.flush()?;
}
self.worksheet_count += 1;
self.worksheets.push(name.to_string());
self.current_row = 0;
self.max_col = 0;
let path = self
.temp_dir
.path()
.join("xl/worksheets")
.join(format!("sheet{}.xml", self.worksheet_count));
let file = File::create(path)?;
let mut writer = BufWriter::with_capacity(32 * 1024, file);
writer.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
writer.write_all(
b"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
)?;
writer.write_all(b"<dimension ref=\"A1\"/>")?;
writer.write_all(b"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>")?;
writer.write_all(b"<sheetFormatPr defaultRowHeight=\"15\"/>")?;
writer.write_all(b"<sheetData>")?;
self.current_writer = Some(writer);
Ok(())
}
pub fn write_row(&mut self, values: &[&str]) -> Result<()> {
self.current_row += 1;
let row_num = self.current_row;
if values.len() as u32 > self.max_col {
self.max_col = values.len() as u32;
}
self.xml_buffer.clear();
self.xml_buffer.extend_from_slice(b"<row r=\"");
self.xml_buffer
.extend_from_slice(row_num.to_string().as_bytes());
self.xml_buffer.extend_from_slice(b"\">");
for (col_idx, value) in values.iter().enumerate() {
let col_num = (col_idx + 1) as u32;
let col_letter = Self::col_to_letter(col_num);
self.xml_buffer.extend_from_slice(b"<c r=\"");
self.xml_buffer.extend_from_slice(col_letter.as_bytes());
self.xml_buffer
.extend_from_slice(row_num.to_string().as_bytes());
if value.len() > 50 || self.shared_strings.count() >= 100_000 {
self.xml_buffer
.extend_from_slice(b"\" t=\"inlineStr\"><is><t>");
Self::write_escaped(&mut self.xml_buffer, value);
self.xml_buffer.extend_from_slice(b"</t></is></c>");
} else {
let string_id = self.shared_strings.add_string(value);
self.xml_buffer.extend_from_slice(b"\" t=\"s\"><v>");
self.xml_buffer
.extend_from_slice(string_id.to_string().as_bytes());
self.xml_buffer.extend_from_slice(b"</v></c>");
}
}
self.xml_buffer.extend_from_slice(b"</row>");
if let Some(writer) = &mut self.current_writer {
writer.write_all(&self.xml_buffer)?;
}
Ok(())
}
pub fn write_row_styled(&mut self, cells: &[crate::types::StyledCell]) -> Result<()> {
use crate::types::CellValue;
if self.current_writer.is_none() {
return Err(crate::error::ExcelError::WriteError(
"No active worksheet. Call add_worksheet() first.".to_string(),
));
}
self.current_row += 1;
let row_num = self.current_row;
let writer = self.current_writer.as_mut().unwrap();
write!(writer, "<row r=\"{}\"", row_num)?;
if !cells.is_empty() {
write!(writer, " spans=\"1:{}\"", cells.len())?;
}
write!(writer, ">")?;
for (col_idx, cell) in cells.iter().enumerate() {
let col_num = (col_idx + 1) as u32;
let col_letter = Self::col_to_letter(col_num);
let cell_ref = format!("{}{}", col_letter, row_num);
let style_index = cell.style.index();
match &cell.value {
CellValue::Empty => {
continue;
}
CellValue::String(s) => {
write!(writer, "<c r=\"{}\"", cell_ref)?;
if style_index > 0 {
write!(writer, " s=\"{}\"", style_index)?;
}
if s.len() > 50 || self.shared_strings.count() >= 100_000 {
write!(writer, " t=\"inlineStr\"><is><t>")?;
Self::write_escaped_to_writer(writer, s)?;
write!(writer, "</t></is></c>")?;
} else {
let string_index = self.shared_strings.add_string(s);
write!(writer, " t=\"s\"><v>{}</v></c>", string_index)?;
}
}
CellValue::Int(n) => {
write!(writer, "<c r=\"{}\"", cell_ref)?;
if style_index > 0 {
write!(writer, " s=\"{}\"", style_index)?;
}
write!(writer, "><v>{}</v></c>", n)?;
}
CellValue::Float(f) => {
write!(writer, "<c r=\"{}\"", cell_ref)?;
if style_index > 0 {
write!(writer, " s=\"{}\"", style_index)?;
}
write!(writer, "><v>{}</v></c>", f)?;
}
CellValue::Bool(b) => {
write!(writer, "<c r=\"{}\"", cell_ref)?;
if style_index > 0 {
write!(writer, " s=\"{}\"", style_index)?;
}
write!(writer, " t=\"b\"><v>{}</v></c>", if *b { 1 } else { 0 })?;
}
CellValue::Formula(formula) => {
write!(writer, "<c r=\"{}\"", cell_ref)?;
if style_index > 0 {
write!(writer, " s=\"{}\"", style_index)?;
}
write!(writer, "><f>{}</f></c>", formula)?;
}
CellValue::DateTime(_) | CellValue::Error(_) => {
let s = format!("{:?}", cell.value);
write!(writer, "<c r=\"{}\"", cell_ref)?;
if style_index > 0 {
write!(writer, " s=\"{}\"", style_index)?;
}
if s.len() > 50 || self.shared_strings.count() >= 100_000 {
write!(writer, " t=\"inlineStr\"><is><t>")?;
Self::write_escaped_to_writer(writer, &s)?;
write!(writer, "</t></is></c>")?;
} else {
let string_index = self.shared_strings.add_string(&s);
write!(writer, " t=\"s\"><v>{}</v></c>", string_index)?;
}
}
}
}
write!(writer, "</row>")?;
if self.current_row.is_multiple_of(1000) {
writer.flush()?;
}
Ok(())
}
pub fn set_column_width(&mut self, _col: u32, _width: f64) -> Result<()> {
Ok(())
}
pub fn set_next_row_height(&mut self, _height: f64) -> Result<()> {
Ok(())
}
pub fn set_flush_interval(&mut self, _interval: u32) {
}
pub fn set_max_buffer_size(&mut self, _size: usize) {
}
pub fn set_compression_level(&mut self, level: u32) {
self.compression_level = level.min(9);
}
pub fn compression_level(&self) -> u32 {
self.compression_level
}
pub fn close(mut self) -> Result<()> {
if let Some(mut writer) = self.current_writer.take() {
writer.write_all(b"</sheetData></worksheet>")?;
writer.flush()?;
}
self.write_shared_strings()?;
self.write_metadata_files()?;
self.zip_directory()?;
let temp_path = self.temp_dir.path().to_path_buf();
drop(self.temp_dir);
if temp_path.exists() {
let _ = fs::remove_dir_all(&temp_path);
}
Ok(())
}
fn write_shared_strings(&mut self) -> Result<()> {
use super::xml_writer::XmlWriter;
let path = self.temp_dir.path().join("xl/sharedStrings.xml");
let f = BufWriter::new(File::create(path)?);
let mut writer = XmlWriter::new(f);
self.shared_strings.write_xml(&mut writer)?;
writer.flush()?;
Ok(())
}
fn write_escaped(buf: &mut Vec<u8>, s: &str) {
for ch in s.chars() {
match ch {
'<' => buf.extend_from_slice(b"<"),
'>' => buf.extend_from_slice(b">"),
'&' => buf.extend_from_slice(b"&"),
'"' => buf.extend_from_slice(b"""),
'\'' => buf.extend_from_slice(b"'"),
_ => {
let mut bytes = [0u8; 4];
let s = ch.encode_utf8(&mut bytes);
buf.extend_from_slice(s.as_bytes());
}
}
}
}
fn write_metadata_files(&self) -> Result<()> {
let path = self.temp_dir.path().join("[Content_Types].xml");
let mut f = File::create(path)?;
write!(f, "<?xml version=\"1.0\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">")?;
write!(
f,
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>"
)?;
write!(f, "<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>")?;
write!(f, "<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>")?;
for i in 1..=self.worksheet_count {
write!(f, "<Override PartName=\"/xl/worksheets/sheet{}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>", i)?;
}
write!(f, "<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>")?;
write!(f, "<Override PartName=\"/xl/sharedStrings.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml\"/>")?;
write!(f, "<Override PartName=\"/docProps/app.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.extended-properties+xml\"/>")?;
write!(f, "<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>")?;
write!(f, "</Types>")?;
f.flush()?;
let path = self.temp_dir.path().join("xl/workbook.xml");
let mut f = File::create(path)?;
write!(f, "<?xml version=\"1.0\"?><workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><sheets>")?;
for (i, name) in self.worksheets.iter().enumerate() {
write!(
f,
"<sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
name,
i + 1,
i + 1
)?;
}
write!(f, "</sheets></workbook>")?;
f.flush()?;
let path = self.temp_dir.path().join("xl/styles.xml");
let mut f = File::create(path)?;
write!(
f,
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
)?;
write!(
f,
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">"
)?;
write!(f, "<fonts count=\"3\">")?;
write!(f, "<font><sz val=\"11\"/><color theme=\"1\"/><name val=\"Calibri\"/><family val=\"2\"/><scheme val=\"minor\"/></font>")?;
write!(f, "<font><b/><sz val=\"11\"/><color theme=\"1\"/><name val=\"Calibri\"/><family val=\"2\"/><scheme val=\"minor\"/></font>")?;
write!(f, "<font><i/><sz val=\"11\"/><color theme=\"1\"/><name val=\"Calibri\"/><family val=\"2\"/><scheme val=\"minor\"/></font>")?;
write!(f, "</fonts>")?;
write!(f, "<fills count=\"5\">")?;
write!(f, "<fill><patternFill patternType=\"none\"/></fill>")?;
write!(f, "<fill><patternFill patternType=\"gray125\"/></fill>")?;
write!(f, "<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFFFF00\"/><bgColor indexed=\"64\"/></patternFill></fill>")?;
write!(f, "<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF00FF00\"/><bgColor indexed=\"64\"/></patternFill></fill>")?;
write!(f, "<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFF0000\"/><bgColor indexed=\"64\"/></patternFill></fill>")?;
write!(f, "</fills>")?;
write!(f, "<borders count=\"2\">")?;
write!(
f,
"<border><left/><right/><top/><bottom/><diagonal/></border>"
)?;
write!(f, "<border><left style=\"thin\"><color auto=\"1\"/></left><right style=\"thin\"><color auto=\"1\"/></right><top style=\"thin\"><color auto=\"1\"/></top><bottom style=\"thin\"><color auto=\"1\"/></bottom><diagonal/></border>")?;
write!(f, "</borders>")?;
write!(f, "<cellStyleXfs count=\"1\">")?;
write!(
f,
"<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/>"
)?;
write!(f, "</cellStyleXfs>")?;
write!(f, "<cellXfs count=\"14\">")?;
write!(
f,
"<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>"
)?; write!(f, "<xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>")?; write!(f, "<xf numFmtId=\"3\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>")?; write!(f, "<xf numFmtId=\"4\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>")?; write!(f, "<xf numFmtId=\"5\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>")?; write!(f, "<xf numFmtId=\"9\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>")?; write!(f, "<xf numFmtId=\"14\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>")?; write!(f, "<xf numFmtId=\"22\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>")?; write!(f, "<xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>")?; write!(f, "<xf numFmtId=\"0\" fontId=\"2\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>")?; write!(f, "<xf numFmtId=\"0\" fontId=\"0\" fillId=\"2\" borderId=\"0\" xfId=\"0\" applyFill=\"1\"/>")?; write!(f, "<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"0\" xfId=\"0\" applyFill=\"1\"/>")?; write!(f, "<xf numFmtId=\"0\" fontId=\"0\" fillId=\"4\" borderId=\"0\" xfId=\"0\" applyFill=\"1\"/>")?; write!(f, "<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>")?; write!(f, "</cellXfs>")?;
write!(f, "<cellStyles count=\"1\">")?;
write!(f, "<cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/>")?;
write!(f, "</cellStyles>")?;
write!(f, "<dxfs count=\"0\"/>")?;
write!(f, "<tableStyles count=\"0\" defaultTableStyle=\"TableStyleMedium9\" defaultPivotStyle=\"PivotStyleLight16\"/>")?;
write!(f, "</styleSheet>")?;
f.flush()?;
let path = self.temp_dir.path().join("_rels/.rels");
let mut f = File::create(path)?;
write!(f, "<?xml version=\"1.0\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">")?;
write!(f, "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>")?;
write!(f, "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/>")?;
write!(f, "<Relationship Id=\"rId3\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties\" Target=\"docProps/app.xml\"/>")?;
write!(f, "</Relationships>")?;
f.flush()?;
let path = self.temp_dir.path().join("xl/_rels/workbook.xml.rels");
let mut f = File::create(path)?;
write!(f, "<?xml version=\"1.0\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">")?;
for i in 1..=self.worksheet_count {
write!(f, "<Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet{}.xml\"/>", i, i)?;
}
let next_id = self.worksheet_count + 1;
write!(f, "<Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>", next_id)?;
write!(f, "<Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings\" Target=\"sharedStrings.xml\"/>", next_id + 1)?;
write!(f, "</Relationships>")?;
f.flush()?;
let path = self.temp_dir.path().join("docProps/app.xml");
let mut f = File::create(path)?;
write!(
f,
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
)?;
write!(f, "<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\">")?;
write!(f, "<Application>ExcelStream</Application>")?;
write!(f, "<DocSecurity>0</DocSecurity>")?;
write!(f, "<ScaleCrop>false</ScaleCrop>")?;
write!(f, "<Company></Company>")?;
write!(f, "<LinksUpToDate>false</LinksUpToDate>")?;
write!(f, "<SharedDoc>false</SharedDoc>")?;
write!(f, "<HyperlinksChanged>false</HyperlinksChanged>")?;
write!(f, "<AppVersion>1.0</AppVersion>")?;
write!(f, "</Properties>")?;
f.flush()?;
let path = self.temp_dir.path().join("docProps/core.xml");
let mut f = File::create(path)?;
write!(
f,
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
)?;
write!(f, "<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">")?;
write!(f, "<dc:creator>ExcelStream</dc:creator>")?;
write!(f, "<cp:lastModifiedBy>ExcelStream</cp:lastModifiedBy>")?;
write!(
f,
"<dcterms:created xsi:type=\"dcterms:W3CDTF\">2024-01-01T00:00:00Z</dcterms:created>"
)?;
write!(
f,
"<dcterms:modified xsi:type=\"dcterms:W3CDTF\">2024-01-01T00:00:00Z</dcterms:modified>"
)?;
write!(f, "</cp:coreProperties>")?;
f.flush()?;
Ok(())
}
fn zip_directory(&self) -> Result<()> {
let file = File::create(&self.final_path)?;
let mut zip = ZipWriter::new(file);
let compression_method = if self.compression_level == 0 {
CompressionMethod::Stored
} else {
CompressionMethod::Deflated
};
let options = SimpleFileOptions::default()
.compression_method(compression_method)
.compression_level(Some(self.compression_level as i64));
self.add_file_to_zip(&mut zip, "[Content_Types].xml", &options)?;
self.add_file_to_zip(&mut zip, "_rels/.rels", &options)?;
self.add_file_to_zip(&mut zip, "docProps/app.xml", &options)?;
self.add_file_to_zip(&mut zip, "docProps/core.xml", &options)?;
self.add_file_to_zip(&mut zip, "xl/workbook.xml", &options)?;
self.add_file_to_zip(&mut zip, "xl/_rels/workbook.xml.rels", &options)?;
for i in 1..=self.worksheet_count {
self.add_file_to_zip(&mut zip, &format!("xl/worksheets/sheet{}.xml", i), &options)?;
}
self.add_file_to_zip(&mut zip, "xl/sharedStrings.xml", &options)?;
self.add_file_to_zip(&mut zip, "xl/styles.xml", &options)?;
zip.finish()?;
Ok(())
}
fn add_file_to_zip<W: Write + std::io::Seek>(
&self,
zip: &mut ZipWriter<W>,
relative_path: &str,
options: &SimpleFileOptions,
) -> Result<()> {
let full_path = self.temp_dir.path().join(relative_path);
zip.start_file(relative_path, *options)?;
let mut f = File::open(&full_path)?;
let mut buffer = vec![0u8; 64 * 1024];
loop {
let bytes_read = f.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
zip.write_all(&buffer[..bytes_read])?;
}
Ok(())
}
fn col_to_letter(col: u32) -> String {
let mut result = String::new();
let mut n = col;
while n > 0 {
let rem = (n - 1) % 26;
result.insert(0, (b'A' + rem as u8) as char);
n = (n - 1) / 26;
}
result
}
fn write_escaped_to_writer<W: Write>(writer: &mut W, s: &str) -> Result<()> {
for c in s.chars() {
match c {
'<' => writer.write_all(b"<")?,
'>' => writer.write_all(b">")?,
'&' => writer.write_all(b"&")?,
'"' => writer.write_all(b""")?,
'\'' => writer.write_all(b"'")?,
_ => write!(writer, "{}", c)?,
}
}
Ok(())
}
}