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. If you’d rather build a document directly in C# than author HTML, see the Declarative Document-Building API.

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.

WOFF2 and hyphens: auto need a registered Brotli decoder. System.IO.Compression.BrotliStream throws PlatformNotSupportedException in a browser, and WOFF2’s font tables and PeachPDF’s hyphenation patterns are both Brotli-compressed — without a decoder, a WOFF2 font fails to load and hyphens: auto lays out text unhyphenated rather than failing the render. Register PeachDrawing.Text.Brotli, a pure-managed decoder built for exactly this seam, once at startup:

using PeachDrawing.Text.Brotli;

if (OperatingSystem.IsBrowser())
{
    ManagedBrotliDecompressor.Register();
    ManagedBrotliCompressor.Register(); // only needed for the opt-in BrotliCompression PDF streams
}

WOFF 1.0 or plain TrueType/OpenType fonts need no decoder at all, so they remain the simpler choice when you don’t otherwise need WOFF2’s smaller download.

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.

Applying compatibility styles to legacy HTML

Caller-supplied stylesheets can also provide compatibility rules when the input HTML cannot be changed. For example, the obsolete, non-standard <nobr> element is intentionally not included in PeachPDF’s user-agent stylesheet. To give ordinary, well-formed <nobr> elements their historical non-wrapping behavior, add the equivalent CSS explicitly:

var generator = new PdfGenerator();

var styles = await generator.ParseStyleSheet(
    "nobr { white-space: nowrap; }",
    combineWithDefault: true);

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

This adds an author stylesheet on top of PeachPDF’s defaults, so styles in the document can still override it. It only supplies the layout rule: it does not implement a browser’s special HTML parser error recovery for malformed or nested <nobr> markup. When you control the HTML, prefer a standard element such as <span class="nobr"> with .nobr { white-space: nowrap; } instead.

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);

Detecting text that was clipped away

A word can be drawn into the PDF and then truncated by a clip — an overflow: hidden box narrower than an unbreakable value, most often. The glyphs are still in the content stream, so a reader that parses that stream finds them and reports the document complete, while anything that honours the clip shows only part of the word. Both are behaving correctly; the difference simply is not recorded in the file.

That makes it the one loss class only the engine can report, because only the painter knows the word’s rect was wider than the clip it was drawn into. PeachPdfDocument.ClipReport is that report:

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

if (document.ClipReport.ClippedWords.Count > 0)
{
    foreach (var word in document.ClipReport.ClippedWords)
    {
        Console.WriteLine(
            $"'{word.Text}' kept {word.KeptFraction:P0} " +
            $"({word.VisibleWidth:F1}pt of {word.DrawnWidth:F1}pt)");
    }
}

Useful when generating documents from templates against data you do not control, where a value one character longer than its column is the difference between a correct invoice and a truncated one that nothing flagged.

Each ClippedWord carries the geometry rather than a verdict — DrawnWidth, VisibleWidth, DrawnHeight, VisibleHeight, and KeptFraction — so what counts as material loss is yours to decide. A word is reported when either dimension was reduced, so a word clipped only vertically — a box short enough to cut a line’s height but wide enough to keep the whole word — comes back with VisibleWidth equal to DrawnWidth. ClipReport.ClippedChars totals the characters carried by truncated words; it counts the whole word, since a word is the smallest unit the painter knows and apportioning characters to a sub-rectangle would invent precision the measurement does not have.

The report accumulates across repeated AddPdfPages calls, so a document assembled from several calls carries every call’s findings rather than only the last one’s.

Two things are deliberately not reported:

The report under-reports, and that is the safe direction rather than completeness. It measures against the renderer’s tracked clip-rect stack, and two clips never reach it: a border-radius or clip-path clip, and the page-level clip applied outside that stack. An empty report is therefore a weaker statement than “nothing was clipped”. A word that fell entirely outside its clip is not reported here either — it is never drawn at all, so reading the output back detects it as missing text.

Crisp 1px borders at 100% zoom

A box’s background and border are painted on its exact layout position, which is rarely a whole CSS pixel (1px = 0.75pt). A 1px border (0.75pt) at a fractional position straddles two device pixels of a viewer showing the page at 100% zoom, each about half covered, so it reads as a wider, paler line than the same border in a browser. Browsers avoid this by rounding each edge of the box to a whole CSS pixel before painting. PeachPDF writes the exact geometry by default, which is what the PDF format can express and what other CSS print engines write; to get the browser behaviour instead, opt in:

var config = new PdfGenerateConfig
{
    SnapBoxDecorationsToCssPixels = true
};

The grid is one pixel (0.75pt) wide on the page, anchored at its top-left corner, and the background, border, outline and shadows of a box all move to the same snapped edges, so they still meet exactly. Layout is unaffected; only what is painted moves, by at most half a CSS pixel (0.375pt), so a box’s text can sit up to that far from its snapped edge. The overflow clip of a box moves with it, so clipped content does not cover part of the snapped border, and the collapsed borders of a border-collapse: collapse table are snapped with its cell backgrounds. Text inputs, <select>, checkboxes, radio buttons, and a <button> or <textarea> with an author-set border or background are snapped like any other box. Replaced elements (<img>, inline <svg>, <iframe>, <math>, an <object> showing an image), a <button> or <textarea> in its default appearance, multi-column rules, @page margin boxes and page borders, text decorations such as underlines, list markers and anything under a transform are not snapped, and an edge where a box is cut across a page, column or line break stays where it is. A box inside an opacity, blend-mode or filter group is snapped like any other.

In PDFium the page size decides the result, not this option. Snapping fixes where the box sits on the CSS pixel grid; it cannot fix where a viewer puts its own pixel grid. PDFium (the engine of Chrome and Edge’s PDF viewers) rounds the page up to a whole number of pixels (A4 is 793.7px wide at 96 dpi, so 794px), which stretches the page by a fraction of a pixel, and it fills a plain rectangle without anti-aliasing, so a rectangle that lands a hair past a pixel boundary paints the whole next pixel. On an A4 page it therefore draws a 1px rule as two solid rows whether or not the option is on, and it can show a faint one-pixel edge of background beside a border. Measured on A4 at 96 dpi with thirteen 1px rules at fractional offsets: PDFium draws all thirteen as two rows with the option off and still with it on, while MuPDF draws all thirteen as one row with the option on. On a page whose size is a whole number of CSS pixels (for example @page { size: 794px 1123px }), PDFium draws every rule as one row even without the option. For output that will be read in a PDFium-based viewer, use a whole-pixel page size; the option helps viewers that anti-alias fills. The command-line equivalent is --snap-box-decorations-to-css-pixels.

Whole-pixel border widths

A browser snaps a length-valued border width to whole device pixels: a width above 0 and below 1px becomes 1px, and a width of 1px or more is rounded down to a whole number. That is why a 0.5px border shows as a visible 1px line. PeachPDF uses widths as written by default; to snap them, opt in:

var config = new PdfGenerateConfig
{
    SnapBorderWidthsToCssPixels = true
};

A PDF has no device pixel, so the snapping is done on the CSS pixel (0.75pt): 0.5px becomes 1px, 1.5px becomes 1px and 2.9px becomes 2px. It applies to border-width, outline-width and column-rule-width, and leaves the thin, medium and thick keywords alone. Unlike SnapBoxDecorationsToCssPixels it changes the used width, so the box’s size changes with it. The command-line equivalent is --snap-border-widths-to-css-pixels.

Rendering CSS animations as a still frame

A PDF is a single moment, so a page that is animated with CSS (animation and @keyframes) has to be rendered at one point in the animation. By default PeachPDF ignores animations and every element shows its ordinary style. To render a point of the animation instead, set AnimationProgress to a number from 0 (the start) to 1 (the end):

var config = new PdfGenerateConfig
{
    AnimationProgress = 0   // 0 = the start of every animation, 1 = the end, 0.5 = half way
};

A page whose two logos fade over each other (one animates opacity from 1 to 0 and the other from 0 to 1) shows both logos on top of each other without the option. At 0 it shows the first logo, at 1 the second, and at 0.5 both at half strength, as the page looks half way through the cross-fade.

Each animation is sampled at that fraction of its own run, with its easing (animation-timing-function, including a keyframe’s own) and animation-direction applied:

The animated values replace the ordinary ones before layout, so an animated width or margin moves the content around it just as a declared one would, and an !important declaration still wins over an animation. A keyframe may leave out from or to, and the element’s ordinary value fills in. Values are mixed as a browser mixes them: numbers, lengths and percentages (lengths of different units are left to calc()), angles, colors (premultiplied, so a fade from transparent keeps the other color’s hue), and the arguments of transform and filter functions, box-shadow and similar lists. A value with no way to be mixed, such as display, flips half way through the interval. See Animations for the full list of what is supported.

CSS transitions never apply: a transition runs after a style changes, and a document that has just loaded has no such change. The value must be between 0 and 1; anything else makes generation throw an ArgumentOutOfRangeException. The command-line equivalent is --animation-progress, which takes start, end or a number.

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, math, 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)
math (first installed of Latin Modern Math, Cambria Math, STIX Two Math) (first installed of Latin Modern Math, STIX Two Math, Cambria Math) (first installed of Noto Sans Math, STIX Two Math, Latin Modern Math) (first installed of Latin Modern Math, STIX Two Math, DejaVu Math TeX Gyre, Cambria Math, Noto Sans Math; not asked of fontconfig)
system-ui Segoe UI (platform default font) (platform default font) (delegated to fontconfig’s own system-ui alias)

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, and on Linux it goes through fontconfig exactly like the five generics above — which is also what Chromium does there — falling back to the platform default font only if fontconfig cannot answer or names a family that is not installed. On macOS/Android it remains 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).

math is a list rather than one name, because Chromium’s own choice (Latin Modern Math) is not installed by default on Windows or macOS: PeachPDF uses the first family of the platform’s list that is installed, and the platform default font when none is - a <math> element then renders in that default font rather than the surrounding text’s font, since its font-family is the (unresolvable) generic. On a stock Windows machine that is Cambria Math, which Windows ships inside a font collection (cambria.ttc). It is deliberately not delegated to fontconfig on Linux — fc-match math matches no rule and answers with the ordinary default sans-serif family, which would win over a real math font. A <math> element uses this generic by default (see MathML), and AddFontFamilyMapping("math", "Your Math Font") overrides the choice.

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 (.ttf/.otf) and font collections (.ttc/.otc) from the operating system’s own installed fonts. Every face of a collection is discovered as its own font — this is where platforms ship several of their own fonts, such as Windows’ Cambria Math and MS Gothic, macOS’s Helvetica, Times and Menlo, and the Noto CJK fonts on Linux:

You can also add a font at runtime by loading the font into a Stream, and then using the AddFontFromStream API. A .ttc/.otc collection passed this way registers only its first face:

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.

Rendering MathML formulas

MathML embedded directly in HTML renders as real vector PDF content — no configuration needed:

var html = @"
<html><body>
  <p>The quadratic formula:</p>
  <math display=""block"">
    <mi>x</mi><mo>=</mo>
    <mfrac>
      <mrow><mo>-</mo><mi>b</mi><mo>&#177;</mo>
        <msqrt><mrow><msup><mi>b</mi><mn>2</mn></msup><mo>-</mo><mn>4</mn><mi>a</mi><mi>c</mi></mrow></msqrt>
      </mrow>
      <mrow><mn>2</mn><mi>a</mi></mrow>
    </mfrac>
  </math>
</body></html>";

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

A formula renders in the font-family its <math> element resolves to via ordinary CSS — for correct fraction bars, radicals, and stretchy operator sizing, that font should carry a real OpenType MATH table (e.g. STIX Two Math, Latin Modern Math, or any other dedicated math font); a font without one still renders structurally correctly using approximate fallback metrics. See Fonts above for how to register a custom font, and Supported MathML Features for the full compatibility matrix.

using var fontStream = File.OpenRead("STIXTwoMath-Regular.ttf");
await generator.AddFontFromStream(fontStream);
// html's <math> elements then use font-family: "STIX Two Math";

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.

PDF 2.0 output

PeachPDF writes PDF 2.0 (ISO 32000-2) by default. Set PdfVersion to Pdf17 for a PDF 1.x file (the header is %PDF-1.4, or later when a feature or a PDF/A or PDF/X level needs it):

var config = new PdfGenerateConfig
{
    PdfVersion = PdfVersion.Pdf17
};

Requesting a PDF/A or PDF/X level while leaving PdfVersion alone picks the version that standard is defined against, so the default never conflicts with an earlier level: PdfA1B still produces a PDF 1.4 file, PdfA2B and PdfA3B PDF 1.7, X4 PDF 1.6, and PdfA4 and X6 PDF 2.0. Only an explicit PdfVersion that contradicts the level throws.

This is needed for full spec conformance when combined with EnableTaggedPdf on a document containing <math> elements: the /AF (Associated Files) array PeachPDF attaches to a Formula structure element to carry the original MathML source (see MathML Associated Files) is a PDF 2.0 addition to the structure element dictionary. /AF is still written under PdfVersion.Pdf17 and tolerated by most real-world readers, but only Pdf20 (the default) makes the file’s own header agree with the features it uses.

Brotli stream compression

Set BrotliCompression = true under PdfVersion.Pdf20 and PeachPDF compresses page content, fonts, embedded files and raster image data with the PDF 2.0 /BrotliDecode filter instead of /FlateDecode, which usually produces smaller files. It is off by default because not every PDF reader decodes Brotli yet: PDFium (the engine behind Chrome’s and Edge’s viewers) and Acrobat show such a file as blank, while MuPDF and pdf.js read it. Enable it when you control the readers. It is always off for other PdfVersion values. Brotli uses .NET’s own encoder; on a host without one (WebAssembly in a browser) PeachPDF falls back to /FlateDecode unless you register an encoder with PeachDrawing.Text.Compression.BrotliCompression.SetCompressor. The peachpdf tool exposes both as --pdf-version and --brotli.

var config = new PdfGenerateConfig
{
    PdfVersion = PdfVersion.Pdf20,
    BrotliCompression = true // Brotli streams: smaller, but unreadable in PDFium and Acrobat today
};

JPEG XL pass-through (experimental)

Set JxlPassthrough = true (default false) with PdfVersion.Pdf20 to embed an opaque, upright, non-animated gray or RGB JPEG XL image as its original bytes under a /JXLDecode filter, instead of decoding it and recompressing the pixels. No PDF standard defines a JPEG XL filter yet: the filter name and image dictionary follow the only open-source design published so far, so no shipping PDF reader opens such a file today. A JPEG XL image with alpha, animation or CMYK, a sideways orientation, or any image under ImageCompression.Lossy, takes the normal raster path. The image is embedded at its natural size and is never downscaled. The peachpdf tool exposes this as --jxl-passthrough.

PdfVersion.Pdf20 is incompatible with every PDF/A and PDF/X level except the two that are defined against PDF 2.0: PDF/A-4 (PdfA4, PdfA4E, PdfA4F) and PDF/X-6 (X6). Asking for any earlier level together with an explicit PdfVersion.Pdf20 throws. Conversely, requesting PDF/A-4 or PDF/X-6 selects PDF 2.0 by itself as long as you never set PdfVersion; setting it to Pdf17 explicitly alongside one of them throws.

Encrypting and password-protecting PDF output

Set PdfGenerateConfig.Encryption to encrypt the PDF and to control what a reader lets people do with it. PeachPDF writes the PDF standard security handler with AES-256 (ISO 32000-2, revision 6) and nothing older: RC4 and AES-128 depend on algorithms that are deprecated, so they are not offered. All the cryptography comes from .NET’s own System.Security.Cryptography.

var config = new PdfGenerateConfig
{
    Encryption = new PdfEncryptionOptions
    {
        UserPassword = "to open it",      // needed to open the document; null or "" opens without one
        OwnerPassword = "to unlock it",   // lifts every restriction below
        Permissions = PdfPermissions.AllowPrint | PdfPermissions.AllowCopy
    }
};
Option Meaning
UserPassword The password a reader asks for before showing the document. With none, anyone can open it and only Permissions apply.
OwnerPassword Lifts the restrictions. With none, a random one is generated and thrown away, so the restrictions can never be lifted.
Permissions What someone who opened the document with the user password may do (default PdfPermissions.All).
EncryptMetadata false leaves the XMP metadata stream readable, so search and indexing tools can see it without a password. Default true.

The permission flags are AllowPrint, AllowHighQualityPrint (full-resolution printing; without it printing is low quality, and without AllowPrint there is none), AllowCopy (copying text and graphics), AllowAccessibilityExtract (screen readers), AllowModify, AllowAnnotations, AllowFillForms and AllowAssemble (inserting, rotating and deleting pages). PdfPermissions.None forbids all of them and PdfPermissions.All allows all of them.

For a read-only document — one that can be viewed, printed and copied from but not edited, annotated, filled in or reassembled — use PdfPermissions.ReadOnly, or the shortcut that also takes the passwords:

var config = new PdfGenerateConfig
{
    Encryption = PdfEncryptionOptions.ReadOnly(ownerPassword: "to unlock it")
};

Things to know:

The peachpdf tool exposes this as --user-password, --owner-password, --permissions and --no-encrypt-metadata.

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.

Generating PDF/A-conformant output

PeachPDF can optionally produce a PDF/A (ISO 19005) conformant file — the archival PDF profile many government, legal, and long-term-records workflows require. PDF/A conformance is off by default; opt in with PdfGenerateConfig.PdfAConformance:

var config = new PdfGenerateConfig
{
    PdfAConformance = PdfAConformance.PdfA2B,
    // XMP metadata needs a real creation date - see "The XMP creation date" below.
    Metadata = new PdfDocumentMetadata { CreationDate = DateTimeOffset.UtcNow }
};

Choosing a conformance level

PdfAConformance covers all three ISO 19005 parts, each with a “B” (visual-only), “U” (guaranteed Unicode text extraction), and accessible “A” variant:

Part Levels Notes
PDF/A-1 PdfA1B, PdfA1A Based on PDF 1.4. Forbids PDF transparency groups entirely — see below.
PDF/A-2 PdfA2B, PdfA2U, PdfA2A Based on PDF 1.7. Permits transparency.
PDF/A-3 PdfA3B, PdfA3U, PdfA3A Same as PDF/A-2, plus permission to embed arbitrary files — see Embedding files.
PDF/A-4 PdfA4, PdfA4E, PdfA4F Based on PDF 2.0, which is selected automatically. Has no A/B/U levels; E is the engineering variant and F also permits embedded files. Permits transparency. See PDF/A-4 below.

If you don’t have a specific requirement for PDF/A-1 or PDF/A-3, PdfA2B (or PdfA2U, effectively free once you’re already targeting 2B — see below) is the least restrictive, most broadly useful choice.

U levels cost nothing extra over the matching B level: every embedded font already carries a ToUnicode CMap, so the guaranteed-text-extraction requirement is already met.

PDF/A-4

PDF/A-4 (ISO 19005-4) differs from the earlier parts in ways PeachPDF handles for you:

PDF/A-1 and transparency

CSS/SVG opacity below 1, fill-opacity/stroke-opacity below 1, a semi-transparent gradient color stop, an SVG <mask>, and a CSS filter PeachPDF renders as a bitmap (blur(), grayscale(), sepia(), saturate(), hue-rotate(); see Rasterized effects) all need a PDF transparency group or soft mask — constructs PDF/A-1 forbids outright. By default, rather than silently emit a non-conformant file, generation throws an InvalidOperationException naming the offending feature if the document uses any of them under PdfA1B/PdfA1A. A document that doesn’t use any of these features generates normally under PDF/A-1. Set PdfGenerateConfig.TransparencyPolicy to Flatten to have PeachPDF render what needs transparency as opaque bitmaps instead — see Flattening transparency — or target PdfA2*/PdfA3*, which permit it.

The accessible “A” levels

PdfA1A/PdfA2A/PdfA3A build on the same tagged-structure-tree machinery as tagged PDF — requesting one of them enables tagging for that render even if EnableTaggedPdf is left false. Two things follow:

XMP metadata

Requesting any PdfAConformance level writes an XMP metadata stream (the document catalog’s /Metadata entry), since PDF/A requires one. You can also opt into an XMP stream independently of PDF/A conformance with EnableXmpMetadata:

var config = new PdfGenerateConfig { EnableXmpMetadata = true };

The XMP packet’s Dublin Core/pdf:/xmp: fields are always derived from the same Document Information dictionary values PdfDocumentMetadata already controls (Title, Author, Subject, Keywords), so the two stay consistent by construction. pdfaid:part/pdfaid:conformance are added only when PdfAConformance is set — they’re derived from it, not independently settable (a caller can’t claim a conformance level the actual output doesn’t meet).

To include your own additional XMP metadata (an internal provenance or records-management schema, for example), add System.Xml.Linq.XElements to PdfDocumentMetadata.CustomXmpProperties — each becomes its own rdf:Description in the packet, alongside the built-in one:

using System.Xml.Linq;

XNamespace acme = "urn:acme:records";
var metadata = new PdfDocumentMetadata { CreationDate = DateTimeOffset.UtcNow };
metadata.CustomXmpProperties.Add(new XElement(acme + "retentionPolicy", "7-years"));

var config = new PdfGenerateConfig { EnableXmpMetadata = true, Metadata = metadata };

The XMP creation date

xmp:CreateDate needs a real value. PeachPDF uses the same date HTML <meta>-extraction already populates the Document Information dictionary’s /CreationDate from, or PdfDocumentMetadata.CreationDate when you set it explicitly (it wins over the HTML-extracted date, same override pattern as Title/Author/etc.). Whenever an XMP stream is being written (EnableXmpMetadata or PdfAConformance) and neither is available, generation throws an InvalidOperationException rather than writing a placeholder date — set PdfDocumentMetadata.CreationDate to fix it.

Embedding files (PDF/A-3 attachments)

PDF/A-3 is the PDF/A part that lets a document carry arbitrary files — the source data behind a report, supporting documents, or the structured data of an invoice. Add them to PdfGenerateConfig.Attachments:

var config = new PdfGenerateConfig
{
    PdfAConformance = PdfAConformance.PdfA3B,
    Metadata = new PdfDocumentMetadata { CreationDate = DateTimeOffset.UtcNow }
};

config.Attachments.Add(new PdfAttachment
{
    FileName = "q3-sales.csv",
    MimeType = "text/csv",
    Relationship = PdfAttachmentRelationship.Data,
    Description = "The numbers behind the report's table",
    Data = File.ReadAllBytes("q3-sales.csv")
});

Each file shows up in a viewer’s attachments panel and is indexed twice: in the document catalog’s /AF (Associated Files) array, which PDF/A-3 uses to say how a file relates to the document, and in the /Names /EmbeddedFiles name tree, which viewers list attachments from.

PdfAttachment property Meaning
FileName Required and unique within the document. Limited to Latin-1 characters; a non-ASCII name is also written as a Unicode string.
Data The file’s content.
MimeType For example text/xml. Defaults to application/octet-stream.
Relationship How the file relates to the document — Source, Data, Alternative, Supplement or Unspecified (the default). It informs a reader; it has no technical effect on the PDF.
Description An optional human-readable description.
ModificationDate The file’s last-modification date. Defaults to the document’s creation date, so the output stays deterministic (and to the current time when the document has no creation date either).

Attachments are only allowed with a PDF/A-3 level (PdfA3B, PdfA3U or PdfA3A) or with no PDF/A level at all, where they are ordinary PDF attachments. PDF/A-1 forbids embedded files entirely and PDF/A-2 allows only PDF/A files, so requesting an attachment under one of those levels throws an InvalidOperationException. Embedded streams are Flate-compressed unless CompressContentStreams is turned off.

Attachments are a whole-document property, like PdfAConformance. When several AddPdfPages/AddPages calls build one document — or a declarative document has several pages — every call must specify the same attachments; the files are embedded once.

ZUGFeRD / Factur-X e-invoices

Factur-X (France) and ZUGFeRD (Germany) are one hybrid e-invoice format under two names: a PDF/A-3 file that carries the invoice twice — as the readable pages PeachPDF renders, and as structured XML (a Cross Industry Invoice, the syntax of the European EN 16931 invoice standard) that accounting software can read without parsing the pages. Set PdfGenerateConfig.FacturX to make the output one:

byte[] invoiceXml = /* the invoice as Cross Industry Invoice XML - see below */;

var config = new PdfGenerateConfig
{
    PdfAConformance = PdfAConformance.PdfA3A,
    DefaultLanguage = "en",
    Metadata = new PdfDocumentMetadata { CreationDate = DateTimeOffset.UtcNow },
    FacturX = new FacturXOptions { Xml = invoiceXml }
};

var document = await generator.GeneratePdf(invoiceHtml, config);

PeachPDF embeds the XML you give it. It does not generate the invoice XML, and it does not validate it against the format’s schemas and business rules. Generate the XML with any Cross Industry Invoice library — for .NET, the open-source ZUGFeRD-csharp package (InvoiceDescriptor … Save(stream, ZUGFeRDVersion.Version23, Profile.Comfort)) — and check the finished file with a validator such as Mustang. What PeachPDF takes care of is the PDF side of the format, which is fiddly to get right by hand:

Requirements:

The profile

A Factur-X invoice declares how much of the EN 16931 data model its XML carries — its profile. PeachPDF reads it from the XML’s own guideline identifier (GuidelineSpecifiedDocumentContextParameter/ID, business term BT-24), so by default you set nothing. Set FacturXOptions.Profile only to state it explicitly — it must then agree with the XML, so a document cannot claim a profile its XML does not meet — or when the XML uses a guideline PeachPDF does not recognise.

FacturXProfile fx:ConformanceLevel Embedded as Default /AFRelationship
Minimum MINIMUM factur-x.xml Data
BasicWl BASIC WL factur-x.xml Data
Basic BASIC factur-x.xml Alternative
En16931 EN 16931 factur-x.xml Alternative
Extended EXTENDED factur-x.xml Alternative
XRechnung XRECHNUNG xrechnung.xml Alternative

Minimum and BasicWl carry too little to be an invoice under German tax law, and the German rules forbid them when both seller and buyer are in Germany; prefer Basic, or better En16931. PeachPDF cannot know the parties, so it does not enforce this.

FacturXOptions.Relationship overrides the /AFRelationship, within what the specification permits for the profile. Alternative — asserting that the PDF and the XML carry the same invoice information — is the only value allowed for German invoices; French invoices may also use Source or Data for Basic, En16931 and Extended. A value outside the permitted set is rejected.

Supporting documents

Add further files — a purchase order, a timesheet — to PdfGenerateConfig.Attachments as described above. The invoice XML is always the first attachment, and a Factur-X document carries exactly one invoice data file, so Attachments may not use the names factur-x.xml or xrechnung.xml.

Like Attachments and PdfAConformance, FacturX is a whole-document property: when several AddPdfPages/AddPages calls build one document — or a declarative document has several pages — every call must specify the same invoice.

The showcase has two complete, validated examples, built with the declarative API and ZUGFeRD-csharp: an EN 16931 invoice between companies and an XRechnung invoice to a public body. Only the Factur-X 1.0x / ZUGFeRD 2.1-and-later XMP schema (fx) is supported, not the legacy ZUGFeRD 1.0 and 2.0 ones, and the container is always PDF/A-3: the specification also allows PDF/A-4f, but PeachPDF only writes the PDF/A-3 container.

Color and ICC profiles

A PDF/A OutputIntent needs a device-independent ICC profile to name — PeachPDF embeds the ICC’s own freely-redistributable sRGB2014.icc profile for this and identifies it as "sRGB IEC61966-2.1". PeachPDF’s document-wide color mode stays mixed/undefined under PdfAConformance (a device-cmyk()-authored color still writes as real DeviceCMYK, unaffected by PDF/A’s own sRGB OutputIntent), so the bundled sRGB profile is always the right one to name here. It’s unrelated to how a source raster image embeds its own color data, covered next.

Image orientation

A photo taken with the phone held sideways is usually stored sideways, with its orientation recorded in the file (JPEG and TIFF EXIF, a PNG eXIf chunk, WebP EXIF, AVIF irot/imir, or the JPEG XL header). PeachPDF draws it upright, as browsers do: the CSS image-orientation property defaults to from-image, and an image’s intrinsic size is its upright size, so <img> without a size, background-size: auto, object-fit and border-image-slice all see the picture the way a person would.

<!-- Drawn upright from its recorded orientation (the default) -->
<img src="phone-photo.jpg" style="width: 3in">

<!-- Ignore the recorded orientation and draw the stored pixels -->
<img src="phone-photo.jpg" style="width: 3in; image-orientation: none">

<!-- Ignore it and apply an explicit quarter turn and mirror instead -->
<img src="phone-photo.jpg" style="width: 3in; image-orientation: 90deg flip">

The turn is a transform in the page content, so the embedded image bytes are never re-encoded: a passed-through JPEG, PNG, GIF or CMYK image stays byte-for-byte what the source file held. The property is inherited, so one rule on body (for example image-orientation: none) changes a whole document. See the image-orientation row for the full grammar and where it applies.

CMYK and embedded ICC profiles in source images

A CMYK or YCCK JPEG — the form a print-ready image typically arrives in, separated for a specific press profile — is embedded via byte-for-byte pass-through rather than converted to RGB: PeachPDF has no general color-management engine, so preserving the original bytes unchanged is the only way to guarantee the source’s separations survive intact. The PDF ColorSpace is DeviceCMYK, or ICCBased (referencing the JPEG’s own embedded ICC profile, carried through verbatim) when one is present. An Adobe-authored CMYK JPEG’s inverted-sample convention is corrected via a PDF Decode array rather than by re-encoding the pixel data.

A CMYK/YCCK JPEG is always embedded at its natural pixel size — DownscaleImages and MaximumDownscaleMultiplier don’t apply to it, since PeachPDF has no CMYK JPEG encoder to re-encode a resized copy with.

A CMYK TIFF is preserved the same way in spirit, but by a different mechanism: TIFF has no PDF-native byte-for-byte pass-through filter the way JPEG’s DCTDecode is, so PeachPDF decodes it natively (never forced through RGB) and embeds the decoded pixel buffer as a raw, Flate-compressed DeviceCMYK (or ICCBased, when the TIFF carries a usable embedded ICC profile) stream instead. Like a CMYK JPEG, it’s always embedded at its natural pixel size.

An RGB or grayscale JPEG carrying a usable embedded ICC profile is also embedded via byte-for-byte pass-through, specifically to preserve that profile (ICCBased referencing it, rather than the usual bare DeviceRGB/DeviceGray). Unlike a CMYK source, this doesn’t disable resizing: if the image is being downscaled for its on-page display size, that particular embed falls back to the ordinary re-encoded path instead (losing the embedded profile for that embed, not the image) — pass-through and downscaling are mutually exclusive for a given embed, and downscaling wins when both would otherwise apply. An RGB/grayscale JPEG with no embedded ICC profile is unaffected by any of this.

PNG, WebP, and AVIF sources may also carry an embedded ICC profile, and PeachPDF preserves it. A PNG’s iCCP chunk rides along its own byte-for-byte pass-through path (below): the color space PeachPDF would otherwise write as a bare DeviceGray/DeviceRGB/Indexed becomes ICCBased (referencing the profile, carried through verbatim) instead — pixel data is untouched either way, since the profile only changes how the ColorSpace entry names the space those pixels live in. WebP and AVIF have no pass-through mechanism (their pixel data is always decoded and embedded as a raw bitmap, as before), but PeachPDF now decodes a WebP/AVIF source a second time, natively, purely to read its embedded profile — the ordinary pixel decode still runs once, so this doesn’t affect what gets embedded, only the ColorSpace it’s tagged with. Unlike the JPEG case above, this isn’t defeated by downscaling or ImageCompression.Lossy: a PNG/WebP/AVIF that falls back to a re-encoded JPEG embed for either reason still carries its source profile forward onto that embed, since re-encoding only recompresses already-decoded samples — it never changes what color space they’re in. A source PNG/WebP/AVIF with no embedded profile is unaffected: it embeds with a bare DeviceGray/DeviceRGB exactly as before.

A JPEG XL file made by recompressing a JPEG (what cjxl does for JPEG input) carries the information to rebuild that exact JPEG, so PeachPDF turns it back into the original JPEG without decoding any pixels and then treats it exactly like a .jpg: a CMYK/YCCK or ICC-tagged one is embedded byte for byte as above, and an untagged RGB/grayscale one is decoded and embedded like any other JPEG. Any other JPEG XL image (lossy or lossless, with or without alpha, 8/16-bit or HDR) is decoded to RGBA and embedded like a WebP or AVIF; a CMYK one is color-managed to sRGB first, and an animated one renders its first frame. A lossless JPEG XL (Modular, not XYB-encoded) is protected from a lossy JPEG re-encode by ImageCompression.Auto the same way a lossless WebP or AVIF is, and embeds as a raw Flate stream; a lossy one is re-encoded as JPEG like any other lossy source.

Requesting PdfAConformance on a document containing a CMYK image without an embedded ICC profile throws an InvalidOperationException at generation time: a bare DeviceCMYK image has no relationship to PeachPDF’s RGB-based OutputIntent, so it isn’t PDF/A-conformant on its own. An ICCBased CMYK image (one with an embedded profile) is self-describing and doesn’t have this problem. An RGB or grayscale image is unaffected either way — it stays conformant with or without an embedded ICC profile.

Lossless embedding for opaque PNG/BMP/GIF images

An opaque PNG — a screenshot, chart, logo, QR code, or line art, with no real per-pixel alpha channel and not Adam7-interlaced — is embedded via the same kind of byte-for-byte pass-through as a CMYK JPEG, but for its pixel data rather than its color separations: the PDF Filter is FlateDecode, with DecodeParms describing PNG’s own predictor/color layout, and the stream bytes are the PNG’s own compressed IDAT data, unchanged. This is smaller and pixel-exact, unlike re-encoding as JPEG (which this replaces) — a QR code, for example, stays scannable regardless of how sharp its edges are. A PNG carrying a tRNS chroma-key chunk still qualifies too: a grayscale/truecolor tRNS (always a single exact transparent color) or a palette tRNS where every listed entry is fully opaque or fully transparent both pass through the same way, with a PDF color-key Mask array built from the chunk instead of a separate alpha plane. Like a CMYK JPEG, a pass-through-eligible PNG is always embedded at its natural pixel size; DownscaleImages and MaximumDownscaleMultiplier don’t apply to it.

A PNG with a real per-pixel alpha channel (color type 4/6, or a palette PNG whose tRNS has a genuinely partial-alpha entry — neither fully opaque nor fully transparent) passes through too, when it isn’t interlaced: the color data embeds as its own FlateDecode XObject (PeachPDF splits it out of the PNG’s interleaved color+alpha IDAT into an independent, PNG-row-filtered stream — a real, if narrow, decode step, unlike the byte-for-byte opaque/chroma-key case above) alongside a child SMask image carrying just the alpha samples, itself FlateDecode-compressed with the same PNG-predictor DecodeParms. A palette source with partial-alpha tRNS needs no color-side work at all — its original indexed IDAT/palette embed unchanged, with only the alpha plane newly split out. An interlaced alpha-bearing PNG (or, in this version, one with 16-bit-per-channel color type 4/6) still falls back to the older full decode plus a raw alpha mask.

An opaque BMP — no lossy encoding mode at all — also stops being silently re-encoded as JPEG at its own natural display size, embedding via a raw FlateDecode RGB stream instead. Downscaling one, though, keeps the existing JPEG-at-DownscaleQuality behavior, since that’s a deliberate, separate size/quality trade-off.

A GIF — eligible for pass-through when it isn’t interlaced, its LZW minimum code size is exactly 8 (GIF starts codes at MinCodeSize + 1 bits with clear code 1 << MinCodeSize, matching the fixed 9-bit/256-clear-code start PDF’s own LZWDecode filter always uses), and its frame covers the whole logical screen — embeds the same LZW-compressed codes its own encoder produced as LZWDecode, with an Indexed color space built from its palette and a color-key Mask for a declared transparent index (GIF transparency is always exactly one palette index, so this is even simpler than a PNG’s tRNS). This isn’t a literal byte-for-byte copy the way PNG’s pass-through is: GIF packs LZW codes least-significant-bit-first where PDF’s LZWDecode expects most-significant-bit-first, and the two conventions grow code width by one bit at slightly different points, so PeachPDF re-packs the underlying codes into PDF’s bit order rather than reusing the GIF’s bytes directly — still no LZW decompress-then-recompress round trip, just a bit-order transform. Like a pass-through-eligible PNG, this is always embedded at natural size, regardless of DownscaleImages. A GIF that doesn’t qualify (small palette, interlaced, or a partial-canvas frame) falls back to the same raw FlateDecode path a BMP uses.

PdfGenerateConfig.ImageCompression controls all of this:

var config = new PdfGenerateConfig { ImageCompression = ImageCompression.Lossless };
Value Behavior
Auto (default) The behavior described above: an eligible PNG or GIF always passes through; a PNG/BMP/GIF that can’t (interlaced, small-palette, partial-canvas, or being downscaled) still avoids lossy JPEG only at its own natural size.
Lossless Same as Auto, but a downscaled PNG/BMP/GIF also never gets JPEG’d — it’s decoded, resampled, and re-FlateDecode-encoded instead, at any size. Larger downscaled files, always pixel-exact.
Lossy Always re-encode an opaque PNG/BMP/GIF as JPEG — the behavior every PeachPDF version before this option used. An explicit opt-in for the smallest files when fidelity doesn’t matter, even for diagram/line-art content.

An interlaced PNG (alpha-bearing or not) is unaffected by ImageCompression in every mode — it still falls back to the existing decode-and-FlateDecode path regardless of the setting. A non-interlaced alpha-bearing PNG, though, is pass-through-eligible like any other (see above) and follows the same Auto/Lossless/Lossy table: Lossy doesn’t force a JPEG re-encode for it either, since JPEG has no alpha channel to hold the transparency in at all.

A losslessly-encoded WebP, AVIF, TIFF, or JPEG XL source gets the same protection as an opaque PNG/BMP/GIF: PeachPDF can tell whether a given source actually used its format’s lossless mode (WebP’s VP8L, AVIF’s lossless AV1 tool, TIFF’s uncompressed/LZW/PackBits compression, or JPEG XL’s Modular mode without XYB color), and only re-encodes it as lossy JPEG under ImageCompression.Lossy. A lossy-encoded WebP/AVIF/JPEG XL source (and a JPEG XL recompressed from a JPEG, which follows the JPEG rules above) is unaffected by ImageCompression in every mode — re-encoding an already-lossy source as JPEG loses nothing a lossless re-embed would have recovered, so it stays on the JPEG-re-encode path regardless of the setting.

Deduplicating repeated images

Referencing the same image source more than once in a document — the same <img src> twice, an <img> and a background-image/list-style-image/content: url() sharing a source, or an <object> pointing at the same source as an <img> — fetches and decodes it only once per GeneratePdf/AddPdfPages call, and every reference at the same on-page size shares one embedded PDF image object. This happens automatically; there’s nothing to opt into.

What that doesn’t cover is a duplicate that spans two separate AddPdfPages/AddPages calls into the same document, or two different sources that happen to encode to identical bytes. For those, call PeachPdfDocument.ConsolidateImages() after adding all pages and before Save:

var document = await generator.GeneratePdf(html, config);
await generator.AddPdfPages(document, moreHtml, config);
document.ConsolidateImages();
document.Save(stream);

It merges embedded images whose encoded bytes are identical, keeping one copy and repointing every other reference at it. It’s opt-in rather than automatic, since hashing every embedded image isn’t a cost most documents should pay on every Save.

Authoring colors in CMYK

Colors written with the CSS Color 5 device-cmyk() function are carried through PeachPDF’s whole pipeline natively — never approximated to RGB — and reach the PDF as a real DeviceCMYK fill or stroke operator:

<p style="color: device-cmyk(0 0.81 0.94 0)">Pantone-adjacent orange, defined in ink, not light.</p>

device-cmyk() works anywhere color accepts any other <color> — color, background-color, border-color and the border/background shorthands, and SVG fill/stroke/stop-color. PDF itself allows a single page to freely mix DeviceRGB and DeviceCMYK content, so an ordinary RGB-authored color elsewhere on the same page is completely unaffected — there is no whole-document conversion, and none is attempted.

A gradient (linear-gradient/radial-gradient/conic-gradient, or SVG <linearGradient>/<radialGradient>) whose stops are all device-cmyk() interpolates directly in C/M/Y/K space, the same way an all-RGB gradient interpolates in RGB space. color-mix() between two device-cmyk() operands mixes the same way, ignoring the declared in <space> keyword (which has no CMYK equivalent). A gradient or color-mix() mixing device-cmyk() and RGB-authored operands has no defined conversion and is rejected — see the color row in the compatibility matrix.

Generating PDF/X-conformant output

PeachPDF can optionally produce a PDF/X (ISO 15930) conformant file — the print-production profile a commercial press or prepress workflow requires. Like PDF/A, PDF/X conformance is off by default; opt in with PdfGenerateConfig.PdfXConformance, and (unlike PDF/A, which bundles a default sRGB profile) you must supply your own output-intent ICC profile via ColorOptions — PeachPDF has no single correct default press profile to assume:

var config = new PdfGenerateConfig
{
    PdfXConformance = PdfXConformance.X4,
    ColorOptions = new ColorOptions
    {
        OutputIntentProfile = File.ReadAllBytes("CoatedFOGRA39.icc"),
        OutputIntentIdentifier = "Coated FOGRA39",
    },
};

PdfAConformance and PdfXConformance are mutually exclusive on the same document — archival and print-production are different documents in practice, and generation throws an InvalidOperationException if both are set to a level other than None.

Choosing a conformance level

Level Content restriction Transparency PDF version
X1a CMYK (or a named spot color — PeachPDF has no spot-color support, so in practice CMYK-only) content only Forbidden 1.4
X3 ICC-managed color permitted — an RGB- or Gray-tagged color may stay in its own space Forbidden 1.4
X4 ICC-managed color permitted Permitted 1.6
X6 ICC-managed color permitted Permitted 2.0

X6 (ISO 15930-9, based on PDF 2.0) is selected like X4 but identifies itself in XMP only (pdfxid:GTS_PDFXVersion = PDF/X-6), with no keys in the document information dictionary, and it selects PDF 2.0 automatically. Only the complete-exchange flavor is supported: PDF/X-6p and PDF/X-6n, whose output-intent profile lives outside the file, are not. Like PDF/A-4 it uses only the stream filters ISO 32000-2 defines, so BrotliCompression and JxlPassthrough are switched off for it.

X4 is the modern, most commonly requested level among the PDF 1.x ones, and the least restrictive of those three — if you don’t have a specific requirement for X1a/X3, start there. Each level’s PDF version header, and its GTS_PDFXVersion/GTS_PDFXConformance identification (in both the document information dictionary and, for X4, its XMP metadata — the mechanism ISO 15930-7 names as primary for that level) are set automatically; nothing further to configure.

X1a and X3: no live transparency

Same restriction, and the same mechanism, as PDF/A-1: CSS/SVG opacity below 1, fill-opacity/stroke-opacity below 1, a semi-transparent gradient color stop, an SVG <mask>, and a CSS filter PeachPDF renders as a bitmap all need a PDF transparency group or soft mask, which X1a/X3 forbid outright. Generation throws an InvalidOperationException naming the offending feature rather than silently emitting a non-conformant file. Set TransparencyPolicy to Flatten (see below) to render it as opaque bitmaps instead, or target X4, which permits transparency.

X1a: CMYK-only content

X1a additionally rejects any chromatic (non-gray) RGB-authored color the moment it would be written — color: red throws, color: device-cmyk(0 1 1 0) doesn’t. There is no whole-document RGB→CMYK conversion in PeachPDF to fall back on (see Authoring colors in CMYK above), so under X1a you need to author any chromatic color you actually want to appear with device-cmyk().

An achromatic RGB color (gray, including pure black — color: black, an un-set default text color, border: 1px solid #ccc) is the one exception: rather than reject it, PeachPDF converts it to CMYK deterministically via ColorOptions.BlackGeneration — an exact, lossless ink mapping for a gray value, unlike an arbitrary hue, so this isn’t the kind of approximation PeachPDF otherwise declines to compute:

ColorOptions.BlackGeneration = ColorBlackGeneration.UseRichBlack; // default: UseTrueBlack

This is what makes X1a practical without rewriting every color: black/border-color: #ccc in a stylesheet as device-cmyk() — only genuinely chromatic colors need to be authored explicitly.

Converting document colors to a target ICC profile

ColorOptions.ConversionMode applies a real, colorimetric ICC device-to-device conversion to every color in the document — no naive RGB↔CMYK formula is used anywhere. PreserveAsAuthored (the default) writes every color in whichever space it was authored in, exactly as described above; the other modes convert instead:

var config = new PdfGenerateConfig
{
    PdfXConformance = PdfXConformance.X4,
    ColorOptions = new ColorOptions
    {
        OutputIntentProfile = File.ReadAllBytes("CoatedFOGRA39.icc"),
        OutputIntentIdentifier = "Coated FOGRA39",
        ConversionMode = ColorConversionMode.ConvertToOutputIntent,
        RenderingIntent = ColorRenderingIntent.RelativeColorimetric, // default
        UseBlackPointCompensation = true, // default
    },
};

A color’s source profile is always well-defined for an RGB-authored color (the ICC-published sRGB profile PeachPDF already bundles for PDF/A — CSS colors are sRGB by definition outside of device-cmyk()). A device-cmyk()-authored color is uncalibrated ink by definition and has no source profile unless FallbackCmykProfile supplies one — without that set, a device-cmyk() color is left exactly as authored even under a conversion mode, since there is nothing to convert from.

Rasterized effects and resolution

A few effects cannot be drawn as vector PDF content: the CSS filter functions blur(), grayscale(), sepia(), saturate(), hue-rotate() and drop-shadow(), blurred box-shadows and text-shadows (see Rasterized effects for exactly what is rendered this way), and SVG <filter> elements that use a blur, lighting, morphology, convolution, turbulence, displacement or another pixel primitive (see SVG filters). For those, PeachPDF renders the affected element into a bitmap and embeds it; the rest of the page stays vector.

RasterizationDpi sets how sharp that bitmap is, in pixels per inch of paper:

var config = new PdfGenerateConfig
{
    PageSize = PageSize.A4,
    RasterizationDpi = 300,   // the default; 72 to 1200
};

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

The default of 300 is the print-quality convention and leaves room to zoom a viewer to several hundred percent. Raise it for large-format printing, or lower it to make the file smaller.

The value is a physical resolution and does not depend on PixelsPerInch. A bitmap is always placed at exactly the size of the content it replaces, so one inch on the page is still one inch, and only the number of pixels behind it changes. For example, with PixelsPerInch = 96 and RasterizationDpi = 288, each CSS pixel is backed by 288 / 96 = 3 bitmap pixels in each direction, 9 in all, and the bitmap is scaled down to occupy the same one CSS pixel it always did.

Two related settings:

A value outside 72 to 1200 throws an ArgumentOutOfRangeException when generation starts. On the command line the setting is --raster-dpi; see the CLI reference.

Text inside a rasterized HTML element is drawn into the bitmap and also kept as invisible, positioned text over it, so it stays selectable and searchable. A document targeting PDF/A-1 or PDF/X-1a/X-3 is rejected if it uses one of these effects, unless it asks for flattening — see PDF/A-1 and transparency.

Sharper small text in bitmaps: TextHinting

Text drawn into one of these bitmaps is, by default, the font’s design scaled to the bitmap’s pixels. At a low RasterizationDpi and a small size, that puts edges through the middle of pixels and softens the text. TextHinting asks for the font’s own hinting to be run (the instructions of a TrueType font, the stem hints and blue zones of a font with CFF outlines), so stems, x-heights and baselines land on whole pixels:

var config = new PdfGenerateConfig
{
    PageSize = PageSize.A4,
    RasterizationDpi = 96,
    TextHinting = TextHinting.Standard,   // None (the default), Standard or Monochrome
};

A value that is not one of the three throws an ArgumentOutOfRangeException when generation starts.

Portable color filters

CSS brightness(), contrast() and invert() are written by default as a PDF transfer function, which is exact for any content but which some readers ignore: MuPDF (and the viewers and libraries built on it) shows the element unfiltered, and PDFium applies it to solid fills and text but not to a gradient. PortableColorFilters writes them as blend-mode layers instead, so every reader that supports PDF transparency shows the filter:

var config = new PdfGenerateConfig
{
    PortableColorFilters = true,   // false (the default) writes a transfer function
};

The output is still vector content, not a bitmap. On opaque content it matches the CSS formulas to within a few of 255 levels in both PDFium and MuPDF. Where the content is partly transparent (an rgba() colour, an antialiased edge) a blend mode lets some of the layer’s own colour through, so the colour there is slightly off. Like the default, it needs PDF transparency, so it is rejected under PDF/A-1 and PDF/X-1a/X-3, as the transfer-function form is. grayscale(), sepia(), saturate() and hue-rotate() are bitmaps either way and are not affected.

Turning off smoothing: RasterAntiAliasing

Every bitmap PeachPDF renders itself — a filter:-triggered region, a region flattened under TransparencyPolicy.Flatten, and any glyph fill drawn while TextHinting routes it through the raster path — is anti-aliased by default: an edge that only partly covers a pixel gets a proportional (fractional) alpha instead of being rounded to fully in or fully out. RasterAntiAliasing turns that off:

var config = new PdfGenerateConfig
{
    PageSize = PageSize.A4,
    RasterizationDpi = 96,
    RasterAntiAliasing = false,   // true (the default) smooths edges; false gives hard 0/255 pixels
};

On the command line the setting is --no-raster-antialiasing; see the CLI reference.

Flattening transparency for PDF/A-1 and PDF/X

PDF/A-1 and PDF/X-1a/X-3 forbid transparency, which CSS uses everywhere: opacity, rgba() colours, gradients with an alpha stop, PNGs with an alpha channel, mix-blend-mode, blurred shadows, filter, SVG masks and filters. By default a document that targets one of these levels and uses any of it is rejected. TransparencyPolicy.Flatten turns the rejection into a conversion:

var config = new PdfGenerateConfig
{
    PageSize = PageSize.A4,
    PdfAConformance = PdfAConformance.PdfA1B,
    TransparencyPolicy = TransparencyPolicy.Flatten,
    RasterizationDpi = 300, // resolution of the flattened regions
};

What needs transparency is worked out by the PDF writer’s own rules, so nothing that would have been rejected slips through. Each such element is rendered, together with everything painted behind it, into an opaque bitmap at RasterizationDpi, and the bitmap replaces the element’s region. The file then contains no soft mask, no alpha value, no blend mode and no transparency group. Everything that needs no transparency stays vector, so a page with one translucent card is one bitmap and otherwise ordinary content.

backdrop-filter needs none of this: its bitmap is opaque (it sits over white paper), so it is legal under PDF/A-1 as it is. On the command line, the option is --flatten-transparency; see the CLI reference.

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;
    }
}