Skip to content

The low-level API

Everything in the tutorial goes through the high-level document model: you describe content, and the layout engine decides where it lands. Underneath that sits a fully public low-level layer where you are the layout engine: you build the PDF file object-by-object and place every glyph and path at explicit coordinates.

This page is an orientation for that layer: the object model, the content-stream operator builder, and the two writers.

Reach for the low-level API when you need something the document model does not express:

  • a PDF construct with no model counterpart: an unusual catalog or page-tree entry, a custom dictionary, an appearance stream, an experimental extension;
  • operator-level control of a page: exact text matrices, hand-tuned paths, a specific sequence of graphics-state changes;
  • very large generated documents: thousands of pages written with bounded memory via the streaming writer;
  • learning or debugging: hand-writing a minimal file is the fastest way to understand what the renderer emits (see How PDF works).

For everything else (paragraphs, tables, styles, images, page breaks), stay with Document + SavePdf. At the low level nothing wraps, nothing paginates, and nothing is measured for you.

Low-level coordinates are native PDF user space: the origin is the bottom-left corner of the page, y increases upward, and units are points (1/72 inch). A US Letter page is 612 × 792 points, so “one inch from the top” is y = 720.

Layer 1: the object model (Rivoli.Pdf.Objects)

Section titled “Layer 1: the object model (Rivoli.Pdf.Objects)”

A PDF file is a graph of typed objects. The Rivoli.Pdf.Objects namespace models each of them:

TypePDF syntaxNotes
PdfNull, PdfBooleannull, trueSingletons: PdfNull.Value, PdfBoolean.Get(b)
PdfInteger, PdfReal42, 3.14Implicit conversions from int / double
PdfString(text) or <hex>Implicit conversion from string; IsTextString for metadata strings
PdfName/NameImplicit conversion from string; escaping handled
PdfArray[ ... ]Add/AddRange, params constructor
PdfDictionary<< /Key value >>Fluent Set(key, value), Get, indexer
PdfStreamdictionary + stream ... endstreamWraps a byte[]; maintains /Length automatically
PdfIndirectObject7 0 obj ... endobjA numbered object; CreateReference() yields its 7 0 R
PdfIndirectReference7 0 RHow one object points at another

Dictionaries stay mutable until the file is written, which is how circular references are wired: create the page-tree dictionary first (so pages can point at it as /Parent), and fill in its /Kids and /Count after the pages exist.

Layer 2: content operators (PdfContentStream)

Section titled “Layer 2: content operators (PdfContentStream)”

A page’s appearance is a content stream: a program of drawing operators. The PdfContentStream class is a fluent builder that emits correct operator syntax:

  • Text: BeginText() / EndText() (BT/ET), SetFont("F1", 12) (Tf), MoveText(x, y) (Td, relative to the previous line origin), ShowText("...") (Tj), plus SetTextMatrix, SetTextLeading, SetCharacterSpacing, SetWordSpacing, SetTextRenderMode.
  • Paths: MoveTo / LineTo / CurveTo (m/l/c), Rectangle(x, y, w, h) (re: x, y are the lower-left corner), RoundedRectangle, Ellipse, ClosePath (h), then paint with Stroke (S), Fill (f), FillAndStroke (B), or clip with Clip() + EndPath().
  • Color & state: SetFillColor(r, g, b) (rg), SetStrokeColor(r, g, b) (RG), SetLineWidth / SetLineCap / SetLineJoin / SetDashPattern, SaveGraphicsState / RestoreGraphicsState (q/Q), and ConcatenateMatrix(a, b, c, d, e, f) (cm) for transforms.
  • Other: PaintXObject("Im1") (Do), marked content (BeginArtifact, BeginStructContent, EndMarkedContent), SetExtGState.

When the page is done, ToPdfStream() packages the operators as a PdfStream ready to become the page’s /Contents; ToBytes() / ToString() expose the raw stream.

Collects PdfIndirectObjects in memory and writes the complete file (header, objects, cross-reference table, trailer) in one pass. CreateObject(value) assigns the next object number and returns the indirect object; set Catalog (required) and Info (optional), then call WriteTo(stream) or WriteToFile(path). It also supports SecuritySettings (encryption) and GenerateFileID / FileIDSeed (deterministic file IDs).

Writes each object to the output stream the moment you hand it over; only object numbers and byte offsets are retained for the xref table. The pattern:

  1. ReserveObject() numbers for anything that must be referenced before it is written (catalog, page tree, shared fonts).
  2. WriteObject(objectNumber, value) each object as soon as it is complete: pages can stream out one at a time.
  3. Complete(catalogRef[, infoRef]) writes the xref table and trailer.

Memory is O(number of objects), not O(document size): this is the writer for thousand-page generated documents.

The hybrid: streaming through PdfDocumentWriter

Section titled “The hybrid: streaming through PdfDocumentWriter”

The two writers combine. AttachStreamingOutput(stream) switches a PdfDocumentWriter to a hybrid mode backed by StreamingPdfWriter: CreateFinalizedObject(value) (a promise that the value will never be mutated), writes the object to the output immediately, while plain CreateObject keeps buffering objects that may still change (a page dictionary that will receive /Annots, say) until WriteTo(stream) flushes them and completes the file. Both kinds draw numbers from one creation-ordered sequence, so encryption (which binds keys to object numbers) works identically in either mode. Without an attached output, CreateFinalizedObject behaves exactly like CreateObject.

This is the seam the document renderer uses for PdfSaveOptions.StreamingWrite: content streams, images and font programs stream out as pages render; the page tree, catalog, outlines and structure tree stay buffered until the end.

This is the same structure as runnable sample 57-LowLevelApiSample.cs (samples/Rivoli.Pdf.Samples), which renders two pages this way and re-opens the result with PdfImporter to verify it. A minimal one-page version:

using Rivoli.Pdf;
using Rivoli.Pdf.Objects;
var writer = new PdfDocumentWriter("1.4");
// Page tree first, so the page can reference it as /Parent.
var pagesDict = new PdfDictionary();
var pages = writer.CreateObject(pagesDict);
writer.Catalog = writer.CreateObject(new PdfDictionary()
.Set("Type", new PdfName("Catalog"))
.Set("Pages", pages.CreateReference()));
// A standard-14 font: nothing embedded, every viewer supplies Helvetica.
var font = writer.CreateObject(new PdfDictionary()
.Set("Type", new PdfName("Font"))
.Set("Subtype", new PdfName("Type1"))
.Set("BaseFont", new PdfName("Helvetica"))
.Set("Encoding", new PdfName("WinAnsiEncoding")));
// The page's appearance: text at (72, 720), one inch from the left and
// one inch below the TOP of a 792pt-tall page, plus a filled rectangle.
var content = new PdfContentStream()
.BeginText()
.SetFont("F1", 22)
.MoveText(72, 720)
.ShowText("Hand-built PDF")
.EndText()
.SetFillColor(0.16, 0.50, 0.73)
.Rectangle(72, 640, 200, 24) // x, y of the LOWER-left corner
.Fill();
var contentObj = writer.CreateObject(content.ToPdfStream());
var page = writer.CreateObject(new PdfDictionary()
.Set("Type", new PdfName("Page"))
.Set("Parent", pages.CreateReference())
.Set("MediaBox", new PdfArray(
new PdfInteger(0), new PdfInteger(0),
new PdfInteger(612), new PdfInteger(792)))
.Set("Resources", new PdfDictionary()
.Set("Font", new PdfDictionary().Set("F1", font.CreateReference())))
.Set("Contents", contentObj.CreateReference()));
// Now the page exists, finish the page tree and write the file.
pagesDict
.Set("Type", new PdfName("Pages"))
.Set("Kids", new PdfArray(page.CreateReference()))
.Set("Count", new PdfInteger(1));
writer.WriteToFile("hand-built.pdf");

The streaming variant of the same document replaces CreateObject with ReserveObject() + WriteObject(...) and ends with Complete(catalogRef): sample 57 shows both side by side.

The result round-trips through the library’s own reader:

using Rivoli.Pdf.Content;
using var importer = PdfImporter.Open(File.ReadAllBytes("hand-built.pdf"));
Console.WriteLine(importer.PageCount); // 1
Console.WriteLine(importer.ExtractText(0)); // "Hand-built PDF"

The low-level and high-level layers mix through three bridges:

  • Raw operators inside a laid-out document: PdfCanvasBlock. Add a canvas block (or call section.AddCanvas(width, height, draw)) and the layout engine reserves a fixed box in the flow (it paginates and honors KeepWithNext like any block); at paint time your callback receives the page’s PdfContentStream with the full fluent operator API. The callback’s origin is the box’s bottom-left corner, y up (PDF-native: the same convention as this page, and unlike VectorCanvasBlock’s top-left/y-down canvas). Get fonts via PdfCanvasContext.GetFont(style, text). The renderer brackets the callback with q/Q and re-balances leaked graphics state, so a buggy callback cannot corrupt the page.

    section.AddCanvas(200, 60, (content, ctx) =>
    {
    content.SetStrokeColor(0.12, 0.45, 0.85);
    content.MoveTo(0, 10).LineTo(ctx.Width, 50).Stroke();
    });
  • Page post-processing: PdfSaveOptions.PageDecorator. A callback invoked once per rendered page, after body and header/footer painting, with the page’s content stream, document-wide 1-based PageNumber/PageCount, page size and margins: the hook for watermarks and stamps. See How do I watermark or stamp every page?

  • Low-level content placed INTO the flow: FormXObjectBlock. The reverse direction: take content built at the low level: raw content-stream operators, or a page lifted out of another PDF, and place it as a Form XObject in the laid-out flow. section.AddFormXObject(width, height, bbox, draw) reserves a fixed box (it paginates, honors KeepWithNext, and aligns via Style.TextAlignment like any block); the form’s content is authored in its own bbox coordinate space (origin bottom-left, y up) and mapped onto the block box (an independent x/y scale: size the block to match the BBox’s proportions). A form is page-independent, so the same block instance placed in several spots is emitted as one shared XObject and Do’d wherever it appears: the natural way to stamp a reusable logo/badge without duplicating bytes. Get fonts for the form via FormXObjectContext.GetFont(style, text).

    var stamp = new FormXObjectBlock
    {
    Width = 160, Height = 48,
    BBox = new FormBoundingBox(0, 0, 160, 48),
    Draw = (content, ctx) =>
    {
    content.SetStrokeColor(0.16, 0.55, 0.16);
    content.RoundedRectangle(1, 1, ctx.Width - 2, ctx.Height - 2, 8).Stroke();
    var font = ctx.GetFont(new Style { FontWeight = FontWeight.Bold }, "APPROVED");
    content.BeginText().SetFont(font.ResourceName, 20);
    content.MoveText(24, 16).ShowText("APPROVED", font).EndText();
    },
    };
    section.Add(stamp); // place it: reuse the same instance to share one XObject

    An imported PDF page can be placed the same way with section.AddPdfPage(importer, pageIndex, width, height): the page’s content stream and resources are captured self-contained (N-up, letterhead overlay, appendix embedding). See How do I embed a reusable stamp or low-level graphic?

Remaining honest limits:

  • Imported-page placement is unrotated and best-effort. AddPdfPage captures the page in its own unrotated user space, so a page with a non-zero /Rotate is stamped without applying the rotation; content in stream filters the reader cannot decode is dropped. It is a page stamp, not a structural merge: the placed page’s tags/annotations/links are not carried into the host’s structure tree.
  • There is no general incremental update: files are always written whole; you cannot append a revision to an existing PDF.
  • Neither a PdfCanvasBlock nor a FormXObjectBlock can be JSON-serialized (a draw callback is code; a form may also carry raw low-level objects); ToJson() on a document containing one throws rather than dropping content.
  • How PDF works: the file format these objects serialize to.
  • API reference: the namespace map, including the low-level entries.
  • Sample 57-LowLevelApiSample.cs in samples/Rivoli.Pdf.Samples: the runnable two-page + streaming version of the example above.
  • Sample 58-CanvasAndWatermarkSample.cs: the two bridges in one flowing report (gauge/sparkline canvases + a DRAFT watermark decorator).
  • Sample 61-FormXObjectSample.cs: the low→high bridge: a reusable “APPROVED” stamp form placed in multiple spots (one shared XObject) plus an imported PDF page stamped into the flow.