Skip to content

How-to guides

Focused, task-oriented recipes: one task per page, mapped to the examples catalog.

Each recipe below is self-contained: a sentence or two on why, then the code. They assume you have the packages installed (dotnet add package Rivoli.Pdf.Core and dotnet add package Rivoli.Pdf). If you are new to the library, read Getting started and the tutorial first; the recipes here link back to the relevant chapters.

Use MergePdfs from Rivoli.Pdf. It concatenates existing PDF files (or streams) into a single output, page order following input order. There is a params overload for the common “just give me a list of paths” case, plus one that takes an IEnumerable<string> and a target PDF version.

using Rivoli.Pdf;
DocumentExtensions.MergePdfs("combined.pdf", "cover.pdf", "body.pdf", "appendix.pdf");
// Or with an explicit version and an enumerable of inputs:
DocumentExtensions.MergePdfs(
"combined.pdf",
new[] { "cover.pdf", "body.pdf", "appendix.pdf" },
pdfVersion: "1.7");

There is also a stream overload (MergePdfs(Stream output, IEnumerable<Stream> inputs, ...)) and MergePdfsWithMetadata(...) when you want to set the merged document’s title/author.

SplitPdf extracts a page range into a new file. Pages are 1-based: startPage is the first page to keep, count is how many. There are overloads for an explicit list of page numbers and for streams, plus SplitPdfIntoSeparateFiles(...) to burst one document into many.

using Rivoli.Pdf;
// Pages 2 through 4 (start at page 2, take 3 pages).
DocumentExtensions.SplitPdf("report.pdf", "extract.pdf", startPage: 2, count: 3);
// A specific, non-contiguous set of pages:
DocumentExtensions.SplitPdf("report.pdf", "selected.pdf", new[] { 1, 3, 7 });

LoadPdf parses an existing PDF back into the in-memory document model, so you can inspect or transform it with the same API you use to author. It accepts a path or a stream, and there is an async variant.

using Rivoli.Pdf;
Document loaded = DocumentExtensions.LoadPdf("existing.pdf");
foreach (var section in loaded.Sections)
foreach (var block in section.Blocks)
// inspect blocks, extract text, re-style, then re-save...
;

Document-level features of the source survive the round-trip: bookmarks (Document.Outlines), comments and markup (Document.ImportedAnnotations), page labels, /Info metadata and the XMP packet, optional-content layers (including hidden ones, kept toggleable, including OCMD //VE visibility expressions), print page boxes, color-space identity (/Separation//DeviceN spot colors, exact CMYK ink splits, ICC profiles, /Indexed//Lab//Cal*), and prepress graphics-state parameters such as overprint: on paths, images, and text alike.

How do I safely read PDFs I didn’t produce?

Section titled “How do I safely read PDFs I didn’t produce?”

Anything you accept from a user, a mailbox, or a third-party system is untrusted input. A PDF’s declared sizes, compression ratios, object counts and reference graph are all under the sender’s control, so a few kilobytes on the wire can expand into gigabytes of memory or unbounded CPU. Pass PdfReadOptions and the budgets are enforced for you.

using Rivoli.Pdf;
using Rivoli.Pdf.Reading;
var options = PdfReadOptions.Default // safe budgets are the default
.WithCancellation(httpContext.RequestAborted);
try
{
var document = DocumentExtensions.LoadPdf(uploadStream, options);
// ...
}
catch (PdfLimitExceededException ex)
{
// Too expensive to process: ex.Budget names which limit, with ex.Limit / ex.Actual.
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
catch (PdfParseException)
{
// Malformed: a different problem, and not worth retrying with bigger budgets.
return Results.BadRequest();
}

PdfLimitExceededException is deliberately not a PdfParseException: a document that is merely too expensive and one that is broken deserve different responses, and only the first is worth retrying with raised budgets.

PdfReadLimits is immutable; With* returns a copy. A budget of 0 means no limit.

var strict = PdfReadOptions.Default.WithLimits(
PdfReadLimits.Default
.WithMaxInputBytes(20 * 1024 * 1024) // reject >20 MB uploads outright
.WithMaxPages(500)
.WithMaxDecodedImageBytes(100 * 1024 * 1024)
.WithMaxTotalDecodedBytes(100 * 1024 * 1024));
// For documents you generated yourself, opt out entirely:
var trusted = PdfReadOptions.Default.WithLimits(PdfReadLimits.Unlimited);
BudgetDefaultBounds
MaxInputBytes512 MiBThe file itself, checked before parsing starts
MaxTotalDecodedBytes500 MiBDecompression across the whole document, so many small streams can’t add up
MaxDecodedBytesPerStream128 MiBA single decompression bomb
MaxFilterChainLength8Nested filters, where each layer multiplies the last
MaxObjects / MaxXrefEntries5,000,000Object-count explosions
MaxPages100,000Page-count explosions
MaxReferenceDepth256Deep and cyclic reference graphs
MaxObjectStreamMembers100,000A declared member count driving a parse loop
MaxImagePixels100,000,000Declared image dimensions, checked before allocation
MaxDecodedImageBytes400 MiBThe RGBA allocation for one decoded image
MaxRecoveryScanBytes256 MiBRebuilding a damaged cross-reference table

The defaults sit far above any legitimate document, so ordinary files are unaffected: measured overhead on a normal multi-page report is below run-to-run noise.

Add OutlineItems to Document.Outlines. Each entry has a title, a zero-based target page index, optional X/Y/Zoom for the exact landing position, and children for nesting. Entries imported from an existing PDF live in the same collection.

var chapter = doc.AddOutline("1. Introduction", pageIndex: 0);
chapter.Children.Add(OutlineItem.Create("1.1 Motivation", pageIndex: 1));
chapter.IsOpen = true; // expanded in the bookmark panel
doc.AddOutline("2. Results", pageIndex: 4);

A document with no outline entries emits no /Outlines dictionary, so authored output is unchanged unless you opt in.

How do I control the page numbers a viewer displays (page labels)?

Section titled “How do I control the page numbers a viewer displays (page labels)?”

Page labels change the numbers shown in the viewer’s page indicator: they do not touch the content. The classic use is roman-numeral front matter followed by arabic body pages:

doc.AddPageLabelRange(0, PageLabelStyle.RomanLower); // i, ii, iii …
doc.AddPageLabelRange(3, PageLabelStyle.Decimal); // 1, 2, 3 …
doc.AddPageLabelRange(9, PageLabelStyle.Decimal, "A-"); // A-1, A-2 … appendix

Ranges apply from their start page index until the next range begins. For the numbers printed on the page, use header/footer tokens (previous answer): the two are independent.

How do I add repeating headers, footers, and page numbers?

Section titled “How do I add repeating headers, footers, and page numbers?”

Attach a HeaderFooterConfig to Section.HeaderFooter. The renderer re-emits the header and footer bands on every page of the section. For page numbers, put the tokens {PAGE} (current page, 1-based), {PAGES} (total page count), or {PAGE_OF_PAGES} (“3 of 8”) in the text: they are substituted per page at render time, inside Paragraph text and inside Table cells (a borderless three-column table is the usual way to get left/center/right alignment). Page numbers run sequentially across all sections of the document.

using Rivoli.Pdf.Model;
var footer = new Footer();
footer.Add(new Paragraph("Page {PAGE} of {PAGES}")
{
Style = new Style { FontSize = 9, TextAlignment = TextAlignment.Right },
});
var header = new Header();
header.Add(new Paragraph("ACME · Quarterly Report")
{
Style = new Style { FontSize = 9, FontWeight = FontWeight.Bold },
});
section.HeaderFooter = new HeaderFooterConfig { Header = header, Footer = footer };

HeaderFooterConfig also has FirstPageHeader/FirstPageFooter and odd/even variants (OddPageHeader, EvenPageHeader, and the footer equivalents) for cover pages and two-sided printing; the renderer resolves first-page, then odd/even, then the default per page. The runnable example is samples/Rivoli.Pdf.Samples/48-HeaderFooterSample.cs.

How do I build a report table that repeats its header and totals across pages?

Section titled “How do I build a report table that repeats its header and totals across pages?”

Set Table.AllowPageBreak so a long table splits across pages. The header band repeats at the top of every slice. Set Table.RepeatingHeaderRowCount to repeat more than the first row (default 1). Mark totals rows with TableRow.IsFooter (or the Footer(...) builder) and they re-emit at the bottom of every slice. Table.Columns carries per-column defaults (alignment, style, width) at the lowest precedence, and Rows<T> binds a sequence of records without a manual AddCell loop.

using Rivoli.Pdf.Builders;
using Rivoli.Pdf.Model;
var table = new Table { AllowPageBreak = true, RepeatingHeaderRowCount = 1 };
table.SetColumnWidths(ColumnWidth.Fixed(80), ColumnWidth.Relative(2), ColumnWidth.Fixed(95));
table.Columns.Add(new TableColumn());
table.Columns.Add(new TableColumn());
table.Columns.Add(new TableColumn { DefaultStyle = new Style { TextAlignment = TextAlignment.Right } });
table.Header("Date", "Description", "Amount");
table.Rows(invoices, i => i.Date, i => i.Description, i => i.Amount.ToString("C"));
table.Footer("", "Total", invoices.Sum(i => i.Amount).ToString("C"));

The runnable example is samples/Rivoli.Pdf.Samples/59-EnterpriseTableSample.cs.

How do I render an image I already have in memory?

Section titled “How do I render an image I already have in memory?”

Set ImageBlock.ImageData instead of ImagePath. This is the path for images your process already holds (fetched over HTTP, read from a database, or generated on the fly), so nothing has to be staged to a temporary file and cleaned up afterwards. That matters on read-only filesystems and in containers, and it removes the temp-file leak a cancelled request would otherwise cause.

using Rivoli.Pdf.Model;
byte[] chart = await httpClient.GetByteArrayAsync(chartUrl);
var section = document.AddSection();
section.Add(ImageBlock.FromBytes(chart, width: 240, height: 160));
// Or read a stream to its end (the stream is consumed immediately, so you can
// dispose it right away):
using var blob = await container.OpenReadAsync(blobName);
section.Add(ImageBlock.FromStream(blob).WithSize(96, 96));

PNG and JPEG are supported, detected from the bytes themselves rather than a file extension. Output is identical to loading the same bytes from disk, and identical content is decoded and embedded once however many blocks reference it, so a logo repeated on every page costs one XObject even if each block holds its own copy of the array.

ImageData takes precedence when both it and ImagePath are set, which lets you override a serialized path without clearing it.

See sample 66.

How do I put paragraphs, lists or images inside a table cell?

Section titled “How do I put paragraphs, lists or images inside a table cell?”

A cell is not limited to a plain string: TableCell.Blocks carries full block content: multiple paragraphs with mixed inline formatting, images, lists, even a nested table. The blocks are laid out top-to-bottom at the cell’s content width (the column band minus Table.CellPadding), and the row grows to fit the tallest cell. When Blocks is non-empty it takes precedence over TableCell.Text; cells with only Text keep the simple path and render byte-identically to previous releases.

using Rivoli.Pdf.Builders;
using Rivoli.Pdf.Model;
var finding = new Paragraph();
finding.Inlines.Add(new Run("Stale credentials. ") { Style = new Style { FontWeight = FontWeight.Bold } });
finding.Inlines.Add(new Run("Two service accounts bypass the rotation policy."));
var steps = new List();
steps.Items.Add(new ListItem { Text = "Rotate both keys" });
steps.Items.Add(new ListItem { Text = "Enable expiry alerts" });
var table = new Table { AllowPageBreak = true };
table.SetColumnWidths(ColumnWidth.Fixed(64), ColumnWidth.Relative(1), ColumnWidth.Fixed(80));
table.Header("ID", "Finding", "Severity");
table.Row(
new TableCell("AUD-101"),
new TableCell(finding, new Paragraph("Remediation steps:"), steps),
new TableCell("High"));

Rich cells compose with the rest of the table machinery: the cell’s effective style (cell → table → column default) is what the blocks inherit, so a paragraph without its own style picks up the cell font and alignment; the effective style’s VerticalAlignment places the block stack within the cell (default Middle, matching plain text cells); ColumnSpan/RowSpan give the blocks the merged band’s width and height; and AllowPageBreak slicing keeps every rich row whole: a row never straddles two pages. Painting is clipped to the cell box, so over-tall content cannot bleed into neighboring cells.

The runnable example is samples/Rivoli.Pdf.Samples/64-RichTableCellsSample.cs.

How do I generate a PDF in an ASP.NET Core endpoint?

Section titled “How do I generate a PDF in an ASP.NET Core endpoint?”

Build the document in an injectable service, render it to an in-memory buffer, and return it as a file response. The runnable sample lives at samples/Rivoli.Pdf.AspNetCore.Sample.

using Rivoli.Pdf;
// Minimal-API endpoint. The document service is injected (DI), keeping
// the handler thin and the builder testable.
app.MapGet("/invoice.pdf", (string? billTo, IInvoiceDocumentService invoices) =>
{
var document = invoices.BuildInvoice(billTo ?? "Acme Corp");
using var buffer = new MemoryStream();
document.SavePdf(buffer);
return Results.File(buffer.ToArray(), "application/pdf", "invoice.pdf");
});

Use SavePdfAsync. It mirrors SavePdf (path or stream, with a version argument) and takes a CancellationToken. The token is threaded into the render loop and checked once per page, so a long multi-page render is genuinely interruptible, if the caller cancels (an aborted HTTP request, an elapsed deadline), the render throws OperationCanceledException promptly instead of running to completion. Writes to the target stream use only asynchronous I/O, so it is safe on a Kestrel response stream with AllowSynchronousIO = false.

using Rivoli.Pdf;
await document.SavePdfAsync("report.pdf", cancellationToken: ct);
// Or to a stream, with an explicit version:
await document.SavePdfAsync(outputStream, PdfVersion.Version17, ct);

The PDF writer itself is synchronous, so the render is CPU-bound work made cancellable rather than true async I/O; cancellation is real (per page), which is what matters for server workloads. LoadPdfAsync is the read-side counterpart. See the builder patterns for how to keep document construction itself clean.

How do I render very large documents without running out of memory?

Section titled “How do I render very large documents without running out of memory?”

Turn on PdfSaveOptions.StreamingWrite. By default the renderer buffers every PDF object in memory and writes the whole file at the end (simple, and fine for typical documents, but peak memory grows with the total content size. With StreamingWrite = true, finalized objects (page content streams, images, font programs), the bulk of a large document) are written to the output stream as each page finishes rendering, so the GC reclaims them immediately. Only the small skeleton objects (page dictionaries, catalog, outlines, structure tree) stay buffered until the end. On a content-heavy document this cuts the render’s heap growth from “the whole document” to roughly “one page at a time.”

using Rivoli.Pdf;
var options = new PdfSaveOptions { StreamingWrite = true };
using var file = new FileStream("big-report.pdf", FileMode.Create, FileAccess.ReadWrite);
new PdfDocumentRenderer().Render(bigDocument, file, options);
// The file is complete when Render returns: xref and trailer included.

What to know before opting in:

  • Deterministic, but not byte-identical to the default mode. Rendering the same document twice with StreamingWrite produces byte-identical output (encryption excepted: its random initialization vectors make every save unique in any mode). The bytes differ from the buffered mode’s only in the physical order of objects in the file; the object numbering is identical and the cross-reference table maps everything, so readers see the same document. Don’t mix the two modes in golden-file comparisons.
  • The output stream must be seekable (the writer records byte offsets for the xref table). A FileStream is the natural target. For an HTTP response, render to a temp file or buffer first. See the ASP.NET Core note above.
  • Bytes are written during the render. A failed or cancelled render leaves a partial file for the caller to discard, unlike the buffered mode which writes only on success. (SavePdfAsync is unaffected: it renders into its own buffer before touching your stream.)
  • Encryption, PDF/A, Tagged PDF, outlines, attachments and form fields all work in streaming mode; this is a per-export option with no process-wide state.

The runnable demo is sample 65 (samples/Rivoli.Pdf.Samples/65-StreamingWriteSample.cs); the lower-level seam it rides on is described in the low-level patterns.

How do I handle CJK text and embed the right font?

Section titled “How do I handle CJK text and embed the right font?”

CJK glyphs need a font that contains them; supply one through PdfSaveOptions.CjkFontPath for a file path, or PdfSaveOptions.CjkFontData for an in-memory font program (the container-friendly option: ship the font as an embedded resource or blob rather than relying on OS font paths). When both are set, CjkFontData wins. Because this is an advanced option, render through PdfDocumentRenderer rather than the SavePdf extension.

using Rivoli.Pdf;
var renderer = new PdfDocumentRenderer("1.7");
// From a file path:
renderer.RenderToFile(document, "cjk.pdf", new PdfSaveOptions
{
CjkFontPath = "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
});
// Or from bytes (e.g. an embedded resource), with no dependency on the host filesystem:
byte[] cjkFont = LoadEmbeddedFont();
renderer.RenderToFile(document, "cjk.pdf", new PdfSaveOptions
{
CjkFontData = cjkFont,
});

How do I register a custom font (and ship it inside a container)?

Section titled “How do I register a custom font (and ship it inside a container)?”

Register a logical family’s faces on the Document, then reference the family from a Style. The renderer embeds and subsets the matching face by weight and posture. Faces can come from a file path or, for containers that ship fonts as embedded resources or blobs, directly from bytes or a stream: no dependency on OS font directories.

using Rivoli.Pdf;
using Rivoli.Pdf.Styling;
var doc = Document.Create("Report");
// From bytes (e.g. an embedded assembly resource or object-store download):
byte[] regular = LoadEmbeddedFont("SourceSerif-Regular.ttf");
byte[] bold = LoadEmbeddedFont("SourceSerif-Bold.ttf");
doc.RegisterFontFamily("Source Serif", regular, FontWeight.Normal);
doc.RegisterFontFamily("Source Serif", bold, FontWeight.Bold);
// A stream overload is also available:
using var stream = typeof(Program).Assembly.GetManifestResourceStream("Fonts.SourceSerif-Italic.ttf")!;
doc.RegisterFontFamily("Source Serif", stream, FontWeight.Normal, italic: true);
// From a file path (unchanged):
doc.RegisterFontFamily("Source Serif", FontWeight.Normal, italic: false, "/fonts/SourceSerif-Regular.ttf");
doc.AddSection().Add(new Paragraph("Rendered with the registered family")
{
Style = new Style { FontFamily = "Source Serif", FontWeight = FontWeight.Bold },
});

Registration is strictly opt-in: a family you never register leaves the default base-14 font selection unchanged, so existing output stays byte-identical.

Pass a PdfVersion (or the string form "1.7") to SavePdf, or set PdfSaveOptions.PdfVersion. The enum is Version14, Version15, Version16, Version17. The default is Version14.

using Rivoli.Pdf;
document.SavePdf("modern.pdf", PdfVersion.Version17);
document.SavePdf(stream, "1.7"); // string form, same effect

Prefer per-call PdfSaveOptions. Build a PdfSaveOptions (optionally from a shared template) and pass it to each render. It is passed per call, so it is safe when several documents render concurrently: the norm in a multi-tenant web server.

using Rivoli.Pdf;
// A shared template you construct once, then pass to every render.
PdfSaveOptions MakeOptions() => new()
{
PdfVersion = "1.7",
EnableTaggedPDF = true, // accessible/tagged output
Language = "en-US",
CjkFontPath = "/fonts/NotoSansCJKsc-Regular.otf",
};
var renderer = new PdfDocumentRenderer();
renderer.RenderToFile(document, "report.pdf", MakeOptions());

How do I password-protect or encrypt a PDF?

Section titled “How do I password-protect or encrypt a PDF?”

Encryption lives on PdfSaveOptions.SecuritySettings, so render through PdfDocumentRenderer. Set a user and/or owner password, pick an algorithm, and choose permissions.

using Rivoli.Pdf;
using Rivoli.Pdf.Security;
var renderer = new PdfDocumentRenderer("1.7");
renderer.RenderToFile(document, "secure.pdf", new PdfSaveOptions
{
SecuritySettings = new PdfSecuritySettings
{
UserPassword = "open-me",
OwnerPassword = "owner",
Algorithm = PdfEncryptionAlgorithm.Aes256,
Permissions = PdfPermissions.Print | PdfPermissions.CopyContents,
EncryptMetadata = true,
},
});

Set PdfSaveOptions.PageDecorator: a callback invoked once per rendered page, after the body and header/footer bands, so whatever it draws overlays the page. The context carries the page’s PdfContentStream, the document-wide 1-based PageNumber and PageCount, the page size/margins, and a GetFont helper. Coordinates are PDF-native (origin at the page’s bottom-left, y up). Drawing is bracketed with q/Q automatically (and tagged as an /Artifact when Tagged PDF is on), so a decorator cannot corrupt the page state.

using Rivoli.Pdf;
using Rivoli.Pdf.Styling;
var options = new PdfSaveOptions
{
PageDecorator = ctx =>
{
// Diagonal DRAFT watermark (45° text matrix) ...
var font = ctx.GetFont(new Style { FontWeight = FontWeight.Bold }, "DRAFT");
double c = Math.Cos(Math.PI / 4), s = Math.Sin(Math.PI / 4);
ctx.Content.SetFillColor(0.88, 0.88, 0.92);
ctx.Content.BeginText()
.SetFont(font.ResourceName, 96)
.SetTextMatrix(c, s, -s, c, ctx.PageSize.Width / 2 - 120, ctx.PageSize.Height / 2 - 140)
.ShowText("DRAFT", font)
.EndText();
// ... and a corner stamp with the document-wide page number.
var stamp = $"PAGE {ctx.PageNumber}/{ctx.PageCount}";
var small = ctx.GetFont(text: stamp);
ctx.Content.BeginText()
.SetFont(small.ResourceName, 9)
.SetTextMatrix(1, 0, 0, 1, ctx.PageSize.Width - 120, ctx.PageSize.Height - 30)
.ShowText(stamp, small)
.EndText();
},
};
renderer.RenderToFile(document, "stamped.pdf", options);

Leaving PageDecorator null costs nothing and produces byte-identical output. It composes with encryption, PDF/A, multi-section documents, and {PAGE}/{PAGES} footer tokens.

How do I draw raw graphics inside a flowing document?

Section titled “How do I draw raw graphics inside a flowing document?”

Add a PdfCanvasBlock: most easily via section.AddCanvas(width, height, draw). The layout engine reserves a width × height box that flows and paginates like any block (honoring KeepWithNext, StartOnNewPage, and horizontal alignment via the style’s TextAlignment); at paint time your callback draws into the page’s content stream with the full fluent operator API (paths, Bézier curves, transforms, rotated text) shapes the document model has no primitives for.

The callback origin is the box’s bottom-left corner with y increasing up (PDF-native, matching raw PdfContentStream), unlike VectorCanvasBlock, which uses a top-left/y-down canvas. Use ctx.GetFont(style, text) for text so the font lands in the page resources and its characters are registered for subsetting.

using Rivoli.Pdf;
section.AddCanvas(300, 60, (content, ctx) =>
{
// A sparkline: a polyline the DOM cannot express.
content.SetStrokeColor(0.12, 0.45, 0.85).SetLineWidth(1.5);
content.MoveTo(0, 10).LineTo(80, 35).LineTo(160, 22).LineTo(ctx.Width, 52).Stroke();
var font = ctx.GetFont(text: "burn rate");
content.BeginText()
.SetFont(font.ResourceName, 8)
.SetTextMatrix(1, 0, 0, 1, 4, 2)
.ShowText("burn rate", font)
.EndText();
});

The renderer brackets the callback with q/Q and re-balances any graphics states or text objects it leaves open, so a buggy callback cannot corrupt the rest of the page. One caveat: the draw callback is code, so a document containing a PdfCanvasBlock cannot be JSON-serialized: ToJson() throws instead of silently dropping the block. See sample 58-CanvasAndWatermarkSample.cs for a gauge and sparkline drawn this way.

How do I embed a reusable stamp or low-level graphic?

Section titled “How do I embed a reusable stamp or low-level graphic?”

When the same graphic appears in several places (a logo, an “APPROVED” badge, a signature block), author it once as a Form XObject and place it with a FormXObjectBlock. Unlike a PdfCanvasBlock (whose operators are emitted inline into each page), a form is a single reusable object: place the same block instance in multiple spots and it is emitted once and Do’d wherever it appears.

The form’s content is authored in its own BBox coordinate space (origin bottom-left, y up). At placement the BBox is mapped onto the block box with an independent x/y scale, so size the block to match the BBox’s proportions. The block flows and paginates like any other (honoring KeepWithNext and horizontal alignment via Style.TextAlignment).

using Rivoli.Pdf;
using Rivoli.Pdf.Styling;
// Author the badge once, in a 160 x 48 coordinate box.
var stamp = new FormXObjectBlock
{
Width = 160, Height = 48,
BBox = new FormBoundingBox(0, 0, 160, 48),
Draw = (content, ctx) =>
{
content.SetFillColor(0.90, 0.97, 0.90).SetStrokeColor(0.16, 0.55, 0.16);
content.RoundedRectangle(1, 1, ctx.Width - 2, ctx.Height - 2, 8).FillAndStroke();
var font = ctx.GetFont(new Style { FontWeight = FontWeight.Bold }, "APPROVED");
content.SetFillColor(0.10, 0.40, 0.10);
content.BeginText().SetFont(font.ResourceName, 20)
.MoveText(24, 16).ShowText("APPROVED", font).EndText();
},
};
section.Add(stamp); // place it …
section.Add(new Paragraph(""));
section.Add(stamp); // … again: one shared XObject, two `Do`s

For a graphic you build from raw operator bytes rather than a callback, pass the bytes to section.AddFormXObject(width, height, bbox, contentBytes): a pure-bytes form has no delegate.

To stamp an existing PDF page into a laid-out document (N-up, letterhead overlay, appendix embedding), open it with PdfImporter and place a page with AddPdfPage:

using var importer = PdfImporter.Open(existingPdfBytes);
var size = importer.GetPageSize(0);
double w = 320, h = w * (size.Height / size.Width); // keep the source aspect ratio
section.AddPdfPage(importer, pageIndex: 0, width: w, height: h);

The page’s content stream and resources are captured self-contained, so the placed page references nothing from the source afterwards. It is a page stamp, not a structural merge: the page is placed in its unrotated user space (a /Rotate is not applied), and its tags/annotations/links are not carried into the host’s structure tree: to concatenate whole documents keeping those, use PDF merge instead. Under Tagged PDF the placement is artifact-wrapped, and a document containing a delegate-backed form cannot be JSON-serialized (ToJson() throws). See sample 61-FormXObjectSample.cs.