Skip to main content

Migration from ClosedXML

XLibur was forked from ClosedXML v0.105.0, and the public API surface is largely unchanged. To migrate:

  1. Install the NuGet package (see Getting Started).
  2. Replace using ClosedXML namespace references with using XLibur.
  3. 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 (or XLibur + 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 automatically

    The 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 XLibur package with no font engine, creating a workbook throws an InvalidOperationException telling 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:

    PackageFont libraryLicenseNotes
    XLibur.Fonts.SkiaSharpSkiaSharpMITDefault. Auto-registers; ships native binaries.
    XLibur.Fonts.SixLabors.V1SixLabors.Fonts 1.xApache 2.0Pure-managed; matches ClosedXML 0.105's engine.
    XLibur.Fonts.SixLaborsSixLabors.Fonts 2.xSix Labors Split LicenseCommercial 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:

ChangeClosedXML 0.105XLiburWhat to do
IXLRange.Cell(string)returned null for an unresolvable addressthrows ArgumentExceptionCatch ArgumentException where you tested for null
Range address validationIndexOutOfRangeException / NullReferenceExceptionArgumentNullException, ArgumentException, FormatExceptionCatch the typed exception instead
Loading a malformed workbookNullReferenceException, ArgumentException, ArgumentOutOfRangeExceptionPartStructureExceptionCatch PartStructureException around a load
Evaluate with no cell contextinternal InvalidOperationExceptionXLNoWorksheetContextExceptionCatch the new type; a catch (InvalidOperationException) no longer runs
SheetView.SplitRow / SplitColumnfroze that many linesa split bar, measured in twentieths of a pointCall Freeze(rows, cols) if a freeze was meant
IXLColumn.CellCount()always 11048576Check any loop written against it
XLColorType ordinalsColor=0, Theme=1, Indexed=2Automatic=0, Color=1, Theme=2, Indexed=3Remap any persisted numeric value; recompile against XLibur
XLColor.NoColor.Colorreturned ARGB (0,0,0,0)throwsTest IsAutomatic before reading Color/Indexed/ThemeColor
XLColor.NoColorcurrent[Obsolete], aliases XLColor.AutomaticRename to XLColor.Automatic
XLError gained members7 members18 members, starting at GettingData = 7Handle the new members in exhaustive switches
Interfaces gained members—new membersOnly affects types outside XLibur that implement these interfaces
An operator applied to a rangekept the range's first elementintersects against the formula's cellExpect different — correct — values from recalculation
Page break listsList<int>IReadOnlyList<int>Use the Add…, Remove… and Clear… methods
Parser exceptionsClosedXML.Parser.ParsingExceptionExpressionParseExceptionCatch the new type
Refreshing an unsupported pivot sourceNotImplementedExceptionNotSupportedExceptionCatch the new type, or check SourceKind first
RecalculateAllFormulasthrew at the first circular reference or unsupported formulacalculates the rest and leaves those cells not calculatedCheck NeedsRecalculation instead of catching
Saving with calculated valueshid every failurethrows on a bug in XLiburNothing, unless you relied on the save always succeeding
XLHelper.GetColumnNumberFromLetter("")ArgumentNullExceptionArgumentExceptionCatch 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:

AddressXLibur throws
nullArgumentNullException
"" and " "ArgumentException
"A1:", ":", "$", "not an address"FormatException
past the sheet limitsOverflowException
an unknown defined nameArgumentOutOfRangeException

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:

MemberErrorValue
GettingData#GETTING_DATA7
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​

InterfaceNew members
IXLPageSetupRemoveHorizontalPageBreak, RemoveVerticalPageBreak, ClearHorizontalPageBreaks, ClearVerticalPageBreaks
IXLPivotCacheSourceKind, SourceRange, SourceName, SourceWorksheet, SetSourceRange
IXLConditionalFormatSetRanges
IXLSheetViewFreezePanes — the value that tells a freeze from a split
IXLPivotTableShowLastColumn and its two SetShowLastColumn overloads, Slicers, Timelines
IXLWorksheetSlicers, 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:

DeprecatedReplacement
IXLWorkbook.NamedRanges, IXLWorksheet.NamedRanges, IXLDefinedNames.NamedRangeDefinedNames / DefinedName
IXLCell.DataValidation, IXLRangeBase.DataValidationGetDataValidation() to read, CreateDataValidation() to create
IXLRanges.SetDataValidation()CreateDataValidation()
XLFontCharSet.HangeulXLFontCharSet.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 turned Sheet1!$A$1:$B$2 into a phantom one-row range over whatever data had moved up into it. =SUM(A1:A2) with those rows deleted now reads SUM(#REF!) instead of quietly summing the wrong cells.

  • Row-only and column-only references shift correctly. 3:5 with row 4 deleted became 2:4 — a reference that had walked onto a row it never covered. Inserting two rows at row 4 moved 3:5 to 5:7 rather than expanding it to 3: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:5 with rows 5–7 deleted came back as 3:2, which is not a valid formula; A2:A8 with rows 5–9 deleted came back as A2:A3, dropping row 4, which survived.

  • =SUM(B2:A1) evaluates instead of throwing ArgumentException("Range address must be normalized"). Each axis is ordered independently, carrying its own fixed marker.

  • SUBTOTAL no longer counts a nested post-2007 function twice. Excel stores every function added after 2007 under an _xlfn. namespace, so a nested AGGREGATE was 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 span Sales[[Amount]:[Tax]] lost everything but its first column, and Sales[#All] resolved to nothing. An unknown column threw ArgumentOutOfRangeException out 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 any try/catch you wrapped around IXLDefinedName.Ranges can go.

  • Copying a worksheet repoints the copy's self-references at the copy. Copying a sheet named Original holding Original!A1 * 3 produced 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:A4 became A2:A3, expanding the range over a row that was never part of it; it is now A3:A3, as Excel produces.

  • Array and dynamic-array formulas survive row and column shifts. A shift used to rebuild every formula cell through the FormulaA1 setter, 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 threw NotImplementedException. 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 as ABS(1,2). After such a formula was read, every later edit, anywhere in the workbook, threw ExpressionParseException. Now only reading that cell throws.

  • XLWorkbook.EvaluateExpr is 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 as IXLWorksheet.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.Indent on 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.
Dynamic arrays now spill

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", not rgb="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 → SaveAs wrote 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 ArgumentException naming an invalid XML character — common in Japanese workbooks.
  • Page breaks no longer inflate the used range. AddHorizontalPageBreak() / AddVerticalPageBreak() wrote brk@max as 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 2023 produced 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:strRef with no c:f, a c:doughnutChart missing c:holeSize, and c:marker written after c:cat/c:val). Excel tolerated all three; stricter readers and SaveOptions.ValidatePackage did not.
  • Grouped pictures and shapes survive a round trip instead of being dropped, and pictures nested in xdr:grpSp groups are now a first-class API.
  • A frozen pane is written as state="frozen", and the unsplit axis is left out. SaveAs wrote state="frozenSplit" on every pane it emitted, plus xSplit="0" or ySplit="0" for an axis that was not split — while XLStreamingWorkbook wrote 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 with SplitRow and SplitColumn both 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.Anchor and Style.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, so hidden, collapsed and outlineLevel were 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 / RemoveRange let a caller empty one directly. The schema requires a non-empty sqref, and Excel reads sqref="" 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 sqref but left cell references inside formula1/formula2 pointing at the pre-shift location, silently breaking any List, Custom or comparison rule that referenced other cells — most visibly dependent dropdown pairs driven by OFFSET/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 on A5 after two rows were inserted above cleared A5 — by then empty — and left the note sitting on A7.

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 throws NotSupportedException. 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:twoCellAnchor was 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.CopyTo copies 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.
  • Title and Description survive 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 ShowLastColumn from the showColStripes attribute, 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 filters element, 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 is pageFields and was already supported.)
  • A PivotChart keeps its manual series and point formatting, via the chartFormats collection 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 K13 that should move to K23 landed at K33, 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:

FeatureSee
Dynamic array functions with a spill engineFormulas
Slicers over pivot tables and tables, and pivot timelinesSlicers and Timelines
A streaming, append-only writer for large exportsStreaming
Workbook encryption and decryptionEncryption
Charts across all 78 XLChartType valuesCharts
Threaded comments, read and written as conversationsComments and hyperlinks
A swappable font engineFonts
Report templating from .xlsx templatesReport Templating