V4 V5

For anything with a fixed look — an invoice, a report — loading a template built in Excel and dropping values into it is more reliable than assembling the layout in code. Borders, fonts, column widths and print setup survive the round trip.

This needs no window at all, so it runs in a batch job on a server (Headless Use).

1. Build the template in Excel

Build it in Excel as usual and save as .xlsx.

Leave the cells you will fill empty, but give them their formatting. Set #,##0 on the amount cells and yyyy/mm/dd on the date cells and your code only ever has to write numbers.

Set the paper, margins and print area in Excel too. They are loaded as PrintSettings and apply directly to PDF export (Printing and Page Setup).

2. Load it and fill it

using unvell.ReoGrid.Core;
using unvell.ReoGrid.IO.Excel;
using unvell.ReoGrid.IO.Pdf;

// the template keeps its styles, borders, formulas and print setup
Workbook wb = XlsxReader.Read("invoice-template.xlsx");
Worksheet ws = wb[0];

ws.SetText(1, 4, "2026-08-19");                 // E2, the issue date
ws.SetText(3, 0, "Contoso Ltd.");        // A4, the addressee

XlsxWriter.Write(wb, "invoice-0001.xlsx");
PdfExporter.Export(wb, "invoice-0001.pdf");

Loading preserves the formatting. SetText / SetNumber change only the value, so the styles, borders and number formats set in the template stay exactly as they were.

3. Locate things by defined name

Hard-coded row numbers break the moment someone adds a row to the template. Name the ranges in Excel and look them up by name.

Workbook wb = XlsxReader.Read("invoice-template.xlsx");
Worksheet ws = wb[0];

// Locate things by the name defined in the template.
// The layout can move without the code changing
RangePosition body = ws.ResolveRange("LineItems");

var lines = new[]
{
	("Design", 400_000d),
	("Implementation", 850_000d),
	("Maintenance", 120_000d),
};

for (int i = 0; i < lines.Length; i++)
{
	ws.SetText(body.Row + i, body.Col, lines[i].Item1);
	ws.SetNumber(body.Row + i, body.Col + 1, lines[i].Item2);
}

In Excel that is Formulas → Define Name. XLSX defined names are loaded as-is (Defined Names).

Now rows can be added to the template and, as long as the name still covers the right range, the code does not change.

4. Write it out

XlsxWriter.Write(wb, "invoice-0001.xlsx");     // opens in Excel
PdfExporter.Export(wb, "invoice-0001.pdf");    // ready to send or print

PDF embeds Japanese fonts, so it does not turn into tofu on a Linux server with no fonts installed (PDF Export).

Embedding the template in your app

To avoid shipping the template as a separate file, make it an embedded resource.

// embed the template in the assembly and you ship one file
Workbook wb = XlsxReader.Read(templateStream);

In the .csproj:

<ItemGroup>
  <EmbeddedResource Include="Templates\invoice-template.xlsx" />
</ItemGroup>
using Stream s = typeof(Program).Assembly
    .GetManifestResourceStream("MyApp.Templates.invoice-template.xlsx")!;
Workbook wb = XlsxReader.Read(s);

When the number of line items varies

For a form whose detail section is sometimes 3 rows and sometimes 30, the simplest approach is to format enough rows in the template and hide the leftovers.

for (int r = body.Row + lines.Length; r <= body.EndRow; r++)
    ws.Rows.SetHidden(r, true);

A hidden row counts as zero height in print and PDF, so it does not appear on paper (Rows and Columns).

You could also insert rows to grow the section, but an inserted row does not inherit the row above’s formatting, so you would have to set the borders yourself. Hiding is the better option.

What a template cannot carry

Anything XLSX loading does not support is lost, even if the template has it.

  • Charts, shapes and text boxes — not supported in V5
  • Pivot tables — not supported in V5
  • Threaded comments — the classic note does round-trip
  • Shared formulas — expansion is not supported

The support status is collected in XLSX Compatibility. Images do load, so a logo is fine (Images).

Was this article helpful?