Table of Contents

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