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.
How do I merge several PDFs into one?
Section titled “How do I merge several PDFs into one?”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.
How do I split a PDF?
Section titled “How do I split a PDF?”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 });How do I load and read an existing PDF?
Section titled “How do I load and read an existing PDF?”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.
Tightening or relaxing budgets
Section titled “Tightening or relaxing 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);| Budget | Default | Bounds |
|---|---|---|
MaxInputBytes | 512 MiB | The file itself, checked before parsing starts |
MaxTotalDecodedBytes | 500 MiB | Decompression across the whole document, so many small streams can’t add up |
MaxDecodedBytesPerStream | 128 MiB | A single decompression bomb |
MaxFilterChainLength | 8 | Nested filters, where each layer multiplies the last |
MaxObjects / MaxXrefEntries | 5,000,000 | Object-count explosions |
MaxPages | 100,000 | Page-count explosions |
MaxReferenceDepth | 256 | Deep and cyclic reference graphs |
MaxObjectStreamMembers | 100,000 | A declared member count driving a parse loop |
MaxImagePixels | 100,000,000 | Declared image dimensions, checked before allocation |
MaxDecodedImageBytes | 400 MiB | The RGBA allocation for one decoded image |
MaxRecoveryScanBytes | 256 MiB | Rebuilding 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.
How do I add bookmarks (outlines)?
Section titled “How do I add bookmarks (outlines)?”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 paneldoc.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 … appendixRanges 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");});How do I generate a PDF asynchronously?
Section titled “How do I generate a PDF asynchronously?”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
StreamingWriteproduces 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
FileStreamis 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.
(
SavePdfAsyncis 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.
How do I control the output PDF version?
Section titled “How do I control the output PDF version?”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 effectHow do I set application-wide defaults?
Section titled “How do I set application-wide defaults?”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, },});How do I watermark or stamp every page?
Section titled “How do I watermark or stamp every page?”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`sFor 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 ratiosection.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.
- The builder patterns: keeping document construction readable.
- The document model: what
LoadPdfparses into. - API reference: the namespace and type map.
- FAQ & troubleshooting: fonts on Linux, determinism, thread-safety.