В 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-1 | ISO 32000-2 |
|---|---|---|---|
/View, /D | Начальный вид (D — details, T — tiles, H — hidden, плюс C для navigator в PDF 2.0) и ключ name tree файла, который показывают первым | 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 и показывает только эти страницы, а 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, 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 файловой спецификации (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 ниже
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
С датами та же логика. Поле 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
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