Article technique

Comment un PDF Portfolio stocke colonnes, tris et dossiers

Trois offres partent dans un PDF Portfolio, et la colonne Amount décroissante du viewer place l'offre de USD 104250 en dernier, sous USD 9800 — les montants sont partis comme du texte. Les valeurs de colonnes vivent dans l'entrée /CI de chaque fichier attaché, et SetPortfolioItemNumericMetadata de HotPDF les y écrit comme des nombres PDF, si bien que le tri compare 104250 avec 9800 plutôt que deux chaînes

Qu'est-ce qui fait qu'un PDF s'ouvre comme un Portfolio ?

Une clé de catalogue décide de tout. L'ISO 32000-1 §12.3.5 dit que lorsqu'un dictionnaire /Collection est présent, un lecteur conforme doit présenter le document comme une collection portable (le nom que la spécification donne à ce qu'Acrobat appelle un PDF Portfolio), et chaque fichier de l'arbre de noms /Names /EmbeddedFiles en est membre ; les pièces jointes hors de cet arbre ne le sont pas. Un PDF avec dix pièces jointes et pas de /Collection n'est qu'un PDF avec un panneau de pièces jointes bien rempli, ce qui est la bonne forme quand un seul fichier lisible par machine voyage avec, comme le XML dans une facture électronique ZUGFeRD ou Factur-X. Le dictionnaire est petit et presque entièrement optionnel ; les numéros de tables ont bougé entre les deux éditions, donc les deux sont listés :

EntréeCe que cela contrôleISO 32000-1ISO 32000-2
/View, /DVue initiale (D details, T tiles, H hidden, plus C pour un navigateur en PDF 2.0) et la clé d'arbre de noms du fichier montré en premierTable 155Table 153
/SchemaLes colonnes de la liste de fichiersTables 156, 157Tables 154, 155
/SortChamps et sens de triTable 158Table 156
/ColorsPalette DeviceRGB suggérée pour la dispositionaucuneTable 157
/SplitSens et position de la barre de divisionaucuneTable 158
/FoldersRacine de l'arbre de dossiersaucuneTable 159 (§12.3.5.2)

Les pages du conteneur comptent toujours, parce qu'elles sont la page de couverture. Quand /D manque, le document initial est le conteneur lui-même ; l'ISO 32000-2 ajoute qu'un /D ne nommant aucun fichier existant fait choisir au lecteur le premier fichier de la liste à la place. Un viewer sans support Portfolio ignore /Collection et ne montre que ces pages, et la NOTE 2 du §12.3.5 y demande un titre et un paragraphe d'introduction ; la réponse habituelle est une page disant ce que le paquet contient et qu'il demande un viewer capable de Portfolio. ConfigurePortfolio(Name, View, InitialDocument) fixe la vue et le /D en un appel, et ConfigurePortfolioView bascule plus tard entre pvmDetails, pvmTile et pvmHidden. La vue C demande un dictionnaire de navigateur (ISO 32000-2 Table 160) ; pour un paquet d'offres ou un dossier de passation, la vue details avec un tri sensé est ce qu'attendent les utilisateurs

Champs de schéma et valeurs derrière chaque colonne

Les colonnes viennent du dictionnaire de schéma de collection, un dictionnaire de champ de collection par clé que vous choisissez. L'essentiel d'un champ est de la présentation — /N est l'en-tête, /O la position de colonne, /V et /E la visibilité initiale et la modifiabilité, passés à THotPDF.AddPortfolioSchemaField comme DisplayName, Order, Visible et Editable. /Subtype est la décision aux conséquences. Il dit qui fournit la valeur :

/SubtypeConstante HotPDFLa valeur vient de
S, D, NcfsText, cfsDate, cfsNumberVous, par fichier, dans /CI
F, DesccfsFileName, cfsDescriptionLa spécification de fichier (/UF ou /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeLe /Params du fichier embarqué
CompressedSize (PDF 2.0)cfsCompressedSizeLe /Length du flux embarqué

Les valeurs par fichier se trouvent dans le dictionnaire d'élément de collection que l'entrée /CI de la spécification de fichier pointe (ISO 32000-1 §7.11.6 Table 48, Table 46 dans la partie 2). Chaque entrée est une chaîne de texte, une date, un nombre, ou un dictionnaire d'élément secondaire de collection (Table 49, ou Table 47 dans la partie 2) dont le /D porte la donnée et dont le /P porte un préfixe d'affichage que le tri ignore. L'exemple 2 de la spécification elle-même montre pourquoi le préfixe existe : un objet de mail stocké comme /D (Let's have lunch on Friday!) avec /P (Re:), pour qu'une réponse se trie à côté du message auquel elle répond. Curieusement, les deux éditions disent que le schéma de cet exemple est au §12.4.2, Page Labels ; ils veulent dire l'exemple 1 juste au-dessus. Quant aux colonnes liées au fichier, AddDocumentAttachment horodate les dates /Params au moment de l'embarquement, si bien qu'une colonne cfsModDate montre quand le paquet a été construit. Les relecteurs qui ont besoin de la date du fichier source reçoivent une colonne D que vous remplissez vous-même, comme received ci-dessous

Schéma de PDF Portfolio avec HotPDF en Delphi : sous-types AddPortfolioSchemaField cfsFileName, cfsText, cfsNumber, cfsDate et cfsSize mappés vers la spécification de fichier, le dictionnaire d'élément de collection /CI et le /Params du fichier embarqué
Les colonnes texte, date et nombre prennent leurs valeurs dans l'entrée /CI de chaque fichier, tandis que les colonnes nom de fichier, description, date et taille viennent de structures écrites au moment de l'embarquement du fichier
uses
  System.SysUtils, HPDFDoc;

procedure BuildBidPackage;
const
  Files: array[0..2] of string =
    ('bids\civil.pdf', 'bids\electrical.pdf', 'bids\hvac.pdf');
  Trades: array[0..2] of AnsiString = ('Civil', 'Electrical', 'HVAC');
  Amounts: array[0..2] of Double = (104250.0, 98500.0, 9800.0);
var
  Pdf: THotPDF;
  SortKeys: THPDFPortfolioSortKeyArray;
  Key: AnsiString;
  Stamp: TDateTime;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf17;
    Pdf.FileName := 'bid-package.pdf';
    Pdf.BeginDoc;
    // Page de couverture : tout ce qu'un viewer sans support Portfolio montrera
    Pdf.CurrentPage.SetFont('Arial', [], 16);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Riverside depot - bid package');
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 736, 0,
      'Three bids are attached. Open this PDF Portfolio in a portfolio-capable viewer.');

    Pdf.ConfigurePortfolio('', 'D', 'civil.pdf');
    Pdf.AddPortfolioSchemaField('file', 'File', cfsFileName, 0);
    Pdf.AddPortfolioSchemaField('trade', 'Trade', cfsText, 1);
    Pdf.AddPortfolioSchemaField('amount', 'Amount', cfsNumber, 2);
    Pdf.AddPortfolioSchemaField('received', 'Received', cfsDate, 3);
    Pdf.AddPortfolioSchemaField('size', 'Size', cfsSize, 4, False); // cachée au début

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // la clé d'arbre de noms
      Pdf.SetPortfolioItemMetadata(Key, 'trade', Trades[I]);
      Pdf.SetPortfolioItemNumericMetadata(Key, 'amount', Amounts[I], 'USD ');
      if FileAge(Files[I], Stamp) then
        Pdf.SetPortfolioItemMetadata(Key, 'received',
          AnsiString('D:' + FormatDateTime('yyyymmddhhnnss', Stamp)));
    end;

    SetLength(SortKeys, 2);
    SortKeys[0].Key := 'amount';
    SortKeys[0].Ascending := False;   // la plus grosse offre en haut
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // départage
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata et SetPortfolioItemNumericMetadata lèvent quand la clé de schéma n'est pas enregistrée ou qu'aucun fichier embarqué n'a la clé donnée, donc enregistrez le schéma, attachez le fichier, puis fixez les valeurs. Leur argument FileName est la clé d'arbre de noms, qui pour AddDocumentAttachment est le nom de fichier sans son répertoire. Les clés sont des chaînes d'octets comparées sensible à la casse (§7.9.6), comme les destinations nommées dans l'arbre /Dests, si bien que Civil.pdf ne trouvera pas civil.pdf. Quand deux fichiers sources partagent un nom de base, HotPDF garde les deux et donne au second une clé numérotée comme hvac (2).pdf, que les appels de métadonnées doivent alors utiliser

Comment une offre de 104250 finit-elle sous 9800 ?

Il avait été stocké comme texte. Les règles de tri du §12.3.5 dépendent du type de donnée — les nombres numériquement, les dates du plus vieux au plus récent, les chaînes de texte lexicalement, la NOTE 3 laissant les détails de l'ordre lexical à chaque lecteur. La Table 48 (Table 46 dans la partie 2) exige que chaque entrée /CI corresponde au /Subtype de son champ, si bien que "USD 104,250" écrit comme texte dans une colonne N casse déjà la règle. Il reste au lecteur à comparer des chaînes. Après le "USD " partagé, le caractère 1 se trie avant 9, et un tri décroissant place la plus grosse offre en dernier. SetPortfolioItemMetadata écrit toujours un /D texte, juste pour les champs S et les chaînes de date. Pour les champs N, SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) écrit un nombre PDF et porte le symbole monétaire dans /P, que le tri ignore. Gardez les unités et les symboles monétaires dans le préfixe sur chaque colonne numérique : une valeur qui n'a l'air numérique qu'à l'écran est celle qui se trie mal. Avec un préfixe vide et UseSubitemDictionary à False, le nombre part directement dans /CI

SetPortfolioItemNumericMetadata de HotPDF en Delphi écrit les montants de PDF Portfolio comme des nombres avec un préfixe /P USD, si bien qu'un tri décroissant liste 104250 en premier, contrairement aux chaînes de texte qui mettent USD 104,250 sous USD 9,800
Stockée comme chaîne, l'offre de 104250 atterrit au bas d'une colonne Amount décroissante ; stockée comme nombre avec sa devise dans /P, elle monte en tête

Les dates suivent la même logique. Un champ D attend une chaîne de date PDF (§7.9.4) comme D:20261031143000 ; un 31/10/2026 venu des paramètres régionaux de Windows est du texte dans une colonne de date et atterrit où tombent ses caractères. Le tri lui-même vit dans le dictionnaire de tri de collection : /S est un nom de champ ou un tableau dans lequel chaque nom suivant départage les égalités laissées par les précédents, et /A est un booléen ou un tableau parallèle de booléens, les entrées en trop étant ignorées et les manquantes lues comme vrai, c'est-à-dire croissant. SetPortfolioSortOrder prend un tableau d'enregistrements THPDFPortfolioSortKey et écrit la forme à nom unique pour une clé, la forme tableau pour plusieurs. Utilisez les clés que vous avez enregistrées dans le schéma ; le /S ne peut nommer que des champs décrits dans le dictionnaire de collection parent

Les dossiers PDF 2.0 vivent dans les noms de fichiers

Un dossier ne contient aucun fichier dans le PDF. L'ISO 32000-2 §12.3.5.2 définit les dictionnaires de dossier comme des objets indirects chaînés en un arbre : /Folders pointe vers la racine, chaque enfant porte /Parent, un dossier atteint son premier enfant via /Child, et les frères se chaînent via /Next. Chaque dossier a un /Name requis et un entier /ID non négatif unique dans le document, et le tableau /Free de la racine liste les plages d'ID encore inutilisées. L'appartenance est encodée dans la clé EmbeddedFiles : une qui commence par <, continue avec des chiffres et se ferme par > appartient au dossier de cet ID, et le reste est le nom de fichier, si bien que <3>invoice-1031.pdf est invoice-1031.pdf dans le dossier 3. La clé, c'est le dossier. Toute clé hors convention appartient à la racine, et c'est ainsi que des factures destinées à un dossier 2026 finissent au niveau supérieur — attachées avec le simple AddDocumentAttachment, leurs clés ne portent aucun préfixe

HotPDF construit l'arbre avec trois appels. EnsureCollectionRootFolder(RootName, Description) crée la racine avec l'ID 0, ou renvoie l'ID de la racine existante. AddCollectionChildFolder(ParentFolderID, FolderName, Description) accole un enfant, renvoie son nouvel ID et garde /Free à jour. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) embarque le fichier sous <FolderID>name et écrit le nom nu dans /F et /UF, ce que montre une colonne cfsFileName. Tout appel ultérieur qui nomme le fichier a besoin de la clé complète, y compris InitialDocument dans ConfigurePortfolio et les appels de métadonnées. Les dossiers, /Split et /Colors sont des entrées PDF 2.0, donc fixez Version à pdf20 avant BeginDoc

Dossiers PDF 2.0 de PDF Portfolio avec HotPDF en Delphi : EnsureCollectionRootFolder et AddCollectionChildFolder construisent l'arbre de dossiers /Child et /Next, et AddDocumentAttachmentToCollectionFolder écrit des clés EmbeddedFiles portant le préfixe d'ID de dossier
L'appartenance aux dossiers d'une collection portable PDF 2.0 est encodée dans le préfixe de chiffres de chaque clé EmbeddedFiles, et toute clé sans chiffres entre chevrons reste à la racine
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders, /Split et /Colors sont PDF 2.0
    Pdf.FileName := 'handover.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 16);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Project handover - PDF Portfolio');

    Pdf.ConfigurePortfolio('', 'D', '<0>handover-summary.pdf');
    Pdf.AddPortfolioSchemaField('file', 'File', cfsFileName, 0);
    Pdf.AddPortfolioSchemaField('amount', 'Amount', cfsNumber, 1);

    RootID := Pdf.EnsureCollectionRootFolder('Handover', 'All deliverables'); // 0
    Year2025 := Pdf.AddCollectionChildFolder(RootID, '2025');                 // 1
    Year2026 := Pdf.AddCollectionChildFolder(RootID, '2026');                 // 2
    Q4 := Pdf.AddCollectionChildFolder(Year2026, 'Q4 invoices', 'Oct-Dec');   // 3

    Pdf.AddDocumentAttachmentToCollectionFolder(RootID,
      'out\handover-summary.pdf', 'Read this first');  // <0>handover-summary.pdf
    Pdf.AddDocumentAttachmentToCollectionFolder(Year2025,
      'out\as-built-2025.pdf', 'As-built drawings');   // <1>as-built-2025.pdf
    Pdf.AddDocumentAttachmentToCollectionFolder(Q4,
      'out\invoice-1031.pdf', 'October invoice');      // <3>invoice-1031.pdf

    Pdf.SetPortfolioItemNumericMetadata(
      AnsiString(Format('<%d>invoice-1031.pdf', [Q4])), 'amount', 10400.0, 'USD ');

    Pdf.SetPortfolioSplit(csdVertical, 30);    // barre de division à 30 % de la fenêtre
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

L'ordre des frères est une recommandation de la norme : les frères devraient suivre la clé de tri de collection, ou les noms de dossiers s'il n'y en a pas. AddCollectionChildFolder chaîne les frères dans l'ordre des appels, donc créez-les triés, comme l'exemple le fait avec 2025 avant 2026. La position /Split (ISO 32000-2 Table 158) est un pourcentage de 0 à 100, d'où 30 et pas 0.3. Les règles de nommage après la Table 159 s'appliquent aux noms de dossiers et aux fichiers dans les dossiers, et comme ces noms viennent en général d'utilisateurs ou de formulaires de téléversement, filtrez-les avant l'appel ; AddCollectionChildFolder lui-même refuse les barres obliques, les caractères de contrôle et un nom de frère qui répète un existant quand la casse ASCII est ignorée :

  • entre 1 et 255 caractères, sans NUL incrusté
  • aucun de / \ : * " < > | où que ce soit dans le nom
  • pas de point comme dernier caractère
  • pas de deux dossiers frères, ni de deux fichiers dans le même dossier, dont les noms ne diffèrent que par la casse

Lire un Portfolio écrit par un autre producteur

Un lecteur doit accepter chaque forme légale de /CI, parce que les producteurs diffèrent : certains emballent chaque valeur dans un dictionnaire d'élément secondaire, d'autres écrivent des chaînes et des nombres nus, et l'exemple 2 de la spécification mélange les deux dans un même élément. Du code qui ne cherche qu'une chaîne sous un /D d'élément secondaire montre des colonnes vides pour le second groupe. GetLoadedCollectionItemFields(FileName) renvoie un THPDFCollectionItemFieldValue par entrée avec IsSubitemDictionary, IsNumeric, NumericValue, StringValue et Prefix remplis. GetLoadedCollectionInfo renvoie schéma, clés de tri, split, couleurs et les nœuds de dossier de GetLoadedCollectionFolderTree dans l'ordre de l'arbre, chacun avec ID, Name, ParentID, FirstChildID et NextSiblingID. Le dossier de chaque fichier vient de sa clé, et le parse doit suivre la règle à la lettre : des chiffres seulement entre les chevrons. Une clé comme <draft>notes.txt venue d'un producteur qui ne sait rien des dossiers n'a pas de chiffres là, donc le fichier appartient à la racine ; tout couper jusqu'au premier > inventerait un dossier nommé draft

procedure DumpPortfolio(const PdfFile: string);
var
  Pdf: THotPDF;
  Info: THPDFCollectionInfo;
  Keys: THPDFAnsiStringArray;
  Fields: THPDFCollectionItemFieldValueArray;
  BareName: AnsiString;
  I, J, FolderID: Integer;
  Line: string;

  // ISO 32000-2 12.3.5.2 : '<' chiffres '>' nom de fichier ; toute autre clé est racine
  function FolderOfKey(const Key: AnsiString; RootID: Integer;
    out Name: AnsiString): Integer;
  var
    P: Integer;
  begin
    Result := RootID;
    Name := Key;
    if (Length(Key) < 3) or (Key[1] <> '<') then
      Exit;
    P := 2;
    while (P <= Length(Key)) and (Key[P] in ['0'..'9']) do
      Inc(P);
    if (P = 2) or (P > Length(Key)) or (Key[P] <> '>') then
      Exit;
    Result := StrToIntDef(string(Copy(Key, 2, P - 2)), RootID);
    Name := Copy(Key, P + 1, MaxInt);
  end;

begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(PdfFile) <= 0 then
      Exit;
    if not Pdf.GetLoadedCollectionInfo(Info) then
    begin
      Writeln('No /Collection in the catalog: plain attachments only');
      Exit;
    end;
    for I := 0 to High(Info.FolderNodes) do
      Writeln(Format('folder %d "%s" parent %d',
        [Info.FolderNodes[I].ID, Info.FolderNodes[I].Name,
         Info.FolderNodes[I].ParentID]));

    Keys := Pdf.GetLoadedEmbeddedFileNames;
    for I := 0 to High(Keys) do
    begin
      if Info.HasFolderTree then
        FolderID := FolderOfKey(Keys[I], Info.RootFolderID, BareName)
      else
      begin
        FolderID := -1;            // pas de dossiers : une liste à plat
        BareName := Keys[I];
      end;
      Line := Format('[%d] %s:', [FolderID, BareName]);
      Fields := Pdf.GetLoadedCollectionItemFields(Keys[I]);
      for J := 0 to High(Fields) do
        if Fields[J].IsNumeric then
          Line := Line + Format(' %s=%s%g',
            [Fields[J].Key, Fields[J].Prefix, Fields[J].NumericValue])
        else
          Line := Line + Format(' %s=%s%s',
            [Fields[J].Key, Fields[J].Prefix, Fields[J].StringValue]);
      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

Attendu pour le fichier de passation : quatre lignes de dossiers avec la racine à l'ID 0 et Q4 invoices sous le parent 2, puis [3] invoice-1031.pdf: amount=USD 10400 signalé comme numérique. Faites tourner la même boucle sur le paquet d'offres et chaque entrée amount revient avec IsNumeric actif ; un montant qui n'arrive que dans StringValue est celui qui triera 104250 sous 9800. Cette vérification tient en quelques lignes à côté du code qui construit les paquets avec le composant HotPDF pour Delphi et C++Builder