Trzy oferty trafiają do PDF Portfolio, a malejąca kolumna Amount przeglądarki stawia ofertę USD 104 250 na końcu, pod USD 9 800 — kwoty weszły jako tekst. Wartości kolumn mieszkają we wpisie /CI każdego podpiętego pliku, a SetPortfolioItemNumericMetadata w HotPDF zapisuje je tam jako liczby PDF, więc sort porównuje 104250 z 9800, a nie dwa napisy
Co sprawia, że PDF otwiera się jako Portfolio?
Rozstrzyga jeden klucz katalogu. ISO 32000-1 §12.3.5 mówi, że gdy słownik /Collection jest obecny, zgodny czytnik ma przedstawić dokument jako portable collection (tak specyfikacja nazywa to, co Acrobat zwie PDF Portfolio), a każdy plik w drzewie nazw /Names /EmbeddedFiles jest członkiem; załączniki poza tym drzewem — nie. PDF z dziesięcioma załącznikami i bez /Collection to po prostu PDF z zapchanym panelem załączników, i to jest właściwy kształt, gdy wzdłuż jedyny plik czytelny maszynowo, jak XML w e-fakturze ZUGFeRD albo Factur-X. Słownik jest mały i niemal w całości opcjonalny; numery tabel przesunęły się między wydaniami, więc wypisuję oba:
| Wpis | Co steruje | ISO 32000-1 | ISO 32000-2 |
|---|---|---|---|
/View, /D | Widok początkowy (D szczegóły, T kafelki, H ukryty, plus C dla nawigatora w PDF 2.0) i klucz drzewa nazw pliku pokazywanego najpierw | Tabela 155 | Tabela 153 |
/Schema | Kolumny listy plików | Tabele 156, 157 | Tabele 154, 155 |
/Sort | Pola i kierunki sortowania | Tabela 158 | Tabela 156 |
/Colors | Sugerowana paleta DeviceRGB dla układu | brak | Tabela 157 |
/Split | Kierunek i pozycja paska podziału | brak | Tabela 158 |
/Folders | Korzeń drzewa folderów | brak | Tabela 159 (§12.3.5.2) |
Własne strony kontenera nadal mają znaczenie, bo to strona tytułowa. Gdy /D brakuje, dokumentem początkowym jest sam kontener; ISO 32000-2 dodaje, że /D wskazujące nieistniejący plik każe czytnikowi wybrać zamiast tego pierwszy plik listy. Przeglądarka bez wsparcia portfolio ignoruje /Collection i pokazuje tylko te strony, a NOTA 2 w §12.3.5 prosi tam o tytuł i akapit wstępny; zwykła odpowiedź to jedna strona mówiąca, co pakiet zawiera i że potrzebuje przeglądarki rozumiejącej portfolio. ConfigurePortfolio(Name, View, InitialDocument) ustawia widok i /D jednym wołaniem, a ConfigurePortfolioView przełącza później między pvmDetails, pvmTile i pvmHidden. Widok C potrzebuje słownika nawigatora (ISO 32000-2 tabela 160); dla pakietu ofert albo zestawu przekazania projektu użytkownicy oczekują widoku szczegółów z sensownym sortem
Pola schematu i wartości za każdą kolumną
Kolumny pochodzą ze słownika schematu kolekcji, po jednym słowniku pola kolekcji na każdy wybrany przez ciebie klucz. Większość pola to prezentacja — /N to nagłówek, /O pozycja kolumny, /V i /E początkowa widoczność i edytowalność, przekazywane do THotPDF.AddPortfolioSchemaField jako DisplayName, Order, Visible i Editable. /Subtype to decyzja z konsekwencjami. Mówi, kto dostarcza wartość:
/Subtype | Stała HotPDF | Wartość pochodzi z |
|---|---|---|
| S, D, N | cfsText, cfsDate, cfsNumber | Ty, per plik, w /CI |
| F, Desc | cfsFileName, cfsDescription | Specyfikacja pliku (/UF albo /F, /Desc) |
| ModDate, CreationDate, Size | cfsModDate, cfsCreationDate, cfsSize | /Params osadzonego pliku |
| CompressedSize (PDF 2.0) | cfsCompressedSize | /Length osadzonego strumienia |
Wartości per plik siedzą w słowniku elementu kolekcji, na który wskazuje wpis /CI specyfikacji pliku (ISO 32000-1 §7.11.6 tabela 48, tabela 46 w części 2). Każdy wpis to napis tekstowy, data, liczba albo słownik subelementu kolekcji (tabela 49, albo tabela 47 w części 2), którego /D trzyma dane, a /P trzyma prefiks wyświetlania ignorowany przy sortowaniu. Przykład 2 samej specyfikacji pokazuje, po co prefiks istnieje: temat e-maila zapisany jako /D (Let's have lunch on Friday!) z /P (Re:), żeby odpowiedź sortowała się obok wiadomości, na którą odpowiada. Co dziwne, oba wydania mówią, że schemat tego przykładu jest w §12.4.2, Page Labels; chodzi im o Przykład 1 bezpośrednio nad nim. A jeśli chodzi o kolumny związane z plikiem — AddDocumentAttachment stempluje daty /Params momentem osadzenia, więc kolumna cfsModDate pokazuje, kiedy pakiet powstał. Recenzenci potrzebujący własnej daty pliku źródłowego dostają kolumnę D wypełnianą przez ciebie, jak received niżej
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;
// Strona tytułowa: wszystko, co pokaże przeglądarka bez wsparcia portfolio
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); // na starcie ukryta
for I := 0 to High(Files) do
begin
Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
Key := AnsiString(ExtractFileName(Files[I])); // klucz drzewa nazw
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; // największa oferta na górze
SortKeys[1].Key := 'trade';
SortKeys[1].Ascending := True; // rozstrzyga remisy
Pdf.SetPortfolioSortOrder(SortKeys);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
SetPortfolioItemMetadata i SetPortfolioItemNumericMetadata podnoszą wyjątek, gdy klucz schematu jest niezarejestrowany albo żaden osadzony plik nie ma danego klucza, więc zarejestruj schemat, podpięj plik, a potem ustawiaj wartości. Ich argument FileName to klucz drzewa nazw, który dla AddDocumentAttachment jest nazwą pliku bez katalogu. Klucze to napisy bajtowe porównywane z rozróżnianiem wielkości liter (§7.9.6), jak named destinations w drzewie /Dests, więc Civil.pdf nie znajdzie civil.pdf. Gdy dwa pliki źródłowe dzielą nazwę bazową, HotPDF zostawia oba i daje drugiemu numerowany klucz w stylu hvac (2).pdf, którego wołania metadanych muszą potem użyć
Jak oferta 104 250 ląduje pod 9 800?
Została zapisana jako tekst. Reguły sortowania w §12.3.5 zależą od typu danych — liczby liczbowo, daty od najstarszych do najnowszych, napisy leksykograficznie, przy czym NOTA 3 zostawia szczegóły porządku leksykograficznego każdemu czytnikowi. Tabela 48 (tabela 46 w części 2) wymaga, by każdy wpis /CI pasował do /Subtype swojego pola, więc "USD 104,250" zapisane jako tekst w kolumnie N już łamie regułę. Czytnik zostaje z porównywaniem napisów. Po wspólnym "USD " znak 1 sortuje się przed 9, a sort malejący stawia największą ofertę na końcu. SetPortfolioItemMetadata zawsze zapisuje tekstowe /D — właściwe dla pól S i napisów dat. Dla pól N SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) zapisuje liczbę PDF i niesie symbol waluty w /P, który sort ignoruje. Jednostki i symbole walut trzymaj w prefiksie przy każdej liczbowej kolumnie: wartość, która tylko wygląda liczbowo na ekranie, to ta, która posortuje się źle. Przy pustym prefiksie i UseSubitemDictionary ustawionym na False liczba wchodzi prosto do /CI
Daty podążają za tą samą logiką. Pole D oczekuje napisu daty PDF (§7.9.4), typu D:20261031143000; 31.10.2026 z lokalizacji Windows to tekst w kolumnie daty i ląduje gdziekolwiek wypadną jego znaki. Sam sort mieszka w słowniku sortowania kolekcji: /S to jedna nazwa pola albo tablica, w której każda dalsza nazwa rozstrzyga remisy zostawione przez wcześniejsze, a /A to boolean albo równoległa tablica booleanów, z nadliczbowymi wpisami ignorowanymi i brakującymi czytanymi jako true, czyli rosnąco. SetPortfolioSortOrder bierze tablicę rekordów THPDFPortfolioSortKey i zapisuje formę pojedynczej nazwy dla jednego klucza, formę tablicową dla kilku. Używaj kluczy zarejestrowanych w schemacie; /S może nazywać tylko pola opisane w nadrzędnym słowniku kolekcji
Foldery PDF 2.0 mieszkają w nazwach plików
Folder nie trzyma żadnych plików wewnątrz PDF. ISO 32000-2 §12.3.5.2 definiuje słowniki folderów jako obiekty pośrednie wpięte w jedno drzewo: /Folders wskazuje korzeń, każde dziecko niesie /Parent, folder dociera do swojego pierwszego dziecka przez /Child, a rodzeństwo łączy się przez /Next. Każdy folder ma wymagany /Name i nieujemną liczbę całkowitą /ID unikalną w dokumencie, a tablica /Free korzenia wypisuje jeszcze nieużywane zakresy ID. Członkostwo jest zakodowane w kluczu EmbeddedFiles: klucz zaczynający się od <, kontynuowany cyframi i zamykany > należy do folderu o tym ID, a reszta to nazwa pliku, więc <3>invoice-1031.pdf to invoice-1031.pdf w folderze 3. Klucz jest folderem. Każdy klucz poza konwencją należy do korzenia — właśnie tak faktury przeznaczone do folderu 2026 lądują na najwyższym poziomie: podpięte zwykłym AddDocumentAttachment, ich klucze nie niosą żadnego prefiksu
HotPDF buduje drzewo trzema wołaniami. EnsureCollectionRootFolder(RootName, Description) tworzy korzeń z ID 0 albo zwraca ID istniejącego korzenia. AddCollectionChildFolder(ParentFolderID, FolderName, Description) dokleja dziecko, zwraca jego nowe ID i trzyma /Free na bieżąco. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) osadza plik pod <FolderID>name i zapisuje gołą nazwę do /F i /UF — to pokazuje kolumna cfsFileName. Każde późniejsze wołanie nazywające plik potrzebuje pełnego klucza, łącznie z InitialDocument w ConfigurePortfolio i wołaniami metadanych. Foldery, /Split i /Colors to wpisy PDF 2.0, więc ustaw Version na pdf20 przed 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 i /Colors są z 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); // pasek podziału na 30 % okna
Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Kolejność rodzeństwa to w standardzie zalecenie: rodzeństwo powinno iść za kluczem sortowania kolekcji, albo za nazwami folderów, gdy go nie ma. AddCollectionChildFolder wpina rodzeństwo w kolejności wołań, więc twórz je posortowane, jak w przykładzie z 2025 przed 2026. Pozycja /Split (ISO 32000-2 tabela 158) to procent od 0 do 100, stąd 30, a nie 0,3. Reguły nazewnictwa po tabeli 159 dotyczą nazw folderów i plików wewnątrz folderów, a skoro te nazwy zwykle pochodzą od użytkowników albo z formularzy uploadu, filtruj je przed wołaniem; samo AddCollectionChildFolder odmawia ukośników, znaków kontrolnych i nazwy rodzeństwa powtarzającej istniejącą przy ignorowaniu wielkości liter ASCII:
- od 1 do 255 znaków, bez osadzonego NUL
- żadnego z
/ \ : * " < > |gdziekolwiek w nazwie - bez kropki jako ostatniego znaku
- bez dwóch folderów rodzeństwa i bez dwóch plików w tym samym folderze, których nazwy różnią się wyłącznie wielkością liter
Czytanie Portfolio napisanego przez innego producenta
Czytnik musi przyjąć każdy legalny kształt /CI, bo producenci się różnią: jedni owijają każdą wartość w słownik subelementu, inni zapisują gołe napisy i liczby, a Przykład 2 specyfikacji miesza oba wewnątrz jednego elementu. Kod szukający wyłącznie napisu pod subelementowym /D pokazuje puste kolumny dla drugiej grupy. GetLoadedCollectionItemFields(FileName) zwraca po jednym THPDFCollectionItemFieldValue na wpis, z wypełnionymi IsSubitemDictionary, IsNumeric, NumericValue, StringValue i Prefix. GetLoadedCollectionInfo zwraca schemat, klucze sortowania, split, kolory i węzły folderów z GetLoadedCollectionFolderTree w kolejności drzewa, każdy z ID, Name, ParentID, FirstChildID i NextSiblingID. Folder każdego pliku pochodzi z jego klucza, a parsowanie musi iść za regułą dosłownie: wyłącznie cyfry między nawiasami kątowymi. Klucz typu <draft>notes.txt od producenta, który o folderach nic nie wie, nie ma tam żadnych cyfr, więc plik należy do korzenia; ucięcie wszystkiego do pierwszego > wymyśliłoby folder o nazwie 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: '<' cyfry '>' nazwa pliku; każdy inny klucz to korzeń
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; // bez folderów: płaska lista
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;
Oczekiwane dla pliku przekazania: cztery wiersze folderów z korzeniem przy ID 0 i Q4 invoices pod rodzicem 2, potem [3] invoice-1031.pdf: amount=USD 10400 oznaczone jako liczbowe. Puść tę samą pętlę po pakiecie ofert, a każdy wpis amount wraca z ustawionym IsNumeric; kwota, która przychodzi tylko w StringValue, to ta, która posortuje 104 250 pod 9 800. Taka kontrola mieści się w kilku wierszach obok kodu budującego pakiety z komponentem HotPDF dla Delphi i C++Builder