技术文章

PDF Portfolio 如何存列、排序键与文件夹

三份投标书放进一个 PDF Portfolio,查看器的 Amount 列按降序排列,却把 USD 104,250 那份排在最后、落在 USD 9,800 之下——因为金额是按文本存进去的。列值住在每个附件文件的 /CI 条目里,HotPDF 的 SetPortfolioItemNumericMetadata 把它们当作 PDF number 写进去,于是排序比较的是 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 为 details、T 为 tiles、H 为 hidden,PDF 2.0 另有表示 navigator 的 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 需要 navigator 字典(ISO 32000-2 表 160);对投标包或移交材料来说,details 视图配上合理的排序就是用户预期

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 表 48,第 2 部分是表 46)。每个条目是一个文本字符串、一个日期、一个数字,或一个 collection subitem 字典(表 49,第 2 部分为表 47)——后者的 /D 装数据,/P 装排序时会被忽略的显示前缀。规范自己的示例 2 说明了前缀为什么存在:一封邮件的主题存成 /D (Let's have lunch on Friday!) 加 /P (Re:),这样回复就能排在它所回应的那封信旁边。奇怪的是,两版规范都说该示例的 schema 在 §12.4.2 Page Labels 里;它们指的是紧挨在上面的示例 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 把字典序的细节留给各阅读器自己定。表 48(第 2 部分为表 46)要求每个 /CI 条目与其字段的 /Subtype 一致,所以把“USD 104,250”当作文本写进 N 列,本身就已经违规。阅读器剩下能做的只是比较字符串。去掉共同的“USD ”之后,字符 1 排在 9 之前,降序一倒,最大的一笔就到了最后。SetPortfolioItemMetadata 永远写文本形式的 /D,对 S 字段和日期字符串正合适。N 字段要用 SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary),它写的是 PDF number,货币符号放进排序会忽略的 /P。每个数字列都把单位和货币符号留在前缀里:只在屏幕上看起来像数字的值,正是排序出错的那个。前缀为空、UseSubitemDictionary 为 False 时,数字直接写进 /CI

HotPDF 的 SetPortfolioItemNumericMetadata 在 Delphi 里把 PDF Portfolio 金额写成 PDF number 并配上 USD 的 /P 前缀,降序排列时 104250 排在最前,而文本字符串会把 USD 104,250 压在 USD 9,800 之下
存成字符串时 104,250 那笔落进降序 Amount 列的底部,存成 number、货币符号放进 /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 portable collection 里的文件夹归属编码在每个 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 表 158)是 0 到 100 的百分比,所以写 30 而不是 0.3。表 159 之后的命名规则既管文件夹名,也管文件夹里的文件,而那些名字通常来自用户或上传表单,调用前先过滤;AddCollectionChildFolder 自己就会拒绝斜杠、控制字符,以及忽略 ASCII 字母大小写后与既有同级重名的名字:

  • 长度在 1 到 255 个字符之间,不含内嵌 NUL
  • 名字里任何位置都不得出现 / \ : * " < > |
  • 最后一个字符不能是句点
  • 同级文件夹之间、同一文件夹内的文件之间,不得有两个名字只差大小写

读回别人写出的 Portfolio

阅读器必须接受 /CI 的每一种合法形态,因为各家写出的东西不一样:有人把每个值包进 subitem 字典,有人只写裸的字符串和数字,规范自己的示例 2 还在同一个条目里两种混用。只会在 subitem 的 /D 下找字符串的代码,遇到第二类就显示空列。GetLoadedCollectionItemFields(FileName) 每个条目返回一个 THPDFCollectionItemFieldValue,填好 IsSubitemDictionary、IsNumeric、NumericValue、StringValue 和 Prefix。GetLoadedCollectionInfo 返回 schema、排序键、split、colors,以及按树序排列的 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;

handover 文件跑出来的结果应当是:四行文件夹,根在 ID 0,Q4 invoices 的父目录是 2,然后是标为数值的 [3] invoice-1031.pdf: amount=USD 10400。同一个循环套到投标包上,每个 amount 条目回来时都带着 IsNumeric;只在 StringValue 里出现的金额,就是那个会把 104,250 排到 9,800 之下的。这项检查不过几行,放在用 面向 Delphi 与 C++Builder 的 HotPDF 组件构建包的那段代码旁边正好