Technischer Artikel

PDF-Optional-Content-Layer in Delphi mit PDFium umschalten

PDFium Component steuert PDF-Optional-Content-Layer (OCGs) in Delphi über zwei TPdf-Methoden: InspectOptionalContent listet jeden Layer zusammen mit der Sichtbarkeit auf, die PDFium tatsächlich rendert, und SaveAsOptionalContentConfigured schreibt eine verifizierte Kopie, in der die von Ihnen gewählten Layer ein- oder ausgeschaltet sind. Die zweite Methode neutralisiert zusätzlich die Usage- und /AS-Regeln, die Ihre Änderung sonst stillschweigend rückgängig machen würden. Beide arbeiten auf dem in TPdf bereits geöffneten Dokument, es gibt also keinen zweiten Parser, der mit dem synchron gehalten werden müsste, was der Viewer zeigt

Die Anfrage kommt meist aus einem CAD- oder GIS-Haus: Der Zeichnungssatz kommt mit Bemaßungen, Anmerkungen und einem Schriftfeld auf getrennten Layern, und der Kunde will eine Kopie mit versteckten Bemaßungen, bevor sie zum Lieferanten geht. PDFium rendert Optional Content korrekt, aber sein öffentliches ABI hat keine Funktion, um OCGs aufzuzählen, eine Konfiguration zu wählen oder einen Layer-State umzukippen. Also steigt man auf Objektebene hinab, editiert /OCProperties, speichert, lädt neu – und der Layer ist immer noch da. Der Grund ist PDFiums Sichtbarkeitslogik, und die zu verstehen lohnt sich, bevor man irgendein Byte anfasst

Warum ändert das Editieren von /ON und /OFF nichts daran, was PDFium rendert?

Die /ON- und /OFF-Arrays des Konfigurations-Dictionarys zu editieren reicht nicht, denn PDFium lässt einen expliziten State im eigenen /Usage-Dictionary des OCG über diese Arrays gewinnen, und eine /AS-Auto-State-Regel kann danach beide überstimmen. ISO 32000-1 §8.11.4 beschreibt Konfigurationen und Usage-Dictionarys als getrennte Mechanismen; PDFiums Renderer faltet sie in eine einzige Entscheidung zusammen, und InspectOptionalContent bildet sie in dieser Reihenfolge nach:

  • Beginne beim /BaseState der Konfiguration, wo /ON und /Unchanged beide als sichtbar zählen und nur /OFF versteckt
  • Wende das /ON-Array der Konfiguration an, dann sein /OFF-Array, sodass eine in beiden gelistete Gruppe am Ende versteckt ist
  • Wende den expliziten Usage-State der Gruppe für die angefragte Usage an, etwa /Usage << /View << /ViewState /OFF >> >>, der alles Vorherige überstimmt
  • Betrachte eine Gruppe, deren /Intent weder /View noch /All enthält, als sichtbar, da sie an der View-Intent-Sichtbarkeit nicht teilnimmt
  • Führe zuletzt das /AS-Array der gewählten Konfiguration aus, dessen Einträge für das passende Event den State der von ihnen gelisteten Gruppen setzen
Die fünfstufige Sichtbarkeitsentscheidung, die PDFium Component für jede PDF-Optional-Content-Gruppe in Delphi nachspielt: BaseState legt den Start fest, die ON- und OFF-Arrays der Konfiguration greifen der Reihe nach, ein expliziter Usage-ViewState- oder PrintState-Eintrag überstimmt beide, Intent-Nichtteilnahme zählt als sichtbar, und das AS-Array läuft zuletzt
ON- und OFF-Arrays zu editieren reicht nicht, weil PDFium BaseState, beide Arrays, den Usage-State der Gruppe und zuletzt die AS-Auto-State-Regeln zu einem Urteil zusammenfaltet, das InspectOptionalContent Schritt für Schritt nachbildet

Der dritte Schritt ist der, der Leute verbrennt. Eine von einem Layout-Tool gespeicherte Datei trägt oft /ViewState /ON auf jedem OCG, und PDFium ignoriert dann Ihr sorgfältig editiertes /OFF-Array: Das Speichern läuft durch, die Datei öffnet sich sauber wieder, und der Layer malt trotzdem weiter. Für Print und Export liest OcExplicitUsageState zuerst PrintState oder ExportState und fällt auf ViewState zurück, wenn der spezifische Eintrag fehlt, sodass ein einzelnes ViewState /ON den Layer auch fürs Drucken festnagelt. Marked Content, das ein OCMD referenziert (§8.11.2.2), wird dann gegen diese Ergebnisse pro Gruppe aufgelöst – über die /P-Policy oder, falls vorhanden, den /VE-Visibility-Ausdruck

Wie listet man die Layer auf, die PDFium tatsächlich zeigt?

TPdf.InspectOptionalContent liefert ein TPdfOptionalContentInventory, dessen Groups-Array die Objektnummer, den Namen, die Intents, die drei Usage-States, die Sprache, den Zoom-Bereich, das Locked-Flag, den Radio-Group-Index und das berechnete EffectiveVisible jedes OCG trägt. Die Methode lässt PDFium zuerst das aktuelle In-Memory-Dokument speichern, expandiert Object Streams und scannt das Ergebnis, sodass frühere Edits aus derselben Session eingeflossen sind. Konfigurationsindex 0 ist immer das Default-/D-Dictionary, die Einträge von /Configs folgen ab Index 1; das Default-Argument -1 wählt Index 0. Ein Dokument ohne /OCProperties bringt die Methode dazu, False mit dem Grund in ErrorMessage zurückzugeben, statt eine Exception zu werfen

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  G: TPdfOptionalContentGroup;
begin
  // Usage ist per Default ocuView; -1 wählt Konfiguration 0, das /D-Dictionary
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Memo1.Lines.Add(Format('Configuration %d: %s',
    [Inv.SelectedConfigurationIndex,
     string(Inv.Configurations[Inv.SelectedConfigurationIndex].Name)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %s  visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Das Memberships-Array meldet jedes OCMD mit seiner Policy (ocmpAnyOn, ocmpAllOn, ocmpAnyOff, ocmpAllOff), dem rohen VisibilityExpression-Text und seinem eigenen EffectiveVisible. Ein paar Randregeln sind bewusst so gezogen. /P ist per Default /AnyOn, und ein OCMD ohne Gruppen zählt als sichtbar. Eine Referenz auf eine Objektnummer, die kein bekannter OCG ist, wird als sichtbar behandelt, statt den ganzen Ausdruck scheitern zu lassen. Die /VE-Auswertung stoppt bei einer Verschachtelungstiefe von 32 und behandelt alles Tieferliegende als versteckt, damit ein feindlicher oder selbstreferenzieller Ausdruck die Inspektion nicht in einen Stack-Overflow verwandelt

Einen neuen Layer-State mit SaveAsOptionalContentConfigured schreiben

TPdf.SaveAsOptionalContentConfigured nimmt ein Array aus TPdfOptionalContentStateChange-Records entgegen (Gruppenobjektnummer plus Visible) und schreibt ein Dokument, in dem die gewählte Konfiguration exakt diesen State erzeugt. Die gewählte Konfiguration bekommt /BaseState /ON plus vollständige /ON- und /OFF-Arrays, die jede Gruppe abdecken, und jeder OCG, der bereits ein Usage-Dictionary hat, erhält einen expliziten ViewState (oder PrintState / ExportState, je nach Options.Usage), der seinem neuen State entspricht. Mit TPdfOptionalContentConfigureOptions.Default wird der /AS-Schlüssel der gewählten Konfiguration entfernt, damit ein Open-, Print- oder Export-Event die Layer nicht zurückkippen kann

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer);
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;

  // Konfiguration 0, ocuView, DisableAutomaticState und EnforceRadioGroups True
  Options := TPdfOptionalContentConfigureOptions.Default;

  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;

Der Schreibpfad hält PDFiums eigene gespeicherte Ausgabe als Byte-für-Byte-Präfix und hängt nur den neu geschriebenen Konfigurations-Owner und die OCG-Objekte mit Usage-Dictionarys an, gefolgt von einer neuen Xref-Section und dem Trailer. Bevor ein einziges Byte Ihr Ziel erreicht, wird das Ergebnis in einem separaten TPdf unter der strikten Load-Policy wieder geöffnet, und die Methode schlägt fehl, wenn die Cross-Reference-Tabelle nicht validiert. Der Datei-Overload geht einen Schritt weiter: Er schreibt in eine temporäre Datei neben dem Ziel und ersetzt das Ziel erst, nachdem die Verifikation durch ist, sodass eine abgelehnte Änderung nie eine halb geschriebene Zeichnung zurücklässt. Das ist derselbe verifizierte Inkremental-Revision-Ansatz, den der PDF-Name-Tree- und Number-Tree-Editor in PDFium Component benutzt

Wie SaveAsOptionalContentConfigured in PDFium Component ein Delphi-PDF mit umgeschalteten Layern schreibt: State-Änderungen und Optionen gehen hinein, die gewählte Konfiguration wird mit vollständigen ON- und OFF-Arrays und Usage-States neu geschrieben, die verifizierte Inkremental-Revision wird angehängt, und ein striktes Wiederöffnen muss validieren, bevor irgendetwas geschrieben wird
Der konfigurierte Save hält PDFiums eigenen Re-Save als Byte-Präfix, hängt den neu geschriebenen Konfigurations-Owner plus eine neue Xref-Section an und öffnet das Ergebnis in einem separaten TPdf wieder, bevor das Ziel angefasst wird

Was verweigert der konfigurierte Save?

Der konfigurierte Save verweigert jede Änderung, die das Dokument selbst verbietet oder nicht sicher darstellen kann, und jede Verweigerung passiert, bevor das Ziel angefasst wird. Eine Objektnummer, die nicht in /OCGs steht, failt kompromisslos. Eine in das /Locked-Array der Konfiguration eingetragene Gruppe zu ändern failt ebenfalls, wobei das Neuerklären ihres aktuellen Werts erlaubt ist. Mit EnforceRadioGroups wird jedes /RBGroups-Set zurückgewiesen, das am Ende mehr als ein sichtbares Mitglied hätte, statt die anderen stillschweigend auszuschalten. Verschlüsselte Dokumente werden abgelehnt, weil Klartext-Inkremental-Objekte den aktiven Security-Handler nicht tragen können. Signierte Dokumente werfen EPdfError, sofern Sie nicht AllowSignedDocument = True übergeben, denn zu ändern, was eine Seite zeigt, kann Signature-Coverage oder eine Zertifizierungs-Policy brechen

Die Verweigerungs-Gates, die SaveAsOptionalContentConfigured in PDFium Component durchläuft, bevor ein konfiguriertes Delphi-PDF geschrieben wird: Eine Objektnummer außerhalb der OCGs failt, gesperrte Gruppen failen, RBGroups-Sets mit mehr als einem sichtbaren Mitglied werden zurückgewiesen, verschlüsselte Dokumente können keine Klartext-Inkremental-Objekte tragen, und signierte Dateien verlangen AllowSignedDocument
Jede Verweigerung passiert, bevor das Ziel angefasst wird, und der Fehlergrund landet in Report.ErrorMessage, statt eine halb geschriebene Zeichnung zurückzulassen
function TFormMain.SavePrintPreset(Target: TStream;
  const Changes: TPdfOptionalContentStateChanges): Boolean;
var
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  Options := TPdfOptionalContentConfigureOptions.Default;
  Options.Usage := ocuPrint;          // schreibt /Print << /PrintState ... >>
  Options.ConfigurationIndex := 1;    // erster Eintrag von /Configs, nicht /D
  try
    Result := Pdf.SaveAsOptionalContentConfigured(Target, Changes, Options,
      Report);                        // AllowSignedDocument bleibt False
    if not Result then
      ShowMessage(Report.ErrorMessage);
  except
    on E: EPdfError do
    begin
      ShowMessage(E.Message);         // signierte Datei: nichts in Target geschrieben
      Result := False;
    end;
  end;
end;

Kennen Sie die Trade-offs, bevor Sie das in einen Batch-Job einbauen. Die angehängte Revision sitzt auf PDFiums vollem Re-Save auf, nicht auf Ihren originalen Datei-Bytes – genau deshalb braucht signierter Input eine explizite Zustimmung. Das Neuschreiben normalisiert die gewählte Konfiguration außerdem auf /BaseState /ON, eine /Unchanged- oder /OFF-Baseline des Autors wird also durch explizite Arrays mit derselben resultierenden Sichtbarkeit ersetzt. Das Weglassen von /AS entfernt Print-only-Tricks wie einen Wasserzeichen-Layer, der nur auf Papier erscheint; setzen Sie DisableAutomaticState auf False, um diese Regeln zu behalten, in Kauf nehmend, dass sie Ihren angefragten State für dieses Event überstimmen können. Auf der Plusseite: PDF/A-2 (ISO 19005-2, Abschnitt 6.9) und PDF/UA (ISO 14289-1, Abschnitt 7.10) verbieten /AS in Konfigurations-Dictionarys gleichermaßen, die Default-Ausgabe nimmt also ein Problem vorweg, das Ihre PDF/A-Preflight-Validierung mit PDFium Component sonst melden würde

Wo Layer-Kontrolle in einen Delphi-PDF-Viewer passt

In einem Viewer ist Layer-Kontrolle eine Checkliste, getrieben vom Inventory plus einem Neuladen des gespeicherten Ergebnisses. Füllen Sie die Checkliste aus Groups, deaktivieren Sie die Einträge mit Locked, behandeln Sie Mitglieder mit gemeinsamem RadioGroupIndex als gegenseitig ausschließend, und schreiben Sie beim Anwenden in einen TMemoryStream und laden Sie diesen Stream zurück in TPdf, damit die View den neuen State zeichnet. Die Verdrahtung zwischen TPdf und TPdfView behandelt der Artikel zum Bau eines funktionsreichen PDF-Viewers mit PDFium VCL in Delphi. Lizenzierung, Trial-Downloads und der Rest des Feature-Sets stehen auf der Produktseite zu PDFium Component für Delphi