Mert Özen Further With Every Line
Backend Email, PDF, Excel and Files in .NET — #06

Generating PDFs Part 2: HTML to PDF with Playwright, Reading and Merging PDFs and Which Approach When

Mert Özen Nov 15, 2026 12 min 8 views
Generating PDFs Part 2: HTML to PDF with Playwright, Reading and Merging PDFs and Which Approach When

Turn a document the designer built in HTML and CSS into a PDF with Playwright's headless Chromium: share the browser, open a context per document, turn JavaScript off and allow only your own fonts. Bookstore's institutional quote takes 38 milliseconds per document; merging and reading use PdfPig, because PDFsharp moved to the AGPL with 6.5.0.

Version note: The code in this post was built and run with .NET 10, Microsoft.Playwright 1.64.1 (Chromium 156), PdfPig 0.1.16 and QuestPDF 2026.9.2. PDFsharp license information was checked on NuGet and on the official license FAQ page on 15 November 2026.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, sends quotes to institutional customers. The designer built the quote document in HTML and CSS; our job is to turn it into a PDF for the library of Hilltop Primary School. Three questions:

First: we put the designer's CSS file into the Razor template with <style>@Css</style>. What does the PDF look like?

Second: how much per-document difference is there between starting a new headless Chromium for every PDF and sharing one browser?

Third: to merge two PDFs we added the latest stable release of PDFsharp, the library people have used for years. What does the license check from the first post say?

The answers come from real runs. In the first case the PDF is produced without an error, but none of the design rules apply and the text is set in Times. In the second the difference is about six times: 250 versus 38 milliseconds per document. In the third the check turns red: with PDFsharp 6.5.0 the license moved from MIT to AGPL.

The Designer's HTML: a Razor Component and Print CSS

In the fifth post we drew the invoice in code with QuestPDF. The quote is different: the designer owns the layout, it changes often and the designer does not write C#. So the markup lives in a Razor component and the styles in a separate CSS file. Like the email templates in the third post, we render the component to a string with HtmlRenderer:

<table>
    <thead>
        <tr><th>ISBN</th><th>Title</th><th class="num">Qty</th><th class="num">Unit price</th><th class="num">Amount</th></tr>
    </thead>
    <tbody>
        @foreach (QuoteLine line in Model.Lines)
        {
            <tr>
                <td>@line.Isbn</td>
                <td>@line.Title</td>
                <td class="num">@line.Quantity.ToString(Tr)</td>
                <td class="num">@line.UnitPrice.ToString("C", Tr)</td>
                <td class="num">@line.Amount.ToString("C", Tr)</td>
            </tr>
        }
    </tbody>
</table>

Print CSS decides the page layout. The important rules in the designer's file:

/* Page size and margins come from the CSS (PreferCSSPageSize) */
@page { size: A4; margin: 22mm 16mm 20mm 16mm; }
/* The table header repeats on every page the table continues on */
thead { display: table-header-group; }
/* A row or the totals block is never split across two pages */
tr { break-inside: avoid; }
.totals { margin-left: auto; width: 45%; margin-top: 14px; break-inside: avoid; }

In the first attempt we inserted the CSS with <style>@Css</style>. Razor HTML-encodes every value, and that includes the content of a <style> element. The generated HTML contained font-family: &quot;Lato&quot;;. The browser does not decode entities inside <style>, so every rule with a quote in it was invalid and Chromium fell back to its default font without a single warning. We listed the fonts in the PDF with PdfPig:

fonts: AAAAAA+Times-Bold, BAAAAA+Times-Bold, CAAAAA+Times-Roman, DAAAAA+Times-Bold, EAAAAA+Helvetica, FAAAAA+Times-Roman

The CSS is our own embedded file; it does not come from a user. Writing it unencoded with @((MarkupString)Css) is therefore correct, while every value from the model is still encoded. That distinction matters again in the section on user content.

Playwright for .NET drives Chromium through a driver process. Producing the PDF is a single call:

IPage page = await context.NewPageAsync();
page.SetDefaultTimeout((float)options.Value.RenderTimeout.TotalMilliseconds);
await page.SetContentAsync(html);
return await page.PdfAsync(new()
{
    Format = "A4",
    PrintBackground = true,
    PreferCSSPageSize = true,
    DisplayHeaderFooter = true,
    HeaderTemplate = "<span></span>",
    FooterTemplate = $"""
        <div style="font-family:sans-serif;font-size:8pt;color:#64748b;width:100%;padding:0 16mm;display:flex;justify-content:space-between">
            <span>{System.Net.WebUtility.HtmlEncode(footerTitle)}</span>
            <span>Page <span class="pageNumber"></span> / <span class="totalPages"></span></span>
        </div>
        """,
});

Without PrintBackground background colors are not printed. Chromium fills the pageNumber and totalPages classes in the footer template. The template is rendered separately, so it needs its own font size, otherwise the text comes out too small to read. A quote with 60 lines:

$ dotnet run -- quote 60
info: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Chromium 156.0.8078.4 started
out/quote.pdf 63 KB in 309 ms
page 1: header row=True rows=25 total=False footer='Page 1 / 3'
page 2: header row=True rows=31 total=False footer='Page 2 / 3'
page 3: header row=True rows=4 total=True footer='Page 3 / 3'

The table header is on every page, 25 + 31 + 4 = 60 rows, and the totals block sits unbroken on the last page.

Every Playwright release works with one specific browser build. The NuGet package places .playwright/package/browsers.json in the output folder, and for 1.64.1 it says:

{
  "name": "chromium-headless-shell",
  "revision": "1248",
  "installByDefault": true,
  "browserVersion": "156.0.8078.4",
  "title": "Chrome Headless Shell"
}

With only the browser downloaded by a newer Playwright (revision 1254) on the machine, the same code does not start:

Unhandled exception. Microsoft.Playwright.PlaywrightException: Executable doesn't exist at <browsers>/chromium_headless_shell-1248/.../chrome-headless-shell
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated.       ║
║ Please run the following command to download new browsers: ║
║                                                            ║
║     pwsh bin/Debug/netX/playwright.ps1 install             ║
║                                                            ║
║ <3 Playwright Team                                         ║
╚════════════════════════════════════════════════════════════╝

The fix is the script that ships with the package version: pwsh bin/Debug/net10.0/playwright.ps1 install chromium. When you upgrade the package you have to upgrade the browser too; running the same script in CI and in the Dockerfile keeps the versions in step. Do not use the Google Chrome installed on the machine (Channel = "chrome") on the server side: you do not control its version, and PDFs can change when it updates.

In containers the cost is high. Microsoft's mcr.microsoft.com/playwright/dotnet:v1.64.1-noble image is 1,397 MB compressed for amd64, while mcr.microsoft.com/dotnet/aspnet:10.0 is 98 MB (we summed the layer sizes in both manifests). The Playwright image carries the SDK and all three browsers. You can shrink this by installing only Chromium and its Linux dependencies in your own image (playwright.ps1 install --with-deps chromium). Still, a separate "document worker" image next to the API image is often the better decision.

Fonts: Chromium Silently Falls Back

QuestPDF threw an exception for a missing font. Chromium does not: if the font in the CSS cannot be found, it picks a font from the system and carries on. That means the same HTML prints with different fonts on the development machine, in CI and in the container. To ship the font with the application we embedded two files of Lato from the QuestPDF package into the assembly. Lato is distributed under SIL's Open Font License, which allows shipping the font with an application. The CSS requests the font from an address that does not exist:

@font-face { font-family: "Lato"; font-weight: 400; src: url("https://assets.bookstore.invalid/fonts/Lato-Regular.ttf"); }
@font-face { font-family: "Lato"; font-weight: 700; src: url("https://assets.bookstore.invalid/fonts/Lato-Bold.ttf"); }
body { font-family: "Lato", sans-serif; font-size: 10.5pt; color: #1f2937; margin: 0; }

The renderer intercepts those requests and answers them with the file from memory; the .invalid domain never resolves by design:

await context.RouteAsync("**/*", async route =>
{
    // Allow-list: our own fonts, answered from memory; everything else is aborted
    if (route.Request.Url.StartsWith(AssetHost + "fonts/", StringComparison.Ordinal)
        && Fonts.TryGetValue(route.Request.Url[(AssetHost.Length + 6)..], out byte[]? font))
    {
        await route.FulfillAsync(new() { BodyBytes = font, ContentType = "font/ttf" });
        return;
    }

    logger.LogWarning("Blocked request from document: {Method} {Url}", route.Request.Method, route.Request.Url);
    await route.AbortAsync();
});

The result:

fonts: AAAAAA+Lato-Bold, BAAAAA+Lato-Regular, CAAAAA+Times-Roman

The body is Lato now. The remaining Times-Roman comes from the footer. Header and footer templates are rendered apart from the page and cannot use the web font the page loaded; even though we wrote sans-serif, our environment picked Times. If the footer must use the brand font, put it inside the page as a position: fixed element that repeats on every page.

The Browser's Lifetime

Starting Chromium is expensive: a process starts, a profile is prepared, the driver connects. We produced 20 PDFs from the same small HTML each way:

$ dotnet run -- lifetime 20
new browser per document: 20 PDFs in 5000 ms (250 ms each)
info: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Chromium 156.0.8078.4 started
shared browser, new context: 20 PDFs in 764 ms (38 ms each)
shared browser, 20 in parallel (max 4 pages): 307 ms

That is why the renderer is a singleton. The browser starts with the first document; each document opens and closes its own BrowserContext. A context is like a lightweight profile with its own cookies and storage, so the state of one quote never leaks into another. A SemaphoreSlim limits the number of open pages, because every page holds its own rendering memory in the browser:

private async Task<IBrowser> GetBrowserAsync()
{
    if (_browser is { IsConnected: true })
    {
        return _browser;
    }

    await _startLock.WaitAsync();
    try
    {
        if (_browser is { IsConnected: true })
        {
            return _browser;
        }

        // First call, or the browser process died: start (again)
        _playwright ??= await Playwright.CreateAsync();
        _browser = await _playwright.Chromium.LaunchAsync(new() { Headless = true });
        logger.LogInformation("Chromium {Version} started", _browser.Version);
        return _browser;
    }
    finally
    {
        _startLock.Release();
    }
}

The IsConnected check restarts the browser on the next document if the process crashes. The renderer is IAsyncDisposable and closes the browser when the application stops; the DI container disposes the singleton, so you write nothing extra. In the API the first quote download took 1.4 seconds including the browser start, the second 58 milliseconds.

User Content, SSRF and Local Files

A browser that turns HTML into PDF is a complete web client running inside your server. If content in the template can make it send requests, you have opened the door to SSRF. Razor encodes by default, so the danger starts the day someone uses MarkupString to show the formatted notes of Hakan, the sales rep. In the experiment we imitated an internal service with a small HttpListener on 127.0.0.1:52566 and put this into the note:

<iframe src="http://127.0.0.1:52566/admin/export" width="500" height="40"></iframe>
<iframe src="secret.txt" width="500" height="40"></iframe>
<script>document.body.append('script ran')</script>

First with our renderer, then with the common shortcut of writing the HTML to a file and opening it with a file:// address:

$ dotnet run -- untrusted
info: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Chromium 156.0.8078.4 started
warn: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Blocked request from document: GET http://127.0.0.1:52566/admin/export
renderer: Quote Q-2026-0117 end of note Quote Page 1 / 1
internal service got: GET http://127.0.0.1:52566/admin/export
file:// page: Quote Q-2026-0117 contents of secret.txt script ran end of note

The renderer aborted the request to the internal service and logged it. JavaScript was off, so the script did not run. The page lives on about:blank, so Chromium did not open the relative secret.txt path either. With the shortcut all three happened: the internal service received the request, the content of the neighboring file went into the PDF and the script ran. In the cloud that internal service could be the metadata endpoint that hands out the server's credentials. The rules: pass the HTML with SetContentAsync, turn JavaScript off, answer only allowed addresses through routing, and sanitize user HTML first if you have to accept it. Security on the upload and file side is the subject of the ASP.NET Core Security series.

Reading and Merging PDFs: PdfPig and the PDFsharp License

The quote package has two parts: the quote we generate and the finished "Terms and Conditions" PDF the legal team sends. PdfPig, which we used for reading in the earlier posts, can merge as well:

// Reading and merging existing PDFs with PdfPig (Apache 2.0)
public static class PdfFiles
{
    public static byte[] Merge(params byte[][] documents) => PdfMerger.Merge(documents);

    public static IReadOnlyList<string> PageTexts(byte[] pdf)
    {
        using PdfDocument document = PdfDocument.Open(pdf);
        return [.. document.GetPages().Select(page => string.Join(" ", page.GetWords().Select(w => w.Text)))];
    }
}
$ dotnet run -- merge
quote=1 page(s) + terms=1 page(s) -> 2 pages, 55 KB in 167 ms
page 1: Quote Q-2026-0117 Issued 15 Kasım 2026 Bookstore Valid until 15 Aralık
page 2: Terms and Conditions for Institutional Orders 1. Orders of 20 or more

Extracting text held a surprise. The word "fixed" in the quote's note came back from the PDF as "fixed", with the "fi" ligature as a single character. Chromium writes ligatures as single glyphs. That broke Assert.Contains("Prices are fixed", text); the test first converts the text to Unicode's compatibility form with Normalize(NormalizationForm.FormKC).

Why not PDFsharp? Our plan was the MIT-licensed PDFsharp for merging. According to the NuGet history, 6.2.4 (6 January 2026) was published under MIT, while 6.5.0, published on 14 October 2026, is AGPL-3.0-only. The official license FAQ now says PDFsharp and MigraDoc are dual-licensed under the AGPL and a commercial license. According to the version history, from 6.5.0 on your code has to choose the license type (PdfSharpLicense.SetAgplLicense() or a commercial key). We added 6.5.0 to a probe project and ran the check from the first post (columns shortened):

| Package  | Version | License Expression | Error                                                           |
| PdfPig   | 0.1.16  | Apache-2.0         |                                                                 |
| PDFsharp | 6.5.0   | AGPL-3.0-only      | License "AGPL-3.0-only" not found in list of supported licenses |

The AGPL can require an application that uses the library and serves users over a network to offer them its source code; for a closed-source web API that is a serious decision, and not one a developer makes alone. Pinning 6.2.4 is possible, but then you give up the fixes in later releases. In Bookstore we merge with PdfPig, which was already a dependency, and no new license decision was needed.

QuestPDF or HTML?

TopicQuestPDF (code)HTML + Chromium
Who changes the layoutDeveloperDesigner (HTML and CSS)
Time per document~2 ms (invoice)~38 ms (shared browser)
Missing fontExceptionSilently another font
DeploymentNuGet package and native binaryBrowser and Linux dependencies, large image
Security surfaceOnly your own codeA complete web client (SSRF, file://)
LicenseCommunity License, revenue thresholdPlaywright MIT, no extra terms
Fits bestInvoices, reports, high volumeFew documents whose design changes often

In Bookstore both stay: the thousands of invoices issued every day in QuestPDF, the few dozen institutional quotes a month in HTML.

In Production

Keep PDF generation out of the API process. The Chromium process shares memory and CPU with the API; a burst of quotes can ruin the API's response times. Hand the quote to a job and write the result to IFileStore.

Set timeouts. SetDefaultTimeout stops a page from hanging, and the job that calls the renderer needs an upper limit too.

Watch the browser's memory. A long-running Chromium process can grow over time. Restarting the browser after a fixed number of documents is a simple and effective measure.

Upgrade the browser together with the package. The Playwright package and the browser build should be installed by the same script; a PDF test running in CI catches a mismatch.

Test the document. Our test checks that the 60-line quote has three pages, that every page has the table header and that the total is on the last page. On a machine without the browser it skips itself.

Common Pitfalls

Starting a new browser for every request. That means 250 milliseconds per document and dozens of Chromium processes under concurrent load.

Putting user content into the HTML template without sanitizing it, worse still opening the HTML with file://. The result is SSRF and local file reads.

Putting the CSS into <style> with Razor encoding. No error, no design.

Leaving the font to the system. The same document prints with another font in every environment and nobody sees an error.

Checking a dependency's license only when you first add it. As PDFsharp shows, the license can change in a minor release; the check has to run on every restore.

A Small Experiment

★☆☆ Delete the thead { display: table-header-group; } line from quote.css and run quote 60 again. What happens to header row on the second and third pages?

★★☆ Fix the Times footer: write the footer as a position: fixed element inside the page instead of using DisplayHeaderFooter, and confirm with PdfPig that the font list contains only Lato. Can you still print page numbers this way?

★★★ Add a counter to ChromiumPdfRenderer that closes and restarts the browser after a fixed number of documents. How do you wait for the documents being rendered at that moment?

This is where we finish with PDFs. In the next post we move to book covers: we resize the uploaded image with SkiaSharp and ImageSharp, strip the EXIF data, set a pixel limit against decompression bombs, and look at why ImageSharp 4 stops a Release build without a license key.