Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This commit is contained in:
Eduardo Garcia
2026-10-20 18:39:49 -03:00
co-authored by Claude Sonnet 5.5
parent 68e58a2eae
commit eb30ab8f3f
3 changed files with 67 additions and 2 deletions
+15 -1
View File
@@ -40,6 +40,7 @@ const MAX_ITEMS: usize = 1_048_576;
const MAX_FIELDS: usize = 16_384;
/// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank).
const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}";
const EXT_X14_PIVOT_TABLE: &str = "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}";
const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -850,7 +851,14 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
} else {
row_levels.max(1)
};
let _ = write!(s, "<location ref=\"{}\" firstHeaderRow=\"1\" firstDataRow=\"{first_data_row}\" firstDataCol=\"{first_data_col}\"", loc.a1());
// Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row.
let hide_values_row = col_values.is_some() && col_idx.is_empty();
let first_header_row = usize::from(!hide_values_row);
let _ = write!(
s,
"<location ref=\"{}\" firstHeaderRow=\"{first_header_row}\" firstDataRow=\"{first_data_row}\" firstDataCol=\"{first_data_col}\"",
loc.a1()
);
if !pages.is_empty() {
let _ = write!(s, " rowPageCount=\"{}\" colPageCount=\"1\"", pages.len());
}
@@ -1007,6 +1015,12 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
let _ = write!(s, " name=\"{}\"", esc_attr(&pt.style));
}
s.push_str(" showRowHeaders=\"1\" showColHeaders=\"1\" showRowStripes=\"0\" showColStripes=\"0\" showLastColumn=\"1\"/>");
if hide_values_row {
let _ = write!(
s,
"<extLst><ext uri=\"{EXT_X14_PIVOT_TABLE}\" xmlns:x14=\"{NS_X14}\"><x14:pivotTableDefinition hideValuesRow=\"1\"/></ext></extLst>"
);
}
s.push_str("</pivotTableDefinition>");
s
}
+50
View File
@@ -674,3 +674,53 @@ fn data_on_rows_with_one_value_field_reads_without_warning() {
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert!(back.sheet(0).unwrap().pivots[0].values_on_rows);
}
/// A one-sheet-of-data workbook with a pivot on "Report": `columns` are the source columns
/// (header, values), `pt` the pivot built on `Data!A1:<last>`.
fn small_pivot(columns: &[(&str, Vec<Value>)], pt: impl FnOnce(&mut PivotTable)) -> HashMap<String, String> {
let mut wb = Workbook::new();
let data = wb.sheet_mut(0).unwrap();
data.name = "Data".into();
let mut rows = 0;
for (c, (h, vals)) in columns.iter().enumerate() {
data.cells.set(CellRef::new(0, c as u32), Cell::value(Value::text(*h)));
for (r, v) in vals.iter().enumerate() {
if *v != Value::Empty {
data.cells.set(CellRef::new(r as u32 + 1, c as u32), Cell::value(v.clone()));
}
}
rows = rows.max(vals.len());
}
let last = format!("{}{}", gridcraft_core::col_to_letters(columns.len() as u32 - 1), rows + 1);
let mut table = PivotTable { id: 1, name: "PT".into(), source: format!("Data!$A$1:{last}"), anchor: at("A3"), ..PivotTable::default() };
pt(&mut table);
let mut report = Sheet::new("Report");
report.pivots = vec![table];
wb.sheets.push(Arc::new(report));
unzip(&write_xlsx(&wb).unwrap()).into_iter().map(|(k, v)| (k, String::from_utf8_lossy(&v).into_owned())).collect()
}
/// #1447: two value fields and no column field: Excel 365 adds a `Values` row unless told not to.
#[test]
fn two_value_fields_without_columns_hide_the_values_row() {
let cols = [
("Region", vec![Value::text("East"), Value::text("West"), Value::text("East")]),
("Sales", vec![Value::number(10.0), Value::number(20.0), Value::number(5.0)]),
("Qty", vec![Value::number(1.0), Value::number(2.0), Value::number(3.0)]),
];
let value = |c: &str| PivotValue { source_col: c.into(), name: format!("Sum of {c}"), ..PivotValue::default() };
let two = small_pivot(&cols, |p| {
p.rows = vec![field("Region")];
p.values = vec![value("Sales"), value("Qty")];
});
let t = &two["xl/pivotTables/pivotTable1.xml"];
assert!(t.contains("firstHeaderRow=\"0\" firstDataRow=\"1\""), "{t}");
assert!(t.contains("<x14:pivotTableDefinition hideValuesRow=\"1\"/>"), "{t}");
let one = small_pivot(&cols, |p| {
p.rows = vec![field("Region")];
p.values = vec![value("Sales")];
});
let t = &one["xl/pivotTables/pivotTable1.xml"];
assert!(t.contains("firstHeaderRow=\"1\""), "{t}");
assert!(!t.contains("hideValuesRow"), "{t}");
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.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 (XLSX PivotTables with two value fields keep their layout in Excel (#1447); .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**
@@ -91,6 +91,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) |
| 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other |
| 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-16 | minor | XLSX PivotTables: two value fields without a column field are written with `firstHeaderRow="0"` and `hideValuesRow`, so Excel 365 doesn't add a `Values` row that can turn the report into `#SPILL!` (#1447, item 6) |
| 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-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) |