Excelsior
September 23, 2026 · View on GitHub
Excelsior is a Excel spreadsheet generation library with a distinctive data-driven approach. Uses DocumentFormat.OpenXml for spreadsheet creation and OpenXmlHtml for HTML cell rendering.
See Milestones for release notes.
Open Source Maintenance Fee
This project participates in the Open Source Maintenance Fee. The source code is freely available under the terms of the license. To support sustainable maintenance, use of the project's official binary releases in revenue-generating activities and all government agencies requires adherence to the Open Source Maintenance Fee EULA. The fee is paid by sponsoring Papyrine.
This project uses SponsorCheck to surface a build-time reminder in consuming projects that are not yet sponsoring.
Usage
Model
Given an input class:
using Excelsior;
public class Employee
{
[Column(Heading = "Employee ID", Order = 1)]
public required int Id;
[Column(Heading = "Full Name", Order = 2)]
public required string Name;
[Column(Heading = "Email Address", Order = 3)]
public required string Email;
[Column(Heading = "Hire Date", Order = 4)]
public Date? HireDate;
[Column(Heading = "Annual Salary", Order = 5)]
public int Salary;
public bool IsActive;
public EmployeeStatus Status;
}
[ColumnAttribute] is optional. If omitted:
- Order is based on the order of the properties defined in the class. Order can be programmatically controlled
- Heading text is based on the property names that is camel case split. Headings can be programmatically controlled
Builder
BookBuilder is the root entry point.
Once instantiated, the data for multiple sheets can be added.
var builder = new BookBuilder();
List<Employee> data =
[
new()
{
Id = 1,
Name = "John Doe",
Email = "john@company.com",
HireDate = new(2020, 1, 15),
Salary = 75000,
IsActive = true,
Status = EmployeeStatus.FullTime
},
new()
{
Id = 2,
Name = "Jane Smith",
Email = "jane@company.com",
HireDate = new(2019, 3, 22),
Salary = 120000,
IsActive = true,
Status = EmployeeStatus.FullTime
},
];
builder.AddSheet(data);
using var book = await builder.Build();
Result:
Worksheet Name
Worksheet defaults to SheetN, when N is a counter. So the first sheet is Sheet1, the second is Sheet2, etc.
The name can be controlled by passing an explicit value.
var builder = new BookBuilder();
builder.AddSheet(employees, "Employee Report");
DataAnnotations Attributes
DisplayAttribute and DisplayNameAttribute from System.ComponentModel.DataAnnotations are supported.
DisplayAttribute and DisplayNameAttribute are support for scenarios where it is not convenient to reference Excelsior from that assembly.
public class Employee
{
[Display(Name = "Employee ID", Order = 1)]
public required int Id;
[Display(Name = "Full Name", Order = 2)]
public required string Name;
[Display(Name = "Email Address", Order = 3)]
public required string Email;
[Display(Name = "Hire Date", Order = 4)]
public Date? HireDate;
[Display(Name = "Annual Salary", Order = 5)]
public int Salary;
[DisplayName("IsActive")]
public bool IsActive { get; init; }
public EmployeeStatus Status;
}
public enum EmployeeStatus
{
[Display(Name = "Full Time")]
FullTime,
[Display(Name = "Part Time")]
PartTime,
[Display(Name = "Contract")]
Contract,
[Display(Name = "Terminated")]
Terminated
}
Result
Saving to a stream
To save to a stream use ToStream().
var builder = new BookBuilder();
builder.AddSheet(data);
var stream = new MemoryStream();
await builder.ToStream(stream);
Saving to bytes
To save to a byte array use ToBytes().
var builder = new BookBuilder();
builder.AddSheet(data);
var bytes = await builder.ToBytes();
Saving to a MemoryStream
To save to a MemoryStream use ToMemoryStream(). The returned stream is positioned at zero, ready to read.
var builder = new BookBuilder();
builder.AddSheet(data);
var stream = await builder.ToMemoryStream();
Reading xlsx
BookReader is the inverse of BookBuilder: register the sheets to read, then Convert (throws on failure) or TryConvert (returns a result).
Strong-typed
The same property-discovery pipeline that drives writes also drives reads — [Column], [Display], [DisplayName], ordering, and inclusion all carry over. When the workbook was produced by Excelsior, the column→property mapping is recovered from a custom XML metadata part written at build time, so renaming a heading on either side does not break the round-trip.
var stream = new MemoryStream();
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees());
await builder.ToStream(stream);
stream.Position = 0;
var reader = new BookReader();
var sheet = reader.AddSheet<Employee>();
reader.Convert(stream);
var employees = sheet.Rows;
Model construction
Strong-typed rows are instantiated in one of two ways:
- Parameterless constructor (preferred) — public or non-public. After construction, every parsed column value is applied via its property setter.
initsetters andrequiredmembers are honoured: construction goes throughConstructorInfo.Invoke, which bypasses the runtimerequired-members check thatActivator.CreateInstancewould enforce. - Longest matching public constructor (fallback) — used when no parameterless constructor exists. Constructor parameters are filled by name from the parsed column values; any column whose name does not match a constructor parameter is then applied via its property setter.
This means records, primary-constructor classes, and other immutable models work without extra configuration as long as constructor parameter names match the property names.
public record PersonRecord(string Name, int Age);
[Test]
public async Task PositionalRecord()
{
var stream = await Write(
new PersonRecord("Alice", 30),
new PersonRecord("Bob", 25));
var reader = new BookReader();
var sheet = reader.AddSheet<PersonRecord>();
reader.Convert(stream);
Assert.That(
sheet.Rows,
Is.EqualTo<PersonRecord>(
[
new("Alice", 30),
new("Bob", 25)
]));
}
Limitations:
- Parameter matching is case-sensitive — a constructor parameter
namewill not bind to propertyName. - Public instance properties and fields are both bound.
readonlyandconstfields are read on write but skipped on read (they keep their initializer value). - A type with no public constructors and no parameterless constructor (public or non-public) throws on the first row.
Source-generated activators
Types marked with [SheetModel] get a source-generated factory that replaces the reflection path at row time. The generator follows the same parameterless-preferred / longest-matching-public-ctor rules and emits direct constructor calls and setter assignments — no reflection per row. Registration happens via a [ModuleInitializer] in the consuming assembly, so the fast path is automatic.
Anonymous / dictionary
For sheets without a backing model, declare every column explicitly. Each parsed row is an IReadOnlyDictionary<string, object?> keyed by the column name.
The name passed to Column<T> serves two roles: it is matched against the file's header row (case-insensitively) and it is the key under which the parsed value is exposed in each row dictionary. The simplest choice is the file's heading text itself. For files written by BookBuilder, the underlying property name can be passed instead; the workbook's metadata resolves it back to the correct column.
Because header matching is case-insensitive, names must be unique within a sheet under the same comparison — "Name" and "name" collide — and must not have leading or trailing whitespace. Either violation throws when the column is declared.
var reader = new BookReader();
var sheet = reader.AddSheet();
sheet
.Column<int>("Employee ID")
.Column<string>("Full Name")
.Column<string>("Email Address")
.Column<Date?>("Hire Date")
.Column<int>("Annual Salary")
.Column<bool>("IsActive")
.Column<EmployeeStatus>("Status");
reader.Convert(stream);
var first = sheet.Rows[0];
Multiple sheets
Register multiple sheets on the same BookReader. Pass a name to AddSheet to bind to a specific sheet by name; omit it to bind by registration order. Strong-typed and dictionary readers can be mixed freely.
Strong-typed:
var reader = new BookReader();
var staff = reader.AddSheet<Employee>("Staff");
var departments = reader.AddSheet<Department>("Departments");
reader.Convert(stream);
var employees = staff.Rows;
var depts = departments.Rows;
Dictionary:
var reader = new BookReader();
var staff = reader.AddSheet("Staff");
staff
.Column<int>("Employee ID")
.Column<string>("Full Name")
.Column<string>("Email Address")
.Column<Date?>("Hire Date")
.Column<int>("Annual Salary")
.Column<bool>("IsActive")
.Column<EmployeeStatus>("Status");
var departments = reader.AddSheet("Departments");
departments
.Column<string>("Name")
.Column<int>("HeadCount");
reader.Convert(stream);
Assert.That(staff.Rows[0]["Employee ID"], Is.EqualTo(1));
Assert.That(staff.Rows[0]["Full Name"], Is.EqualTo("John Doe"));
Assert.That(departments.Rows.Select(_ => _["Name"]), Is.EqualTo(new object[] { "Eng", "Sales" }));
Assert.That(departments.Rows.Select(_ => _["HeadCount"]), Is.EqualTo(new object[] { 12, 7 }));
If a sheet's declared columns don't match what's in the file, that sheet's row parsing is skipped (one error per missing column, plus one error per unrecognized header column, is recorded against it), but subsequent sheets are still processed. Per-row parse errors don't have this short-circuit — they are collected per failing cell.
Duplicate columns
If more than one header cell in a sheet resolves to the same declared column, that's reported as an error and the sheet's rows are skipped. Both resolution paths are checked:
- Heading match — two header cells whose text matches the same declared column (
[Column(Heading = "...")],[DisplayName], or property name) produce an error citing both cell references. - Metadata match — the round-trip metadata XML written by
BookBuilderis also checked. If two columns in the metadata point at the same property, that's reported separately so a corrupted or hand-edited workbook surfaces clearly rather than silently last-writes-wins.
The error message names both cell references involved (e.g. A1 and B1), the declared column it collided on, and which path detected it. Like the missing-column / unrecognized-header errors, a duplicate stops row parsing for that sheet but does not affect other sheets in the workbook.
Per-cell delegate conversion
Override the default parsing for a single column with a delegate that receives the underlying OpenXml Cell.
Strong-typed:
var reader = new BookReader();
var sheet = reader.AddSheet<Target>();
sheet.Convert(
_ => _.Priority,
cell =>
{
var raw = cell.InnerText.Trim().ToLowerInvariant();
return raw switch
{
"low" => Priority.Low,
"medium" => Priority.Medium,
"high" => Priority.High,
_ => Priority.Low
};
});
reader.Convert(stream);
Dictionary:
var reader = new BookReader();
var sheet = reader.AddSheet();
sheet.Column<string>("Code");
sheet.Column(
"Priority",
cell =>
{
var text = cell.InnerText;
return text.Trim().ToLowerInvariant() switch
{
"low" => 1,
"medium" => 2,
"high" => 3,
_ => 0
};
});
reader.Convert(stream);
Convert vs TryConvert
Convert throws ReadException on the first batch of conversion failures. The exception's Errors property is the same collection that TryConvert exposes.
TryConvert never throws on data errors. It returns a ReadResult that is implicitly convertible to bool (success) and to ReadError[].
var stream = await WriteStringNumber();
var reader = new BookReader();
var sheet = reader.AddSheet<IntTarget>();
var result = reader.TryConvert(stream);
if (!result)
{
foreach (var error in result.Errors)
{
Console.WriteLine(error);
}
}
Embedded metadata
An arbitrary instance can be embedded in the workbook itself, serialized with System.Text.Json. Useful for round-tripping out-of-band context — report headers, schema versions, audit info — that doesn't belong in any sheet.
The payload is written into a custom XML part with a dedicated namespace, so it coexists with the column-mapping metadata and any other custom parts.
var stream = new MemoryStream();
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees());
builder.SetMetadata(
new BookHeader
{
Title = "Q1 staff snapshot",
Version = 3,
GeneratedAt = new(2026, 1, 15, 9, 30, 0, DateTimeKind.Utc)
});
await builder.ToStream(stream);
stream.Position = 0;
var reader = new BookReader();
reader.AddSheet<Employee>();
reader.Convert(stream);
var header = reader.GetMetadata<BookHeader>();
Calling SetMetadata a second time with a non-null value throws — metadata is treated as a single intentional payload, so accidental double-assignment is surfaced loudly rather than silently last-wins. To overwrite deliberately, pass null first to clear and then set the new value.
On the reader, GetMetadata<T>() throws if no payload is present in the workbook. When absence is expected, use TryGetMetadata<T>(out var value).
Raw JSON access
For callers who already hold a JSON string — or who want to inspect or rewrite the embedded payload without going through JsonSerializer — BookBuilder.SetMetadata(string), BookReader.GetMetadata() and BookReader.TryGetMetadata(out string) operate on the raw string directly. No validation is performed on the write side.
var stream = new MemoryStream();
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees());
builder.SetMetadata(
"""
{
"title": "raw",
"version": 7
}
""");
await builder.ToStream(stream);
stream.Position = 0;
var reader = new BookReader();
reader.AddSheet<Employee>();
reader.Convert(stream);
var json = reader.GetMetadata();
Rich text
Cells written with run-level formatting (mixed bold / colors / fonts within a single cell — Excel's "rich string" feature, which is what IsHtml = true produces on the write side) are flattened to plain text on read. The runs are concatenated and formatting attributes are discarded; a string property receives the joined text.
For formatted text that must round-trip through a workbook, store the markup as plain text in the cell rather than relying on Excel rich-text formatting. Markdown or HTML stored as a regular string is preserved exactly across write → read and can be rendered downstream. Excel's own rich-text data model has no equivalent on the .NET side and therefore cannot be reconstructed by BookReader.
To inspect the runs directly (e.g. to extract formatting), wire up a per-cell delegate (sheet.Convert(_ => _.Prop, cell => ...)) and walk the OpenXml Run elements.
Supported types
BookReader understands all standard .NET primitives and their nullable variants:
bool, byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal, string, char, Guid, DateTime, DateOnly (Date), TimeOnly (Time), TimeSpan, DateTimeOffset, and any enum (matched against the humanised display string used by the writer).
Grouping Column Configuration
When applying multiple settings to the same column, prefer grouping them in a single Column call rather than using separate method calls. This makes it clearer which settings belong together.
// prefer
sheet.Column(
_ => _.Name,
_ =>
{
_.Width = 25;
_.IsHtml = true;
_.Render = (row, _) => $"<a href='/people/{row.Id}'>{row.Name}</a>";
});
// over
sheet.Width(_ => _.Name, 25);
sheet.IsHtml(_ => _.Name);
sheet.Render(_ => _.Name, (row, _) => $"<a href='/people/{row.Id}'>{row.Name}</a>");
Custom Headings
The heading text for a column can be overridden:
Fluent
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(
_ => _.Name,
_ => _.Heading = "Employee Name");
ColumnAttribute
public class Employee
{
[Column(Heading = "Employee Name")]
public required string Name;
DisplayNameAttribute
public class Employee
{
[DisplayName("Employee Name")]
public required string Name { get; init; }
DisplayAttribute
public class Employee
{
[Display(Name = "Employee Name")]
public required string Name;
Result:
Order of precedence
- Fluent
ColumnAttributeDisplayAttributeDisplayNameAttribute
Column Ordering
The column order can be overridden:
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Email, _ => _.Order = 1)
.Column(_ => _.Name, _ => _.Order = 2)
.Column(_ => _.Salary, _ => _.Order = 3);
Result:
Heading Style
var builder = new BookBuilder(
headingStyle: style =>
{
style.Font.Bold = true;
style.Font.Color = "FFFFFF";
style.BackgroundColor = "00008B";
});
builder.AddSheet(data);
Result:
Global Style
var builder = new BookBuilder(
globalStyle: style =>
{
style.Font.Bold = true;
style.Font.Color = "FFFFFF";
style.BackgroundColor = "00008B";
});
builder.AddSheet(data);
Result:
Cell Styling
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(
_ => _.Salary,
config =>
{
config.CellStyle = (style, employee, salary) =>
{
if (salary > 100000)
{
style.Font.Color = "006400";
style.Font.Bold = true;
}
};
})
.Column(
_ => _.IsActive,
config =>
{
config.CellStyle = (style, employee, isActive) =>
{
if (isActive)
{
style.BackgroundColor = "90EE90";
}
else
{
style.BackgroundColor = "FFB6C1";
}
};
});
Result:
Render
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(
_ => _.Name,
_ => _.Render = (employee, name) => name.ToUpper())
.Column(
_ => _.IsActive,
_ => _.Render = (employee, active) => active ? "Active" : "Inactive")
.Column(
_ => _.HireDate,
_ => _.Format = "yyyy-MM-dd");
Result:
Formula
A column can emit an Excel formula per row instead of a computed value. The
callback receives a FormulaContext<TModel> that exposes the current
1-based Excel Row number and helpers to build cell references to other
columns in the same row:
Ref(_ => _.OtherProperty)— full cell reference (e.g.B5).Column(_ => _.OtherProperty)— column letter only (e.g.B).
Formulas take precedence over the normal value rendering, and may still use
Format for number formatting and CellStyle for styling.
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(
_ => _.Salary,
_ =>
{
_.Formula = (employee, context) =>
$"={context.Ref(_ => _.Id)} * 10000";
_.Format = "#,##0";
_.Width = 15;
});
The shorter Formula() overload on ISheetBuilder<TModel> can be used when
the formula does not depend on the model:
builder.AddSheet(employees)
.Formula(
_ => _.Salary,
context => $"={context.Ref(_ => _.Id)} * 1000");
Result:
Note: Formulas are an Excel-only feature. They are not supported in Word tables and will throw when Build() is called.
Column Widths
Fluent
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Name, _ => _.Width = 25)
.Column(_ => _.Email, _ => _.Width = 30)
.Column(_ => _.HireDate, _ => _.Width = 15);
ColumnAttribute
public class Employee
{
[Column(Width = 25)]
public required string Name;
Result:
Order of precedence
- Fluent
ColumnAttribute
Column Min/Max Widths
Columns can be constrained to a minimum or maximum width while still auto-sizing based on content.
When Width is explicitly set, MinWidth/MaxWidth are ignored.
A book-wide or per-sheet defaultMinColumnWidth can also be set; it applies to every auto-sized column that does not have its own MinWidth. This pairs with the existing defaultMaxColumnWidth (default 50).
Per-sheet default min width
var builder = new BookBuilder();
builder.AddSheet(employees, defaultMinColumnWidth: 25);
Book-wide default min width
var builder = new BookBuilder(defaultMinColumnWidth: 25);
builder.AddSheet(employees);
MinWidth
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Name, _ => _.MinWidth = 40);
MaxWidth
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Name, _ => _.MaxWidth = 5);
Standalone methods
var sheet = builder.AddSheet(employees);
sheet.MinWidth(_ => _.Name, 40);
sheet.MaxWidth(_ => _.Email, 20);
ColumnAttribute
public class EmployeeWithMinMaxWidth
{
[Column(MinWidth = 40)]
public required string Name;
[Column(MaxWidth = 20)]
public required string Email;
}
Max Row Height
Cells with wrapped or multi-line content can cause rows to grow very tall. A maximum row height (in points) can be set on the BookBuilder (applied to every sheet) or per sheet on AddSheet. Rows whose estimated content fits within the limit are left to auto-size; rows that would exceed it are capped.
The header row is exempt — it always auto-sizes, regardless of MaxRowHeight.
Per-sheet
var builder = new BookBuilder();
builder.AddSheet(notes, maxRowHeight: 60);
Book-wide
var builder = new BookBuilder(maxRowHeight: 60);
builder.AddSheet(notes);
Protection
Pass a SheetProtectionOptions to produce a password-protected workbook. By default:
- data cells are editable, header cells are locked
- the sheet structure (add / remove / rename / reorder) is locked
- cell formatting, inserting, and deleting are blocked
- sorting and using the auto-filter remain available
var builder = new BookBuilder(
protection: new()
{
Password = "secret"
});
builder.AddSheet(data);
Password is optional
If Password is omitted, a fresh GUID is used for each SheetProtectionOptions instance. That produces a workbook the user cannot manually unprotect — useful when the goal is to lock structure rather than share an unlock code.
var builder = new BookBuilder(
protection: new());
builder.AddSheet(data);
Customizing what is allowed
Every SheetProtection flag is exposed on SheetProtectionOptions. Booleans use OOXML's "disabled" semantics: true means the action is blocked when the sheet is protected.
var builder = new BookBuilder(
protection: new()
{
Password = "secret",
FormatCells = false,
Sort = true,
AutoFilter = true
});
builder.AddSheet(data);
SheetProtectionOptions definition
namespace Excelsior;
/// <summary>
/// Defaults match a "data-entry" workbook: the user can edit unlocked data cells,
/// sort, and filter, but cannot change structure or formatting. Booleans that map to
/// SheetProtection attributes use OOXML's "disabled" semantics: true = the action is
/// blocked when the sheet is protected.
/// </summary>
public class SheetProtectionOptions
{
/// <summary>
/// If not provided, a fresh GUID is used. That gives a workbook the user can't
/// accidentally unprotect — fine when the goal is to lock structure rather than
/// share an unlock code.
/// </summary>
public string Password { get; init; } = Guid.NewGuid().ToString();
/// <summary>
/// Block editing of embedded objects (shapes, charts, controls).
/// </summary>
public bool Objects { get; init; } = true;
/// <summary>
/// Block editing of saved scenarios (Data > What-If Analysis > Scenario Manager).
/// </summary>
public bool Scenarios { get; init; } = true;
/// <summary>
/// Block changing cell formatting (font, fill, number format, etc).
/// </summary>
public bool FormatCells { get; init; }
/// <summary>
/// Block changing column width / hiding columns.
/// </summary>
public bool FormatColumns { get; init; }
/// <summary>
/// Block changing row height / hiding rows.
/// </summary>
public bool FormatRows { get; init; } = true;
/// <summary>
/// Block inserting new columns.
/// </summary>
public bool InsertColumns { get; init; } = true;
/// <summary>
/// Block inserting new rows.
/// </summary>
public bool InsertRows { get; init; } = true;
/// <summary>
/// Block inserting hyperlinks.
/// </summary>
public bool InsertHyperlinks { get; init; } = true;
/// <summary>
/// Block deleting columns.
/// </summary>
public bool DeleteColumns { get; init; } = true;
/// <summary>
/// Block deleting rows.
/// </summary>
public bool DeleteRows { get; init; } = true;
/// <summary>
/// Allow selecting locked cells (e.g. headers) so users can copy from them.
/// </summary>
public bool SelectLockedCells { get; init; }
/// <summary>
/// Allow selecting unlocked (data) cells so they can be edited.
/// </summary>
public bool SelectUnlockedCells { get; init; }
/// <summary>
/// Allow sorting; useful for reviewing the data.
/// </summary>
public bool Sort { get; init; }
/// <summary>
/// Allow using the auto-filter dropdowns added to the header row.
/// </summary>
public bool AutoFilter { get; init; }
/// <summary>
/// Block editing pivot tables and pivot charts.
/// </summary>
public bool PivotTables { get; init; } = true;
}
| Option | Default | When true, blocks |
|---|---|---|
Objects | true | Editing embedded objects (shapes, charts, controls) |
Scenarios | true | Editing saved scenarios (Data > What-If Analysis > Scenario Manager) |
FormatCells | false | Changing cell formatting (font, fill, number format) |
FormatColumns | false | Changing column width / hiding columns |
FormatRows | true | Changing row height / hiding rows |
InsertColumns | true | Inserting new columns |
InsertRows | true | Inserting new rows |
InsertHyperlinks | true | Inserting hyperlinks |
DeleteColumns | true | Deleting columns |
DeleteRows | true | Deleting rows |
SelectLockedCells | false | Selecting locked cells (e.g. headers) |
SelectUnlockedCells | false | Selecting unlocked (data) cells |
Sort | false | Sorting |
AutoFilter | false | Using the auto-filter dropdowns |
PivotTables | true | Editing pivot tables and pivot charts |
Document Properties
BookBuilder.SetProperties sets the workbook's document properties — the standard fields Excel surfaces in the File > Info pane, plus any user-defined entries shown on the "Custom" tab of the Advanced Properties dialog. Every field is optional; only values that are set get written, so an unset property leaves that part of the workbook untouched.
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees());
builder.SetProperties(
new()
{
Title = "Q1 staff snapshot",
Author = "Simon",
Subject = "Headcount",
Keywords = "staff, q1",
Comments = "Generated by Excelsior",
Category = "HR",
Status = "Final",
LastModifiedBy = "Simon",
Company = "Papyrine",
Manager = "Jane",
Custom =
{
["Project"] = "Excelsior",
["Reviewed"] = true,
["Revision"] = 3,
["Score"] = 9.5,
["GeneratedAt"] = new DateTime(2026, 1, 15, 9, 30, 0, DateTimeKind.Utc),
["DatasetId"] = new Guid("9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d")
}
});
using var stream = await builder.ToMemoryStream();
The standard fields map onto the workbook's core and extended (app) property parts:
BookProperties member | Written to |
|---|---|
Title, Subject, Keywords, Category, Status | Core properties |
Author | Core Creator |
Comments | Core Description |
LastModifiedBy | Core LastModifiedBy |
Company, Manager | Extended (app) properties |
Custom | "Custom" tab — user-defined name/value pairs |
Custom values may be a string, bool, an integral or floating-point number, DateTime, DateOnly (Date), or Guid. Any other value type throws an ArgumentException when the workbook is built, so convert it to a supported type first. A null value is written as empty text.
The last call to SetProperties wins; pass a fresh BookProperties to replace previously set values.
Reading custom properties
BookReader reads custom properties back with GetCustomProperty<T>(name) (throws when the property is absent or its value cannot be converted to T) and TryGetCustomProperty<T>(name, out value) (returns false in those cases instead). The type argument drives the conversion — the inverse of the value type passed in — so a Guid written as text is parsed back to a Guid. Names are matched case-insensitively. Call after Convert/TryConvert.
var datasetId = new Guid("9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d");
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees());
builder.SetProperties(
new()
{
Custom =
{
["DatasetId"] = datasetId,
["Project"] = "Excelsior",
["Revision"] = 3,
["Reviewed"] = true,
["Score"] = 9.5
}
});
using var stream = await builder.ToMemoryStream();
var reader = new BookReader();
reader.AddSheet<Employee>();
reader.Convert(stream);
// The type argument drives the conversion: a Guid is parsed back from its text form.
var id = reader.GetCustomProperty<Guid>("DatasetId");
var revision = reader.GetCustomProperty<int>("Revision");
Reading a property this way needs a Convert first, which parses every sheet. Where only a property is wanted — routing an upload by an embedded id, say — read it on its own:
var properties = BookReader.ReadCustomProperties(stream);
That opens docProps/custom.xml and nothing else. Values come back as the raw stored text, so a Guid or a number is converted by the caller. The instance members above do that conversion, at the cost of reading the workbook.
Cell Notes
Note attaches an Excel note — the classic red-triangle annotation, not a threaded comment — to a column's heading cell. Unlike an input hint — which only appears on an editable cell — a note stays visible on hover even when the sheet is protected and the cell is locked. That makes it the natural place to explain a constraint, document a column, or say why a column is read-only.
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees())
.Note(_ => _.Salary, "Gross annual salary in USD, before tax.");
using var book = await builder.Build();
Notes work on every sheet kind (data-bound, dictionary, and template) and combine freely with protection and validation.
Banner Row
Banner adds a single merged row of arbitrary text above the header — a place to surface instructions to whoever edits the sheet. The row spans every column, and under protection it is read-only.
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees())
.Banner("Confidential - for internal distribution only.");
using var book = await builder.Build();
By default the banner and header are frozen together, so the instructions stay visible while scrolling. Pass freeze: false to leave the top of the sheet unpinned — Excel can only freeze contiguous rows from the top, so a scrolling banner means the header scrolls with it.
The banner row grows to fit its wrapped text, since Excel does not auto-size merged cells. Pass maxHeight (in points) to cap how tall it can get; text beyond the cap is clipped.
A second overload hands back the raw Cell instead of a string, so rich text (multiple formatted runs) can be placed in the banner:
builder.AddSheet(SampleData.Employees())
.Banner(cell =>
{
cell.DataType = CellValues.InlineString;
cell.InlineString = new(
new Run(
new RunProperties(new Bold()),
new Text("Action required: ")),
new Run(
new Text("complete every highlighted field.")));
});
Banners work on every sheet kind (data-bound, dictionary, and template). The banner row count is recorded in the workbook's column metadata, so BookReader skips past it automatically and a bannered sheet round-trips cleanly.
Complex Types
For complex types, by default is to render via .ToString().
Models
public record Person(string Name, Address Address);
public record Address(int Number, string Street, string City, State State, ushort PostCode);
Build
var builder = new BookBuilder();
List<Person> data =
[
new("John Doe",
new Address(
Number: 900,
Street: "Victoria Square",
City: "Adelaide",
State: State.SA,
PostCode: 5000)),
];
builder.AddSheet(data);
Result:
Custom render for Complex Types
[ModuleInitializer]
public static void Init() =>
ValueRenderer.For<Address>(_ => $"{_.Number}, {_.Street}, {_.City}, {_.State}, {_.PostCode}");
var builder = new BookBuilder();
List<Person> data =
[
new("John Doe",
new Address(
Number: 900,
Street: "Victoria Square",
City: "Adelaide",
State: State.SA,
PostCode: 5000)),
];
builder.AddSheet(data);
Result:
Links
Excelsior has first-class support for hyperlinks via the Link type.
namespace Excelsior;
public record Link(string Url, string? Text = null);
A single Link property renders as a clickable hyperlink. When Text is provided it is shown as the display text; otherwise the URL is displayed.
For IEnumerable<Link>, items are rendered as blue-styled rich text with URLs visible (e.g., ● Google (https://google.com)) but are not individually clickable since Excel supports only one hyperlink per cell.
Model
public record LinkTarget(
string Name,
Link Link,
Link? NullableLink,
IEnumerable<Link> Links,
IEnumerable<Link>? NullableLinks,
IEnumerable<Link?> LinksWithNulls);
Build
List<LinkTarget> data =
[
new(
"Test",
new("https://google.com", "Google"),
new("https://github.com", "GitHub"),
[
new("https://google.com", "Google"),
new("https://github.com", "GitHub")
],
[
new("https://google.com", "Google")
],
[
new("https://google.com", "Google"),
null,
new("https://github.com", "GitHub")
])
];
var builder = new BookBuilder();
builder.AddSheet(data);
Result
HTML Cells
A column can be marked as HTML so its string values are parsed and rendered as rich text via OpenXmlHtml.
There are four equivalent ways to opt in:
// ColumnAttribute
public class Employee
{
[Column(IsHtml = true)]
public required string Notes;
}
// StringSyntax attribute (case-insensitive match on "html")
public class Employee
{
[StringSyntax("html")]
public required string Notes;
}
// Any attribute whose type name is `HtmlAttribute` (namespace ignored, matched by name)
public class Employee
{
[Html]
public required string Notes;
}
// Fluent
sheet.Column(
_ => _.Notes,
_ => _.IsHtml = true);
[StringSyntax("html")] is useful when the property is already being annotated for IDE/analyzer support and a second attribute would be redundant.
[Html] detection is provided as a convenience for codebases that already define a custom HtmlAttribute for other purposes (e.g. sanitization, templating). Excelsior does not ship this attribute — it matches any attribute whose type name is HtmlAttribute, regardless of namespace. This path has the lowest precedence: both [Column(IsHtml = ...)] and [StringSyntax("html")] override it.
If any two of these opt-in paths disagree — for example [Column(IsHtml = false)] combined with [StringSyntax("html")], or a fluent IsHtml = false on a column where the attribute says true — Excelsior throws at runtime. The EXCEL003 analyzer catches the attribute-level form of this mismatch at compile time.
Whitespace
By default whitespace is trimmed
var builder = new BookBuilder();
List<Employee> data =
[
new()
{
Id = 1,
Name = " John Doe ",
Email = " john@company.com ",
}
];
builder.AddSheet(data);
using var book = await builder.Build();
Result
Disable whitespace trimming
static void DisableTrimWhitespace() =>
ValueRenderer.DisableWhitespaceTrimming();
Result
Enumerable string properties
Properties that are castable to an IEnumerable<string> will automatically be rendered as a point form list.
Model
public record Person(string Name, IEnumerable<string> PhoneNumbers);
Render
List<Person> data =
[
new("John Doe",
PhoneNumbers:
[
"+1 3057380950",
"+1 5056169368",
"+1 8634446859"
]),
];
var builder = new BookBuilder();
builder.AddSheet(data);
Result
Binding Model
The recommended approach is to use a specific type for binding.
This will make configuration and rendering simpler. It will often also result in better performance. The reason being that the projection into the binding type can be done by the database via an ORM. This will result in a faster query response and less data being transferred from the database.
Take for example of rendering employees to a sheet. A potential model could be Company, Employee, and Address.
public class Address
{
public required int StreetNumber { get; init; }
public required string Street { get; init; }
}
public class Company
{
public required int Id { get; init; }
public required string Name { get; init; }
}
public class Employee
{
public required int Id { get; init; }
public required string Name { get; init; }
public required Company Company { get; init; }
public required Address Address { get; init; }
public required string Email { get; init; }
}
Then a custom binding type can be used.
public class EmployeeBindingModel
{
public required string Name;
public required string Email;
public required string Company;
public required string Address;
}
The custom binding type can be queried and rendered into a sheet.
var employees = dbContext
.Employees
.Select(_ =>
new EmployeeBindingModel
{
Name = _.Name,
Email = _.Email,
Company = _.Company.Name,
Address = $"{_.Address.StreetNumber} {_.Address.Street}",
});
var builder = new BookBuilder();
builder.AddSheet(employees);
Anonymous Types
Anonymous types can be used as binding models. Type-safe column configuration via Column(_ => _.Property, ...) works as long as it is chained directly off AddSheet(...) so the compiler can infer the model type.
var employees = SampleData.Employees()
.Select(_ => new
{
_.Name,
_.Email,
_.Salary
});
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(
_ => _.Salary,
_ => _.Heading = "Annual Salary");
using var book = await builder.Build();
Dictionary Sheets
When the data isn't backed by a class, use AddDictionarySheet to write rows from IReadOnlyDictionary<string, object?>. Columns are declared explicitly via Column<TProperty>("key", ...); the key is the dictionary lookup and the default heading. TProperty drives type-based defaults (date format, enum dropdown, numeric ISNUMBER validation) the same way a strong-typed property does. Keys missing from a row are written as null cells.
Keys must be unique within a sheet — compared case-insensitively, so "Name" and "name" collide — and must not have leading or trailing whitespace. Either violation throws when the column is declared.
var rows = new IReadOnlyDictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["Name"] = "John Doe",
["Email"] = "john@company.com",
["HireDate"] = new DateTime(2020, 1, 15),
["Salary"] = 75_000m
},
new Dictionary<string, object?>
{
["Name"] = "Jane Smith",
["Email"] = "jane@company.com",
["HireDate"] = new DateTime(2019, 3, 22),
["Salary"] = 120_000m
}
};
var builder = new BookBuilder();
builder.AddDictionarySheet(rows)
.Column<string>("Name", _ => _.Width = 25)
.Column<string>("Email", _ => _.Width = 30)
.Column<DateTime>("HireDate", _ => _.Heading = "Hire Date")
.Column<decimal>(
"Salary",
_ =>
{
_.Heading = "Annual Salary";
_.Format = "$#,##0.00";
});
using var book = await builder.Build();
Type-Based Inference
A column's TProperty drives the same defaults as the strong-typed path. For example, an enum-typed column auto-derives its allowed values into a dropdown:
var rows = new IReadOnlyDictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["Name"] = "Alice",
["Status"] = EmployeeStatus.FullTime
},
new Dictionary<string, object?>
{
["Name"] = "Bob",
["Status"] = EmployeeStatus.PartTime
},
};
var builder = new BookBuilder();
builder.AddDictionarySheet(rows)
.Column<string>("Name")
.Column<EmployeeStatus>("Status");
using var book = await builder.Build();
Formulas
Formulas can reference other columns by key via string-keyed overloads on FormulaContext. ctx.Ref("Quantity") resolves to the cell reference (e.g. B2) for the Quantity column on the current row.
var rows = new IReadOnlyDictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["Item"] = "Widget",
["Quantity"] = 3,
["UnitPrice"] = 10m
},
new Dictionary<string, object?>
{
["Item"] = "Gadget",
["Quantity"] = 5,
["UnitPrice"] = 8m
},
};
var builder = new BookBuilder();
builder.AddDictionarySheet(rows)
.Column<string>("Item")
.Column<int>("Quantity")
.Column<decimal>("UnitPrice")
.Column<decimal>(
"Total",
_ =>
{
_.Format = "$#,##0.00";
_.Formula = (_, context) => $"={context.Ref("Quantity")}*{context.Ref("UnitPrice")}";
_.Width = 12;
});
using var book = await builder.Build();
Round-Trip with BookReader
Dictionary sheets written by BookBuilder can be read back by the dictionary path on BookReader (reader.AddSheet()). Column metadata records each column's key, so headings can be renamed independently of the keys without breaking the round-trip.
var rows = new IReadOnlyDictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["Name"] = "Alice",
["HireDate"] = new Date(2020, 1, 15),
["Status"] = EmployeeStatus.FullTime
},
new Dictionary<string, object?>
{
["Name"] = "Bob",
["HireDate"] = new Date(2021, 6, 1),
["Status"] = EmployeeStatus.PartTime
}
};
var stream = new MemoryStream();
var builder = new BookBuilder();
builder.AddDictionarySheet(rows)
.Column<string>("Name")
.Column<Date>("HireDate", _ => _.Heading = "Hire Date")
.Column<EmployeeStatus>("Status");
await builder.ToStream(stream);
stream.Position = 0;
var reader = new BookReader();
var sheet = reader.AddSheet();
sheet
.Column<string>("Name")
.Column<Date>("HireDate")
.Column<EmployeeStatus>("Status");
reader.Convert(stream);
var first = sheet.Rows[0];
Template Sheets
AddTemplateSheet produces an empty spreadsheet for the user to fill in — known column names, types, widths, formats, and validation but no data rows. Validation, locked-cell behavior, and conditional formatting all extend down templateRowCount rows below the header (defaults to 1000).
Column names must be unique within a sheet — compared case-insensitively, so "Name" and "name" collide — and must not have leading or trailing whitespace. Either violation throws when the column is declared.
var builder = new BookBuilder();
builder.AddTemplateSheet("Employees")
.Column<string>("Name", _ => _.Width = 25)
.Column<string>("Email", _ => _.Width = 30)
.Column<DateTime>(
"HireDate",
_ =>
{
_.Heading = "Hire Date";
_.Width = 15;
})
.Column<decimal>(
"Salary",
_ =>
{
_.Heading = "Annual Salary";
_.Format = "$#,##0.00";
_.Width = 18;
});
using var book = await builder.Build();
Type-Based Inference
Template sheets infer common validation rules from the column's type:
| Signal | Inferred | Gated by inferValidationFromTypes |
|---|---|---|
enum / enum? | dropdown list of enum members | no — always on |
bool / bool? | dropdown of TRUE / FALSE (see note below) | no — always on |
Numeric (int, decimal, double, etc.) | ISNUMBER constraint — manually-typed non-numeric values are blocked | no — always on |
Date/time (DateTime, DateTimeOffset, DateOnly, TimeOnly) | date constraint across Excel's full date span — manually-typed non-dates are blocked | no — always on |
C# required modifier or [Required] attribute | Required = true | no — always on |
Non-nullable value type (int, decimal, DateTime, bool, enum) | Required = true | yes |
| Non-nullable reference type (NRT-aware, data-bound only) | Required = true | yes |
When a column is Required and has no other validation type (e.g. a non-empty string), Excelsior emits a LEN(TRIM(...))>0 custom validation with allowBlank="0". Excel blocks blank entries with the default message "This field is required." — clearing the cell triggers the Stop popup. Set ErrorMessage to override.
public class Employee
{
// always-on Required
public required string Name;
// always-on Required
[Required]
public string Email = "";
// not Required
public string? Notes { get; init; }
}
var builder = new BookBuilder();
builder.AddTemplateSheet("Employees", templateRowCount: 10)
.Column<string>("Name")
.Column<int>("Age")
.Column<bool>("IsActive")
.Column<DateTime>("HireDate");
using var book = await builder.Build();
Inference is on by default for AddTemplateSheet and off by default for AddSheet. Pass inferValidationFromTypes: false to disable on a template, or inferValidationFromTypes: true to opt in on a data-bound sheet:
InferenceModel[] data =
[
new()
{
Name = "Alice",
Age = 30,
IsActive = true,
HireDate = new(2020, 1, 1)
}
];
var builder = new BookBuilder();
builder.AddSheet(
data,
templateRowCount: 5,
inferValidationFromTypes: true);
using var book = await builder.Build();
Per-column overrides always win — set Required = false or DisableAllowedValues = true to opt out for one column.
Note on the bool dropdown. Excel does have a native Boolean cell type (OOXML t="b") and Excelsior writes bool values that way. The auto-derived dropdown is a string list of TRUE,FALSE, so when a user picks an entry Excel inserts the literal text — in practice it auto-coerces back to a Boolean cell on edit, but the result can be mixed cell types in the same column (Booleans written by Excelsior vs. strings the user picked) which can affect formulas like =A2*1 or COUNTIF(A:A, TRUE). For strict Boolean enforcement, set DisableAllowedValues = true on the column — this drops the dropdown so a custom =ISLOGICAL(A2) constraint can be supplied via a custom data validation. For typical data-entry templates the dropdown is the better UX.
Auto-Derived Enum Dropdowns
Enum-typed columns automatically render as dropdown lists (regardless of the inference flag). The list values match the same rendering used for cell content, so [Display(Name = "Full Time")] shows "Full Time" in the dropdown.
var builder = new BookBuilder(headingStyle: _ => _.Font.Bold = true);
builder.AddTemplateSheet("Employees", templateRowCount: 50)
.Column<string>("Name", _ => _.Width = 25)
.Column<EmployeeStatus>("Status", _ => _.Width = 14);
using var book = await builder.Build();
This applies to data-bound sheets too — set templateRowCount on AddSheet to extend dropdowns past the data rows so users adding new rows still get validation.
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees(), templateRowCount: 25);
using var book = await builder.Build();
To suppress the auto-derived dropdown for a specific column, set DisableAllowedValues = true. Setting AllowedValues explicitly to a list overrides the auto-derived values.
Numeric and Date Ranges
Restrict entry to a numeric or date range via Range(min, max), or set NumericMin/NumericMax/DateMin/DateMax individually for one-sided constraints.
var builder = new BookBuilder();
builder.AddTemplateSheet("Scorecard", templateRowCount: 25)
.Column<string>("Name", _ => _.Width = 25)
.Column<int>(
"Score",
_ =>
{
_.Width = 10;
_.Range(0, 100);
_.InputTitle = "Score";
_.InputMessage = "Whole number between 0 and 100.";
_.ErrorTitle = "Invalid score";
_.ErrorMessage = "Score must be between 0 and 100.";
});
using var book = await builder.Build();
var builder = new BookBuilder();
builder.AddTemplateSheet("Hires", templateRowCount: 25)
.Column<string>("Name", _ => _.Width = 25)
.Column<DateTime>(
"HireDate",
_ =>
{
_.Heading = "Hire Date";
_.Width = 15;
_.Range(new(2020, 1, 1), new DateTime(2030, 12, 31));
});
using var book = await builder.Build();
Input Hints and Error Messages
Excel speaks to editors through two channels, and Excelsior fills both in automatically so a constraint is never a hidden surprise:
- Input hint — the tooltip shown proactively when a cell is selected, before anything is typed. This is the only channel that tells an editor the rule before they break it.
- Error alert — the popup shown reactively after an invalid value is rejected.
Both ride on Excel's data validation: the input hint is its input message; the rejection popup is its error alert.
Every constrained column advertises its rule as an input hint with no configuration. A hint is derived for:
- allowed-value lists / dropdowns —
"Select one of: A, B, C." - numeric ranges —
"Enter a number between X and Y."(a one-sided bound becomes greater than or equal to / less than or equal to) - date ranges —
"Enter a date between X and Y.", with the same one-sided variants - required cells —
"This field is required.", shown only when no range or list also applies to the column
The one constraint left without a hint is the bare ISNUMBER check on a plain numeric column — that a number column holds numbers is self-evident, so it is not surfaced on every cell select.
// No InputMessage is set, yet the Range constraint is surfaced as an input hint
// ("Enter a number between 0 and 1000000.") shown when the cell is selected.
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees(), templateRowCount: 10)
.Column(_ => _.Salary, _ => _.Range(0, 1_000_000));
using var book = await builder.Build();
Set InputTitle / InputMessage to replace the auto-generated hint with custom wording, or suppress the auto hints entirely — DisableInputMessage for a single column, or DisableInputMessages for the whole sheet. An explicit InputMessage always survives either.
var builder = new BookBuilder();
var sheet = builder.AddSheet(SampleData.Employees(), templateRowCount: 10);
sheet.Range(_ => _.Salary, 0, 1_000_000);
sheet.DisableInputMessage(_ => _.Salary);
using var book = await builder.Build();
DisableInputMessages turns the auto hints off across the sheet in one call (handy for a dense data-entry sheet where every column would otherwise carry a tooltip):
var builder = new BookBuilder();
var sheet = builder.AddSheet(SampleData.Employees(), templateRowCount: 10);
sheet.Range(_ => _.Salary, 0, 1_000_000);
// Turn the auto-generated hints off for every column on the sheet...
sheet.DisableInputMessages();
// ...but an explicit InputMessage still applies.
sheet.InputMessage(_ => _.Email, "Use the corporate address.");
using var book = await builder.Build();
ErrorTitle / ErrorMessage override the rejection popup. When unset, Excelsior fills in a default based on the validation type — "Must be one of: A, B, C." for dropdowns, "Must be a number between X and Y." for ranges, "Must be a number." for the auto-ISNUMBER constraint, etc.
ErrorStyle controls Excel's response to invalid input:
| Style | Behavior |
|---|---|
Stop (default) | Block the entry — user must enter a valid value or cancel. |
Warning | Warn the user; they can choose to keep the invalid value. |
Information | Inform the user; the value is accepted regardless. |
var builder = new BookBuilder();
builder.AddSheet(SampleData.Employees(), templateRowCount: 5)
.Column(
_ => _.Salary,
_ =>
{
_.Range(0, 1_000_000);
_.ErrorStyle = ValidationErrorStyle.Warning;
});
using var book = await builder.Build();
Required Cells
Required = true does two things:
- Highlights blank cells in the column with a soft red conditional-format fill, drawing attention to fields the user has not yet filled in.
- When the column has no other validation type (e.g. a free-text string), emits a
LEN(TRIM(...))>0custom validation withallowBlank="0"so Excel rejects blank values typed into the cell.
Excel limitation. Excel's data validation only fires on typed entry. It does not fire when the user clears a cell with the Delete key, pastes a blank value in, fills via drag, or writes from a macro — that's documented Excel behavior, not something Excelsior can override. So:
- Typing a blank value and pressing Enter → the Stop popup fires and the entry is rejected.
- Pressing Delete to clear an existing value → the cell goes blank silently, but the conditional-formatting highlight makes the gap visible.
For true enforcement at save time (block save while any required cell is blank), a workbook-level VBA macro on Workbook_BeforeSave is required — that's out of scope for a template generator.
var builder = new BookBuilder();
builder.AddTemplateSheet("Employees", templateRowCount: 25)
.Column<string>(
"Name",
_ =>
{
_.Width = 25;
_.Required = true;
})
.Column<string>("Email", _ => _.Width = 30)
.Column<DateTime>(
"HireDate",
_ =>
{
_.Heading = "Hire Date";
_.Width = 15;
_.Required = true;
});
using var book = await builder.Build();
Locked Cells under Protection
When the workbook is built with SheetProtectionOptions, headings are locked and data cells are unlocked by default. Set Locked = true on a column to lock its data cells too — useful for read-only identifier columns or pre-filled formula results.
var builder = new BookBuilder(
protection: new()
{
Password = "secret"
});
builder.AddTemplateSheet("Employees", templateRowCount: 25)
.Column<string>("Name", _ => _.Width = 25)
.Column<string>(
"EmployeeId",
_ =>
{
_.Heading = "Employee ID";
_.Width = 14;
_.Locked = true;
});
using var book = await builder.Build();
Combining Features
A typical "data entry" workbook combines several of these features:
var builder = new BookBuilder(
headingStyle: _ =>
{
_.Font.Bold = true;
_.BackgroundColor = "FFEFEFEF";
});
builder.AddTemplateSheet("Employees", templateRowCount: 100)
.Column<string>(
"Name",
_ =>
{
_.Width = 25;
_.Required = true;
_.InputMessage = "Full name of the employee.";
})
.Column<string>(
"Email",
_ =>
{
_.Width = 30;
_.Required = true;
})
.Column<DateTime>(
"HireDate",
_ =>
{
_.Heading = "Hire Date";
_.Width = 15;
_.Required = true;
_.Range(new(2020, 1, 1), new DateTime(2030, 12, 31));
_.ErrorMessage = "Hire date must be on or after 2020-01-01.";
})
.Column<decimal>(
"Salary",
_ =>
{
_.Heading = "Annual Salary";
_.Format = "$#,##0.00";
_.Width = 18;
_.Range(0m, 1_000_000m);
})
.Column<EmployeeStatus>(
"Status",
_ =>
{
_.Width = 14;
_.Required = true;
});
using var book = await builder.Build();
All of the above features (AllowedValues, Range, Required, Locked, InputMessage, ErrorMessage) work the same way on the data-bound AddSheet(...).Column(_ => _.Foo, c => ...) API.
Shortcut Methods
The data-bound ISheetBuilder<TModel> exposes each validation feature as a one-line shortcut, mirroring the existing Width, Format, Filter etc. shortcuts.
| Shortcut | Configures |
|---|---|
AllowedValues(p, values) | dropdown list |
DisableAllowedValues(p) | suppress the auto-derived enum dropdown |
Range(p, decimal min, decimal max) | numeric range |
Range(p, DateTime min, DateTime max) | date range |
Required(p) | conditional-format blank highlight |
Locked(p, value = true) | per-column lock under protection |
InputMessage(p, message, title = null) | input-hint tooltip |
ErrorMessage(p, message, title = null) | error popup on invalid input |
var builder = new BookBuilder();
var sheet = builder.AddSheet(SampleData.Employees(), templateRowCount: 25);
sheet.Range(_ => _.Salary, 0, 1_000_000);
sheet.Required(_ => _.Email);
sheet.InputMessage(_ => _.Salary, "Annual salary in USD.", "Salary");
sheet.ErrorMessage(_ => _.Salary, "Salary must be between 0 and 1,000,000.", "Invalid salary");
using var book = await builder.Build();
ColumnAttribute
ColumnAttribute allows customization of rendering at the model level.
It is intended as the preferred approach over usage of DisplayAttribute and DisplayNameAttribute.
DisplayAttribute and DisplayNameAttribute are support for scenarios where it is not convenient to reference Excelsior from that assembly.
ColumnAttribute definition
using JetBrains.Annotations;
namespace Excelsior;
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
[MeansImplicitUse]
public sealed class ColumnAttribute :
Attribute
{
public string? Heading { get; set; }
public int Order { get; set; } = -1;
public int Width { get; set; } = -1;
public int MinWidth { get; set; } = -1;
public int MaxWidth { get; set; } = -1;
public string? Format { get; set; }
public string? NullDisplay { get; set; }
public bool IsHtml
{
get;
set
{
field = value;
IsHtmlHasValue = true;
}
}
internal bool IsHtmlHasValue { get; private set; }
public bool Filter
{
get;
set
{
field = value;
FilterHasValue = true;
}
}
internal bool FilterHasValue { get; private set; }
public bool Include
{
get;
set
{
field = value;
IncludeHasValue = true;
}
} = true;
internal bool IncludeHasValue { get; private set; }
/// <summary>
/// When <see cref="Include"/> is explicitly <c>false</c> the column is dropped before
/// any other setting is read, so every other setting is silently ignored. Returns the
/// names of any such redundant settings (empty when there is no conflict).
/// </summary>
internal IReadOnlyList<string> ConflictingExclusionSettings()
{
if (!IncludeHasValue || Include)
{
return [];
}
var conflicts = new List<string>();
if (Heading != null)
{
conflicts.Add(nameof(Heading));
}
if (Order > -1)
{
conflicts.Add(nameof(Order));
}
if (Width > -1)
{
conflicts.Add(nameof(Width));
}
if (MinWidth > -1)
{
conflicts.Add(nameof(MinWidth));
}
if (MaxWidth > -1)
{
conflicts.Add(nameof(MaxWidth));
}
if (Format != null)
{
conflicts.Add(nameof(Format));
}
if (NullDisplay != null)
{
conflicts.Add(nameof(NullDisplay));
}
if (IsHtmlHasValue)
{
conflicts.Add(nameof(IsHtml));
}
if (FilterHasValue)
{
conflicts.Add(nameof(Filter));
}
return conflicts;
}
}
Usage
public class Employee
{
[Column(Heading = "Employee ID", Order = 1, Format = "0000")]
public required int Id;
[Column(Heading = "Full Name", Order = 2, Width = 20)]
public required string Name;
[Column(Heading = "Email Address", Width = 30)]
public required string Email;
[Column(Order = 3, NullDisplay = "unknown")]
public Date? HireDate;
}
var builder = new BookBuilder();
List<Employee> data =
[
new()
{
Id = 1,
Name = "John Doe",
Email = "john@company.com",
HireDate = new(2020, 1, 15),
},
new()
{
Id = 2,
Name = "Jane Smith",
Email = "jane@company.com",
HireDate = null,
}
];
builder.AddSheet(data);
Result
ValueRenderer.ForEnums
ValueRenderer.ForEnums can be used to control the rendering for all enums
Config in a ModuleInitializer
static void CustomEnumRender() =>
ValueRenderer.ForEnums(_ => _.ToString().ToUpper());
Example use
var builder = new BookBuilder();
List<Car> data =
[
new()
{
Manufacturer = Manufacturer.BuildYourDream,
Color = Color.AntiqueWhite,
NullableColor = Color.AntiqueWhite,
},
new()
{
Manufacturer = Manufacturer.BuildYourDream,
Color = Color.AntiqueWhite,
}
];
builder.AddSheet(data);
Result
ValueRenderer.ForEnums using Humanizer
Using Humanizer to convert enums to strings:
Humanizer is not a dependency of Excelsior, so a reference to the Humanizer NuGet package (or Humanizer.Core) must be added to make the Humanize() extension method available.
Config in a ModuleInitializer
static void CustomEnumRender() =>
ValueRenderer.ForEnums(_ => _.Humanize());
Example use
var builder = new BookBuilder();
List<Car> data =
[
new()
{
Manufacturer = Manufacturer.BuildYourDream,
Color = Color.AntiqueWhite,
NullableColor = Color.AntiqueWhite,
},
new()
{
Manufacturer = Manufacturer.BuildYourDream,
Color = Color.AntiqueWhite,
}
];
builder.AddSheet(data);
Result
ValueRenderer.For<T>
ValueRenderer.For<T> can be used to control the rendering for all instances of a specific type. See ValueRendererForSpecificType for an example with custom enums.
Note
ValueRenderer.For<bool> and ValueRenderer.NullDisplayFor<bool> throw — replacing the cell value with a string would lose Excel's native boolean type, so formulas like =IF(A2, ...) and COUNTIF(A:A, TRUE) would stop working. Use ValueRenderer.BoolDisplay instead, which keeps cells as native booleans and applies the display via a number format.
Type specificity
When multiple For<T> registrations match a property type, the most specific type wins. For example, For<Color>(...) takes precedence over For<Enum>(...) for Color properties, while other enum types still use the Enum fallback.
ValueRenderer.BoolDisplay
ValueRenderer.BoolDisplay controls how bool and bool? columns render in Excel. Cells stay native booleans (t="b") so Excel formulas continue to recognize them as boolean values; the display strings are applied via the number format [=1]"trueDisplay";[=0]"falseDisplay". The optional third argument supplies a display for null cells in bool? columns.
Config in a ModuleInitializer
static void ConfigureBoolDisplay() =>
ValueRenderer.BoolDisplay("Yes", "No", "Unknown");
Example use
var builder = new BookBuilder();
List<Target> data =
[
new()
{
Name = "Alice",
IsActive = true,
IsAdmin = true,
},
new()
{
Name = "Bob",
IsActive = false,
IsAdmin = false,
},
new()
{
Name = "Carol",
IsActive = true,
IsAdmin = null,
}
];
builder.AddSheet(data);
Result
ValueRenderer.NullDisplayFor<T>
ValueRenderer.NullDisplayFor<T> can be used to control the display text when a nullable property is null. This combines well with ValueRenderer.For<T> — the rendered value is used when the property has a value, and the null display when it doesn't. Type specificity applies the same way as For<T>: a more specific registration wins over a less specific one for matching properties.
Note
NullDisplayFor<bool> throws — use ValueRenderer.BoolDisplay and pass the third (nullDisplay) argument.
Config in a ModuleInitializer
static void CustomNullDisplayForType()
{
ValueRenderer.For<Address>(_ => $"{_.Street}, {_.City}");
ValueRenderer.NullDisplayFor<Address>("No address on file");
}
Example use
var builder = new BookBuilder();
List<Target> data =
[
new()
{
Name = "Alice",
Address = new()
{
Street = "1 Park Ave",
City = "Springfield"
}
},
new()
{
Name = "Bob",
Address = null
}
];
builder.AddSheet(data);
Result
ValueRenderer.NullDisplayFor<Enum>
ValueRenderer.NullDisplayFor<Enum> can be used to set a default display text for all null enum properties:
Config in a ModuleInitializer
static void CustomNullEnumDisplay() =>
ValueRenderer.NullDisplayFor<Enum>("Unknown");
Example use
var builder = new BookBuilder();
List<Target> data =
[
new()
{
Name = "Alice",
Color = Color.AntiqueWhite,
},
new()
{
Name = "Bob",
}
];
builder.AddSheet(data);
Result
The same type specificity applies to NullDisplayFor<T>: NullDisplayFor<Color>("Color unknown") takes precedence over NullDisplayFor<Enum>("Enum unknown") for Color? properties.
Date formats
DateTime, DateOnly, and TimeOnly are passed directly in to the respective library.
Excel is directed (using a format string) to render the value using the following:
yyyy-MM-dd HH:mm:ssforDateTimesyyyy-MM-ddforDateOnlysHH:mm:ssforTimeOnlys
Excel has no direct support for DateTimeOffset — a cell is either a number (formatted as a date) or a string, and the offset cannot be represented natively. So DateTimeOffsets are stored as strings using the yyyy-MM-dd HH:mm:ss z format and CultureInfo.InvariantCulture. This preserves the offset on round-trip, but the cell is plain text — Excel will not treat it as a date for sorting, filtering, or arithmetic, and the format string is applied at write time rather than via Excel's cell number format.
Custom Date formats
Date formats can be customized:
static void CustomDateFormats()
{
ValueRenderer.DefaultDateFormat = "yyyy/MM/dd" ;
ValueRenderer.DefaultDateTimeFormat = "yyyy/MM/dd HH:mm:ss" ;
ValueRenderer.DefaultDateTimeOffsetFormat = "yyyy/MM/dd HH:mm:ss z" ;
ValueRenderer.DefaultTimeFormat = "HH:mm:ss" ;
}
Result
Filters
By default, auto-filter is enabled on all columns.
Disable all filters
var builder = new BookBuilder();
var sheet = builder.AddSheet(Data());
sheet.DisableFilter();
Result
Enable filter on specific columns
Filters can be disabled at the sheet level, then selectively enabled on specific columns:
var builder = new BookBuilder();
var sheet = builder.AddSheet(Data());
sheet.DisableFilter();
sheet.Filter(_ => _.Name);
Result
Disable filter on specific columns
Individual columns can opt out of filtering while the rest remain enabled:
var builder = new BookBuilder();
var sheet = builder.AddSheet(Data());
sheet.Column(
_ => _.Age,
_ => _.Filter = false);
Result
ColumnAttribute
public class Employee
{
[Column(Filter = true)]
public required string Name { get; init; }
Include/Exclude Columns
Columns can be included or excluded from the output at runtime. This is useful when generating multiple spreadsheets from the same model with different columns based on some state.
Toggle based on state
var data = Data();
var isInternalReport = true;
var builder = new BookBuilder();
var sheet = builder.AddSheet(data);
sheet.Include(_ => _.Email, !isInternalReport);
Result
Multiple spreadsheets from the same model
The same data can produce different reports by toggling column inclusion per spreadsheet:
var data = Data();
// Public report: exclude age and email
var builder = new BookBuilder();
var sheet = builder.AddSheet(data);
sheet.Exclude(_ => _.Age);
sheet.Exclude(_ => _.Email);
Result
List<Target> data = [
new("Alice", 30, "alice@test.com"),
new("Bob", 25, "bob@test.com")
];
// Internal report: include all columns
var builder = new BookBuilder();
builder.AddSheet(data);
Result
Exclude a column
List<Target> data = [
new("Alice", 30, "alice@test.com"),
new("Bob", 25, "bob@test.com")
];
var builder = new BookBuilder();
var sheet = builder.AddSheet(data);
sheet.Exclude(_ => _.Age);
Result
Exclude via Column configuration
List<Target> data = [
new("Alice", 30, "alice@test.com"),
new("Bob", 25, "bob@test.com")
];
var builder = new BookBuilder();
var sheet = builder.AddSheet(data);
sheet.Column(
_ => _.Age,
_ => _.Include = false);
Result
ColumnAttribute
public class Employee
{
[Column(Include = false)]
public required string Name { get; init; }
Splitting
SplitAttribute can be used push properties up.
public record Person(
string Name,
[Split] Address Address);
public record Address(int StreetNumber, string Street, string City, State State, ushort PostCode);
Result
UseHierachyForName
SplitAttribute.UseHierachyForName can be used to prefix members with the parent property name.
public record Person(
string Name,
[Split(UseHierachyForName = true)]
Address Address);
public record Address(int Number, string Street, string City, State State, ushort PostCode);
Result
Source Generated Extensions
A source generator is included that generates typed extension methods for ISheetBuilder, providing a more concise API.
Model
Add [SheetModel] to the model class:
[SheetModel]
public class GeneratedTestModel
{
public required string Name;
public required int Age;
}
This generates typed extension methods for each property, such as NameColumn, NameOrder, AgeWidth, etc.
Usage
Instead of:
sheet.Column(_ => _.Name, _ => _.Heading = "Full Name");
sheet.Order(_ => _.Age, 1);
Use the generated methods:
var builder = new BookBuilder();
List<GeneratedTestModel> data =
[
new()
{
Name = "Alice",
Age = 30
},
new()
{
Name = "Bob",
Age = 25
},
];
var sheet = builder.AddSheet(data);
sheet.NameColumn(_ => _.Heading = "Full Name");
sheet.AgeOrder(1);
sheet.NameOrder(2);
sheet.AgeWidth(15);
Generated methods per property
For each public property, the following extension methods are generated:
{Property}Column— configure the column (heading, order, width, style, etc.){Property}HeadingText— set the heading text{Property}Order— set the column order{Property}Width— set the column width{Property}HeadingStyle— set the heading style{Property}CellStyle— set the cell style{Property}Format— set the format string{Property}NullDisplay— set the null display text{Property}IsHtml— mark the column as HTML{Property}Render— set a custom render function{Property}Filter— enable auto-filter for the column{Property}Include— include or exclude the column from the output{Property}Exclude— exclude the column from the output
Properties with [Ignore] are skipped. Properties with [Split] (or types with [Split]) are recursed into, generating methods for the nested properties.
Word Tables
WordTableBuilder<TModel> renders model data into a Word <w:tbl> element that can be appended to an existing Word document. It reuses the same property discovery, column ordering, and per-column configuration as BookBuilder.
The header row is marked as a repeating header (w:tblHeader), so a table long enough to break across a page keeps its heading on each later page.
var builder = new WordTableBuilder<Employee>(employees);
using var stream = new MemoryStream();
using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
{
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new(new Body());
var table = builder.Build(mainPart);
var body = mainPart.Document.Body!;
body.Append(table);
Column configuration (headings, ordering, render, etc.) works the same as with BookBuilder:
var builder = new WordTableBuilder<Employee>(employees)
.Column(
_ => _.Name,
_ => _.Heading = "Person");
When a MainDocumentPart is passed to Build(), Link-typed properties produce real <w:hyperlink> elements. When omitted, links fall back to their display text.
Heading styling
The WordTableBuilder<TModel> constructor accepts an optional table-level headingStyle callback that styles every header cell. It mirrors BookBuilder.HeadingStyle and is translated at build time:
CellStyle.BackgroundColor→ cell shading (<w:shd>).CellFont.Bold/Underline/Color/Size/Name→ run properties.CellAlignment.Horizontal→ paragraph justification (defaults to left).CellAlignment.Vertical→ cell vertical alignment.
The CellStyle is preseeded with Font.Bold = true and horizontal alignment Left, matching the default header look. Callers layer on additions, or opt out by setting Font.Bold = false:
var builder = new WordTableBuilder<Employee>(
SampleData.Employees(),
_ =>
{
_.BackgroundColor = "4472C4";
_.Font.Color = "FFFFFF";
_.Font.Name = "Arial";
_.Font.Size = 12;
_.Font.Underline = true;
});
A per-column HeadingStyle on ColumnConfig composes on top of the table-level style, so individual headers can override or extend the shared look:
var builder = new WordTableBuilder<Employee>(
SampleData.Employees(),
_ => _.BackgroundColor = "000000")
.Column(
_ => _.Name,
_ => _.HeadingStyle = cell => cell.BackgroundColor = "FF0000");
Colors accept a leading # (e.g. "#4472C4") and it will be stripped before being written to OpenXml.
Body cell styling
The constructor also accepts an optional bodyStyle callback that styles every data cell, mirroring headingStyle:
var builder = new WordTableBuilder<Employee>(
SampleData.Employees(),
bodyStyle: _ =>
{
_.Font.Size = 9;
_.Font.Name = "Arial";
});
A per-column CellStyle on ColumnConfig composes on top, and — because it receives the model and value — can style cells conditionally (e.g. bold a salary over a threshold, shade a status cell). Font and alignment are written to the cell's runs/paragraph; background and vertical alignment to the cell properties.
Body styling is applied to plain-text cells. IsHtml and Link cells own their own run formatting, so they only pick up cell-level properties (background, vertical alignment), not the body font.
When neither bodyStyle nor a column CellStyle is configured, data cells stay lean — a bare run with no run or paragraph properties — so cell text follows the host document's styles (matching a hand-inserted Word table).
Named paragraph styles
To drive cell appearance from named paragraph styles already defined in the host document (e.g. a branded template), use HeadingParagraphStyle and BodyParagraphStyle:
var builder = new WordTableBuilder<Employee>(SampleData.Employees())
.HeadingParagraphStyle("TBLHeading")
.BodyParagraphStyle("TBLText");
Unlike the run-level headingStyle/bodyStyle callbacks, a paragraph style is applied to every cell paragraph — including IsHtml and Link cells — so the style's font, size, and spacing reach all content. The referenced style ids must exist in the host document's styles part. This is the cleanest way to brand an Excelsior table from a template: define the look once as TBLHeading/TBLText (or any names) and point the table at them.
Named table style
The table itself references Word's built-in TableGrid — single-line borders — unless told otherwise. TableStyle points it at a style defined in the host document instead, which is how a table takes its borders, banding, and cell margins from a branded template:
var table = new WordTableBuilder<Employee>([])
.TableStyle("LinedColumns")
.Build(mainPart);
TableGrid is inserted into the host's styles part when it is missing, because it is a Word built-in with a known definition. A template's own style is not: it must already be defined there, since inventing a definition for it would style the table as something other than what the template says. Column widths are unaffected — the style drives borders and margins, the columns drive layout, and the two compose.
Style inheritance
Tables render at 100% page width (<w:tblW w:type="pct" w:w="5000"/>) and reference Word's built-in TableGrid style. When Build(mainPart) is called, the renderer adds the two style definitions Word itself ships when a table is inserted via the ribbon — TableNormal (the default table style that supplies 108dxa left/right cell padding) and TableGrid (single-line 4pt borders, declared basedOn="TableNormal" so the padding is inherited) — into the host's StyleDefinitionsPart if they aren't already there. Word lazy-writes both into a doc's styles.xml only once a table that uses them exists, so programmatically-built hosts and templates authored without tables won't have them on disk. The insertion is idempotent: building multiple tables against the same host adds each style once, and a host that already declares either style (typical of templates authored with tables present) is left untouched so customizations survive.
The supported way to rebrand Excelsior tables is to customize TableGrid in the host template. Any borders, cell margins, or <w:tblStylePr w:type="firstRow"> conditional formatting declared on TableGrid flow straight through the tblStyle reference. The firstRow=1 bit on <w:tblLook> is always emitted so a customized TableGrid can paint the header row separately. Per-column HeadingStyle and the table-level headingStyle callback layer on top.
The standalone Build() overload (no MainDocumentPart) has no styles part to add TableGrid to, so it emits inline borders and cell margins directly on the table instead. The 100% width tblW is still emitted so the table fills whatever container it's appended to.
To customize TableGrid on the host document in Word itself, see Microsoft's guidance:
- Change the look of a table — picking, modifying, and setting a table style as default.
- Customize or create new styles — covers the Modify Style dialog, including the "New documents based on this template" option that's needed to make a table style stick as the default for the document.
- Apply a table style — short walkthrough of the Table Design ribbon.
- Set or change table properties — borders, cell margins, alignment.
Merged rows
A row with nothing to report across most of its columns reads better as a single cell saying so than as a line of blanks. MergeRemainder decides that per row, on the builder:
var table = new WordTableBuilder<Reading>(readings)
.MergeRemainder(
when: _ => _.Scope == null,
after: _ => _.Name,
content: _ => "<i>nothing recorded</i>",
isHtml: true)
.Build(mainPart);
when picks the rows that merge; every other row is untouched. after names the last column that keeps its own cell on a merged row — the one that identifies it, typically — anchored to the property rather than a position, so reordering columns cannot quietly move the boundary. Every column after it becomes one cell spanning the rest of the row, saying whatever content returns for that row.
content is plain text unless isHtml — the same contract as a column's IsHtml — so 1 < 2 & counting needs no escaping, and markup like the italics above is an opt-in.
A merged cell spans the remaining columns rather than sitting in any of them, so what it says cannot widen those columns: merged rows are left out of the width measuring.
Word tables only. BookBuilder has nothing like it, because a spreadsheet cell is something the reader sorts and filters on, and merging those away costs more than the blanks do.
Limitations
Formula columns are not supported in Word tables. Word has no equivalent of Excel cell formulas, so configuring a Formula on a column used with WordTableBuilder will throw when Build() is called. Use Render or a computed property instead.
Icon
Grim Fandango from Papirus Icons.
The Excelsior Line is a travel package sold by Manuel Calavera in the Lucas Arts game "Grim Fandango". The package consists of nothing more than a walking stick with a compass in the handle.