Technischer Artikel

PDF Portfolios: Spalten, Sortierschlüssel und Ordner

Drei Angebote wandern in ein PDF Portfolio, und die absteigende Amount-Spalte des Viewers listet das Gebot über USD 104,250 zuletzt, unterhalb von USD 9,800 — die Beträge kamen als Text hinein. Spaltenwerte liegen im /CI-Eintrag jeder angehängten Datei, und SetPortfolioItemNumericMetadata in HotPDF schreibt sie dort als PDF-Zahlen, sodass der Sort 104250 mit 9800 vergleicht statt zwei Strings

Was lässt ein PDF als Portfolio öffnen?

Ein Katalogschlüssel entscheidet es. ISO 32000-1 §12.3.5 sagt, dass ein konformer Reader das Dokument als Portable Collection präsentieren soll, sobald ein /Collection-Dictionary vorhanden ist (der Name der Spezifikation für das, was Acrobat ein PDF Portfolio nennt), und jede Datei im /Names /EmbeddedFiles-Namensbaum ist Mitglied; Anhänge außerhalb dieses Baums sind es nicht. Ein PDF mit zehn Anhängen und ohne /Collection ist einfach ein PDF mit voller Anhänge-Palette, und das ist die richtige Form, wenn eine einzelne maschinenlesbare Datei mitfährt, wie das XML in einer ZUGFeRD- oder Factur-X-E-Rechnung. Das Dictionary ist klein und fast komplett optional; die Tabellennummern haben sich zwischen den beiden Editionen verschoben, daher sind beide gelistet:

EintragWas es steuertISO 32000-1ISO 32000-2
/View, /DStartansicht (D Details, T Kacheln, H versteckt, plus C für einen Navigator in PDF 2.0) und der Namensbaum-Schlüssel der zuerst gezeigten DateiTabelle 155Tabelle 153
/SchemaDie Spalten der DateilisteTabellen 156, 157Tabellen 154, 155
/SortSortierfelder und RichtungenTabelle 158Tabelle 156
/ColorsEmpfohlene DeviceRGB-Palette für das LayoutkeineTabelle 157
/SplitRichtung und Position der SplitterleistekeineTabelle 158
/FoldersWurzel des OrdnerbaumskeineTabelle 159 (§12.3.5.2)

Die eigenen Seiten des Containers zählen trotzdem, denn sie sind das Deckblatt. Fehlt /D, ist das Startdokument der Container selbst; ISO 32000-2 ergänzt, dass ein /D, das keine existierende Datei benennt, den Reader stattdessen die erste Datei der Liste wählen lässt. Ein Viewer ohne Portfolio-Support ignoriert /Collection und zeigt nur diese Seiten, und NOTE 2 in §12.3.5 wünscht sich dort einen Titel und einen einleitenden Absatz; die übliche Antwort ist eine Seite, die sagt, was das Paket enthält und dass es einen portfoliofähigen Viewer braucht. ConfigurePortfolio(Name, View, InitialDocument) setzt View und /D in einem Aufruf, und ConfigurePortfolioView schaltet später zwischen pvmDetails, pvmTile und pvmHidden um. View C braucht ein Navigator-Dictionary (ISO 32000-2 Tabelle 160); für ein Angebots-Paket oder ein Übergabeset ist die Details-Ansicht mit einer vernünftigen Sortierung das, was Nutzer erwarten

Schema-Felder und die Werte hinter jeder Spalte

Spalten kommen aus dem Collection-Schema-Dictionary, ein Collection-Field-Dictionary pro Schlüssel, den Sie wählen. Der größte Teil eines Felds ist Präsentation — /N ist der Kopf, /O die Spaltenposition, /V und /E die anfängliche Sichtbarkeit und Editierbarkeit, übergeben an THotPDF.AddPortfolioSchemaField als DisplayName, Order, Visible und Editable. /Subtype ist die Entscheidung mit Folgen. Er sagt, wer den Wert liefert:

/SubtypeHotPDF-KonstanteWert kommt aus
S, D, NcfsText, cfsDate, cfsNumberSie, pro Datei, in /CI
F, DesccfsFileName, cfsDescriptionDie File Specification (/UF oder /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeDie /Params der eingebetteten Datei
CompressedSize (PDF 2.0)cfsCompressedSizeDie /Length des eingebetteten Streams

Die Werte pro Datei sitzen im Collection-Item-Dictionary, auf das der /CI-Eintrag der File Specification zeigt (ISO 32000-1 §7.11.6 Tabelle 48, Tabelle 46 in Teil 2). Jeder Eintrag ist ein Text-String, ein Datum, eine Zahl oder ein Collection-Subitem-Dictionary (Tabelle 49, bzw. Tabelle 47 in Teil 2), dessen /D die Daten hält und dessen /P ein Anzeige-Präfix trägt, das die Sortierung ignoriert. Das eigene Example 2 der Spezifikation zeigt, warum es das Präfix gibt: Ein E-Mail-Betreff, gespeichert als /D (Let's have lunch on Friday!) mit /P (Re:), sodass eine Antwort neben der Nachricht sortiert, auf die sie antwortet. Kurioserweise verweisen beide Editionen dafür auf §12.4.2, Page Labels; gemeint ist Example 1 direkt darüber. Zu den dateibezogenen Spalten: AddDocumentAttachment stempelt die /Params-Daten mit dem Zeitpunkt des Einbettens, also zeigt eine cfsModDate-Spalte, wann das Paket gebaut wurde. Prüfer, die das eigene Datum der Quelldatei brauchen, bekommen eine D-Spalte, die Sie selbst füllen, wie received unten

HotPDF PDF-Portfolio-Schema in Delphi: AddPortfolioSchemaField-Subtypen cfsFileName, cfsText, cfsNumber, cfsDate und cfsSize zugeordnet der File Specification, dem /CI-Collection-Item-Dictionary und den /Params der eingebetteten Datei
Text-, Datums- und Zahlenspalten nehmen ihre Werte aus dem /CI-Eintrag jeder Datei, während Dateiname-, Beschreibungs-, Datums- und Größenspalten aus Strukturen kommen, die beim Einbetten der Datei geschrieben werden
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;
    // Deckblatt: alles, was ein Viewer ohne Portfolio-Support zeigen wird
    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); // anfangs versteckt

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // der Namensbaum-Schlüssel
      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;   // größtes Gebot oben
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // Tie-Breaker
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata und SetPortfolioItemNumericMetadata werfen eine Exception, wenn der Schema-Schlüssel unregistriert ist oder keine eingebettete Datei den gegebenen Schlüssel hat, also: Schema registrieren, Datei anhängen, dann Werte setzen. Ihr FileName-Argument ist der Namensbaum-Schlüssel, der bei AddDocumentAttachment der Dateiname ohne sein Verzeichnis ist. Schlüssel sind Byte-Strings, verglichen mit Case-Sensitivity (§7.9.6), wie bei Named Destinations im /Dests-Baum, also findet Civil.pdf nicht civil.pdf. Teilen sich zwei Quelldateien einen Basisnamen, behält HotPDF beide und gibt der zweiten einen nummerierten Schlüssel wie hvac (2).pdf, den die Metadata-Aufrufe dann verwenden müssen

Wie landet ein Gebot über 104,250 unter 9,800?

Er wurde als Text gespeichert. Die Sortierregeln in §12.3.5 hängen vom Datentyp ab — Zahlen numerisch, Daten vom ältesten zum neuesten, Text-Strings lexikalisch, wobei NOTE 3 die Details der lexikalischen Ordnung jedem Reader überlässt. Tabelle 48 (Tabelle 46 in Teil 2) verlangt, dass jeder /CI-Eintrag zum /Subtype seines Felds passt, also bricht "USD 104,250", als Text in eine N-Spalte geschrieben, die Regel bereits. Dem Reader bleibt der String-Vergleich. Nach dem gemeinsamen "USD " sortiert das Zeichen 1 vor 9, und eine absteigende Sortierung stellt das größte Gebot ans Ende. SetPortfolioItemMetadata schreibt immer einen Text-/D, richtig für S-Felder und Datums-Strings. Für N-Felder schreibt SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) eine PDF-Zahl und trägt das Währungssymbol in /P, das die Sortierung ignoriert. Halten Sie Einheiten und Währungssymbole im Präfix jeder numerischen Spalte: Ein Wert, der nur auf dem Bildschirm numerisch aussieht, ist der, der falsch sortiert. Mit leerem Präfix und UseSubitemDictionary auf False geht die Zahl direkt in /CI

HotPDF SetPortfolioItemNumericMetadata in Delphi schreibt PDF-Portfolio-Beträge als Zahlen mit einem USD-/P-Präfix, also listet eine absteigende Sortierung 104250 zuerst, anders als Text-Strings, die USD 104,250 unter USD 9,800 setzen
Als String gespeichert landet das Gebot über 104,250 am Boden einer absteigenden Amount-Spalte; als Zahl gespeichert mit seiner Währung in /P steigt es nach oben

Daten folgen derselben Logik. Ein D-Feld erwartet einen PDF-Datums-String (§7.9.4) wie D:20261031143000; 31/10/2026 aus dem Windows-Locale ist Text in einer Datumsspalte und landet, wo seine Zeichen eben fallen. Die Sortierung selbst wohnt im Collection-Sort-Dictionary: /S ist ein Feldname oder ein Array, in dem jeder spätere Name die Gleichstände bricht, die die früheren übrig lassen, und /A ist ein Boolean oder ein paralleles Boolean-Array, wobei überschüssige Einträge ignoriert werden und fehlende als true gelten, also aufsteigend. SetPortfolioSortOrder nimmt ein Array aus THPDFPortfolioSortKey-Records und schreibt die Einzelnamen-Form für einen Schlüssel, die Array-Form für mehrere. Verwenden Sie die Schlüssel, die Sie im Schema registriert haben; /S darf nur Felder benennen, die im übergeordneten Collection-Dictionary beschrieben sind

PDF 2.0: Ordner wohnen in den Dateinamen

Ein Ordner hält keine Dateien im PDF. ISO 32000-2 §12.3.5.2 definiert Folder-Dictionarys als Indirect Objects, verknüpft zu einem Baum: /Folders zeigt auf die Wurzel, jedes Kind trägt /Parent, ein Ordner erreicht sein erstes Kind über /Child, und Geschwister verketten über /Next. Jeder Ordner hat ein Pflicht-/Name und eine nicht-negative Integer-/ID, einzigartig im Dokument, und das /Free-Array der Wurzel listet noch unbenutzte ID-Bereiche. Die Mitgliedschaft ist im EmbeddedFiles-Schlüssel kodiert: Einer, der mit < beginnt, mit Ziffern weiterläuft und mit > schließt, gehört zum Ordner mit dieser ID, und der Rest ist der Dateiname, also ist <3>invoice-1031.pdf invoice-1031.pdf in Ordner 3. Der Schlüssel ist der Ordner. Jeder Schlüssel außerhalb der Konvention gehört zur Wurzel, weshalb Rechnungen, die für einen 2026-Ordner gedacht waren, auf der obersten Ebene landen — angehängt mit bloßem AddDocumentAttachment tragen ihre Schlüssel überhaupt kein Präfix

HotPDF baut den Baum mit drei Aufrufen. EnsureCollectionRootFolder(RootName, Description) erzeugt die Wurzel mit ID 0 oder gibt die ID der bestehenden Wurzel zurück. AddCollectionChildFolder(ParentFolderID, FolderName, Description) hängt ein Kind an, gibt seine neue ID zurück und hält /Free aktuell. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) bettet die Datei unter <FolderID>name ein und schreibt den nackten Namen in /F und /UF, was eine cfsFileName-Spalte zeigt. Jeder spätere Aufruf, der die Datei nennt, braucht den vollen Schlüssel, eingeschlossen InitialDocument in ConfigurePortfolio und die Metadata-Aufrufe. Ordner, /Split und /Colors sind PDF-2.0-Einträge, also setzen Sie Version auf pdf20 vor BeginDoc

HotPDF PDF-2.0-Portfolio-Ordner in Delphi: EnsureCollectionRootFolder und AddCollectionChildFolder bauen den /Child-/Next-Ordnerbaum, und AddDocumentAttachmentToCollectionFolder schreibt EmbeddedFiles-Schlüssel mit dem Ordner-ID-Präfix
Die Ordner-Mitgliedschaft in einer PDF-2.0-Portable-Collection ist im Ziffern-Präfix jedes EmbeddedFiles-Schlüssels kodiert, und jeder Schlüssel ohne Ziffern in spitzen Klammern bleibt an der Wurzel
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders, /Split und /Colors sind 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 bei 30 % des Fensters
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Die Geschwister-Reihenfolge ist eine Empfehlung der Norm: Geschwister sollten dem Collection-Sort-Schlüssel folgen, oder den Ordnernamen, wenn es keinen gibt. AddCollectionChildFolder verknüpft Geschwister in Aufrufreihenfolge, also legen Sie sie sortiert an, wie das Beispiel mit 2025 vor 2026. Die /Split-Position (ISO 32000-2 Tabelle 158) ist ein Prozentwert von 0 bis 100, daher 30 und nicht 0.3. Die Namensregeln nach Tabelle 159 gelten für Ordnernamen und für Dateien in Ordnern, und da diese Namen meist von Nutzern oder Upload-Formularen kommen, filtern Sie sie vor dem Aufruf; AddCollectionChildFolder selbst verweigert Slashes, Steuerzeichen und einen Geschwister-Namen, der einen bestehenden wiederholt, wenn ASCII-Groß-/Kleinschreibung ignoriert wird:

  • zwischen 1 und 255 Zeichen, ohne eingebettetes NUL
  • keins von / \ : * " < > | irgendwo im Namen
  • kein Punkt als letztes Zeichen
  • keine zwei Geschwister-Ordner und keine zwei Dateien im selben Ordner, deren Namen sich nur durch Groß-/Kleinschreibung unterscheiden

Ein Portfolio eines anderen Producers lesen

Ein Reader muss jede legale Form von /CI akzeptieren, denn Producer unterscheiden sich: Manche wickeln jeden Wert in ein Subitem-Dictionary, andere schreiben nackte Strings und Zahlen, und das Example 2 der Spezifikation mischt beides in einem Item. Code, der nur nach einem String unter einem Subitem-/D sucht, zeigt für die zweite Gruppe leere Spalten. GetLoadedCollectionItemFields(FileName) liefert einen THPDFCollectionItemFieldValue pro Eintrag, mit IsSubitemDictionary, IsNumeric, NumericValue, StringValue und Prefix gefüllt. GetLoadedCollectionInfo gibt Schema, Sortierkeys, Split, Farben und die Ordnerknoten von GetLoadedCollectionFolderTree in Baumreihenfolge zurück, jeden mit ID, Name, ParentID, FirstChildID und NextSiblingID. Der Ordner jeder Datei kommt aus ihrem Schlüssel, und das Parsen muss die Regel wörtlich folgen: nur Ziffern zwischen den spitzen Klammern. Ein Schlüssel wie <draft>notes.txt von einem Producer, der nichts von Ordnern weiß, hat dort keine Ziffern, also gehört die Datei zur Wurzel; alles bis zum ersten > abzuschneiden würde einen Ordner namens draft erfinden

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: '<' Ziffern '>' Dateiname; jeder andere Schlüssel ist Wurzel
  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;            // keine Ordner: eine flache Liste
        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;

Erwartet für die Übergabedatei: vier Ordner-Zeilen mit der Wurzel bei ID 0 und Q4 invoices unter Parent 2, dann [3] invoice-1031.pdf: amount=USD 10400, als numerisch markiert. Lassen Sie dieselbe Schleife über das Angebots-Paket laufen und jeder amount-Eintrag kommt mit gesetztem IsNumeric zurück; ein Betrag, der nur in StringValue ankommt, ist der, der 104,250 unter 9,800 sortieren wird. Diese Prüfung passt in ein paar Zeilen neben dem Code, der Pakete mit der HotPDF-Komponente für Delphi und C++Builder baut