Artículo técnico

PDF Portfolio: columnas, orden y carpetas en Delphi

Tres ofertas entran a un PDF Portfolio y la columna Amount descendente del visor lista la oferta de USD 104,250 al final, debajo de la de USD 9,800 — los montos entraron como texto. Los valores de columna viven en la entrada /CI de cada archivo adjunto, y SetPortfolioItemNumericMetadata de HotPDF los escribe ahí como números PDF, de modo que el orden compara 104250 con 9800 en vez de dos cadenas

¿Qué hace que un PDF se abra como Portfolio?

Una clave del catálogo lo decide. ISO 32000-1 §12.3.5 dice que cuando hay un diccionario /Collection presente, un lector conforme debe presentar el documento como una portable collection (el nombre que la especificación da a lo que Acrobat llama PDF Portfolio), y cada archivo del name tree /Names /EmbeddedFiles es un miembro; los adjuntos fuera de ese árbol no lo son. Un PDF con diez adjuntos y sin /Collection es solo un PDF con un panel de adjuntos concurrido, que es la forma correcta cuando viaja un único archivo legible por máquina, como el XML de una factura electrónica ZUGFeRD o Factur-X. El diccionario es pequeño y casi todo opcional; los números de tabla se movieron entre las dos ediciones, así que se listan ambos:

EntradaQué controlaISO 32000-1ISO 32000-2
/View, /DVista inicial (D detalles, T mosaicos, H oculta, más C para un navigator en PDF 2.0) y la clave del name tree del archivo que se muestra primeroTabla 155Tabla 153
/SchemaLas columnas de la lista de archivosTablas 156, 157Tablas 154, 155
/SortCampos de orden y direccionesTabla 158Tabla 156
/ColorsPaleta DeviceRGB sugerida para la disposiciónningunaTabla 157
/SplitDirección y posición de la barra divisorianingunaTabla 158
/FoldersRaíz del árbol de carpetasningunaTabla 159 (§12.3.5.2)

Las páginas propias del contenedor siguen importando, porque son la hoja de portada. Cuando falta el /D, el documento inicial es el contenedor mismo; ISO 32000-2 añade que un /D que nombra un archivo inexistente hace que el lector seleccione el primer archivo de la lista. Un visor sin soporte de portfolios ignora el /Collection y muestra solo esas páginas, y la NOTA 2 de la §12.3.5 pide ahí un título y un párrafo introductorio; la respuesta habitual es una página que diga qué contiene el paquete y que necesita un visor con soporte de portfolios. ConfigurePortfolio(Name, View, InitialDocument) fija la vista y el /D en una llamada, y ConfigurePortfolioView cambia entre pvmDetails, pvmTile y pvmHidden más adelante. La vista C necesita un diccionario navigator (ISO 32000-2 Tabla 160); para un paquete de ofertas o un set de entrega, la vista de detalles con un orden sensato es lo que los usuarios esperan

Campos del schema y los valores detrás de cada columna

Las columnas vienen del diccionario schema de la colección, un diccionario de campo por cada clave que usted elija. Buena parte de un campo es presentación — /N es el encabezado, /O la posición de la columna, /V y /E la visibilidad inicial y la editabilidad, pasados a THotPDF.AddPortfolioSchemaField como DisplayName, Order, Visible y Editable. El /Subtype es la decisión con consecuencias: dice quién aporta el valor:

/SubtypeConstante de HotPDFEl valor viene de
S, D, NcfsText, cfsDate, cfsNumberUsted, por archivo, en /CI
F, DesccfsFileName, cfsDescriptionLa especificación del archivo (/UF o /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeEl /Params del archivo incrustado
CompressedSize (PDF 2.0)cfsCompressedSizeEl /Length del stream incrustado

Los valores por archivo viven en el diccionario collection item al que apunta la entrada /CI de la especificación del archivo (ISO 32000-1 §7.11.6 Tabla 48, Tabla 46 en la parte 2). Cada entrada es una cadena de texto, una fecha, un número o un diccionario collection subitem (Tabla 49, o Tabla 47 en la parte 2) cuyo /D guarda el dato y cuyo /P guarda un prefijo de visualización que el ordenamiento ignora. El propio Ejemplo 2 de la especificación muestra por qué existe el prefijo: un asunto de correo guardado como /D (Let's have lunch on Friday!) con /P (Re:), de modo que una respuesta ordena junto al mensaje que contesta. Curiosamente, ambas ediciones dicen que el schema de ese ejemplo está en la §12.4.2, Page Labels; quieren decir el Ejemplo 1, justo arriba. En cuanto a las columnas ligadas al archivo, AddDocumentAttachment estampa fechas /Params con el momento de la incrustación, así que una columna cfsModDate muestra cuándo se construyó el paquete. Los revisores que necesiten la fecha propia del archivo fuente reciben una columna D que usted llena, como received más abajo

HotPDF schema de PDF Portfolio en Delphi: subtipos de AddPortfolioSchemaField cfsFileName, cfsText, cfsNumber, cfsDate y cfsSize mapeados a la especificación del archivo, el diccionario collection item /CI y el /Params del archivo incrustado
Las columnas de texto, fecha y número toman sus valores de la entrada /CI de cada archivo, mientras que las columnas de nombre, descripción, fecha y tamaño vienen de estructuras escritas cuando el archivo se incrusta
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;
    // Portada: todo lo que mostrará un visor sin soporte de portfolios
    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 al principio

    for I := 0 to High(Files) do
    begin
      Pdf.AddDocumentAttachment(Files[I], Trades[I] + ' bid');
      Key := AnsiString(ExtractFileName(Files[I]));   // la clave del 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;   // la oferta más grande arriba
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // desempate
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata y SetPortfolioItemNumericMetadata lanzan una excepción cuando la clave del schema no está registrada o ningún archivo incrustado tiene la clave indicada, así que registre el schema, adjunte el archivo y después fije los valores. Su argumento FileName es la clave del name tree, que para AddDocumentAttachment es el nombre del archivo sin su directorio. Las claves son cadenas de bytes comparadas con distinción de mayúsculas (§7.9.6), igual que los named destinations del árbol /Dests, de modo que Civil.pdf no encontrará civil.pdf. Cuando dos archivos fuente comparten el nombre base, HotPDF conserva ambos y le da al segundo una clave numerada como hvac (2).pdf, que las llamadas de metadata deben usar a partir de entonces

¿Cómo acaba una oferta de 104,250 debajo de 9,800?

Se almacenó como texto. Las reglas de orden de la §12.3.5 dependen del tipo de dato — números numéricamente, fechas de la más vieja a la más nueva, cadenas de texto léxicamente, con la NOTA 3 dejando los detalles del orden léxico a cada lector. La Tabla 48 (Tabla 46 en la parte 2) exige que cada entrada /CI coincida con el /Subtype de su campo, así que "USD 104,250" escrito como texto en una columna N ya rompe la regla. El lector queda comparando cadenas: tras el "USD " compartido, el carácter 1 ordena antes que el 9, y un orden descendente deja la oferta más grande al final. SetPortfolioItemMetadata siempre escribe un /D de texto, correcto para campos S y cadenas de fecha. Para campos N, SetPortfolioItemNumericMetadata(FileName, Key, Value, Prefix, UseSubitemDictionary) escribe un número PDF y lleva el símbolo de moneda en /P, que el ordenamiento ignora. Guarde unidades y símbolos de moneda en el prefijo de cada columna numérica: un valor que solo se ve numérico en pantalla es el que ordena mal. Con prefijo vacío y UseSubitemDictionary en False, el número entra directo al /CI

HotPDF SetPortfolioItemNumericMetadata en Delphi escribe montos de PDF Portfolio como números con prefijo /P de USD, de modo que un orden descendente lista 104250 primero, a diferencia de cadenas de texto que dejan USD 104,250 debajo de USD 9,800
Guardada como cadena, la oferta de 104,250 cae al fondo de una columna Amount descendente; guardada como número con su moneda en /P, sube a la cima

Las fechas siguen la misma lógica. Un campo D espera una cadena de fecha PDF (§7.9.4) como D:20261031143000; un 31/10/2026 del locale de Windows es texto en una columna de fecha y cae donde le toquen sus caracteres. El ordenamiento en sí vive en el diccionario sort de la colección: /S es un nombre de campo o un arreglo donde cada nombre posterior rompe los empates que dejaron los anteriores, y /A es un booleano o un arreglo paralelo de booleanos, con entradas sobrantes ignoradas y faltantes leídas como true, o sea ascendente. SetPortfolioSortOrder toma un arreglo de records THPDFPortfolioSortKey y escribe la forma de nombre único para una clave y la de arreglo para varias. Use las claves registradas en el schema; el /S solo puede nombrar campos descritos en el diccionario de la colección padre

Las carpetas PDF 2.0 viven en los nombres de archivo

Una carpeta no guarda archivos dentro del PDF. ISO 32000-2 §12.3.5.2 define los diccionarios de carpeta como objetos indirectos enlazados en un solo árbol: /Folders apunta a la raíz, cada hijo lleva /Parent, una carpeta alcanza a su primer hijo vía /Child y los hermanos se encadenan por /Next. Toda carpeta tiene un /Name obligatorio y un entero no negativo /ID único en el documento, y el arreglo /Free de la raíz lista rangos de ID sin usar. La pertenencia se codifica en la clave EmbeddedFiles: una que abre con <, sigue con dígitos y cierra con > pertenece a la carpeta con ese ID, y el resto es el nombre del archivo, así que <3>invoice-1031.pdf es invoice-1031.pdf en la carpeta 3. La clave es la carpeta. Cualquier clave fuera de la convención pertenece a la raíz, que es como las facturas destinadas a una carpeta 2026 acaban en el nivel superior — adjuntadas con AddDocumentAttachment sencillo, sus claves no llevan prefijo alguno

HotPDF construye el árbol con tres llamadas. EnsureCollectionRootFolder(RootName, Description) crea la raíz con ID 0, o devuelve el ID de la raíz existente. AddCollectionChildFolder(ParentFolderID, FolderName, Description) añade un hijo, devuelve su nuevo ID y mantiene el /Free al día. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) incrusta el archivo bajo <FolderID>name y escribe el nombre desnudo en /F y /UF, que es lo que muestra una columna cfsFileName. Toda llamada posterior que nombre el archivo necesita la clave completa, incluido InitialDocument en ConfigurePortfolio y las llamadas de metadata. Las carpetas, el /Split y el /Colors son entradas de PDF 2.0, así que fije Version en pdf20 antes de BeginDoc

HotPDF carpetas de PDF Portfolio 2.0 en Delphi: EnsureCollectionRootFolder y AddCollectionChildFolder construyen el árbol de carpetas /Child y /Next, y AddDocumentAttachmentToCollectionFolder escribe claves EmbeddedFiles que llevan el prefijo de ID de carpeta
La pertenencia a carpetas en una portable collection PDF 2.0 se codifica en el prefijo de dígitos de cada clave EmbeddedFiles, y toda clave sin dígitos entre ángulos se queda en la raíz
procedure BuildHandoverPackage;
var
  Pdf: THotPDF;
  RootID, Year2025, Year2026, Q4: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Version := pdf20;          // /Folders, /Split y /Colors son 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);    // splitter al 30 % de la ventana
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

El orden entre hermanos es una recomendación de la norma: los hermanos deben seguir la clave de orden de la colección, o los nombres de carpeta si no la hay. AddCollectionChildFolder enlaza hermanos en orden de llamada, así que créelos ya ordenados, como hace el ejemplo con 2025 antes de 2026. La posición del /Split (ISO 32000-2 Tabla 158) es un porcentaje de 0 a 100, de ahí el 30 y no un 0.3. Las reglas de nombres posteriores a la Tabla 159 aplican a nombres de carpeta y a archivos dentro de carpetas, y como esos nombres suelen venir de usuarios o formularios de carga, fíltrelos antes de la llamada; AddCollectionChildFolder por sí mismo rechaza barras, caracteres de control y un nombre de hermano que repita uno existente cuando se ignora la mayúscula ASCII:

  • entre 1 y 255 caracteres, sin NUL incrustado
  • ninguno de / \ : * " < > | en el nombre
  • ningún punto como último carácter
  • ninguna dos carpetas hermanas, ni dos archivos en la misma carpeta, cuyos nombres difieran solo en mayúsculas

Leer un Portfolio escrito por otro productor

Un lector tiene que aceptar toda forma legal de /CI, porque los productores difieren: algunos envuelven cada valor en un diccionario subitem, otros escriben cadenas y números desnudos, y el Ejemplo 2 de la especificación mezcla ambas dentro de un mismo item. Un código que solo busca una cadena bajo un /D de subitem muestra columnas vacías para el segundo grupo. GetLoadedCollectionItemFields(FileName) devuelve un THPDFCollectionItemFieldValue por entrada con IsSubitemDictionary, IsNumeric, NumericValue, StringValue y Prefix llenos. GetLoadedCollectionInfo devuelve schema, claves de orden, split, colores y los nodos de carpeta de GetLoadedCollectionFolderTree en orden de árbol, cada uno con ID, Name, ParentID, FirstChildID y NextSiblingID. La carpeta de cada archivo sale de su clave, y el parseo debe seguir la regla al pie de la letra: solo dígitos entre los ángulos. Una clave como <draft>notes.txt, de un productor que no sabe nada de carpetas, no tiene dígitos ahí, así que el archivo pertenece a la raíz; cortar todo hasta el primer > inventaría una carpeta llamada 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 '>' nombre de archivo; cualquier otra clave es la raíz
  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;            // sin carpetas: una 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;

Salida esperada para el archivo de entrega: cuatro líneas de carpeta con la raíz en ID 0 y Q4 invoices bajo el padre 2, y luego [3] invoice-1031.pdf: amount=USD 10400 marcado como numérico. Corra el mismo ciclo sobre el paquete de ofertas y cada entrada amount volverá con IsNumeric activado; un monto que llegue solo en StringValue es el que ordenará 104,250 debajo de 9,800. Esa verificación cabe en unas pocas líneas junto al código que construye paquetes con el componente HotPDF para Delphi y C++Builder