← Back to list

How to Convert HTML Templates to PDF Using PuppeteerSharp in ASP.NET Core

Stop wrestling with clunky PDF libraries. Use a real browser to render pixel-perfect PDFs from HTML — in just a few lines of C#.

Jathurshan Santhirasekaram | C#.NET | JS | SQL ✨ in .NET|C# Hub · 2026-06-06 13:13 · 1 claps · 7.0 min read paywalled
#html-to-pdf #puppeteersharp #aspdotnetcore #csharp #coding
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development 🥊 · Combat Sports

How to Convert HTML Templates to PDF Using PuppeteerSharp in ASP.NET Core

Stop wrestling with clunky PDF libraries. Use a real browser to render pixel-perfect PDFs from HTML — in just a few lines of C#.

AI Generated Image

AI Generated Image

Generating PDFs in .NET has always been painful.

iTextSharp makes you build documents programmatically — no HTML, no CSS, just API calls stacked on API calls. RDLC reports require Visual Studio designers and XML configuration files that feel like they belong in 2005. Third-party libraries either cost money, produce ugly output, or both.

There’s a better way.

PuppeteerSharp is a .NET port of Google’s Puppeteer — a headless Chrome automation library. Instead of you describing a PDF to a library, you hand an actual Chrome browser your HTML and CSS, and it renders it exactly as it would appear on screen — then saves it as a PDF.

The result? Pixel-perfect documents. Full CSS support. Real fonts. Tables that don’t break. Charts that render. Everything you’d expect from a modern browser.

Let’s build it.

What We’re Building

A reusable PdfService in ASP.NET Core that:

  1. Accepts an HTML string (or a Razor template)
  2. Spins up a headless Chromium browser
  3. Renders the HTML
  4. Returns a PDF as a byte array
  5. Serves it as a downloadable file from a controller endpoint

By the end, you’ll have a production-ready PDF generator you can drop into any ASP.NET Core project.

Step 1: Install PuppeteerSharp

Create a new ASP.NET Core Web API project, then install the package:

dotnet new webapi -n PdfDemo
cd PdfDemo
dotnet add package PuppeteerSharp

That’s all you need. PuppeteerSharp will download a compatible Chromium browser automatically on first run.

Step 2: Download Chromium at Startup

PuppeteerSharp needs a local Chromium binary to operate. Add a one-time download step in Program.cs:

using PuppeteerSharp;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register PdfService for dependency injection
builder.Services.AddScoped<IPdfService, PdfService>();
var app = builder.Build();
// Download Chromium on startup if not already present
await new BrowserFetcher().DownloadAsync();
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

This runs once. After the first download, Chromium is cached locally and subsequent startups skip this step entirely.

Step 3: Create the PDF Service Interface

Good architecture means coding to an interface. Create IPdfService.cs:

namespace PdfDemo.Services;

public interface IPdfService
{
    Task<byte[]> GenerateFromHtmlAsync(string html, PdfOptions? options = null);
    Task<byte[]> GenerateFromTemplateAsync(string templateName, object model);
}

Step 4: Implement the PDF Service

Create PdfService.cs:

using PuppeteerSharp;
using PuppeteerSharp.Media;

namespace PdfDemo.Services;
public class PdfService : IPdfService
{
    private readonly ILogger<PdfService> _logger;
    public PdfService(ILogger<PdfService> logger)
    {
        _logger = logger;
    }
    public async Task<byte[]> GenerateFromHtmlAsync(
        string html,
        PdfOptions? options = null)
    {
        _logger.LogInformation("Starting PDF generation...");
        await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            Headless = true,
            Args = new[]
            {
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--disable-dev-shm-usage"  // important for Linux/Docker
            }
        });
        await using var page = await browser.NewPageAsync();
        // Set the HTML content - the browser renders it fully
        await page.SetContentAsync(html, new NavigationOptions
        {
            WaitUntil = new[] { WaitUntilNavigation.NetworkIdle0 }
        });
        // Default PDF options if none provided
        options ??= new PdfOptions
        {
            Format = PaperFormat.A4,
            PrintBackground = true,   // include CSS backgrounds
            MarginOptions = new MarginOptions
            {
                Top = "20mm",
                Bottom = "20mm",
                Left = "15mm",
                Right = "15mm"
            }
        };
        var pdfBytes = await page.PdfDataAsync(options);
        _logger.LogInformation("PDF generated successfully. Size: {Size} bytes", pdfBytes.Length);
        return pdfBytes;
    }
    public async Task<byte[]> GenerateFromTemplateAsync(string templateName, object model)
    {
        // Load HTML template from the Templates folder
        var templatePath = Path.Combine(
            Directory.GetCurrentDirectory(), "Templates", $"{templateName}.html");
        if (!File.Exists(templatePath))
            throw new FileNotFoundException($"Template '{templateName}' not found.", templatePath);
        var html = await File.ReadAllTextAsync(templatePath);
        // Simple token replacement - swap {{PropertyName}} with model values
        html = ReplaceTokens(html, model);
        return await GenerateFromHtmlAsync(html);
    }
    private static string ReplaceTokens(string html, object model)
    {
        foreach (var prop in model.GetType().GetProperties())
        {
            var token = $"{{{{{prop.Name}}}}}";
            var value = prop.GetValue(model)?.ToString() ?? string.Empty;
            html = html.Replace(token, value);
        }
        return html;
    }
}

The key line is WaitUntil = new[] { WaitUntilNavigation.NetworkIdle0 }. This tells Puppeteer to wait until all network requests — fonts, images, external CSS — have finished loading before generating the PDF. Without this, you risk capturing an incompletely rendered page.

Step 5: Create an HTML Invoice Template

Create a Templates folder in your project root, then add invoice.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      font-size: 14px;
      color: #333;
      background: #fff;
    }
    .header {
      background: #1a56db;
      color: white;
      padding: 30px 40px;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .header h1 { font-size: 28px; font-weight: 700; }
    .header p  { font-size: 13px; opacity: 0.85; margin-top: 4px; }
    .invoice-meta {
      padding: 30px 40px;
      display: flex;
      justify-content: space-between;
    }
    .invoice-meta .label { color: #888; font-size: 12px; text-transform: uppercase; }
    .invoice-meta .value { font-size: 16px; font-weight: 600; margin-top: 4px; }
    .section { padding: 0 40px 30px; }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 10px;
    }
    thead tr { background: #f3f4f6; }
    th { padding: 12px 16px; text-align: left; font-size: 12px;
         text-transform: uppercase; color: #555; }
    td { padding: 12px 16px; border-bottom: 1px solid #e5e7eb; }
    tr:last-child td { border-bottom: none; }
    .totals {
      padding: 20px 40px;
      display: flex;
      justify-content: flex-end;
    }
    .totals table { width: 280px; }
    .totals td { padding: 6px 12px; border: none; }
    .totals .grand-total td {
      font-size: 16px;
      font-weight: 700;
      color: #1a56db;
      border-top: 2px solid #1a56db;
      padding-top: 10px;
    }
    .footer {
      margin: 20px 40px 40px;
      padding: 16px;
      background: #f9fafb;
      border-left: 4px solid #1a56db;
      font-size: 13px;
      color: #555;
    }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <h1>{{CompanyName}}</h1>
      <p>{{CompanyAddress}}</p>
    </div>
    <div style="text-align:right">
      <div style="font-size:22px; font-weight:700;">INVOICE</div>
      <div style="margin-top:4px; opacity:0.85;">#{{InvoiceNumber}}</div>
    </div>
  </div>
  <div class="invoice-meta">
    <div>
      <div class="label">Billed To</div>
      <div class="value">{{CustomerName}}</div>
      <div style="color:#555; margin-top:4px;">{{CustomerEmail}}</div>
    </div>
    <div style="text-align:right">
      <div class="label">Invoice Date</div>
      <div class="value">{{InvoiceDate}}</div>
      <div style="margin-top:16px;" class="label">Due Date</div>
      <div class="value">{{DueDate}}</div>
    </div>
  </div>
  <div class="section">
    <table>
      <thead>
        <tr>
          <th>Description</th>
          <th>Qty</th>
          <th>Unit Price</th>
          <th style="text-align:right">Total</th>
        </tr>
      </thead>
      <tbody>
        {{InvoiceItems}}
      </tbody>
    </table>
  </div>
  <div class="totals">
    <table>
      <tr>
        <td>Subtotal</td>
        <td style="text-align:right">{{Subtotal}}</td>
      </tr>
      <tr>
        <td>Tax ({{TaxRate}})</td>
        <td style="text-align:right">{{TaxAmount}}</td>
      </tr>
      <tr class="grand-total">
        <td>Total Due</td>
        <td style="text-align:right">{{TotalDue}}</td>
      </tr>
    </table>
  </div>
  <div class="footer">
    Thank you for your business. Please make payment by {{DueDate}}.
    Questions? Contact us at {{CompanyEmail}}
  </div>
</body>
</html>

Step 6: Build the Controller

Create PdfController.cs:

using Microsoft.AspNetCore.Mvc;
using PdfDemo.Services;
using PuppeteerSharp;
using PuppeteerSharp.Media;

namespace PdfDemo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PdfController : ControllerBase
{
    private readonly IPdfService _pdfService;
    public PdfController(IPdfService pdfService)
    {
        _pdfService = pdfService;
    }
    // Generate PDF from raw HTML string
    [HttpPost("from-html")]
    public async Task<IActionResult> FromHtml([FromBody] string html)
    {
        var pdfBytes = await _pdfService.GenerateFromHtmlAsync(html);
        return File(pdfBytes, "application/pdf", "document.pdf");
    }
    // Generate a real invoice PDF
    [HttpGet("invoice/{invoiceNumber}")]
    public async Task<IActionResult> GenerateInvoice(string invoiceNumber)
    {
        // Build invoice items HTML
        var items = new[]
        {
            new { Description = "ASP.NET Core Development", Qty = 10, Price = 150.00m },
            new { Description = "Database Design & Setup",  Qty = 5,  Price = 200.00m },
            new { Description = "API Integration",          Qty = 8,  Price = 120.00m },
        };
        var itemsHtml = string.Join("", items.Select(i =>
            $"<tr>" +
            $"<td>{i.Description}</td>" +
            $"<td>{i.Qty}</td>" +
            $"<td>${i.Price:F2}</td>" +
            $"<td style='text-align:right'>${i.Qty * i.Price:F2}</td>" +
            $"</tr>"));
        var subtotal  = items.Sum(i => i.Qty * i.Price);
        var tax       = subtotal * 0.10m;
        var total     = subtotal + tax;
        var model = new
        {
            CompanyName    = "Acme Software Ltd.",
            CompanyAddress = "42 Developer Lane, Tech City, TC 10001",
            CompanyEmail   = "billing@acmesoftware.com",
            InvoiceNumber  = invoiceNumber,
            CustomerName   = "John Smith",
            CustomerEmail  = "john.smith@example.com",
            InvoiceDate    = DateTime.Now.ToString("MMMM dd, yyyy"),
            DueDate        = DateTime.Now.AddDays(30).ToString("MMMM dd, yyyy"),
            InvoiceItems   = itemsHtml,
            Subtotal       = $"${subtotal:F2}",
            TaxRate        = "10%",
            TaxAmount      = $"${tax:F2}",
            TotalDue       = $"${total:F2}"
        };
        var pdfBytes = await _pdfService.GenerateFromTemplateAsync("invoice", model);
        return File(pdfBytes, "application/pdf", $"Invoice-{invoiceNumber}.pdf");
    }
    // Custom paper size and orientation
    [HttpPost("custom")]
    public async Task<IActionResult> Custom([FromBody] string html)
    {
        var options = new PdfOptions
        {
            Format          = PaperFormat.Letter,
            Landscape       = true,
            PrintBackground = true,
            MarginOptions = new MarginOptions
            {
                Top = "10mm", Bottom = "10mm",
                Left = "10mm", Right = "10mm"
            }
        };
        var pdfBytes = await _pdfService.GenerateFromHtmlAsync(html, options);
        return File(pdfBytes, "application/pdf", "custom.pdf");
    }
}

Step 7: Run It

dotnet run

On first run, you’ll see Chromium downloading in the console. After that, hit:

GET https://localhost:5001/api/pdf/invoice/INV-2024-001

Your browser will download a beautiful, fully styled PDF invoice. Fonts rendered correctly. Colors intact. Tables perfectly laid out.

Bonus: Running in Docker

PuppeteerSharp inside Docker needs a few extra dependencies. In your Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
# Install Chromium dependencies for PuppeteerSharp
RUN apt-get update && apt-get install -y \
    libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 \
    libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
    libasound2 libxss1 libxtst6 xdg-utils \
    fonts-liberation libappindicator3-1 \
    --no-install-recommends && rm -rf /var/lib/apt/lists/*
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["PdfDemo.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "PdfDemo.dll"]

And add --no-sandbox to your launch args — already in the service code above. That flag is required in any containerized environment.

PdfOptions: The Full Toolkit

Option What It Does Example Format Paper size PaperFormat.A4, PaperFormat.Letter Landscape Page orientation true / false PrintBackground Include CSS backgrounds true (always set this) MarginOptions Page margins "20mm", "0" Scale Zoom level 0.8 (80% zoom) DisplayHeaderFooter Show header/footer true HeaderTemplate Custom header HTML <div>Page <span class='pageNumber'></span></div> FooterTemplate Custom footer HTML <div style='font-size:10px'>Confidential</div> PageRanges Which pages "1-3", "1,3,5"

Adding Page Numbers

var options = new PdfOptions
{
    Format               = PaperFormat.A4,
    PrintBackground      = true,
    DisplayHeaderFooter  = true,
    HeaderTemplate       = "<div></div>",
    FooterTemplate       = @"
        <div style='width:100%; font-size:10px; color:#999;
                    text-align:center; padding:0 20px;'>
            Page <span class='pageNumber'></span>
            of <span class='totalPages'></span>
        </div>",
    MarginOptions = new MarginOptions
    {
        Top = "20mm", Bottom = "25mm",
        Left = "15mm", Right = "15mm"
    }
};

The Project Structure

PdfDemo/
├── Controllers/
│   └── PdfController.cs
├── Services/
│   ├── IPdfService.cs
│   └── PdfService.cs
├── Templates/
│   └── invoice.html
├── Program.cs
└── PdfDemo.csproj

Clean. Minimal. Extensible.

What You Built

A production-ready PDF generation service that:

  • Renders any HTML/CSS to a PDF using a real Chromium engine
  • Supports custom templates with simple token replacement
  • Accepts custom paper sizes, orientations, margins, and page numbers
  • Integrates cleanly with ASP.NET Core’s dependency injection
  • Runs in Docker with the right configuration

The next time a designer hands you a beautiful invoice mockup and asks “can we generate this as a PDF?”, you won’t have to translate it into API calls. You’ll just write HTML.

That’s the power of PuppeteerSharp.

Found this useful? Follow for more ASP.NET Core deep dives. Next up: Generating PDFs with Razor templating — combine this approach with real Razor views for fully dynamic, design-ready documents.


메타데이터
post_id
aaf9c9957ba8
slug
how-to-convert-html-templates-to-pdf-using-puppeteersharp-in-asp-net-core-aaf9c9957ba8
url
https://medium.com/we-are-developers/how-to-convert-html-templates-to-pdf-using-puppeteersharp-in-asp-net-core-aaf9c9957ba8
canonical_url
https://medium.com/we-are-developers/how-to-convert-html-templates-to-pdf-using-puppeteersharp-in-asp-net-core-aaf9c9957ba8
author_url
https://medium.com/@code_santa
status
ok
fetched_at
2026-06-14 11:28:49