3 件の入札を PDF Portfolio に入れると、ビューアの Amount 列の降順で USD 104,250 の入札が最後、USD 9,800 の下に並びます。金額がテキストとして入ったからです。列の値は各添付ファイルの /CI エントリに住んでいて、HotPDF の SetPortfolioItemNumericMetadata はそこへ PDF の数値として書き込みます。ソートは 2 つの文字列ではなく 104250 と 9800 を比較します
PDF が Portfolio として開かれる条件
決めるのは catalog のキー 1 つです。ISO 32000-1 の §12.3.5 は、/Collection 辞書があるとき、適合リーダーは文書を portable collection として提示すべきだと述べます(Acrobat が PDF Portfolio と呼ぶものの、仕様書での名前です)。そして /Names /EmbeddedFiles の name tree にあるファイルはすべてメンバーになり、その木の外の添付は対象外です。添付 10 個で /Collection なしの PDF は、賑やかな添付パネルを持つただの PDF です。これは 1 個の機械可読ファイルがお供として付く形にちょうど合っています。ZUGFeRD や Factur-X の電子請求書で XML がそうであるように。辞書は小さく、ほぼ全部が任意です。2 つの版の間でテーブル番号がずれているので、両方載せておきます:
| エントリ | 制御するもの | ISO 32000-1 | ISO 32000-2 |
|---|---|---|---|
/View、/D | 初期表示(D が details、T が tiles、H が hidden、PDF 2.0 ではナビゲータ用の C も)と、最初に表示するファイルの name-tree キー | Table 155 | Table 153 |
/Schema | ファイルリストの列 | Tables 156、157 | Tables 154、155 |
/Sort | ソートのフィールドと方向 | Table 158 | Table 156 |
/Colors | レイアウトに提案する DeviceRGB パレット | none | Table 157 |
/Split | スプリッターの向きと位置 | none | Table 158 |
/Folders | フォルダツリーの根 | none | Table 159(§12.3.5.2) |
コンテナ自身のページもまだ重要です。カバーシートだからです。/D がなければ初期文書はコンテナ自体になり、ISO 32000-2 はさらに、実在しないファイルを /D が名指すときはリーダーがリストの最初のファイルを選ぶと加えます。portfolio 非対応のビューアは /Collection を無視してそのページだけを表示します。§12.3.5 の NOTE 2 はそこへタイトルと導入段落を求めます。定番の応えは、パッケージの中身と portfolio 対応ビューアが必要であることを書いた 1 ページです。ConfigurePortfolio(Name, View, InitialDocument) はビューと /D を 1 呼び出しで設定し、ConfigurePortfolioView は後から pvmDetails、pvmTile、pvmHidden を切り替えます。ビュー C にはナビゲータ辞書が要ります(ISO 32000-2 Table 160)。入札パッケージや引き継ぎセットなら、筋のいいソート付き details ビューがユーザーの期待するところです
スキーマフィールドと各列の値の出どころ
列は collection schema 辞書から来ます。選んだキーごとに collection field 辞書が 1 つです。フィールドの大部分は見た目の設定——/N がヘッダー、/O が列位置、/V と /E が初期の表示と編集可否で、THotPDF.AddPortfolioSchemaField には DisplayName、Order、Visible、Editable として渡します。結果を左右する判断は /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 Table 48、part 2 では Table 46)。各エントリはテキスト文字列、日付、数値、あるいは collection subitem 辞書(Table 49、part 2 では Table 47)です。subitem 辞書は /D にデータを、ソートが無視する表示プレフィックスを /P に持ちます。プレフィックスが存在する理由は、仕様書自身の Example 2 が見せてくれます。メールの件名を /D (Let's have lunch on Friday!) と保存し、/P (Re:) を添える。返信は返信先のメッセージの隣に並ぶわけです。妙なことに、両版ともこの例のスキーマは §12.4.2(Page Labels)にあると言います。直上の Example 1 のことです。ファイル由来の列については、AddDocumentAttachment が /Params の日付に埋め込みの時刻を刻むので、cfsModDate 列はパッケージを作った時刻を示します。元ファイル本来の日付が要る reviewer には、自分で埋める 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])); // name-tree のキー
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 は、スキーマキーが未登録のときや、指定キーを持つ埋め込みファイルがないときに例外を上げます。だから順序は、スキーマを登録し、ファイルを添付し、それから値を設定、です。FileName 引数は name-tree のキーで、AddDocumentAttachment の場合、ディレクトリを除いたファイル名になります。キーはバイト列で、大文字小文字を区別して比較されます(§7.9.6)。/Dests ツリーの named destination と同じく、Civil.pdf は civil.pdf を見つけられません。2 つの元ファイルが同じベース名を共有するとき、HotPDF は両方を保持し、2 つ目に hvac (2).pdf のような番号付きキーを与えます。メタデータ呼び出しはその後このキーを使います
104,250 の入札がなぜ 9,800 の下に落ちるのか
テキストとして格納されていたからです。§12.3.5 のソート規則はデータ型に従います。数値は数値として、日付は古い順に、テキストは辞書式に。NOTE 3 が辞書順の詳細を各リーダーに委ねます。Table 48(part 2 では Table 46)はすべての /CI エントリに、フィールドの /Subtype への一致を要求するので、N 列にテキストで書かれた「USD 104,250」はこの時点で規則違反です。リーダーに残されるのは文字列比較です。共通の「USD 」の先を、文字 1 は 9 の手前に並ぶので、降順ソートは最大額の入札を最後に置きます。SetPortfolioItemMetadata は常にテキストの /D を書きます。S フィールドと日付文字列にはそれで正しい。N フィールドには SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) が PDF の数値を書き、通貨記号をソートが無視する /P に運びます。数値列では単位と通貨記号をプレフィックスに追い出しましょう。画面上でだけ数値に見える値こそ、ソートを間違える値です。プレフィックスが空で UseSubitemDictionary が False なら、数値は /CI に直接入ります
日付も同じ論理に従います。D フィールドが期待するのは D:20261031143000 のような PDF 日付文字列(§7.9.4)です。Windows のロケール由来の 31/10/2026 は日付列の中ではテキストで、文字が落ちた場所に着地します。ソート本体は collection sort 辞書に住んでいます。/S は 1 つのフィールド名、あるいは配列で、後の名前ほど前の名前の残した同着を決めます。/A は真偽値か、平行する真偽値の配列で、余分なエントリは無視され、欠けたエントリは true(昇順)と読まれます。SetPortfolioSortOrder は THPDFPortfolioSortKey レコードの配列を取り、1 キーなら単一名の形を、複数なら配列の形を書き込みます。使うキーはスキーマに登録したものに限ります。/S が名指せるのは、親の collection 辞書が記述するフィールドだけです
PDF 2.0 のフォルダはファイル名の中に住む
フォルダは PDF の中にファイルを収めていません。ISO 32000-2 の §12.3.5.2 は、フォルダ辞書を 1 本の木に繋がれた間接オブジェクトとして定義します。/Folders が根を指し、各子は /Parent を運び、フォルダは /Child を通して最初の子へ届き、兄弟は /Next で繋がります。すべてのフォルダには必須の /Name と、文書内で一意な非負整数の /ID があり、根の /Free 配列はまだ使われていない ID の範囲を列挙します。所属は EmbeddedFiles キーに符号化されます。< で始まり、数字が続き、> で閉じるキーはその ID のフォルダに属し、残りがファイル名です。<3>invoice-1031.pdf はフォルダ 3 の invoice-1031.pdf です。キーこそがフォルダです。この慣習の外にあるキーはすべて根に属します。2026 フォルダ用の請求書がトップレベルにたたき落ちるのはこうしてです。素の AddDocumentAttachment で添付されたものは、キーにプレフィックスを 1 文字も運びません
HotPDF は 3 つの呼び出しでこの木を組み立てます。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 Table 158)は 0〜100 のパーセントです。だから 30 であって 0.3 ではない。Table 159 の後の命名規則はフォルダ名とフォルダ内のファイルに及びます。その名前はたいていユーザーやアップロードフォーム由来なので、呼び出しの前に濾過を。AddCollectionChildFolder 自身も、スラッシュと制御文字、ASCII の大文字小文字を無視して既存と重なる兄弟名を拒みます:
- 1〜255 文字で、途中に NUL を含まないこと
- 名前のどこにも
/ \ : * " < > |を含まないこと - 末尾の文字がピリオドでないこと
- 大文字小文字だけが違う名前の兄弟フォルダ、同じフォルダ内のファイルが 2 つ以上ないこと
他の作成者が書いた Portfolio を読む
リーダーは /CI の合法な形をすべて受け入れなければなりません。作成者はまちまちだからです。値をすべて subitem 辞書に包む者、素の文字列と数値を書く者、仕様書の Example 2 は 1 つの item の中で両方を混ぜます。subitem の /D の下の文字列だけを探すコードは、後のグループに空の列を見せます。GetLoadedCollectionItemFields(FileName) はエントリごとに 1 つの THPDFCollectionItemFieldValue を返し、IsSubitemDictionary、IsNumeric、NumericValue、StringValue、Prefix を埋めてくれます。GetLoadedCollectionInfo はスキーマ、ソートキー、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: '<' 数字 '>' ファイル名。他のキーは root
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 が parent 2 の下という 4 行のフォルダ行に続き、[3] invoice-1031.pdf: amount=USD 10400 が numeric として現れる形です。同じループを入札パッケージに回すと、すべての amount エントリは IsNumeric が立って返ってきます。StringValue にしか届かない金額こそ、104,250 を 9,800 の下に並べる値です。この検査は Delphi と C++Builder 用 HotPDF コンポーネントでパッケージを組むコードの隣に、数行で収まります