Техническая статья

Как PDF Portfolio хранит колонки, ключи сортировки и папки

В PDF Portfolio попадают три заявки, и колонка Amount просмотрщика, сортирующая по убыванию, ставит заявку на 104 250 USD в самый конец, ниже 9 800 USD — суммы сохранили текстом. Значения колонок живут во входе /CI каждого приложенного файла, и SetPortfolioItemNumericMetadata в HotPDF пишет их туда PDF-числами, так что сортировка сравнивает 104250 с 9800, а не две строки

Что превращает PDF в Portfolio?

Всё решает один ключ в каталоге. ISO 32000-1 §12.3.5 говорит: когда в каталоге есть словарь /Collection, соответствующий стандарту ридер обязан показать документ как portable collection (так спецификация называет то, что Acrobat зовёт PDF Portfolio), и каждый файл в name tree /Names /EmbeddedFiles — его участник; вложения вне этого дерева участниками не считаются. PDF с десятью вложениями и без /Collection — это просто PDF с переполненной панелью вложений, и это правильная форма, когда в комплекте едет один машиночитаемый файл, как XML в электронном счёте ZUGFeRD или Factur-X. Словарь маленький и почти весь опциональный; номера таблиц между двумя редакциями съехали, поэтому привожу обе:

ЗаписьЗа что отвечаетISO 32000-1ISO 32000-2
/View, /DНачальный вид (D — details, T — tiles, H — hidden, плюс C для navigator в PDF 2.0) и ключ name tree файла, который показывают первым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 и показывает только эти страницы, а NOTE 2 из §12.3.5 просит дать там заголовок и вводный абзац; обычный ответ — одна страница, сообщающая, что лежит в пакете и что для него нужен просмотрщик с поддержкой Portfolio. ConfigurePortfolio(Name, View, InitialDocument) выставляет вид и /D одним вызовом, а ConfigurePortfolioView позже переключает pvmDetails, pvmTile и pvmHidden. Виду C нужен словарь navigator (ISO 32000-2 Table 160); для пакета заявок или комплекта передачи дел пользователи ожидают вид details с разумной сортировкой

Поля схемы и значения за каждой колонкой

Колонки приходят из словаря схемы коллекции — по одному словарю поля на каждый выбранный вами ключ. Большая часть поля — презентация: /N — заголовок, /O — позиция колонки, /V и /E — начальная видимость и редактируемость; в THotPDF.AddPortfolioSchemaField они приходят как DisplayName, Order, Visible и Editable. Решение с последствиями — /Subtype: оно говорит, кто поставляет значение:

/SubtypeКонстанта HotPDFОткуда значение
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 файловой спецификации (ISO 32000-1 §7.11.6 Table 48, в части 2 — Table 46). Каждый вход — текстовая строка, дата, число или словарь subitem коллекции (Table 49, в части 2 — Table 47), у которого /D держит данные, а /P — отображаемый префикс, который сортировка игнорирует. Пример 2 из самой спецификации показывает, зачем префикс существует: тема письма сохранена как /D (Let's have lunch on Friday!) с /P (Re:), чтобы ответ при сортировке оказался рядом с сообщением, на которое отвечает. Забавно, что обе редакции отправляют схему этого примера в §12.4.2, Page Labels; имелся в виду Example 1 прямо над ним. Что до колонок о самом файле, AddDocumentAttachment проставляет /Params даты моментом вложения, так что колонка cfsModDate показывает, когда собран пакет. Тем, кому на ревью нужна собственная дата исходного файла, достаётся колонка D, которую вы заполняете сами, — как received ниже

Схема PDF Portfolio в HotPDF на Delphi: сабтипы AddPortfolioSchemaField cfsFileName, cfsText, cfsNumber, cfsDate и cfsSize сопоставлены с файловой спецификацией, словарём элемента коллекции /CI и /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), как и named destinations в дереве /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

SetPortfolioItemNumericMetadata в HotPDF на Delphi пишет суммы PDF Portfolio числами с префиксом /P «USD », поэтому сортировка по убыванию ставит 104250 наверх, а текстовые строки отправляют USD 104 250 ниже USD 9 800
Сохранённая строкой заявка 104 250 падает на дно колонки Amount с сортировкой по убыванию; число с валютой в /P поднимается наверх

С датами та же логика. Поле D ждёт строку даты PDF (§7.9.4) вроде D:20261031143000; 31/10/2026 из локали Windows — это текст в колонке даты, и ляжет он туда, куда упадут его символы. Сама сортировка живёт в словаре sort коллекции: /S — одно имя поля или массив, где каждое следующее имя разбирает ничью, оставшуюся от предыдущих, а /A — булево значение или параллельный массив булевых; лишние входы игнорируются, отсутствующие читаются как true, то есть по возрастанию. SetPortfolioSortOrder принимает массив записей THPDFPortfolioSortKey и пишет одноимённую форму для одного ключа и массив для нескольких. Берите ключи, зарегистрированные в схеме: /S вправе называть только поля, описанные в родительском словаре коллекции

Папки PDF 2.0 живут в именах файлов

Папка не держит файлы внутри PDF. ISO 32000-2 §12.3.5.2 определяет словари папок как indirect objects, сцепленные в одно дерево: /Folders указывает на корень, каждый ребёнок носит /Parent, до первого ребёнка папка добирается через /Child, сиблинги цепляются через /Next. У каждой папки обязательные /Name и неотрицательное целое /ID, уникальное в документе, а массив /Free корня перечисляет ещё свободные диапазоны ID. Принадлежность закодирована в ключе EmbeddedFiles: ключ, начинающийся с <, продолжающийся цифрами и закрытый >, принадлежит папке с этим ID, а остальное — имя файла, так что <3>invoice-1031.pdf — это invoice-1031.pdf из папки 3. Ключ и есть папка. Любой ключ вне конвенции попадает корню — поэтому счета, предназначенные для папки 2026, оказываются на верхнем уровне: их приложили обычным AddDocumentAttachment, и в ключах нет никакого префикса

HotPDF строит дерево тремя вызовами. EnsureCollectionRootFolder(RootName, Description) создаёт корень с ID 0 или возвращает ID существующего. AddCollectionChildFolder(ParentFolderID, FolderName, Description) добавляет ребёнка, возвращает его новый ID и поддерживает /Free в актуальном состоянии. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) вкладывает файл под ключом <FolderID>name и пишет голое имя в /F и /UF — именно его показывает колонка cfsFileName. Любой последующий вызов, называющий файл, требует полный ключ, включая InitialDocument в ConfigurePortfolio и вызовы метаданных. Папки, /Split и /Colors — входы PDF 2.0, поэтому выставьте Version в pdf20 до BeginDoc

Папки Portfolio PDF 2.0 в HotPDF на Delphi: EnsureCollectionRootFolder и AddCollectionChildFolder строят дерево папок через /Child и /Next, а AddDocumentAttachmentToCollectionFolder пишет ключи EmbeddedFiles с префиксом ID папки
Членство в папке в portable collection 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;

Порядок сиблингов — рекомендация стандарта: сиблинги должны следовать ключу сортировки коллекции, а без него — именам папок. 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 спецификации мешает оба стиля в одном элементе. Код, который ищет только строку под subitem /D, для второй группы покажет пустые колонки. GetLoadedCollectionItemFields(FileName) возвращает по одному THPDFCollectionItemFieldValue на вход с заполненными IsSubitemDictionary, IsNumeric, NumericValue, StringValue и Prefix. GetLoadedCollectionInfo возвращает схему, ключи сортировки, split, цвета и узлы папок из 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. Такая проверка умещается в несколько строк рядом с кодом, собирающим пакеты с компонентом HotPDF для Delphi и C++Builder