HotPDF walks every operator in a page's content stream and hands each one to your code through VisitLoadedPageContentOperators. For each operator you choose an action: keep it, delete it, replace it with your own bytes, stop the walk, or reject the whole edit. When every operator is kept, the rewritten stream is byte-for-byte identical to the original, which is the property that makes the mechanism safe to run over documents you did not create
Content stream editing is where PDF tools usually go wrong. A stream is a sequence of operators over a stack machine, not a document tree, and the naive approach of running a regular expression over the decoded bytes breaks the moment a string literal contains something that looks like an operator, or an inline image contains arbitrary binary. A visitor that has already tokenized the stream does not have that problem
What can you actually do with operator-level access?
Three families of task. Auditing: count how many times a page sets a color outside a known set, or find text drawn with a font that is not embedded. Removal: delete a watermark that was drawn as marked content, or strip a specific image invocation. And normalization: replace one operator form with an equivalent that a downstream consumer handles better
Each of those shares one requirement: you must be able to leave the other 99 percent of the stream untouched. HotPDF's syntax tree stores one copy of the source plus compact spans, and edits are sorted non-overlapping overlays. Writing the result copies untouched ranges directly and emits replacement bytes only where you changed something, so a no-op round trip cannot introduce drift
The visitor contract
Implement the interface or assign the event, then call the visit method with ApplyChanges set to false for an audit or true for a rewrite. The event data carries the phase, the operator name, its operand count, its byte span, the current nesting state and a borrowed view for reading operands:
uses
HPDFDoc, HPDFContentStream;
procedure TAuditor.HandleOperator(Sender: TObject;
var EventData: THPDFContentOperatorEventData);
var
Bytes: TBytes;
begin
if EventData.Phase <> covpOperator then
Exit;
// Count text-showing operators drawn inside marked content
if (EventData.OperatorName = 'Tj') or
(EventData.OperatorName = 'TJ') then
begin
Inc(FTextOperators);
if EventData.State.MarkedContentDepth > 0 then
Inc(FTextInsideMarkedContent);
end;
// Read an operand only when you need it
if EventData.OperatorName = 'Do' then
if EventData.View.GetOperandBytes(0, Bytes) then
NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));
EventData.Action := coaKeep;
end;
procedure TAuditor.Run(const FileName: string);
var
Pdf: THotPDF;
Stats: THPDFContentOperatorVisitStatistics;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile(FileName) <= 0 then
Exit;
Pdf.OnContentOperator := HandleOperator;
for I := 0 to Pdf.LoadedPageCount - 1 do
Pdf.VisitLoadedPageContentOperators(I, False, Stats); // audit only
Writeln(Format('%d operators visited, max q/Q depth %d',
[Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
finally
Pdf.Free;
end;
end;
Why is the view borrowed rather than mutable?
The callback never receives a modifiable syntax tree. It receives a view that can fetch the current operation's bytes or a specific operand on demand, and that is all. Every change goes through the action and replacement fields instead
That indirection is not ceremony. Direct tree mutation would let a visitor bypass the replacement budget and the overlapping-edit check, and overlapping edits in a byte-span overlay model produce output that is silently wrong rather than loudly broken. Routing changes through the action field means every edit is validated the same way regardless of what the visitor intended
Reading on demand also keeps the common case cheap. An audit that only counts operator names never materializes operand bytes at all, which matters on a content stream with hundreds of thousands of operations
The state field describes the input, always
Each event carries the graphics state depth, text object depth, marked content depth and compatibility section depth as they stood in the original input immediately before that operator executed. That is true even if your visitor deleted or replaced earlier operators
Anchoring state to the original input rather than to the edited output is a deliberate choice with a practical payoff: running the same audit twice over the same document produces identical results, and an audit that runs alongside edits does not shift its own frame of reference. If you need to know the depth in the output, you know what you deleted, so you can compute it; the reverse, reconstructing stable input positions from a moving output, is not possible
Deleting and replacing, with the rules that protect you
A replacement is raw content-stream bytes, and you are responsible for their syntax. The safeguards around them are the budget on total replacement bytes, the non-overlapping requirement, and the optional output validation that reparses the result before it is accepted:
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
var EventData: THPDFContentOperatorEventData);
begin
EventData.Action := coaKeep;
if EventData.Phase <> covpOperator then
Exit;
// Drop the operators that paint the watermark form
if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
EventData.Action := coaDelete
// Normalise a stray colour to the house grey
else if EventData.OperatorName = 'rg' then
begin
EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
EventData.Action := coaReplace;
end
// Refuse the entire edit if we meet something we do not understand
else if EventData.OperatorName = 'BI' then
EventData.Action := coaReject;
end;
var
Options: THPDFContentOperatorVisitOptions;
begin
Options := THPDFContentOperatorVisitOptions.Default;
Options.ValidateOutput := True; // reparse before accepting
Options.MaxReplacementBytes := 64 * 1024;
Pdf.OnContentOperator := HandleOperator;
if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
if Stats.Modified then
Pdf.SaveLoadedDocument('cleaned.pdf');
end;
The action set includes two exits for a reason. coaStop ends the walk and keeps what has been decided so far, which is right when you have found what you were looking for. coaReject abandons the whole visit and leaves the page untouched, which is right when you encounter a construct your rewrite rules do not cover. Inline images are the classic example: their binary payload is not something a color-normalizing visitor should be editing around
Reading the statistics, and where the walk stops
The status field distinguishes completion from stopping, rejection, invalid input, an exceeded limit, invalid output and a visitor error. Those are different problems: invalid output means your replacement bytes did not reparse, while invalid input means the source stream was already malformed and no edit was attempted
The counters make an audit self-checking. VisitedOperatorCount against KeptOperatorCount, DeletedOperatorCount and ReplacedOperatorCount must add up, and the maximum depth figures tell you whether the page nests deeply enough to be worth a closer look. SourceBytes against OutputBytes gives the size effect of your edit in one number
For the common tasks there are higher-level routes that do not require writing a visitor at all. Removing content permanently, with the resource cleanup that real redaction needs, is covered in redaction and page stitching on loaded documents. Replacing visible text is covered in search and replace in an existing PDF. Reach for the operator visitor when your rule is structural rather than textual, or when you need an audit that no existing report answers
Content stream visiting, extraction and rewriting run against the same loaded document model in Delphi and C++Builder; the complete feature list is on the HotPDF Delphi PDF component page