- Project Description
- Features
- Prerequisites
- Installing
- Getting Started
- Usage
- Docker Usage
- Examples
- Architecture
- How-To
- API Reference
- Architecture Decision Records (ADR)
- Documentation
- Code of Conduct
- Contributing
- Credits
- License
- FAQ
HtmlPdfPlus is a modern and lightweight library for .Net10, .Net9 and .Net8 that allows you to convert HTML or RAZOR pages to PDF with high fidelity.
It is a scalable and flexible solution that can be used in client-server mode or only server. It supports CSS and JavaScript, and it is easy to integrate with your application.
You can customize the PDF settings, such as page size and margins, and add headers and footers to your PDF files. HtmlPdfPlus is a powerful tool that can help you generate PDF files from HTML or RAZOR pages with ease.
This library was built using the Playwright (engine to automate Chromium, Firefox, and WebKit with a single API). Playwright is built to enable cross-browser web automation that is evergreen, capable, reliable, and fast.
As of the Playwright version this library currently targets (see the Microsoft.Playwright reference in HtmlPdfPlus.Server.csproj), its PDF generation API supports only the Chromium browser.
- Convert HTML or RAZOR page to PDF with high fidelity
- Support for CSS and JavaScript
- Asynchronous API
- Customizable PDF settings (e.g., page size, margins)
- Support for headers and footers
- Lightweight and easy to integrate
- Flexible and scalable (Client-Server mode or only Server)
- Support HTML5 and CSS3
- Communicate with the server using REST API (with compressed request) or user custom protocol
- Minify HTML and CSS
- Client-side HTML parser with custom error action (optional)
- Requests are sent as gzip-compressed raw bytes by default, no base64/JSON-string wrapping (see ADR-003)
- A successful
byte[]response is served as the raw PDF body, not wrapped in JSON - Extension on server side to customize the conversion process (before and after conversion)
- BeforePDF : Normalize HTML, Replace tokens, etc
- AfterPDF : Save file, Send to cloud, etc
- Disable features to improve/balance performance (minify, compress and log)
- Backpressure signaled via
ErrorCode.PoolExhausted+ a realRetry-After, automatic browser recovery, liveness/readiness endpoints, andSystem.Diagnostics.Metricsinstrumentation - see the resilience guide
Current version: 2.0.0. Full version history has moved to CHANGELOG.md.
- .NET 8, .NET 9 or .NET 10 (SDK to build from source or develop against the library; the runtime alone is enough to just run an app that already references the NuGet packages)
- Visual Studio 2026 or later - optional, only if that's your editor of choice; any .NET-capable IDE/CLI works
- Playwright browser binaries - only required on whatever machine actually runs
HtmlPdfPlus.Server(your dev machine while testing the server, a VM, a container).HtmlPdfPlus.Clientnever launches a browser and does not need this at all. See Installation Steps for Playwright below, or the Docker guide if you're deploying in a container instead.
Only needed on a machine that runs HtmlPdfPlus.Server outside a container - skip this if you're only building against HtmlPdfPlus.Client, or deploying with Docker (see the Docker guide, which bundles the browser inside the image instead).
dotnet tool update --global PowerShell
dotnet tool install --global Microsoft.Playwright.CLI
playwright.exe install --with-deps
Note: Make sure that the path to the executable is mapped to: C:\Users\[login]\.dotnet\tools.
If it is not, run it directly via the path C:\Users\[login]\.dotnet\tools\playwright.exe install --with-deps
Client library can be installed via NuGet or line command.
Install-Package HtmlPdfPlus.Client [-pre]
dotnet add package HtmlPdfPlus.Client [--prerelease]
Server library can be installed via NuGet or line command.
Install-Package HtmlPdfPlus.Server [-pre]
dotnet add package HtmlPdfPlus.Server [--prerelease]
Note: [-pre]/[--prerelease] usage for pre-release versions
Follow these steps to get started with HtmlPdfPlus:
- Install the necessary packages using NuGet.
- Configure the services in your application.
- Use the provided API to convert HTML to PDF.
It is possible to generate a PDF in two ways:
sequenceDiagram
participant AppClient as App Client
participant HtmlPdfClient
participant AppServer as App Server
participant HtmlPdfServer
HtmlPdfServer->>AppServer: AddHtmlPdfService
AppServer-->>AppServer: Warmup HtmlPdfService
Note over AppClient,HtmlPdfClient: Minify, Compress and Logging can be disabled (via DisableOptionsHtmlToPdf)
AppClient->>HtmlPdfClient: FromHtml
HtmlPdfClient-->>HtmlPdfClient: Minify HTML
AppClient->>HtmlPdfClient: FromRazor
HtmlPdfClient-->>HtmlPdfClient: Execute Razor engine, minify HTML
AppClient->>HtmlPdfClient: FromUrl
AppClient->>HtmlPdfClient: PageConfig / Timeout
AppClient->>HtmlPdfClient: Run (optional input param)
HtmlPdfClient-->>HtmlPdfClient: Build RequestHtmlPdf, gzip it
HtmlPdfClient->>AppServer: HTTP POST (gzip bytes or plain JSON as the raw body)
AppServer->>HtmlPdfServer: BeforePDF hook (optional)
AppServer->>HtmlPdfServer: AfterPDF hook (optional)
AppServer->>HtmlPdfServer: Run
HtmlPdfServer-->>HtmlPdfServer: Decompress to RequestHtmlPdf
HtmlPdfServer-->>HtmlPdfServer: Exec BeforePDF(input param)
HtmlPdfServer-->>HtmlPdfServer: Generate PDF
HtmlPdfServer-->>HtmlPdfServer: Exec AfterPDF(input param, transform output)
HtmlPdfServer->>AppServer: HtmlPdfResult
Note over AppServer,HtmlPdfClient: byte[] success returns the raw PDF body (application/pdf), any other outcome returns ErrorInfo/HtmlPdfResult as JSON
AppServer->>HtmlPdfClient: HTTP response
HtmlPdfClient->>AppClient: HtmlPdfResult
using HtmlPdfPlus;
...
Host.CreateDefaultBuilder(args).ConfigureServices((hostContext, services) =>
{
services.AddHttpClient("HtmlPdfServer", httpClient =>
{
httpClient.BaseAddress = new Uri("https://localhost:7212/GeneratePdf");
});
});
...
//client http to endpoint
var clienthttp = HostApp!.Services
.GetRequiredService<IHttpClientFactory>()
.CreateClient("HtmlPdfServer");
//create client instance and send to HtmlPdfPlus server endpoint
var pdfresult = await HtmlPdfClient
.Create("HtmlPdfPlusClient")
.PageConfig((cfg) =>
{
cfg.Margins(10)
.Footer("'<span style=\"text-align: center;width: 100%;font-size: 10px\"> <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></span>")
.Header("'<span style=\"text-align: center;width: 100%;font-size: 10px\" class=\"title\"></span>")
.Orientation(PageOrientation.Landscape)
.DisplayHeaderFooter(true);
})
.Logger(HostApp.Services.GetService<ILogger<Program>>())
.FromHtml(HtmlSample())
.Timeout(5000)
.Run(clienthttp, applifetime.ApplicationStopping);
//performs writing to file after performing conversion
if (pdfresult.IsSuccess)
{
await File.WriteAllBytesAsync("html2pdfsample.pdf", pdfresult.OutputData!);
}
else
{
//show error via pdfresult.Error
}using HtmlPdfPlus;
...
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddHtmlPdfService((cfg) =>
{
cfg.Logger(LogLevel.Debug, "MyPDFServer");
});
var app = builder.Build();
app.MapOpenApi();
...
// The request/response contract (raw PDF on success, structured ErrorInfo on failure) comes
// straight from the library, so the OpenAPI document generated above actually describes it.
app.MapHtmlPdfEndpoints("/GeneratePdf");sequenceDiagram
participant AppClient as App Client
participant HtmlPdfClient
participant Submit as Func.Submit (custom transport)
participant AppServer as App Server
participant HtmlPdfServer
HtmlPdfServer->>AppServer: AddHtmlPdfService
AppServer-->>AppServer: Warmup HtmlPdfService
Note over AppClient,HtmlPdfClient: Minify, Compress and Logging can be disabled (via DisableOptionsHtmlToPdf)
AppClient->>HtmlPdfClient: FromHtml / FromRazor / FromUrl
HtmlPdfClient-->>HtmlPdfClient: Minify HTML (and execute Razor engine, if FromRazor)
AppClient->>HtmlPdfClient: PageConfig / Timeout
AppClient->>HtmlPdfClient: Run(Submit, optional input param)
HtmlPdfClient-->>HtmlPdfClient: Build RequestHtmlPdf, gzip it
HtmlPdfClient->>Submit: Execute Submit(bytes)
Submit->>AppServer: caller-defined transport (TCP, queue, gRPC, ...)
AppServer->>HtmlPdfServer: BeforePDF hook (optional)
AppServer->>HtmlPdfServer: AfterPDF hook (optional)
AppServer->>HtmlPdfServer: Run
HtmlPdfServer-->>HtmlPdfServer: Decompress to RequestHtmlPdf
HtmlPdfServer-->>HtmlPdfServer: Exec BeforePDF(input param)
HtmlPdfServer-->>HtmlPdfServer: Generate PDF
HtmlPdfServer-->>HtmlPdfServer: Exec AfterPDF(input param, transform output)
HtmlPdfServer->>AppServer: HtmlPdfResult<TOut>
AppServer->>Submit: caller-defined transport response
Submit-->>HtmlPdfClient: HtmlPdfResult<TOut>
HtmlPdfClient->>AppClient: HtmlPdfResult<TOut>
Unlike the HTTP path above, the wire format between Submit and App Server is entirely up to the caller's own Submit delegate - the library only hands it request bytes and expects an HtmlPdfResult<TOut> back, so there is no built-in compress/decompress step to describe on the response side (see ClientSendTcp for a working example over raw TCP).
using HtmlPdfPlus;
// Generic suggestion for writing a file to a cloud like gcp/azure
// Suggested return would be the full path "repo/filename"
var paramTosave = new DataSavePDF("Filename.pdf","MyRepo","MyConnectionstring");
var pdfresult = await HtmlPdfClient.Create("HtmlPdfPlusClient")
.PageConfig((cfg) =>
{
cfg.Margins(10);
})
.Logger(HostApp.Services.GetService<ILogger<Program>>())
.FromRazor(TemplateRazor(), order1)
.Timeout(50000)
.Run<DataSavePDF,string>(SendToServer,paramTosave, applifetime.ApplicationStopping);
//Shwo result
if (pdfresult.IsSuccess)
{
Console.WriteLine($"File PDF generate at {pdfresult.OutputData}");
}
else
{
Console.WriteLine($"HtmlPdfClient error: {pdfresult.Error!}");
}
private static async Task<HtmlPdfResult<string>> SendToServer(byte[] requestdata, CancellationToken token)
{
//send requestdata to server and return result
}using HtmlPdfPlus;
...
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHtmlPdfService<DataSavePDF,string>((cfg) =>
{
cfg.Logger(LogLevel.Debug, "MyPDFServer");
});
...
var PDFserver = HostApp.Services.GetHtmlPdfService();
var result = await PDFserver
.ScopeRequest(data)
.BeforePDF( (html,inputparam, _) =>
{
if (inputparam is null)
{
return Task.FromResult(html);
}
//performs replacement token substitution in the HTML source before performing the conversion
var aux = html.Replace("[{FileName}]", inputparam.Filename);
return Task.FromResult(aux);
})
.AfterPDF( (pdfbyte, inputparam, token) =>
{
if (inputparam is null)
{
return Task.FromResult(string.Empty);
}
//TODO : performs writing to file after performing conversion
return Task.FromResult(inputparam.Filename);
})
.Run(token);
//send result to clientsequenceDiagram
participant AppServer as App Server
participant HtmlPdfServer
HtmlPdfServer->>AppServer: AddHtmlPdfService
AppServer-->>AppServer: Warmup HtmlPdfService
Note over AppServer,HtmlPdfServer: Minify and Logging can be disabled (via DisableFeatures on the builder) - there is no network hop here, so there is nothing to compress/decompress
AppServer->>HtmlPdfServer: FromHtml / FromRazor / FromUrl
HtmlPdfServer-->>HtmlPdfServer: Minify HTML (and execute Razor engine, if FromRazor)
AppServer->>HtmlPdfServer: Input param / Timeout / PageConfig (all optional)
AppServer->>HtmlPdfServer: BeforePDF / AfterPDF hooks (optional)
AppServer->>HtmlPdfServer: Run
HtmlPdfServer-->>HtmlPdfServer: Exec BeforePDF(input param)
HtmlPdfServer-->>HtmlPdfServer: Generate PDF
HtmlPdfServer-->>HtmlPdfServer: Exec AfterPDF(input param, transform output)
HtmlPdfServer->>AppServer: HtmlPdfResult
using HtmlPdfPlus;
...
Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddHtmlPdfService((cfg) =>
{
cfg.Logger(LogLevel.Debug, "MyPDFServer")
.DefaultConfig((page) =>
{
page.DisplayHeaderFooter(true)
.Margins(10, 10, 10, 10);
});
});
});
...
//instance of Html to Pdf Engine and Warmup HtmlPdfServerPlus
var PDFserver = HostApp!.Services.GetHtmlPdfService();
//Performs conversion and custom operations on the server
var pdfresult = await PDFserver
.ScopeData()
.FromHtml(HtmlSample(),5000)
.Run(applifetime.ApplicationStopping);
//performs writing to file after performing conversion
if (pdfresult.IsSuccess)
{
await File.WriteAllBytesAsync( "html2pdf.pdf", pdfresult.OutputData!);
}
else
{
//show error via pdfresult.Error
}The use of Playwright works very well for local testing on Windows machines following the standard installation instructions. For containerization scenarios, image size is worth a closer look: the working Dockerfile keeps only the one browser variant the code actually launches, cutting the image from 763MB to 370MB. See the Docker guide for the full before/after numbers, what changed, and why.
Each sample is scoped to one clear lesson. For more examples, please refer to the Samples directory:
- Server Only - no client, no network, HTML/URL converted in the same process
- OnlyAtServer/CustomHooks -
BeforePDF/AfterPDFhooks (token substitution, custom file output) and typedTIn/TOut - OnlyAtServer/QuickStart - the minimal default setup:
byte[]output, bothFromHtmlandFromUrlrender modes, no hooks
- OnlyAtServer/CustomHooks -
- Client-Server (HTTP) - client and server as separate processes over
HttpClient- ClientSendHttp - one client walking through all three content sources (
FromHtml,FromRazorwith a typed model,FromUrl) against the same generic server - WebHtmlToPdf.GenericServer - the server side:
MapHtmlPdfEndpoints(),AddOpenApi(), and the health endpoints, in as few lines as the library allows
- ClientSendHttp - one client walking through all three content sources (
- Client-Server Custom - customizing what the server returns instead of raw PDF bytes
- ClientCustomSendHttp - typed input/output (
DataSavePDF) standing in for "save the PDF to cloud storage, return its path" - WebHtmlToPdf.CustomSaveFileServer - the matching server: token substitution via
BeforePDF, thenAfterPDFturning the PDF bytes into a saved-file result
- ClientCustomSendHttp - typed input/output (
- Client-Server TCP - swapping the transport for a non-HTTP one (⚠ demonstrates shipping flexibility only, not production-ready)
- ClientSendTcp - the client's
Run(Func<byte[],...>)overload driving a raw TCP round-trip via SuperSimpleTcp - TcpServerHtmlToPdf.GenericServer - the matching TCP listener, unpacking a request and writing the result back over the same connection
- ClientSendTcp - the client's
- Cross-language - consuming the server from outside .NET
- JavaClientSendHttp - a single dependency-free
.javafile (JDK's ownHttpClient+GZIPOutputStream, no build tool) showing the exact wire format any non-.NET client must produce: JSON → gzip → POST asapplication/octet-stream- see the file header for thejavac/javacommands and which server profile to run
- JavaClientSendHttp - a single dependency-free
- Production readiness - the resilience/observability features covered in the resilience guide, each with a deliberately tiny page pool (
PagesBuffer(1)) so the behavior being demonstrated is easy to reproduce on any machine instead of depending on real render timing- RetryAfterBackpressure - firing concurrent requests, detecting
ErrorCode.PoolExhausted, and backing off usingErrorInfo.RetryAfterSecondsbefore retrying - MetricsObserver - attaching a
MeterListener(no OTel/exporter package needed) to observe the instruments a healthy run produces (htmlpdfplus.pool.available_pages,.request.duration,.errors,.pool.acquire_wait), including how a validation failure incrementshtmlpdfplus.errorswithout touchinghtmlpdfplus.request.duration-htmlpdfplus.browser.restartsonly appears after an unexpected disconnect, so it stays silent here
- RetryAfterBackpressure - firing concurrent requests, detecting
/healthzand/readyzare mapped by the two web server samples above (viaMapHtmlPdfHealthEndpoints()), but no sample calls them from a client or shows what a real orchestrator would do with the response. See the resilience guide for how they work until a dedicated sample exists.
Package layout (Client/Server/Shared), the one distinction that decides whether a request gets compressed (ScopeData() vs ScopeRequest(bytes)), the page pool and browser lifecycle, and where each piece of configuration is supposed to live: see the Architecture guide.
Task-oriented recipes, one page per use case - rendering content, running client and server as separate processes, customizing the pipeline, handling failures: see the How-To index.
The library has a main namespace HtmlPdfPlus for client and server, and all methods use a fluent interface. The full generated reference for every public type is in the Docs directory.
HtmlPdfPlus documents its significant architectural and design decisions as Architecture Decision Records (ADR), following the adrplus convention. Each record captures the context, the decision, the alternatives considered, and the consequences - so the reasoning behind the library's design stays traceable over time.
👉 See the ADR index for the full list of decisions.
Deeper operational guides live under docs/guide: resilience and observability, Docker. Version history is in CHANGELOG.md.
This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community. For more information see the Code of Conduct.
Please read Contributing for details on our code of conduct, and the process for submitting pull requests to us.
API documentation generated by
- XmlDocMarkdown, Copyright (c) 2024 Ed Ball
- See an unrefined customization to contain header and other adjustments in project XmlDocMarkdownGenerator
This project is licensed under the MIT License - see the License file for details.
Disclaimer : HtmlPdfPlus includes PackageReference from other software released under other licences:
- NUglify released under the BSD-Clause 2 license.
- The original Microsoft Ajax Minifier was released under the Apache 2.0 license.
Common questions, answered briefly: see the FAQ page.