Technical Article

Form XObject Recursion: PDFlibPas Cycle Detection in Delphi

PDFlibPas resolves recursive Form XObject calls in Delphi PDF content streams by tracking the active call chain, not a global visited set, so TPDFlib.EnumPageContentStatesEx can walk the same Form invoked several times on one page without mistaking legitimate reuse for a cycle. A stamp Form XObject in an invoice template is the typical case: the same object gets called from the header, the footer, and a watermark layer on one page, and only a call chain that loops back on itself is a genuine cycle

ISO 32000-1 §8.10 defines a Form XObject as a self-contained content stream that a page, or another Form, invokes with the Do operator, complete with its own coordinate system in /Matrix, a clipping boundary in that coordinate system in /BBox, and optionally its own resource dictionary. Nothing in the specification caps how many times one Form can be invoked or how deeply Forms can invoke each other, so a conforming parser has to accept legitimate reuse and legitimate nesting while still defending itself against the one arrangement the specification does forbid: a Form whose content stream, directly or transitively, invokes itself. PDFlibPas reports that distinction through the TPDFlibContentFormTraversalStatus values attached to every Do snapshot, most notably ftsEnumerated for a successful descent and ftsCycle for the one case that is actually a loop

Why Doesn't Reusing the Same Form XObject Trigger a False Cycle?

A repeated Form XObject reference is not, on its own, evidence of anything wrong. ISO 32000-1 allows the same Form object to be invoked from as many places in a content stream as the author wants, which is exactly how a logo stamp, a letterhead template, or a page-number footer gets reused across a page without duplicating its content stream several times over. The naive guard against runaway recursion is a single visited set keyed by object number: the first time a walker sees Form object 12, it marks 12 as seen and refuses to enter it again anywhere else in the tree. That approach breaks the moment the same stamp appears in two unrelated corners of one page, because the second, entirely legitimate call arrives after the object number is already marked seen and gets rejected as though it were a loop

PDFlibPas avoids that false positive by scoping cycle detection to the current call chain instead of the whole document. EnumPageContentStatesEx pushes the resolved Form stream onto the active call chain immediately before descending into it, then pops that same entry off again the moment the descent returns, successfully or not. A sibling invocation of the identical stream only starts after the first one has already been popped, so the call chain is clear of that stream by the time the sibling call checks it, and the walker enumerates it exactly as it would any other Form. A true cycle looks different on that same chain: Form A calls Form B, B is still open on the chain when its own content calls back into A, and A is still sitting on the chain from the outer call that has not returned yet — that is the only shape ftsCycle reports, a Form stream still open somewhere earlier on the current call chain, not merely present somewhere else on the page

How Deep Can Form XObject Recursion Go Before PDFlibPas Stops It?

Cycle detection and depth limiting solve two different problems, and PDFlibPas keeps them as two different TPDFlibContentFormTraversalStatus outcomes for exactly that reason. A chain of twenty distinct Forms, each one calling the next and none of them repeating, is not a cycle by any definition — the active-chain check never finds a repeated stream — but twenty honest levels of nesting is still twenty levels of parsing, matrix concatenation, and resource resolution that a malformed or adversarial PDF could push arbitrarily higher if nothing else stopped it. EnumPageContentStatesEx takes a MaxFormDepth parameter for exactly this reason and clamps whatever value is passed to a maximum of 64, regardless of what the caller asks for. A depth of zero is a special case worth knowing on its own: it disables Form recursion entirely and reproduces the flat, page-only behaviour of the older EnumPageContentStates method, which is why every Do snapshot in that mode reports ftsNotRequested instead of attempting anything

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

One Sub-Tracker Per Invocation: Isolating Graphics State

Every descent into a Form XObject gets its own graphics-state tracker rather than sharing the one already walking the page, because a Form's content stream is required to leave the graphics state exactly as it found it, and PDFlibPas cannot assume every PDF it opens actually honours that requirement. The child tracker starts from a snapshot of whatever CTM, colour state, and text parameters were active at the calling Do instruction, then resets its own save-and-restore stack and current-path tracking to empty before executing a single instruction of the Form. An unbalanced q with no matching Q inside a careless or damaged Form, not a rare thing to find in PDFs produced by older tooling, stays contained inside that one invocation's tracker and never leaks into the page tracker or into a sibling invocation of the same stamp sitting one line later in the content stream

Form /Matrix composes with the CTM in effect at the Do the same way a cm operator does, left-multiplied against the current transform rather than substituted for it, and PDFlibPas deliberately reuses that one code path instead of maintaining a second formula, since two independent implementations of the same matrix algebra are exactly the kind of duplication that quietly drifts apart after a few rounds of scale, rotate, and shear composition. /BBox then clips in the Form's own coordinate space after the matrix has already been applied, and all four corners of that box get transformed individually rather than just the opposite corners, since a rotated or sheared Form can otherwise report a bounding box that misses real content sitting in what used to be an extreme corner before the transform moved it somewhere else. Extending the loop from the previous example over the same States array reads those fields directly

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

Do Two Forms With the Same Resource Name Share One Font?

No. A resource name such as /F1 only means something relative to the resource dictionary active at the point it is used, and two different Form XObjects are free to define two completely different fonts under that identical name. PDFlibPas resolves this by tracking a resource scope alongside every resource name: when a Form carries its own /Resources dictionary, that dictionary becomes the complete resource scope for everything inside it, with no per-key fallback to the page or caller dictionary for whatever the Form's own dictionary happens to omit. Only a Form with no /Resources key at all, a pattern still produced by some older PDF generators, inherits the calling dictionary wholesale, and that is a deliberate compatibility carve-out rather than a general rule worth leaning on in new output. Font identity in a TPDFlibContentGraphicsState snapshot is therefore the pair of FontResource and FontResourceScope, not the name alone, with FontObjectNumber available to confirm exactly which indirect object a given /F1 resolved to in that particular scope

The same scoping applies to every other named resource a Form can carry, ExtGState entries and nested XObject entries included, since the underlying resolution mechanism does not special-case fonts — the font case just happens to matter most, because a mismatched font identity silently produces the wrong glyphs instead of an obvious failure. Extraction code that groups text runs by font name alone, without also grouping by resource scope, will merge two visually different fonts that happen to share a name, and the mistake will not surface until someone notices numerals from the wrong typeface sitting inside what was supposed to read as one consistent font

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

Reading FormTraversalStatus in Your Own Pipeline

FormTraversalStatus turns every Do snapshot into a small diagnostic report on its own, and a pipeline that ignores it is throwing away exactly the information that would explain an incomplete extraction. ftsNotApplicable means the instruction was never a resolved Form invocation to begin with; ftsNotRequested means recursion was switched off for this call; ftsEnumerated means the Form was parsed and walked successfully; ftsDepthLimit and ftsCycle mark the two ways a descent gets cut short on purpose; and ftsMalformed covers everything else that stopped the walk — an unresolvable stream reference, a /Matrix or /BBox that failed to parse, or an exception raised while executing the Form's own content. That last case matters operationally, because a failed nested walk rolls back whatever partial output it had already produced for that branch, so a caller never has to guess whether a Form was genuinely empty or simply blew up two instructions into its content stream

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

Boundaries, Costs, and Where This Fits

A Form's content stream is decoded and parsed exactly once per enumeration call no matter how many times the Form gets invoked, because PDFlibPas caches the parsed instruction list against the underlying stream object rather than re-parsing it on every sibling call — the three-corner stamp from the opening example gets decoded once and walked three times, not decoded three times. What does get rebuilt on every single invocation is everything that legitimately differs between one call site and the next: the child tracker, the concatenated CTM, the intersected clip, and the resource scope. That per-invocation CTM and clip bookkeeping is the same machinery behind PDFlibPas's content-stream CTM and clipping state tracker, worth reading alongside this one for any content-stream walk that goes beyond Form recursion itself

Two limits are worth setting expectations around before this API goes into a larger pipeline. The 64-level depth ceiling is not a tuning knob for legitimately deep documents, since real invoices, statements, and report templates essentially never nest Forms more than three or four levels deep — a document that actually hits ftsDepthLimit is far more likely to be malformed or adversarial than unusually elaborate, and is worth logging as a data-quality signal rather than silently retried with a larger number. EnumPageContentStatesEx is also a read-side analysis API: it reports what a content stream does, not whether a Form should be visible at all, which is a separate question answered by Optional Content Group visibility state when a stamp or watermark Form sits behind a layer a viewer might have toggled off. Call-chain cycle detection, per-invocation isolation, and resource scoping together make up one corner of the content-stream inspection surface in the PDFlibPas component for Delphi and C++Builder