Artículo técnico

Cómo guardan los PDF Portfolio columnas, orden y carpetas

Tres ofertas entran en un PDF Portfolio y la columna Amount descendente del viewer lista la de USD 104,250 la última, por debajo de USD 9,800 — los importes entraron como texto. Los valores de columna viven en la entrada /CI de cada fichero adjunto, y SetPortfolioItemNumericMetadata de HotPDF los escribe ahí como números PDF, así que el orden compara 104250 con 9800 en lugar 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, un lector conforme debe presentar el documento como una colección portable (el nombre que la especificación da a lo que Acrobat llama PDF Portfolio) y que cada fichero del árbol de nombres /Names /EmbeddedFiles es un miembro; los adjuntos fuera de ese árbol no. 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 fichero 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 ambas:

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 árbol de nombres del fichero que se muestra primeroTabla 155Tabla 153
/SchemaLas columnas de la lista de ficherosTablas 156, 157Tablas 154, 155
/SortCampos y direcciones de ordenaciónTabla 158Tabla 156
/ColorsPaleta DeviceRGB sugerida para la disposiciónningunaTabla 157
/SplitDirección y posición del separadorningunaTabla 158
/FoldersRaíz del árbol de carpetasningunaTabla 159 (§12.3.5.2)

Las páginas del contenedor propio siguen importando, porque son la portada. Cuando falta /D, el documento inicial es el propio contenedor; ISO 32000-2 añade que un /D que nombra un fichero inexistente hace que el lector seleccione el primer fichero de la lista. Un viewer sin soporte de portfolios ignora /Collection y muestra solo esas páginas, y la NOTA 2 de §12.3.5 pide ahí un título y un párrafo introductorio; la respuesta habitual es una página que dice qué contiene el paquete y que necesita un viewer con soporte de portfolios. ConfigurePortfolio(Name, View, InitialDocument) fija la vista y /D en una llamada, y ConfigurePortfolioView cambia después entre pvmDetails, pvmTile y pvmHidden. La vista C necesita un diccionario navigator (ISO 32000-2 Tabla 160); para un paquete de ofertas o un traspaso, la vista de detalles con una ordenación sensata 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 elijas. La mayor parte de un campo es presentación — /N es la cabecera, /O la posición de columna, /V y /E la visibilidad y editabilidad iniciales, pasados a THotPDF.AddPortfolioSchemaField como DisplayName, Order, Visible y Editable. /Subtype es la decisión con consecuencias: dice quién aporta el valor:

/SubtypeConstante de HotPDFDe dónde viene el valor
S, D, NcfsText, cfsDate, cfsNumberTú, por fichero, en /CI
F, DesccfsFileName, cfsDescriptionLa especificación de fichero (/UF o /F, /Desc)
ModDate, CreationDate, SizecfsModDate, cfsCreationDate, cfsSizeEl /Params del fichero incrustado
CompressedSize (PDF 2.0)cfsCompressedSizeEl /Length del stream incrustado

Los valores por fichero viven en el diccionario de item de colección al que apunta la entrada /CI de la especificación de fichero (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 de subitem de colección (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 orden ignora. El Example 2 de la propia especificación muestra para qué existe el prefijo: un asunto de correo guardado como /D (Let's have lunch on Friday!) con /P (Re:), para que una respuesta ordene junto al mensaje que contesta. Curiosamente, ambas ediciones dicen que el schema de ese ejemplo está en §12.4.2, Page Labels; se refieren al Example 1, justo encima. En cuanto a las columnas ligadas al fichero, AddDocumentAttachment estampa las fechas de /Params con el momento de incrustación, así que una columna cfsModDate muestra cuándo se construyó el paquete. Los revisores que necesiten la fecha del fichero original reciben una columna D que rellenas tú, como received más abajo

Schema de un PDF Portfolio de HotPDF en Delphi: los subtypes de AddPortfolioSchemaField cfsFileName, cfsText, cfsNumber, cfsDate y cfsSize mapeados a la especificación de fichero, el diccionario de item de colección /CI y el /Params del fichero incrustado
Las columnas de texto, fecha y número toman sus valores de la entrada /CI de cada fichero, mientras que las columnas de nombre, descripción, fecha y tamaño vienen de estructuras escritas al incrustar el fichero
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 viewer 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 árbol de nombres
      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 mayor arriba
    SortKeys[1].Key := 'trade';
    SortKeys[1].Ascending := True;    // desempate
    Pdf.SetPortfolioSortOrder(SortKeys);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

SetPortfolioItemMetadata y SetPortfolioItemNumericMetadata lanzan excepción si la clave del schema no está registrada o si ningún fichero incrustado tiene la clave dada, así que registra el schema, adjunta el fichero y luego fija los valores. Su argumento FileName es la clave del árbol de nombres, que para AddDocumentAttachment es el nombre del fichero sin su directorio. Las claves son cadenas de bytes comparadas con mayúsculas y minúsculas distintas (§7.9.6), igual que los destinos con nombre del árbol /Dests, así que Civil.pdf no encontrará civil.pdf. Cuando dos ficheros origen comparten nombre base, HotPDF conserva ambos y 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 por debajo de 9,800?

Se guardó como texto. Las reglas de ordenación de §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, y al lector no le queda más que comparar cadenas: tras el "USD " compartido, el carácter 1 ordena antes que el 9, y un orden descendente pone la oferta mayor la última. SetPortfolioItemMetadata siempre escribe un /D de texto, correcto para campos S y para 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 orden ignora. Guarda las unidades y los símbolos de moneda en el prefijo de cada columna numérica: el valor que solo parece numérico en pantalla es el que ordena mal. Con prefijo vacío y UseSubitemDictionary a False, el número entra directo en /CI

SetPortfolioItemNumericMetadata de HotPDF en Delphi escribe importes de PDF Portfolio como números con un prefijo /P de USD, así que un orden descendente lista 104250 primero, al contrario que las cadenas de texto que ponen USD 104,250 por debajo de USD 9,800
Guardada como cadena, la oferta de 104,250 aterriza 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 aterriza donde caigan sus caracteres. La ordenación en sí vive en el diccionario sort de la colección: /S es un nombre de campo o un array donde cada nombre posterior desempata lo que dejaron los anteriores, y /A es un booleano o un array paralelo de booleanos, con las entradas sobrantes ignoradas y las ausentes leídas como true, es decir ascendente. SetPortfolioSortOrder toma un array de registros THPDFPortfolioSortKey y escribe la forma de nombre único para una clave y la de array para varias. Usa las claves que registraste en el schema; /S solo puede nombrar campos descritos en el diccionario de colección padre

Las carpetas de PDF 2.0 viven en los nombres de fichero

Una carpeta no guarda ficheros 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 llega a su primer hijo por /Child y los hermanos se encadenan por /Next. Toda carpeta tiene un /Name obligatorio y un /ID entero no negativo único en el documento, y el array /Free de la raíz lista los rangos de ID aún sin usar. La pertenencia se codifica en la clave de EmbeddedFiles: una que empieza con <, sigue con dígitos y cierra con > pertenece a la carpeta con ese ID, y el resto es el nombre del fichero, 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 cómo las facturas destinadas a una carpeta 2026 acaban en el nivel superior — adjuntadas con AddDocumentAttachment a secas, 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 al final, devuelve su nuevo ID y mantiene /Free al día. AddDocumentAttachmentToCollectionFolder(FolderID, FileName, Description) incrusta el fichero 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 fichero necesita la clave completa, incluidos el InitialDocument de ConfigurePortfolio y las llamadas de metadata. Las carpetas, /Split y /Colors son entradas de PDF 2.0, así que pon Version a pdf20 antes de BeginDoc

Carpetas de un PDF Portfolio 2.0 con HotPDF en Delphi: EnsureCollectionRootFolder y AddCollectionChildFolder construyen el árbol de carpetas /Child y /Next, y AddDocumentAttachmentToCollectionFolder escribe claves de EmbeddedFiles con el prefijo de ID de carpeta
La pertenencia a carpetas en una colección portable PDF 2.0 se codifica en el prefijo de dígitos de cada clave de EmbeddedFiles, y cualquier 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 de 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);    // separador al 30 % de la ventana
    Pdf.SetPortfolioColor('Background', 0.96, 0.97, 0.98);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

El orden de los hermanos es una recomendación de la norma: deberían seguir la clave de orden de la colección, o los nombres de carpeta cuando no la hay. AddCollectionChildFolder enlaza a los hermanos en orden de llamada, así que créalos ordenados, como hace el ejemplo con 2025 antes de 2026. La posición de /Split (ISO 32000-2 Tabla 158) es un porcentaje de 0 a 100, de ahí el 30 y no el 0.3. Las reglas de nombres posteriores a la Tabla 159 aplican a los nombres de carpeta y a los ficheros dentro de carpetas, y como esos nombres suelen venir de usuarios o formularios de subida, filtra antes de la llamada; AddCollectionChildFolder se niega por sí misma a barras, caracteres de control y un nombre de hermano que repita uno existente si se ignoran mayúsculas y minúsculas:

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

Leer un Portfolio escrito por otro productor

Un lector tiene que aceptar todas las formas legales de /CI, porque los productores difieren: algunos envuelven cada valor en un diccionario de subitem, otros escriben cadenas y números desnudos, y el Example 2 de la especificación mezcla ambas dentro de un mismo item. Un código que solo busca una cadena bajo el /D de un subitem muestra columnas vacías para el segundo grupo. GetLoadedCollectionItemFields(FileName) devuelve un THPDFCollectionItemFieldValue por entrada con IsSubitemDictionary, IsNumeric, NumericValue, StringValue y Prefix rellenados. 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 fichero viene 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, venida de un productor que no sabe nada de carpetas, no tiene dígitos ahí, así que el fichero 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 fichero; 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;

Esperado para el fichero de traspaso: cuatro líneas de carpetas con la raíz en ID 0 y Q4 invoices bajo el padre 2, y después [3] invoice-1031.pdf: amount=USD 10400 marcado como numérico. Ejecuta el mismo bucle sobre el paquete de ofertas y cada entrada amount vuelve con IsNumeric activado; un importe que llega solo en StringValue es el que ordenará 104,250 por debajo de 9,800. Esa comprobación cabe en unas líneas junto al código que construye paquetes con el componente HotPDF para Delphi y C++Builder