Artykuł techniczny

PDF Portfolio: kolumny, klucze sortowania i foldery

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:

WpisCo sterujeISO 32000-1ISO 32000-2
/View, /DWidok początkowy (D szczegóły, T kafelki, H ukryty, plus C dla nawigatora w PDF 2.0) i klucz drzewa nazw pliku pokazywanego najpierwTabela 155Tabela 153
/SchemaKolumny listy plikówTabele 156, 157Tabele 154, 155
/SortPola i kierunki sortowaniaTabela 158Tabela 156
/ColorsSugerowana paleta DeviceRGB dla układubrakTabela 157
/SplitKierunek i pozycja paska podziałubrakTabela 158
/FoldersKorzeń drzewa folderówbrakTabela 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ść:

/SubtypeStała HotPDFWartość pochodzi z
S, D, NcfsText, cfsDate, cfsNumberTy, per plik, w /CI
F, DesccfsFileName, cfsDescriptionSpecyfikacja pliku (/UF albo /F, /Desc)
ModDate, CreationDate, SizecfsModDate, 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

Schemat PDF Portfolio w Delphi z HotPDF: podtypy AddPortfolioSchemaField cfsFileName, cfsText, cfsNumber, cfsDate i cfsSize zmapowane na specyfikację pliku, słownik elementu kolekcji /CI i /Params osadzonego pliku
Kolumny tekstowe, daty i liczby biorą wartości z wpisu /CI każdego pliku, podczas gdy kolumny nazwy pliku, opisu, daty i rozmiaru pochodzą ze struktur zapisywanych w chwili osadzenia pliku
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

SetPortfolioItemNumericMetadata w Delphi z HotPDF zapisuje kwoty PDF Portfolio jako liczby z prefiksem /P USD, więc sort malejący stawia 104250 pierwsze, w przeciwieństwie do napisów, które lądują USD 104 250 pod USD 9 800
Zapisana jako napis oferta 104 250 ląduje na dnie malejącej kolumny Amount; zapisana jako liczba z walutą w /P wznosi się na szczyt

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

Foldery PDF 2.0 Portfolio w Delphi z HotPDF: EnsureCollectionRootFolder i AddCollectionChildFolder budują drzewo folderów /Child i /Next, a AddDocumentAttachmentToCollectionFolder zapisuje klucze EmbeddedFiles niosące prefiks ID folderu
Członkostwo w folderze PDF 2.0 portable collection jest zakodowane w prefiksie cyfrowym każdego klucza EmbeddedFiles, a każdy klucz bez cyfr w nawiasach kątowych zostaje w korzeniu
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