A production-ready, self-contained PDF generation service built on real browser rendering.
Drop in a Razor template. Call an API. Get a pixel-perfect PDF. That's it.
PDF generation is a solved problem β until you try to do it right.
Most libraries render HTML in a stripped-down engine with partial CSS support, missing fonts, broken layouts, and no Flexbox. The result: PDFs that look nothing like the design.
This project was born from that frustration. Instead of fighting with rendering quirks, HtmlPdf delegates the entire rendering pipeline to a real Chromium browser β the same engine your users see every day. Full CSS3, Flexbox, Grid, custom fonts, @page rules. If it renders in Chrome, it renders in your PDF.
Beyond that, the service is built to be plug-and-play for any system. You don't need to migrate your stack. Call the REST API from any language, any platform, any legacy system. Add custom templates at runtime without touching the code or restarting the container.
This project is part of my professional portfolio β a demonstration of how I approach real engineering problems: with clean architecture, thoughtful design decisions, and production-grade quality.
| Feature | Details |
|---|---|
| π¨οΈ Real Browser Rendering | Full Chromium engine β pixel-perfect PDFs with complete CSS3, Flexbox, Grid support |
| π Dynamic Templates | Drop a .cshtml file into a volume, restart, get a new endpoint. No code changes |
| β‘ Hot-Reload Config | Change concurrency limits and template whitelists while the service runs |
| π Secure by Default | Template whitelist prevents path traversal β only explicitly allowed templates are served |
| π³ Docker-First | One docker compose up and you're live. Chromium cached in a persistent volume |
| π Language Agnostic | Pure REST API β call it from Python, Java, PHP, Ruby, legacy .NET, anything |
| π Concurrent Processing | Configurable semaphore-based concurrency β handles simultaneous requests safely |
HTTP POST
β
βΌ
Endpoint Handler β auto-discovered via reflection
β
βΌ
Template Renderer β RazorLight compiles .cshtml β HTML (cached)
β
βΌ
PDF Renderer β PuppeteerSharp sends HTML to headless Chromium
β
βΌ
PDF bytes β Response
Two flavors of endpoints:
- Static β strongly-typed DTO, defined in code, registered at build time
- Dynamic β
.cshtmlfile dropped in a volume folder, registered at startup automatically
docker pull svctech/svc-html-pdfOr with docker compose:
# docker-compose.yml
name: htmlpdf-stack
services:
htmlpdf:
image: svctech/svc-html-pdf:latest
ports:
- "6000:8080"
volumes:
- chromium_data:/app/chromium
- ./my-templates:/app/Templates/DynamicTemplates
restart: unless-stopped
volumes:
chromium_data:docker compose up -dThe first start downloads Chromium into the chromium_data volume. Every subsequent start is instant β no re-download, no cold start.
This option is ideal if you want to integrate the service directly into an existing solution, call it locally, or run it on a server without Docker.
git clone https://github.com/SvcGeek/HtmlPdf.git
cd HtmlPdf/HtmlPdf.Service
dotnet runThe service will be available at http://localhost:5008 (configured in appsettings.Development.json and launchSettings.json).
On first run, Chromium is downloaded automatically if not already present at the configured
ChromiumPath.
You can then call it from any system or language via HTTP β no .NET dependency on the client side.
The service ships with a working sample endpoint.
Port reference
Docker (docker compose up):http://localhost:6000
Clone & run (dotnet run):http://localhost:5008
# Docker
curl -X POST http://localhost:6000/pdf/sample-endpoint-render \
-H "Content-Type: application/json" \
-d '{
"template": "sample-endpoint-render",
"language": "en",
"direction": "ltr",
"data": {
"orderId": "ORD-2025-001",
"orderDate": "2025-01-15",
"client": {
"name": "Jane Smith",
"email": "jane@example.com"
},
"items": []
}
}' \
--output my-first.pdfOpen my-first.pdf. That's a real browser-rendered PDF β generated in under a second.
The most powerful feature: add a PDF template to a running service without writing code or rebuilding the image.
<!-- invoices/invoice.cshtml -->
@model dynamic
<!DOCTYPE html>
<html>
<body>
<h1>Invoice</h1>
<p>Client: @Model["client"]</p>
<p>Amount: @Model["amount"]</p>
</body>
</html>cp invoice.cshtml ./my-templates/docker compose restartThe service logs:
π Dynamic template discovered β POST /pdf/dynamic/invoice
POST http://localhost:6000/pdf/dynamic/invoice
Content-Type: application/json
{
"data": {
"client": "Acme Corp",
"amount": "β¬ 4,200.00"
}
}No code. No build. No deployment pipeline.
// appsettings.json
{
"Browser": {
"ChromiumPath": "/app/chromium"
},
"PdfRendering": {
"MaxConcurrentRenderings": 10,
"AllowedTemplates": [
"sample-endpoint-render"
],
"PagePdfOptions": [
{
"NameOption": "default",
"PrintBackground": true,
"Landscape": false,
"MarginOptions": {
"Top": "1cm",
"Bottom": "1cm",
"Left": "1cm",
"Right": "1cm"
}
}
]
}
}Hot-reload supported: MaxConcurrentRenderings and AllowedTemplates update instantly when you save the file β no restart needed.
When you need a strongly-typed, validated endpoint with a specific DTO:
1. Create the DTO (Pdf.Abstractions/DTO/InvoiceDTO.cs)
public class InvoiceDTO
{
public string? Language { get; set; }
public string? Direction { get; set; }
public string? ClientName { get; set; }
public decimal Total { get; set; }
}2. Create the handler (PdfHandlerEndpoints/InvoicePdfHandler.cs)
public class InvoicePdfHandler : IPdfEndpoint
{
public string Pattern => "invoice";
private readonly IPdfRenderer _renderer;
public InvoicePdfHandler(IPdfRenderer renderer) => _renderer = renderer;
public async Task<IResult> ProcessHandle(RenderPdfRequestBase request,
IOptionsMonitor<PdfRenderingOptions> options)
{
var model = IPdfEndpoint.BuildModel<InvoiceDTO>(request);
var pdf = await _renderer.RenderAsync(request.Template, model);
return Results.File(pdf, "application/pdf");
}
}3. Create the template (Templates/invoice.cshtml)
@model Pdf.Abstractions.DTO.InvoiceDTO
<h1>Invoice for @Model.ClientName</h1>
<p>Total: @Model.Total.ToString("C")</p>4. Add to whitelist in appsettings.json β no restart needed.
That's it. Auto-discovery registers the endpoint at startup.
HtmlPdf/
βββ HtmlPdf.Service/ # Main web API
β βββ DependencyInjection/ # DI wiring + auto-discovery
β βββ Helpers/ # BrowserProvider, PDF option helpers
β βββ Options/ # Configuration models
β βββ PdfEndpoints/ # IPdfEndpoint interface
β βββ PdfHandlerEndpoints/
β β βββ DynamicEndpoints/ # Dynamic template handler
β β βββ SampleEndpointPdfHandler.cs
β βββ Renderer/ # TemplateRenderer, PuppeteerPdfRenderer
β βββ Templates/
β β βββ DynamicTemplates/ # β mount your volume here
β β βββ sample-endpoint-render.cshtml
β βββ Program.cs
β
βββ Pdf.Abstractions/ # Shared DTOs and request models
β
βββ docs/
βββ adr/ # Architecture Decision Records
βββ ADR-001-chromium-rendering.md
βββ ADR-002-razorlight-templating.md
βββ ADR-003-dynamic-endpoints.md
βββ ADR-004-singleton-lifetime.md
Key technical choices are documented as Architecture Decision Records:
| ADR | Decision |
|---|---|
| ADR-001 | Why Chromium (PuppeteerSharp) over iTextSharp, WkHtmlToPdf, and others |
| ADR-002 | Why RazorLight for runtime template compilation |
| ADR-003 | How and why Dynamic Endpoints work via file system volume |
| ADR-004 | Why all core services are singletons |
| Layer | Technology |
|---|---|
| Runtime | .NET 10, ASP.NET Core Minimal APIs |
| PDF Engine | PuppeteerSharp 24 (Chromium) |
| Templating | RazorLight 2.3 |
| Containerization | Docker, Docker Compose |
Built by Silviu CΔtΔlin Valcu β a .NET developer who got tired of bad PDF libraries and decided to build the right tool.
If you find this useful, a β on the repo goes a long way.
Questions, issues, or ideas? Open an issue or reach out on LinkedIn.
MIT β see LICENSE for details.