Generate an invoice PDF from an HTML template in C#

An invoice is data poured into a layout. Keep the layout as an HTML template with CSS, fill in the customer, the line items and the total from your data, and let HtmlToPdf turn the result into an A4 PDF.

Compiled and run: Linux ARM64 · Linux x64 · macOS ARM64 · Windows x64

Video walkthrough · 3:42 · Watch on YouTube · Short · 1:11
§STEP BY STEP

Build it step by step.

  1. 1Install the NuGet packages

    SelectPdf.Universal is the library. The native package carries the Chromium engine for one platform: add the one for the platform you run on.

    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.win-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-arm64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.osx-arm64
  2. 2Create a console app

    Any .NET 8 or .NET 10 project works. To follow along, create a console app, run the install commands above inside its folder, then replace Program.cs with the code below.

    terminal
    dotnet new console -n PdfSample
    cd PdfSample
  3. 3Import the namespaces

    Program.cs · lines 1–3
    using System.Net;
    using System.Text;
    using SelectPdf.Universal;

    System.Net provides WebUtility.HtmlEncode, System.Text the StringBuilder for the rows, and SelectPdf.Universal the converter.

  4. 4Get the invoice data

    Program.cs · lines 5–11
    // 1. The invoice data. In your application it comes from your database.
    Invoice invoice = new Invoice("2026-118", new DateOnly(2026, 10, 17), "Fabrikam Ltd", "12 King Street, London",
    [
        new InvoiceLine("Reporting service, October 2026", 1, 1200.00m),
        new InvoiceLine("Additional user licenses", 5, 40.00m),
        new InvoiceLine("Onboarding workshop", 1, 450.00m),
    ]);

    An Invoice record with its customer and line items, declared at the end of the file. In your application this comes from your database or order system.

  5. 5Write the HTML template

    Program.cs · lines 13–37
    // 2. The HTML template: plain HTML and CSS, with {{placeholders}} for the data.
    string template = """
        <html><head><style>
          body { font-family: sans-serif; color: #222; }
          .top { display: flex; justify-content: space-between; }
          table { width: 100%; border-collapse: collapse; margin-top: 30px; }
          th { background: #1d4ed8; color: #fff; text-align: left; padding: 8px; }
          td { border-bottom: 1px solid #ddd; padding: 8px; }
          .num { text-align: right; }
          .total td { font-weight: bold; border: none; }
        </style></head><body>
          <div class="top">
            <div><h1>Invoice {{number}}</h1><p>Date: {{date}}</p></div>
            <div><b>Example Ltd</b><br>1 Market Road, Leeds<br>VAT GB 123 4567 89</div>
          </div>
          <p><b>Bill to:</b><br>{{customer}}<br>{{address}}</p>
          <table>
            <thead><tr><th>Description</th><th class="num">Qty</th>
              <th class="num">Unit price</th><th class="num">Amount</th></tr></thead>
            <tbody>{{rows}}</tbody>
            <tr class="total"><td colspan="3" class="num">Total due (GBP)</td><td class="num">{{total}}</td></tr>
          </table>
          <p>Payment within 30 days to account 12345678, sort code 01-02-03.</p>
        </body></html>
        """;

    The layout is plain HTML and CSS: a flexbox header with the seller's details on the right, a styled table for the lines and a bold total row. {{number}}, {{rows}} and the other placeholders mark where the data goes. The converter renders it the way Chromium does, so you can design and preview the template in a browser.

  6. 6Fill in the template

    Program.cs · lines 39–52
    // 3. Fill in the template, HTML-encoding every value, one table row per line.
    StringBuilder rows = new StringBuilder();
    foreach (InvoiceLine line in invoice.Lines)
        rows.Append($"""
            <tr><td>{WebUtility.HtmlEncode(line.Description)}</td><td class="num">{line.Quantity}</td>
            <td class="num">{line.UnitPrice:N2}</td><td class="num">{line.Amount:N2}</td></tr>
            """);
    string html = template
        .Replace("{{number}}", WebUtility.HtmlEncode(invoice.Number))
        .Replace("{{date}}", invoice.Date.ToString("d MMMM yyyy"))
        .Replace("{{customer}}", WebUtility.HtmlEncode(invoice.Customer))
        .Replace("{{address}}", WebUtility.HtmlEncode(invoice.Address))
        .Replace("{{rows}}", rows.ToString())
        .Replace("{{total}}", invoice.Total.ToString("N2"));

    Each line item becomes a table row, and Replace puts every value in its placeholder. Text from your data goes through WebUtility.HtmlEncode, so a customer called "Smith & Sons" or a description with < cannot break the HTML. Amounts are formatted with N2.

  7. 7Convert to an A4 PDF and save

    Program.cs · lines 54–64
    // 4. Convert to an A4 PDF with margins and a document title, and save it.
    HtmlToPdf converter = new HtmlToPdf();
    converter.Options.PdfPageSize = PdfPageSize.A4;
    converter.Options.MarginTop = 30;
    converter.Options.MarginBottom = 30;
    converter.Options.MarginLeft = 30;
    converter.Options.MarginRight = 30;
    converter.Options.PdfDocumentInformation.Title = $"Invoice {invoice.Number}";
    PdfDocument doc = converter.ConvertHtmlString(html);
    doc.Save($"invoice-{invoice.Number}.pdf");
    doc.Close();

    PdfPageSize and the four margins (in points) set the page; PdfDocumentInformation.Title is the title a PDF viewer shows. ConvertHtmlString renders the HTML and Save writes invoice-2026-118.pdf. To send the invoice by email or return it from a web API, doc.Save() without arguments returns the PDF as a byte array.

  8. 8The data types

    Program.cs · lines 66–74
    record InvoiceLine(string Description, int Quantity, decimal UnitPrice)
    {
        public decimal Amount => Quantity * UnitPrice;
    }
    
    record Invoice(string Number, DateOnly Date, string Customer, string Address, InvoiceLine[] Lines)
    {
        public decimal Total => Lines.Sum(line => line.Amount);
    }

    Two records hold the data; Amount and Total are computed, so the PDF always adds up.

  9. 9Run it

    terminal
    dotnet run

    The program writes invoice-2026-118.pdf to the working folder. Without a license key, every page carries a trial watermark; set GlobalProperties.LicenseKey before the first conversion to remove it.

§COMPLETE PROGRAM

The complete Program.cs.

This is the exact file our samples pipeline compiled and ran on Linux ARM64 and Linux x64 and macOS ARM64 and Windows x64 to produce the output on this page.

Program.cs
using System.Net;
using System.Text;
using SelectPdf.Universal;

// 1. The invoice data. In your application it comes from your database.
Invoice invoice = new Invoice("2026-118", new DateOnly(2026, 10, 17), "Fabrikam Ltd", "12 King Street, London",
[
    new InvoiceLine("Reporting service, October 2026", 1, 1200.00m),
    new InvoiceLine("Additional user licenses", 5, 40.00m),
    new InvoiceLine("Onboarding workshop", 1, 450.00m),
]);

// 2. The HTML template: plain HTML and CSS, with {{placeholders}} for the data.
string template = """
    <html><head><style>
      body { font-family: sans-serif; color: #222; }
      .top { display: flex; justify-content: space-between; }
      table { width: 100%; border-collapse: collapse; margin-top: 30px; }
      th { background: #1d4ed8; color: #fff; text-align: left; padding: 8px; }
      td { border-bottom: 1px solid #ddd; padding: 8px; }
      .num { text-align: right; }
      .total td { font-weight: bold; border: none; }
    </style></head><body>
      <div class="top">
        <div><h1>Invoice {{number}}</h1><p>Date: {{date}}</p></div>
        <div><b>Example Ltd</b><br>1 Market Road, Leeds<br>VAT GB 123 4567 89</div>
      </div>
      <p><b>Bill to:</b><br>{{customer}}<br>{{address}}</p>
      <table>
        <thead><tr><th>Description</th><th class="num">Qty</th>
          <th class="num">Unit price</th><th class="num">Amount</th></tr></thead>
        <tbody>{{rows}}</tbody>
        <tr class="total"><td colspan="3" class="num">Total due (GBP)</td><td class="num">{{total}}</td></tr>
      </table>
      <p>Payment within 30 days to account 12345678, sort code 01-02-03.</p>
    </body></html>
    """;

// 3. Fill in the template, HTML-encoding every value, one table row per line.
StringBuilder rows = new StringBuilder();
foreach (InvoiceLine line in invoice.Lines)
    rows.Append($"""
        <tr><td>{WebUtility.HtmlEncode(line.Description)}</td><td class="num">{line.Quantity}</td>
        <td class="num">{line.UnitPrice:N2}</td><td class="num">{line.Amount:N2}</td></tr>
        """);
string html = template
    .Replace("{{number}}", WebUtility.HtmlEncode(invoice.Number))
    .Replace("{{date}}", invoice.Date.ToString("d MMMM yyyy"))
    .Replace("{{customer}}", WebUtility.HtmlEncode(invoice.Customer))
    .Replace("{{address}}", WebUtility.HtmlEncode(invoice.Address))
    .Replace("{{rows}}", rows.ToString())
    .Replace("{{total}}", invoice.Total.ToString("N2"));

// 4. Convert to an A4 PDF with margins and a document title, and save it.
HtmlToPdf converter = new HtmlToPdf();
converter.Options.PdfPageSize = PdfPageSize.A4;
converter.Options.MarginTop = 30;
converter.Options.MarginBottom = 30;
converter.Options.MarginLeft = 30;
converter.Options.MarginRight = 30;
converter.Options.PdfDocumentInformation.Title = $"Invoice {invoice.Number}";
PdfDocument doc = converter.ConvertHtmlString(html);
doc.Save($"invoice-{invoice.Number}.pdf");
doc.Close();

record InvoiceLine(string Description, int Quantity, decimal UnitPrice)
{
    public decimal Amount => Quantity * UnitPrice;
}

record Invoice(string Number, DateOnly Date, string Customer, string Address, InvoiceLine[] Lines)
{
    public decimal Total => Lines.Sum(line => line.Amount);
}

Open the project on GitHub → this program and the variations below, ready to dotnet run

§SUPPORTING FILES

Supporting files.

invoice-template.html

invoice-template.html
<html>
<head><link rel="stylesheet" href="invoice.css"></head>
<body>
  <h1>Invoice {{number}}</h1>
  <p class="customer">Bill to: {{customer}}</p>
  <p class="total">Total due: {{total}} GBP</p>
</body>
</html>

invoice.css

invoice.css
body { font-family: sans-serif; margin: 40px; }
h1 { color: #1d4ed8; border-bottom: 3px solid #1d4ed8; padding-bottom: 8px; }
.customer { font-size: 18px; }
.total { font-size: 24px; font-weight: bold; text-align: right; }
§OUTPUT

The PDF it produces.

First page of invoice-2026-118.pdf, the PDF this sample produces
invoice-2026-118.pdf, page 1

Download invoice-2026-118.pdf ↓ the file this sample produced, unedited

§VARIATIONS

Variations you will need next.

Each variation is a separate file in the same project, compiled and run with the program above.

A long invoice: repeat the table header and number the pages

With 80 lines the table runs onto a second page. display: table-header-group on the thead, with CssMediaType = HtmlToPdfCssMediaType.Print, repeats the header row at the top of each page, and a footer PdfTextSection with {page_number} and {total_pages} prints "page 1 of 2".

LongInvoice.cs
using System.Text;
using SelectPdf.Universal;

static class LongInvoice
{
    public static void Run()
    {
        // 80 lines: the table runs over several pages
        StringBuilder rows = new StringBuilder();
        for (int i = 1; i <= 80; i++)
            rows.Append($"<tr><td>Support ticket #{4100 + i}</td><td class=\"num\">{i % 4 + 1}</td><td class=\"num\">75.00</td></tr>");

        string html = $$"""
            <style>
              body { font-family: sans-serif; }
              table { width: 100%; border-collapse: collapse; }
              th { background: #1d4ed8; color: #fff; text-align: left; padding: 6px; }
              td { border-bottom: 1px solid #ddd; padding: 6px; }
              .num { text-align: right; }
            </style>
            <h1>Invoice 2026-119</h1>
            <table>
              <thead style="display: table-header-group">
                <tr><th>Item</th><th class="num">Hours</th><th class="num">Rate</th></tr>
              </thead>
              <tbody>{{rows}}</tbody>
            </table>
            """;

        HtmlToPdf converter = new HtmlToPdf();
        // the table header repeats on every page when rendered with the print media type
        converter.Options.CssMediaType = HtmlToPdfCssMediaType.Print;

        // "Page X of Y" in the footer of every page
        converter.Options.DisplayFooter = true;
        converter.Footer.Height = 30;
        PdfTextSection pageNumbers = new PdfTextSection(0, 8, "Invoice 2026-119, page {page_number} of {total_pages}",
            PdfFont.CreateFromSystemFont("Liberation Sans", 9f));
        pageNumbers.HorizontalAlign = PdfTextHorizontalAlign.Right;
        converter.Footer.Add(pageNumbers);

        PdfDocument doc = converter.ConvertHtmlString(html);
        doc.Save("invoice-2026-119.pdf");
        doc.Close();
    }
}

Keep the template and its stylesheet in files

The template is invoice-template.html and it links invoice.css by a relative path, so a designer can change the layout without touching code. The second argument of ConvertHtmlString is the base URL, here the program's folder, against which invoice.css and any images are resolved.

TemplateFromFiles.cs
using System.Net;
using SelectPdf.Universal;

static class TemplateFromFiles
{
    public static void Run()
    {
        // the template and its stylesheet are files a designer can edit;
        // the base URL lets the HTML load invoice.css by a relative path
        string folder = Path.GetFullPath(".") + Path.DirectorySeparatorChar;
        string html = File.ReadAllText("invoice-template.html")
            .Replace("{{number}}", WebUtility.HtmlEncode("2026-120"))
            .Replace("{{customer}}", WebUtility.HtmlEncode("Northwind Traders & Co"))
            .Replace("{{total}}", 980.00m.ToString("N2"));

        HtmlToPdf converter = new HtmlToPdf();
        PdfDocument doc = converter.ConvertHtmlString(html, new Uri(folder).AbsoluteUri);
        doc.Save("invoice-2026-120.pdf");
        doc.Close();
    }
}
§TROUBLESHOOTING

Common problems and fixes.

Images or the stylesheet are missing

An HTML string has no address of its own, so relative paths cannot be resolved. Pass a base URL as the second argument of ConvertHtmlString, use absolute URLs, or embed small images as data URIs.

The table header appears only on the first page

Set display: table-header-group on the thead and Options.CssMediaType = HtmlToPdfCssMediaType.Print; with the default screen media type the header is not repeated.

Customer data breaks the layout

Encode every value that comes from your data with WebUtility.HtmlEncode before putting it in the HTML. Only the rows you build yourself, from encoded values, go in unencoded.

Amounts or dates use the wrong format

ToString("N2") and date formats follow the current culture of the process. Pass a CultureInfo, for example invoice.Total.ToString("N2", CultureInfo.GetCultureInfo("en-GB")), to fix the format for every server.

§GOOD TO KNOW

Good to know.

  • HtmlToPdf converts any HTML string to PDF with ConvertHtmlString; the converter supports HTML5 and CSS3. Source
  • ConvertHtmlString(html, baseUrl) resolves relative URLs of images and stylesheets against the base URL. Source
  • Page size, orientation and margins are set with PdfPageSize, PdfPageOrientation and MarginLeft, MarginRight, MarginTop, MarginBottom; margins are in points. Source
  • display: table-header-group on the thead repeats a table's header at the top of every page, when the page is rendered with the print media type. Source
  • PdfDocument.Save() without arguments returns the PDF as a byte array. Source
?FAQ

Questions, answered.

Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.

Keep the invoice layout as an HTML template, replace its placeholders with your data (HTML-encoded), and convert the result with HtmlToPdf.ConvertHtmlString. Save the PdfDocument to a file, a stream or a byte array.
Yes. The converter renders HTML5 and CSS3 with a Chromium engine, so the template looks as it does in Chrome. Load stylesheets and fonts by absolute URL, or by relative path with a base URL.
Call doc.Save() without arguments: it returns the PDF as a byte array. doc.Save(stream) writes it to a stream.
Yes. Convert the same HTML as PDF/A-3 and embed the invoice XML with AddZugferdInvoice; the ZUGFeRD guide shows how.