Articolo tecnico

Creare layer PDF (OCG e OCMD) in Delphi con PDFium

PDFium Component crea nuovi layer PDF da Delphi attraverso TPdf.SaveAsOptionalContentCreated. In un solo salvataggio verificato il metodo aggiunge gruppi di contenuto opzionale (OCG), dizionari di membership del contenuto opzionale (OCMD) e disegni forniti dal chiamante avvolti in Form XObject la cui voce /OC punta a un nuovo gruppo, così PDFium e altri viewer mostrano o nascondono quel disegno quando il layer viene commutato

La richiesta raramente è astratta. Un team di impiantistica vuole quote metriche e imperiali sullo stesso foglio di disegno, con esattamente un insieme visibile. Una tipografia vuole un overlay delle linee di taglio che si vede a schermo ma si può spegnere prima della prova. Nessuno dei due file ha ancora layer, quindi non c'è nulla da commutare. Commutare layer esistenti è un lavoro diverso, trattato in commutare i layer di contenuto opzionale PDF esistenti con SaveAsOptionalContentConfigured; questo articolo parla di creare i layer in primo luogo

Perché non si può semplicemente impostare /OC su un page object con PDFium?

PDFium non ha una funzione pubblica per attaccare contenuto opzionale a un page object esistente. Gli header pubblici non contengono FPDFPageObj_SetOC né alcunché di equivalente, quindi la solita strada edit-poi-FPDFPage_GenerateContent non può produrre contenuto gated. Il gate va scritto nella struttura del file, e l'unica domanda è quale struttura PDFium onorerà quando renderizza

ISO 32000-1 §8.11.3 dà tre modi per rendere la grafica opzionale, e PDFium li tratta diversamente:

  • Sezioni del content stream di pagina avvolte in marked content /OC /Name BDC ... EMC, controllate per page object durante il rendering
  • Form XObject e image XObject che portano una voce /OC nel proprio dizionario, controllati quando l'XObject viene dipinto
  • Annotazioni con una voce /OC nel dizionario di annotazione, che il percorso di rendering di PDFium non consulta affatto, così un'annotazione gated resta visibile a prescindere dallo stato del layer

L'avvolgimento in marked content significa riscrivere i content stream esistenti operatore per operatore, e le annotazioni sono escluse dal renderer. Resta la strada del Form XObject, ed è quella che usa PDFium Component: il vostro disegno va in un nuovo Form XObject con /OC che punta al gruppo, la form viene registrata nel dizionario /Resources /XObject della pagina, e un nuovo content stream che contiene un singolo /Name Do viene accodato al /Contents della pagina. Il contenuto esistente della pagina non viene mai analizzato né riscritto

Percorsi di gating del contenuto opzionale ISO 32000-1 sotto PDFium: il marked content tra OC BDC e EMC viene controllato per page object, un Form XObject con una voce /OC viene controllato quando dipinto ed è la strada che PDFium Component scrive da Delphi, mentre l'/OC delle annotazioni non viene mai consultato
PDFium controlla il contenuto opzionale per le sezioni marked-content e i dizionari XObject ma non per le annotazioni, ed è per questo che SaveAsOptionalContentCreated mura il disegno fornito attraverso un Form XObject e un operatore Do

Descrivere gruppi e form gated in Delphi

TPdf.SaveAsOptionalContentCreated prende tre array e restituisce un report. Entrambi gli overload, uno che scrive su un TStream e uno su un nome di file, condividono la stessa lista di parametri: Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport, e un AllowSignedDocument: Boolean = False facoltativo. Il metodo è arrivato in PDFium Component v3.154.0

Ogni TPdfOptionalContentCreateGroup ha un Name, un Creator facoltativo, un Intent pari a pociView o pociDesign, un flag InitialStateVisible, un flag Locked e un RadioTag. I gruppi che condividono lo stesso RadioTag finiscono in un unico array /RBGroups, che è come un viewer sa che accenderne uno spegne gli altri. Ogni TPdfOptionalContentCreateForm nomina il proprio gruppo di gating con GroupIndex (un indice in base zero nel vostro array Groups), un PageNumber in base 1, i byte grezzi del content stream, un bounding box, una matrice facoltativa e un ResourceName facoltativo

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 azzera i record
    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;

Notate che il contenuto della form è disegno vettoriale puro. Il componente scrive solo un dizionario /Resources vuoto su ogni form, quindi il content stream deve essere autosufficiente: colori device, tracciati, ritagli e transform funzionano, ma qualunque cosa che richieda una risorsa con nome (un font per Tf, un'immagine per Do, un ExtGState per gs) non ha nulla contro cui risolvere. Se lasciate ResourceName vuoto, le form si chiamano FmOc1, FmOc2 e così via; un nome personalizzato può contenere solo lettere, cifre, underscore, meno e punto

Cosa aggiunge un OCMD rispetto a un gruppo semplice?

Un dizionario di membership del contenuto opzionale fa dipendere la visibilità da parecchi gruppi insieme. ISO 32000-1 §8.11.2.2 definisce due forme: un elenco /OCs con una policy /P come /AnyOn o /AllOn, e un'espressione di visibilità /VE costruita con /And, /Or e /Not nella specifica. PDFium Component prende un TPdfOptionalContentCreateMembership con MemberGroupIndices, una Policy e una Expression; quando Expression.GroupIndices non è vuoto vince l'espressione e /VE viene scritta accanto a /OCs

procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
  SetLength(Memberships, 2);

  // Visibile quando uno qualunque dei layer di unità è acceso: /OCs [...] /P /AnyOn
  SetLength(Memberships[0].MemberGroupIndices, 2);
  Memberships[0].MemberGroupIndices[0] := 0;
  Memberships[0].MemberGroupIndices[1] := 1;
  Memberships[0].Policy := ocmpAnyOn;

  // Visibile solo finché il layer imperiale è spento: /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;

Il sottoinsieme supportato è volutamente ristretto. Le policy sono limitate a ocmpAllOn e ocmpAnyOn; ocmpAnyOff e ocmpAllOff vengono rifiutate. Le espressioni sono profonde un livello, con poceAll, poceAny o poceNot sui gruppi creati, e /Not richiede esattamente un indice di gruppo. poceAll si serializza come /And, poceAny come /Or e poceNot come /Not, gli unici tre operatori che §8.11.2.2 definisce. Le versioni prima della 3.181.2 scrivevano /All e /Any, che PDFium valuta come nascosti, quindi se avete creato OCMD con una build più vecchia, rigenerateli invece di fidarvi dell'espressione. Ogni membership richiede anche almeno una voce in MemberGroupIndices, anche quando un'espressione è presente, perché quell'elenco diventa l'array /OCs. I numeri di oggetto dei nuovi dizionari tornano in Report.CreatedMembershipObjectNumbers

Dizionari di membership del contenuto opzionale creati da PDFium Component in Delphi: un elenco /OCs con una policy /AnyOn o /AllOn valuta insieme gli stati dei gruppi, mentre un'espressione di visibilità /VE come /Not su un gruppo mura il contenuto con logica And, Or e Not profonda un livello
Le policy valutano l'elenco /OCs mentre le espressioni /VE combinano i gruppi con /And, /Or e /Not - in entrambi i casi la membership richiede almeno un gruppo membro da referenziare

Due comportamenti vale la pena conoscere prima di costruire sugli OCMD. Primo, le form gated nella stessa chiamata referenziano solo gruppi: TPdfOptionalContentCreateForm.GroupIndex indicizza Groups, non Memberships, quindi una form non può essere murata da un OCMD appena creato attraverso questa API. Secondo, PDFium Component aggiunge ogni nuovo OCMD all'array /OCProperties /OCGs del catalogo, mentre /ON, /OFF e /Order restano solo per i gruppi. ISO 32000-1 §8.11.4.2 descrive /OCGs come l'elenco dei gruppi, ma senza quella registrazione TPdf.InspectOptionalContent non enumera affatto la membership. L'effetto collaterale è che un inventario dell'output elenca ogni OCMD come voce extra senza nome in Inventory.Groups, quindi abbinate i gruppi per Name invece che per posizione

Come mette in patch il componente un /OCProperties esistente?

TPdf.SaveAsOptionalContentCreated oppure costruisce /OCProperties da zero oppure estende quello già presente nel catalogo, ed estende accodando, mai ricostruendo. Un documento senza layer riceve un nuovo dizionario con /OCGs e una configurazione predefinita /D che porta /ON, /OFF e /Order, più /Locked e /RBGroups quando qualche gruppo le richiede, come disposto in ISO 32000-1 §8.11.4.3

Per un documento che ha già layer, ogni array di destinazione viene messo in patch inserendo i nuovi riferimenti appena prima della sua parentesi quadra di chiusura. Sembra un dettaglio minuzioso, e c'è per un motivo. Un array /Order può contenere array annidati ed etichette testuali che danno forma all'albero dei layer che un viewer mostra, e un approccio precedente che appiattiva e ricostruiva l'array perdeva quelle etichette. L'inserimento in coda tiene intatto l'albero esistente e semplicemente elenca i nuovi gruppi dopo. Gli array che vivono nei propri oggetti indiretti vengono risolti e messi in patch sul posto

Estensione di un dizionario /OCProperties esistente con PDFium Component in Delphi: i nuovi riferimenti ai gruppi di contenuto opzionale vengono inseriti appena prima della parentesi di chiusura così gli array /Order annidati e le etichette testuali sopravvivono, e una configurazione predefinita /D indiretta viene seguita e messa in patch sul posto
Accodare i riferimenti prima della parentesi di chiusura tiene intatto un albero /Order esistente, cosa che una ricostruzione che appiattisce array annidati ed etichette non può garantire

Dalla v3.175.0 anche la configurazione predefinita stessa può essere indiretta. Alcuni produttori scrivono /OCProperties << /OCGs [...] /D 12 0 R >>, e PDFium Component ora segue quel riferimento e mette in patch il dizionario di destinazione invece di fallire con un errore di voce malformata. I dizionari /Resources di pagina diretti e indiretti, compreso un sottodizionario /XObject indiretto, vengono trattati allo stesso modo

Dimostrare che i nuovi layer commutano davvero

Un layer che esiste in /OCProperties ma non mura nulla è facile da produrre e difficile da notare. Durante lo sviluppo la prima build del writer delle form avvolse un oggetto stream che portava già il proprio header N 0 obj e il trailer endobj dentro un secondo header. PDFium non riusciva a fare il parsing dell'oggetto raddoppiato, e invece di riportare un errore saltava silenziosamente l'operatore Do. Il file si apriva, il pannello dei layer sembrava giusto, e il disegno murato non compariva mai. A esporlo è stata una sonda che commutava il layer e confrontava i pixel renderizzati: nessuna differenza, quindi qualcosa non andava

Ecco perché il metodo verifica prima di consegnare. L'output viene riaperto attraverso un caricamento rigoroso che controlla la tabella dei riferimenti incrociati, e ogni gruppo richiesto viene riletto attraverso InspectOptionalContent per confermare il proprio nome, il proprio stato iniziale e la propria registrazione locked. Solo allora l'output viene copiato nel vostro stream, e l'overload su file scrive un file temporaneo e sostituisce la destinazione atomicamente. Potete eseguire voi lo stesso controllo di commutazione riaprendo l'output, trovando il gruppo per nome e salvando una copia con lo stato opposto

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;

Renderizzate la pagina uno di entrambi i file e le linee blu e rossa dovrebbero scambiarsi. Se non lo fanno, ispezionate prima il valore EffectiveVisible del gruppo nell'inventario; riflette lo stato che PDFium renderizzerà davvero dopo che array di configurazione e regole d'uso sono applicati

Limiti e trappole di cui tenere conto

TPdf.SaveAsOptionalContentCreated è un profilo delimitato, e la maggior parte delle sorprese viene dal trattarlo come un editor di layer generale. I vincoli sotto sono imposti all'inizio, così una chiamata che li viola fallisce con Report.ErrorMessage impostato e non scrive alcun output:

  • È richiesto almeno un gruppo; gruppi, membership e form hanno un tetto di 4096 voci ciascuno, i nomi dei gruppi devono essere unici e lunghi da 1 a 255 caratteri, e ogni form accetta al massimo 16 MiB di byte di contenuto
  • I documenti cifrati vengono rifiutati, e un documento firmato richiede AllowSignedDocument = True; l'output è una riscrittura completa, quindi le firme esistenti non verificheranno più
  • Ogni voce Forms diventa il proprio Form XObject, quindi mettere lo stesso overlay su 200 pagine memorizza il contenuto 200 volte invece che una
  • Il nuovo stream Do viene accodato dopo il contenuto esistente della pagina, quindi gira nello stato grafico che gli stream precedenti lasciano dietro; le pagine ben formate ripristinano il proprio stato, ma una pagina con q/Q sbilanciati può spostare il vostro overlay
  • Il contenuto esistente della pagina non viene mai spostato in un layer; solo il disegno che passate in Forms viene murato

Il punto della ripetizione è quello da valutare per i documenti grandi. Se l'overlay è pesante e si ripete su ogni pagina, l'approccio a oggetti condivisi in timbri di pagina riutilizzabili costruiti su Form XObject tiene una sola copia nel file, al costo di gestire da voi il gate del layer

FAQ

PDFium Component può mettere il contenuto esistente della pagina in un nuovo layer?

No. TPdf.SaveAsOptionalContentCreated mura solo il contenuto che fornite in Forms; non avvolge gli operatori esistenti in marked content né li sposta in un XObject. Per mettere su layer una grafica esistente, rigeneratela come contenuto di form autosufficiente e togliete il disegno originale in un passo separato

Perché le mie annotazioni gated restano visibili quando il layer è spento?

PDFium non controlla la voce /OC dei dizionari di annotazione sul proprio percorso di rendering, quindi il gating a livello di annotazione non ha effetto nei viewer basati su PDFium. È il motivo per cui PDFium Component attacca il contenuto dei layer attraverso Form XObject, che PDFium mura davvero

Quali numeri di oggetto usare quando in seguito commuto i nuovi layer?

Report.CreatedGroupObjectNumbers contiene i numeri nel file appena scritto. Quando riaprite quel file e lo configurate, prendete ObjectNumber da TPdf.InspectOptionalContent e abinate per Name, che resta corretto anche se un salvataggio successivo rinumera gli oggetti

PDFium Component include SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured e InspectOptionalContent come un unico toolkit di contenuto opzionale per Delphi e C++Builder, costruito sul renderer PDFium. Dettagli e download sono sulla pagina del prodotto PDFium Component