Usage Examples

This page collects practical, copy-pasteable examples for common PeachPDF scenarios, including hosting it behind an HTTP endpoint. For the full list of supported HTML elements and CSS properties, see HTML & CSS Support; for how the rendering pipeline works internally, see Architecture.

All examples assume:

using PeachPDF;
using PeachPDF.Network;

Contents

Thread safety

A PdfGenerator instance is not thread-safe: don’t call methods on the same instance concurrently from multiple threads, and don’t reuse one instance across overlapping renders.

Using a separate PdfGenerator instance per thread — one per incoming web request, one per item in a parallel batch — is safe, and is the intended way to generate PDFs concurrently. Every one of the ASP.NET Core, Minimal API, and Azure Functions examples below already follows this pattern by constructing a new PdfGenerator inside the request handler; that’s not incidental, it’s the correct usage.

// Safe: each thread/task gets its own PdfGenerator.
var results = await Task.WhenAll(htmlDocuments.Select(async html =>
{
    var generator = new PdfGenerator();
    var document = await generator.GeneratePdf(html, pdfConfig);

    var stream = new MemoryStream();
    document.Save(stream);
    return stream;
}));
// Unsafe: sharing one PdfGenerator across concurrent renders.
var generator = new PdfGenerator();

var results = await Task.WhenAll(htmlDocuments.Select(async html =>
{
    var document = await generator.GeneratePdf(html, pdfConfig); // do not do this
    var stream = new MemoryStream();
    document.Save(stream);
    return stream;
}));

Every custom font registered on a PdfGenerator — via @font-face or AddFontFromStream — is owned exclusively by that instance, so two instances that register different font bytes under the same font family name (a realistic multi-tenant scenario) never collide. Pure system-font data (fonts already installed on the machine) is the one thing genuinely shared across instances internally, since it’s read-only and safe to share — this is what actually supports many instances being used concurrently, one per thread.

Rendering HTML from a local string

The simplest case: render an in-memory HTML string to a PDF stream. With no NetworkLoader configured, data: URIs are always resolved, and relative src/href/url() references are loaded from the local file system, resolved against the current working directory (the same way a browser resolves them against the document’s location). A <base href> element overrides that base.

var html = "<html><body><h1>Hello, PeachPDF</h1></body></html>";

var pdfConfig = new PdfGenerateConfig
{
    PageSize = PageSize.Letter,
    PageOrientation = PageOrientation.Portrait
};

var generator = new PdfGenerator();

var stream = new MemoryStream();
var document = await generator.GeneratePdf(html, pdfConfig);
document.Save(stream);

Rendering a local HTML file

FileUriNetworkLoader renders a local .html file and every resource it references (stylesheets, images, fonts) from disk. It sets the base URL to the file’s own location — exactly like opening the file in a browser — so relative references resolve against the file’s directory. Pass null as the HTML argument to load the root document from the file.

var pdfConfig = new PdfGenerateConfig
{
    PageSize = PageSize.Letter,
    PageOrientation = PageOrientation.Portrait,
    NetworkLoader = new FileUriNetworkLoader("report/index.html")
};

var generator = new PdfGenerator();

var stream = new MemoryStream();

// Passing null to GeneratePdf loads the HTML from the configured NetworkLoader instead
var document = await generator.GeneratePdf(null, pdfConfig);
document.Save(stream);

file: URIs are always resolved from disk regardless of which loader is configured (the same way data: URIs always are), so a document loaded over HTTP or from an MHTML archive can still reference an absolute file: resource.

How a local file’s content type is determined

Each local file’s Content-Type is resolved from the operating system’s own MIME mechanism by default — the shell file-association database on Windows, the Uniform Type Identifiers API on macOS/iOS, and /etc/mime.types on Linux — falling back to a built-in set when the OS provides nothing: HTML, CSS, SVG, common raster image extensions (PNG, JPEG, BMP, GIF, WebP, AVIF, TGA, PSD, HDR — this only resolves a MIME type string for the extension, independent of whether PeachPDF can actually decode that format; it decodes PNG/JPEG/BMP/GIF/WebP/AVIF/TIFF, not TGA/PSD/HDR — TIFF decodes even though tif/tiff have no entry in this built-in list, since images are recognized by their bytes rather than their resolved content type, as described below), and the TTF/OTF/WOFF/WOFF2 font formats. Anything else resolves to application/octet-stream.

If you reference a local file whose extension falls outside that built-in set and your OS has no MIME association for it, register the type with the OS — for example, set the shell Content Type association for the extension on Windows, or add an entry to /etc/mime.types (or ~/.local/share/mime) on Linux — so PeachPDF can resolve it. This matters only where the content type is actually enforced: as in a browser, PeachPDF accepts a linked stylesheet only when its type is text/css, whereas images and fonts are recognized by their bytes and load regardless of content type.

Rendering an MHTML file

Self-contained MHTML archives (what Chrome calls “single page documents”) bundle the HTML plus every referenced image, stylesheet, and font into one file. MimeKitNetworkLoader resolves all of those references from the archive, so nothing is fetched from disk or the network.

using var mhtmlStream = File.OpenRead("example.mhtml");

var pdfConfig = new PdfGenerateConfig
{
    PageSize = PageSize.Letter,
    PageOrientation = PageOrientation.Portrait,
    NetworkLoader = new MimeKitNetworkLoader(mhtmlStream)
};

var generator = new PdfGenerator();

var stream = new MemoryStream();

// Passing null to GeneratePdf loads the HTML from the configured NetworkLoader instead
var document = await generator.GeneratePdf(null, pdfConfig);
document.Save(stream);

Fetching HTML over HTTP

HttpClientNetworkLoader fetches the root document and every referenced resource (stylesheets, images) through a caller-supplied HttpClient, so you control headers, authentication, proxies, and timeouts.

var httpClient = new HttpClient();

var pdfConfig = new PdfGenerateConfig
{
    PageSize = PageSize.Letter,
    PageOrientation = PageOrientation.Portrait,
    NetworkLoader = new HttpClientNetworkLoader(httpClient, new Uri("https://www.example.com"))
};

var generator = new PdfGenerator();

var stream = new MemoryStream();

// Passing null to GeneratePdf loads the HTML from the configured NetworkLoader instead
var document = await generator.GeneratePdf(null, pdfConfig);
document.Save(stream);

Relative references resolve against the configured NetworkLoader’s BaseUri (as above), or a <base href> element if the document has one. With no loader configured, they resolve against the current working directory and load from the local file system (see Rendering a local HTML file to make that base an explicit file location instead, or Disabling local file access to switch that behaviour off).

Disabling local file access

By default a document can reach the local file system: a file: reference is read from disk, and a relative reference resolves against the process’s working directory. That’s what makes rendering a local HTML file work like opening it in a browser — but it is rarely what you want when the HTML comes from somewhere you don’t control.

Set AllowLocalFileAccess = false to refuse it:

var config = new PdfGenerateConfig
{
    PageSize = PageSize.Letter,
    AllowLocalFileAccess = false
};

var document = await new PdfGenerator().GeneratePdf(untrustedHtml, config);

Two things change. Every file: resource request — <img>, <link rel="stylesheet">, CSS url(), an SVG <image href>, an @font-face src — resolves to “not found”. And a loader that supplies no BaseUri of its own (DataUriNetworkLoader and MimeKitNetworkLoader are both in that category) no longer inherits the working directory as a base, so a relative reference goes unresolved rather than being turned into a file: URI. A deny wins even over an explicitly configured FileUriNetworkLoader.

The document you pass in is unaffected: it’s an input, not something the document asked for. The same goes for a file a FileUriNetworkLoader was explicitly constructed with — that root document is still read; only the resources it references are refused.

Rendering in the browser (Blazor WebAssembly)

PeachPDF is pure managed code, so it runs unmodified in a browser under WebAssembly — the whole pipeline, from HTML parsing through font subsetting to PDF output, with no server round-trip. PeachPDF’s own demo does exactly this; its source is in src/PeachPDF.Demo.BlazorWasm/.

Three things differ from a server or desktop host:

You must supply the fonts. A browser exposes no system fonts to WebAssembly, so PeachPDF discovers none. Register your own before rendering, or nothing can be measured, let alone drawn:

var generator = new PdfGenerator();

foreach (var file in new[] { "LiberationSans-Regular.woff", "LiberationSerif-Regular.woff" })
{
    using var stream = new MemoryStream(await httpClient.GetByteArrayAsync($"fonts/{file}"));
    await generator.AddFontFromStream(stream);
}

// The generic families were resolved before any of these existed, so re-point them.
generator.AddFontFamilyMapping("sans-serif", "Liberation Sans");
generator.AddFontFamilyMapping("serif", "Liberation Serif");

The default font on a browser host is Liberation Sans; register a family under that name and it is used directly. Register something else and PeachPDF adopts the first family you register as the default, so text still renders. Note that font-family mapping is consulted only when the requested family isn’t registered, and it is single-hop — every mapping has to name a real registered family.

Use WOFF or TrueType, not WOFF2. WOFF2 is Brotli-compressed and a browser/WebAssembly host has no Brotli decoder — System.IO.Compression.Brotli throws there. WOFF 1.0 uses deflate and works, at roughly 55% of the TrueType size. The same limitation makes hyphens: auto unavailable in the browser: PeachPDF’s hyphenation patterns are Brotli-compressed, so text lays out unhyphenated rather than failing.

Pin the culture. A Blazor WebAssembly app adopts the browser’s locale, and CSS is invariant by definition — a visitor whose browser is set to a comma-decimal locale would otherwise have lengths misparsed. Set <InvariantGlobalization>true</InvariantGlobalization> in the project file, which also drops the ICU data from the download.

Rendering is synchronous once it starts, and WebAssembly in the browser is single-threaded, so a long document will make the tab unresponsive for the duration. Say so in your UI.

Sharing a parsed CSS context across renders

If you’re rendering many documents against the same stylesheet — for example, a batch of invoices that all use one company template — parse the CSS once with PdfGenerator.ParseStyleSheet and reuse the resulting PeachPdfCssContent instead of re-parsing the same CSS for every document.

const string css = """
    body { font-family: Arial, sans-serif; }
    h1 { color: #2c3e50; }
    .total { font-weight: bold; }
    """;

var generator = new PdfGenerator();

// combineWithDefault: true (the default) merges this stylesheet on top of the
// W3 user-agent defaults; false replaces the defaults entirely.
var sharedCssData = await generator.ParseStyleSheet(css, combineWithDefault: true);

var pdfConfig = new PdfGenerateConfig
{
    PageSize = PageSize.Letter,
    PageOrientation = PageOrientation.Portrait
};

foreach (var invoiceHtml in invoiceHtmlDocuments)
{
    var document = await generator.GeneratePdf(invoiceHtml, pdfConfig, sharedCssData);

    using var fileStream = File.Create($"invoice-{Guid.NewGuid()}.pdf");
    document.Save(fileStream);
}

Reusing sharedCssData this way avoids re-parsing identical CSS on every iteration; the same PdfGenerator instance can also be reused across renders like this — sequentially, as in the loop above (font mappings and loaded fonts persist on it). See Thread safety if you’re parallelizing this loop across threads: use one PdfGenerator per thread rather than sharing this one.

Saving a PDF to a file

PeachPdfDocument.Save writes to any Stream, so saving directly to disk just means opening a file stream instead of a MemoryStream:

var html = "<html><body><h1>Hello, PeachPDF</h1></body></html>";
var pdfConfig = new PdfGenerateConfig { PageSize = PageSize.Letter };

var generator = new PdfGenerator();
var document = await generator.GeneratePdf(html, pdfConfig);

using var fileStream = File.Create("output.pdf");
document.Save(fileStream);

Fonts

For the full compatibility details of the font-related CSS properties themselves (font-family, font-weight, font-style, font-stretch, @font-face), see Color & Typography and CSS At-Rules in HTML & CSS Support.

Default Font

By default, PeachPDF uses Segoe UI on Windows. Segoe UI isn’t installed by default on other platforms, so PeachPDF picks a different platform-appropriate default there instead (see the generic-family table below — the same “verify installed, else fall back” logic applies). On a browser/WebAssembly host, where no system font is discoverable at all, the default is Liberation Sans; if you register some other family instead, the first one you register becomes the default, so text still renders (see Rendering in the browser). You can remap the default font (or any other family) to another one using

PdfGenerator generator = new();
generator.AddFontFamilyMapping("Segoe UI","sans-serif"); // or any other system installed font

Generic families and system-ui

serif, sans-serif, monospace, cursive, fantasy, and system-ui all resolve to a real installed font, matching actual Chromium behavior per platform rather than one invented cross-platform table:

Generic Windows macOS Android Linux
serif Times New Roman Times Noto Serif (delegated to fontconfig’s own serif alias)
sans-serif Arial Helvetica Roboto (delegated to fontconfig’s own sans-serif alias)
monospace Consolas Menlo Droid Sans Mono (delegated to fontconfig’s own monospace alias)
cursive Comic Sans MS Apple Chancery Dancing Script (delegated to fontconfig’s own cursive alias)
fantasy Impact Papyrus Dancing Script (delegated to fontconfig’s own fantasy alias)
system-ui Segoe UI (platform default font) (platform default font) (platform default font)

On Linux, PeachPDF delegates directly to the system’s own fontconfig library (libfontconfig.so.1) at startup — the managed equivalent of running fc-match <generic> — so the resolved family always matches whatever that distro’s own font configuration actually maps each generic to, rather than a name that might not be installed. system-ui on Windows is an exact match for Chromium’s own system-ui → Segoe UI resolution; on macOS/Linux/Android it’s a pragmatic approximation using the platform’s default font rather than true native system-UI-font detection (e.g. macOS’s actual system-ui is the private San Francisco font, not something cleanly resolvable via plain TTF/OTF file discovery).

Every mapping above — including a custom one set via AddFontFamilyMapping — is verified against the fonts actually installed on the running machine before use; if the target isn’t present, PeachPDF falls back to the platform’s default font instead of silently substituting whatever arbitrary font happened to be discovered first.

Font weight, style, and stretch matching

Requesting a font-weight/font-style/font-stretch PeachPDF can’t find an exact registered face for doesn’t just fall back to Regular:

See Color & Typography in HTML & CSS Support for per-property compatibility notes, including the per-character font matching model.

Adding custom fonts

The recommended way to install custom fonts is to install them into your operating system. PeachPDF picks up TrueType/OpenType fonts from the operating system’s own installed fonts:

You can also add a font at runtime by loading the font into a Stream, and then using the AddFontFromStream API:

PdfGenerator generator = new();
await generator.AddFontFromStream(fontStream); // Supports TrueType (TTF), CFF, WOFF, and WOFF2 formats

To restrict a stream-registered font to specific codepoints — the programmatic equivalent of an @font-face unicode-range descriptor — pass a list of RuneRanges. Characters outside the declared ranges resolve to another registered font (per-character font matching):

using System.Text;

PdfGenerator generator = new();
// Use this font only for Basic Latin; other characters fall back to another registered font.
await generator.AddFontFromStream(fontStream,
    [new RuneRange(new Rune(0x0000), new Rune(0x00FF))]);

Web fonts loaded via @font-face (url(), with a comma-separated fallback list, and local()) are also supported, including per-character selection via unicode-range — see @font-face in CSS At-Rules and Per-character font matching for the full descriptor support notes.

Supported font formats

We support TrueType, CFF, WOFF, and WOFF2 font formats.

Enabling tagged PDF (PDF/UA) output

PeachPDF can optionally produce a tagged PDF — one with a logical structure tree (/StructTreeRoot) exposing the document’s headings, paragraphs, lists, tables, links, and images to assistive technology (e.g. screen readers). Tagging is off by default; enable it with:

var config = new PdfGenerateConfig
{
    EnableTaggedPdf = true
};

When enabled:

When EnableTaggedPdf is left at its default (false), none of this runs — output is byte-for-byte the same as if tagging didn’t exist in the codebase at all.

The HTML-tag → structure-type mapping is CSS-driven and author-overridable via the -peachpdf-pdf-tag-type custom property — see Tagged PDF (PDF/UA) Support in HTML & CSS Support for the property’s accepted values, the full default mapping table, and known limitations.

PDF outline (bookmark) generation is a separate, always-on feature — no PdfGenerateConfig flag needed — driven purely by the bookmark-level/bookmark-label/bookmark-state CSS properties; see PDF Bookmarks (Outline) Support in HTML & CSS Support.

Enabling interactive PDF forms

PeachPDF can optionally produce a fillable PDF — real AcroForm text, checkbox, radio, and select (combo-box) fields a reader can actually fill in, instead of the default static rendering of <input>/<select> elements. Interactive forms are off by default; enable them with:

var config = new PdfGenerateConfig
{
    EnableInteractivePdfForms = true
};
<form>
  <label>Name: <input type="text" name="name" value="" /></label>
  <label><input type="checkbox" name="subscribe" checked /> Subscribe to updates</label>
  <label><input type="radio" name="plan" value="basic" checked /> Basic</label>
  <label><input type="radio" name="plan" value="pro" /> Pro</label>
  <select name="country">
    <option value="us">United States</option>
    <option value="ca">Canada</option>
  </select>
</form>

With the flag on, each <input>/<select> above becomes a real AcroForm field: type="checkbox"/type="radio" inputs become checkbox/radio fields (radios sharing a name become one mutually-exclusive group), every other <input> becomes a text field, and <select> becomes a combo-box field populated from its <option> children. <textarea> and <button> are not supported and always render as static boxes.

When EnableInteractivePdfForms is left at its default (false), none of this runs — no AcroForm object is created and the page’s static rendering is unchanged.

The field-kind inference is CSS-driven and author-overridable via the -peachpdf-pdf-form-field custom property (plus three text-field-only sub-setting properties for auto-sizing, comb layout, and scroll behavior) — see Interactive PDF Forms Support in HTML & CSS Support for the full property reference and default inference table.

ASP.NET Core controller endpoint

Render to a MemoryStream, rewind it, and return it with File() so ASP.NET Core streams it to the client with the right content type. BuildInvoiceHtml below stands in for whatever HTML-generation logic you use (a Razor template, a string builder, etc.) — the PDF-specific part is everything after var html = ....

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("reports")]
public class ReportsController : ControllerBase
{
    [HttpGet("invoice/{id:int}")]
    public async Task<IActionResult> GetInvoice(int id)
    {
        var html = await BuildInvoiceHtml(id);

        var pdfConfig = new PdfGenerateConfig
        {
            PageSize = PageSize.Letter,
            PageOrientation = PageOrientation.Portrait
        };

        var generator = new PdfGenerator();
        var document = await generator.GeneratePdf(html, pdfConfig);

        var stream = new MemoryStream();
        document.Save(stream);
        stream.Position = 0;

        return File(stream, "application/pdf", $"invoice-{id}.pdf");
    }
}

ASP.NET Core Minimal API endpoint

The same approach works with Results.File, mapped on the WebApplication built in Program.cs:

app.MapGet("/reports/invoice/{id:int}", async (int id) =>
{
    var html = await BuildInvoiceHtml(id);

    var pdfConfig = new PdfGenerateConfig
    {
        PageSize = PageSize.Letter,
        PageOrientation = PageOrientation.Portrait
    };

    var generator = new PdfGenerator();
    var document = await generator.GeneratePdf(html, pdfConfig);

    var stream = new MemoryStream();
    document.Save(stream);
    stream.Position = 0;

    return Results.File(stream, "application/pdf", $"invoice-{id}.pdf");
});

Azure Functions (isolated worker) HTTP handler

HttpResponseData.Body is itself a writable Stream, so PeachPdfDocument.Save can write straight to the response body with no intermediate buffer.

using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;

public class GenerateInvoicePdf
{
    private readonly ILogger _logger;

    public GenerateInvoicePdf(ILoggerFactory loggerFactory)
    {
        _logger = loggerFactory.CreateLogger<GenerateInvoicePdf>();
    }

    [Function("GenerateInvoicePdf")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "invoice/{id:int}")] HttpRequestData req,
        int id)
    {
        var html = await BuildInvoiceHtml(id);

        var pdfConfig = new PdfGenerateConfig
        {
            PageSize = PageSize.Letter,
            PageOrientation = PageOrientation.Portrait
        };

        var generator = new PdfGenerator();
        var document = await generator.GeneratePdf(html, pdfConfig);

        var response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "application/pdf");
        response.Headers.Add("Content-Disposition", $"attachment; filename=invoice-{id}.pdf");

        document.Save(response.Body);

        return response;
    }
}