Skip to content

PivotTables: refresh keeps sort by value, captions, item order and layout settings - #722

Open
neilcawse wants to merge 11 commits into
storytold:mainfrom
neilcawse:fix/pivot-refresh-fidelity
Open

neilcawse wants to merge 11 commits into
storytold:mainfrom
neilcawse:fix/pivot-refresh-fidelity

Conversation

@neilcawse

@neilcawse neilcawse commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Refreshing a PivotTable read from an Excel file gave a different report from the one Excel shows. Items came out sorted by label when the file sorts them by a value, headers read "Row Labels", "Values" and the source column names instead of the file's captions, renamed items lost their names, manually ordered columns were re-sorted, and per-field subtotal and label settings were dropped. The PivotTable definition stores all of these settings, but GridCraft didn't read them, or read them and didn't apply them.

What changed

The model changes are in crates/model/src/features.rs, the layout changes in crates/engine/src/pivot.rs and the XLSX read/write in crates/xlsx/src/pivot.rs. Every new field is #[serde(default)].

  • Sort by a value field. PivotField.sort_by: Option<String> (a value field's caption) sorts the items by that value field in the sort direction, within each parent item, with subtotals. It is read from <autoSortScope> when the scope refers to the Values field (field="4294967294") and data item n, and written back the same way. pivot.sort takes by: "Sum of Sales" (a caption or a source field in the Values area). Renaming the value field with pivot.valueSettings keeps the sort.
  • Sort scope. PivotField.sort_scope: Vec<PivotScopeItem> (source column, date grouping, item label) says where the sort takes its values: Excel's "sort by the values in the Apples column" adds a reference to an item of a field on the other axis to the <autoSortScope>. It is read, written back (as the index into that field's <items>) and used by the engine, which used to sort by the grand total. Entries name their field by source column and date grouping, so Years and Quarters grouped from one date column stay apart. The engine and the writer resolve a scope the same way: the other axis's fields outer first, up to the first one it doesn't name; a scope naming an item that's no longer there sorts by the grand total, and is saved as a sort by the grand total. A scope GridCraft can't represent (a reference with no item or with several, byPosition, relative, or a field that isn't on the other axis) loads as a sort by label, with a warning. pivot.sort clears the scope.
  • Captions. On PivotTable: row_header_caption, col_header_caption (Compact headers), data_caption (the Values field, dataCaption; it used to be written but not read), grand_total_caption (grandTotalCaption) and empty_caption (For empty cells show: missingCaption with showMissing). A caption that reads as a number (Excel stores missingCaption="0") is shown as a number, in the value field's format. On PivotField: caption (custom name, <pivotField name>, shown in headers and in the "X Total" subtotal labels) and item_captions (renamed items, <item n>).
  • Manual item order. PivotField.item_order keeps the order of a field's <items> when it isn't auto-sorted, even when that order happens to be cache order, so a refresh over reordered source rows keeps it. Cache items name the report's items by value: a <d> item counts real days from 1899-12-30 the way Excel writes them, so a time (1899-12-30T08:00:00) or a date before March 1900 (serial 1 is 1899-12-31) names the right item instead of falling behind (blank). GridCraft writes those dates the same way. This applies to pivot caches only; worksheet t="d" cells read as before. Items the list doesn't name follow in source order. Date groups that aren't sorted show in date order (the order of Excel's cache group items; they used to come out in source order), so they keep a list only when it was reordered, and unlisted groups follow in date order.
  • Layout settings. PivotField.subtotal: Option<bool> (defaultSubtotal per field; the table default goes to xpdl:pivotTableDefinition16 EnabledSubtotalsDefault when fields override it), repeat_labels (Repeat Item Labels, x14:pivotField fillDownLabels), number_format (field number format for item labels, numFmtId). On PivotTable: show_values_row (x14:pivotTableDefinition hideValuesRow) and classic_layout (gridDropZones: with one value field and no column field, the value caption goes above the row headers and the value column is headed "Total"). Changing subtotals in Design (pivot.layout) now replaces each field's own subtotal setting, as Excel does. With field headers off there's no Values row, so the written firstDataRow no longer counts one.
  • Custom subtotals. A field with custom subtotal functions (Field Settings › Subtotals › Custom: defaultSubtotal="0" with sumSubtotal="1", countSubtotal="1"…) has subtotals on. On the first row or column field, with no xpdl:pivotTableDefinition16, the same rule sets the table's default subtotal setting. GridCraft doesn't model the functions themselves: it shows the automatic subtotal, with a warning, and saves the field with the automatic subtotal.
  • Grand totals. rowGrandTotals/colGrandTotals were read and written the wrong way round. A round trip hid this, but files saved by Excel opened with the wrong totals.
  • Date items sort after text, logical and error items, before blanks (numbers, text, TRUE/FALSE, errors, dates, blanks). A number counts as a date when its own cell has a date format, as Excel decides when it caches the cell as <d> rather than <n>. One number held in both a date cell and a General cell is a single item. It sorts as a number if any cell in the source column holds it with a non-date format, whatever the row order or report filter. On refresh the flags are read only for ungrouped row and column fields. The field list's item samples (pivot.fields) and the recommended-PivotTable suggestions read them for every source column they list. Either way, each distinct style costs one format lookup. This matches Excel's saved item order for the two mixed fields of ClosedXML's VariousDataTypesInTableColumns (only_date_formatted_cells_sort_as_dates). Item labels still use the column's first-cell format, so a time in a column whose first cell is General shows as 0.6875 where Excel shows 16:30.
  • pivot.list reports each field's caption, sortBy and numberFormat.

Measured

A throwaway pivotcmp example (not committed) opens each of 11 Excel-saved fleet reports, recomputes every PivotTable and compares each cell of the computed report range with the cell Excel saved (numbers within 1e-9 relative, empty equal to empty text). It counts the cells that differ. On this branch it gives 3475 differing cells before ce8f5fc and 3428 after. The whole drop comes from the numeric "For empty cells show" caption: the two pivots that improved are the only ones with missingCaption="0", and writing that caption as text again (the one-line change in compute) brings back exactly 3475. The sort scope has no measured effect: all 20 <autoSortScope>s in the 11 reports reference only the Values field, none an item of a column.

PivotTable before ce8f5fc after
Aggressive Driving › Summary (missingCaption="0") 40 10
Aggressive Driving › Graph (Top 10) (missingCaption="0") 73 56
The other 24 pivots 3362 3362
Total 3475 3428

The follow-up fixes (scope keyed by date grouping, vanished scope items, custom subtotals, date order of date groups) leave the total at 3428.

ClosedXML's VariousDataTypesInTableColumns__input.xlsx (saved by Excel; a time field and a field mixing numbers, text, TRUE, an error, dates and a blank) refreshes with 16 cells different from Excel's saved report, against 55 on main. (Remeasured after the round-2 fix: still 16. Command: /tmp/rv-fix-722/scratch/diffcount.sh <gridcraft-cli> <file>, which runs data.refreshAll and counts differing cells over A1:AZ200 on every sheet. The remaining differences include the label formats above.)

Most of what's left is out of scope for this PR: label/value and Top 10 filters (#498) and the order of items that tie on the sort value (see Not done).

gridcraft-cli check on the 11 reports, before and after ce8f5fc and after the follow-up fixes: 0 of 11 files differ on round trip, 0 fail to open, and the calc oracle's 2 mismatches are the same.

Verification

  • Engine (crates/engine/src/cmd/pivot.rs): sort_by_value_within_each_parent (descending and ascending, nested rows with subtotals, column fields, by validation, rename follow), captions_renamed_items_and_manual_order, field_subtotals_repeat_labels_and_values_row, empty_cell_caption_classic_layout_and_date_order, sort_by_value_in_a_column_keeps_its_scope (the column scope sorts the rows, survives an XLSX round trip, and an unsupported two-item scope loads as a sort by label with a warning), design_subtotals_replace_field_settings, numeric_empty_cell_caption_is_a_number, manual_order_of_time_items_from_an_excel_cache (an Excel-style <d v="1899-12-30T08:00:00"/> cache keeps 8:00, 9:30, (blank) through a refresh and a save) and dates_sort_after_logicals_and_errors and only_date_formatted_cells_sort_as_dates (Excel's saved item order for ClosedXML's two mixed fields, and the same number in a date cell and a General cell in either row order; fails without the per-cell date flag).
  • XLSX (crates/xlsx/src/tests/pivot_tests.rs): the round-trip fixtures set every new setting (sort by value with a scope, manual item order, field and item captions, table captions, per-field subtotals overriding the table default, Repeat Item Labels, field number format, Values row, classic layout), pivots_round_trip checks they read back equal, and pivot_xml_details checks the scope's <references>. manual_order_in_cache_order_is_kept and location_without_field_headers_has_no_values_row cover the item order and firstDataRow, and early_dates_in_caches_count_real_days the dates written before March 1900. Core (crates/core/src/date.rs): iso_dates_count_real_days covers the cache date conversion both ways in both date systems.
  • Each of the five new tests fails with its fix reverted. Five more each fail on ce8f5fc: sort_scope_tells_date_levels_of_one_column_apart (a Years + Quarters scope sorts by the Qtr2 column and is written back whole), custom_subtotal_functions_keep_the_subtotal_rows, custom_subtotals_on_the_first_field_keep_the_table_default (the table default stays on and saving adds no EnabledSubtotalsDefault="0"), sort_scope_on_a_vanished_item_sorts_as_after_saving (displayed order equals the order after save and reopen) and date_groups_not_sorted_are_in_date_order.
  • cargo fmt --all, clippy -D warnings on core, model, xlsx and engine (all targets) are clean, and every core, model, xlsx and engine test passes. cargo xtask ci passes all 8 steps (fmt, clippy, test, assets, layers, wasm, parity, keytips) on the head commit ec3666f.

Merging with #498

Rebased onto main, which now has #718 (values on rows): the row layout takes both #718's Σ Values row level and this PR's repeat-labels parameter, the "Values" header row is skipped when Σ Values sits in Rows (!on_rows, in the engine and in the written firstDataRow), and the old "values on rows are shown on columns" warning is gone. #498 (filters) still overlaps in xlsx/pivot.rs, engine/pivot.rs and engine/cmd/pivot.rs: each side adds code next to the other (field_json goes with #498's axis_json, the get closure sits next to value_at, and filters_xml is written before the extLst). Whichever lands second, I'll rebase it.

Not done

Review

Six adversarial review rounds ran on this PR.

  • Round 1: (P1) manual item order missed time-of-day and pre-March-1900 items read from an Excel cache, because cache dates were read as year 3799; cache dates now count real days from 1899-12-30 (fadcec3, 0739f71, manual_order_of_time_items_from_an_excel_cache, early_dates_in_caches_count_real_days, iso_dates_count_real_days). (P3) dates sorted between text and logicals; they now sort after errors (b60d37e, dates_sort_after_logicals_and_errors).
  • Round 2: (P3) the date sort class came from the first data cell's format rather than each cell's own; the pivot source now carries a per-cell date flag (766b6d3, only_date_formatted_cells_sort_as_dates). The cache writer is unchanged: it writes <d> only for date-grouped fields, so the flag doesn't affect it. The "as in Excel's item lists" claim was corrected.
  • Round 3: (P2) reading every cell's format made each refresh of a 100k-row source 55-77% slower; the flags are now computed only for ungrouped row/column fields, memoized per style, with one Vec<u32> of record rows instead of a Vec<bool> per record. (P3) a date cell and a General cell holding the same number shared one item whose class depended on row order; any General occurrence now makes it a number (round 4 makes that column-wide). Keeping <d>/<n> as separate items was rejected: both would get the same label, and selected/hidden items, captions and manual order are keyed by label. (7fe89ba, only_date_formatted_cells_sort_as_dates.)
  • Round 4: (P3) the round-3 General-wins rule ran only on records that passed the report filter, so filtering out the General cell made its number sort as a date. Source::date_cells now decides it over every record of the column before filtering (20a85b2, a_general_cell_filtered_out_still_makes_its_number_sort_as_a_number, which fails on 7fe89ba with ABC, 45000). The body now says which callers read the flags for every column, and the RSS note no longer quotes a machine-specific range.
  • Round 5: (P3) date_cells hashed every non-date number into the General set even when no cell of the column was a date, so a numeric General row field paid for it on each refresh. The set is now built only when some flag is set (ec3666f). The timing data below replaces the earlier user-time-only notes.
  • Round 6, on ec3666f, found nothing. probe scripts for filters, values only in filtered-out records and date grouping give byte-identical output on release builds of 20a85b2 and ec3666f, and every number below recomputes from the files it cites.

Performance

Every measurement uses release CLIs on the same generated 100k-row × 8-column source: Region and Product text, Date date-formatted, five General number columns. Each run inserts a pivot (rows = the field, Sum of Amount) and refreshes it 0 or 10 times; per refresh = (median at 10 − median at 0) / 10. Builds: 0739f71 (before the per-cell flag), 7fe89ba (round 3), 20a85b2 (round 4), ec3666f (this head).

Instructions retired and cycles (/usr/bin/time -l) are the primary evidence, because user time moved with machine load. Script: /tmp/rv-fix-722/bench/icount.sh; summary: /tmp/rv-fix-722/bench/stats.py <files>.

Row field 0739f71 7fe89ba 20a85b2 ec3666f
Region (text) 828.4M 835.0M (+0.8%) 840.1M (+1.4%) 841.3M (+1.6%)
Date (date cells) 1225.2M 1234.6M (+0.8%) 1242.3M (+1.4%) 1245.2M (+1.6%)
Qty (General numbers) 1480.8M 1489.7M (+0.6%) 1518.1M (+2.5%) 1498.6M (+1.2%)

Instructions per refresh, median of 5 interleaved runs each (icount-r5.txt). Cycles per refresh in the same runs: Region 213.2M / 221.4M / 223.2M / 212.3M, Date 323.5M / 333.7M / 318.7M / 346.6M, Qty 386.0M / 388.0M / 404.5M / 383.7M. They vary by several percent from run to run, in both directions.

The round-5 reviewer measured independently with the same method (/tmp/rv5-722/icount.txt, icount2.txt, icount3.txt, icount4.txt). Per refresh, 0739f71 / 7fe89ba / 20a85b2:

  • Region: 831.8M / 832.9M / 835.8M instructions, 224.4M / 223.4M / 223.2M cycles.
  • Date: 1235.3M / 1236.2M / 1239.0M instructions.
  • Qty: 1479.5M / 1489.2M / 1513.7M instructions.
  • A 20a85b2 build without the General set (cli-noset): 1499.6M instructions on Qty.

User CPU per refresh from the earlier rounds (/tmp/rv-fix-722/bench/bench.sh), all of it:

File Runs Region Date
results.txt 3 0739f71 0.105 s, 7fe89ba 0.105 s 0739f71 0.156 s, 7fe89ba 0.162 s (+4%)
results-r4.txt 3 0739f71 0.108 s, 20a85b2 0.114 s (+6%) 0739f71 0.161 s, 20a85b2 0.177 s (+10%)
results-r4b.txt 5 0.104 s / 7fe89ba 0.108 s / 20a85b2 0.115 s (+11%) 0.164 s / 0.169 s / 0.170 s (+4%)
results-r4c.txt 7 0.106 s / 7fe89ba 0.108 s / 20a85b2 0.119 s (+12%) –
results-r4d.txt 7 0.108 s / 20a85b2 0.114 s (+6%), 20a85b2 with round 3's intern 0.118 s –

The instruction counts don't show those user-time gaps. For example, Region at 20a85b2 retires 1.4% more instructions than 0739f71, but measured 6-12% more user time. Peak RSS showed no consistent difference between builds.

@neilcawse

neilcawse commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Merge order for my open PRs. Some of them touch the same files, so this order avoids conflicts:

Independent (any order, each merges cleanly on main): #449, #417 (includes #449's commit), #457, #460, #461, #684, #686, #717.

Linked workbooks: #417, then #464. They conflict in crates/xlsx/src/array_formula.rs (#464 changes the scan that #417 replaces); after #417 lands I'll rebase #464 onto it, keeping #417's version of that file.

PivotTables, in this order:

  1. PivotTables: label, value and Top 10 filters #498 (filters, by @prxnvt; it needs a one-line ROADMAP.md conflict fix on current main)
  2. PivotTables: refresh keeps sort by value, captions, item order and layout settings #722 (this PR: refresh fidelity)
  3. PivotTables: values on rows (Σ Values in Rows, dataOnRows) #718 (values on rows)

Drawings: #685 (grouped shapes), then #719 (slicers).

Every pair that overlaps has been merged and tested together locally (fmt, clippy, xlsx/engine/model tests, and a round-trip check on 11 real reports with 0 differences). Each PR's description explains how its conflict resolves. Whichever PR of a pair lands second, I'll rebase it the same day.

Edited 2026-10-14: #464 moved out of the independent list; it now conflicts with #417.

neilcawse and others added 4 commits October 13, 2026 13:42
…yout settings

A refreshed PivotTable read from an Excel file now lays out like Excel's:
- Sort by a value field (autoSortScope on data field n): PivotField.sort_by, applied within
  each parent; pivot.sort takes `by: "Sum of X"`.
- Captions: rowHeaderCaption, colHeaderCaption, dataCaption, grandTotalCaption and
  missingCaption (For empty cells show), field custom names and renamed items (item n).
- Manual item order of fields that aren't auto-sorted.
- Per-field subtotals (defaultSubtotal, with the table default in pivotTableDefinition16),
  Repeat Item Labels (fillDownLabels), field number formats, the Values row
  (hideValuesRow) and the classic layout (gridDropZones).
- rowGrandTotals/colGrandTotals were read and written the wrong way round.
- Date items sort after text, as in Excel.

All settings are written back to XLSX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sort by the values in a column (autoSortScope references naming items of the
  other axis): read, written back and used by the engine, instead of sorting by
  the grand total. A scope GridCraft can't represent loads as a sort by label,
  with a warning.
- Changing subtotals in Design replaces each field's own subtotal setting.
- A numeric "For empty cells show" caption (Excel stores missingCaption="0")
  is written as a number in the value's format.
- With field headers off there's no Values row, so firstDataRow matches.
- A field that isn't auto-sorted keeps its <items> order even when it is cache
  order (date groups only when reordered).

Items tied in a sort by value still break ties by label in the sort
direction: over 60 tie groups in the 11 Excel-saved reports no stored order
(pivotField items, sharedItems, first appearance, reversed or not) explains
more than 21, the same as the current rule.
…te order of date groups

- Sort scope entries name their field by source column and date grouping
  (PivotScopeItem), so Years and Quarters of one date column stay apart when
  reading, computing and writing.
- The engine and the writer resolve a scope the same way: a scope naming an
  item that's no longer there sorts by the grand total, as it is then saved.
- A field with custom subtotal functions (sumSubtotal...varPSubtotal) has
  subtotals on; it shows the automatic subtotal, with a warning.
- Date groups that aren't sorted show in date order, the order of the cache's
  group items, instead of source order.
Local contributor added 7 commits October 14, 2026 09:11
…items

Excel writes a pivot cache's date items as ISO dates counting real days from
1899-12-30: a time is 1899-12-30T08:00:00 and serial 1 is 1899-12-31. GridCraft
read them with DATE() rules, which move year 1899 on to 3799, so those items
never matched the report's items. A refresh then put them after every item that
did match, such as (blank), and saving wrote that wrong order into the file.

ISO dates (pivot cache items, t="d" cells) now go through a calendar-date
conversion that counts real days from the epoch, accepts 1900-02-29 as serial
60, and in the 1904 system takes 1899-12-30 as a time's date part. The cache
writer uses the inverse, so serial 1 is written as 1899-12-31 as Excel does.
DATE() keeps its own rules.
The ascending item order put dates between text and TRUE/FALSE. Excel's item
lists put numbers, text, logicals, errors, dates and then blanks: in the one
Excel-saved cache in the test corpus whose fields mix dates with logicals or
errors (ClosedXML VariousDataTypesInTableColumns), the Mixed Types field lists
10, 14.5, ABC, TRUE, #DIV/0!, 16:30, 8/10/2020, (blank), and the DateTime with
num field puts its numbers before its dates. The sort classes follow that order.

Also adds the regression test for the time items read from an Excel cache.
The previous commit changed the shared ISO date parser, so worksheet t="d"
cells dated before March 1900 also read one day later. The evidence for the
real-day convention comes from Excel's pivot caches only, so worksheet cells
read exactly as before again. The parser takes the date conversion as a
parameter: worksheet cells keep DATE() rules, pivot cache items use the
real-day conversion that the cache writer mirrors.
The date sort class came from the number format of the column's first data
cell. Excel decides per cell (it caches date-formatted cells as <d> and General
ones as <n>), so a General first cell made every date sort as a number, and a
date-formatted first cell made General numbers sort as dates. The source now
records, per cell, whether its own format is a date, and the class follows that.
Reading every source cell's style made each refresh of a large source much
slower. The source now keeps the sheet row of each record, and the per-cell
date flags are read only for ungrouped row and column fields, one format lookup
per distinct style.

One number in a date cell and in a General cell is one item; its sort class
depended on which cell came first. Any General occurrence now makes it a
number.
… number

The General-wins rule ran in item interning, which only sees records that pass
the report filter, so filtering out the General cell made its number sort as a
date. The date flags are now decided over every record of the column before
filtering, as Excel's cache decides.
date_cells hashed every non-date number of a column into the General set even
when no cell of the column was a date, which a numeric General row field paid
for on every refresh. The set is now built only when some flag is set.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant