Read and write XLSX and PPTX in .NET without Excel or PowerPoint
WorkbookEditor covers XLSX and PresentationEditor covers PPTX. Both follow
the same conventions as the Word surface: static methods, byte[] by default, Stream and path
overloads where you need them.
Creating a workbook
byte[] xlsx = WorkbookEditor.Create("Sales", new object?[][]
{
new object?[] { "Region", "Total" },
new object?[] { "North", 1200 },
new object?[] { "South", 950 },
});
string before = WorkbookEditor.ReadCell(xlsx, "Sales", "B2");
byte[] updated = WorkbookEditor.SetCell(xlsx, "Sales", "B2", 1500);
string after = WorkbookEditor.ReadCell(updated, "Sales", "B2");
Cell values are object?. Numbers are written as numbers and strings as strings, so a column of
totals arrives in Excel as something you can sum rather than as text that merely looks numeric.
ReadCell returns a string, because that is what almost every caller does with it next.
Note
It is the cell's value as text, not the cell as Excel displays it. A cell holding 1200
under a #,##0.00 number format reads back as 1200, not 1,200.00 — the number format is a
presentation instruction stored beside the value, and this returns the value. The text also
follows the calling thread's CurrentCulture, which is why the exporters below deliberately do
not use it.
Reading a workbook you were handed
You do not need to know the shape in advance.
IReadOnlyList<string> sheets = WorkbookEditor.SheetNames(updated);
IReadOnlyList<IReadOnlyList<string>> grid = WorkbookEditor.ReadSheet(updated, sheets[0]);
SheetNames tells you what is in the file and ReadSheet returns the used range as rows of
strings. Together they are enough to walk an upload you have never seen.
Several sheets, and formulas between them
byte[] workbook = WorkbookEditor.Create(new[]
{
XlsxSheet.Named("Q1", new[]
{
new object?[] { "Region", "Revenue" },
new object?[] { "EMEA", 1200 },
new object?[] { "APAC", 980 },
}),
XlsxSheet.Named("Summary", new[]
{
new object?[] { "Grand total", XlsxFormula.From("SUM(Q1!B2:B4)") },
}),
});
// Rows append after the sheet's last used row, leaving every other sheet untouched.
workbook = WorkbookEditor.AppendRows(workbook, "Q1", new[]
{
new object?[] { "AMER", 1450 },
});
Two details in there are worth calling out.
A cell holding an XlsxFormula is written as a formula, not as text that starts with
=. XlsxFormula.From("SUM(Q1!B2:B4)") is how you say "this is a computation" — and note the
formula has no leading =, which the writer adds.
Reading that formula back through this library gives you the computed value. The underlying engine evaluates it, because a file written this way carries no cached result. A reader that only reads cached values — which is most of them — sees an empty cell until Excel has opened and saved the file. This is a genuinely surprising interop failure, and it is worth knowing which side of it you are on.
AppendRows adds after the sheet's last used row and leaves every other sheet untouched, which is
the operation you want for a log or an export that accumulates.
Making a generated sheet look like a report
A sheet written from data is correct and unreadable: no header emphasis, columns too narrow for
their contents, and the header scrolling out of sight on the first flick of the wheel.
WorkbookEditor.Format fixes those, and more besides — a number format or an explicit width per
column, a freeze at any position, an autofilter, conditional formats and data validations.
The count that used to sit in that sentence said "eight" and then listed five, which is the shape of mistake this documentation keeps making: a number in prose is a claim nothing verifies. The settings are enumerable from XlsxFormat itself, so they are named here and counted nowhere.
The boundary is a CLOSED vocabulary rather than a small one. Six rule conditions
(XlsxRuleKind), five validation kinds (XlsxValidationKind) and four
highlights (XlsxHighlight) — each enumerable, measured and guaranteed.
XlsxHighlight names an intent rather than a colour on purpose: a colour picker cannot be
enumerated, and the moment one exists the boundary is gone. If what you need falls outside a closed
set — arbitrary fonts, borders, colour scales — use ClosedXML directly rather than through a thinner
API.
The snippet below starts from that preset and adds five of them. Every setting is a With… call on
the same immutable object, so they compose in any order — with one rule worth knowing, visible in
the snippet: an explicit width is applied after auto-fit, so naming one wins for that column while
every other column is still sized to its contents.
byte[] presented = WorkbookEditor.Format(workbook, "Q1", XlsxFormat.Report
.WithNumberFormat("B", "#,##0.00")
// Auto-fit sizes a column to what is in it today; an explicit width survives longer values.
.WithColumnWidth("A", 14)
// Report already freezes the header row. Naming a position freezes a column too, so the
// region labels stay visible when a wide sheet scrolls sideways.
.WithFreezeAt(row: 1, column: 1)
.WithAutoFilter()
// XlsxHighlight names an INTENT, never a colour - a colour picker cannot be enumerated,
// and the moment one exists the closed vocabulary is gone.
.WithRule(XlsxRule.GreaterThan("B2:B4", 1000, XlsxHighlight.Green))
// The half of a generated workbook that survives a human editing it: Excel refuses a region
// outside this list rather than accepting a typo that breaks tomorrow's formula.
.WithValidation(XlsxValidation.OneOf("A2:A4", "EMEA", "APAC", "AMER")));
Formatted : 7,880 bytes (was 7,351)
XlsxFormat is immutable, like PageSetup — every With… returns a new
instance, so XlsxFormat.Report is safe to read from anywhere. Report is the combination you
almost always want (bold header, frozen header, auto-fit); XlsxFormat.None is the empty one to
build up from.
Two things worth knowing before you reach for it:
It applies to a sheet that already exists. Format is not an argument to Create or
AppendRows — it is a separate call taking a workbook and a sheet name. That is what lets it
compose with all of them, and with a file somebody else made.
Auto-fit does not always widen. It fits the column to its content, and against short values that is narrower than Excel's 8.43-character default. A column of two-digit numbers gets narrower, which is correct and is not what "auto-fit" makes most people picture.
The number-format string is Excel's own ("#,##0.00", "0%", "yyyy-mm-dd"), keyed by column
letter. It changes how Excel displays the cell and not what is stored in it — so ReadCell and
the exporters below still see 1200, per the note further up.
Handing a sheet to something that is not Excel
XlsxToCsvConverter and XlsxToHtmlConverter take one sheet, by name.
string csv = XlsxToCsvConverter.Convert(presented, "Q1");
string html = XlsxToHtmlConverter.Convert(presented, "Q1");
As CSV : Region,Revenue / EMEA,1200 / APAC,980 / AMER,1450 /
As HTML : 222 chars, starts "<table>
One sheet at a time is the API, not a limitation to work around. A workbook is not one table,
and neither CSV nor an HTML <table> has any way to say "and now a different sheet". Call it once
per name from SheetNames.
Both are culture-invariant, deliberately, and this one is not a preference. A machine set to
de-DE formats 1234.5 as 1234,5 — and a decimal comma inside a comma-delimited file is not a
differently-formatted CSV, it is a corrupt one, with a row that silently gained a column. So the
exporters format numbers, dates (ISO 8601) and booleans invariantly regardless of the calling
thread, which is the same line SetCell already holds on the way in.
The CSV is RFC 4180 and quotes only when it has to — a value containing a comma, a quote or a
newline — so the common case stays diffable. The HTML is a <table> fragment with a <thead>, not
a document: there is no <html> or <body> around it, because the caller is embedding it in a
page they already have. Every cell is escaped.
Pivot tables
WorkbookEditor.AddPivotTable creates a pivot table from existing sheet data:
byte[] sales = WorkbookEditor.Create("Sales", new object?[][]
{
new object?[] { "Region", "Amount" },
new object?[] { "North", 1200 },
new object?[] { "South", 950 },
new object?[] { "North", 300 },
new object?[] { "South", 600 },
});
byte[] withPivot = WorkbookEditor.AddPivotTable(
sales, "Sales", "A1:B5", "D1", "RegionSummary",
rowFields: new[] { "Region" },
dataFields: new[] { new PivotDataField("Amount", PivotFunction.Sum) });
Pivot cell D1 right after creation: ""
The result grid is empty until Excel opens and recalculates it. That is a harder version of
the formula caveat above (Several sheets, and formulas between them): a formula's value is
computed by ReadCell/ReadSheet on read, because this library's own engine evaluates it — a
pivot table's is not, because nothing that writes a workbook, this method included, computes a
pivot aggregation. Reading the pivot's own cells back with ReadCell/ReadSheet immediately after
this call returns empty strings, and XlsxToPdfConverter renders nothing where the pivot's results
would be, for the identical reason it renders a formula's literal text rather than its computed
value. Open the result in Excel (or an equivalent) to see it populated.
Presentations
PresentationEditor.Create builds a deck from a typed model — no template file involved.
byte[] pptx = PresentationEditor.Create(new[]
{
PptxSlide.Titled("Hello {{who}}", "Built from a typed model", "No template file involved"),
PptxSlide.Titled("Second slide", "Bullets are optional"),
});
int slides = PresentationEditor.SlideCount(pptx);
IReadOnlyList<string> text = PresentationEditor.ExtractText(pptx);
PptxSlide.Titled gives you a title and any number of bullets, emitted as real title and
body placeholders rather than free-floating text boxes. That matters if anyone opens the deck in
PowerPoint afterwards: placeholders inherit the theme, respond to layout changes, and appear in the
outline view.
Warning
ExtractText returns one entry per text-bearing body, not one per slide. Create emits two
shapes per slide — a title and a content placeholder — so a two-slide deck reports four bodies.
Use SlideCount for the slide count; ExtractText(...).Count is not it.
ReplaceText works the same way it does for Word documents, so a deck can be a template too:
byte[] edited = PresentationEditor.ReplaceText(pptx, new Dictionary<string, string>
{
["{{who}}"] = "World",
});
Charts
WorkbookEditor.AddChart and PresentationEditor.AddChart create charts, sharing one
ChartType/ChartData model:
var chartData = new ChartData(
new[] { "North", "South" },
new[] { new ChartSeries("Total", new double[] { 1500, 1550 }) });
byte[] withChart = WorkbookEditor.AddChart(
sales, "Sales", "D8", ChartType.ColumnClustered, chartData, title: "Regional Totals");
With chart : <varies> bytes (does not touch the sheet's cell data)
var chartData = new ChartData(
new[] { "North", "South" },
new[] { new ChartSeries("Total", new double[] { 1200, 980 }) });
pptx = PresentationEditor.AddChart(
pptx, slideIndex: 1, ChartType.ColumnClustered, chartData, title: "Regional Totals");
With chart : <varies> bytes (reaches PptxToPdfConverter's output)
DOCX chart creation is not included in this version — OfficeIMO.Word's chart API has a
structurally different shape from the one Excel and PowerPoint share, and forcing it into the same
model would under- or over-serve one side. Word charts may get their own API in a future version.
Both calls above reach the render: PptxToPdfConverter and XlsxToPdfConverter carry the chart
through, title and category labels included — see Rendering either one to PDF below for what is
measured and what is not.
SmartArt is read, not (yet) written
ReadSmartArt returns each diagram's node texts on a slide, one entry per diagram, each entry
already newline-joined:
using (var source = new MemoryStream(pptx, writable: false))
using (var doc = PowerPointPresentation.Load(source))
{
var box = PowerPointLayoutBox.FromInches(1, 3, 6, 2);
doc.Slides[0].AddSmartArt(PowerPointSmartArtType.BasicProcess, new[] { "Plan", "Build", "Ship" }, box);
using var output = new MemoryStream();
doc.Save(output);
pptx = output.ToArray();
}
IReadOnlyList<string> diagrams = PresentationEditor.ReadSmartArt(pptx, index: 1);
SmartArt : 1 diagram(s) on slide 1
Diagram text : "Plan / Build / Ship"
In ExtractText too: True
A SmartArt diagram's text lives in a different OOXML construct entirely — a diagram data
part, not a text-bearing shape body — which is why it needs its own method rather than showing up
in ReadSlide. ExtractText includes it too, appended after that slide's ordinary text, for the
same reason.
There is no AddSmartArt on this package's own API yet: creating one through the usual
byte[]-in/byte[]-out shape is measured to have a rendering gap — content added that way does
not currently reach PptxToPdfConverter's output, so it is left out rather than shipped with a
silent surprise. The sample above builds its demonstration deck with OfficeIMO.PowerPoint
directly for exactly that reason — it is the same escape hatch a caller reaches for, not a shortcut
unique to this guide. A deck authored in PowerPoint itself reads back correctly either way.
Rendering either one to PDF
XlsxToPdfConverter and PptxToPdfConverter mirror DocxToPdfConverter exactly — the same three members, the same behaviour.
byte[] fromSheet = XlsxToPdfConverter.Convert(xlsx);
byte[] fromDeck = PptxToPdfConverter.Convert(pptx);
XlsxToPdfConverter.ConvertFile("report.xlsx", "report.pdf");
await PptxToPdfConverter.ConvertAsync(source, destination, ct);
The same fidelity limit applies as everywhere else: features the rendering engine cannot represent
— conditional formatting, some shape effects — are dropped silently, with no warning channel on
the public API. A chart is not one of those drops, when it was added by this library. A chart
created with WorkbookEditor.AddChart or PresentationEditor.AddChart (see Charts above) renders
correctly here, title and category labels included, measured directly rather than assumed. A chart
authored some other way — directly in Excel or PowerPoint, or through OfficeIMO — and merely
present in the source file is a different, unmeasured case; if that is your situation, render it to
an image yourself and place that instead.
Legacy .ppt decks
PptxToPdfConverter.Convert also reads PowerPoint 97-2003 binary decks. No separate call and no
conversion step — hand it the bytes.
It succeeds on 60.2% of them, measured over 88 real decks from a government crawl, which is a lower bar than the OOXML path and is published rather than rounded up to "supported". The refusals are dominated by one upstream limitation, and none of them produces a corrupt PDF — a deck that cannot be read is refused, not rendered blank.
So it is worth pointing at an archive of old decks, and worth checking the result rather than
assuming it. XlsxToPdfConverter has no equivalent: a legacy .xls workbook is refused, with a
message saying so.
Choosing a starting point
| You have | Use |
|---|---|
| A file somebody made in Excel or PowerPoint | SetCell / AppendRows / ReplaceText — edit in place, keep the formatting |
| Data, and no file | Create — describe the content, let the library write the file |
| A file you did not write and have never seen | SheetNames + ReadSheet, or SlideCount + ExtractText |