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:
| Entrada | Qué controla | ISO 32000-1 | ISO 32000-2 |
|---|---|---|---|
/View, /D | Vista 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 primero | Tabla 155 | Tabla 153 |
/Schema | Las columnas de la lista de ficheros | Tablas 156, 157 | Tablas 154, 155 |
/Sort | Campos y direcciones de ordenación | Tabla 158 | Tabla 156 |
/Colors | Paleta DeviceRGB sugerida para la disposición | ninguna | Tabla 157 |
/Split | Dirección y posición del separador | ninguna | Tabla 158 |
/Folders | Raíz del árbol de carpetas | ninguna | Tabla 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:
/Subtype | Constante de HotPDF | De dónde viene el valor |
|---|---|---|
| S, D, N | cfsText, cfsDate, cfsNumber | Tú, por fichero, en /CI |
| F, Desc | cfsFileName, cfsDescription | La especificación de fichero (/UF o /F, /Desc) |
| ModDate, CreationDate, Size | cfsModDate, cfsCreationDate, cfsSize | El /Params del fichero incrustado |
| CompressedSize (PDF 2.0) | cfsCompressedSize | El /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
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
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
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