Skip to content

Charts: axis and data label number formats typed in the Format pane are read, drawn and saved - #1219

Open
neilcawse wants to merge 8 commits into
storytold:mainfrom
neilcawse:feat/chart-number-formats
Open

neilcawse wants to merge 8 commits into
storytold:mainfrom
neilcawse:feat/chart-number-formats

Conversation

@neilcawse

@neilcawse neilcawse commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Number formats typed in Excel's Format Axis and Format Data Labels panes were lost: on import every axis and data label followed its source cells, and saving dropped them. In a set of 11 Excel-saved fleet reports, 10 such formats (6 axis, 4 data label) appear across 5 files: a duration axis ([h]:mm) next to a percentage axis (0%) on a combo chart, #,##0 value axes, and 0;;; labels (positives only, so zero bars carry no label). GridCraft saved none of them, and drew the Idling Violations axes as 0…1.0.

What changed

  • Model (crates/model/src/features.rs). Chart.axis_number_formats: AxisNumberFormats { x, y, y2 } (the X value axis of scatter and bubble charts, the value axis, the secondary value axis) and Series.label_number_format. Both are Option<String> with #[serde(default)]. None means "Linked to source", as before.
  • Read (crates/xlsx/src/chart.rs). A c:numFmt counts only with sourceLinked="0". Each value axis is matched to the chart group that uses it through the groups' c:axIds, not by file order: Excel can save the secondary value axis first (it does in the Idling Violations report). Scatter and bubble charts take X and Y from the group's own two value axes, X being the bottom/top one. Data labels come from the series' c:dLbls, otherwise from the group's. Three kinds of typed format are skipped because applying them would show something Excel doesn't: a deleted (hidden) axis's format, since GridCraft draws every axis; the value axis format of a 100% stacked line or area group, since GridCraft reads those as plain stacked and draws absolute values (a 0.0% typed for the 0%–100% axis would read "2000000.0%"); and the format of labels that show percentages, bubble sizes or scatter X values but not the values, since it was typed for those.
  • Write. The typed axis format is written as c:numFmt sourceLinked="0" on its axis, otherwise the linked General as before. A series with a label format gets its own c:dLbls carrying the c:numFmt and the chart's show-value flag.
  • Draw (crates/chart/src/render.rs, resolve.rs). Axis tick labels use the typed format for their axis, otherwise the source cells' format as before. Fixed-scale (100%) charts keep their 0% unless a format is typed. Data labels use the series' typed format, otherwise the source format.
  • PivotCharts. Refreshing a PivotTable rebuilds its charts' series. Each rebuilt series takes the label format of the series with the same pivot items: the column items in order, then the value field when there are several. So hiding a leading item doesn't pass its format to the next series, and a series that one refresh drops (a filter hides a column item) gets its format back when a later refresh brings it back. Only the value field's name may sit at another level, since Excel can show Σ Values above the column items; "No - Yes" and "Yes - No" (two Yes/No column fields) are different series and keep their own formats. Series named by the report's header cells (PivotCharts read from a file) are read as a list of items before the refresh rewrites the report. Excel starts those names at the report's top row, the "Column Labels" (or colHeaderCaption) caption, which is skipped. A blank header cell takes the label to its left, since an outer item is written only above its first column, but no further left than the first data column, and on an inner row no further left than its outer item's column, so the blank rows under a collapsed item stay blank. The formats are kept by position, as before items were matched, only if all three hold: every series is named by header cells, no name matches a rebuilt series and the series count is unchanged, and no name shows an item of the pivot's row or column fields (counting the ones a filter hides) or consists only of value field names. A value field's name next to captions renamed in the file doesn't block it, so this also works with two or more value fields. Names that show any of the pivot's items are only ever matched by item, so a filter that swaps the shown items without changing their count doesn't hand one item's format to another. After a fallback the formats are remembered under the rebuilt series' items, not under the file's captions, so a source item that later reads like one of those captions doesn't take a format. The item set is read from the source only when the first two conditions hold, so a refresh whose names match pays nothing for it. Excel ties these formats to the pivot items too, through chartFormat/pivotArea in the PivotTable definition. GridCraft keeps the link in memory only and doesn't read or write those parts, so after a reopen the formats are matched by the items the series' names show, and a format remembered for a series that was filtered out when the file was saved is lost.
  • docs/chart-parity.md: the Data labels and Axis options rows and the round-trip note.

Measured

Every chart part in the 11 reports was opened and saved with gridcraft-cli run --in F --out O, and typed (sourceLinked="0") formats were counted in the saved XML: on value/category axes, and on the series' or group's own c:dLbls (per-point c:dLbl formats aren't read, so they aren't counted). Remeasured on 2e40b54 with main's, this branch's and this branch-on-#685's (b19cd90) release builds: python3 measure.py <main cli> <branch cli> <branch+#685 cli>, a 36-line script that unzips each part and counts with regexes.

axis formats data label formats
In the files Excel saved 6 4
Kept by main 0 0
Kept by this branch 3 3
Kept by this branch + #685 5 3

The 2 axis formats that only #685 brings back belong to two bar charts in Driver-Safety-Scorecard that sit inside grouped shapes. Main and this branch both drop those charts (grouped shapes are not supported). With this branch cherry-picked onto #685's head (no conflicts), the saved file has its 4 chart parts and both bar charts keep their #,##0 axes.

The 2 that are never kept are dropped on purpose:

  • ELD-Unverified-Logs: #,##0 on the deleted value axis of a 100% stacked chart. GridCraft draws that axis, and with this format it would read 0, 0, 0, 1, 1, 1 instead of 0%…100%.
  • Driver-Safety-Scorecard: 0.0% on doughnut labels that show percentages only. Applied to the values, it reads 3 as "300.0%".

An offscreen snapshot (crates/ui-egui/examples/snapshot.rs) of Idling Violations shows the left axis in [h]:mm (0:00…24:00) and the right axis in 0%…100%. Main shows 0…1.0 on both. The plot itself stays empty until #684 lands: this PivotChart's series point at the PivotTable's corner cell. With #684 and this PR together, the chart plots its 15 cached points up to 600:00 with % labels on the line, as Excel shows it.

Verification

  • crates/xlsx/src/tests/chart_number_format.rs:
    • typed_formats_are_read_and_linked_ones_are_not reads an Excel-style combo chart with the secondary value axis listed first. The value axes come out as [h]:mm and 0%, the linked category axis and the source-linked series labels stay None, and a group-level dLbls format reaches the line series.
    • typed_formats_round_trip saves and reopens a combo chart (Y, secondary Y and per-series labels), a scatter chart (typed X and Y) and a pie chart (labels only), and checks every format reads back equal.
    • a_percentage_label_format_is_not_applied_to_values, a_format_typed_for_sizes_or_x_values_is_not_applied_to_values, a_hidden_value_axis_keeps_no_typed_format and scatter_axes_are_the_groups_own_and_x_comes_first cover the review findings below. All four fail without the second commit.
    • a_100_percent_line_or_area_axis_keeps_no_typed_format reads a typed 0.0% on the axis of a 100% stacked line and of a 100% stacked area, alone and on the secondary axis of a combo chart, and checks it isn't kept while the column group's #,##0 is.
  • crates/engine/src/cmd/pivot.rs (PivotChart label formats; all five fail on the previous head 7b5596c):
    • pivot_chart_label_formats_follow_their_items filters column items out and back and checks each series gets its label format again; then hides the leading item (selected: [Pears, Plums]) and checks Pears and Plums keep their own formats, with series named by literals and by header-cell references shaped as Excel writes them: from the report's top row ("Column Labels" in B3), including three-row names with two value fields (caption, product, value; the product blank above its second column).
    • pivot_chart_label_formats_read_collapsed_excel_headers copies the shape of the Excel-saved forum-mso-de-104083 chart: names $B$3:$B$5 with the colHeaderCaption "Datum" on top, the outer item, then a blank row under the collapsed item whose label column holds the rowHeaderCaption "KW". A refresh keeps the three #,##0.0 formats.
    • pivot_chart_label_formats_keep_column_items_in_order (Plan × Done, both Yes/No): only "No - Yes" has a format; after a refresh, and after a filter that drops it and brings it back, "Yes - No" still has none.
    • pivot_chart_label_formats_match_values_above_column_items: names with Σ Values above the product ([value, product]) still match GridCraft's [product, value] series.
    • pivot_chart_label_formats_fall_back_to_position_for_unknown_names: header captions that show none of the pivot's items keep the formats by position on a refresh that keeps the series count, and are dropped (not shifted) when the series change.
    • pivot_chart_label_formats_fall_back_to_position_with_two_value_fields: with Sales and Units, each name ends in a value field's name; with the product captions renamed, the six formats are still kept by position (347ee2a kept none).
    • pivot_chart_label_formats_forget_captions_after_a_fallback: after a fallback over the captions Kiwis/Birnen/Pflaumen, one source record's product becomes Kiwis; the new Kiwis series gets no format (347ee2a gave it Apples' 0;;;, remembered under the caption), and the other three keep theirs.
    • pivot_chart_label_formats_follow_items_a_filter_swaps (fails on 2e40b54): a file-named series for Apples, typed 0.0, then a filter showing Pears alone (same count) leaves Pears without a format, and clearing the filter gives Apples 0.0 back; with two of three regions shown, swapping one of them hands the new one no format.
  • The real file: sc/qa/unit/data/xlsx/forum-mso-de-104083.xlsx from LibreOffice's test files (Excel 6, not committed: it's third-party). With #,##0.0 typed on its three series' labels, gridcraft-cli run --in de-typed.xlsx --cmd 'pivot.refresh={"pivot":"PivotTable2"}' --out r.xlsx keeps all three; 7b5596c kept none. With 0.0/0.00/0.000 typed on 2015/2016/2017, pivot.sort (Datum descending) saves 2017 0.000, 2016 0.00, 2015 0.0: the formats follow the items, not the positions.
  • crates/chart/src/tests.rs: typed_axis_and_label_formats_override_the_source draws a #,##0.0,"k" value axis, 0;;; labels on one series (no label on its negative point, the other series keep the source $#,##0), and a combo chart's secondary axis in its own format while the primary keeps the source's. The chart fuzz test now also generates random label and axis formats.
  • On f883adb: cargo xtask ci passes all 8 steps (fmt, clippy -D warnings, test, assets, layers, wasm, parity, keytips). cargo fmt --all, cargo clippy -p gridcraft-engine --all-targets -- -D warnings and cargo test -p gridcraft-engine (294 lib tests plus the integration tests) pass. 2e40b54, 347ee2a and f883adb touch only the engine crate and docs/chart-parity.md.
  • Item-set cost: a temporary eprintln! in pivot_items (removed before the commit) and a 100,000-row Region × Date / Product pivot, refreshed five times with Excel-shaped names (Sheet2!$B$3:$B$4…; Excel names a series from the report's top row down to the row above the first data row) and 0.0 on each series (cargo test -p gridcraft-engine --lib <probe> -- --ignored --nocapture). On f883adb the set was built 0 times and the formats were kept. On 347ee2a it was built on every refresh with these names too, because the set was built before any name was checked. Names that can't match, such as $B$3:$B$5 (which takes in R0's subtotal row), still build it on each refresh. Wall times on this shared machine varied 0.2–2.5 s between runs of the same build, so no timing is claimed.
  • gridcraft-cli check on forum-mso-de-104083.xlsx and the 11 reports (gridcraft-cli check de.xlsx /tmp/gridcraft-try/sheets/downloads/*.xlsx) gives the same output with main's and this branch's (f883adb) release builds (diff of the two outputs is empty): 263,831 formula cells all matched, and both exit 1 on the same package round-trip differences in 9 of the 12 files (lost chartStyle/chartColorStyle relationships and the like), none from this PR. The real-file results above were rerun on f883adb and are unchanged. ELD-Unverified-Logs' PivotChart keeps both its 0;;; series formats through selection.set={"sheet":"Graph","range":"A12"} then pivot.refresh={"pivot":"PivotTable2"}, which rebuilds the chart's series as Verified/Unverified. Without the selection, that command refreshes Summary's PivotTable2 and doesn't touch the chart. Main's build saves none.
  • Excel for Mac 16.114, on a synthetic column chart built in Excel (value axis 0.0%, data labels #,##0.000, both with Linked to source unticked):
    • Excel wrote the label format on each of the five c:dLbl because the labels were formatted one by one. Saved by this PR (f883adb), the file opens with no repair prompt and the axis keeps 0.0%, but the labels fall back to General: per-point formats aren't read (Not done).
    • The same chart with one series-level <c:numFmt formatCode="#,##0.000" sourceLinked="0"/> in the series c:dLbls (edited by hand from Excel's file), saved by this PR: it opens with no repair prompt and shows the labels as 0.120, 0.345, 0.500, 0.079 and 0.900. Format Data Labels > Number shows #,##0.000 and Format Axis > Number shows 0.0%, both with Linked to source unticked. Saved by main (6e984bb), both formats are General.

Review

Before the PR was opened, two review passes ran on the diff: one on the first commit, one on the fixes. The second commit addresses what they found:

  • Percentage-only labels (major). Driver-Safety-Scorecard's doughnut has 0.0% on labels that show percentages only. The first commit applied it to the values ("300.0%"). The second pass found the same problem with labels that show only bubble sizes or scatter X values. Those formats are now skipped.
  • Scatter X/Y axes (minor). They were taken from the first bottom and left value axes in the plot area. openpyxl writes axPos="l" on both scatter axes, so Y got X's format and title. They are now the group's own axes, found through its axIds.
  • Deleted axes (minor). A hidden axis's format was drawn on an axis GridCraft shows anyway (ELD-Unverified-Logs, above).
  • PivotChart refresh (minor). A series that one refresh dropped came back without its label format. The chart now remembers the formats by series name until the series returns.

After it was opened, five adversarial review rounds ran on the pushed diff.

Round 1 (on dff577b) found two defects, fixed in 2363045, 564789f and 7b5596c:

  • 100% stacked line and area (major). GridCraft reads them as plain stacked, so a 0.0% typed for their axis labelled absolute values ("2000000.0%") and was saved onto a plain stacked chart. That format is now skipped, like a deleted axis's. Modelling the 100% line and area variants (a new chart kind, writer, renderer normalisation, Insert gallery) is a separate feature.
  • PivotChart formats by item (major). Formats were kept by position, so hiding a leading item gave every later series the format of the one before it. They now follow the series' pivot items, compared as lists.

Round 2 (on 7b5596c) found two more defects in that item matching and four body claims the code didn't support. Fixed in 2e40b54:

  • Excel's series names start at the caption row (major). Excel names a PivotChart series from the report's top row ("Column Labels" or the colHeaderCaption) down, and over collapsed fields the lower rows are blank. The items were read from every row, and a blank cell took text from as far left as the row-label column ("KW"), so no name matched and a refresh of the real forum-mso-de-104083 file dropped every typed label format (dff577b kept them by position). The caption row is now skipped, a blank cell looks left no further than the first data column (and on inner rows no further than its outer item), and if no name matches at all, formats stay by position when the series count is unchanged.
  • Same labels in another order (minor). Items were compared as sorted lists, so "Yes - No" took the format typed on "No - Yes", and a remembered format for a dropped series could be suppressed by a reordered one. Column items are now compared in order; only the value field's name may sit at another level.
  • Body claims. The PivotCharts paragraph now describes the caption row, the left-fill limits, the in-order rule and the position fallback. The header-name test used references that skipped Excel's caption row; it now uses Excel's shape, and the real-file check is listed under Verification. The fix-commit count was wrong ("third and fourth commits"); the commits are named above.

Round 3 (on 2e40b54) found one defect. Fixed in 347ee2a:

  • Position fallback on a filter swap (minor). The fallback ran whenever no file-read name matched a rebuilt series and the count was unchanged, which also happens when a filter swaps the shown items: Apples' 0.0 went to Pears, and the remembered formats were dropped on that path, so Apples never got it back. The fallback now runs only when the names show none of the pivot's labels (hidden items included), and the formats keyed by item are remembered on every path.

Round 4 (on 347ee2a) found two defects, a cost, and two claims that were too broad. Fixed in f883adb:

  • Two or more value fields blocked the fallback (minor). The value fields' names counted as pivot labels, and with two value fields every Excel name ends in one, so renamed captions lost every format (2e40b54 kept them). Only row and column items block the fallback now; a name made of value field names alone still does.
  • Captions remembered after a fallback (minor). The file's captions were stored with the formats just given by position, so a source item that later read like a caption (Kiwis) took Apples' 0;;;. After a fallback only the rebuilt series' items are remembered (plus formats remembered earlier for dropped series).
  • Cost of the item set (minor). It was read from the source whenever every series was named by header cells, Excel's own $B$3:$B$4 shape included. It's now read only when no name matches a rebuilt series and the series count is unchanged (see Verification).
  • Body claims. "Captions renamed in the file" held only with one value field, and the docs row's "none of the pivot's items" left out the value-name and series-count conditions. Both now say exactly when the fallback runs.

Round 5 (on f883adb) found no defects. Every round-3 and round-4 probe gives the same result. Charts with no column fields and one or two value fields, and with Σ Values on rows, match by item and never build the item set; so does a real Costliest-Assets chart (Summary!$B$4). It corrected two lines here: the ELD refresh command, which needs the chart's PivotTable selected first (rerun: both 0;;; kept), and the round-4 cost note, which had said the reviewer's probe used names Excel doesn't write.

  • Excel check (Excel for Mac 16.114, synthetic files): the added text had an adversarial review; no findings.

Merging with #684

#684 adds Series.number_format (the format of literal values) next to this PR's Series.label_number_format, so both touch every Series { … } literal and read_chart. I applied the first two commits onto #684's then head (df4599d) to check. The conflicts were the two new fields side by side, read_chart returning #684's (Chart, Vec<PivotSeriesCache>) (the new tests take .0), and the two doc rows. That combined tree passed fmt, clippy -D warnings, and the chart, xlsx, model and engine tests. #684 has moved since (its review branch is at ef6776d) and this branch has five more commits; that merge hasn't been redone. Whichever lands second, I'll rebase it.

Not done

  • Any UI for these formats: there's no Format Axis or Format Data Labels pane, so they can come only from a file or the model.
  • Display units (thousands, millions) and per-point data label formats aren't read.
  • The trendline label number format is still not kept.
  • A category axis's typed format (dates on a category axis, say) isn't read: those labels still follow the source cells.
  • 100% stacked line and area charts are still drawn as plain stacked, and their typed value axis format isn't kept.
  • PivotChart chartFormat/pivotArea and c:pivotFmts aren't read or written.

…re read, drawn and saved

A typed c:numFmt (sourceLinked="0") on a value or category/X axis, or in a
series or group c:dLbls, was dropped on import and every label followed its
source cells. The model keeps them (Chart.axis_number_formats for x, y and
the secondary y; Series.label_number_format), the reader maps each value
axis to the group that uses it (Excel may list the secondary axis first),
the writer emits them, and the renderer uses them over the source format.
PivotChart refresh keeps label formats by series position.
Local contributor added 7 commits October 13, 2026 22:38
- Labels that show percentages, bubble sizes or scatter X values but not the values keep no
  format: it was typed for those (Driver-Safety-Scorecard: 0.0% on a percent-only doughnut).
- A deleted value axis keeps no format (ELD-Unverified-Logs: #,##0 on a hidden 100% axis).
- Scatter and bubble X/Y axes are the group's own, found through its axIds.
- PivotChart refresh keeps label formats for series it drops and brings back.
GridCraft reads lineChart and areaChart groups with grouping="percentStacked"
as plain stacked and draws their absolute values; only the column family has
a 100% variant. A number format typed for the 0%-100% axis of such a chart
(Excel saves `0.0%` with sourceLinked="0" once the decimals are changed) was
applied to those absolute values, so the axis read "2000000.0%", and saving
wrote it onto a plain stacked chart.

Modelling 100% stacked line and area would take a new chart kind, writer
support, normalisation in the renderer and Insert gallery entries, which is
a feature of its own. The reader now drops the typed value axis format of
any axis a percentStacked line or area group uses, the same way it already
does for deleted axes, so the axis follows the source cells and nothing is
saved for it.
…shes

A refresh rebuilds a PivotChart's series and handed the typed data label
formats back by position. When a filter hid an item that wasn't last, every
later series took the format of the series before it, and saving wrote the
wrong formats. Excel ties this formatting to the pivot items.

The formats are now keyed by series name, which is the series' pivot items.
They are collected before the refresh rewrites the report, so series named by
the report's header cells (PivotCharts read from a file) resolve to the old
headers, and the remembered formats of series a refresh dropped are kept by
name too.
… items

A PivotChart read from a file names each series by the report's header
cells. With two column levels (a column field and the value fields, say) the
name spans two cells, and Excel writes the outer item only above its first
column. The formats were matched on display strings: the reference evaluated
to the non-blank cells joined by spaces ("Apples" or "Apples Sum of Sales"),
while the rebuilt series is named "Apples - Sum of Sales", so the first
refresh after opening dropped every such format.

The report now hands each PivotChart series its list of pivot items, and the
remembered formats are keyed by that list. A referenced name is read as a
list of header cells, top to bottom, a blank cell taking the nearest label to
its left within the old report. Keys match item by item, as GridCraft's
joined literal name, or in any order, since Excel can put the value field
above the column items.
…ep column items in order

Excel names a PivotChart series by its header cells from the report's top row, the
"Column Labels" caption, down to the last column-item row, with blank rows under a
collapsed item. Those names are now read without the caption row, a blank cell's
left-fill stays inside the data columns and inside the outer item's columns, and a
chart whose names still match nothing keeps its formats by position when the series
count is unchanged.

The order-insensitive item match gave "Yes - No" the format typed on "No - Yes".
Only the value field's name may now sit at another level, and a looser match only uses
a key no rebuilt series has item by item.
…that show no pivot item

The position fallback ran whenever no file-read series name matched a rebuilt series and the
count was unchanged, which is also what a filter that swaps the shown items does: Apples'
format went to Pears. It now runs only when the names show none of the pivot's row/column
items (hidden ones included) or value names. The formats keyed by item are remembered on
every path, so a hidden item gets its format back when it returns.
…lue fields and forget captions

Value field names no longer block the position fallback (with two or more
value fields every Excel series name ends in one), a name made only of value
field names still does. After a fallback the formats are remembered under the
rebuilt series' items, not the file's captions, so a later source item that
reads like a caption doesn't take a format. The pivot's item set is read from
the source only when no name matches and the series count is unchanged.

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