Skip to content

Preserve cell formatting through decode→encode round-trip - #449

Open
ben-milanko wants to merge 1 commit into
justkawal:mainfrom
Railway-Engineering-Solutions:fix/preserve-cell-style-on-value-write
Open

ben-milanko wants to merge 1 commit into
justkawal:mainfrom
Railway-Engineering-Solutions:fix/preserve-cell-style-on-value-write

Conversation

@ben-milanko

Copy link
Copy Markdown

Problem

Loading a styled .xlsx, mutating one cell, then re-encoding silently rebuilds the styles table and drops every untouched cell's font / fill / border / alignment. End-to-end on a real Numbers-exported template (~95 cellXfs, dataValidations, conditionalFormatting), styles.xml grows to 117 cellXfs (22 generic borderId="0" fillId="0" fontId="0" entries appended) and every cell's <c s="N"> index is remapped to point into the new table — so headers render as plain text, dropdowns vanish visually, etc.

Root cause

Four compounding bugs:

  1. Sheet._putData clobbers existing styles. Every value write unconditionally did cell._cellStyle = CellStyle(numberFormat: NumFormat.defaultFor(value)), wiping the cell's font/fill/border/alignment.
  2. Always-true guard. The follow-up if (cell._cellStyle != NumFormat.standard_0) compares a CellStyle? to a NumFormat (different types — always true), so _styleChanges flipped on every value write and triggered a styles-table rebuild.
  3. Parser flips the change flags. The parser drives every cell through Sheet.updateCell while loading the file, so _styleChanges / _mergeChanges / _rtlChanges were already true after a no-op decode. Those flags should reflect post-decode user mutations, not the act of decoding itself.
  4. StandardNumericNumFormat.accepts(TextCellValue()) returned true only for numFmtId == 0 (General), not 49 (@/text). Writing text into a text-formatted column was therefore deemed format-incompatible and triggered a numberFormat overwrite.

Fix

Targeted, minimal — preserves the existing public contract (writing a Double into a General cell still upgrades to defaultFloat; writing null resets to General):

  • Sheet._putData keeps the existing CellStyle and only patches numberFormat via copyWith. Promotes General to a value-specific default; resets to General on null; otherwise leaves explicit formats alone unless accepts(value) is false.
  • Excel._ resets _styleChanges / _mergeChanges / _rtlChanges after parser._startParsing() so a no-op decode→encode skips the rebuild paths.
  • StandardNumericNumFormat.accepts(TextCellValue()) accepts both numFmtId == 0 and numFmtId == 49.

Tests

All 34 existing tests still pass. Two new tests in Style preservation on round-trip cover the regression on borders.xlsx:

  • writing into one cell keeps an untouched cell's style intact — writes Z50, asserts A1's bold + medium-border header survives.
  • overwriting a styled cell's value preserves font/fill/border — writes a new value into A1, asserts every visual attribute is intact after round-trip.

Verification on a real template

On a Numbers-exported template with the bulk-import write pattern (5 text writes into the valueList sheet + setColumnAutoFit on 11 columns):

Before fix After fix
<fonts count> 7 (was 10) 10
<fills count> 9 (was 8) 8
<cellXfs count> 117 (was 95) 95
styles.xml diff vs original 1610 bytes added 0 bytes
Header A1 <c s="N"> s="76" (was 60) s="60"
<dataValidations> preserved preserved
<conditionalFormatting> preserved preserved

When loading a styled .xlsx, mutating one cell, and re-encoding, the
package was silently rewriting the styles table and dropping every
untouched cell's font/fill/border/alignment. Four compounding bugs
caused this:

1. `_putData` overwrote `cell._cellStyle` with a fresh
   `CellStyle(numberFormat: defaultFor(value))` on every value write,
   wiping the existing visual style.

2. The follow-up guard `cell._cellStyle != NumFormat.standard_0` compared
   a `CellStyle?` to a `NumFormat` (different types — always true), so
   `_styleChanges` flipped on every write and triggered a styles-table
   rebuild that re-numbered every cell's `<c s="N">` index.

3. The parser drives every cell through `Sheet.updateCell` while
   loading the file, so `_styleChanges`/`_mergeChanges`/`_rtlChanges`
   were already true after a no-op decode. Reset them at the end of
   `Excel._` so those flags only reflect post-decode user mutations.

4. `StandardNumericNumFormat.accepts(TextCellValue())` returned true
   only for numFmtId 0 (General), not 49 (`@`/text). Writing text into
   a text-formatted column was therefore deemed format-incompatible
   and triggered a numberFormat overwrite.

Replaces the unconditional cellStyle reassignment in `_putData` with a
targeted update that:
- assigns a value-appropriate default only when the cell had no style;
- promotes General to a value-specific format (preserving the test
  contract that writing a Double into a General cell yields 0.00);
- resets numberFormat to General when value is null;
- patches incompatible explicit formats (e.g. date written into
  currency cell) but leaves font/fill/border/alignment intact via
  `copyWith`.

Adds two regression tests using `borders.xlsx` that assert font weight,
borders, fill, and font family on an A1 header survive both an
unrelated-cell write and a same-cell value overwrite.

End-to-end verification with a real Numbers-exported template (~95
cellXfs, dataValidations, conditionalFormatting): styles.xml is now
byte-identical after a value write that previously rebuilt the table
into 117 cellXfs, and every cell's `<c s="N">` index is preserved.
All 36 existing tests still pass.
@gonojuarez

Copy link
Copy Markdown

hello @ben-milanko I created a fork of Flutter Excel. The name is excel_community you can find it here link

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.

2 participants