Article technique

Créer des couches PDF (OCG et OCMD) en Delphi avec PDFium

PDFium Component crée de nouvelles couches PDF depuis Delphi via TPdf.SaveAsOptionalContentCreated. En une sauvegarde vérifiée, la méthode ajoute des groupes de contenu optionnel (OCG), des dictionnaires dadhésion de contenu optionnel (OCMD) et un dessin fourni par lappelant enveloppé dans des Form XObjects dont lentrée /OC pointe un nouveau groupe, si bien que PDFium et les autres visionneuses montrent ou cachent ce dessin quand la couche est basculée

La demande est rarement abstraite. Une équipe dingénierie dusine veut des cotes métriques et impériales sur la même feuille de dessin, avec exactement un ensemble visible. Un imprimeur veut une surcharge de lignes de coupe qui se montre à lécran mais peut être coupée avant lépreuve. Aucun des deux fichiers na encore de couches, donc il ny a rien à basculer. Basculer des couches existantes est un autre travail, couvert dans la bascule des couches de contenu optionnel PDF existantes avec SaveAsOptionalContentConfigured ; cet article parle de créer les couches en premier lieu

Pourquoi ne peut-on pas simplement poser /OC sur un objet de page avec PDFium ?

PDFium na aucune fonction publique pour attacher du contenu optionnel à un objet de page existant. Les en-têtes publics ne contiennent aucun FPDFPageObj_SetOC ni rien déquivalent, si bien que la route habituelle éditer-puis-FPDFPage_GenerateContent ne peut pas produire de contenu bloqué. Le blocage doit être écrit dans la structure du fichier, et la seule question est de savoir quelle structure PDFium honorera au rendu

ISO 32000-1 §8.11.3 donne trois façons de rendre des graphiques optionnels, et PDFium les traite différemment :

  • Des sections de flux de contenu de page enveloppées dans du contenu marqué /OC /Name BDC ... EMC, contrôlées par objet de page pendant le rendu
  • Des Form XObjects et image XObjects qui portent une entrée /OC dans leur propre dictionnaire, contrôlées quand le XObject est peint
  • Des annotations avec une entrée /OC dans le dictionnaire dannotation, que le chemin de rendu PDFium ne consulte pas du tout, si bien quune annotation bloquée reste visible quel que soit létat de la couche

Envelopper en contenu marqué signifie réécrire les flux de contenu existants opérateur par opérateur, et les annotations sont écartées par le moteur de rendu. Reste la route Form XObject, et cest celle quemploie PDFium Component : votre dessin entre dans un nouveau Form XObject avec /OC pointant le groupe, la forme est enregistrée dans le dictionnaire /Resources /XObject de la page, et un nouveau flux de contenu détenant un unique /Name Do est ajouté au /Contents de la page. Le contenu de page existant nest jamais analysé ni réécrit

Routes de blocage de contenu optionnel ISO 32000-1 sous PDFium : le contenu marqué entre OC BDC et EMC est contrôlé par objet de page, un Form XObject avec une entrée /OC est contrôlé quand peint et est la route quécrit PDFium Component depuis Delphi, tandis que lannotation /OC nest jamais consultée
PDFium contrôle le contenu optionnel pour les sections de contenu marqué et les dictionnaires XObject mais pas pour les annotations, voilà pourquoi SaveAsOptionalContentCreated bloque le dessin fourni au travers dun Form XObject et dun opérateur Do

Décrire groupes et formes bloquées en Delphi

TPdf.SaveAsOptionalContentCreated prend trois tableaux et renvoie un rapport. Les deux surcharges, lune écrivant vers un TStream et lautre vers un nom de fichier, partagent la même liste de paramètres : Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport, et un AllowSignedDocument: Boolean = False optionnel. La méthode est arrivée dans PDFium Component v3.154.0

Chaque TPdfOptionalContentCreateGroup a un Name, un Creator optionnel, un Intent de pociView ou pociDesign, un drapeau InitialStateVisible, un drapeau Locked et un RadioTag. Les groupes qui partagent le même RadioTag atterrissent dans un tableau /RBGroups, cest ainsi quune visionneuse sait quallumer lun éteint les autres. Chaque TPdfOptionalContentCreateForm nomme son groupe de blocage par GroupIndex (un index à base zéro dans votre tableau Groups), un PageNumber à base un, les octets bruts du flux de contenu, une boîte englobante, une matrice optionnelle et un ResourceName optionnel

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 remplit les enregistrements de zéros
    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;

Remarquez que le contenu de la forme est du dessin vectoriel pur. Le composant nécrit quun dictionnaire /Resources vide sur chaque forme, si bien que le flux de contenu doit être autosuffisant : couleurs périphériques, tracés, clipping et transformations marchent, mais tout ce qui exige une ressource nommée (une police pour Tf, une image pour Do, un ExtGState pour gs) na rien contre quoi se résoudre. Si vous laissez ResourceName vide, les formes se nomment FmOc1, FmOc2 et ainsi de suite ; un nom personnalisé ne peut contenir que lettres, chiffres, souligné, moins et point

Quest-ce quun OCMD ajoute par rapport à un groupe simple ?

Un dictionnaire dadhésion de contenu optionnel fait dépendre la visibilité de plusieurs groupes à la fois. ISO 32000-1 §8.11.2.2 définit deux formes : une liste /OCs avec une politique /P telle que /AnyOn ou /AllOn, et une expression de visibilité /VE bâtie à partir de /And, /Or et /Not dans la spécification. PDFium Component prend un TPdfOptionalContentCreateMembership avec MemberGroupIndices, un Policy et une Expression ; quand Expression.GroupIndices nest pas vide, lexpression lemporte et /VE est écrit aux côtés de /OCs

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

  // Visible quand lune des couches dunités est allumée : /OCs [...] /P /AnyOn
  SetLength(Memberships[0].MemberGroupIndices, 2);
  Memberships[0].MemberGroupIndices[0] := 0;
  Memberships[0].MemberGroupIndices[1] := 1;
  Memberships[0].Policy := ocmpAnyOn;

  // Visible seulement tant que la couche impériale est coupée : /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;

Le sous-ensemble pris en charge est délibérément étroit. Les politiques sont limitées à ocmpAllOn et ocmpAnyOn ; ocmpAnyOff et ocmpAllOff sont rejetées. Les expressions sont dun niveau de profondeur, avec poceAll, poceAny ou poceNot sur des groupes créés, et /Not exige exactement un index de groupe. poceAll se sérialise en /And, poceAny en /Or et poceNot en /Not, les trois seuls opérateurs que §8.11.2.2 définit. Les versions antérieures à 3.181.2 écrivaient /All et /Any, que PDFium évalue comme caché, si bien que si vous avez créé des OCMD avec une build plus ancienne, régénérez-les plutôt que de vous fier à lexpression. Chaque adhésion exige aussi au moins une entrée dans MemberGroupIndices, même quand une expression est présente, parce que cette liste devient le tableau /OCs. Les numéros dobjets des nouveaux dictionnaires reviennent dans Report.CreatedMembershipObjectNumbers

Dictionnaires dadhésion de contenu optionnel créés par PDFium Component en Delphi : une liste /OCs avec une politique /AnyOn ou /AllOn évalue les états de groupes ensemble, tandis quune expression de visibilité /VE telle que /Not sur un groupe bloque le contenu avec une logique And, Or et Not dun niveau de profondeur
Les politiques évaluent la liste /OCs tandis que les expressions /VE combinent des groupes avec /And, /Or et /Not - dans les deux cas ladhésion exige au moins un groupe membre à référencer

Deux comportements valent la peine dêtre connus avant de bâtir sur les OCMD. Dabord, les formes bloquées dans le même appel ne référencent que des groupes : TPdfOptionalContentCreateForm.GroupIndex indexe Groups, pas Memberships, si bien quune forme ne peut pas être bloquée par un OCMD nouvellement créé au travers de cette API. Ensuite, PDFium Component ajoute chaque nouvel OCMD au tableau /OCProperties /OCGs du catalogue, tandis que /ON, /OFF et /Order restent réservés aux groupes. ISO 32000-1 §8.11.4.2 décrit /OCGs comme la liste des groupes, mais sans cet enregistrement TPdf.InspectOptionalContent nenumerote pas du tout ladhésion. Leffet de bord est quun inventaire de la sortie liste chaque OCMD comme une entrée supplémentaire sans nom dans Inventory.Groups, donc appariez les groupes par Name plutôt que par position

Comment le composant patche-t-il un /OCProperties existant ?

TPdf.SaveAsOptionalContentCreated construit soit /OCProperties de zéro, soit étend celle déjà dans le catalogue, et il étend en ajoutant, jamais en reconstruisant. Un document sans couches reçoit un nouveau dictionnaire avec /OCGs et une configuration par défaut /D portant /ON, /OFF et /Order, plus /Locked et /RBGroups quand un groupe les demande, comme posé dans ISO 32000-1 §8.11.4.3

Pour un document qui a déjà des couches, chaque tableau cible est patché en insérant les nouvelles références juste avant son crochet fermant. Cela paraît pointilleux, et cest là pour une raison. Un tableau /Order peut détenir des tableaux imbriqués et des étiquettes de texte qui façonnent larbre de couches quune visionneuse montre, et une approche antérieure qui aplaniait et reconstruisait le tableau perdait ces étiquettes. Linsertion à la fin garde larbre existant intact et liste simplement les nouveaux groupes après lui. Les tableaux qui vivent dans leurs propres objets indirects sont résolus et patchés en place

Extension dun dictionnaire /OCProperties existant avec PDFium Component en Delphi : les nouvelles références de groupes de contenu optionnel sont insérées juste avant le crochet fermant pour que les tableaux /Order imbriqués et les étiquettes de texte survivent, et une configuration par défaut /D indirecte est suivie et patchée en place
Ajouter des références avant le crochet fermant garde un arbre /Order existant intact, ce quune reconstruction qui aplatit tableaux imbriqués et étiquettes ne peut pas garantir

Depuis la v3.175.0, la configuration par défaut elle-même peut aussi être indirecte. Certains producteurs écrivent /OCProperties << /OCGs [...] /D 12 0 R >>, et PDFium Component suit maintenant cette référence et patche le dictionnaire cible au lieu déchouer avec une erreur dentrée mal formée. Les dictionnaires /Resources de page directs et indirects, y compris un sous-dictionnaire /XObject indirect, sont traités de la même façon

Prouver que les nouvelles couches basculent vraiment

Une couche qui existe dans /OCProperties mais ne bloque rien est facile à produire et difficile à remarquer. Pendant le développement, la première build du rédacteur de formes enveloppait un objet de flux qui portait déjà son propre en-tête N 0 obj et son trailer endobj dans un second en-tête. PDFium ne pouvait pas analyser lobjet doublé, et au lieu de rapporter une erreur il sautait silencieusement lopérateur Do. Le fichier souvrait, le panneau de couches avait lair bon, et le dessin bloqué napparaissait jamais. Ce qui la exposé fut une sonde qui basculait la couche et comparait les pixels rendus : aucune différence, donc quelque chose nallait pas

Voilà pourquoi la méthode vérifie avant de livrer. La sortie est rouverte par un chargement strict qui contrôle la table de référence croisée, et chaque groupe demandé est relu au travers dInspectOptionalContent pour confirmer son nom, son état initial et son enregistrement verrouillé. Ce nest quensuite que la sortie est copiée vers votre flux, et la surcharge fichier écrit un fichier temporaire et remplace la cible atomiquement. Vous pouvez lancer vous-même le même contrôle de bascule en rouvrant la sortie, trouvant le groupe par nom et sauvegardant une copie avec létat opposé

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;

Rendez la page un des deux fichiers et les lignes bleue et rouge devraient séchanger. Si elles ne léchangent pas, inspectez dabord la valeur EffectiveVisible du groupe dans linventaire ; elle reflète létat que PDFium rendra réellement après application des tableaux de configuration et des règles dusage

Limites et pièges à planifier autour

TPdf.SaveAsOptionalContentCreated est un profil borné, et la plupart des surprises viennent de le traiter comme un éditeur de couches général. Les contraintes ci-dessous sont appliquées davance, si bien quun appel enfreignant échoue avec Report.ErrorMessage réglé et nécrit aucune sortie :

  • Au moins un groupe est exigé ; groupes, adhésions et formes sont plafonnés à 4096 entrées chacun, les noms de groupes doivent être uniques et de 1 à 255 caractères, et chaque forme accepte au plus 16 Mio doctets de contenu
  • Les documents chiffrés sont rejetés, et un document signé exige AllowSignedDocument = True ; la sortie est une réécriture complète, si bien que les signatures existantes ne se vérifieront plus
  • Chaque entrée de Forms devient son propre Form XObject, si bien que mettre la même surcharge sur 200 pages stocke le contenu 200 fois plutôt quune
  • Le nouveau flux Do est ajouté après le contenu de page existant, si bien quil tourne dans quel que soit létat graphique que les flux antérieurs laissent derrière ; les pages bien formées restaurent leur état, mais une page avec des q/Q déséquilibrés peut décaler votre surcharge
  • Le contenu de page existant nest jamais déplacé dans une couche ; seul le dessin que vous passez dans Forms est bloqué

Le point de répétition est celui à peser pour de grands documents. Si la surcharge est lourde et se répète sur chaque page, lapproche objet partagé des tampons de page réutilisables bâtis sur des Form XObjects garde une copie dans le fichier, au prix de gérer vous-même le blocage de couche

FAQ

PDFium Component peut-il mettre du contenu de page existant dans une nouvelle couche ?

Non. TPdf.SaveAsOptionalContentCreated ne bloque que le contenu que vous fournissez dans Forms ; il nenveloppe pas les opérateurs existants en contenu marqué ni ne les déplace dans un XObject. Pour mettre en couche un graphisme existant, régénérez-le comme contenu de forme autosuffisant et retirez le dessin dorigine dans une étape séparée

Pourquoi mes annotations bloquées restent-elles visibles quand la couche est coupée ?

PDFium ne contrôle pas lentrée /OC des dictionnaires dannotation sur son chemin de rendu, si bien que le blocage au niveau annotation na aucun effet dans les visionneuses basées sur PDFium. Voilà pourquoi PDFium Component attache le contenu de couche au travers de Form XObjects, que PDFium bloque bien

Quels numéros dobjets employer quand je bascule les nouvelles couches plus tard ?

Report.CreatedGroupObjectNumbers détient les numéros dans le fichier qui vient dêtre écrit. Quand vous rouvrez ce fichier et le configurez, prenez ObjectNumber depuis TPdf.InspectOptionalContent et appariez par Name, qui reste correct même si une sauvegarde ultérieure renumérote les objets

PDFium Component livre SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured et InspectOptionalContent comme une boîte à outils de contenu optionnel pour Delphi et C++Builder, bâtie sur le moteur de rendu PDFium. Détails et téléchargements sont sur la page produit PDFium Component