mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
Merge PR #1514: Fix scalar INDEX/MATCH XLSX export
Conflicts: - docs/file-format-parity.md: kept main's header and log rows, added this PR's change phrase and row once
This commit is contained in:
@@ -154,7 +154,9 @@ impl Scan<'_> {
|
||||
"INDEX" => {
|
||||
let row = args.get(1);
|
||||
let col = args.get(2);
|
||||
!row.is_some_and(positive) || col.is_some_and(|e| !positive(e)) || (col.is_none() && !args.first().is_some_and(single_column))
|
||||
let row_positive = row.is_some_and(|e| positive_index(e, shapes.get(1).copied()));
|
||||
let col_positive = col.is_some_and(|e| positive_index(e, shapes.get(2).copied()));
|
||||
!row_positive || col.is_some_and(|_| !col_positive) || (col.is_none() && !args.first().is_some_and(single_column))
|
||||
}
|
||||
"ROW" | "COLUMN" => {
|
||||
affected |= first_array && !array_context;
|
||||
@@ -187,6 +189,11 @@ fn positive(e: &Expr) -> bool {
|
||||
matches!(e, Expr::Number(n) if *n >= 1.0)
|
||||
}
|
||||
|
||||
fn positive_index(e: &Expr, shape: Option<Shape>) -> bool {
|
||||
// MATCH returns a one-based position or an error, never INDEX's zero "whole row" selector.
|
||||
positive(e) || (matches!(e, Expr::Call(name, _) if matches!(name.as_str(), "MATCH" | "XMATCH")) && shape.is_some_and(|s| !s.array))
|
||||
}
|
||||
|
||||
fn one(e: &Expr) -> bool {
|
||||
matches!(e, Expr::Number(n) if *n == 1.0)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,47 @@ fn scalar_formulas_constants_and_legacy_cse_do_not_require_dynamic_metadata() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_index_match_formulas_remain_scalar_after_xlsx_roundtrip() {
|
||||
let source = super::minimal(
|
||||
r#"<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>a</t></is></c><c r="B1"><v>0.1</v></c><c r="D1" t="inlineStr"><is><t>c</t></is></c><c r="E1" t="inlineStr"><is><t>x</t></is></c><c r="F1"><f>INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0))</f><v>0.7</v></c><c r="F3" t="str"><f>IF(E1="","",INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0)))</f><v>0.7</v></c><c r="F5"><f>VLOOKUP(D1,$A$1:$B$3,2,FALSE)</f><v>0.7</v></c><c r="F6"><f>SUM($B$1:$B$3)</f><v>1.2</v></c><c r="F7"><f>MATCH(D1,$A$1:$A$3,0)</f><v>3</v></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>b</t></is></c><c r="B2"><v>0.4</v></c><c r="F2"><f>IFERROR(INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0)),0)</f><v>0.7</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>c</t></is></c><c r="B3"><v>0.7</v></c></row></sheetData>"#,
|
||||
&[],
|
||||
"",
|
||||
"",
|
||||
);
|
||||
let (opened, _) = read_xlsx(&source).unwrap();
|
||||
let saved = write_xlsx(&opened).unwrap();
|
||||
let mut pkg = Package::open(&saved).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
|
||||
for address in ["F1", "F2", "F3", "F5", "F6", "F7"] {
|
||||
assert_scalar(&sheet, address);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_legacy_index_match_formulas_remain_scalar_after_xlsx_roundtrip() {
|
||||
let mut body = String::from("<sheetData>");
|
||||
for row in 16..=39 {
|
||||
let formula = if row == 16 {
|
||||
format!(r#"<f t="shared" ref="F16:F39" si="0">IF(E{row}="","",IFERROR(INDEX($B$1:$B$3,MATCH(D{row},$A$1:$A$3,0)),0))</f>"#)
|
||||
} else {
|
||||
r#"<f t="shared" si="0"/>"#.to_string()
|
||||
};
|
||||
body.push_str(&format!(r#"<row r="{row}"><c r="F{row}">{formula}<v>0.7</v></c></row>"#));
|
||||
}
|
||||
body.push_str("</sheetData>");
|
||||
let source = super::minimal(&body, &[], "", "");
|
||||
let (opened, _) = read_xlsx(&source).unwrap();
|
||||
let saved = write_xlsx(&opened).unwrap();
|
||||
let mut pkg = Package::open(&saved).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
|
||||
for row in 16..=39 {
|
||||
assert_scalar(&sheet, &format!("F{row}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_table_column_keeps_formula_and_cell_value() {
|
||||
let mut wb = Workbook::new();
|
||||
@@ -304,6 +345,11 @@ fn implicit_intersection_metadata_respects_function_argument_context() {
|
||||
("SQRT(INDIRECT(A1))", true),
|
||||
("INDEX(A1:B3,1,1)", false),
|
||||
("INDEX(A1:A3,1)", false),
|
||||
("INDEX(A1:A3,MATCH(D1,A1:A3,0))", false),
|
||||
("IFERROR(INDEX(A1:A3,MATCH(D1,A1:A3,0)),0)", false),
|
||||
("IF(E1=\"\",\"\",INDEX(A1:A3,MATCH(D1,A1:A3,0)))", false),
|
||||
("INDEX(A1:B3,MATCH(D1,A1:A3,0),MATCH(E1,A1:B1,0))", false),
|
||||
("INDEX(A1:B3,MATCH(D1,A1:A3,0))", true),
|
||||
("SUM(INDEX(A1:B3,0,1))", false),
|
||||
("SQRT(INDEX(A1:B3,0,1))", true),
|
||||
("SUM(ROW(A1:A3))", true),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX text keeps carriage returns on save and reopen; .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) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (legacy XLSX `INDEX`/`MATCH` formulas stay scalar on save; XLSX text keeps carriage returns on save and reopen; .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) · **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**
|
||||
@@ -11,6 +11,9 @@ documentation (the Office bundle is off limits under this repo's clean-room rule
|
||||
support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and the tests in
|
||||
`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, XLS, PivotTables).
|
||||
|
||||
Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when
|
||||
shared formulas are expanded during import (`array_export.rs`, #1505).
|
||||
|
||||
## Workbook formats
|
||||
|
||||
Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point
|
||||
@@ -93,6 +96,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
| 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` |
|
||||
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
|
||||
| 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses ` ` (#1488) |
|
||||
| 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) |
|
||||
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
|
||||
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
|
||||
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
|
||||
|
||||
Reference in New Issue
Block a user