Três propostas entram em um PDF Portfolio, e a coluna Amount decrescente do viewer lista a proposta de USD 104,250 por último, abaixo de USD 9,800 — os valores entraram como texto. Os valores de coluna moram na entrada /CI de cada arquivo anexado, e o SetPortfolioItemNumericMetadata do HotPDF os grava lá como números PDF, então a ordenação compara 104250 com 9800 em vez de duas strings
O que faz um PDF abrir como Portfolio?
Uma chave do catalog decide. A ISO 32000-1 §12.3.5 diz que, quando um dicionário /Collection está presente, um reader conforme shall apresentar o documento como uma portable collection (o nome da especificação para o que o Acrobat chama de PDF Portfolio), e todo arquivo na name tree /Names /EmbeddedFiles é um membro; anexos fora dessa árvore não são. Um PDF com dez anexos e nenhum /Collection é só um PDF com um painel de anexos movimentado, que é a forma certa quando um único arquivo legível por máquina vem junto, como o XML faz em uma e-fatura ZUGFeRD ou Factur-X. O dicionário é pequeno e quase inteiramente opcional; os números de tabela mudaram entre as duas edições, então ambos estão listados:
| Entrada | O que controla | ISO 32000-1 | ISO 32000-2 |
|---|---|---|---|
/View, /D | Visão inicial (D details, T tiles, H hidden, mais C para um navigator no PDF 2.0) e a chave da name tree do arquivo exibido primeiro | Tabela 155 | Tabela 153 |
/Schema | As colunas da lista de arquivos | Tabelas 156, 157 | Tabelas 154, 155 |
/Sort | Campos de ordenação e direções | Tabela 158 | Tabela 156 |
/Colors | Paleta DeviceRGB sugerida para o layout | nenhuma | Tabela 157 |
/Split | Direção e posição da barra divisória | nenhuma | Tabela 158 |
/Folders | Raiz da árvore de pastas | nenhuma | Tabela 159 (§12.3.5.2) |
As páginas do próprio contêiner continuam importando, porque são a folha de capa. Quando o /D está ausente, o documento inicial é o próprio contêiner; a ISO 32000-2 acrescenta que um /D nomeando arquivo inexistente faz o reader selecionar o primeiro arquivo da lista. Um viewer sem suporte a portfolio ignora o /Collection e mostra só essas páginas, e a NOTE 2 em §12.3.5 pede um título e um parágrafo introdutório ali; a resposta usual é uma página dizendo o que o pacote contém e que ele precisa de um viewer com suporte a portfolio. O ConfigurePortfolio(Name, View, InitialDocument) define a view e o /D em uma chamada, e o ConfigurePortfolioView alterna entre pvmDetails, pvmTile e pvmHidden depois. A view C precisa de um dicionário navigator (ISO 32000-2 Tabela 160); para um pacote de propostas ou um conjunto de entrega, a view details com uma ordenação sensata é o que os usuários esperam
Campos de schema e os valores por trás de cada coluna
As colunas vêm do dicionário schema da collection, um dicionário collection field por chave que você escolher. A maior parte de um field é apresentação — o /N é o cabeçalho, o /O a posição da coluna, o /V e o /E a visibilidade e a editabilidade iniciais, passados ao THotPDF.AddPortfolioSchemaField como DisplayName, Order, Visible e Editable. O /Subtype é a decisão com consequências. Ele diz quem fornece o valor:
/Subtype | Constante HotPDF | Valor vem de |
|---|---|---|
| S, D, N | cfsText, cfsDate, cfsNumber | Você, por arquivo, no /CI |
| F, Desc | cfsFileName, cfsDescription | A file specification (/UF ou /F, /Desc) |
| ModDate, CreationDate, Size | cfsModDate, cfsCreationDate, cfsSize | O /Params do arquivo embutido |
| CompressedSize (PDF 2.0) | cfsCompressedSize | O /Length do stream embutido |
Os valores por arquivo ficam no dicionário collection item para o qual a entrada /CI da file specification aponta (ISO 32000-1 §7.11.6 Tabela 48, Tabela 46 na parte 2). Cada entrada é uma text string, uma data, um número ou um dicionário collection subitem (Tabela 49, ou Tabela 47 na parte 2) cujo /D guarda o dado e cujo /P guarda um prefixo de exibição que a ordenação ignora. O Example 2 da própria especificação mostra por que o prefixo existe: um assunto de e-mail armazenado como /D (Let's have lunch on Friday!) com /P (Re:), para que uma resposta ordene ao lado da mensagem que ela responde. Curiosamente, as duas edições dizem que o schema desse exemplo está em §12.4.2, Page Labels; elas querem o Example 1 logo acima. Quanto às colunas ligadas ao arquivo, o AddDocumentAttachment carimba as datas do /Params com o momento do embedding, então uma coluna cfsModDate mostra quando o pacote foi montado. Revisores que precisam da data do próprio arquivo de origem recebem uma coluna D que você preenche, como a received abaixo
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;
// Folha de capa: tudo que um viewer sem suporte a portfolio vai mostrar
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); // oculta de início
for I := 0 to High(Files) do
begin
Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
Key := AnsiString(ExtractFileName(Files[I])); // a chave da 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; // maior proposta no topo
SortKeys[1].Key := 'trade';
SortKeys[1].Ascending := True; // desempate
Pdf.SetPortfolioSortOrder(SortKeys);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
O SetPortfolioItemMetadata e o SetPortfolioItemNumericMetadata levantam exceção quando a chave de schema não está registrada ou nenhum arquivo embutido tem a chave dada, então registre o schema, anexe o arquivo e só depois defina os valores. O argumento FileName deles é a chave da name tree, que para o AddDocumentAttachment é o nome do arquivo sem o diretório. As chaves são byte strings comparadas com distinção de maiúsculas (§7.9.6), como nas named destinations na árvore /Dests, então Civil.pdf não encontra civil.pdf. Quando dois arquivos de origem dividem um nome de base, o HotPDF mantém os dois e dá ao segundo uma chave numerada como hvac (2).pdf, que as chamadas de metadata passam a usar
Como uma proposta de 104,250 acaba abaixo de 9,800?
Ela foi armazenada como texto. As regras de ordenação em §12.3.5 dependem do tipo de dado — números numericamente, datas da mais antiga para a mais nova, text strings lexicograficamente, com a NOTE 3 deixando os detalhes da ordem lexical a cada reader. A Tabela 48 (Tabela 46 na parte 2) exige que toda entrada /CI bata com o /Subtype do field dela, então "USD 104,250" escrito como texto numa coluna N já quebra a regra. O reader fica comparando strings. Depois do "USD " compartilhado, o caractere 1 ordena antes do 9, e uma ordenação decrescente põe a maior proposta por último. O SetPortfolioItemMetadata sempre grava um /D de texto, certo para fields S e date strings. Para fields N, o SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) grava um número PDF e carrega o símbolo de moeda no /P, que a ordenação ignora. Mantenha unidades e símbolos de moeda no prefixo em toda coluna numérica: um valor que só parece numérico na tela é o que ordena errado. Com prefixo vazio e UseSubitemDictionary em False, o número vai direto para o /CI
As datas seguem a mesma lógica. Um field D espera uma PDF date string (§7.9.4) como D:20261031143000; 31/10/2026 vindo do locale do Windows é texto numa coluna de data e cai onde os caracteres dele derem. A ordenação em si mora no dicionário de ordenação da collection: o /S é um nome de field ou um array em que cada nome posterior desempata o que os anteriores deixaram, e o /A é um boolean ou um array paralelo de booleans, com entradas excedentes ignoradas e as ausentes lidas como true, ou seja, ascendente. O SetPortfolioSortOrder recebe um array de records THPDFPortfolioSortKey e grava a forma de nome único para uma chave, a forma de array para várias. Use as chaves registradas no schema; o /S só pode nomear fields descritos no dicionário collection pai
Pastas do PDF 2.0 moram nos nomes de arquivo
Uma pasta não guarda arquivo nenhum dentro do PDF. A ISO 32000-2 §12.3.5.2 define dicionários de pasta como objetos indiretos ligados em uma árvore: o /Folders aponta para a raiz, cada filho carrega /Parent, uma pasta alcança o primeiro filho pelo /Child, e os irmãos se encadeiam pelo /Next. Toda pasta tem um /Name exigido e um inteiro não negativo /ID único no documento, e o array /Free da raiz lista faixas de ID ainda sem uso. A pertença fica codificada na chave do EmbeddedFiles: uma que começa com <, continua com dígitos e fecha com > pertence à pasta com aquele ID, e o resto é o nome do arquivo, então <3>invoice-1031.pdf é invoice-1031.pdf na pasta 3. A chave é a pasta. Qualquer chave fora da convenção pertence à raiz, que é como faturas destinadas a uma pasta 2026 acabam no nível de topo — anexadas com AddDocumentAttachment simples, as chaves delas não carregam prefixo nenhum
O HotPDF constrói a árvore com três chamadas. O EnsureCollectionRootFolder(RootName, Description) cria a raiz com ID 0, ou retorna o ID da raiz existente. O AddCollectionChildFolder(ParentFolderID, FolderName, Description) anexa um filho, retorna o ID novo dele e mantém o /Free em dia. O AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) embute o arquivo sob <FolderID>name e grava o nome puro no /F e no /UF, que é o que uma coluna cfsFileName mostra. Toda chamada posterior que nomear o arquivo precisa da chave completa, incluindo o InitialDocument no ConfigurePortfolio e as chamadas de metadata. Pastas, /Split e /Colors são entradas do PDF 2.0, então defina o Version como pdf20 antes do 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 e /Colors são 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); // barra divisória a 30 % da janela
Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
A ordem entre irmãos é uma recomendação na norma: os irmãos devem seguir a chave de ordenação da collection, ou os nomes de pasta quando não há nenhuma. O AddCollectionChildFolder encadeia irmãos na ordem de chamada, então crie-os ordenados, como o exemplo faz com 2025 antes de 2026. A posição do /Split (ISO 32000-2 Tabela 158) é uma porcentagem de 0 a 100, daí 30 e não 0.3. As regras de nome depois da Tabela 159 valem para nomes de pasta e para arquivos dentro de pastas, e como esses nomes normalmente vêm de usuários ou de formulários de upload, filtre-os antes da chamada; o AddCollectionChildFolder em si recusa barras, caracteres de controle e um nome de irmão que repita um existente quando a caixa das letras ASCII é ignorada:
- entre 1 e 255 caracteres, sem NUL embutido
- nenhum destes
/ \ : * " < > |em ponto algum do nome - nenhum ponto final como último caractere
- nenhum par de pastas irmãs, e nenhum par de arquivos na mesma pasta, cujos nomes difiram só pela caixa
Lendo um Portfolio escrito por outro produtor
Um reader precisa aceitar toda forma legal de /CI, porque os produtores diferem: alguns embrulham cada valor em um dicionário subitem, outros gravam strings e números puros, e o Example 2 da especificação mistura os dois dentro de um mesmo item. Código que só procura uma string sob um /D de subitem mostra colunas vazias para o segundo grupo. O GetLoadedCollectionItemFields(FileName) retorna um THPDFCollectionItemFieldValue por entrada com IsSubitemDictionary, IsNumeric, NumericValue, StringValue e Prefix preenchidos. O GetLoadedCollectionInfo retorna schema, chaves de ordenação, split, cores e os nós de pasta do GetLoadedCollectionFolderTree em ordem de árvore, cada um com ID, Name, ParentID, FirstChildID e NextSiblingID. A pasta de cada arquivo vem da chave dele, e o parse precisa seguir a regra literalmente: só dígitos entre os colchetes angulares. Uma chave como <draft>notes.txt vinda de um produtor que não sabe nada de pastas não tem dígitos ali, então o arquivo pertence à raiz; cortar tudo até o primeiro > inventaria uma pasta chamada 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: '<' dígitos '>' nome de arquivo; qualquer outra chave é raiz
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; // sem pastas: uma lista plana
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;
Esperado para o arquivo de handover: quatro linhas de pasta com a raiz no ID 0 e Q4 invoices sob o pai 2, depois [3] invoice-1031.pdf: amount=USD 10400 sinalizado como numérico. Rode o mesmo loop sobre o pacote de propostas e toda entrada amount volta com IsNumeric definido; um valor que chega só no StringValue é o que vai ordenar 104,250 abaixo de 9,800. Essa conferência cabe em poucas linhas ao lado do código que monta pacotes com o componente HotPDF para Delphi e C++Builder