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.
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
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();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");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
}To save to a stream use ToStream().
var builder = new BookBuilder();
builder.AddSheet(data);
var stream = new MemoryStream();
await builder.ToStream(stream);To save to a byte array use ToBytes().
var builder = new BookBuilder();
builder.AddSheet(data);
var bytes = await builder.ToBytes();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();BookReader is the inverse of BookBuilder: register the sheets to read, then Convert (throws on failure) or TryConvert (returns a result).
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;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.
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.
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.
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];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.
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.
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 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);
}
}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).
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();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.
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).
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>");The heading text for a column can be overridden:
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(
_ => _.Name,
_ => _.Heading = "Employee Name");public class Employee
{
[Column(Heading = "Employee Name")]
public required string Name;
public class Employee
{
[DisplayName("Employee Name")]
public required string Name { get; init; }
public class Employee
{
[Display(Name = "Employee Name")]
public required string Name;
- Fluent
ColumnAttributeDisplayAttributeDisplayNameAttribute
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);var builder = new BookBuilder(
headingStyle: style =>
{
style.Font.Bold = true;
style.Font.Color = "FFFFFF";
style.BackgroundColor = "00008B";
});
builder.AddSheet(data);var builder = new BookBuilder(
globalStyle: style =>
{
style.Font.Bold = true;
style.Font.Color = "FFFFFF";
style.BackgroundColor = "00008B";
});
builder.AddSheet(data);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";
}
};
});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");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");Note: Formulas are an Excel-only feature. They are not supported in Word tables and will throw when Build() is called.
var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Name, _ => _.Width = 25)
.Column(_ => _.Email, _ => _.Width = 30)
.Column(_ => _.HireDate, _ => _.Width = 15);public class Employee
{
[Column(Width = 25)]
public required string Name;
- Fluent
ColumnAttribute
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).
var builder = new BookBuilder();
builder.AddSheet(employees, defaultMinColumnWidth: 25);var builder = new BookBuilder(defaultMinColumnWidth: 25);
builder.AddSheet(employees);var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Name, _ => _.MinWidth = 40);var builder = new BookBuilder();
builder.AddSheet(employees)
.Column(_ => _.Name, _ => _.MaxWidth = 5);var sheet = builder.AddSheet(employees);
sheet.MinWidth(_ => _.Name, 40);
sheet.MaxWidth(_ => _.Email, 20);public class EmployeeWithMinMaxWidth
{
[Column(MinWidth = 40)]
public required string Name;
[Column(MaxWidth = 20)]
public required string Email;
}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.
var builder = new BookBuilder();
builder.AddSheet(notes, maxRowHeight: 60);var builder = new BookBuilder(maxRowHeight: 60);
builder.AddSheet(notes);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);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);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);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 |
For complex types, by default is to render via .ToString().
public record Person(string Name, Address Address);
public record Address(int Number, string Street, string City, State State, ushort 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);[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);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.
public record LinkTarget(
string Name,
Link Link,
Link? NullableLink,
IEnumerable<Link> Links,
IEnumerable<Link>? NullableLinks,
IEnumerable<Link?> LinksWithNulls);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);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.
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();static void DisableTrimWhitespace() =>
ValueRenderer.DisableWhitespaceTrimming();Properties that are castable to an IEnumerable<string> will automatically be rendered as a point form list.
public record Person(string Name, IEnumerable<string> PhoneNumbers);List<Person> data =
[
new("John Doe",
PhoneNumbers:
[
"+1 3057380950",
"+1 5056169368",
"+1 8634446859"
]),
];
var builder = new BookBuilder();
builder.AddSheet(data);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 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();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.
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();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 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();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];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).
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();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 |
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.
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.
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();InputTitle / InputMessage configure the tooltip Excel shows when a cell is selected. ErrorTitle / ErrorMessage override the popup shown when invalid input is rejected.
When neither is set, Excelsior fills in a sensible 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. Set ErrorMessage explicitly to override.
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 = 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();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();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.
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 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.
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; }
}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);ValueRenderer.ForEnums can be used to control the rendering for all enums
static void CustomEnumRender() =>
ValueRenderer.ForEnums(_ => _.ToString().ToUpper());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);Using Humanizer to convert enums to strings:
static void CustomEnumRender() =>
ValueRenderer.ForEnums(_ => _.Humanize());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);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.
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 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.
static void ConfigureBoolDisplay() =>
ValueRenderer.BoolDisplay("Yes", "No", "Unknown");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);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.
static void CustomNullDisplayForType()
{
ValueRenderer.For<Address>(_ => $"{_.Street}, {_.City}");
ValueRenderer.NullDisplayFor<Address>("No address on file");
}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);ValueRenderer.NullDisplayFor<Enum> can be used to set a default display text for all null enum properties:
static void CustomNullEnumDisplay() =>
ValueRenderer.NullDisplayFor<Enum>("Unknown");var builder = new BookBuilder();
List<Target> data =
[
new()
{
Name = "Alice",
Color = Color.AntiqueWhite,
},
new()
{
Name = "Bob",
}
];
builder.AddSheet(data);The same type specificity applies to NullDisplayFor<T>: NullDisplayFor<Color>("Color unknown") takes precedence over NullDisplayFor<Enum>("Enum unknown") for Color? properties.
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.
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" ;
}By default, auto-filter is enabled on all columns.
var builder = new BookBuilder();
var sheet = builder.AddSheet(Data());
sheet.DisableFilter();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);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);public class Employee
{
[Column(Filter = true)]
public required string Name { get; init; }
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.
var data = Data();
var isInternalReport = true;
var builder = new BookBuilder();
var sheet = builder.AddSheet(data);
sheet.Include(_ => _.Email, !isInternalReport);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);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);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);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);public class Employee
{
[Column(Include = false)]
public required string Name { get; init; }
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);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);A source generator is included that generates typed extension methods for ISheetBuilder, providing a more concise API.
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.
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);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.
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.
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.
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.
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.
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.
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.
































