PDFium Component erzeugt neue PDF-Layer aus Delphi über TPdf.SaveAsOptionalContentCreated. In einem einzigen verifizierten Save ergänzt die Methode Optional-Content-Groups (OCGs), Optional-Content-Membership-Dictionarys (OCMDs) und vom Aufrufer gelieferte Zeichnungen, gehüllt in Form XObjects, deren /OC-Eintrag auf eine neue Gruppe zeigt, sodass PDFium und andere Viewer diese Zeichnung zeigen oder verbergen, wenn der Layer umgeschaltet wird
Die Anfrage ist selten abstrakt. Ein Anlagenbau-Team will metrische und imperiale Bemaßungen auf demselben Zeichnungsblatt, mit exakt einem der beiden sichtbar. Eine Druckerei will ein Schnittlinien-Overlay, das auf dem Bildschirm erscheint, aber vor dem Andruck abgeschaltet werden kann. Keine der beiden Dateien hat bisher Layer, also gibt es nichts umzuschalten. Bestehende Layer umzuschalten ist ein anderer Job, behandelt in bestehende PDF-Optional-Content-Layer mit SaveAsOptionalContentConfigured umschalten; dieser Artikel handelt davon, die Layer überhaupt erst zu erzeugen
Warum kann man mit PDFium nicht einfach /OC an ein Page-Object setzen?
PDFium hat keine öffentliche Funktion, um Optional Content an ein bestehendes Page-Object zu hängen. Die öffentlichen Header enthalten kein FPDFPageObj_SetOC oder etwas Äquivalentes, also kann die übliche Editieren-dann-FPDFPage_GenerateContent-Route keinen geschalteten Inhalt produzieren. Die Schaltung muss in die Dateistruktur geschrieben werden, und die einzige Frage ist, welche Struktur PDFium beim Rendern beachtet
ISO 32000-1 §8.11.3 nennt drei Wege, Grafik optional zu machen, und PDFium behandelt sie unterschiedlich:
- Abschnitte des Seiten-Content-Streams, gehüllt in
/OC /Name BDC...EMC-Marked-Content, geprüft pro Page-Object während des Renderns - Form XObjects und Image XObjects, die einen
/OC-Eintrag in ihrem eigenen Dictionary tragen, geprüft, wenn das XObject gemalt wird - Annotationen mit einem
/OC-Eintrag im Annotation-Dictionary, die der PDFium-Renderpfad überhaupt nicht konsultiert, sodass eine geschaltete Annotation unabhängig vom Layer-Zustand sichtbar bleibt
Marked-Content-Hüllung bedeutet, bestehende Content-Streams Operator für Operator umzuschreiben, und Annotationen fallen durch den Renderer weg. Das lässt die Form-XObject-Route übrig, und sie ist die, die PDFium Component verwendet: Ihre Zeichnung geht in ein neues Form XObject mit /OC zeigend auf die Gruppe, das Formular wird im /Resources /XObject-Dictionary der Seite registriert, und ein neuer Content-Stream mit einem einzigen /Name Do wird an die /Contents der Seite angehängt. Der bestehende Seiteninhalt wird nie geparst oder umgeschrieben
Gruppen und geschaltete Formulare in Delphi beschreiben
TPdf.SaveAsOptionalContentCreated nimmt drei Arrays entgegen und liefert einen Report. Beide Überladungen — eine schreibend in einen TStream, eine in einen Dateinamen — teilen dieselbe Parameterliste: Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport und ein optionales AllowSignedDocument: Boolean = False. Die Methode kam in PDFium Component v3.154.0
Jedes TPdfOptionalContentCreateGroup hat einen Name, einen optionalen Creator, ein Intent von pociView oder pociDesign, ein Flag InitialStateVisible, ein Locked-Flag und ein RadioTag. Gruppen, die dasselbe RadioTag teilen, landen in einem /RBGroups-Array — so weiß ein Viewer, dass das Einschalten der einen die anderen ausschaltet. Jedes TPdfOptionalContentCreateForm nennt seine schaltende Gruppe über GroupIndex (einen nullbasierten Index in Ihr Groups-Array), eine 1-basierte PageNumber, die rohen Content-Stream-Bytes, eine Bounding Box, eine optionale Matrix und einen optionalen ResourceName
var
Pdf: TPdf;
Groups: TPdfOptionalContentCreateGroups;
Forms: TPdfOptionalContentCreateForms;
Report: TPdfOptionalContentCreateReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'sheet-a1.pdf';
Pdf.Active := True;
SetLength(Groups, 2); // SetLength nullt die Records
Groups[0].Name := 'Dimensions (mm)';
Groups[0].Intent := pociView;
Groups[0].InitialStateVisible := True;
Groups[0].RadioTag := 'Units';
Groups[1].Name := 'Dimensions (in)';
Groups[1].Intent := pociView;
Groups[1].InitialStateVisible := False;
Groups[1].RadioTag := 'Units';
SetLength(Forms, 2);
Forms[0].GroupIndex := 0;
Forms[0].PageNumber := 1;
Forms[0].Content := TEncoding.ASCII.GetBytes(
'0 0 1 RG 1.5 w 72 120 m 540 120 l S');
Forms[0].BBoxRight := 612;
Forms[0].BBoxTop := 792;
Forms[0].ResourceName := 'DimMetric';
Forms[1] := Forms[0];
Forms[1].GroupIndex := 1;
Forms[1].Content := TEncoding.ASCII.GetBytes(
'1 0 0 RG 1.5 w 72 140 m 540 140 l S');
Forms[1].ResourceName := 'DimImperial';
if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
Groups, nil, Forms, Report) then
raise Exception.Create(Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
Beachten Sie, dass der Formularinhalt reine Vektorzeichnung ist. Die Komponente schreibt nur ein leeres /Resources-Dictionary auf jedes Formular, der Content-Stream muss also in sich abgeschlossen sein: Device-Farben, Pfade, Clipping und Transformationen funktionieren, aber alles, was eine benannte Ressource braucht (eine Schrift für Tf, ein Bild für Do, ein ExtGState für gs), hat nichts, worgegen es aufgelöst werden könnte. Lassen Sie ResourceName leer, werden die Formulare FmOc1, FmOc2 und so weiter genannt; ein eigener Name darf nur Buchstaben, Ziffern, Unterstrich, Minus und Punkt enthalten
Was fügt ein OCMD gegenüber einer schlichten Gruppe hinzu?
Ein Optional-Content-Membership-Dictionary lässt die Sichtbarkeit von mehreren Gruppen gleichzeitig abhängen. ISO 32000-1 §8.11.2.2 definiert zwei Formen: eine /OCs-Liste mit einer /P-Policy wie /AnyOn oder /AllOn, und einen /VE-Sichtbarkeitsausdruck, aufgebaut aus /And, /Or und /Not laut Spezifikation. PDFium Component nimmt ein TPdfOptionalContentCreateMembership mit MemberGroupIndices, einer Policy und einem Expression; ist Expression.GroupIndices nicht leer, gewinnt der Ausdruck, und /VE wird neben /OCs geschrieben
procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
SetLength(Memberships, 2);
// Sichtbar, wenn eine der Unit-Layer an ist: /OCs [...] /P /AnyOn
SetLength(Memberships[0].MemberGroupIndices, 2);
Memberships[0].MemberGroupIndices[0] := 0;
Memberships[0].MemberGroupIndices[1] := 1;
Memberships[0].Policy := ocmpAnyOn;
// Sichtbar nur, solange der Imperial-Layer aus ist: /VE [ /Not ref ]
SetLength(Memberships[1].MemberGroupIndices, 1);
Memberships[1].MemberGroupIndices[0] := 1;
Memberships[1].Expression.Kind := poceNot;
SetLength(Memberships[1].Expression.GroupIndices, 1);
Memberships[1].Expression.GroupIndices[0] := 1;
end;
Die unterstützte Teilmenge ist bewusst schmal. Policies sind auf ocmpAllOn und ocmpAnyOn begrenzt; ocmpAnyOff und ocmpAllOff werden abgewiesen. Ausdrücke sind eine Ebene tief, mit poceAll, poceAny oder poceNot über erzeugte Gruppen, und /Not verlangt exakt einen Gruppenindex. poceAll serialisiert als /And, poceAny als /Or und poceNot als /Not — die einzigen drei Operatoren, die §8.11.2.2 definiert. Versionen vor 3.181.2 schrieben /All und /Any, die PDFium als verborgen auswertet; haben Sie OCMDs mit einem älteren Build erzeugt, generieren Sie sie neu, statt dem Ausdruck zu vertrauen. Jedes Membership braucht außerdem mindestens einen Eintrag in MemberGroupIndices, selbst wenn ein Ausdruck vorhanden ist, denn diese Liste wird zum /OCs-Array. Die Objektnummern der neuen Dictionarys kommen in Report.CreatedMembershipObjectNumbers zurück
Zwei Verhaltensweisen sollte man kennen, bevor man auf OCMDs aufbaut. Erstens referenzieren die geschalteten Formulare im selben Aufruf nur Gruppen: TPdfOptionalContentCreateForm.GroupIndex indexiert Groups, nicht Memberships, ein Formular kann also über diese API nicht von einem neu erzeugten OCMD geschaltet werden. Zweitens ergänzt PDFium Component jedes neue OCMD an das Katalog-/OCProperties /OCGs-Array, während /ON, /OFF und /Order reine Gruppen bleiben. ISO 32000-1 §8.11.4.2 beschreibt /OCGs als die Liste der Gruppen, aber ohne diese Registrierung enumeriert TPdf.InspectOptionalContent das Membership überhaupt nicht. Der Nebeneffekt: Ein Inventar der Ausgabe listet jedes OCMD als zusätzlichen unbenannten Eintrag in Inventory.Groups, ordnen Sie Gruppen also über den Name zu, nicht über die Position
Wie patcht die Komponente ein bestehendes /OCProperties?
TPdf.SaveAsOptionalContentCreated baut /OCProperties entweder von Grund auf oder erweitert das bereits im Katalog vorhandene, und es erweitert durch Anhängen, nie durch Neuaufbau. Ein Dokument ohne Layer bekommt ein neues Dictionary mit /OCGs und einer Default-Konfiguration /D mit /ON, /OFF und /Order, plus /Locked und /RBGroups, wenn irgendeine Gruppe sie verlangt — wie in ISO 32000-1 §8.11.4.3 ausgelegt
Bei einem Dokument, das bereits Layer hat, wird jedes Ziel-Array gepatcht, indem die neuen Referenzen unmittelbar vor seiner schließenden Klammer eingefügt werden. Das klingt pingelig, und es hat einen Grund. Ein /Order-Array kann verschachtelte Arrays und Textlabels halten, die den Layer-Baum formen, den ein Viewer zeigt, und ein früherer Ansatz, der das Array glättete und neu aufbaute, verlor diese Labels. Das Einfügen am Ende hält den bestehenden Baum intakt und listet die neuen Gruppen einfach dahinter auf. Arrays, die in eigenen indirekten Objekten leben, werden an Ort und Stelle aufgelöst und gepatcht
Seit v3.175.0 darf auch die Default-Konfiguration selbst indirekt sein. Manche Produzenten schreiben /OCProperties << /OCGs [...] /D 12 0 R >>, und PDFium Component folgt jetzt dieser Referenz und patcht das Ziel-Dictionary, statt mit einem Malformed-Entry-Fehler zu scheitern. Direkte und indirekte Seiten-/Resources-Dictionarys, eingeschlossen ein indirektes /XObject-Subdictionary, werden genauso behandelt
Beweisen, dass die neuen Layer wirklich umschalten
Ein Layer, der in /OCProperties existiert, aber nichts schaltet, ist leicht zu produzieren und schwer zu bemerken. Während der Entwicklung hüllte der erste Build des Form-Writers ein Stream-Objekt, das bereits seinen eigenen N 0 obj-Header und endobj-Trailer trug, in einen zweiten Header. PDFium konnte das verdoppelte Objekt nicht parsen, und statt einen Fehler zu melden, übersprang es still den Do-Operator. Die Datei öffnete sich, das Layer-Panel sah richtig aus, und die geschaltete Zeichnung erschien nie. Entlarvt hat es eine Sonde, die den Layer umschaltete und gerenderte Pixel verglich: kein Unterschied, also war etwas falsch
Deshalb verifiziert die Methode, bevor sie liefert. Die Ausgabe wird über ein striktes Laden wieder geöffnet, das die Cross-Reference-Tabelle prüft, und jede angeforderte Gruppe wird über InspectOptionalContent zurückgelesen, um ihren Namen, ihren Anfangszustand und ihre Locked-Registrierung zu bestätigen. Erst dann wird die Ausgabe in Ihren Stream kopiert, und der Datei-Overload schreibt eine temporäre Datei und ersetzt das Ziel atomar. Dieselbe Umschaltprüfung können Sie selbst fahren, indem Sie die Ausgabe wieder öffnen, die Gruppe über den Namen finden und eine Kopie mit dem entgegengesetzten Zustand speichern
function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
Pdf: TPdf;
Inventory: TPdfOptionalContentInventory;
Changes: TPdfOptionalContentStateChanges;
Options: TPdfOptionalContentConfigureOptions;
CfgReport: TPdfOptionalContentConfigureReport;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourceName;
Pdf.Active := True;
if not Pdf.InspectOptionalContent(Inventory) then
raise Exception.Create(Inventory.ErrorMessage);
for I := 0 to High(Inventory.Groups) do
if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
(Inventory.Groups[I].Name = 'Dimensions (in)') then
begin
SetLength(Changes, Length(Changes) + 1);
Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
end;
Options := TPdfOptionalContentConfigureOptions.Default;
Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
Options, CfgReport);
finally
Pdf.Free;
end;
end;
Rendern Sie Seite eins beider Dateien, sollten sich die blauen und roten Linien tauschen. Tun sie es nicht, inspizieren Sie zuerst den EffectiveVisible-Wert der Gruppe im Inventar; er spiegelt den Zustand, den PDFium nach Anwendung der Konfigurations-Arrays und Usage-Regeln tatsächlich rendert
Grenzen und Fallen, um die man plant
TPdf.SaveAsOptionalContentCreated ist ein begrenztes Profil, und die meisten Überraschungen kommen daher, es als allgemeinen Layer-Editor zu behandeln. Die Einschränkungen unten werden vorab durchgesetzt, ein verletzendes Aufrufscheitert also mit gesetztem Report.ErrorMessage und schreibt keine Ausgabe:
- Mindestens eine Gruppe ist nötig; Gruppen, Memberships und Formulare sind jeweils auf 4096 Einträge begrenzt, Gruppennamen müssen eindeutig und 1 bis 255 Zeichen lang sein, und jedes Formular akzeptiert höchstens 16 MiB Content-Bytes
- Verschlüsselte Dokumente werden abgewiesen, und ein signiertes Dokument braucht
AllowSignedDocument = True; die Ausgabe ist ein vollständiges Umschreiben, bestehende Signaturen validieren also nicht mehr - Jeder
Forms-Eintrag wird ein eigenes Form XObject, dasselbe Overlay auf 200 Seiten zu legen speichert den Inhalt also 200-mal statt einmal - Der neue
Do-Stream wird nach dem bestehenden Seiteninhalt angehängt, läuft also in dem Grafikzustand, den die früheren Streams hinterlassen; wohlgeformte Seiten stellen ihren Zustand wieder her, aber eine Seite mit unausgeglichenenq/Qkann Ihr Overlay verschieben - Bestehender Seiteninhalt wird nie in einen Layer verschoben; geschaltet wird nur die Zeichnung, die Sie in
Formsübergeben
Der Wiederholungspunkt ist der, den man bei großen Dokumenten abwägen muss. Ist das Overlay schwer und wiederholt sich auf jeder Seite, hält der Shared-Object-Ansatz in wiederverwendbaren Page-Stamps auf Form-XObject-Basis eine Kopie in der Datei — zum Preis, die Layer-Schaltung selbst zu handhaben
FAQ
Kann PDFium Component bestehenden Seiteninhalt in einen neuen Layer legen?
Nein. TPdf.SaveAsOptionalContentCreated schaltet nur den Inhalt, den Sie in Forms liefern; es hüllt bestehende Operatoren nicht in Marked Content und verschiebt sie nicht in ein XObject. Um bestehende Grafik zu layern, regenerieren Sie sie als in sich abgeschlossenen Formularinhalt und entfernen die Originalzeichnung in einem separaten Schritt
Warum bleiben meine geschalteten Annotationen sichtbar, wenn der Layer aus ist?
PDFium prüft den /OC-Eintrag von Annotation-Dictionarys auf seinem Renderpfad nicht, also hat Annotation-Level-Schaltung in PDFium-basierten Viewern keinen Effekt. Genau deshalb hängt PDFium Component Layer-Inhalte über Form XObjects an, die PDFium tatsächlich schaltet
Welche Objektnummern soll ich verwenden, wenn ich die neuen Layer später umschalte?
Report.CreatedGroupObjectNumbers hält die Nummern in der Datei, die gerade geschrieben wurde. Wenn Sie diese Datei wieder öffnen und konfigurieren, nehmen Sie ObjectNumber aus TPdf.InspectOptionalContent und ordnen über den Name zu, was korrekt bleibt, selbst wenn ein späteres Save Objekte neu nummeriert
PDFium Component liefert SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured und InspectOptionalContent als ein gemeinsames Optional-Content-Toolkit für Delphi und C++Builder, aufgebaut auf dem PDFium-Renderer. Details und Downloads finden Sie auf der PDFium-Component-Produktseite