File formats: CSV/TSV export writes displayed values; templates and .xlsm saved with their own content types

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
neilcawse 2026-10-11 16:39:31 -04:00
parent a5137872c1
commit eecfa4074d
8 changed files with 181 additions and 48 deletions

View File

@ -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;

View File

@ -113,26 +113,28 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
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<u8> {
gridcraft_xlsx::write_csv(sh, delimiter, |c| crate::display::value_text(wb, sh, c))
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
@ -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}"
);
}
}

View File

@ -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<Workbook, IoError> {
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<u8> {
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<u8> {
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<u8> {
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('"', "\"\""));

View File

@ -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<Vec<u8>, 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<Vec<u8>, IoError> {
write::write_xlsx(wb, kind)
}
/// CSV import options.

View File

@ -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"));

View File

@ -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);
}

View File

@ -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<Vec<u8>, IoError> {
pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, 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<Vec<u8>, 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());
}

View File

@ -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) |