Excelsior

September 23, 2026 · View on GitHub

Build status NuGet Status

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;
}

snippet source | anchor

[ColumnAttribute] is optional. If omitted:

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();

snippet source | anchor

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");

snippet source | anchor

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
}

snippet source | anchor

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);

snippet source | anchor

Saving to bytes

To save to a byte array use ToBytes().

var builder = new BookBuilder();
builder.AddSheet(data);

var bytes = await builder.ToBytes();

snippet source | anchor

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();

snippet source | anchor

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;

snippet source | anchor

Model construction

Strong-typed rows are instantiated in one of two ways:

  1. Parameterless constructor (preferred) — public or non-public. After construction, every parsed column value is applied via its property setter. init setters and required members are honoured: construction goes through ConstructorInfo.Invoke, which bypasses the runtime required-members check that Activator.CreateInstance would enforce.
  2. 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)
        ]));
}

snippet source | anchor

Limitations:

  • Parameter matching is case-sensitive — a constructor parameter name will not bind to property Name.
  • Public instance properties and fields are both bound. readonly and const fields 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];

snippet source | anchor

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;

snippet source | anchor

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 }));

snippet source | anchor

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 BookBuilder is 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);

snippet source | anchor

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);

snippet source | anchor

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);
    }
}

snippet source | anchor

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>();

snippet source | anchor

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 JsonSerializerBookBuilder.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();

snippet source | anchor

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");

snippet source | anchor

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

  1. Fluent
  2. ColumnAttribute
  3. DisplayAttribute
  4. DisplayNameAttribute

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);

snippet source | anchor

Result:

Heading Style

var builder = new BookBuilder(
    headingStyle: style =>
    {
        style.Font.Bold = true;
        style.Font.Color = "FFFFFF";
        style.BackgroundColor = "00008B";
    });
builder.AddSheet(data);

snippet source | anchor

Result:

Global Style

var builder = new BookBuilder(
    globalStyle: style =>
    {
        style.Font.Bold = true;
        style.Font.Color = "FFFFFF";
        style.BackgroundColor = "00008B";
    });
builder.AddSheet(data);

snippet source | anchor

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";
                }
            };
        });

snippet source | anchor

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");

snippet source | anchor

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;
        });

snippet source | anchor

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);

snippet source | anchor

ColumnAttribute

public class Employee
{
    [Column(Width = 25)]
    public required string Name;

Result:

Order of precedence

  1. Fluent
  2. 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);

snippet source | anchor

Book-wide default min width

var builder = new BookBuilder(defaultMinColumnWidth: 25);
builder.AddSheet(employees);

snippet source | anchor

MinWidth

var builder = new BookBuilder();
builder.AddSheet(employees)
    .Column(_ => _.Name, _ => _.MinWidth = 40);

snippet source | anchor

MaxWidth

var builder = new BookBuilder();
builder.AddSheet(employees)
    .Column(_ => _.Name, _ => _.MaxWidth = 5);

snippet source | anchor

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;
}

snippet source | anchor

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);

snippet source | anchor

Book-wide

var builder = new BookBuilder(maxRowHeight: 60);
builder.AddSheet(notes);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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 &gt; What-If Analysis &gt; 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;
}

snippet source | anchor

OptionDefaultWhen true, blocks
ObjectstrueEditing embedded objects (shapes, charts, controls)
ScenariostrueEditing saved scenarios (Data > What-If Analysis > Scenario Manager)
FormatCellsfalseChanging cell formatting (font, fill, number format)
FormatColumnsfalseChanging column width / hiding columns
FormatRowstrueChanging row height / hiding rows
InsertColumnstrueInserting new columns
InsertRowstrueInserting new rows
InsertHyperlinkstrueInserting hyperlinks
DeleteColumnstrueDeleting columns
DeleteRowstrueDeleting rows
SelectLockedCellsfalseSelecting locked cells (e.g. headers)
SelectUnlockedCellsfalseSelecting unlocked (data) cells
SortfalseSorting
AutoFilterfalseUsing the auto-filter dropdowns
PivotTablestrueEditing 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();

snippet source | anchor

The standard fields map onto the workbook's core and extended (app) property parts:

BookProperties memberWritten to
Title, Subject, Keywords, Category, StatusCore properties
AuthorCore Creator
CommentsCore Description
LastModifiedByCore LastModifiedBy
Company, ManagerExtended (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");

snippet source | anchor

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);

snippet source | anchor

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();

snippet source | anchor

Notes work on every sheet kind (data-bound, dictionary, and template) and combine freely with protection and validation.

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();

snippet source | anchor

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.")));
    });

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

Result:

Custom render for Complex Types

[ModuleInitializer]
public static void Init() =>
    ValueRenderer.For<Address>(_ => $"{_.Number}, {_.Street}, {_.City}, {_.State}, {_.PostCode}");

snippet source | anchor

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);

snippet source | anchor

Result:

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);

snippet source | anchor

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);

snippet source | anchor

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();

snippet source | anchor

Result

Disable whitespace trimming

static void DisableTrimWhitespace() =>
    ValueRenderer.DisableWhitespaceTrimming();

snippet source | anchor

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);

snippet source | anchor

Render

List<Person> data =
[
    new("John Doe",
        PhoneNumbers:
        [
            "+1 3057380950",
            "+1 5056169368",
            "+1 8634446859"
        ]),
];

var builder = new BookBuilder();
builder.AddSheet(data);

snippet source | anchor

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; }
}

snippet source | anchor

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;
}

snippet source | anchor

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);

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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];

snippet source | anchor

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();

snippet source | anchor

Type-Based Inference

Template sheets infer common validation rules from the column's type:

SignalInferredGated by inferValidationFromTypes
enum / enum?dropdown list of enum membersno — 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 blockedno — always on
Date/time (DateTime, DateTimeOffset, DateOnly, TimeOnly)date constraint across Excel's full date span — manually-typed non-dates are blockedno — always on
C# required modifier or [Required] attributeRequired = trueno — always on
Non-nullable value type (int, decimal, DateTime, bool, enum)Required = trueyes
Non-nullable reference type (NRT-aware, data-bound only)Required = trueyes

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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:

StyleBehavior
Stop (default)Block the entry — user must enter a valid value or cancel.
WarningWarn the user; they can choose to keep the invalid value.
InformationInform 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();

snippet source | anchor

Required Cells

Required = true does two things:

  1. Highlights blank cells in the column with a soft red conditional-format fill, drawing attention to fields the user has not yet filled in.
  2. When the column has no other validation type (e.g. a free-text string), emits a LEN(TRIM(...))>0 custom validation with allowBlank="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();

snippet source | anchor

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();

snippet source | anchor

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();

snippet source | anchor

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.

ShortcutConfigures
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();

snippet source | anchor

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;
    }
}

snippet source | anchor

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;
}

snippet source | anchor

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);

snippet source | anchor

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());

snippet source | anchor

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);

snippet source | anchor

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());

snippet source | anchor

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);

snippet source | anchor

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");

snippet source | anchor

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);

snippet source | anchor

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");
}

snippet source | anchor

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);

snippet source | anchor

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");

snippet source | anchor

Example use

var builder = new BookBuilder();

List<Target> data =
[
    new()
    {
        Name = "Alice",
        Color = Color.AntiqueWhite,
    },
    new()
    {
        Name = "Bob",
    }
];
builder.AddSheet(data);

snippet source | anchor

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:ss for DateTimes
  • yyyy-MM-dd for DateOnlys
  • HH:mm:ss for TimeOnlys

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" ;
}

snippet source | anchor

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();

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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;
}

snippet source | anchor

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);

snippet source | anchor

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);

snippet source | anchor

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;
    });

snippet source | anchor

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");

snippet source | anchor

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";
    });

snippet source | anchor

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");

snippet source | anchor

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);

snippet source | anchor

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:

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);

snippet source | anchor

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.