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.
When to drop to this level
Section titled “When to drop to this level”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.
The coordinate system
Section titled “The coordinate system”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:
| Type | PDF syntax | Notes |
|---|---|---|
PdfNull, PdfBoolean | null, true | Singletons: PdfNull.Value, PdfBoolean.Get(b) |
PdfInteger, PdfReal | 42, 3.14 | Implicit conversions from int / double |
PdfString | (text) or <hex> | Implicit conversion from string; IsTextString for metadata strings |
PdfName | /Name | Implicit conversion from string; escaping handled |
PdfArray | [ ... ] | Add/AddRange, params constructor |
PdfDictionary | << /Key value >> | Fluent Set(key, value), Get, indexer |
PdfStream | dictionary + stream ... endstream | Wraps a byte[]; maintains /Length automatically |
PdfIndirectObject | 7 0 obj ... endobj | A numbered object; CreateReference() yields its 7 0 R |
PdfIndirectReference | 7 0 R | How 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), plusSetTextMatrix,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 withStroke(S),Fill(f),FillAndStroke(B), or clip withClip()+EndPath(). - Color & state:
SetFillColor(r, g, b)(rg),SetStrokeColor(r, g, b)(RG),SetLineWidth/SetLineCap/SetLineJoin/SetDashPattern,SaveGraphicsState/RestoreGraphicsState(q/Q), andConcatenateMatrix(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.
Layer 3: the writers
Section titled “Layer 3: the writers”PdfDocumentWriter: buffered
Section titled “PdfDocumentWriter: buffered”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).
StreamingPdfWriter: bounded memory
Section titled “StreamingPdfWriter: bounded memory”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:
ReserveObject()numbers for anything that must be referenced before it is written (catalog, page tree, shared fonts).WriteObject(objectNumber, value)each object as soon as it is complete: pages can stream out one at a time.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.
A worked example
Section titled “A worked example”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); // 1Console.WriteLine(importer.ExtractText(0)); // "Hand-built PDF"Mixing the layers
Section titled “Mixing the layers”The low-level and high-level layers mix through three bridges:
-
Raw operators inside a laid-out document:
PdfCanvasBlock. Add a canvas block (or callsection.AddCanvas(width, height, draw)) and the layout engine reserves a fixed box in the flow (it paginates and honorsKeepWithNextlike any block); at paint time your callback receives the page’sPdfContentStreamwith 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 unlikeVectorCanvasBlock’s top-left/y-down canvas). Get fonts viaPdfCanvasContext.GetFont(style, text). The renderer brackets the callback withq/Qand 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-basedPageNumber/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, honorsKeepWithNext, and aligns viaStyle.TextAlignmentlike any block); the form’s content is authored in its ownbboxcoordinate 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 andDo’d wherever it appears: the natural way to stamp a reusable logo/badge without duplicating bytes. Get fonts for the form viaFormXObjectContext.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 XObjectAn 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.
AddPdfPagecaptures the page in its own unrotated user space, so a page with a non-zero/Rotateis 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
PdfCanvasBlocknor aFormXObjectBlockcan 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.
See also
Section titled “See also”- How PDF works: the file format these objects serialize to.
- API reference: the namespace map, including the low-level entries.
- Sample
57-LowLevelApiSample.csinsamples/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.