From eecfa4074d7ec2a4058e5d0e5b675c2d0c8ccc62 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:39:31 -0400 Subject: [PATCH] File formats: CSV/TSV export writes displayed values; templates and .xlsm saved with their own content types Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/display.rs | 9 +++- crates/engine/src/io.rs | 74 ++++++++++++++++++++++++++---- crates/xlsx/src/csv.rs | 22 +++------ crates/xlsx/src/lib.rs | 55 ++++++++++++++++++++-- crates/xlsx/src/tests/csv_tests.rs | 16 +++++-- crates/xlsx/src/tests/roundtrip.rs | 27 +++++++++++ crates/xlsx/src/write.rs | 13 ++---- docs/file-format-parity.md | 13 +++--- 8 files changed, 181 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 9770518..db7e7c8 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -80,15 +80,20 @@ pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { /// The text a cell shows (full precision General; the grid narrows General to fit the column). pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { - let v = sheet.value(c); if sheet.show_formulas && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()) && !formula_hidden(wb, sheet, c) { return format!("={}", f.text); } + value_text(wb, sheet, c) +} + +/// A cell's value with its number format applied, independent of column width: no `#####` +/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. +pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let style = wb.styles.get(sheet.style_id(c)); - let f = format(&v, style.num_fmt.as_str(), wb); + let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); match f.fill { Some((_, pos)) => { let mut t = f.text; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e388f87..1487d3b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -113,26 +113,28 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { - // A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm - // keeps the macros; any other name saves without them, as Excel does. - FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => { - let mut plain = wb.clone(); - plain.vba_project = None; - gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string())) - } - FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry + // the VBA project, so any other name saves without it, as Excel does. + FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())), FileKind::Xls => { Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into())) } FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), - FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), - FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()), + FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b',')).unwrap_or_default()), + FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), } } +/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all +/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not +/// transferred to other file formats*): number formats applied, never column-width `#####`. +fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { + gridcraft_xlsx::write_csv(sh, delimiter, |c| crate::display::value_text(wb, sh, c)) +} + fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } @@ -384,6 +386,8 @@ mod tests { assert!(has_macros("book.xlsm")); assert!(has_macros("BOOK.XLSM")); assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); + assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); + assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); } @@ -425,4 +429,54 @@ mod tests { assert!(msg.contains("not an Excel workbook"), "got: {msg}"); assert!(!msg.contains("password"), "got: {msg}"); } + + /// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, + /// whole values in narrow columns (never `#####`), quoting unchanged. + #[test] + fn csv_writes_displayed_text() { + use gridcraft_core::{CellError, Value}; + use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; + let mut wb = Workbook::new(); + let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + let (date, money, pct, frac, text, fixed, thousands) = + (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); + let sh = wb.sheet_mut(0).unwrap(); + let cells = [ + ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }), + ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }), + ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }), + ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }), + ("E1", Cell { value: Value::text("007"), formula: None, style: text }), + ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }), + ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }), + ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), + ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }), + ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }), + ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }), + ]; + for (a, c) in cells { + sh.set_cell(CellRef::parse(a).unwrap(), c); + } + // Column B is far too narrow for its numbers: the grid shows #####, the file must not. + sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); + sh.show_formulas = true; + let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); + assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); + let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); + assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); + } + + #[test] + fn template_extension_saves_template_content_type() { + let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut types = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); + assert!( + types.contains( + r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""# + ), + "{types}" + ); + } } diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 315bc96..b9e911c 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use gridcraft_core::parse::parse_input; -use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value, number_to_text}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value}; use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use crate::{CsvOptions, IoError}; @@ -183,20 +183,10 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { Ok(wb) } -fn field_text(v: &Value) -> String { - match v { - Value::Empty => String::new(), - Value::Number(n) => number_to_text(*n), - Value::Text(t) => t.to_string(), - Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(), - Value::Error(e) => e.as_str().into(), - Value::Array(a) => a.get(0, 0).map(field_text).unwrap_or_default(), - } -} - -/// Writes the sheet's used range as CSV (raw values, CRLF line ends, UTF-8 without BOM). -pub fn write_csv(sheet: &Sheet, wb: &Workbook, delimiter: u8) -> Vec { - let _ = wb; +/// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives +/// each cell's field text: the caller decides how values look (Save As CSV writes them as +/// displayed, number formats applied); quoting is done here. +pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec { let delim = if delimiter == 0 { ',' } else { delimiter as char }; let mut out = String::new(); let Some(r) = sheet.used_range() else { return Vec::new() }; @@ -205,7 +195,7 @@ pub fn write_csv(sheet: &Sheet, wb: &Workbook, delimiter: u8) -> Vec { if col > r.start.col { out.push(delim); } - let t = field_text(&sheet.value(CellRef::new(row, col))); + let t = cell_text(CellRef::new(row, col)); if t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r') { out.push('"'); out.push_str(&t.replace('"', "\"\"")); diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 23e3b7b..06523d7 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -74,14 +74,63 @@ impl ReadReport { } } -/// Reads an `.xlsx` / `.xlsm` package. +/// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the +/// package relationships, so every main-part content type is accepted). pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { read::read_xlsx(bytes) } -/// Writes a workbook as an `.xlsx` package. +/// Which member of the SpreadsheetML family a package is saved as. Only the content type of the +/// workbook part differs (ECMA-376 Part 1 Workbook part; MS-OI29500 adds the macro-enabled types). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum XlsxKind { + /// `.xlsx` + #[default] + Workbook, + /// `.xlsm` + MacroWorkbook, + /// `.xltx` + Template, + /// `.xltm` + MacroTemplate, +} + +impl XlsxKind { + /// The kind a file name's extension asks for; anything else is a plain workbook. + pub fn from_path(path: &str) -> XlsxKind { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + match ext.as_str() { + "xlsm" => XlsxKind::MacroWorkbook, + "xltx" => XlsxKind::Template, + "xltm" => XlsxKind::MacroTemplate, + _ => XlsxKind::Workbook, + } + } + + /// Content type of `/xl/workbook.xml`. + pub fn main_content_type(self) -> &'static str { + match self { + XlsxKind::Workbook => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + XlsxKind::MacroWorkbook => "application/vnd.ms-excel.sheet.macroEnabled.main+xml", + XlsxKind::Template => "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml", + XlsxKind::MacroTemplate => "application/vnd.ms-excel.template.macroEnabled.main+xml", + } + } + + /// Whether this kind may carry a VBA project (`.xlsm`, `.xltm`). + pub fn is_macro_enabled(self) -> bool { + matches!(self, XlsxKind::MacroWorkbook | XlsxKind::MacroTemplate) + } +} + +/// Writes a workbook as an `.xlsx` package, or as an `.xlsm` one when it holds a VBA project. pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { - write::write_xlsx(wb) + write::write_xlsx(wb, if wb.vba_project.is_some() { XlsxKind::MacroWorkbook } else { XlsxKind::Workbook }) +} + +/// Writes a workbook as the given member of the `.xlsx` family. +pub fn write_xlsx_as(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { + write::write_xlsx(wb, kind) } /// CSV import options. diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index 480380b..6ca6e92 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -94,11 +94,21 @@ fn writing() { s.set_value(CellRef::new(3, 3), Value::text("x\ny")); s.set_value(CellRef::new(3, 1), Value::Error(gridcraft_core::CellError::Div0)); wb.sheets = vec![std::sync::Arc::new(s)]; - let out = String::from_utf8(write_csv(wb.sheet(0).unwrap(), &wb, b',')).unwrap(); + // The caller supplies each field's text; quoting and line ends are the writer's. + let sh = wb.sheet(0).unwrap(); + let raw = |c: CellRef| match sh.value(c) { + Value::Empty => String::new(), + Value::Number(n) => gridcraft_core::number_to_text(n), + Value::Text(t) => t.to_string(), + Value::Bool(b) => if b { "TRUE" } else { "FALSE" }.into(), + Value::Error(e) => e.as_str().into(), + Value::Array(_) => String::new(), + }; + let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap(); assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n"); - let semi = String::from_utf8(write_csv(wb.sheet(0).unwrap(), &wb, b';')).unwrap(); + let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap(); assert!(semi.starts_with("a,b;")); - assert!(write_csv(&Sheet::new("E"), &wb, b',').is_empty()); + assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty()); // Round trip. let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap(); assert_eq!(v(&back, 0, 0), Value::text("a,b")); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index d2ecec7..821cf28 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -869,3 +869,30 @@ fn scroll_position_round_trips_with_and_without_frozen_panes() { let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap()); assert!(xml.contains(r#"topLeftCell="A2""#), "{xml}"); } + +/// Templates and macro-enabled files differ from `.xlsx` only in the workbook part's content +/// type; a template saved with the workbook type is rejected by Excel. +#[test] +fn package_kind_sets_workbook_content_type() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(at("A1"), Value::Number(7.0)); + for (path, kind, ct) in [ + ("a.xlsx", crate::XlsxKind::Workbook, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), + ("a.XLTX", crate::XlsxKind::Template, "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml"), + ("a.xlsm", crate::XlsxKind::MacroWorkbook, "application/vnd.ms-excel.sheet.macroEnabled.main+xml"), + ("dir.x/a.xltm", crate::XlsxKind::MacroTemplate, "application/vnd.ms-excel.template.macroEnabled.main+xml"), + ] { + assert_eq!(crate::XlsxKind::from_path(path), kind); + let bytes = crate::write_xlsx_as(&wb, kind).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap(); + let main: Vec<_> = + types.kids("Override").filter(|e| e.attr("PartName") == Some("/xl/workbook.xml")).filter_map(|e| e.attr("ContentType")).collect(); + assert_eq!(main, [ct], "{path}"); + // Every kind reads back. + let (back, report) = read_xlsx(&bytes).unwrap(); + assert!(report.warnings.is_empty(), "{path}: {:?}", report.warnings); + assert_eq!(back.sheet(0).unwrap().value(at("A1")), Value::Number(7.0)); + } + assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 0cb8246..3323637 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -7,9 +7,9 @@ use gridcraft_core::RangeRef; use gridcraft_formula::quote_sheet; use gridcraft_model::{CalcMode, Style, Visibility, Workbook}; -use crate::IoError; use crate::package::ZipOut; use crate::xml::{esc, esc_attr}; +use crate::{IoError, XlsxKind}; pub const NS_MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; pub const NS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; @@ -20,10 +20,6 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"; pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; -/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main -/// part uses the macro-free content type, and vice versa). -pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; -pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"; pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject"; pub const CT_THREADED_COMMENTS: &str = "application/vnd.ms-excel.threadedcomments+xml"; pub const CT_PERSONS: &str = "application/vnd.ms-excel.person+xml"; @@ -165,7 +161,7 @@ pub fn abs_range(r: &RangeRef) -> String { format!("${}${}:${}${}", c(r.start.col), r.start.row + 1, c(r.end.col), r.end.row + 1) } -pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { +pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { if wb.sheets.is_empty() { return Err(IoError::Format("a workbook needs at least one sheet".into())); } @@ -346,8 +342,9 @@ pub fn write_xlsx(wb: &Workbook) -> Result, IoError> { out.part("xl/persons/person.xml", Some(CT_PERSONS), x.into_bytes()); wb_rels.add_uri(REL_PERSON, "persons/person.xml"); } - out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes()); - if let Some(data) = &wb.vba_project { + out.part("xl/workbook.xml", Some(kind.main_content_type()), w.into_bytes()); + // A macro-free package can't hold a VBA project (Excel refuses such a file). + if let Some(data) = wb.vba_project.as_ref().filter(|_| kind.is_macro_enabled()) { wb_rels.add("vbaProject", "vbaProject.bin"); out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone()); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06dd452..1a92432 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (`.xls` BIFF8 import, #121) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CSV/TSV export writes displayed values; templates and `.xlsm` saved with their own content types) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -19,8 +19,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | -| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | -| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | +| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | +| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | | `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | @@ -34,8 +34,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) | -| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | | `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table) | Formatting not exported | @@ -69,13 +69,14 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | -| CSV/text: displayed-value export, encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 5–8 | +| CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | | PDF: embed subsetted system fonts so every script exports | (counted in [localization](localization-parity.md) script work) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | | 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) | | 2026-10-11 | minor | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls, OLE objects, sheet backgrounds kept on save (#330, #324) | | 2026-10-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) |