Skip to content

Change tracking

Rivoli PDF can tell you, in detail, every time the document tree changes. Change tracking turns the DOM into a live model: you subscribe to an event, mutate the tree, and receive a description of what happened, which node, what kind of change, and enough context to reverse it. This is the foundation for collaborative editing, undo/redo, synchronization, and audit logging.

The event types live in the Rivoli.Pdf.ChangeTracking namespace.

Change tracking is disabled by default. A document you never opt in pays nothing: no event allocation, no change objects, no overhead.

var doc = Document.Create("Test");
doc.AddSection().Add(new Paragraph("Content"));
// No events fired.
doc.EnableChangeTracking();
doc.Changed += (sender, e) => Console.WriteLine($"Change: {e.ChangeType} on {e.NodeId}");
doc.AddSection(); // Now an event fires.

Document exposes the whole surface:

public bool IsChangeTrackingEnabled { get; }
public event EventHandler<DocumentChangedEventArgs>? Changed;
public void EnableChangeTracking();
public void DisableChangeTracking();
public IDisposable BeginBatch();

Every mutation is one of four fundamental change types, plus a Batch wrapper:

ChangeTypeMeaningExample
NodeAddedA node was added to a collectionAdd a section, block, or inline
NodeRemovedA node was removedRemove a paragraph, clear blocks
NodeUpdatedA node property changedChange text, update a style
NodeMovedA node was reorderedMove a block up, reorder sections
BatchMultiple changes grouped togetherA bulk operation (see batching)

Every event derives from DocumentChangedEventArgs, which carries the common context:

public class DocumentChangedEventArgs : EventArgs
{
public ChangeType ChangeType { get; init; }
public string NodePath { get; init; } // e.g. "sections[0].blocks[2]"
public string NodeId { get; init; } // identity of the changed node
public string NodeType { get; init; } // e.g. "Paragraph"
public DateTimeOffset Timestamp { get; init; }
public string? UserId { get; init; } // optional session/user attribution
}

NodeId ties straight back to the stable node identity, so a listener always knows exactly which node moved or changed.

Specific change types add the details needed to reverse the change:

// NodeUpdatedEventArgs: what property changed, and from/to what
public string PropertyName { get; init; }
public object? OldValue { get; init; }
public object? NewValue { get; init; }
// NodeAddedEventArgs / NodeRemovedEventArgs: where in which parent
public int Index { get; init; }
public string ParentId { get; init; }
public string ParentType { get; init; }
// NodeMovedEventArgs: old and new positions
public int OldIndex { get; init; }
public int NewIndex { get; init; }
public string ParentId { get; init; }
public string ParentType { get; init; }

Because each event captures OldValue/NewValue (or old/new index, or the removed/added node and its position), you have everything you need to build an undo stack: an update can be reversed by setting the property back to OldValue, a move by swapping the indices, and so on.

doc.Changed += (sender, e) =>
{
if (e is NodeUpdatedEventArgs upd)
Console.WriteLine($"{upd.NodeType} '{upd.PropertyName}': " +
$"{upd.OldValue} -> {upd.NewValue}");
};

Bulk edits would otherwise flood listeners with one event per change. Wrap them in a batch and the document emits a single BatchEventArgs when the batch disposes:

using (doc.BeginBatch())
{
for (int i = 0; i < 1000; i++)
section.Add(new Paragraph($"Para {i}"));
} // One BatchEventArgs fires here, carrying all 1000 changes.

BeginBatch() returns an IDisposable, so a using block scopes the batch naturally. The resulting event exposes the collected changes:

public IReadOnlyList<DocumentChangedEventArgs> Changes { get; init; }
public int Count => Changes.Count;

A listener can react once to the whole batch, or iterate Changes to process each individual mutation.

  • Collaborative editing: broadcast each change (or batch) to other clients and apply it there, matching nodes by NodeId.
  • Undo/redo: keep a stack of events; reverse them using the captured old/new state.
  • Synchronization: keep multiple in-memory copies of a document in step.
  • Audit logging: record who (UserId) changed what (NodePath, PropertyName) and when (Timestamp).
  • Live UI: re-render only the affected node when the tree changes.

Change tracking pairs naturally with the JSON document model: a serialized snapshot plus a stream of change events is enough to reconstruct any intermediate state of the document.