Technical Article

PDF-Ebenen in Delphi: Optional Content Groups (OCG)

Ein Landvermesser öffnet einen Lageplan und möchte die Höhenlinien ausblenden, während die Versorgungsleitungen sichtbar bleiben. Ein Prüfer möchte die roten Anmerkungen auf dem Bildschirm sehen, sie aber nicht auf dem Ausdruck haben. Ein Produktdatenblatt wird in drei Sprachen aus einer einzigen Datei bereitgestellt, und der Leser wählt aus, welche Sprache angezeigt wird. Alle drei Anwendungsfälle nutzen dieselbe PDF-Funktion, und das Panel, das sie in Acrobat steuert, heißt Ebenen. Die dahinterstehende Funktion is optionaler Inhalt, und sie sorgt dafür, dass eine einzelne Seite mehrere unabhängige visuelle Schichten tragen kann, die ein Viewer ein- und ausschalten kann.

Optionaler Inhalt wird in ISO 32000-1 §8.11 spezifiziert. Die Einheit der Sichtbarkeit ist eine Optional Content Group (OCG), ein Dictionary des Typs /OCG, das einen Namen trägt. Markierter Inhalt auf einer Seite wird einer Gruppe zugeordnet, und the Viewer entscheidet, ob diese Gruppe gerade angezeigt wird. Ein verwandtes Konstrukt, das Optional Content Membership Dictionary (OCMD), ermöglicht es, die Sichtbarkeit von einer booleschen Kombination mehrerer Gruppen abhängig zu machen. Der alltägliche Fall ist jedoch eine einzelne benannte Gruppe, die für eine einzelne Ebene steht. Das Dokument verbindet den gesamten Mechanismus über einen Katalogeintrag namens /OCProperties, der als Nächstes beschrieben wird.

Was der Katalog enthalten muss

Eine OCG allein ist inaktiv. Damit ein Viewer eine Ebene auflistet und sich ihren Zustand merkt, benötigt der Dokumentenkatalog ein /OCProperties-Dictionary, und §8.11.4 legt genau fest, was darin enthalten sein muss. Es gibt ein /OCGs-Array, das jede Gruppe in der Datei benennt, und einen /D-Eintrag, der die Standardkonfiguration enthält. Die Standardkonfiguration ist der Teil, den ein Reader anwendet, wenn die Datei zum ersten Mal geöffnet wird. Sie zeichnet auf, welche Gruppen standardmäßig aktiv und welche inaktiv sind, welche Einträge gegen das Umschalten durch den Benutzer gesperrt sind und wie die Ebenennamen über ein /Order-Array im Panel angeordnet und verschachtelt sind.

Die praktische Konsequenz ist, dass das Erstellen einer Ebene niemals ein rein lokaler Akt ist. Die Gruppe muss auf der Seite gezeichnet werden und sie muss auch in einer Struktur auf Katalogebene registriert werden, die zuvor nicht existierte. PDFlibPas erledigt beides für Sie. Der erste Aufruf, der eine Gruppe erstellt, fügt den /OCProperties-Eintrag zum Katalog hinzu und initialisiert die Standardkonfiguration, sodass die Ebene ohne separaten Buchhaltungsaufwand Ihrerseits sowohl gezeichnet als auch aufgelistet wird.

Warum ein Compliance-Modus die Funktion verweigern kann

Bevor Code für Ebenen ausgeführt wird, entscheidet das Konformitätsziel des Dokuments, ob optionaler Inhalt überhaupt zulässig ist. PDF/A-1, das in ISO 19005-1 definierte Archivierungsprofil, verbietet den /OCProperties-Eintrag in §6.1.13 gänzlich. Die Begründung entspricht dem Zweck des Formats. Eine Archivdatei muss für jeden Leser weit in der Zukunft identisch gerendert werden, und Inhalt, dessen Sichtbarkeit ein Viewer ändern kann, ist Inhalt, dessen Aussehen nicht festgelegt ist. Daher verbietet das Profil dieses Konstrukt, anstatt ein zweideutiges Archiv zuzulassen. PDF/A-2 und PDF/A-3, definiert in ISO 19005-2 und ISO 19005-3, vertreten in ihrem §6.9 die gegenteilige Ansicht und erlauben optionalen Inhalt mit Regeln zur Standardsichtbarkeit.

Dieser Unterschied zeigt sich direkt in die API. Wenn sich das Dokument in einem PDF/A-1-Modus befindet, NewOptionalContentGroup weigert sich, die Gruppe zu erstellen, und gibt Null zurück, da das Erfüllen der Anforderung eine Datei erzeugen würde, die ihre eigene deklarierte Konformität verletzt. Im PDF/A-2- oder PDF/A-3-Modus sowie im normalen, uneingeschränkten PDF ist derselbe Aufruf erfolgreich und gibt eine OCG-ID ungleich Null zurück. Ein Ergebnis von Null ist daher kein allgemeiner Fehler, den man später untersuchen müsste; es ist die Rückmeldung der Bibliothek, dass die aktive Compliance-Stufe keinen Platz für diese Funktion bietet.

var
  Pdf: TPDFlib;
  LayerID: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.SetPDFAMode(1);                       // PDF/A-1a: OCProperties forbidden

    LayerID := Pdf.NewOptionalContentGroup('Utilities');
    if LayerID = 0 then
      // refused under PDF/A-1; not a transient error, the mode bans layers
      ShowMessage('Optional content is not available in PDF/A-1 mode.');
  finally
    Pdf.Free;
  end;
end;

Zwei Zustände pro Ebene, nicht einer

Eine Ebene ist nicht einfach nur sichtbar oder unsichtbar. Die Standardkonfiguration zeichnet ihren Bildschirmzustand und einen separaten Druckzustand auf, da §8.11.4 zwischen dem unterscheidet, was ein Viewer anzeigt, und dem, was eine Druck-Pipeline ausgibt. Beide sind bewusst unabhängig voneinander. Ein Entwurfswasserzeichen kann auf dem Bildschirm angezeigt und auf dem Papier weggelassen werden, und eine Schnittlinien-Ebene kann auf dem Bildschirm ausgeblendet, aber an einen Plotter gesendet werden. Das Zusammenführen der beiden würde erzwingen, dass das eine dem anderen folgt, wodurch genau die Kontrolle verloren günge, für die die Funktion eigentlich existiert.

PDFlibPas stellt dieses Paar über zwei Setter bereit. SetOptionalContentGroupVisible übernimmt die Gruppen-ID und ein Flag, wobei eins sichtbar und null ausgeblendet bedeutet, und steuert den Standard-Bildschirmzustand. SetOptionalContentGroupPrintable übernimmt die Gruppen-ID und ein Flag dafür, ob die Ebene beim Drucken des Dokuments ausgegeben wird. Die entsprechenden Getter, GetOptionalContentGroupVisible and GetOptionalContentGroupPrintable, geben jeweils eins oder null zurück, sodass Sie den Bildschirm- und Druckzustand einer Ebene separat auslesen können, anstatt den einen aus dem anderen abzuleiten.

Erstellen von zwei Ebenen auf einer Seite

Das Erstellen und Befüllen einer Ebene folgt einer festen Reihenfolge. Sie zeichnen den Inhalt für die Ebene auf die aktuelle Seite und rufen dann SetContentStreamOptional mit der Gruppen-ID auf. Dies kapselt den aktuellen Inhaltsstrom der Seite, sodass alles bisher Gezeichnete zu dieser Gruppe gehört. Da der Aufruf genau das erfasst, was sich in diesem Moment im Stream befindet, besteht die Vorgehensweise darin, die Markierungen einer Ebene zu zeichnen, sie zuzuweisen und erst dann mit der nächsten Ebene zu beginnen. Das folgende Beispiel platziert Versorgungsleitungen auf der ersten Seite und Reviewer-Notizen auf einer zweiten Seite, legt den Bildschirm- und Druckzustand für jede Ebene fest und speichert das Dokument.

var
  Pdf: TPDFlib;
  FontID, UtilLayer, RedlineLayer: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;                          // unconstrained PDF: layers allowed
    Pdf.SetPageDimensions(595, 842);          // A4 in points
    FontID := Pdf.AddStandardFont(0);         // Helvetica
    Pdf.SelectFont(FontID);

    // Layer 1: utilities, drawn then assigned to its own group
    Pdf.SetTextColor(0.10, 0.30, 0.65);
    Pdf.DrawText(72, 770, 'Utilities: water main, valve chamber');
    UtilLayer := Pdf.NewOptionalContentGroup('Utilities');
    Pdf.SetContentStreamOptional(UtilLayer);
    Pdf.SetOptionalContentGroupVisible(UtilLayer, 1);   // shown on screen
    Pdf.SetOptionalContentGroupPrintable(UtilLayer, 1); // and on paper

    // Layer 2: reviewer redline on a fresh page
    Pdf.InsertPages(2, 1);                     // append one page after page 1
    Pdf.SetTextColor(0.80, 0.10, 0.10);
    Pdf.DrawText(72, 770, 'REVIEW: revise valve spec before issue');
    RedlineLayer := Pdf.NewOptionalContentGroup('Reviewer markup');
    Pdf.SetContentStreamOptional(RedlineLayer);
    Pdf.SetOptionalContentGroupVisible(RedlineLayer, 1);    // visible while reviewing
    Pdf.SetOptionalContentGroupPrintable(RedlineLayer, 0);  // never printed

    Pdf.SaveToFile('SitePlan_Layers.pdf');
  finally
    Pdf.Free;
  end;
end;

Die Reviewer-Ebene ist der bemerkenswerte Fall. Sie wird auf dem Bildschirm angezeigt, damit ein Prüfer die Notiz sieht, aber ihr druckbares Flag ist Null, sodass ein Ausdruck derselben Datei keinen Text des Reviews enthält. Diese Asymmetrie ist der eigentliche Grund, warum die beiden Zustände getrennt gehalten werden.

Auslesen der Konfiguration

Das Lesen von Ebenen ist ein anderer Weg durch dieselbe Struktur. Nach dem Laden einer Datei meldet GetOptionalContentConfigCount, wie viele Konfigurations-Dictionaries das Dokument enthält; die erste Standardkonfiguration hat die Konfigurations-ID 1. Innerhalb einer Konfiguration gibt GetOptionalContentConfigOrderCount die Anzahl der Einträge im Order-Baum an, und Sie indizieren sie ab 1. Für jeden Eintrag gibt GetOptionalContentConfigOrderItemLabel seinen Anzeigetext zurück, und GetOptionalContentConfigOrderItemLevel liefert seine Verschachtelungstiefe, sodass eine Panel-Struktur mit untergeordneten Ebenen, die unter Überschriften eingerückt sind, eins zu eins rekonstruiert werden kann.

Jeder Eintrag hat auch einen Typ. GetOptionalContentConfigOrderItemType unterscheidet eine tatsächliche Optional Content Group von einer reinen Textbeschriftung, die nur als Überschrift für einen Abschnitt des Baums dient. Diese Unterscheidung ist wichtig, da die Statusabfragen pro Gruppe nur für echte Gruppen sinnvoll sind. Für einen Gruppeneintrag meldet GetOptionalContentConfigState, ob die Konfiguration ihn standardmäßig ein- oder ausschaltet oder unverändert lässt, und GetOptionalContentConfigLocked berichtet, ob der Benutzer am Umschalten gehindert ist. Die folgende Schleife rendert den Order-Baum mit dem Zustand und Sperrstatus jeder Gruppe, eingerückt nach Ebene.

var
  Pdf: TPDFlib;
  Cfg, Count, I, ItemType, GroupID, Indent: Integer;
  Line: string;
begin
  Pdf := TPDFlib.Create(nil);
  try
    if Pdf.LoadFromFile('SitePlan_Layers.pdf', '') = 0 then Exit;
    if Pdf.GetOptionalContentConfigCount = 0 then Exit;

    Cfg := 1;                                  // the default configuration
    Count := Pdf.GetOptionalContentConfigOrderCount(Cfg);
    for I := 1 to Count do
    begin
      Indent := Pdf.GetOptionalContentConfigOrderItemLevel(Cfg, I);
      Line := StringOfChar(' ', Indent * 2)
              + Pdf.GetOptionalContentConfigOrderItemLabel(Cfg, I);

      ItemType := Pdf.GetOptionalContentConfigOrderItemType(Cfg, I);
      if ItemType = 1 then                     // 1 = optional content group
      begin
        GroupID := Pdf.GetOptionalContentConfigOrderItemID(Cfg, I);
        case Pdf.GetOptionalContentConfigState(Cfg, GroupID) of
          1: Line := Line + '  [on]';
          2: Line := Line + '  [off]';
          3: Line := Line + '  [unchanged]';
        end;
        if Pdf.GetOptionalContentConfigLocked(Cfg, GroupID) = 1 then
          Line := Line + ' (locked)';
      end;
      // ItemType = 2 is a text label heading; it has no per-group state

      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

Zwei Details halten diese Schleife korrekt. Der Order-Index ist einsbasiert, von 1 bis zur Gesamtzahl, was der internen Nummerierung des Baums durch die Bibliothek entspricht. Und die gruppenspezifischen Aufrufe werden nur ausgeführt, wenn der Elementtyp eine Gruppe ist, da ein Textlabel eine Überschrift mit einem Namen und einer Ebene ist, aber keinen Ein-, Aus- oder gesperrten Zustand hat, den man abfragen könnte. Wenn Sie diese Prüfung weglassen, fragen Sie ein Label nach einem Zustand, den es nicht besitzt.

Ebenen sind ein Präsentationsmechanismus, daher muss die Engine sie auf jedem Pfad berücksichtigen, der eine Seite rendert. Die Rendering-Seite wird in unserem Leitfaden zum Multi-Engine-Rendering in Delphi behandelt. Sie schneiden sich auch mit der Dokumentenstruktur, da der Name einer Ebene ein für den Autor sichtbarer Text ist und der Leser von einer strukturierten Ebenengliederung profitiert, was an die Arbeit in unserem Artikel über getaggte PDFs und Barrierefreiheitsstrukturen anknüpft. Beide verbinden sich mit den hier beschriebenen Optional-Content-APIs, die als Teil der Delphi PDF-Bibliothek zusammen mit den an anderer Stelle in diesem Blog behandelten Seiten-, Text-, Schriftart- und Konformitätsfunktionen ausgeliefert werden.