#2765: CSV and text open detect each column's date order

This commit is contained in:
Max Murphy
2026-10-31 16:57:58 +01:00
parent 67e39faeea
commit 72b1310a5a
7 changed files with 348 additions and 17 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (CSV and text open detects each column's date order, so day-first dates are not silently swapped; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
+4 -4
View File
@@ -182,17 +182,17 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
Ok((wb, report.warnings))
}
_ => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
Ok((wb, report.warnings))
}
}
}
+14
View File
@@ -291,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() {
assert_eq!(wb.sheets[0].name, "Sales");
}
#[test]
fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() {
let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap();
let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap();
for file in ["dates.csv", "dates.txt"] {
let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}");
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}");
assert!(warnings.is_empty(), "{file}: {warnings:?}");
}
let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap();
assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
+240 -5
View File
@@ -2,11 +2,12 @@
use std::sync::Arc;
use gridcraft_core::parse::parse_input;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value};
use gridcraft_core::date::{days_in_month_in, serial_from_ymd};
use gridcraft_core::parse::{Parsed, parse_input};
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, Value, col_to_letters};
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
use crate::{CsvOptions, IoError};
use crate::{CsvOptions, IoError, ReadReport};
/// The largest CSV or tab-separated file written (bytes).
pub const MAX_CSV_BYTES: usize = 512 << 20;
@@ -248,13 +249,246 @@ pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record:
}
}
/// How many rows and bytes of a text file are read to decide each column's date order.
const DATE_SAMPLE_ROWS: usize = 200;
const DATE_SAMPLE_BYTES: usize = 1 << 20;
/// What a column's numeric dates say about the order of day and month.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DateRule {
/// No `d/m/y` or `d-m-y` value in the sample: read as typed input does.
NoDates,
/// A value reads only month first (`12/31/2026`), none reads only day first.
MonthFirst,
/// A value reads only day first (`31/05/2026`), none reads only month first.
DayFirst,
/// Every date is ambiguous (`03/04/2026`): keep the input parser's month-first reading.
Ambiguous,
/// Values read only day first and values read only month first: the dates stay text.
Conflict,
}
impl DateRule {
fn of(day_first: usize, month_first: usize, ambiguous: usize) -> Self {
match (day_first > 0, month_first > 0, ambiguous > 0) {
(true, true, _) => Self::Conflict,
(true, false, _) => Self::DayFirst,
(false, true, _) => Self::MonthFirst,
(false, false, true) => Self::Ambiguous,
(false, false, false) => Self::NoDates,
}
}
}
/// The evidence a column's sample gives for its date order.
#[derive(Clone, Default)]
struct DateEvidence {
day_first: usize,
month_first: usize,
ambiguous: usize,
day_first_example: Option<String>,
month_first_example: Option<String>,
ambiguous_example: Option<String>,
}
/// A numeric `d/m/y` or `d-m-y` field, with the serial it has read each way and its time of day.
struct NumericDate {
day_first: Option<f64>,
month_first: Option<f64>,
time: Option<f64>,
}
impl NumericDate {
/// The field under the column's rule: a date when that reading is valid, else text.
fn read(&self, field: &str, rule: DateRule) -> Parsed {
let serial = match rule {
DateRule::DayFirst => self.day_first,
DateRule::MonthFirst | DateRule::Ambiguous => self.month_first,
DateRule::Conflict | DateRule::NoDates => None,
};
match serial {
Some(serial) => Parsed {
value: Value::number(serial + self.time.unwrap_or(0.0)),
format: Some(if self.time.is_some() { "m/d/yyyy h:mm" } else { "m/d/yyyy" }),
},
None => Parsed { value: Value::text(field), format: None },
}
}
}
/// A numeric date written with `/` or `-`, with the serial it has read day first and month first.
/// `None` when the field isn't such a date (including ISO `yyyy-mm-dd`, month names and dots,
/// which read one way and keep the input parser).
fn numeric_date(field: &str, sys: DateSystem) -> Option<NumericDate> {
let text = field.trim();
let (date, time) = split_time(text, sys);
// Typed input reads a trailing comma after a date (`3/4/2026,`); do the same here.
let date = date.trim_end_matches(',');
let mut parts = date.split(['/', '-']);
let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let (a, b, c) = (a.trim(), b.trim(), c.trim());
// ISO dates are unambiguous; the input parser reads them. A three-digit year is not a
// date to typed input either.
if a.len() == 4 || c.len() == 3 {
return None;
}
let a = parse_digits(a)?;
let b = parse_digits(b)?;
let year = expand_year(c, parse_digits(c)?);
let day_first = valid_date(sys, year, b, a);
let month_first = valid_date(sys, year, a, b);
if day_first.is_none() && month_first.is_none() {
return None;
}
Some(NumericDate { day_first, month_first, time })
}
/// Splits a field into its date and a time of day, when the tail reads as a time. Both the first
/// and last space are tried, so `03/04/2026 10:30 AM` and `03 / 04 / 2026 10:30` both split.
fn split_time(text: &str, sys: DateSystem) -> (&str, Option<f64>) {
for at in [text.find(' '), text.rfind(' ')].into_iter().flatten() {
let (date, time) = text.split_at(at);
let time = time.trim();
// Times are short; a long tail is not one, and parsing it would be wasted work.
if time.len() > 40 {
continue;
}
if let Some(fraction) = time_of_day(time, sys) {
return (date.trim(), Some(fraction));
}
}
(text, None)
}
fn time_of_day(text: &str, sys: DateSystem) -> Option<f64> {
match parse_input(text, sys) {
Parsed { value: Value::Number(fraction), format: Some(code) } if code.contains('h') => Some(fraction),
_ => None,
}
}
fn parse_digits(s: &str) -> Option<i64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Expands a one- or two-digit year the way typed input does (`26` is 2026, `30` is 1930).
fn expand_year(text: &str, year: i64) -> i64 {
if text.len() <= 2 { if year < 30 { 2000 + year } else { 1900 + year } } else { year }
}
/// The serial for a date the input parser accepts, or `None`.
fn valid_date(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
if !(1..=12).contains(&month) || day < 1 || year < 1900 {
return None;
}
let year = i32::try_from(year).ok()?;
let month = u32::try_from(month).ok()?;
if day > i64::from(days_in_month_in(sys, year, month)) {
return None;
}
serial_from_ymd(sys, i64::from(year), i64::from(month), day)
}
/// The first records of the file, bounded by rows and bytes, for date-order inference.
fn sample_records(text: &str, delim: char, quote: char) -> Vec<Vec<String>> {
let mut cut = text.len().min(DATE_SAMPLE_BYTES);
while !text.is_char_boundary(cut) {
cut -= 1;
}
let truncated = cut < text.len();
let sample = text.get(..cut).unwrap_or_default();
let mut rows = Vec::new();
parse_records(sample, delim, quote, |record| {
rows.push(record);
rows.len() < DATE_SAMPLE_ROWS
});
// A cut through the last record leaves a partial field; it says nothing reliable.
if truncated {
rows.pop();
}
rows
}
/// The date rule for each column, from the sample.
fn infer_date_rules(rows: &[Vec<String>], sys: DateSystem) -> Vec<(DateRule, DateEvidence)> {
let width = rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_COLS as usize);
let mut columns = vec![DateEvidence::default(); width];
for row in rows {
for (col, field) in row.iter().enumerate() {
let Some(column) = columns.get_mut(col) else { continue };
let Some(date) = numeric_date(field, sys) else { continue };
let example = || field.trim().chars().take(40).collect::<String>();
match (date.day_first.is_some(), date.month_first.is_some()) {
(true, true) => {
column.ambiguous += 1;
column.ambiguous_example.get_or_insert_with(example);
}
(true, false) => {
column.day_first += 1;
column.day_first_example.get_or_insert_with(example);
}
(false, true) => {
column.month_first += 1;
column.month_first_example.get_or_insert_with(example);
}
(false, false) => {}
}
}
}
columns.into_iter().map(|c| (DateRule::of(c.day_first, c.month_first, c.ambiguous), c)).collect()
}
/// Warns when the sample could not choose a date order: an all-ambiguous column keeps the
/// month-first reading, and a column with both kinds keeps its dates as text.
fn date_warnings(columns: &[(DateRule, DateEvidence)], report: &mut ReadReport) {
for (col, (rule, evidence)) in columns.iter().enumerate() {
match rule {
DateRule::Ambiguous => report.warn(format!(
"column {}: every date is ambiguous, so it was read month first ({})",
col_to_letters(col as u32),
evidence.ambiguous_example.as_deref().unwrap_or("a date")
)),
DateRule::Conflict => report.warn(format!(
"column {}: it holds both day-first and month-first dates ({} and {}), so its dates stay text",
col_to_letters(col as u32),
evidence.day_first_example.as_deref().unwrap_or("a day-first date"),
evidence.month_first_example.as_deref().unwrap_or("a month-first date")
)),
DateRule::NoDates | DateRule::MonthFirst | DateRule::DayFirst => {}
}
}
}
/// Reads one field with the column's date rule; other values go through typed input.
fn read_field(field: &str, sys: DateSystem, rule: DateRule) -> Parsed {
match numeric_date(field, sys) {
Some(date) if rule != DateRule::NoDates => date.read(field, rule),
_ => parse_input(field, sys),
}
}
pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
read_csv_with_report(bytes, opts).map(|(wb, _)| wb)
}
/// [`read_csv`], with the warnings an open shows: a column whose date order had to be guessed,
/// or whose dates stayed text because it holds both orders.
pub fn read_csv_with_report(bytes: &[u8], opts: &CsvOptions) -> Result<(Workbook, ReadReport), IoError> {
let text = decode_text(bytes);
let quote = if opts.quote == 0 { '"' } else { opts.quote as char };
let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char;
if delim == quote {
return Err(IoError::Format("the delimiter and quote character must differ".into()));
}
let date_columns = if opts.parse_values { infer_date_rules(&sample_records(&text, delim, quote), opts.date_system) } else { vec![] };
let mut report = ReadReport::default();
date_warnings(&date_columns, &mut report);
let mut wb = Workbook::new();
wb.date_system = opts.date_system;
wb.csv_delimiter = u8::try_from(delim).ok();
@@ -276,7 +510,8 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
}
let pos = CellRef::new(row, col as u32);
let cell = if opts.parse_values {
let p = parse_input(&f, opts.date_system);
let rule = date_columns.get(col).map_or(DateRule::NoDates, |(rule, _)| *rule);
let p = read_field(&f, opts.date_system, rule);
let style = match p.format {
Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) {
Some((_, id)) => *id,
@@ -299,7 +534,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
});
wb.styles = styles;
wb.sheets = vec![Arc::new(sheet)];
Ok(wb)
Ok((wb, report))
}
/// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows
+1 -1
View File
@@ -45,7 +45,7 @@ use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited};
pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited};
pub use ods::read_ods;
pub use package::MAX_PART;
pub use passthrough::rebase_links;
+81 -1
View File
@@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
use gridcraft_model::{Sheet, Workbook};
use crate::csv::{decode_text, parse_records, sniff_delimiter};
use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited};
use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited};
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
wb.sheet(0).unwrap().value(CellRef::new(r, c))
@@ -23,6 +23,86 @@ fn quoting_and_newlines() {
assert_eq!(v(&wb, 3, 0), Value::text("last"));
}
fn date(y: i64, m: i64, d: i64) -> Value {
Value::Number(gridcraft_core::date::serial_from_ymd(DateSystem::D1900, y, m, d).unwrap())
}
#[test]
fn day_first_columns_read_ambiguous_dates_day_first() {
let src = "CustomerID,Name,City,SignupDate,RenewalDate,CardNumber,Balance\n\
00123,Zoë Ångström,Malmö,03/04/2026,25/12/2026,4111111111111111,1234.50\n\
00456,José Núñez,São Paulo,12/01/2026,01/02/2027,5500005555555559,99.90\n\
00789,Françoise Lefèvre,Besançon,31/05/2026,30/11/2026,4012888888881881,0.00\n\
01000,Jürgen Weiß,Köln,07/08/2026,13/09/2026,3530111333300000,-15.25\n";
let (wb, report) = read_csv_with_report(src.as_bytes(), &CsvOptions::default()).unwrap();
// A value that reads only day first (31/05, 25/12) makes the whole column day first.
assert_eq!(v(&wb, 1, 3), date(2026, 4, 3));
assert_eq!(v(&wb, 2, 3), date(2026, 1, 12));
assert_eq!(v(&wb, 3, 3), date(2026, 5, 31));
assert_eq!(v(&wb, 4, 3), date(2026, 8, 7));
assert_eq!(v(&wb, 1, 4), date(2026, 12, 25));
assert_eq!(v(&wb, 2, 4), date(2027, 2, 1));
assert_eq!(v(&wb, 3, 4), date(2026, 11, 30));
assert_eq!(v(&wb, 4, 4), date(2026, 9, 13));
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[test]
fn month_first_columns_keep_month_first_without_a_warning() {
let (wb, report) = read_csv_with_report(b"When\n12/31/2026\n01/02/2027\n", &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 1, 0), date(2026, 12, 31));
assert_eq!(v(&wb, 2, 0), date(2027, 1, 2));
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[test]
fn an_all_ambiguous_date_column_keeps_month_first_and_warns() {
let (wb, report) = read_csv_with_report(b"When\n03/04/2026\n12/01/2026\n", &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 1, 0), date(2026, 3, 4));
assert_eq!(v(&wb, 2, 0), date(2026, 12, 1));
assert!(report.warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{:?}", report.warnings);
// Keeping every field as text reads no dates, so there is no order to guess.
let (_, report) = read_csv_with_report(b"When\n03/04/2026\n12/01/2026\n", &CsvOptions { parse_values: false, ..Default::default() }).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[test]
fn a_column_with_both_orders_keeps_its_dates_as_text_and_warns() {
let (wb, report) = read_csv_with_report(b"When\n31/05/2026\n12/31/2026\n", &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 1, 0), Value::text("31/05/2026"));
assert_eq!(v(&wb, 2, 0), Value::text("12/31/2026"));
assert!(report.warnings.iter().any(|w| w.contains("column A") && w.contains("both day-first and month-first")), "{:?}", report.warnings);
}
#[test]
fn dashes_and_two_digit_years_follow_the_column_order() {
let (wb, report) = read_csv_with_report(b"When\n31-05-26\n03-04-26\n", &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 1, 0), date(2026, 5, 31));
assert_eq!(v(&wb, 2, 0), date(2026, 4, 3));
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let (wb, report) = read_csv_with_report(b"When\n12-31-26\n01-02-27\n", &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 1, 0), date(2026, 12, 31));
assert_eq!(v(&wb, 2, 0), date(2027, 1, 2));
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[test]
fn a_day_first_date_and_time_is_read_day_first() {
let (wb, report) = read_csv_with_report(b"When\n31/05/2026 10:30\n03/04/2026 10:30\n", &CsvOptions::default()).unwrap();
let at = |y, m, d| Value::Number(gridcraft_core::date::serial_from_ymd(DateSystem::D1900, y, m, d).unwrap() + 10.5 / 24.0);
assert_eq!(v(&wb, 1, 0), at(2026, 5, 31));
assert_eq!(v(&wb, 2, 0), at(2026, 4, 3));
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[test]
fn iso_dates_are_not_reordered_by_their_column() {
let wb = read_csv(b"When\n31/05/2026\n2026-03-04\n", &CsvOptions::default()).unwrap();
assert_eq!(v(&wb, 1, 0), date(2026, 5, 31));
assert_eq!(v(&wb, 2, 0), date(2026, 3, 4));
}
#[test]
fn values_and_formats() {
let src = "1,2.5,\"1,234\",50%,TRUE,#N/A,2026-03-04,$5.00,'007,text";
+6 -5
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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**
@@ -66,10 +66,10 @@ values) is still dropped.
| 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, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | 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, likely Windows-1256 Arabic detection, otherwise 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 `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | 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, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | 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, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection; per-column day/month date-order detection for `/` and `-` dates) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | 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; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `<img>` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported |
@@ -110,6 +110,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |