Migration from ClosedXML
XLibur was forked from ClosedXML v0.105.0, and the public API surface is largely unchanged. To migrate:
- Install the NuGet package (see Getting Started).
- Replace
using ClosedXMLnamespace references withusing XLibur. - Read Breaking API changes below. Most projects hit none of them.
Namespaces are prefixed with XLibur, so both libraries can be referenced from the same project
while you port.
Everything on this page is relative to ClosedXML 0.105. The changelog records the same ground release by release, with the pull request behind each entry.
Font engine configuration (different from ClosedXML)
This is the one area where XLibur's packaging differs from the base ClosedXML package. ClosedXML bundles SixLabors.Fonts directly into its core assembly for text measurement (column auto-fit, row heights, glyph metrics). XLibur instead keeps the core assembly free of any font library and ships the font engine as a separate, swappable package. This lets you pick a font library with a license that suits you and avoids forcing a font dependency on library authors who don't need one.
What this means when migrating:
-
Install
XLibur.Bundle(orXLibur+XLibur.Fonts.SkiaSharp) and no code changes are needed. The default SkiaSharp engine (MIT-licensed) is auto-registered by XLibur core the first time you create a workbook — there is no startup call to add:using var wb = new XLWorkbook(); // font engine resolved automaticallyThe default resolves system fonts and falls back to an embedded, metric-only Calibri-compatible font, so text measurement works even in headless/serverless environments with no system fonts installed.
-
If you install the bare
XLiburpackage with no font engine, creating a workbook throws anInvalidOperationExceptiontelling you to add a font engine package. This is intentional — it's how the core stays font-library-agnostic. -
To choose a different engine, install its package and either register it at startup (it takes precedence over the auto-registered default) or pass it per workbook:
Package Font library License Notes XLibur.Fonts.SkiaSharpSkiaSharp MIT Default. Auto-registers; ships native binaries. XLibur.Fonts.SixLabors.V1SixLabors.Fonts 1.x Apache 2.0 Pure-managed; matches ClosedXML 0.105's engine. XLibur.Fonts.SixLaborsSixLabors.Fonts 2.x Six Labors Split License Commercial restrictions over $1M revenue. // Override globally at startup (e.g. keep ClosedXML's SixLabors 1.x behavior):SixLaborsV1FontBootstrap.Register();// Or override per workbook:var options = new LoadOptions { FontEngine = new SkiaSharpFontEngine("Arial") };using var wb = new XLWorkbook(options);
Resolution order for the font engine is: LoadOptions.FontEngine (per workbook) →
LoadOptions.DefaultFontEngine (explicitly registered global) → the auto-registered default engine.
See Fonts and Font Engines for the full picture, including headless environments and
loading fonts from streams, or
docs/font-architecture.md
for the design rationale.
Breaking API changes
These changes since 0.105 can break a build, throw where the old code returned, or silently do something else. At a glance:
| Change | ClosedXML 0.105 | XLibur | What to do |
|---|---|---|---|
IXLRange.Cell(string) | returned null for an unresolvable address | throws ArgumentException | Catch ArgumentException where you tested for null |
| Range address validation | IndexOutOfRangeException / NullReferenceException | ArgumentNullException, ArgumentException, FormatException | Catch the typed exception instead |
| Loading a malformed workbook | NullReferenceException, ArgumentException, ArgumentOutOfRangeException | PartStructureException | Catch PartStructureException around a load |
Evaluate with no cell context | internal InvalidOperationException | XLNoWorksheetContextException | Catch the new type; a catch (InvalidOperationException) no longer runs |
SheetView.SplitRow / SplitColumn | froze that many lines | a split bar, measured in twentieths of a point | Call Freeze(rows, cols) if a freeze was meant |
IXLColumn.CellCount() | always 1 | 1048576 | Check any loop written against it |
XLColorType ordinals | Color=0, Theme=1, Indexed=2 | Automatic=0, Color=1, Theme=2, Indexed=3 | Remap any persisted numeric value; recompile against XLibur |
XLColor.NoColor.Color | returned ARGB (0,0,0,0) | throws | Test IsAutomatic before reading Color/Indexed/ThemeColor |
XLColor.NoColor | current | [Obsolete], aliases XLColor.Automatic | Rename to XLColor.Automatic |
XLError gained members | 7 members | 18 members, starting at GettingData = 7 | Handle the new members in exhaustive switches |
| Interfaces gained members | — | new members | Only affects types outside XLibur that implement these interfaces |
| An operator applied to a range | kept the range's first element | intersects against the formula's cell | Expect different — correct — values from recalculation |
| Page break lists | List<int> | IReadOnlyList<int> | Use the Add…, Remove… and Clear… methods |
| Parser exceptions | ClosedXML.Parser.ParsingException | ExpressionParseException | Catch the new type |
| Refreshing an unsupported pivot source | NotImplementedException | NotSupportedException | Catch the new type, or check SourceKind first |
RecalculateAllFormulas | threw at the first circular reference or unsupported formula | calculates the rest and leaves those cells not calculated | Check NeedsRecalculation instead of catching |
| Saving with calculated values | hid every failure | throws on a bug in XLibur | Nothing, unless you relied on the save always succeeding |
XLHelper.GetColumnNumberFromLetter("") | ArgumentNullException | ArgumentException | Catch ArgumentException |
These rows have no compile-time signal — the code still builds and does something else:
IXLColumn.CellCount(), XLError, an operator applied to a range, and every exception change.
IXLRange.Cell(string) throws instead of returning null
The interface has always been annotated non-null, so the old null contradicted its own
signature and usually surfaced as a NullReferenceException somewhere downstream. It now fails
at the call site, matching IXLWorksheet.Cell(string), which already behaved this way.
// ClosedXML 0.105 — a null return was the only signal the address was unresolvable
var cell = range.Cell(name);
if (cell is null)
return;
// XLibur
try
{
var cell = range.Cell(name);
}
catch (ArgumentException)
{
return;
}
This is not a mechanical swap. If you relied on the null to mean "not found", the equivalent is
catching ArgumentException; code that trusted the non-null annotation needs no change at all.
IXLRange.Range(string) gained the same guard, but nothing reaches it — a bad address already
throws while being parsed.
A bad range address throws a typed exception
Range("") and half-written addresses such as "A1:", ":" and "$" used to throw
IndexOutOfRangeException, and a null address threw NullReferenceException — internal failures
escaping a public API rather than anything a caller could act on. Each now throws something a
caller can catch deliberately:
| Address | XLibur throws |
|---|---|
null | ArgumentNullException |
"" and " " | ArgumentException |
"A1:", ":", "$", "not an address" | FormatException |
| past the sheet limits | OverflowException |
| an unknown defined name | ArgumentOutOfRangeException |
This affects every entry point that parses an address string, including IXLWorksheet.Range,
IXLWorkbook.Range and IXLRange.Range. If you were catching IndexOutOfRangeException or
guarding against a NullReferenceException around an address parse, switch to
FormatException / ArgumentException.
Loading a malformed workbook throws PartStructureException
Nine ways of breaking a spreadsheet used to surface as NullReferenceException,
ArgumentException or ArgumentOutOfRangeException out of new XLWorkbook(stream) — naming
internal parameters such as id, index, sheetName and number that the caller never
supplied. All of them now raise XLibur.Excel.IO.PartStructureException, which names what is
wrong with the document: a package with no workbook part, a relationship pointing at a part that
is not there, a sheet whose relationship id names nothing, a cell value that overflows a double,
a sheet name the format forbids, and a workbook declaring the same sheet name or sheet id twice.
// ClosedXML 0.105 — indistinguishable from a library bug
try { using var wb = new XLWorkbook(stream); }
catch (ArgumentException) { /* bad file? bad argument? no way to tell */ }
// XLibur
try { using var wb = new XLWorkbook(stream); }
catch (PartStructureException) { /* the file is broken */ }
catch (FileFormatException) { /* not an OOXML package at all — unchanged */ }
DocumentFormat.OpenXml exception types no longer escape the constructor either. Two files that
used to be rejected now load, because Excel opens them: a cell naming a style index past the end
of the stylesheet falls back to the default format, and a sheet whose name contains a doubled
apostrophe (Ann''s) loads with its cells rather than empty. See
Importing and Exporting Data.
Evaluate throws a public exception type
IXLWorksheet.Evaluate, IXLWorkbook.Evaluate and XLWorkbook.EvaluateExpr raised an internal
exception type when the expression needed to know which cell it was being evaluated in — ROW(),
COLUMN(), or anything reaching implicit intersection. A caller outside the assembly could not
name that type, so a broken expression could not be told apart from a library bug.
All three now throw the public XLNoWorksheetContextException, which derives from
XLiburException — so a catch (InvalidOperationException) placed around Evaluate no longer
runs and the exception escapes. There is no compile-time signal. Passing a formula address,
worksheet.Evaluate("ROW()", "B7"), is unaffected and still returns a value.
SplitRow and SplitColumn produce a split, not a freeze
These have always been public, and assigning them used to write <pane state="frozen">, because
the pane writer never asked whether a freeze was meant. It now asks — and OOXML measures an
unfrozen split's position in twentieths of a point, not in lines:
// ClosedXML 0.105 — froze three rows
ws.SheetView.SplitRow = 3;
// XLibur — a split bar 3/20 of a point from the top, which is visually nothing
ws.SheetView.SplitRow = 3;
// XLibur, to freeze
ws.SheetView.FreezeRows(3);
// XLibur, for a real split bar: 900 ≈ three default 15pt rows
ws.SheetView.SplitRow = 900;
The code still compiles and silently does something else, so there is no compile-time signal. XLibur carries whichever number you give it verbatim rather than converting between the two units, so a file round-trips exactly; converting would need every column width and row height on the sheet and is not reversible. See Worksheets.
IXLColumn.CellCount() returns the column length
It was a verbatim copy of IXLRow.CellCount(), down to measuring the column span of its
address — and a column's address carries the same column number at both ends, so the count was
always c - c + 1. A column now reports one cell per row in the sheet, which is what the row half
has always reported for its own axis.
The signature is unchanged, so the compiler cannot warn you:
// Silently goes from one iteration to 1,048,576, materialising the whole column
for (var i = 1; i <= column.CellCount(); i++) { /* ... */ }
IXLRangeColumn.CellCount() is a different method and is unaffected.
XLColorType members are renumbered
OOXML has four kinds of colour and ClosedXML modelled three. The automatic colour (ECMA-376
CT_Color/@auto, what Excel's font colour picker labels Automatic) was disguised as a fully
transparent black; it is now a member in its own right, and takes ordinal 0 so that a default
colour key is automatic rather than a transparent black no Excel file means to express.
Color 0 → 1
Theme 1 → 2
Indexed 2 → 3
This is source compatible — recompiling is enough — but binary breaking, and it breaks anything that persisted the numeric value. Stored settings or serialised styles written by ClosedXML need remapping on read.
Reading a component off an automatic colour throws
XLColor.Automatic.Color (and .Indexed / .ThemeColor) throw rather than returning a
meaningless all-zero ARGB. There is no ARGB that means "automatic" — the application resolves it
at display time — so the property refuses to invent one.
// ClosedXML 0.105 — returned Color.FromArgb(0, 0, 0, 0) for an automatic colour
var rgb = cell.Style.Font.FontColor.Color;
// XLibur
var colour = cell.Style.Font.FontColor;
var rgb = colour.IsAutomatic ? defaultRgb : colour.Color;
Decide what your code should render for a colour Excel leaves to the application — generally black for a font or border, white for a fill.
XLColor.NoColor is deprecated
Some Excel pickers (sheet tab colour, fill background) label the same value No Color; that is a
GUI convention, not a different value. NoColor still compiles and still returns Automatic, but
now warns — which is an error for consumers building with TreatWarningsAsErrors.
cell.Style.Font.FontColor = XLColor.NoColor; // before
cell.Style.Font.FontColor = XLColor.Automatic; // after — the same value
XLError gained members
XLError now has a member for every error value Excel shows. The seven errors from 0.105 keep
their numbers, so nothing you stored needs to change. The new members are:
| Member | Error | Value |
|---|---|---|
GettingData | #GETTING_DATA | 7 |
SpillRange | #SPILL! | 8 |
Connect | #CONNECT! | 9 |
Blocked | #BLOCKED! | 10 |
Unknown | #UNKNOWN! | 11 |
Field | #FIELD! | 12 |
Calc | #CALC! | 13 |
Busy | #BUSY! | 14 |
External | #EXTERNAL! | 18 |
Timeout | #TIMEOUT! | 19 |
Python | #PYTHON! | 20 |
A cell that holds one of these errors in a file now loads with that error. Before, it loaded as a
blank cell. XLibur does not create the newer errors itself: for example, a function for which
Excel gives #CALC! still gives #VALUE!.
A switch over XLError that covered every member in 0.105 no longer does. A switch
expression throws at run time, and does not fail to compile. Add arms for the new members, or a
discard.
Interfaces gained members
| Interface | New members |
|---|---|
IXLPageSetup | RemoveHorizontalPageBreak, RemoveVerticalPageBreak, ClearHorizontalPageBreaks, ClearVerticalPageBreaks |
IXLPivotCache | SourceKind, SourceRange, SourceName, SourceWorksheet, SetSourceRange |
IXLConditionalFormat | SetRanges |
IXLSheetView | FreezePanes — the value that tells a freeze from a split |
IXLPivotTable | ShowLastColumn and its two SetShowLastColumn overloads, Slicers, Timelines |
IXLWorksheet | Slicers, Timelines |
Adding a member to a public interface is source-breaking for any type outside the library that
implements it (and binary-breaking too, in the IXLPivotTable case).
None of these interfaces is designed to be implemented externally — each has a single
implementation with an internal constructor — so in practice this affects test doubles. Consumers
that only use them are unaffected, and gain the new members. ShowLastColumn already existed
and already round-tripped; it was simply the one of the five table-style emphasis flags that had
never been put on the interface. See Pivot tables,
Conditional formatting, Worksheets and
Slicers and Timelines.
An operator applied to a range intersects
A legacy formula applying an operator to a range now intersects that range against its own cell,
as Excel does. With A1 = 42, B1 = 100 and B3 = 5, a cell at C3 holding =A1+B1:B3 used to
answer 142 — it applied the operator across the whole range and kept the first element, B1. It
now answers 47, which is A1 + B3, and which is what Excel shows for the very file XLibur
writes.
Every operator kind was affected. A range that spans neither the formula's row nor its column is
now #VALUE!, where it used to answer with the range's top-left cell. This changes results
silently and with no compile-time signal, so a stored value computed by an earlier version can
differ from the same formula recalculated now. Dynamic-array formulas, Evaluate with no address,
and operators inside function arguments are deliberately unchanged — see
Formulas.
Page break lists are read-only
IXLPageSetup.RowBreaks and ColumnBreaks were the page setup's own List<int>. Code could add
to them directly, and skip the checks that keep the breaks sorted and without duplicates. They
are now IReadOnlyList<int>.
Code that only reads, counts or loops over the breaks still compiles. Code that calls Add,
Remove or Clear on the lists does not. Use the page setup's methods:
// ClosedXML 0.105
ws.PageSetup.RowBreaks.Remove(20);
ws.PageSetup.RowBreaks.Clear();
// XLibur
ws.PageSetup.RemoveHorizontalPageBreak(20);
ws.PageSetup.ClearHorizontalPageBreaks();
See Page Setup.
A formula the parser cannot read throws ExpressionParseException
Some calls change a formula between A1 and R1C1 text: reading or setting FormulaR1C1, and
loading a shared formula. When the parser could not read the formula, these calls let the
parser's own ClosedXML.Parser.ParsingException out. Calculating the same formula already threw
ExpressionParseException. Now all of them throw ExpressionParseException, with the parser's
exception as InnerException.
A catch (ParsingException) around these calls no longer runs.
Copying such a formula no longer throws at all. The copy keeps the same text.
Refreshing an unsupported pivot source throws NotSupportedException
XLibur cannot read the data of a pivot cache whose source is another workbook, a data connection,
a consolidation or a scenario. IXLPivotCache.Refresh on such a cache threw
NotImplementedException. It now throws NotSupportedException, because XLibur does not plan to
read these sources.
A catch (NotImplementedException) around Refresh no longer runs. Check SourceKind before
you call Refresh — see Pivot tables.
RecalculateAllFormulas no longer throws for one bad formula
IXLWorkbook.RecalculateAllFormulas, IXLWorksheet.RecalculateAllFormulas and
LoadOptions.RecalculateAllFormulas stopped at the first formula they could not calculate: a
circular reference, a formula XLibur does not evaluate, or one the parser cannot read. So a
workbook that Excel opens could fail to load.
Now they calculate every other formula, and leave those cells with NeedsRecalculation set to
true. Reading one of those cells still throws. A circular reference now throws the public
XLCircularReferenceException, which derives from InvalidOperationException.
If you used the exception to find such a formula, check NeedsRecalculation instead. See
Formulas.
A save that calculates formulas throws on a bug
With SaveOptions.EvaluateFormulasBeforeSaving, a save calculates each formula so that it can
write the value. Every failure was hidden, and the cell was written without a value. A bug in
XLibur looked the same as a formula XLibur does not support.
A circular reference, an unsupported feature and a formula the parser cannot read still give a cell without a value, and Excel calculates it when it opens the file. Any other exception is a bug in XLibur, and the save now throws it. Please report it.
GetColumnNumberFromLetter with an empty string
XLHelper.GetColumnNumberFromLetter("") threw ArgumentNullException. It now throws
ArgumentException, because an empty string is not a missing argument. null still throws
ArgumentNullException. A catch (ArgumentException) catches both, as before.
Deprecations
Three groups of [Obsolete] members that shipped in ClosedXML 0.105 are removed in the next
minor version, so port them now rather than on the upgrade after this one:
| Deprecated | Replacement |
|---|---|
IXLWorkbook.NamedRanges, IXLWorksheet.NamedRanges, IXLDefinedNames.NamedRange | DefinedNames / DefinedName |
IXLCell.DataValidation, IXLRangeBase.DataValidation | GetDataValidation() to read, CreateDataValidation() to create |
IXLRanges.SetDataValidation() | CreateDataValidation() |
XLFontCharSet.Hangeul | XLFontCharSet.Hangul |
XLColor.NoColor is the exception: it is deprecated but stays.
One interface is newly deprecated in XLibur. IXLBaseCollection<TSingle, TMultiple> is an orphan —
nothing in the library implements, extends or consumes it, and the collections it looks like it
should describe (IXLColumns, IXLRows, IXLCells, IXLRangeColumns, IXLRangeRows) all derive
from IEnumerable<T> alone. Use whichever of those you actually hold.
Behaviour changes that need no code change
These compile unchanged but can produce a different result or a different file than ClosedXML 0.105 did. Almost all of them are bug fixes — the old behaviour was silently wrong — but if you pin output with byte-comparison tests or golden files, expect those to move.
Formulas and references
-
A reference whose rows or columns are all deleted becomes
#REF!. Endpoints used to be clamped to row 1, so deleting rows 1–5 turnedSheet1!$A$1:$B$2into a phantom one-row range over whatever data had moved up into it.=SUM(A1:A2)with those rows deleted now readsSUM(#REF!)instead of quietly summing the wrong cells. -
Row-only and column-only references shift correctly.
3:5with row 4 deleted became2:4— a reference that had walked onto a row it never covered. Inserting two rows at row 4 moved3:5to5:7rather than expanding it to3:7. Both axes now follow the same boundary rules as an equivalent cell range. -
A deletion that removes the tail of a reference no longer inverts it.
3:5with rows 5–7 deleted came back as3:2, which is not a valid formula;A2:A8with rows 5–9 deleted came back asA2:A3, dropping row 4, which survived. -
=SUM(B2:A1)evaluates instead of throwingArgumentException("Range address must be normalized"). Each axis is ordered independently, carrying its own fixed marker. -
SUBTOTALno longer counts a nested post-2007 function twice. Excel stores every function added after 2007 under an_xlfn.namespace, so a nestedAGGREGATEwas never recognised by the check that stops a subtotal counting a subtotal. Totals that were wrong are now right. -
A formula reading a table keeps up with the table's contents.
=SUM(Table1[Amount])registered no precedents, so nothing invalidated it and it served a stale cached value until a full recalculation. A structured reference naming a table on another sheet is now resolved against that sheet rather than the calling one, where it previously read the same coordinates on the wrong sheet and quietly returned 0. -
A defined name holding a structured reference resolves to what the reference says.
Sales[[#Headers],[Amount]]pointed at the data instead of the header, a column spanSales[[Amount]:[Tax]]lost everything but its first column, andSales[#All]resolved to nothing. An unknown column threwArgumentOutOfRangeExceptionout of a property getter — reachable just by loading a workbook whose table column had since been renamed — and now contributes no range instead.Sales[Amount], the common form, resolves as before, so anytry/catchyou wrapped aroundIXLDefinedName.Rangescan go. -
Copying a worksheet repoints the copy's self-references at the copy. Copying a sheet named
OriginalholdingOriginal!A1 * 3produced a sheet whose formula still pointed at the original. References to other sheets are left alone. -
Named ranges shrink correctly when their first row or column is deleted.
A3:A4becameA2:A3, expanding the range over a row that was never part of it; it is nowA3:A3, as Excel produces. -
Array and dynamic-array formulas survive row and column shifts. A shift used to rebuild every formula cell through the
FormulaA1setter, splitting one shared array formula into a normal formula per cell — and a spilled=UNIQUE(...)into several implicit-intersection=@UNIQUE(...)cells, even when the edit happened on an unrelated sheet. -
The
@operator and the space (intersection) operator are calculated. Both threwNotImplementedException. See Formulas. -
One formula that XLibur cannot read no longer blocks edits to the whole workbook. An example is
'[Book2.xlsx]Sheet1'!A1, or a call with the wrong number of arguments such asABS(1,2). After such a formula was read, every later edit, anywhere in the workbook, threwExpressionParseException. Now only reading that cell throws. -
XLWorkbook.EvaluateExpris safe to call from several threads at once. A workbook itself is still not safe to use from several threads.
Sheets that are renamed, deleted or copied
XLibur now updates the same things Excel updates. Code that reads these values after a rename, delete or copy sees the new text.
- Delete. A formula that refers to the deleted sheet reads
#REF!in its text, as in Excel. Before, it kept the old sheet name, so a new sheet with the same name was read by the old formula. The same applies to defined names at every scope, data validation rules, conditional format rules and chart series. A 3D reference with the deleted sheet at one end gets smaller.IXLWorksheets.Delete(name)now does the same work asIXLWorksheet.Delete(). - Rename. Data validation rules, conditional format rules, chart series, print areas, pivot caches, and defined names that hold 3D references now use the new name.
- Copy. A data validation or conditional format rule that refers to its own sheet by name refers to the copy on the copy.
See Worksheets.
Styles set through a range
- A style set through a range always reaches every cell. A range remembered the style it was last given, and skipped a value that matched that memory, even if a cell had changed since. Because a range can be rebuilt after garbage collection, the result could change from one run to the next. Now the value is always written to the cells.
Alignment.Indenton a range, row, column or worksheet no longer throws when the alignment is centred. Each cell that cannot take an indent becomes left-aligned. On a single cell, it still throws.
XLibur adds SEQUENCE, UNIQUE, SORT, SORTBY, FILTER, XLOOKUP and XMATCH together with
a spill engine, so one of these written into a single cell auto-fills its computed footprint into
the neighbouring cells. A footprint blocked by existing content, or one running past the sheet
edge, collapses to #SPILL! on the anchor. ClosedXML 0.105 had none of these functions, so this
cannot break existing formulas — but a workbook authored in Excel that uses them behaves
differently once XLibur can evaluate it. See Formulas.
Text and number parsing
Coercion is stricter, and rejects things the BCL parse accepted. Group-separator and currency
placement are now enforced the way Excel enforces them, so under en-US the strings 1,00,
1,00,000 and 1$ no longer coerce to a number. If you were relying on the looser behaviour,
parse the string yourself and assign the typed value:
// Instead of letting a loosely-formatted string coerce
cell.Value = decimal.Parse(raw, NumberStyles.Any, culture);
In the other direction, coercion now succeeds where it used to fail: date-times carry a seconds
component (8/22/2008 3:30:45 PM failed entirely before), overflowing time components carry into
the date, parenthesised and sign-separated numbers such as (100%) and - 100 % read as negative,
a month matches on any prefix from three letters up, and a shortened or dot-suffixed AM/PM
designator is accepted.
What gets written to the file
- Cached formula values are preserved on save whenever they exist and the formula has not been
dirtied, regardless of
EvaluateFormulasBeforeSaving, and the data-type attribute is kept. This fixes round-trip loss of dynamic-array results and spill cell values. - An automatic colour is written as
auto="1", notrgb="00000000". Colour writers switched on the colour type and the automatic colour fell into the RGB arm, pinning down a colour the source deliberately left to the application. The three conditional-format colour converters gained explicit automatic arms too, where they would otherwise have dropped the colour silently. - Rich-text runs keep an absent or automatic colour. A plain load →
SaveAswrote every colour-less run back with an explicit<color rgb="FF000000"/>, which cannot then be overridden by a theme change or by conditional formatting. A run read with no<rPr>is written back without one. - Saving a plain shared string carrying a phonetic guide no longer throws an
ArgumentExceptionnaming an invalid XML character — common in Japanese workbooks. - Page breaks no longer inflate the used range.
AddHorizontalPageBreak()/AddVerticalPageBreak()wrotebrk@maxas the sheet's full row count, so a file with ~2,000 rows of data rendered in Excel with a scrollbar spanning all 1,048,576. - Totals-row formulas escape column names containing spaces. A header such as
Feb 2023produced a structured reference Excel could not parse. - Chart XML passes OpenXML schema validation. Three violations in the chart writer are fixed
(a series name written as
c:strRefwith noc:f, ac:doughnutChartmissingc:holeSize, andc:markerwritten afterc:cat/c:val). Excel tolerated all three; stricter readers andSaveOptions.ValidatePackagedid not. - Grouped pictures and shapes survive a round trip instead of being dropped, and pictures
nested in
xdr:grpSpgroups are now a first-class API. - A frozen pane is written as
state="frozen", and the unsplit axis is left out.SaveAswrotestate="frozenSplit"on every pane it emitted, plusxSplit="0"orySplit="0"for an axis that was not split — whileXLStreamingWorkbookwrote what Excel writes. The two now agree. Files written earlier keep loading exactly as they did; re-saving one rewrites the pane in Excel's form. - An unfrozen split pane survives a round trip.
<pane state="split">— Excel's draggable split bar, what View → Split gives you — loaded withSplitRowandSplitColumnboth at zero, and saving then wrote no<pane>at all, so the split was gone from the file too. - A note is written as moving and sizing with its cell, which is Excel's default and what
XLibur's own object model always claimed. A note stated its anchoring mode twice —
XLComment.AnchorandStyle.Properties.Positioning— and the VML writer read the one that said absolute, so every note XLibur wrote was pinned to the sheet. This changes the bytes written for every note created. A note whose positioning you set explicitly is written as before. - Chart, note, pane and pivot table positions move when rows or columns are inserted or deleted. A chart anchored at row 10 stayed at row 10 after an insert at row 1; a note's callout box detached from the note it pointed at; a sheet frozen at row 5 still froze at row 5 after three rows were inserted above, cutting through the header the freeze was placed to hold. All four now move the way a picture's anchor already did. This changes the file written for an existing document that is edited structurally. An absolutely anchored drawing is still pinned, because its position carries no cell reference for the grid to move.
- A column range running to the last column keeps its per-column flags. Excel writes
<col min="2" max="16384" hidden="1"/>when a user hides or groups from a column rightwards; every range ending at the last column was treated as the sheet's default-width declaration, sohidden,collapsedandoutlineLevelwere dropped and those columns loaded visible and ungrouped.
Data validation
- A data validation is never written covering nothing. Adding a validation over a range that
wholly contained an existing rule left that rule with no coverage, and
ClearRanges/RemoveRangelet a caller empty one directly. The schema requires a non-emptysqref, and Excel readssqref=""as corruption — it repairs the workbook and drops every validation on the sheet. A rule left with no coverage is now deleted, and the writer skips any rule covering nothing. - Validations no longer vanish when inserting at row 1 or column 1. The index was keyed by address at insert time and never re-keyed, so Excel rejected the saved file with "Removed Records: Data validation".
- Criteria formulas are shifted with the sheet. An insert or delete relocated each rule's
sqrefbut left cell references insideformula1/formula2pointing at the pre-shift location, silently breaking anyList,Customor comparison rule that referenced other cells — most visibly dependent dropdown pairs driven byOFFSET/MATCH. The in-memory value was wrong immediately, before any save.
Comments
- A cell carries either a note or a threaded comment, never both. Creating one over the other throws rather than silently discarding it. Threads previously read lossily — the whole conversation was flattened into the legacy note's text joined by newlines — and had no write path at all. See Comments and hyperlinks.
IXLComment.Delete()removes the note from where it is now, not where it was created. A note remembered its construction cell, so deleting a note onA5after two rows were inserted above clearedA5— by then empty — and left the note sitting onA7.
Charts
Charts were stubs in ClosedXML 0.105 and are now fully implemented across all 78 XLChartType
values, so most of this is new rather than changed. Two things to know if you load and re-save
files containing charts:
Series.Add(...)on a chart loaded from a file throwsNotSupportedException. A loaded chart is patched rather than regenerated, so a new series had nowhere to be written and used to vanish on save without a word.- Charts that used to be dropped on load are now read, which changes what a round trip
produces: one-cell and absolute anchors (previously only
xdr:twoCellAnchorwas read), 3D and of-pie chart groups, and second and subsequent plot groups of the same type — which is how Excel stores a secondary axis.
Pivot tables and autofilter
- A pivot table's rendered cells survive a round trip. Loading a workbook cleared the cells every pivot table had been rendered into, on the understanding that the output belonged to the pivot table rather than to the sheet — and nothing put them back, because XLibur writes the definition of a pivot table and never its output. Loading and saving with no edit at all produced a file whose pivot tables were empty in Excel until the user clicked one. This reverses the behaviour introduced by ClosedXML#856, so cells inside a pivot table's range now read back as ordinary cell values rather than as blanks.
IXLPivotTable.CopyTocopies every setting the round trip carries. The hand-written copy list held 29 of the definition's 65 attributes and silently reset the other 36 — compact/outline form, visual totals, the grand-total caption, the data caption. Copy is now driven from the same attribute description as the reader and the writer.TitleandDescriptionsurvive a save and reload. Both are public and settable, but were persisted nowhere.- A pivot table's "show last column" emphasis is independent of its column stripes. The reader
read
ShowLastColumnfrom theshowColStripesattribute, so a round trip could switch the emphasis on or off on its own. - Pivot field filters survive a round trip. The reader skipped the
filterselement, so loading and saving silently un-filtered every pivot table in the workbook — a change to what the workbook shows, not just what it remembers. If a downstream process depended on that accidental un-filtering, it now sees the filtered view. (Not the report-filter axis, which ispageFieldsand was already supported.) - A PivotChart keeps its manual series and point formatting, via the
chartFormatscollection that ties each formatting record to the pivot area it applies to. - Pivot table alignment in differential formats (DXF) round-trips instead of being lost on load.
- A worksheet autofilter keeps the parts XLibur does not model —
iconFilter, button attributes,extLst, the dynamic filter types beyond the two averages. A column that has not been changed is written back from the criteria it was loaded with; every mutation drops them, so an edit is never discarded. - Loading a relative-date filter no longer throws
KeyNotFoundException. Any of the ~38 relative date types (thisMonth,yearToDate,lastQuarter, …) failed a two-entry map lookup.
Conditional formatting
- A rule's ranges shift once, not twice. Inserting rows or columns below the first line doubled
the shift for any rule whose shifted target address collided with another rule's existing range —
a rule at
K13that should move toK23landed atK33, while rules whose targets happened to be empty shifted correctly. - A rule's font keeps its name, family numbering and character set across a round trip. Set a
conditional format's font to Arial with
XLFontCharSet.Arabic, save and reopen, and it came back as the workbook default. The file itself was correct and Excel rendered it correctly; the three values were dropped on the way back in, so the next save lost them from the file too. - A rule keeps its alignment and its protection, and a pivot-table format its protection. A
<dxf>may carry six kinds of formatting and the three places that read one each read a different subset. All six are now read wherever a differential format is read. - A colour filter no longer changes colour across a load and save. Differential formats are rebuilt from scratch on every save in a fixed order, so the index a colour filter was loaded with is generally not the index it will be written at — and an unchanged filter column was written back with its stale index, pointing at whichever differential format now occupied the slot.
New in XLibur
Capabilities with no equivalent in ClosedXML 0.105, so nothing to migrate — but worth knowing they are there:
| Feature | See |
|---|---|
| Dynamic array functions with a spill engine | Formulas |
| Slicers over pivot tables and tables, and pivot timelines | Slicers and Timelines |
| A streaming, append-only writer for large exports | Streaming |
| Workbook encryption and decryption | Encryption |
Charts across all 78 XLChartType values | Charts |
| Threaded comments, read and written as conversations | Comments and hyperlinks |
| A swappable font engine | Fonts |
Report templating from .xlsx templates | Report Templating |