Skip to content

The document model

When you build a document with Rivoli PDF, you are building a tree: a Document Object Model, or DOM. You construct the tree in memory, and later hand it to the layout engine and writer to produce a PDF. The tree is plain data: it describes what the document contains, not how it is rendered.

This separation is deliberate. Because the model knows nothing about pages, fonts, or rasterization, you can serialize it to JSON, diff it, cache it, edit it on a server, and only pay the cost of layout when you actually need pixels.

Every document has the same overall shape:

Document
└── Section (one or more, each a run of pages with shared page setup)
└── Block (paragraphs, tables, lists, images, containers, page breaks)
└── Inline (runs of text, links, inline images, inside paragraphs)

A minimal document touches all three levels:

using Rivoli.Pdf;
using Rivoli.Pdf.Model;
var doc = Document.Create("My Report", "Jane Doe");
doc.DefaultPageSize = PageSize.A4;
doc.DefaultMargin = Margin.Standard;
var section = doc.AddSection();
section.Add(new Paragraph()
.Add("This report covers ")
.AddLink("#details", "key findings")
.Add(" from our analysis."));

Document (namespace Rivoli.Pdf) is the root. It owns the sections, document-wide defaults, and a few cross-cutting collections.

var doc = new Document(); // empty
var doc2 = Document.Create("Title", "Author"); // with metadata

Key members:

MemberPurpose
MetadataTitle, author, subject, keywords, creator
DefaultPageSize, DefaultMarginPage setup inherited by sections that don’t override it
DefaultStyle, StyleSheetDocument-wide styling (see The styling cascade)
SectionsThe ordered list of sections
FormFields, Layers, AttachmentsCross-cutting content (form fields, optional-content layers, embedded files)
OutlinesThe bookmark tree shown in a viewer’s outline panel; nested OutlineItems targeting pages
PageLabels/PageLabels ranges controlling displayed page numbers (“iv, v, 1, 2, …”)
ImportedAnnotationsComments/markup preserved page-anchored from an imported PDF

You add sections with AddSection() (which creates and returns one) or Add(section) (which appends one you already built).

A Section (namespace Rivoli.Pdf.Model) is a run of pages that share page setup: page size, margins, column layout, header/footer, and background. A document with a portrait body and a landscape appendix uses two sections.

var appendix = doc.AddSection();
appendix.PageSize = PageSize.Letter.Rotate(); // landscape for this section only
appendix.WithColumns(ColumnLayout.TwoColumns);
appendix.Add(new Paragraph("Appendix: detailed data"));

If a section leaves PageSize or Margin null, it inherits the document defaults.

A section can also carry print-oriented page geometry: CropBox (the visible window), plus optional BleedBox, TrimBox, and ArtBox. These are mostly populated by PDF import so print geometry survives a round-trip, but they can be set directly when authoring for prepress workflows.

Blocks are the body content. Every block derives from the abstract Block base class, which carries the properties common to all of them:

public abstract class Block
{
public string Id { get; init; } // stable identity (see below)
public Style? Style { get; set; } // optional per-block styling
public bool StartOnNewPage { get; set; }
public bool KeepTogether { get; set; }
public bool KeepWithNext { get; set; }
public string? LayerId { get; set; }
}

The concrete block types:

BlockWhat it is
ParagraphA run of inline content (text, links, inline images)
TableRows and cells, with column widths and borders
ListOrdered or unordered items, optionally nested
ImageBlockA block-level image from a file path
ContainerBlockA group of blocks with its own background, border, padding, and orientation
PageBreakForces the following content onto a new page

The fluent builder shape is consistent: collection methods return the object so you can chain:

section.Add(new Table()
.AddHeaderRow("Quarter", "Revenue", "Profit")
.AddDataRow("Q1", "$1.2M", "$200K")
.AddDataRow("Q2", "$1.5M", "$250K"));
section.Add(List.Unordered()
.Add("First finding")
.Add("Second finding"));

ContainerBlock is worth calling out: it nests blocks and gives them shared framing, which makes it the natural building block for reusable components. See Components & reuse.

Inlines are the content inside a paragraph: they flow and wrap as text. They derive from the abstract Inline base class:

InlineWhat it is
RunA span of text with consistent formatting
HyperlinkA link (external URL or #anchor) wrapping inline content
SpanA styled group of inlines
InlineImageAn image that flows within the line
InlineSymbolA named symbol (non-breaking space, em dash, copyright, …)

Paragraph exposes a convenience Text property and Add(...) overloads so you rarely build Runs by hand:

var para = new Paragraph()
.Add("Visit our ")
.AddLink("https://example.com", "website")
.Add(" for more.");
// para.Text concatenates the runs back into a single string.

Every type is a plain C# object, so you can assign properties directly or use the fluent helpers. Both produce the same tree, and you can mix them freely.

// Fluent
var doc = Document.Create("My Document");
var section = doc.AddSection();
section.Add(new Paragraph("Hello"));
// Direct POCO: handy in loops and conditionals
foreach (var product in products)
{
var para = new Paragraph();
para.Inlines.Add(new Run(product.Name) { Style = boldStyle });
para.Inlines.Add(new Run($", ${product.Price}"));
section.Blocks.Add(para);
}

For a higher-level entry point, DocumentBuilder (namespace Rivoli.Pdf.Builders) wraps the same model with a WithSection(...)/Build() style:

using Rivoli.Pdf.Builders;
var doc = DocumentBuilder.Create("Report")
.WithPageSize(PageSize.A4)
.WithSection(s => s.Add(new Paragraph("Body")))
.Build();

See Working with the builder for when to reach for each style.

Every node (Document, Section, every Block and Inline, plus TableRow, TableCell, and ListItem) carries a stable Id:

public string Id { get; init; } = Guid.NewGuid().ToString();

The Id is a GUID string, generated when the node is constructed and immutable thereafter (init-only). Crucially, it survives JSON round-trips: serialize a document, deserialize it, and every node keeps the same identity.

Stable identity is what makes the higher-level features possible:

  • Change tracking reports which node changed by Id (see Change tracking).
  • Caching can key on node identity.
  • Collaborative editing and diff/patch can match nodes across two versions of the same document.

You can set an Id explicitly at construction time (the JSON deserializer does exactly this to preserve identity):

var para = new Paragraph("Intro") { Id = "intro" };

The model knows nothing about rendering. Layout hints like KeepTogether and StartOnNewPage live on the model because they describe intent, but the actual page-breaking decision happens later, in the layout engine. Computed positions and sizes are not on the model: they belong to the layout result. Keeping that line clean is why the same document can be laid out for A4 or Letter without changing a single block.