Declarative Document-Building API

PdfGenerator.CreateDocument builds a PDF directly from C# — no HTML, no CSS strings — for callers who want a code-first, fluent API instead of authoring markup. It’s built entirely on PeachPDF’s own HTML/CSS layout engine (flexbox for rows and columns, the real table layout algorithm for tables, the same list-marker and running-header/footer machinery HTML documents use), so a value like PdfLength.Points(12) or PdfColor.FromRgb(200, 0, 0) resolves through exactly the same length/color parsing PeachPDF already uses for HTML — this is a different way to construct a document, not a second rendering engine. For the underlying HTML/CSS feature set this API is built on, see HTML & CSS Support; for everyday HTML-string rendering, see Usage Examples.

All examples assume:

using PeachPDF;
using PeachPDF.Layout;

Contents

Quick start

var generator = new PdfGenerator();

var document = await generator.CreateDocument(doc =>
{
    doc.Page(page =>
    {
        page.Size(PageSize.A4);
        page.Margin(20);
        page.Content(container =>
        {
            container
                .Padding(20)
                .Border(1, PdfColor.FromHex("#CCCCCC"))
                .CornerRadius(8)
                .Column(column =>
                {
                    column.Spacing(10);
                    column.Item().Text("Hello, declarative PeachPDF!");
                    column.Item().Text(t =>
                    {
                        t.Span("Bold red text").FontColor(PdfColor.Red).Bold();
                    });
                });
        });
    });
});

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

doc.Page(...) can be called more than once to append further pages, and PdfGenerator.AddPages adds more pages to a PeachPdfDocument you already have (mirroring AddPdfPages on the HTML side). A document built with no PdfGenerateConfig at all defaults to A4 with 20pt margins; pass one to CreateDocument/AddPages for PDF metadata, PDF/A conformance, tagged PDF, compression, or a custom network loader — every PdfGenerateConfig option that isn’t specific to HTML parsing applies here too (PDF/A conformance, file attachments and ZUGFeRD / Factur-X e-invoices among them, set on the config exactly as for an HTML document).

Pages: size, margin, background

page.Size(PageSize.Letter);                       // a named page size...
page.Size(PdfLength.Millimeters(210), PdfLength.Millimeters(297)); // ...or an explicit one
page.Orientation(PageOrientation.Landscape);
page.Margin(20);                                   // all four sides
page.MarginTop(30);                                // one side at a time; also MarginBottom/Left/Right/Horizontal/Vertical
page.Background(PdfColor.White);
page.DefaultTextStyle(style => style.FontFamily("Georgia", "serif").FontSize(11));

Any geometry a page doesn’t set falls back to the document’s own PdfGenerateConfig, the same way an HTML document’s @page rule falls back to it.

Containers: padding, border, background, corner radius, shadow

IContainer is the one composable box used everywhere content is placed — a page’s content area, a Row/Column item, a table cell. Every decorator wraps the current box and returns the new, innermost container, so they chain:

container
    .Padding(16)
    .Border(2, PdfColor.FromRgb(30, 30, 30))
    .CornerRadius(12)
    .Background(PdfColor.FromRgb(245, 245, 245))
    .BackgroundLinearGradient(45, PdfColor.FromHex("#FFDEE9"), PdfColor.FromHex("#B5FFFC"))
    .Shadow(new PdfBoxShadow(PdfColor.FromArgb(60, 0, 0, 0), offsetX: 0, offsetY: 4, blur: 12))
    .Width(300)
    .Text("A card");

BorderLinearGradient(width, angleDegrees, stops) paints a gradient border instead of a solid one. Lengths accept a bare number (points) or an explicit unit — PdfLength.Pixels(16), PdfLength.Inches(1), PdfLength.Percent(50), PdfLength.Em(1.5) — resolved through PeachPDF’s own CSS length parser.

LineHorizontal(thickness, color)/LineVertical(thickness, color) place a simple rule — a solid-filled line by default, or dashed: true for a dashed one:

container.LineHorizontal(1, PdfColor.FromHex("#BBBBBB"), dashed: true);

Class, Id, Tag, and a document-level stylesheet

Class/Id/Tag tag a container the same way an HTML element’s class/id/tag name would, so a stylesheet attached to the whole document (IDocumentBuilder.Stylesheet) can target it with a real CSS selector:

var stylesheet = await generator.ParseStyleSheet("""
    .card { border: 1px solid #ccc; }
    .card .title { font-weight: 700; }
    #hero { background: #f5f5f5; }
    """);

var document = await generator.CreateDocument(doc =>
{
    doc.Stylesheet(stylesheet);
    doc.Page(page =>
    {
        page.Content(container =>
        {
            container.Id("hero").Class("card").Column(column =>
            {
                column.Item().Class("title").Text("Card title");
            });
        });
    });
});

Selector matching is real and general - class, id, compound, descendant, attribute, and :not()/:is()-style selectors all work, the same selector engine HTML rendering uses. Tag(name) renames a container’s own internal tag (every anonymous container defaults to a synthetic "div", an image to "img", a hyperlink to "a") so a bare type selector like li { ... } can target it precisely - without a rename, a type/universal selector still matches this API’s own internal structure (div/img/a), so prefer class/id/Tag for predictable targeting.

An explicit builder call always outranks a plain (non-!important) stylesheet rule for the same property - the same precedence an inline style="" attribute has over an author stylesheet - but a matching !important rule still wins, matching ordinary CSS:

// stylesheet: .card { padding: 8pt; }
container.Class("card").Padding(20);   // ends up 20pt - the explicit call wins

IDocumentBuilder.Stylesheet can be called anywhere in the document-building callback (Page only records its own handler; every page’s content tree, and this stylesheet’s effect, are built afterward) - only the last call is kept. Class/Id/Tag have no visual effect at all when no stylesheet is attached; they’re a stable hook a document can add before it needs styling.

A stylesheet’s @font-face/@property/@font-palette-values rules take effect too - fonts register, custom properties resolve through var() - with the same precedence rule extended to @page: a base @page rule’s margin/size only fills in whatever the page’s own Margin*/Size call left unset, ranking between an explicit call and the document’s PdfGenerateConfig default. PageName(name) sets the CSS page property (CSS 2.1 §13.2) so a document-level @page name { ... } rule can target this point in the flow onward:

var stylesheet = await generator.ParseStyleSheet("@page chapter { size: landscape; }");

column.Item().PageName("chapter").Text("A landscape chapter starts here.");

Stamping pages of an existing PDF

IDocumentBuilder.OverlayFile and UnderlayFile draw pages of an existing PDF over or under the document’s own pages - a watermark, a “DRAFT” stamp, a letterhead, a form background:

var document = await generator.CreateDocument(doc =>
{
    // The letterhead sits behind every page's own content.
    doc.UnderlayFile(layer => layer.File("letterhead.pdf").SourcePages("1").RepeatSourcePages("1"));

    // A stamp on the last page only.
    doc.OverlayFile(layer => layer.File(stampBytes).TargetPages("z"));

    doc.Page(page => page.Content(container => container.Text("...")));
});

File takes a path, the bytes of a PDF, or a stream. Page numbers are one-based and a range is a comma-separated list of pages and spans - "1", "1-3,5", "2-z" - where z is the last page and a backwards span ("z-1") runs backwards.

Layers apply in the order they were added, and an underlay is drawn before the page’s own background and content. A layer page is drawn into the new document (its text, paths, clipping, colour and transparency become ordinary content of the new page, with fonts re-embedded and the text keeping its text map), not copied object for object; it is clipped to its own page box and placed at the top-left of the page it lands on. Images (JPEG, JPEG 2000, CCITT fax, JBIG2 and the Flate/LZW/run-length family, with stencil, colour-key and soft masks) axial, radial and mesh gradients, and luminosity and alpha soft masks come across too. What the importer cannot draw yet - and fonts that are not embedded as a TrueType, OpenType, CFF or Type 1 program - is left out of the layer, so check a layer file renders as you expect. A file protected with AES (128 or 256 bit) and the empty user password (the usual “no editing or copying” protection) is opened; one that needs a password to open, or that uses RC4, throws NotSupportedException. A file that is not a PDF throws InvalidDataException, and a malformed range throws FormatException, both before any page is drawn.

A layer also brings the navigation of the pages it draws. Their links (a web address, or a link to another page of the file) are added to the page they land on, a link to a page of the file leading to the page that page was drawn on; a link to a page that was not drawn is left out. The file’s named destinations that lead to a drawn page, and its outline entries for drawn pages (with the entries they nest under, kept as plain headings), join the document’s own; the outline comes after the document’s own entries. The file’s title, author, subject, keywords and language fill whichever of those the document does not set itself, and its attached files are attached to the document (not for PDF/A-1 and PDF/A-2, which do not allow attachments). A file’s PDF/A or PDF/X claim is never taken over, since the document’s content is not the file’s: the document conforms to the level its own PdfAConformance and PdfXConformance ask for. The one marker that is used is the file’s output intent, for a document that was not given an output intent profile of its own and is not building to PDF/A (which brings its own): a document building to PDF/X needs one, and any other document then records what its colours are meant for, without claiming a level. A layer page’s interactive form fields (text fields, multi-line text fields, check boxes, radio buttons, drop-downs, list boxes, push buttons and signature fields) are added to the page as real fields, drawn with the appearance the file gave them; a page drawn more than once gets its fields again under a numbered name (name_2). Its other annotations (highlights, underlines, notes, stamps, drawings, shapes) are added as real annotations with their contents, colours, opacity, border width, marked regions, ink strokes and appearance, so they can still be selected and edited in a reader; one with no appearance stream, one marked hidden, and pop-up windows are left out. Actions are not carried: a push button does nothing, and a signature field comes across unsigned (the signature would no longer match the new document). When the document is tagged (EnableTaggedPdf), the logical structure of a layer’s pages comes across too: the headings, paragraphs, lists, tables and figures the file tagged (with a figure’s alternative text) join the document’s structure tree, using the standard type a custom tag’s role map names, and everything the file did not tag is marked as an artifact so the layer adds no untagged content. Each element keeps its language, actual text, title and attributes (a table header’s scope, a cell’s spans), a link’s structure element is tied to its annotation, and the elements are in the order of the file’s structure tree rather than the order they are drawn in. Elements with no marked content or link on the page, class maps and element identifiers are not carried. In an untagged document nothing is marked.

The same importer is available on its own as PdfPageSource in PeachDrawing.PDF.

Text and rich text

container.Text("Plain paragraph text.");

container.Text(t =>
{
    t.Alignment(TextAlignment.Justify);
    t.Span("Regular, ");
    t.Span("bold").Bold();
    t.Span(", and ");
    t.Span("italic red").Italic().FontColor(PdfColor.Red);
    t.Span(" text, plus ");
    t.Span("underlined").Underline().DecorationColor(PdfColor.Blue).DecorationStyle(PdfTextDecorationStyle.Wavy);
    t.Span(" and a ");
    t.Span("superscript").Superscript();
    t.Span(" footnote marker.");
});

ITextStyle (shared by DefaultTextStyle and every span) also covers FontWeight(int) (a raw 1–1000 weight, not just Bold()), LetterSpacing/WordSpacing, LineHeight, Subscript(), FontFeature(tag, enabled) for OpenType features, Direction(PdfTextDirection.Rtl) for right-to-left paragraphs (or PdfTextDirection.Auto to detect it from the span’s own text — the same first-strong-character detection dir="auto" uses on the HTML side), and BreakAnywhere(). A container itself has ParagraphFirstLineIndentation, ParagraphSpacing, and ClampLines(n, ellipsis) to truncate a block after a fixed number of lines, marking the cut with the usual “…” by default, a custom string (e.g. ClampLines(2, "… [continued]")), or nothing at all (ClampLines(2, "")). ITextSpanContainer.Element(content => ...) inserts arbitrary container content — an image, a styled box — inline between spans.

Images

container.Image(imageBytes);                 // byte[] - raster or SVG, detected automatically
container.Image(stream);                     // Stream, read fully
container.Image(new Uri("https://example.com/logo.png"));
container.Image(@"C:\assets\logo.png");      // local file path

// Reuse the same resolved image in more than one place - decoded once, not once per placement:
var logo = PdfImage.FromFile(@"C:\assets\logo.png");
container.Image(logo);
otherContainer.Image(logo);

Image(byte[])/Image(Stream) decode directly into the image the container places (no data: URI round trip); Image(Uri)/Image(string filePath) still load lazily, over the network or from disk. PdfImage.FromFile/FromBytes/FromStream load once and cache the decoded result, so placing the same PdfImage instance in more than one container (even across pages) decodes its source only the first time.

An image placed with Image sizes itself from its own intrinsic dimensions (the raster’s pixel size, or an SVG’s width/height/viewBox) unless the image itself - not the container it’s chained onto - is given an explicit size; Width/Height called before Image/Svg sizes the container the image sits in, which the image doesn’t automatically stretch to fill (ordinary CSS replaced-element sizing - the same as an <img> with no width/height of its own inside a sized <div>). To make image content fill a container of a known size, generate or author it at that size directly, or use the dynamic overloads below, which do fill by default.

Standalone SVG

container.Svg(svgMarkup);   // string
container.Svg(stream);      // Stream, read fully
container.Svg(svgBytes);    // byte[]

Parses SVG markup directly into a real vector image, painted with PeachPDF’s own SVG renderer - the same one an inline <svg> or <img src="x.svg"> already uses on the HTML side, so anything on the SVG feature matrix works here too.

Dynamic (size-aware) content

container.Width(300).Height(160).Image(size =>
{
    // size.Width/size.Height are the container's own resolved point size - generate content
    // at exactly that size instead of a guessed fixed one.
    return RenderChartAsPng((int)size.Width, (int)size.Height);
});

container.Width(300).Height(160).Svg(size => RenderChartAsSvgMarkup(size.Width, size.Height));

Image(Func<PdfSize, byte[]>)/Svg(Func<PdfSize, string>) run their callback once layout knows the container’s resolved size, and - unlike the other overloads above - fill that container by default (width: 100%; height: 100%), so the generated content is sized to fit rather than needing its own explicit dimensions. The container itself still needs a definite resolved size for this to mean anything: an explicit Width/Height, or an ancestor that already has one (the page content area, a table cell with a definite column width, a Row/Column item with an explicit size); an indefinite container throws InvalidOperationException rather than generating content at a garbled or zero size. Each callback runs exactly once, even across the layout engine’s own internal convergence passes.

HTML fragments and slots

Html(markup) parses an HTML fragment (through the same parser and cascade the HTML-string rendering path uses) and splices it into a container’s tree in place:

container.Html("""
    <table>
      <tr><td>Row 1</td><td>Data</td></tr>
      <tr><td>Row 2</td><td>Data</td></tr>
    </table>
    """);

Unlike every other terminal method here, a fragment can contain more than one top-level element - real HTML semantics apply throughout (UA default styles, an anonymous-table fixup for a bare <tr>, the fragment’s own <style> tag). Pass a stylesheet for the fragment’s own class/id-driven styling to cascade against:

var cardStyles = await generator.ParseStyleSheet(".highlight { background: yellow; }");
container.Html("""<p class="highlight">Important</p>""", cardStyles);

A <link rel="stylesheet"> inside the fragment is never loaded (there is no document context to resolve it through at this point in building the tree) - author a <style> tag, or pass CSS via the stylesheet parameter, instead. Stream/byte[] overloads exist alongside the string one, matching Svg’s own shape.

Slots

A fragment can declare replaceable <slot> points, Web-Components style:

container.Html(
    """<div class="card"><slot name="body">Loading…</slot></div>""",
    onSlot: (slot, slotContainer) =>
    {
        if (slot.Name == "body")
        {
            slotContainer.Text("Real content, filled in from C#.");
        }
    });

onSlot fires once per <slot> element in the fragment (in document order, regardless of name), with a SlotContext (its Name and Attributes) and an IContainer positioned to replace it - populate it the same way any other container is populated. A slot the callback leaves untouched (including when onSlot is null altogether) keeps its own fallback content - whatever markup it contained - exactly as authored.

container.Hyperlink("https://peachpdf.net/").Text("Visit our site");

container.Bookmark("Chapter One", level: 1).Text("Chapter One");

Hyperlink produces a real PDF link annotation; Bookmark adds a real PDF outline (sidebar) entry, at the given nesting level (1 = top level) — both reuse the same generic link/bookmark machinery an HTML <a href>/heading already gets.

Rows and columns

Backed by PeachPDF’s flexbox layout engine:

container.Row(row =>
{
    row.Spacing(8);
    row.Item().Width(80).Height(40).Background(PdfColor.Red);
    row.Item().Grow().Height(40).Background(PdfColor.Green); // fills remaining space
    row.Item().Width(80).Height(40).Background(PdfColor.Blue);
});

container.Column(column =>
{
    column.Spacing(6);
    column.Item().Text("First line");
    column.Item().Text("Second line");
});

Grow(ratio) maps to flex-grow; combine it with an explicit Width/Height on sibling items for a “fixed items plus one flexible item” layout.

AlignLeft()/AlignCenter()/AlignRight() position a container within its parent (with auto margins, so give it a Width narrower than the parent - or leave it auto-width, and it shrinks to its content). They work on both Row and Column items. Text inside a container is aligned separately, with Alignment(...) on the text (t.Alignment(TextAlignment.Right)).

Tables

Backed by the real CSS table layout algorithm, including colspan/rowspan:

container.Table(table =>
{
    table.Columns(columns =>
    {
        columns.FixedColumn(80);   // an absolute width
        columns.RelativeColumn(2); // shares the remaining width, weighted against other relative columns
        columns.RelativeColumn(1);
    });
    table.Header(header =>
    {
        header.Cell().Text("ID");
        header.Cell().Text("Name");
        header.Cell().Text("Amount");
    });
    table.Row(row =>
    {
        row.Cell().Text("1");
        row.Cell(columnSpan: 2).Text("A row spanning two columns");
    });
});

To right-align a column of amounts, align the cells’ text — row.Cell().AlignRight().Text("3.20") (or Text(t => { t.Alignment(TextAlignment.Right); t.Span("3.20"); })). Margins do not apply to a table cell, so on a cell AlignLeft()/AlignCenter()/AlignRight() align the cell’s content instead of positioning it.

Header/Footer repeat on every physical page the table spans, exactly like a real <thead>/<tfoot>.

Lists

Backed by PeachPDF’s own list-style-type/list-style-position/list-style-image support, which already covers dozens of numbering systems:

container.OrderedList(list =>
{
    list.Item().Text("First");
    list.Item().Text("Second");
}, PdfListMarkerType.UpperRoman); // → I., II., ...

container.UnorderedList(list =>
{
    list.Position(PdfListMarkerPosition.Inside);
    list.Item().Text("Bulleted");
}, PdfListMarkerType.Square);

IListDescriptor.MarkerImage(...) uses an image as every item’s marker instead of a bullet/number; MarkerText("→ ") uses a literal custom marker string.

Headers, footers, and page numbers

page.Header(header =>
{
    header.Text("My Document");
});
page.Footer(footer =>
{
    footer.Text(t =>
    {
        t.Alignment(TextAlignment.Center);
        t.Span("Page ");
        t.CurrentPageNumber();
        t.Span(" of ");
        t.TotalPages();
    });
});

Header/Footer content repeats at the top/bottom of every physical page the page’s own content paginates across (the same position: running() + @page margin-box mechanism an HTML document uses for repeating headers). CurrentPageNumber()/TotalPages() only resolve inside Header/Footer content — a page number has no meaning in ordinary flowing content.

Sectioned page numbers

A page count scoped to a named part of the document, rather than the whole thing - “Page 2 of 5” within a chapter, not the whole report:

container.Column(column =>
{
    column.Item().Text("Cover / table of contents...");

    column.Item().BeginPageNumberOfSection("chapter1").Text("Chapter 1");
    column.Item().Text("Chapter 1 content, spanning several pages...");
    column.Item().EndPageNumberOfSection("chapter1").Text("End of Chapter 1.");

    column.Item().Text("Appendix...");
});

page.Footer(footer => footer.Text(t =>
{
    t.Span("Page ");
    t.PageNumberWithinSection("chapter1");
    t.Span(" of ");
    t.TotalPagesWithinSection("chapter1");
}));

BeginPageNumberOfSection(id)/EndPageNumberOfSection(id) are decorators, like Bookmark - they tag whatever container they’re chained onto (rather than placing content of their own), so chain one before that container’s own terminal content and it can go anywhere in a page’s normal content flow (not just Header/Footer). PageNumberWithinSection(id)/TotalPagesWithinSection(id) then resolve against the physical pages that pair of tagged containers land on, the same way CurrentPageNumber()/TotalPages() do - only inside Header/Footer content, and continuously renumbered per document page. The current-page-within-section number is deliberately unclamped outside the section itself: it climbs by exactly one per document page throughout the whole document, so it reads non-positive before the section starts and past the total after it ends - a footer that only wants to show it inside the section’s own pages should condition on that itself. An id with no matching BeginPageNumberOfSection call (a typo, or a forgotten call) resolves to 1 rather than throwing, the same fallback an unresolved target-counter() reference already uses.

Merging documents

DocumentMerge writes several declarative documents into one PDF. Each part is the callback that builds one document, exactly what CreateDocument takes:

static Action<IDocumentBuilder> Report(string title) => doc => doc.Page(page =>
{
    page.Size(420, 595).Margin(30);
    page.Header(h => h.Text(title));
    page.Content(c => c.Text("..."));
    page.Footer(f => f.Text(t => { t.CurrentPageNumber(); t.Span(" of "); t.TotalPages(); }));
});

var merge = DocumentMerge.Of(Report("Q1"), Report("Q2")).UseContinuousPageNumbers();
var document = await generator.CreateMergedDocument(merge);

AddMergedPages(document, merge) appends a merge to a document that already has pages, the way AddPages does for one document.

Numbering the parts as one run needs every page count before any page is drawn, so the parts are laid out once to count and again to draw, and each callback runs twice: build the document from the callback’s own state, not from anything that must happen once. Only the page numbers a document prints change (counter(page), counter(pages) and target-counter(..., page)); @page rules (:first, :left, :right) still select by a page’s place in its own part, so each part’s cover page is its own :first page. A part with several Page calls numbers them as one run in both modes, where a document built by CreateDocument alone restarts the numbering at each Page call. File layers added inside a part (OverlayFile, UnderlayFile) read their target pages against the merged document, so "1" is the first page of the first part.

The PdfGenerateConfig passed to CreateMergedDocument applies to every part. To put PDF files that already exist in the document, see Adding existing PDFs below; to work on the saved files themselves, use PdfOperation from PeachDrawing.PDF.

Adding existing PDFs

Append and Prepend on the document builder write the pages of an existing PDF into the document being generated, as a section like a Page call is. The sections come out in the order of the calls:

var document = await generator.CreateDocument(doc =>
{
    doc.Append(File.ReadAllBytes("cover.pdf"));       // first: nothing comes before it

    doc.Page(page =>
    {
        page.Size(PageSize.A4);
        page.Margin(20);
        page.Content(c => c.Text("The permit"));
    });

    doc.Append(File.ReadAllBytes("regulations.pdf")); // last
});

Prepend puts the PDF before everything else wherever it is called (several Prepend calls keep their order). Append and Prepend take the bytes of a file, a Stream, or a PdfOperation, which chooses pages (TakePages("2-5")), joins several files (MergeFile) or stamps a layer on them (OverlayFile) before they are added. A file that needs a password takes it as the password argument.

A DocumentMerge part can use Append and Prepend too. With UseContinuousPageNumbers() on the merge the added pages are counted in the run; with original numbering they count only if the part’s builder called UseContinuousPageNumbers() itself.

Known v1 limitations