技術文章

Delphi PDF Portfolio:數字排序與資料夾結構的寫法

三份報價進了一個 PDF Portfolio,檢視器把 Amount 欄由大到小排序,USD 104,250 那份排在最後、落在 USD 9,800 之下——因為金額是當成文字寫進去的。欄位值住在每個附加檔案的 /CI 條目裡,HotPDF 的 SetPortfolioItemNumericMetadata 把它們以 PDF 數字寫進去,排序因此是 104250 對 9800 的比較,而不是兩個字串互比

什麼讓一份 PDF 以 Portfolio 的方式開啟?

一個 catalog 鍵就決定了。ISO 32000-1 §12.3.5 說,只要 /Collection 字典存在,合規讀取器就必須把文件呈現為可攜式集合(portable collection,規範對 Acrobat 所稱 PDF Portfolio 的稱呼),且 /Names /EmbeddedFiles 名稱樹裡的每個檔案都是成員;樹外的附加檔案不算。十個附件卻沒有 /Collection 的 PDF,只是個附件面板很熱鬧的 PDF——當只隨附一份機器可讀檔案時,這才是正確的形狀,就像ZUGFeRD 或 Factur-X 電子發票裡的 XML。這個字典很小且幾乎全為選填;兩個版本的表格編號有位移,所以兩邊都列出:

條目控制什麼ISO 32000-1ISO 32000-2
/View、/D初始檢視(D 詳細資料、T 並排、H 隱藏,PDF 2.0 另有 C 代表導覽器)與最先顯示檔案的名稱樹鍵Table 155Table 153
/Schema檔案清單的各欄Tables 156、157Tables 154、155
/Sort排序欄位與方向Table 158Table 156
/Colors版面建議的 DeviceRGB 調色盤無Table 157
/Split分隔列方向與位置無Table 158
/Folders資料夾樹的根無Table 159(§12.3.5.2)

容器自己的頁面仍然要緊,因為它們就是封面。缺 /D 時,初始文件就是容器本身;ISO 32000-2 另規定 /D 指名不存在的檔案時,讀取器改選清單裡的第一個檔案。不支援 Portfolio 的檢視器無視 /Collection,只顯示那些頁面,而 §12.3.5 的 NOTE 2 要求那裡有標題與一段引言;常見的做法是一頁說明這個包裹裝了什麼、以及需要支援 Portfolio 的檢視器。ConfigurePortfolio(Name, View, InitialDocument) 一個呼叫同時設檢視與 /D,ConfigurePortfolioView 之後可在 pvmDetails、pvmTile 與 pvmHidden 之間切換。檢視 C 需要導覽器字典(ISO 32000-2 Table 160);對報價包裹或交接文件集,使用者期待的是搭配合理排序的詳細資料檢視

Schema 欄位與每欄背後的值

欄位來自 collection schema 字典,您選的每個鍵對應一個 collection field 字典。欄位的大部分是展示層的事——/N 是表頭、/O 是欄位位置、/V 與 /E 是初始可見性與可編輯性,以 DisplayName、Order、Visible、Editable 傳給 THotPDF.AddPortfolioSchemaField。真正有後果的決定是 /Subtype,它說明值由誰供應:

/SubtypeHotPDF 常數值的來源
S、D、NcfsText、cfsDate、cfsNumber您自己,逐檔案,寫在 /CI
F、DesccfsFileName、cfsDescription檔案規格(/UF 或 /F、/Desc)
ModDate、CreationDate、SizecfsModDate、cfsCreationDate、cfsSize內嵌檔案的 /Params
CompressedSize(PDF 2.0)cfsCompressedSize內嵌串流的 /Length

逐檔案的值放在檔案規格 /CI 條目指向的 collection item 字典裡(ISO 32000-1 §7.11.6 Table 48,第 2 部是 Table 46)。每個條目是文字字串、日期、數字,或是 collection subitem 字典(Table 49,第 2 部是 Table 47)——其 /D 放資料、/P 放排序時被無視的顯示前綴。規範自己的 Example 2 說明了前綴存在的理由:郵件主旨存成 /D (Let's have lunch on Friday!) 加上 /P (Re:),回覆就能排在它所回應的那封信旁邊。奇怪的是,兩個版本都說那個範例的 schema 在 §12.4.2(Page Labels);其實指的是正上方那個 Example 1。至於檔案相關欄位,AddDocumentAttachment 會把內嵌當下的時間蓋進 /Params 日期,所以 cfsModDate 欄顯示的是包裹製作的時間。需要來源檔原始日期的審閱者,拿到的是一個您自己填的 D 欄,像下面的 received

HotPDF 在 Delphi 中的 PDF Portfolio schema:AddPortfolioSchemaField 的 cfsFileName、cfsText、cfsNumber、cfsDate 與 cfsSize 子類型,分別對應到檔案規格、/CI collection item 字典與內嵌檔案的 /Params
文字、日期與數字欄的值取自每個檔案的 /CI 條目;檔名、描述、日期與大小欄則來自內嵌檔案當下寫入的結構
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;
    // 封面:不支援 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); // 一開始隱藏

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // 名稱樹鍵
      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;   // 最高報價排最上
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // 同分決勝
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata 與 SetPortfolioItemNumericMetadata 在 schema 鍵未註冊、或沒有內嵌檔案帶給定鍵時會拋例外,所以順序是:註冊 schema、附加檔案,然後設值。它們的 FileName 參數是名稱樹鍵,對 AddDocumentAttachment 而言就是去掉目錄的檔名。鍵是逐位元組比較、區分大小寫的字串(§7.9.6),與/Dests 樹裡的具名目的地一樣,所以 Civil.pdf 找不到 civil.pdf。兩個來源檔案共用同一個檔名基底時,HotPDF 兩者都保留,給第二個編號鍵如 hvac (2).pdf,之後的中繼資料呼叫就得用這個鍵

104,250 的報價怎麼會排在 9,800 底下?

它是當成文字存的。§12.3.5 的排序規則依資料型別而異——數字按數值、日期由舊到新、文字按字典序,NOTE 3 則把字典序的細節交給各讀取器。Table 48(第 2 部 Table 46)要求每個 /CI 條目與其欄位的 /Subtype 相符,所以把 "USD 104,250" 以文字寫進 N 欄,已經先壞了規矩。讀取器只能拿字串來比:共同前綴 "USD " 之後,字元 1 排在 9 前面,由大到小的排序就把最大報價排到最後。SetPortfolioItemMetadata 永遠寫文字 /D,適合 S 欄與日期字串。N 欄要用 SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary),它寫 PDF 數字、把貨幣符號放進排序時被無視的 /P。每個數字欄都把單位與貨幣符號留在前綴裡:畫面上看起來是數字、實際上不是的值,就是會排錯序的那個。前綴留空且 UseSubitemDictionary 設 False 時,數字直接進 /CI

HotPDF SetPortfolioItemNumericMetadata 在 Delphi 中把 PDF Portfolio 金額寫成數字並帶 USD /P 前綴,由大到小排序時 104250 排第一;文字字串則會把 USD 104,250 排在 USD 9,800 之下
以字串儲存,104,250 的報價落在由大到小 Amount 欄的底部;以數字儲存、貨幣放進 /P,它就浮到頂端

日期是同一套邏輯。D 欄期待 PDF 日期字串(§7.9.4),如 D:20261031143000;Windows 地區設定給的 31/10/2026 在日期欄裡是文字,落在哪裡全看它的字元。排序本身住在 collection sort 字典:/S 是一個欄位名或陣列,後面的名字逐一化解前面名字留下的同序;/A 是布林值或平行的布林陣列,多餘條目被忽略、缺失條目讀為 true(升冪)。SetPortfolioSortOrder 收 THPDFPortfolioSortKey 記錄陣列,單鍵寫成單名形式、多鍵寫成陣列形式。鍵請用您在 schema 註冊過的;/S 只能指名上層 collection 字典描述過的欄位

PDF 2.0 的資料夾藏在檔名裡

PDF 裡的資料夾不裝檔案。ISO 32000-2 §12.3.5.2 把資料夾字典定義為連結成一棵樹的間接物件:/Folders 指向根,每個子節點帶 /Parent,資料夾透過 /Child 找到第一個子節點,兄弟之間以 /Next 串接。每個資料夾有必填的 /Name 與文件內唯一的非負整數 /ID,根的 /Free 陣列列出尚未使用的 ID 區段。成員資格編碼在 EmbeddedFiles 鍵裡:以 < 起頭、接著數字、以 > 收尾的鍵屬於該 ID 的資料夾,其餘部分是檔名,所以 <3>invoice-1031.pdf 是資料夾 3 裡的 invoice-1031.pdf。鍵就是資料夾。不合這個慣例的鍵一律歸根,這就是為 2026 資料夾準備的發票跑到頂層的原因——用普通 AddDocumentAttachment 附加的檔案,鍵裡根本沒有前綴

HotPDF 用三個呼叫把樹建起來。EnsureCollectionRootFolder(RootName, Description) 建立根(ID 0),根已存在則回傳其 ID。AddCollectionChildFolder(ParentFolderID, FolderName, Description) 附加子節點、回傳新 ID,並保持 /Free 最新。AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) 把檔案嵌在 <FolderID>name 底下,把裸檔名寫進 /F 與 /UF——cfsFileName 欄顯示的正是這個。之後所有指名這個檔案的呼叫都要用完整鍵,包括 ConfigurePortfolio 的 InitialDocument 與中繼資料呼叫。資料夾、/Split 與 /Colors 是 PDF 2.0 條目,所以 BeginDoc 之前把 Version 設為 pdf20

HotPDF 在 Delphi 中建立 PDF 2.0 Portfolio 資料夾:EnsureCollectionRootFolder 與 AddCollectionChildFolder 建 /Child 與 /Next 資料夾樹,AddDocumentAttachmentToCollectionFolder 寫出帶資料夾 ID 前綴的 EmbeddedFiles 鍵
PDF 2.0 可攜式集合裡的資料夾成員資格,編碼在每個 EmbeddedFiles 鍵的數字前綴裡;角括號裡沒有數字的鍵一律留在根
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders、/Split 與 /Colors 是 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);    // 分隔列落在視窗的 30 %
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

兄弟順序在標準裡是建議值:兄弟應遵循 collection 排序鍵,沒有的話按資料夾名。AddCollectionChildFolder 按呼叫順序串接兄弟,所以建立時就照排序後的順序來,範例是 2025 在 2026 之前。/Split 位置(ISO 32000-2 Table 158)是 0 到 100 的百分比,所以是 30 而不是 0.3。Table 159 之後的命名規則同樣適用於資料夾名與資料夾內的檔名,而這些名字通常來自使用者或上傳表單,呼叫前先過濾;AddCollectionChildFolder 本身會拒收斜線、控制字元,以及在忽略 ASCII 大小寫時與既有兄弟重名的名字:

  • 長度 1 到 255 個字元之間,中間不得嵌入 NUL
  • 名稱中任何位置不得出現 / \ : * " < > |
  • 最後一個字元不得是句點
  • 不得有兩個僅大小寫不同的兄弟資料夾,同一資料夾內也不得有兩個僅大小寫不同的檔案

讀別家產生器寫出的 Portfolio

讀取端得接受 /CI 的每一種合法形狀,因為各家產生器做法不一:有的把每個值包在 subitem 字典裡,有的直接寫裸字串與數字,規範的 Example 2 則在同一個 item 裡混用兩種。只找 subitem /D 底下字串的程式碼,對第二種產生器只會顯示空欄。GetLoadedCollectionItemFields(FileName) 對每個條目回傳一個 THPDFCollectionItemFieldValue,填好 IsSubitemDictionary、IsNumeric、NumericValue、StringValue 與 Prefix。GetLoadedCollectionInfo 回傳 schema、排序鍵、分隔列、顏色,以及 GetLoadedCollectionFolderTree 的資料夾節點(按樹序),每個節點帶 ID、Name、ParentID、FirstChildID 與 NextSiblingID。每個檔案的資料夾由它的鍵決定,解析必須一字不差地照規則來:角括號之間只能是數字。<draft>notes.txt 這種來自完全不認識資料夾的產生器的鍵,角括號裡沒有數字,檔案就歸根;把第一個 > 之前全砍掉,會憑空發明一個叫 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:'<' 數字 '>' 檔名;其餘鍵一律歸根
  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;            // 沒有資料夾:扁平清單
        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;

交接檔案的預期輸出:四行資料夾,根在 ID 0、Q4 invoices 的上層是 2,接著是標成數字的 [3] invoice-1031.pdf: amount=USD 10400。把同一個迴圈拿去跑報價包裹,每個 amount 條目回來都帶著 IsNumeric;只在 StringValue 裡出現的金額,就是把 104,250 排到 9,800 底下的那一個。這個檢查用幾行就寫得下,放在用HotPDF component for Delphi and C++Builder 產生包裹的程式碼旁邊正合適