Technical Article

Toggle PDF Optional Content Layers in Delphi with PDFium

A drawing set arrives with its dimensions on a layer of their own, and the obvious way to hide them is to move that group's reference from the /ON array of the default configuration into /OFF. The file saves and reopens, and the dimension lines are still printed across the title block. The cause sits one object away, inside the group dictionary: /Usage << /View << /ViewState /ON >> >>, an explicit state that the renderer reads ahead of the configuration arrays

A layer's state can live in several places in a PDF, and an edit to /OCProperties touches only one of them. Google's PDFium engine renders optional content correctly, but its public C API has no function to enumerate optional content groups (OCGs), choose a configuration or switch a group on and off. Delphi code that drives layers has to rewrite the objects itself and replay the renderer's decision order exactly, or its layer list reports one state while the page shows another

Why does editing /ON and /OFF not change what PDFium renders?

The /ON and /OFF arrays are only the second of five steps, and two later steps can overrule them. ISO 32000-1 §8.11.4.3 defines the optional content configuration dictionary with /BaseState, /ON, /OFF, /Locked, /RBGroups and /AS, while §8.11.4.4 defines the Usage dictionary an OCG may carry. In the specification, Usage describes what a group is for, and the auto-state entries in /AS turn that description into ON or OFF. PDFium takes a shorter road: when it renders for a purpose, the group's own explicit state for that purpose wins. Folded together, the order looks like this:

  • Start from the configuration's /BaseState, where /ON and /Unchanged both count as visible and only /OFF hides
  • Apply the /ON array, then the /OFF array, so a group listed in both ends up hidden
  • Apply the group's explicit Usage state for the requested purpose, such as /Usage << /View << /ViewState /OFF >> >>, which overrides everything above
  • Treat a group whose /Intent contains neither /View nor /All as visible, since it takes no part in view-intent visibility
  • Run the /AS array of the selected configuration last, letting entries for the matching event set the state of the groups they list
The five-step visibility decision that PDFium Component replays for every PDF optional content group in Delphi: BaseState sets the start, the configuration ON and OFF arrays apply in order, an explicit Usage ViewState or PrintState entry overrides both, Intent non-participation counts as visible, and the AS array runs last
Editing ON and OFF arrays is not enough because PDFium folds BaseState, both arrays, the group Usage state and finally the AS auto-state rules into one verdict that InspectOptionalContent reproduces step by step

Step three explains the opening file: some exporters write /ViewState /ON into every group they create, and from then on no amount of array editing hides those layers. Printing and export follow the same rule with one twist: the renderer reads /PrintState or /ExportState first and falls back to /ViewState when the specific entry is missing. A lone /ViewState /ON therefore pins the layer on paper too. Step four is quieter. A group tagged /Intent /Design is outside view-intent visibility altogether and paints regardless of the arrays, whereas a group with no /Intent key is treated as /View, which is the default the specification gives

Listing layers and their rendered state with InspectOptionalContent

PDFium Component replays that order in TPdf.InspectOptionalContent. The method fills a TPdfOptionalContentInventory and returns True on success; its Usage parameter defaults to ocuView and its ConfigurationIndex parameter to -1, which selects index 0. Configuration index 0 is the default /D dictionary, and the entries of /Configs follow from index 1 in file order. Before scanning, the method has PDFium save the open document into memory and expands any object streams, so edits made earlier in the session show up in the result. A file without /OCProperties, or a configuration index outside the available range, makes the call return False with the reason in ErrorMessage; no exception is raised for either case

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  Cfg: TPdfOptionalContentConfiguration;
  G: TPdfOptionalContentGroup;
begin
  // Usage defaults to ocuView; -1 selects configuration 0, the /D dictionary
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Cfg := Inv.Configurations[Inv.SelectedConfigurationIndex];
  Memo1.Lines.Add(Format('Configuration %d "%s", %d groups, /AS present: %s',
    [Inv.SelectedConfigurationIndex, string(Cfg.Name), Length(Inv.Groups),
     BoolToStr(Cfg.HasAutomaticState, True)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %-24s visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Each TPdfOptionalContentGroup carries its object number, name, Intents and the three explicit states ViewState, PrintState and ExportState, each one of ocsUnspecified, ocsOn or ocsOff. When a state reads ocsOn and the group sits in the /OFF array, you have found the trap from the opening paragraph. Locked and RadioGroupIndex come from the selected configuration, and RadioGroupIndex is -1 for a group that belongs to no /RBGroups set

The rest of the Usage dictionary is reported as the file states it: Language and LanguagePreferred, UserType and UserNames, PageElementSubtype, and MinimumZoom and MaximumZoom behind a HasZoomRange flag. EffectiveVisible does not depend on any of them. Zoom-dependent display is up to your viewer. Each TPdfOptionalContentConfiguration exposes its name, BaseState, array contents and RadioGroups, plus OrderGroupObjectNumbers, which flattens /Order into group references and skips the text labels of nested label arrays

Print and export can disagree with the screen

The Usage argument changes the answer, and that is the point of having it. Under ocuPrint the inventory reads /PrintState with its fallback to /ViewState, and only /AS entries whose /Event is /Print take part. A watermark layer built to appear only on paper therefore reports hidden under ocuView and visible under ocuPrint. Comparing the two passes finds such layers before a printout surprises anyone

procedure TFormMain.ReportPrintOnlyLayers;
var
  OnScreen, OnPaper: TPdfOptionalContentInventory;
  I: Integer;
begin
  if not Pdf.InspectOptionalContent(OnScreen, ocuView) or
     not Pdf.InspectOptionalContent(OnPaper, ocuPrint) then
    Exit;
  // Both passes read the same /OCGs array, so group order matches
  for I := 0 to High(OnScreen.Groups) do
    if OnScreen.Groups[I].EffectiveVisible <> OnPaper.Groups[I].EffectiveVisible then
      Memo1.Lines.Add(Format('%s: screen=%s, print=%s',
        [string(OnScreen.Groups[I].Name),
         BoolToStr(OnScreen.Groups[I].EffectiveVisible, True),
         BoolToStr(OnPaper.Groups[I].EffectiveVisible, True)]));
end;

How are OCMD visibility expressions evaluated?

An optional content membership dictionary (OCMD, ISO 32000-1 §8.11.2.2) ties a piece of content to several groups through a /P policy, or through a /VE visibility expression that combines groups with /And, /Or and /Not. The specification gives /P a default of /AnyOn, lets /VE take precedence over /OCGs and /P when it is present, and says an OCMD with no groups has no effect on visibility

The inventory's Memberships array follows those rules against the per-group results of the same pass, so the usage you asked for carries through. Each membership reports its group object numbers, Policy (ocmpAnyOn, ocmpAllOn, ocmpAnyOff, ocmpAllOff, or ocmpUnknown for a name outside those four), the raw VisibilityExpression text and its own EffectiveVisible. An OCMD without groups counts as visible. A reference to an object number that is not a known OCG also counts as visible, so one dangling reference does not hide everything around it. /VE evaluation stops at a nesting depth of 32 and treats anything deeper as hidden, so a self-referencing expression cannot overflow the stack. The scan lists every OCMD stored as an indirect object with /Type /OCMD, object streams included

Writing a new layer state with SaveAsOptionalContentConfigured

TPdf.SaveAsOptionalContentConfigured takes an array of TPdfOptionalContentStateChange records, each holding a GroupObjectNumber and a Visible flag. It writes a copy in which the selected configuration produces exactly that state under the five-step order. Groups you do not mention keep the visibility the inventory computed for them

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer;
  ForPrint: Boolean);
var
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  SetLength(Changes, 2);
  Changes[0].GroupObjectNumber := DimensionsObj;
  Changes[0].Visible := False;
  Changes[1].GroupObjectNumber := NotesObj;
  Changes[1].Visible := True;

  Options := TPdfOptionalContentConfigureOptions.Default;
  if ForPrint then
  begin
    Options.Usage := ocuPrint;         // writes /Print << /PrintState ... >>
    Options.ConfigurationIndex := 1;   // first /Configs entry, /D stays as it was
  end;

  if not Pdf.SaveAsOptionalContentConfigured('C:\Out\Drawing-NoDims.pdf',
    Changes, Options, Report) then
    raise Exception.Create('Layer update rejected: ' + Report.ErrorMessage);

  Log(Format('%d of %d groups changed, %d Usage states rewritten, /AS removed: %s',
    [Report.ChangedGroupCount, Report.GroupCount,
     Report.UpdatedUsageStateCount,
     BoolToStr(Report.RemovedAutomaticState, True)]));
end;

The output keeps PDFium's own saved document as a byte-for-byte prefix and appends only the rewritten configuration owner and the OCG objects that carry Usage dictionaries, followed by a new xref section and trailer. Before anything reaches your destination, the result is reopened in a separate TPdf under the strict load policy, and the method fails if that load does not succeed or the cross-reference table does not validate. The file overload writes to a temporary file beside the target and replaces the target only after verification, so a rejected update leaves the previous drawing untouched. It is the same verified incremental revision approach used by the PDF name tree and number tree editor in PDFium Component. To confirm a print preset, reload the output and call InspectOptionalContent(Inv, ocuPrint, 1); the groups should report the states you passed in

How SaveAsOptionalContentConfigured in PDFium Component writes a Delphi PDF with layers toggled: state changes and options go in, the selected configuration is rewritten with complete ON and OFF arrays and Usage states, the verified incremental revision is appended, and a strict reopen must validate before anything is written
The configured save keeps PDFium's own re-save as a byte prefix, appends the rewritten configuration owner plus a new xref section, and reopens the result in a separate TPdf before the destination is touched

What does the configured save refuse to do?

The configured save refuses any change the document forbids or cannot carry safely, and every refusal happens before the destination is written. The checks run in a fixed order. A signed document raises EPdfError before anything else happens unless you pass AllowSignedDocument = True. An encrypted document is rejected next, because plaintext incremental objects cannot carry the active security handler. Then each requested change is checked: an object number that is not in /OCGs fails outright, and a change to a group listed in the configuration's /Locked array fails, although restating its current value passes. Last, with EnforceRadioGroups on, a /RBGroups set that would end up with more than one visible member is rejected. Every refusal except the signature one returns False with the reason in Report.ErrorMessage

The refusal gates that SaveAsOptionalContentConfigured applies in PDFium Component before writing a configured Delphi PDF: an object number outside OCGs fails, locked groups fail, RBGroups sets with more than one visible member are rejected, encrypted documents cannot carry plaintext incremental objects, and signed files demand AllowSignedDocument
Every refusal happens before the destination is touched; the reason lands in Report.ErrorMessage, or in the EPdfError message when the file is signed

Two of those gates go further than the specification strictly requires. §8.11.4.3 describes /Locked as protection against changes through the viewer's user interface and describes radio-button groups as switching the other members off when one is turned on. PDFium Component applies both to programmatic changes as well, and for radio groups it refuses instead of picking members to switch off. Asked for two members of a mutually exclusive set, the library cannot know which one you meant, and a guess would contradict your request. Turn the others off in the same Changes array and the save goes through

The signed-document gate exists because the appended revision sits on top of PDFium's full re-save, which is a new byte sequence; the byte range an existing signature covers cannot be assumed to survive. Pass AllowSignedDocument = True only when losing that signature is acceptable

Three side effects matter in a batch job. The rewrite normalises the selected configuration to /BaseState /ON, so an author's /Unchanged or /OFF baseline becomes explicit arrays with the same resulting visibility. Dropping /AS also removes print-only tricks such as a watermark layer that appears only on paper; set DisableAutomaticState to False to keep those rules and accept that they may override your requested state for their event. And the /ON and /OFF arrays are written from the states computed for Options.Usage, so a print preset saved into configuration 0 also changes what the screen shows for every group that has no Usage dictionary. Keep print presets in a /Configs entry and leave /D to the screen. On the compliance side, PDF/A-2 (ISO 19005-2 clause 6.9) and PDF/UA (ISO 14289-1 clause 7.10) both forbid /AS in configuration dictionaries, so the default output clears one finding your PDF/A preflight validation with PDFium Component would otherwise report

Applying layer changes inside a Delphi viewer

In a viewer, layer control is a checklist built from the inventory, followed by a save to memory and a reload. Fill the checklist from Groups, disable entries whose Locked flag is set, treat members that share a RadioGroupIndex as mutually exclusive, and on apply write to a TMemoryStream through the stream overload. TPdf.LoadDocument expects an inactive component, so switch it off first; the buffered load copies the bytes, which lets you free the stream straight away

procedure TFormMain.ApplyLayerChecklist(
  const Changes: TPdfOptionalContentStateChanges);
var
  Report: TPdfOptionalContentConfigureReport;
  Output: TMemoryStream;
  KeepPage: Integer;
begin
  Output := TMemoryStream.Create;
  try
    try
      if not Pdf.SaveAsOptionalContentConfigured(Output, Changes,
        TPdfOptionalContentConfigureOptions.Default, Report) then
      begin
        ShowMessage('Layer update rejected: ' + Report.ErrorMessage);
        Exit;
      end;
    except
      on E: EPdfError do
      begin
        ShowMessage(E.Message);       // signed file: Output is still empty
        Exit;
      end;
    end;
    KeepPage := Pdf.PageNumber;
    Pdf.Active := False;
    Pdf.LoadDocument(Output);         // Buffered defaults to True
    Pdf.PageNumber := KeepPage;
  finally
    Output.Free;
  end;
end;

After the reload, PDFium paints from the new configuration, so the screen and the next InspectOptionalContent call agree. The wiring between TPdf and TPdfView is covered in building a feature-rich PDF viewer with PDFium VCL in Delphi. When a document has no layers yet and you need to add them, creating PDF layers with OCG and OCMD in Delphi walks through SaveAsOptionalContentCreated

Pricing, the trial build and the component's other document features are listed on the PDFium Component for Delphi product page