Artigo Técnico

Portfolios PDF: colunas, chaves de ordenação e pastas

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:

EntradaO que controlaISO 32000-1ISO 32000-2
/View, /DVisã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 primeiroTabela 155Tabela 153
/SchemaAs colunas da lista de arquivosTabelas 156, 157Tabelas 154, 155
/SortCampos de ordenação e direçõesTabela 158Tabela 156
/ColorsPaleta DeviceRGB sugerida para o layoutnenhumaTabela 157
/SplitDireção e posição da barra divisórianenhumaTabela 158
/FoldersRaiz da árvore de pastasnenhumaTabela 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:

/SubtypeConstante HotPDFValor vem de
S, D, NcfsText, cfsDate, cfsNumberVocê, por arquivo, no /CI
F, DesccfsFileName, cfsDescriptionA file specification (/UF ou /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeO /Params do arquivo embutido
CompressedSize (PDF 2.0)cfsCompressedSizeO /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

Schema de PDF Portfolio no Delphi com HotPDF: subtypes do AddPortfolioSchemaField cfsFileName, cfsText, cfsNumber, cfsDate e cfsSize mapeados para a file specification, o dicionário collection item /CI e o /Params do arquivo embutido
colunas de texto, data e número tiram os valores delas da entrada /CI de cada arquivo, enquanto colunas de nome de arquivo, descrição, data e tamanho vêm de estruturas gravadas quando o arquivo é embutido
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

O SetPortfolioItemNumericMetadata no Delphi com HotPDF grava valores de PDF Portfolio como números com prefixo /P de USD, então uma ordenação decrescente lista 104250 primeiro, ao contrário de text strings que põem USD 104,250 abaixo de USD 9,800
armazenada como string, a proposta de 104,250 cai no fundo de uma coluna Amount decrescente; armazenada como número com a moeda no /P, ela sobe para o topo

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

Pastas de PDF Portfolio 2.0 no Delphi com HotPDF: EnsureCollectionRootFolder e AddCollectionChildFolder constroem a árvore de pastas /Child e /Next, e o AddDocumentAttachmentToCollectionFolder grava chaves EmbeddedFiles carregando o prefixo de ID de pasta
a pertença de pasta em uma portable collection do PDF 2.0 fica codificada no prefixo de dígitos de cada chave EmbeddedFiles, e qualquer chave sem dígitos entre colchetes angulares fica na raiz
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