Technisch artikel

PDF Portfolio-kolommen, sorteersleutels en mappen in Delphi

Drie biedingen gaan een PDF Portfolio in, en de dalende Amount-kolom van de viewer zet de bieding van USD 104,250 onderaan, onder USD 9,800 — de bedragen gingen er als tekst in. Kolomwaarden wonen in de /CI-entry van elk bijgevoegd bestand, en SetPortfolioItemNumericMetadata in HotPDF schrijft ze daar als PDF-nummers, zodat de sortering 104250 met 9800 vergelijkt in plaats van twee strings

Wat maakt dat een PDF als Portfolio opent?

Eén catalog-key beslist het. ISO 32000-1 §12.3.5 zegt dat wanneer een dictionary /Collection aanwezig is, een conforme reader het document als portable collection moet tonen (de naam die de specificatie geeft aan wat Acrobat een PDF Portfolio noemt), en elk bestand in de naamboom /Names /EmbeddedFiles is lid; bijlagen buiten die boom niet. Een PDF met tien bijlagen en geen /Collection is gewoon een PDF met een druk bijlagenpaneel, en dat is de juiste vorm wanneer één machineleesbaar bestand meereist, zoals de XML in een ZUGFeRD- of Factur-X-e-factuur. De dictionary is klein en bijna volledig optioneel; de tabelnummers schoven op tussen de twee edities, dus beide staan erbij:

EntryWat het stuurtISO 32000-1ISO 32000-2
/View, /DBeginweergave (D details, T tegels, H verborgen, plus C voor een navigator in PDF 2.0) en de naamboom-key van het eerst getoonde bestandTabel 155Tabel 153
/SchemaDe kolommen van de bestandslijstTabellen 156, 157Tabellen 154, 155
/SortSorteervelden en richtingenTabel 158Tabel 156
/ColorsVoorgestelde DeviceRGB-palette voor de layoutgeenTabel 157
/SplitRichting en positie van de splitterbalkgeenTabel 158
/FoldersWortel van de mappenboomgeenTabel 159 (§12.3.5.2)

De eigen pagina's van de container tellen nog steeds mee, want zij zijn het voorblad. Ontbreekt /D, dan is het begindocument de container zelf; ISO 32000-2 voegt toe dat een /D die geen bestaand bestand benoemt de reader het eerste bestand in de lijst laat kiezen. Een viewer zonder portfolio-ondersteuning negeert /Collection en toont alleen die pagina's, en NOTE 2 in §12.3.5 vraagt daar om een titel en een inleidende alinea; het gebruikelijke antwoord is één pagina die zegt wat het pakket bevat en dat het een viewer met portfolio-ondersteuning nodig heeft. ConfigurePortfolio(Name, View, InitialDocument) zet de weergave en /D in één aanroep, en ConfigurePortfolioView schakelt later tussen pvmDetails, pvmTile en pvmHidden. Weergave C heeft een navigator-dictionary nodig (ISO 32000-2 Tabel 160); voor een biedpakket of een opleveringsset is details-weergave met een zinnige sortering wat gebruikers verwachten

Schema-velden en de waarden achter elke kolom

Kolommen komen uit de collection schema-dictionary, één collection field-dictionary per key die u kiest. Het grootste deel van een veld is presentatie — /N is de kop, /O de kolompositie, /V en /E de beginzichtbaarheid en bewerkbaarheid, doorgegeven aan THotPDF.AddPortfolioSchemaField als DisplayName, Order, Visible en Editable. /Subtype is de beslissing met gevolgen. Hij zegt wie de waarde levert:

/SubtypeHotPDF-constanteWaarde komt uit
S, D, NcfsText, cfsDate, cfsNumberU zelf, per bestand, in /CI
F, DesccfsFileName, cfsDescriptionDe filespecificatie (/UF of /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeDe /Params van het embedded bestand
CompressedSize (PDF 2.0)cfsCompressedSizeDe /Length van de embedded stream

Per-bestand-waarden zitten in de collection item-dictionary waar de /CI-entry van de filespecificatie naartoe wijst (ISO 32000-1 §7.11.6 Tabel 48, Tabel 46 in deel 2). Elke entry is een text string, een datum, een getal, of een collection subitem-dictionary (Tabel 49, of Tabel 47 in deel 2) waarvan /D de data vasthoudt en /P een display-prefix dat de sortering negeert. Het eigen Voorbeeld 2 van de specificatie laat zien waarom het prefix bestaat: een e-mailonderwerp opgeslagen als /D (Let's have lunch on Friday!) met /P (Re:), zodat een antwoord naast het bericht waarop het reageert sortiert. Vreemd genoeg zeggen beide edities dat het schema van dat voorbeeld in §12.4.2, Page Labels staat; ze bedoelen Voorbeeld 1 er vlak boven. Wat de bestandsgerelateerde kolommen betreft: AddDocumentAttachment stempelt de /Params-datums met het moment van insluiten, dus een kolom cfsModDate toont wanneer het pakket is gebouwd. Reviewers die de eigen datum van het bronbestand nodig hebben krijgen een D-kolom die u zelf vult, zoals received hieronder

HotPDF PDF Portfolio-schema in Delphi: AddPortfolioSchemaField-subtypes cfsFileName, cfsText, cfsNumber, cfsDate en cfsSize afgebeeld op de filespecificatie, de /CI collection item-dictionary en de /Params van het embedded bestand
Tekst-, datum- en getalkolommen halen hun waarden uit de /CI-entry van elk bestand, terwijl kolommen voor bestandsnaam, beschrijving, datum en grootte uit structuren komen die bij het insluiten van het bestand worden weggeschreven
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;
    // Voorblad: alles wat een viewer zonder portfolio-ondersteuning toont
    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); // aanvankelijk verborgen

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // de naamboom-key
      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;   // grootste bieding bovenaan
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // beslist gelijke stand
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata en SetPortfolioItemNumericMetadata gooien een exception wanneer de schema-key niet is geregistreerd of geen embedded bestand de gegeven key heeft, dus registreer het schema, voeg het bestand toe en zet dan de waarden. Hun argument FileName is de naamboom-key, wat bij AddDocumentAttachment de bestandsnaam zonder directory is. Keys zijn bytestrings die case-gevoelig worden vergeleken (§7.9.6), net als named destinations in de /Dests-boom, dus Civil.pdf vindt civil.pdf niet. Delen twee bronbestanden een basisnaam, dan houdt HotPDF beide en geeft het tweede een genummerde key zoals hvac (2).pdf, die de metadata-aanroepen dan moeten gebruiken

Hoe komt een bieding van 104,250 onder 9,800 terecht?

Hij was als tekst opgeslagen. De sorteerregels in §12.3.5 hangen aan het datatype — getallen numeriek, datums van oud naar nieuw, tekststrings lexicaal, met NOTE 3 dat de details van de lexicale volgorde aan elke reader overlaat. Tabel 48 (Tabel 46 in deel 2) eist dat elke /CI-entry past bij de /Subtype van zijn veld, dus "USD 104,250" als tekst in een N-kolom breekt de regel al. De reader houdt er strings vergelijken over. Na het gedeelde "USD " sortiert het teken 1 vóór 9, en een dalende sortering zet de grootste bieding onderaan. SetPortfolioItemMetadata schrijft altijd een tekst-/D, juist voor S-velden en datumstrings. Voor N-velden schrijft SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) een PDF-getal en draagt het valutasymbool in /P, wat de sortering negeert. Houd eenheden en valutasymbolen in het prefix bij elke numerieke kolom: een waarde die alleen op het scherm numeriek lijkt is degene die verkeerd sortiert. Met een leeg prefix en UseSubitemDictionary op False gaat het getal rechtstreeks in /CI

HotPDF SetPortfolioItemNumericMetadata in Delphi schrijft PDF Portfolio-bedragen als getallen met een USD-/P-prefix, zodat een dalende sortering 104250 eerst zet, anders dan tekststrings die USD 104,250 onder USD 9,800 zetten
Als string opgeslagen belandt de bieding van 104,250 onderin een dalende Amount-kolom; als getal opgeslagen met zijn valuta in /P stijgt hij naar de top

Datums volgen dezelfde logica. Een D-veld verwacht een PDF-datumstring (§7.9.4) zoals D:20261031143000; 31/10/2026 uit de Windows-locale is tekst in een datumkolom en belandt waar zijn tekens toevallig vallen. De sortering zelf woont in de collection sort-dictionary: /S is één veldnaam of een array waarin elke latere naam gelijke standen van de eerdere oplost, en /A is een boolean of een parallelle array van booleans, waarbij overtollige entries genegeerd worden en ontbrekende als true worden gelezen, dus oplopend. SetPortfolioSortOrder neemt een array van records THPDFPortfolioSortKey en schrijft de één-naam-vorm voor één key, de array-vorm voor meerdere. Gebruik de keys die u in het schema registreerde; /S mag alleen velden benoemen die in de bovenliggende collection-dictionary staan beschreven

PDF 2.0-mappen wonen in de bestandsnamen

Een map herbergt geen bestanden binnen de PDF. ISO 32000-2 §12.3.5.2 definieert folder-dictionaries als indirecte objecten die in één boom zijn gehaakt: /Folders wijst naar de wortel, elk kind draagt /Parent, een map bereikt zijn eerste kind via /Child, en siblings ketenen via /Next. Elke map heeft een verplichte /Name en een niet-negatieve integer /ID die uniek is in het document, en de /Free-array van de wortel lijst de nog ongebruikte ID-ranges op. Lidmaatschap zit gecodeerd in de EmbeddedFiles-key: één die met < begint, met cijfers verdergaat en met > sluit, hoort bij de map met die ID, en de rest is de bestandsnaam, dus <3>invoice-1031.pdf is invoice-1031.pdf in map 3. De key is de map. Elke key buiten de conventie hoort bij de wortel, en zo belanden facturen die voor een map 2026 bedoeld zijn op het toplevel — bijgevoegd met kale AddDocumentAttachment dragen hun keys helemaal geen prefix

HotPDF bouwt de boom met drie aanroepen. EnsureCollectionRootFolder(RootName, Description) maakt de wortel aan met ID 0, of geeft de ID van de bestaande wortel terug. AddCollectionChildFolder(ParentFolderID, FolderName, Description) voegt een kind toe, geeft zijn nieuwe ID terug en houdt /Free actueel. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) sluit het bestand in onder <FolderID>name en schrijft de kale naam in /F en /UF, wat een kolom cfsFileName toont. Elke latere aanroep die het bestand benoemt heeft de volledige key nodig, inclusief InitialDocument in ConfigurePortfolio en de metadata-aanroepen. Mappen, /Split en /Colors zijn PDF 2.0-entries, dus zet Version op pdf20 vóór BeginDoc

HotPDF PDF 2.0 Portfolio-mappen in Delphi: EnsureCollectionRootFolder en AddCollectionChildFolder bouwen de /Child- en /Next-mappenboom, en AddDocumentAttachmentToCollectionFolder schrijft EmbeddedFiles-keys met het map-ID-prefix
Map-lidmaatschap in een PDF 2.0 portable collection zit gecodeerd in het cijferprefix van elke EmbeddedFiles-key, en elke key zonder cijfers tussen scherende haakjes blijft in de wortel
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders, /Split en /Colors zijn 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);    // splitter op 30 % van het venster
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Sibling-volgorde is een aanbeveling in de standaard: siblings zouden de collection-sorteerkey moeten volgen, of mapnamen als er geen is. AddCollectionChildFolder koppelt siblings in aanroepvolgorde, dus maak ze gesorteerd aan, zoals het voorbeeld doet met 2025 vóór 2026. De /Split-positie (ISO 32000-2 Tabel 158) is een percentage van 0 tot 100, dus 30 en niet 0.3. De naamregels na Tabel 159 gelden voor mapnamen en voor bestanden in mappen, en omdat die namen meestal van gebruikers of uploadformulieren komen, filter ze vóór de aanroep; AddCollectionChildFolder zelf weigert slashes, control characters en een sibling-naam die een bestaande herhaalt wanneer ASCII-hoofdlettergebruik wordt genegeerd:

  • tussen 1 en 255 tekens, zonder ingesloten NUL
  • geen van / \ : * " < > | ergens in de naam
  • geen punt als laatste teken
  • geen twee sibling-mappen, en geen twee bestanden in dezelfde map, waarvan de namen alleen in hoofdletters verschillen

Een Portfolio van een andere producent lezen

Een reader moet elke legale vorm van /CI accepteren, want producenten verschillen: sommigen wikkelen elke waarde in een subitem-dictionary, anderen schrijven kale strings en getallen, en het Voorbeeld 2 van de specificatie mengt beide binnen één item. Code die alleen naar een string onder een subitem-/D zoekt toont lege kolommen voor de tweede groep. GetLoadedCollectionItemFields(FileName) geeft één THPDFCollectionItemFieldValue per entry terug met IsSubitemDictionary, IsNumeric, NumericValue, StringValue en Prefix gevuld. GetLoadedCollectionInfo geeft schema, sorteersleutels, split, kleuren en de mapknooppunten van GetLoadedCollectionFolderTree in boomvolgorde terug, elk met ID, Name, ParentID, FirstChildID en NextSiblingID. De map van elk bestand komt uit zijn key, en de parse moet de regel letterlijk volgen: alleen cijfers tussen de scherende haakjes. Een key als <draft>notes.txt van een producent die niets van mappen weet heeft daar geen cijfers, dus het bestand hoort bij de wortel; alles tot de eerste > eraf knippen zou een map genaamd draft verzinnen

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: '<' cijfers '>' bestandsnaam; elke andere key is wortel
  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;            // geen mappen: een platte lijst
        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;

Verwacht voor het handover-bestand: vier mapregels met de wortel op ID 0 en Q4 invoices onder parent 2, dan [3] invoice-1031.pdf: amount=USD 10400 als numeric gemarkeerd. Draai dezelfde lus over het biedpakket en elke amount-entry komt terug met IsNumeric gezet; een bedrag dat alleen in StringValue aankomt is degene die 104,250 onder 9,800 zal sorteren. Die check past in een paar regels naast de code die pakketten bouwt met de HotPDF Delphi Component