Technical Article

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

Een landmeter opent een situatieplan en wil de contouren verbergen terwijl de nutsvoorzieningen zichtbaar blijven. Een beoordelaar wil de rode annotaties op het scherm zien, maar niet op de afdruk. Een productblad wordt in drie talen vanuit één bestand verzonden, en de lezer kiest welke taal wordt weergegeven. Alle drie zijn ze gebaseerd op dezelfde PDF-functie, en het paneel dat ze in Acrobat aanstuurt heet Lagen (Layers). De onderliggende functie van dat paneel is optionele inhoud (optional content), en hiermee kan een enkele pagina verschillende onafhankelijke visuele lagen bevatten die een gebruiker kan in- en uitschakelen.

Optionele inhoud is gespecificeerd in ISO 32000-1 §8.11. De eenheid van zichtbaarheid is een optionele inhoudsgroep (optional content group), een OCG, een dictionary van het type /OCG die een naam draagt. Gemarkeerde inhoud op een pagina is gekoppeld aan een groep, en de viewer bepaalt of die groep momenteel wordt getoond. Een verwante structuur, de optionele inhouds-lidmaatschapsdictionary (optional content membership dictionary) of OCMD, maakt de zichtbaarheid afhankelijk van een boolean combinatie van verschillende groepen, maar in het dagelijks gebruik staat een enkele benoemde groep voor een enkele laag. Het document verbindt het hele mechanisme via één catalogusingang, /OCProperties, die hierna wordt beschreven.

Wat de catalogus moet bevatten

Een OCG op zichzelf is inactief. Om ervoor te zorgen dat een viewer een laag kan weergeven en de status ervan kan onthouden, heeft de documentcatalogus een /OCProperties-dictionary nodig, en §8.11.4 legt precies uit wat daarin hoort. Er is een /OCGs-array die elke groep in het bestand benoemt, en er is een /D-ingang die de standaardconfiguratie bevat. De standaardconfiguratie is het deel dat een lezer toepast wanneer het bestand voor het eerst wordt geopend. Het legt vast welke groepen ingeschakeld starten en welke uitgeschakeld starten, welke vermeldingen zijn vergrendeld zodat de gebruiker ze niet kan omschakelen, en, via een /Order-array, hoe de laagnamen zijn gerangschikt en genest in het paneel.

Het praktische gevolg is dat het maken van een laag never een puur lokale handeling is. De groep moet op de pagina worden getekend, en moet ook worden geregistreerd in een structuur op catalogusniveau die voorheen niet bestond. PDFlibPas doet beide voor u. De eerste aanroep die een groep maakt, voegt de vermelding /OCProperties toe aan de catalogus en initialiseert de standaardconfiguratie, zodat de laag zowel getekend als vermeld wordt zonder dat u zelf extra administratie hoeft bij te houden.

Waarom een nalevingsmodus de functie kan weigeren

Voordat er laagcode wordt uitgevoerd, bepaalt het conformiteitsdoel van het document of optionele inhoud überhaupt is toegestaan. PDF/A-1, het archiefprofiel gedefinieerd in ISO 19005-1, verbiedt de /OCProperties-vermelding volledig in §6.1.13. De redenering past bij het doel van het formaat. Een archiefbestand moet tot ver in de toekomst voor elke lezer identiek worden weergegeven, en inhoud waarvan de zichtbaarheid door een viewer kan worden gewijzigd, is inhoud waarvan het uiterlijk niet vaststaat. Het profiel verbiedt daarom deze structuur om een dubbelzinnig archief te voorkomen. PDF/A-2 en PDF/A-3, gedefinieerd in ISO 19005-2 en ISO 19005-3, nemen het tegenovergestelde standpunt in in hun §6.9 en staan optionele inhoud toe, met regels over de standaardzichtbaarheid.

Dat verschil is direct terug te zien in de API. Wanneer het document is in een PDF/A-1-modus, NewOptionalContentGroup weigert de groep aan te maken en retourneert nul, omdat het inwilligen van het verzoek een bestand zou opleveren dat niet voldoet aan de eigen gedeclareerde conformiteit. In de PDF/A-2- of PDF/A-3-modus, en in gewone onbeperkte PDF, slaagt dezelfde aanroep en retourneert een groeps-ID die ongelijk is aan nul. Een nulresultaat is daarom geen algemene fout om later te inspecteren; het is de bibliotheek die u vertelt dat het actieve nalevingsniveau geen ruimte heeft voor deze functie.

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;

Twee statussen per laag, niet één

Een laag is niet simpelweg zichtbaar of onzichtbaar. De standaardconfiguratie registreert de status op het scherm en een afzonderlijke afdrukstatus, omdat §8.11.4 onderscheid maakt tussen wat een viewer weergeeft en wat een afdruktraject produceert. De twee zijn bewust onafhankelijk van elkaar. Een conceptwatermerk kan op het scherm worden getoond maar bij het afdrukken worden weggelaten, en een snijlijnlaag kan op het scherm worden verborgen maar wel naar een plotter worden gestuurd. Het samenvoegen van de twee zou ertoe leiden dat de ene de andere moet volgen, waardoor u precies de controle verliest waarvoor de functie is ontworpen.

PDFlibPas stelt het paar beschikbaar via twee setters. SetOptionalContentGroupVisible vereist het groeps-ID en een vlag (waarbij één zichtbaar betekent en nul verborgen) en regelt de standaardstatus op het scherm. SetOptionalContentGroupPrintable vereist het groeps-ID en een vlag die bepaalt of de laag wordt uitgevoerd wanneer het document wordt afgedrukt. De bijbehorende getters, GetOptionalContentGroupVisible en GetOptionalContentGroupPrintable, retourneren elk één of nul, zodat u de scherm- en afdrukstatus van een laag afzonderlijk kunt uitlezen in plaats van de ene uit de andere af te leiden.

Twee lagen bouwen op een pagina

Het maken en vullen van een laag volgt een vaste volgorde. U tekent de inhoud voor de laag op de huidige pagina en roept vervolgens SetContentStreamOptional aan met het groeps-ID. Dit verpakt de huidige inhoudsstroom van de pagina, zodat alles wat tot nu toe is getekend bij die groep hoort. Omdat de aanroep alles vastlegt wat zich op dat moment in de stroom bevindt, is het de kunst om de markeringen van de ene laag aan te brengen, deze toe te wijzen en pas daarna met de volgende laag te beginnen. Het onderstaande voorbeeld plaatst nutsvoorzieningen op de eerste pagina en de revisiemarkeringen op een tweede pagina, stelt de scherm- en afdrukstatus van elke laag in en slaat het document op.

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;

De revisielaag (redline layer) is hierbij het vermelden waard. Deze wordt op het scherm getoond zodat een beoordelaar de opmerking ziet, maar de afdrukbare vlag (printable flag) is ingesteld op nul, waardoor een afdruk van hetzelfde bestand geen revisietekst bevat. Die asymmetrie is precies de reden om de twee statussen gescheiden te houden.

De configuratie teruglezen

Het lezen van lagen is een andere tocht door dezelfde structuur. Nadat een bestand is geladen, GetOptionalContentConfigCount rapporteert hoeveel configuratiedictionaries het document bevat; de eerste standaardconfiguratie is configuratie-ID 1. Binnen een configuratie geeft GetOptionalContentConfigOrderCount het aantal vermeldingen in de volgordeboom (order tree) aan, en u indexeert deze vanaf 1. Voor elke vermelding retourneert GetOptionalContentConfigOrderItemLabel de weergavetekst en GetOptionalContentConfigOrderItemLevel het nestingsniveau, zodat een paneelstructuur met sublagen die onder koppen zijn ingesprongen, letterlijk kan worden gereconstrueerd.

Elke vermelding heeft ook een type. GetOptionalContentConfigOrderItemType maakt onderscheid tussen een daadwerkelijke optionele inhoudsgroep en een gewoon tekstlabel dat alleen dient als kop van een sectie in de boom. Dat onderscheid is belangrijk omdat statusopvragingen per groep alleen zinvol zijn voor echte groepen. Voor een groepsvermelding rapporteert GetOptionalContentConfigState of de configuratie deze inschakelt (on), uitschakelt (off) of ongewijzigd laat, en GetOptionalContentConfigLocked rapporteert of het de gebruiker is verboden deze om te schakelen. De onderstaande lus geeft de volgordeboom weer met de status en vergrendelingsstatus van elke groep, ingesprongen per niveau.

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;

Twee details zorgen ervoor dat deze lus correct blijft. De volgorde-index is 1-gebaseerd, van 1 tot het totale aantal, wat overeenkomt met hoe de bibliotheek de boom intern nummert. En de aanroepen per groep worden alleen uitgevoerd als het itemtype een groep is, omdat een tekstlabel een kop is met een naam en een niveau, maar zonder een aan-, uit- of vergrendelde status om op te vragen. Sla die beveiliging over en u vraagt een label naar een status die het niet heeft.

Waar dit in het grotere geheel past

Lagen zijn een presentatiemechanisme, dus de engine moet ze respecteren in elk traject dat een pagina rendert. De renderingzijde wordt behandeld in onze handleiding over multi-engine rendering in Delphi. Ze kruisen ook met de documentstructuur, omdat de naam van een laag tekst is die voor de auteur bestemd is en een lezer baat heeft bij een gestructureerd laagoverzicht, wat aansluit bij het werk in ons artikel over tagged PDF en toegankelijkheidsstructuur. Beide sluiten aan bij de API's voor optionele inhoud die hier worden beschreven en die worden geleverd als onderdeel van de Delphi PDF Library, naast de functionaliteiten voor pagina's, tekst, lettertypen en naleving die elders op deze blog worden besproken.