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#.
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
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:
- Accepts an HTML string (or a Razor template)
- Spins up a headless Chromium browser
- Renders the HTML
- Returns a PDF as a byte array
- 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