三份投标书放进一个 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-1 | ISO 32000-2 |
|---|---|---|---|
/View、/D | 初始视图(D 为 details、T 为 tiles、H 为 hidden,PDF 2.0 另有表示 navigator 的 C)以及首个显示文件的名字树键 | Table 155 | Table 153 |
/Schema | 文件列表的列 | Tables 156, 157 | Tables 154, 155 |
/Sort | 排序字段与方向 | Table 158 | Table 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,它说明值由谁提供:
/Subtype | HotPDF 常量 | 值来自 |
|---|---|---|
| S, D, N | cfsText、cfsDate、cfsNumber | 你,逐个文件写在 /CI 里 |
| F, Desc | cfsFileName、cfsDescription | 文件规格(/UF 或 /F、/Desc) |
| ModDate, CreationDate, Size | cfsModDate、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
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
日期走的是同一套逻辑。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
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 组件构建包的那段代码旁边正好