Skip to content

feat: cell styles on write (Core, C ABI 7 and every binding), and ExcelReader.NET.Framework (net472) - #169

Merged
GabrielMarquezMatte merged 67 commits into
masterfrom
develop
Nov 4, 2026
Merged

GabrielMarquezMatte merged 67 commits into
masterfrom
develop

Conversation

@GabrielMarquezMatte

@GabrielMarquezMatte GabrielMarquezMatte commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Three additive features, all targeting 7.4.0:

  • cell styles in Core;
  • ExcelReader.NET.Framework, a new package that reads and writes;
  • the same styles through the C ABI in every binding.

Core's public API grows append-only. The C ABI moves to version 7 and stays additive: every ABI-6 function, struct and size still works.

1. Cell styles on write (Core: XLSX, XLSB, XLS)

CellStyle used to cover only number format and bold/italic, with bold/italic in XLSX only. It now has the full set below, written in all three formats.

  • Font: bold, italic, underline, strikethrough, name, size and color.
  • Fill and borders: solid fill, and per-edge borders with 13 line styles and colors.
  • Alignment: horizontal and vertical, wrap, indent, and rotation (−90..90, or 255 for stacked text).
  • Deduplication and limits: fonts, fills and borders are shared across styles. AddStyle checks the limits: 512 fonts; 65,490 cell styles in XLSX/XLSB and 4,000 in XLS. The 57th distinct color in XLS throws ExcelLimitExceededException.
row.Style(id).Write(x)                  # one cell; replaces row/column style; Style(0) = general
sheet.StartRow(styleId, height)         # row height in points, 0 < h <= 409 (+ async)
[ExcelStyle(Bold = true, ...)]          # styles a property's data cells (FromAttributes and the generator)
layout.WithHeaderStyle(style)           # header row, incl. Excel tables
builder.Column(header, write, style)    # hand-written IExcelRecordMap<T>; row.Style() for value-dependent styles
  • Styled layouts work for serial, table and parallel writes.
  • The generator emits styled columns and reports invalid constant style values as EXR011.
  • CSV accepts and ignores every style call.
  • Unstyled output is byte-identical to before.
  • A style without a number format on a date/time column keeps the column's date/time format. Before, [ExcelStyle(Bold = true)] on a TimeOnly showed 0.5. An explicit NumberFormat still wins, and an all-default style also keeps the column's format.

2. ExcelReader.NET.Framework (net472)

A new NuGet package so .NET Framework 4.7.2+ apps can read and write XLSX, XLSB, XLS and CSV. It P/Invokes the existing NativeAOT ExcelReader.Native library, the one the Python, Rust and C++ bindings use, instead of porting Core to netstandard.

src/ExcelReader.NetFramework/       # namespaces mirror ExcelReader.Core
├── Reader/            # ExcelWorkbook.Open(path | byte[] | Stream), sheets, tables, rows, ExcelReaderException
│   └── Schema/        # InferSchema → ExcelColumnSchema
├── Parser/            # [ExcelColumn] [ExcelIgnore] [ExcelRequired]
├── Writer/            # ExcelWorkbookWriter, options, CellStyle, CellBorder, [ExcelStyle]
├── Interop/           # DllImports, structs, loader + ABI check, SafeHandles, Stream callbacks
└── build/*.targets    # copies the native DLL to bin\x64\
sheet.Read<T>() / ReadBatches<T>(n)          → T[] / IEnumerable<T[]>   (native parses in parallel)
ExcelWorkbookWriter.Create(path) / CreateInMemory(format)
  StartSheet / StartRow / Write(...) / EndRow / EndSheet       # cell by cell
  AddStyle / SetColumnStyle / SetColumnWidth / StartRow(styleId, height) / StyleNextCell
  WriteSheet<T>(name, rows, table?, headerStyle?)              # typed sheets; honors [ExcelStyle]
  Close / CloseToArray / CloseTo(stream)
ExcelWriteOptions { Password, DegreeOfParallelism, ... }       # password-protected workbooks

3. Cell styles in the C ABI and every binding (ABI 7)

 excelreader.h
-#define XL_ABI_VERSION 6
+#define XL_ABI_VERSION 7
+xl_cell_style (104 bytes), xl_border, XL_FONT_* / XL_BORDER_* / XL_HALIGN_* / XL_VALIGN_*
+xl_add_style            // deduplicated; id 0 = general
+xl_set_column_style / xl_set_column_width
+xl_start_row_styled     // row style + height
+xl_style_next_cell      // next cell only
 xl_sheet_options        // 40 → 64 bytes: column_styles, column_widths, header_style (40 still accepted)
 xl_write_typed*         // deprecated, removal in 8.0; use the writer handle
  • Typed writes: xl_write_sheet takes per-column styles, a header style and column widths. Column styles reach data cells only, never the header, and only cells that are written.
  • Number formats: the date/time rule is Core's own; native resolves it through the workbook, with no copy of the logic in the ABI.
  • Errors: invalid values return XL_INVALID_ARGUMENT and Core limits return XL_ERROR. Neither poisons the handle.

Bindings, each in its own idiom:

Binding Styles API
C++ xl::CellStyle, xl::Border; WriterHandle::add_style / set_column_style / set_column_width / start_row(style, height) / style_next_cell; SheetStyles on typed writes
Python CellStyle, Border (frozen dataclasses) and enums; write_sheet(..., column_styles=, header_style=, column_widths=); write_row(values, style_id=, height=, cell_styles=)
Rust CellStyle, Border, enums; Column::style; SheetOptions with write_sheet_with / write_columns_with; derive #[excel(style(bold, number_format = "...", fill_color = 0xFFFF00, ...))], where invalid values are compile errors
.NET Framework see section 2

All four bindings rebuild their one-shot write helpers on the writer handle and mark them deprecated ("since 7.4, removed in 8.0"):

Binding Deprecated helper
C++ free functions, [[deprecated]]
Python write_workbook, with a DeprecationWarning
Rust write_columns(_to) and write_sheet(_to), with #[deprecated(since = "7.4.0")]

Styles are offered only on the writer-handle APIs. docs/guide/versioning.md explains why the ABI version moved even though the change is additive.

Also in this PR:

  • native-bindings.yml: a new test-netframework job (Windows) runs the net48 tests and a legacy packages.config consumer smoke.
  • release.yml: a new netframework job tests, then packs with the Windows native DLL; publish now collects nuget-packages*.
  • CITATION.cff and the Zenodo concept DOI, for APA/BibTeX through GitHub's "Cite this repository".

Evidence

Cell styles (Core)

  • Excel ground truth: I wrote files in all three formats, plus an .xls with exactly 56 colors, and opened them in Excel 16 through COM. 652 read-back values matched with 0 mismatches. They covered font, fill, all 13 border styles, alignment, rotation, indent, row height, styled blank, header style, [ExcelStyle] and parallel write.
  • Performance of unstyled writes: Stopwatch A/B against 7a26aed, 50k rows × 5 columns, median of 10 interleaved processes. Every case is within ±10%.
    • Two regressions are consistent: XLSB cell-by-cell +6.3% (~1.8 ns/cell) and XLSB parallel +4.6%.
    • XLS got 4–7% faster. The other cases are within ±2%.

ABI 7 and bindings

  • ABI-6 compatibility: the output digests of xl_write_typed (XLSX, XLSB, XLS, CSV) and of the 40-byte xl_sheet_options path were computed on c226bc8, before any ABI work. All 6 are identical on this tree.
  • Native tests cover:
    • layout, dedupe and id 0;
    • a styled null, and a pending style at xl_end_row;
    • invalid values without poisoning, and Core limits;
    • CSV no-ops, and serial and parallel typed writes;
    • a header-less sheet styled the same as a headed one;
    • an all-default style on TIME/TIMESTAMP columns.

ExcelReader.NET.Framework

  • Packaging: tests/ExcelReader.NetFramework.PackageSmoke/run.ps1 packs the nupkg, restores it into a legacy packages.config project and builds it with VS MSBuild. It asserts bin\x64\ExcelReader.Native.dll is present, reads RealExcel.xlsx (100 rows), writes, and exits 0.

  • Read performance: net48 x64, 65K_Records_Data, all 14 columns into the same POCO, results identical field by field. Stopwatch A/B, minimum of 5 interleaved bursts.

    ExcelReader.NET.Framework ExcelDataReader 3.9.0
    xlsx 65 ms 1143 ms 17.6×
    xlsb 61 ms 353 ms 5.8×
  • Write performance: 65K rows × 14 columns XLSX takes 45 ms, against 6.8 s for EPPlus 3.1.3.3 (150×).

Test suites on this tree

Suite Result
ExcelReader.Tests 4335 / 4335
pytest 264 passed, 1 xfailed (pre-existing)
cargo test (workspace, all features) 211 passed, 11 trybuild cases; clippy -D warnings clean
C++ (CMake) 10 / 10; benchmarks build without warnings
NetFramework (net48) 180 passed, 1 skipped (x86 layout, 32-bit process only)

Merge Danger

Door: two-way for the merge, one-way at the next release

Merging only adds code, tests and CI jobs, and reverting is clean. The next tagged release makes these permanent:

  • New package: ExcelReader.NET.Framework reaches NuGet for the first time. The package ID and every published version are permanent.
  • Core API: CellStyle properties, ExcelColor, CellBorder, the style enums, IRowWriter.Style, StartRow(int, double), [ExcelStyle], WithHeaderStyle and Column(..., CellStyle). Post-v7 policy is append-only.
  • C ABI 7: xl_cell_style and the five style exports. The binding APIs too, including the Rust derive's style(...) keys, which use Core's names (horizontal_alignment, vertical_alignment, wrap_text).

Blast Radius: CI/release, native-library consumers, and XLS/XLSB output for styled workbooks

  • Security: Microsoft.DotNet.ILCompiler 10.0.13 has a high-severity advisory (GHSA-p3jp-9v83-76w9, NU1903). It compiles ExcelReader.Native, which every binding and the new package ship.
    • Bump it before tagging.
    • Local publishes needed -p:NuGetAudit=false. BenchmarkDotNet can't build either, which is why the performance numbers come from a Stopwatch harness.
  • Exact ABI check: the bindings check the exact ABI version.
    • A 7.4 binding refuses an ABI-6 native library.
    • A ≤7.3 binding refuses the 7.4 library.
    • The Python wheels and the Rust/C++ release downloads bundle a matching library, so only users who mix a custom native build with another version are affected.
  • New deprecation warnings:
    • Rust users who build with -D warnings and call the free write functions will fail to compile until they move to WriterHandle.
    • Python callers of write_workbook see a DeprecationWarning.
  • CI not yet run: neither new workflow job has run on GitHub yet. If the netframework release job fails, publish is blocked for every package, because it is in needs.
  • Review coverage:
    • Core cell-style tasks 8–11 (styled layouts, parallel, generator, docs) and the XLS font-limit fix (e9f5e2a9) had no independent review.
    • Core tasks 1–7, the NET.Framework writer and every ABI 7 / binding task were each reviewed. The ABI 7 work also had a whole-branch review.
  • Hand-written record maps: an invalid CellStyle in a hand-written record map fails as TypeInitializationException on first use. The generator catches the same mistake at compile time as EXR011.
  • x64 only: NET.Framework runs only in x64 processes. A 32-bit process gets a PlatformNotSupportedException that names the missing folder. Enabling x86 needs a decision on the native calling convention: the exports are stdcall on win-x86, while the wrapper declares Cdecl.
  • SBOM: the release SBOM is generated before the new package exists, so it doesn't cover it yet.

GabrielMarquezMatte and others added 30 commits October 30, 2026 15:09
…italic

styles.bin now writes one BrtFont, BrtFill and BrtBorder per deduplicated
component and BrtXF carries font/fill/border ids, trot, indent, alignment
and apply bits. Field layouts match an Excel-authored reference (COM
SaveAs 50); BrtBorder order is top, bottom, left, right, diagonal.
Unstyled output is byte-identical.
Cell XFs now carry their font, alignment, borders and solid fill; custom
fonts follow the four defaults at index 5 and up (BIFF8 has no index 4).
A PALETTE record holds the style colors first, then Excel's defaults, and
is written only when a style uses a color. Unstyled globals are unchanged.

Font size/flags and the BIFF vertical-alignment codes move to FontKey and
StyleAlignment so XLSB and XLS share them. The BIFF record walker moves
from XlsConformanceTests into StyleProbe.
ISheetWriter gains StartRow/StartRowAsync(styleId, height) as default
members that throw NotSupportedException. XLSX writes ht/customHeight,
XLSB and XLS write miyRw in twips with fUnsynced set. CSV validates and
ignores the height.
@GabrielMarquezMatte GabrielMarquezMatte changed the title feat: add ExcelReader.NET.Framework, a net472 reading package over the native library feat: cell styles on write, and ExcelReader.NET.Framework (net472) reading and writing Nov 4, 2026
GabrielMarquezMatte and others added 19 commits November 4, 2026 00:51
…reach and xl_aggregate

ExcelSheet.ForEach hands each row to a callback on the calling thread and
ExcelSheet.Aggregate folds the data rows across worker threads through
IExcelAccumulator<TSelf>. Rows arrive as ExcelRowView/ExcelCellView ref
structs borrowing the native buffers, so nothing is copied per row; a
callback exception is rethrown to the caller.
xl_sheet_options grows column_styles, column_widths and header_style (64 bytes) and still accepts the 40-byte ABI 6 layout. Column styles reach data cells only through the style overlay pushed after the header, keep the temporal number format, and survive parallel writes; styles are validated and registered before the sheet is added. xl_write_typed and xl_write_typed_to_memory are documented deprecated (removal in 8.0).
… style only written cells

Temporal and user column styles are registered before the sheet is added and resolved through the workbook's InheritNumberFormat, replacing the native re-implementation; this also closes the window where a temporal style hitting a format limit poisoned the handle. A header-less sheet now pushes the same overlay once its first data row starts, so its cells, nulls included, are styled exactly as on the header path, serial and parallel. xl_sheet_options is read once per xl_write_sheet.
@GabrielMarquezMatte GabrielMarquezMatte changed the title feat: cell styles on write, and ExcelReader.NET.Framework (net472) reading and writing feat: cell styles on write (Core, C ABI 7 and every binding), and ExcelReader.NET.Framework (net472) Nov 4, 2026
@GabrielMarquezMatte
GabrielMarquezMatte merged commit a7f9bc9 into master Nov 4, 2026
47 checks passed
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