技術記事

PDF Portfolio で数値ソートとフォルダを保つ Delphi コード

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-1ISO 32000-2
/View、/D初期表示(D が details、T が tiles、H が hidden、PDF 2.0 ではナビゲータ用の C も)と、最初に表示するファイルの name-tree キーTable 155Table 153
/Schemaファイルリストの列Tables 156、157Tables 154、155
/Sortソートのフィールドと方向Table 158Table 156
/Colorsレイアウトに提案する DeviceRGB パレットnoneTable 157
/Splitスプリッターの向きと位置noneTable 158
/Foldersフォルダツリーの根noneTable 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 です。誰が値を供給するかを告げます:

/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、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 のように

HotPDF の Delphi での PDF Portfolio スキーマ。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]));   // 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 に直接入ります

HotPDF の Delphi での SetPortfolioItemNumericMetadata。PDF Portfolio の金額を USD の /P プレフィックス付きの数値として書き、降順ソートは 104250 を先頭に並べる。テキスト文字列では USD 104,250 が USD 9,800 の下に落ちる
文字列で格納された 104,250 の入札は降順 Amount 列の底に落ち着きます。数値として、通貨を /P に追い出して格納すれば、頂上まで上がります

日付も同じ論理に従います。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 にしましょう

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 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 コンポーネントでパッケージを組むコードの隣に、数行で収まります