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:
| Eintrag | Was es steuert | ISO 32000-1 | ISO 32000-2 |
|---|---|---|---|
/View, /D | Startansicht (D Details, T Kacheln, H versteckt, plus C für einen Navigator in PDF 2.0) und der Namensbaum-Schlüssel der zuerst gezeigten Datei | Tabelle 155 | Tabelle 153 |
/Schema | Die Spalten der Dateiliste | Tabellen 156, 157 | Tabellen 154, 155 |
/Sort | Sortierfelder und Richtungen | Tabelle 158 | Tabelle 156 |
/Colors | Empfohlene DeviceRGB-Palette für das Layout | keine | Tabelle 157 |
/Split | Richtung und Position der Splitterleiste | keine | Tabelle 158 |
/Folders | Wurzel des Ordnerbaums | keine | Tabelle 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:
/Subtype | HotPDF-Konstante | Wert kommt aus |
|---|---|---|
| S, D, N | cfsText, cfsDate, cfsNumber | Sie, pro Datei, in /CI |
| F, Desc | cfsFileName, cfsDescription | Die File Specification (/UF oder /F, /Desc) |
| ModDate, CreationDate, Size | cfsModDate, cfsCreationDate, cfsSize | Die /Params der eingebetteten Datei |
| CompressedSize (PDF 2.0) | cfsCompressedSize | Die /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
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
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
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