El mismo PDF puede reportar dos autores distintos. El diálogo de propiedades de un visor de escritorio muestra un nombre, un sistema de gestión documental que indexa XMP muestra otro, y ambos programas están leyendo el archivo correctamente. PDF almacena los metadatos del documento dos veces, y guarda marcadores, enlaces y notas en estructuras que nunca tocan el contenido de página, así que cualquiera de ellas puede estar mal mientras todas las páginas se renderizan perfectamente
El modelo de objetos de debajo (objetos indirectos, la tabla de referencias cruzadas, el trailer) queda tratado en el recorrido por la estructura de documentos PDF. Aquí el foco se queda en lo que cuelga del catálogo del documento
Cuatro entradas en el catálogo, cuatro subsistemas independientes
Todo lo que se comenta abajo se alcanza desde el catálogo del documento, el diccionario al que apunta la entrada /Root del trailer (ISO 32000-1 §7.7.2). Un catálogo que conecta todo ello se ve así:
1 0 obj
<< /Type /Catalog
/Pages 2 0 R
/Outlines 3 0 R
/Names << /EmbeddedFiles 4 0 R /Dests 6 0 R >>
/Metadata 5 0 R
>>
endobj
/Pages es el documento visible, y el único de los cuatro que el catálogo exige. /Outlines lleva al árbol de marcadores, /Metadata al stream XMP, y /Names al diccionario de nombres, que guarda archivos incrustados bajo /EmbeddedFiles y destinos con nombre bajo /Dests. Un lector que no encuentre ninguno de los tres opcionales sigue mostrando cada página, que es por lo que esta capa se degrada primero cuando un archivo pasa por herramientas que solo entienden páginas. Nada dentro de un objeto de página apunta de vuelta al esquema, al árbol /Dests o al stream XMP. Copie solo las páginas a un archivo nuevo y las tres se quedan atrás
Por qué un PDF lleva dos almacenes de metadatos
Un PDF lleva dos almacenes de metadatos porque el formato creó un segundo sin retirar el primero. El mecanismo original es el diccionario de información del documento (ISO 32000-1 §14.3.3, Tabla 317), referenciado por /Info en el trailer: entradas de texto plano para /Title, /Author, /Subject, /Keywords, /Creator, /Producer, más /CreationDate, /ModDate y /Trapped. ISO 32000-2 marca todo el diccionario como obsoleto salvo las dos entradas de fecha y apunta a los escritores hacia el stream de metadatos XMP
XMP (ISO 32000-1 §14.3.2) es un documento XML escrito en RDF, almacenado en un stream cuyo diccionario lleva /Type /Metadata /Subtype /XML. A diferencia del diccionario Info, un packet XMP está diseñado para ser encontrado y parseado por herramientas que no saben nada de sintaxis PDF:
5 0 obj
<< /Type /Metadata /Subtype /XML /Length 1235 >>
stream
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>A. Author</rdf:li></rdf:Seq></dc:creator>
<xmp:CreateDate>2026-06-16T10:46:27+08:00</xmp:CreateDate>
<xmp:CreatorTool>Reporting Service 4.2</xmp:CreatorTool>
<pdf:Producer>losLab PDF Library</pdf:Producer>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
endstream
endobj
Tres detalles de ese bloque deciden si el packet sobrevive al tooling real. Las instrucciones de procesamiento xpacket enmarcan el packet para que un scanner pueda localizarlo dentro de un stream de bytes arbitrario; suelte el <?xpacket end="w"?> de cierre y el archivo sigue abriendo, pero los scanners pierden su marcador de fin. Los tipos de valor también importan: dc:title es una alternativa de idioma y necesita rdf:Alt, mientras que dc:creator es una lista ordenada y necesita rdf:Seq. Escribir cualquiera de los dos como nodo de texto pelado es un error fácil. Los prefijos, en cambio, son solo convención; un parser se guía por el URI del namespace, así que un dc: ligado al URI equivocado es otra propiedad por completo
Las claves Info y sus contrapartidas XMP se emparejan como sigue. PDF/A-1 (ISO 19005-1 §6.7.3) declara el emparejamiento como regla de conformidad: cuando ambos almacenes están presentes, los valores deben ser equivalentes
| Diccionario Info | Propiedad XMP |
|---|---|
/Title | dc:title (rdf:Alt) |
/Author | dc:creator (rdf:Seq) |
/Subject | dc:description (rdf:Alt) |
/Keywords | pdf:Keywords |
/Creator | xmp:CreatorTool |
/Producer | pdf:Producer |
/CreationDate, /ModDate | xmp:CreateDate, xmp:ModifyDate |
Las ediciones a mano y las fusiones son donde ambos se desvían. Una fusión puede heredar el /Info de una entrada y el packet XMP de otra. La regla más segura es tratar XMP como la fuente de verdad, mantener /Info para los consumidores viejos y regenerar ambos desde un único conjunto de valores cada vez que uno cambie
Escribir ambos almacenes desde un conjunto de propiedades
HotPDF construye ambos almacenes desde las mismas propiedades del documento. Title, Author, Subject y Keywords van al /Info; cuando EnableXMPMetadata es True, EndDoc escribe además un packet XMP desde esos mismos valores, con dc:title como rdf:Alt y dc:creator como rdf:Seq, y lo enlaza desde el /Metadata del catálogo. EnableXMPMetadata toma False por defecto, y merece activarlo para todo lo que salga de sus manos: un archivo con solo /Info es invisible para toda herramienta que lea únicamente XMP. CustomXMP reemplaza el packet generado por su propio texto cuando necesite schemas extra
uses
System.SysUtils, Vcl.Graphics, HPDFDoc;
procedure BuildReport(const FileName: string);
var
PDF: THotPDF;
Chapter: THPDFDocOutlineObject;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.FileName := FileName;
// Un único conjunto de valores alimenta tanto /Info como el packet XMP
PDF.Title := 'Quarterly Report';
PDF.Author := 'A. Author';
PDF.Subject := 'Results for the third quarter';
PDF.Keywords := 'quarterly, results';
PDF.EnableXMPMetadata := True;
PDF.BeginDoc;
PDF.CurrentPage.SetFont('Arial', [fsBold], 20);
PDF.CurrentPage.TextOut(50, 50, 0, 'Chapter 1: Results');
Chapter := PDF.OutlineRoot.AddChild('Chapter 1: Results', 0, 50);
Chapter.Opened := True; // se guarda como un /Count positivo
PDF.AddPage;
PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
PDF.CurrentPage.TextOut(50, 50, 0, 'Introduction');
Chapter.AddChild('Introduction', 0, 50);
PDF.AddPage;
PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
PDF.CurrentPage.TextOut(50, 50, 0, 'Methodology');
Chapter.AddChild('Methodology', 0, 50);
PDF.EndDoc;
finally
PDF.Free;
end;
end;
Editar un archivo existente es distinto. Sobre un documento cargado, SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator y sus hermanos escriben la entrada del diccionario Info, y el stream XMP es un objeto aparte que usted reemplaza con SetLoadedXMPMetadata. Cambie el autor con una llamada y no con la otra, y SaveLoadedDocument produce exactamente el archivo de dos autores del párrafo inicial
El árbol de esquemas detrás del panel de marcadores
El panel de marcadores se reconstruye desde un árbol de diccionarios doblemente enlazado, el esquema del documento (ISO 32000-1 §12.3.3, Tablas 152 y 153). No existe ningún array de marcadores en ninguna parte del archivo; el visor reconstruye el panel siguiendo referencias desde el /Outlines del catálogo
8 0 obj % la raíz del esquema
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj % nivel superior: un capítulo
<< /Title (Chapter 1: Results)
/Parent 8 0 R /Count 2
/First 12 0 R /Last 15 0 R
/Dest [3 0 R /XYZ 0 792 0] >>
endobj
12 0 obj % primer hijo
<< /Title (Introduction)
/Parent 9 0 R /Next 15 0 R
/Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj % segundo hijo, último hermano
<< /Title (Methodology)
/Parent 9 0 R /Prev 12 0 R
/Dest [5 0 R /Fit] >>
endobj
Los invariantes se leen directamente de los enlaces. Cada item apunta de vuelta a su /Parent. Los hermanos se encadenan por /Prev y /Next; el primer hijo no tiene /Prev, el último no tiene /Next. Un padre nombra sus dos extremos con /First y /Last, y todo lo intermedio existe solo como un paso en el recorrido de hermanos. Rompa un enlace y los visores muestran lo que aún puedan alcanzar: un /Next desfasado acorta un capítulo, mientras que un /Last que discrepe del final de la cadena deja a distintos visores dibujando árboles distintos
/Count sorprende a la gente porque almacena estado del panel. En la raíz es el número de items visibles en todos los niveles. En un item, un valor positivo significa que el item está abierto y da el número de descendientes visibles; un valor negativo significa que está cerrado, y su magnitud es cuántos descendientes aparecerían al expandirlo. En la muestra la raíz tiene 3: un capítulo más sus dos hijos abiertos. Un generador que escriba cada /Count como un total positivo reabre en silencio cada rama que el autor colapsó
En HotPDF, OutlineRoot es un THPDFDocOutlineObject, y AddChild(Title, X, Y) añade un item, cablea sus enlaces /Parent, /Prev, /Next, /First y /Last, y lo apunta a la página actual como [page /XYZ left top 0]. La propiedad Opened decide el signo, y el escritor recalcula cada /Count desde el árbol terminado en EndDoc, así que fijar Opened después de añadir los hijos está bien. InsertBefore, InsertAfter y MoveTo reestructuran el árbol con la misma contabilidad
Destinos: donde aterriza realmente un clic
Marcadores y anotaciones de enlace se resuelven ambos a un destino (ISO 32000-1 §12.3.2, Tabla 151), y un destino es más que un número de página. Es un array que nombra un objeto de página y después un tipo de fit que le dice al visor cómo enmarcarlo. La bestia de carga es [page /XYZ left top zoom]. Cualquiera de los tres operandos puede ser null, que significa «deje lo que tuviera el lector», y un zoom de 0 significa lo mismo que null. Así que [page /XYZ null null null] cambia la página y nada más, que suele ser lo que un enlace de «ir a página» debería hacer
La familia de fits cambia posicionamiento exacto por resiliencia. [page /Fit] escala la página entera a la ventana, [page /FitH top] ajusta el ancho de la página con un borde superior dado, y [page /FitR left bottom right top] hace zoom a un rectángulo para llenar la vista. Estos calculan la escala desde la geometría de la página en el momento del clic; un /XYZ con zoom horneado lo conserva incluso después de redimensionar la página. Para una tabla de contenidos, /FitH en el borde superior de la sección es el valor por defecto mejor
Las coordenadas son donde la gente se quema. Los números de destino están en espacio de usuario por defecto, origen abajo a la izquierda e y creciendo hacia arriba (§8.3.2.3), mientras que los hábitos de maquetación en pantalla miden desde arriba. Las llamadas estilo canvas de HotPDF (TextOut, rectángulos de enlace, la X y la Y de AddChild) toman coordenadas top-down y las convierten por usted. RegisterNamedDestination escribe su Left y su Top en el array de destino tal cual, así que ya deben estar en espacio de usuario PDF:
// Llame entre BeginDoc y EndDoc, una vez existan las páginas 0..2
procedure AddContentsLinks(PDF: THotPDF);
var
R: TRect;
begin
// Destino con nombre 50 pt por debajo del principio de la página 1.
// Top está en espacio de usuario PDF, medido hacia arriba desde el borde inferior
PDF.CurrentPageNumber := 1;
PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
0, PDF.CurrentPage.Height - 50, 0, 0, 0);
// Entradas de índice en la página 0; los rectángulos usan coordenadas top-down
PDF.CurrentPageNumber := 0;
R := Rect(50, 175, 545, 209);
PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');
// YPos = -1 escribe [page /XYZ null null null]: cambia la página, conserva la vista
R := Rect(50, 225, 545, 259);
PDF.CurrentPage.AddGoToLink(R, 2, -1, 'Methodology');
R := Rect(50, 275, 545, 309);
PDF.CurrentPage.AddURILink(R, 'https://www.example.com', 'Project website');
end;
Pase el valor estilo pantalla 50 como Top y el enlace aterriza 50 puntos por encima del borde inferior, mostrando el pie en lugar del encabezado. Un destino con nombre vive en el árbol de nombres /Dests del catálogo y cada enlace se resuelve a través del nombre, así que con docenas de enlaces hacia los mismos capítulos es la llamada correcta. Mueva una sección y edita una entrada en /Dests; cada enlace que la nombre la sigue. El orden de páginas del que dependen esos destinos queda tratado en por qué importa el orden del árbol de páginas
Anotaciones: objetos interactivos fuera del stream de contenido
Una anotación se sienta en una página sin ser parte de su stream de contenido (ISO 32000-1 §12.5). Enlaces, notas adhesivas, resaltados, widgets de formulario, iconos de adjunto y sellos viven todos en el array /Annots de su página. Quite una entrada y la anotación desaparece, mientras el stream de contenido queda byte a byte igual
Toda anotación comparte un pequeño núcleo (Tabla 164): /Subtype nombra la clase, /Rect da el bounding box en espacio de usuario por defecto, y /Contents guarda el texto que hace de descripción alternativa que la tecnología de asistencia lee en voz alta. Las anotaciones de enlace (§12.5.6.5, Tabla 173) vienen en dos formas:
20 0 obj % enlace a un destino
<< /Type /Annot /Subtype /Link
/Rect [100 200 300 250]
/Border [0 0 0]
/Dest [5 0 R /XYZ null null null] >>
endobj
21 0 obj % enlace que ejecuta una acción
<< /Type /Annot /Subtype /Link
/Rect [50 50 200 100]
/Border [0 0 0]
/Contents (Project website)
/A << /Type /Action /S /URI /URI (https://www.example.com) >> >>
endobj
/Border [0 0 0] está ahí por una razón: el borde por defecto es [0 0 1], un marco de un punto que muchos visores dibujan alrededor de cada enlace. /Dest y /A se excluyen mutuamente, y el /S de la acción elige el comportamiento: /GoTo dentro de este archivo (§12.6.4.2), /GoToR para una página de otro PDF (§12.6.4.3), /URI para una dirección web (§12.6.4.7), /Launch para abrir una aplicación o archivo externo (§12.6.4.5). Deje /Launch fuera de los documentos que distribuya: los visores suelen bloquearlo o poner delante un aviso de seguridad, y PDF/A lo prohíbe sin más (ISO 19005-1 §6.6.1). El argumento Description de las llamadas de enlace de arriba rellena /Contents, y con el PDFUACompliance de HotPDF activado una descripción vacía lanza una excepción, porque PDF/UA-1 exige esa entrada en cada enlace
Las anotaciones de marcado como resaltados y notas, y las formas como /Square, añaden un giro: su tipo no fija su aspecto. Sin appearance stream (la entrada /AP, §12.5.5), cada visor dibuja su propia interpretación. ISO 32000-2 hace obligatorio el /AP para la mayoría de tipos de anotación, con Popup y Link entre las excepciones. Los adjuntos reutilizan la misma maquinaria: un stream de archivo incrustado envuelto en una file specification (§7.11), presentado como anotación /FileAttachment en una página o a través del árbol de nombres /EmbeddedFiles bajo el /Names del catálogo
Cómo comprueba esta capa antes de que un archivo se envíe
Dos comprobaciones cazan la mayor parte: clicar por el panel de marcadores y una muestra de enlaces en un visor real, y después leer los metadatos con un programa, porque por mucho que cliche nunca revelará un /Info que discrepe del XMP. Las llamadas de documento cargado de HotPDF cubren ambas mitades. GetLoadedOutlineTitlesDeep recorre la cadena /First y /Next en profundidad, el mismo recorrido que hace un visor, así que una rama cortada por un enlace desfasado también falta en su resultado. FindLoadedOutlineByTitle resuelve la página destino de un marcador y reporta -1 cuando el destino es un destino con nombre o no resuelve a una página
// StripXmlTags es una función auxiliar que usted añade; no es una llamada de biblioteca.
// GetLoadedXMPValue devuelve el marcado interno del elemento, así que dc:creator
// vuelve envuelto en etiquetas rdf:Seq y rdf:li
function StripXmlTags(const S: AnsiString): AnsiString;
var
I: Integer;
InTag: Boolean;
begin
Result := '';
InTag := False;
for I := 1 to Length(S) do
if S[I] = '<' then InTag := True
else if S[I] = '>' then InTag := False
else if not InTag then Result := Result + S[I];
Result := AnsiString(Trim(string(Result)));
end;
procedure CheckNavigationLayer(const FileName: string);
var
PDF: THotPDF;
Title, Author, Subject, Keywords, Creator, Producer, XmpAuthor: AnsiString;
Titles: THPDFAnsiStringArray;
I, PageIndex: Integer;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
if PDF.LoadFromFile(FileName) <= 0 then
raise Exception.Create('No readable pages');
if PDF.GetLoadedDocumentInfo(Title, Author, Subject, Keywords,
Creator, Producer) then
begin
XmpAuthor := StripXmlTags(PDF.GetLoadedXMPValue('dc', 'creator'));
if (XmpAuthor <> '') and (XmpAuthor <> Author) then
Writeln('Author mismatch: /Info="', Author, '" XMP="', XmpAuthor, '"');
end;
Titles := PDF.GetLoadedOutlineTitlesDeep;
Writeln(Length(Titles), ' bookmarks reachable');
for I := 0 to High(Titles) do
if PDF.FindLoadedOutlineByTitle(Titles[I], PageIndex, False, True)
and (PageIndex < 0) then
Writeln('No direct page target: ', Titles[I]);
finally
PDF.Free;
end;
end;
Un conteo de marcadores por debajo de lo que usted escribió apunta a una cadena de hermanos rota. Un -1 en un marcador que usa un destino con nombre es lo esperado, y FindLoadedOutlineByTitle se para en la primera coincidencia, así que los títulos duplicados reportan el destino del primero. GetLoadedXMPValue es una búsqueda de texto plano sobre el packet, y la comparación es byte a byte, así que con nombres no ASCII mire cómo vuelven ambos valores antes de fiarse de una discrepancia
Todo en esta capa es contabilidad de referencias, y la contabilidad es la parte que conviene entregar a una biblioteca. El HotPDF Delphi Component para Delphi y C++Builder escribe ambos almacenes de metadatos desde un conjunto de propiedades, hilvana los enlaces del esquema conforme usted añade items y adjunta anotaciones de enlace con sus acciones y descripciones; su API de documento cargado lee las mismas estructuras para comprobarlas