Artículo técnico

Editar metadatos de un PDF cargado en Delphi sin reescribir

Tienes diez mil PDFs de contratos de una docena de generadores distintos y legal necesita que cada uno muestre el Author correcto, una cadena Producer corregida y un modo de lectura que abra el panel de marcadores al iniciar. La solución más fácil suele ser cargar cada archivo, reconstruir las páginas y escribir un documento nuevo. Si lo haces, descartas cada número de objeto existente, el historial de actualizaciones incrementales, cualquier firma digital y la xref afinada que emitió la herramienta original. Las páginas se ven igual, pero estructuralmente el archivo queda como un documento ajeno. Para editar metadatos, ese intercambio es el menos recomendable

La opción correcta es tratar el PDF cargado como un grafo de objetos que se actualiza en el mismo lugar. Accedes al diccionario Info, al stream /Metadata y al Catalog, cambias las entradas necesarias y escribes el resultado de vuelta. HotPDF, el componente VCL nativo de PDF para Delphi y C++Builder, expone exactamente esa superficie con su API de escritura para documentos cargados. Este artículo explica cómo usarla bien y el error que casi todos cometen: editar Info y olvidar que una segunda copia de esos metadatos vive en XMP

Dos lugares guardan el mismo metadato y no coinciden

PDF guarda la información del documento en dos ubicaciones paralelas, y eso está detrás de casi todos los casos de “cambié el título y Acrobat sigue mostrando el anterior”. La primera es el diccionario de información, el clásico objeto /Info con las claves /Title, /Author, /Subject, /Keywords, /Creator y /Producer, definido en ISO 32000-1 §14.3.3. La segunda es un paquete XMP, un documento XML guardado como stream bajo /Metadata del Catalog, definido en §14.3.2 y basado en el modelo de datos de Adobe XMP

Ambos pueden contener un título. La norma no exige que coincidan. Los visores modernos y la mayoría de validadores PDF/A priorizan el paquete XMP cuando existe y usan Info cuando no existe. Si solo actualizas /Info , que es lo que hace la mayoría del código de “establecer metadatos de PDF”, un lector que confía en XMP seguirá mostrando el valor viejo y un validador PDF/A marcará la diferencia. La operación correcta en cualquier archivo con XMP ya existente es escribir en ambos lugares: cambia la entrada Info y regenera XMP para mantener ambos consistentes. HotPDF te da ambas capacidades; la disciplina de usar ambas es tu responsabilidad

Editar el diccionario Info

Los helpers de Info son simples y predecibles. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator y SetLoadedProducer reciben un solo AnsiString y escriben la clave correspondiente en el diccionario Info cargado, reemplazando el valor si ya existe o agregándolo si no existe. Si quieres eliminar una clave completa, por ejemplo un /Creator que exponga tu herramienta interna, llama RemoveLoadedInfoKey con el nombre de la clave. Ninguno de estos métodos toca XMP; solo modifican el objeto /Info localizado por LoadFromFile al leer el archivo

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Un detalle importante: todos reciben AnsiString. Para títulos en ASCII no hay problema, pero los textos de PDF que contengan caracteres no latinos deben codificarse como pide la especificación, UTF-16BE con marca de orden de bytes o PDFDocEncoding, antes de pasarlos. La librería escribe los bytes que recibe en un objeto string y no infiere la codificación. Si tus títulos están en inglés normal puedes ignorarlo; si incluyen acentos o caracteres CJK, codifícalos de forma explícita y pruébalo en un visor real

Reescribir el paquete XMP

SetLoadedXMPMetadata es la otra mitad. Recibe el paquete XMP completo como AnsiString y hace una de dos cosas: si el Catalog ya referencia un stream /Metadata, reemplaza el contenido de ese stream en el mismo objeto, manteniendo su número; si no existe stream de metadatos, crea uno, lo marca como /Type /Metadata y /Subtype /XML, asigna un número de objeto y lo enlaza desde el Catalog. En ambos casos terminas con un objeto de metadatos válido que los visores pueden leer

Tú generas el XML, por eso tú controlas el esquema: dc:title, dc:creator, xmp:CreatorTool y similares. Ahí está la potencia y la responsabilidad: la librería no interpreta ni valida tu paquete, y escribe los bytes sin compresión, sin filtro de stream aplicado. Si el paquete está mal formado, la llamada no falla allí y el problema aparece después como reclamo de metadatos rotos. Construye el XML con cuidado y repite exactamente los mismos valores usados en Info para que las dos vistas no entren en contradicción

const
  XMP_TEMPLATE =
    '<?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/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

Ese orden es la clave: primero Info, luego XMP, y después guardar. Las dos llamadas son independientes, la consistencia solo existe porque les pasas los mismos valores. Si omites XMP en un archivo que ya tiene un paquete XMP, vuelves al error de metadatos silenciosamente desactualizados que explica esta sección

Diagrama donde se ve un diccionario Info y un stream XMP con el título y el autor, editados en su lugar junto al árbol de marcadores
Los metadatos viven en dos sitios, el diccionario Info y el stream XMP, además de las pistas de lectura del nivel de Catalog y del árbol de marcadores. Una edición in-place toca cada uno sin reconstruir el documento.

Controlar cómo se abre el visor

3 entradas del Catalog determinan lo que ve un lector en el instante de abrir el documento, y las tres se cambian en una sola línea sobre el grafo cargado. SetLoadedPageMode escribe /PageMode como un nombre: pasa 'UseOutlines' para abrir el panel de marcadores, 'UseThumbs' para la barra de miniaturas, 'FullScreen' para presentación o 'UseAttachments' para mostrar adjuntos, según ISO 32000-1 §7.7.3.1, Tabla 28. SetLoadedPageLayout escribe /PageLayout de la misma forma, con 'SinglePage', 'OneColumn', 'TwoColumnLeft' y más opciones. Ambos toman el nombre sin la barra inicial, la librería la agrega al guardar

SetLoadedLanguage escribe la entrada /Lang del Catalog, que es la etiqueta de idioma del documento: 'en-US', 'de-DE' o cualquier etiqueta BCP 47. Aquí hay un detalle importante: /PageMode y /PageLayout son objetos de tipo name, mientras que /Lang es una string. HotPDF lo maneja correctamente internamente, pero si inspeccionas el resultado verás /PageMode /UseOutlines junto a /Lang (en-US), y ahora sabes por qué. La entrada /Lang pesa más de lo que parece: es lo que la tecnología asistiva usa para decidir pronunciación, y es un requisito fuerte para cumplir con PDF/UA

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

Cambiar nombres del árbol de marcadores sin tocar su estructura

Los títulos de marcador suelen requerir ajustes rutinarios, como corregir un error tipográfico o renumerar un capítulo ya construido. SetLoadedOutlineTitle recibe un índice base 0 para entradas de nivel superior del árbol, y el texto nuevo. Recorre la cadena Catalog → /Outlines/First/Next hasta esa posición y reemplaza /Title de la entrada. Solo cambia el título; el destino, el estado abierto o cerrado y los hijos no se alteran

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

Eso es seguro porque no toca contadores estructurales. Borrar una entrada de marcadores sí es delicado y conviene entenderlo, incluso si solo cambias nombres, para saber qué no debes editar a mano. Cada nodo de marcador trae un /Count y por ISO 32000-1 §12.3.3 ese número no es solo la cantidad de hijos inmediatos. Es el total de descendientes visibles: un /Count positivo de N significa que hay N descendientes expuestos, y un valor negativo que el nodo tiene descendientes pero está colapsado. Al quitar una entrada superior, el contador raíz de /Outlines no puede disminuir solo en uno; hay que recalcularlo sumando, para cada nodo de nivel superior que queda, uno por el nodo más su /Count positivo, ignorando los descendientes de nodos colapsados. Si te equivocas, el total de marcadores que muestra un lector deriva y puede saltar de uno por eliminación. Renombrar evita esto, por eso conviene usar el helper dedicado en lugar de editar manualmente el diccionario

Cómo se conserva la actualización sin reconstrucción

Cada cambio anterior muta objetos en memoria y nada llega al disco hasta ejecutar SaveLoadedDocument. Este enfoque es eficiente porque el guardado no regenera el documento, conserva los números de objeto y la estructura parseada por HotPDF al cargarlo, y escribe el mismo grafo con los pocos objetos editados o nuevos que añadiste. Así la edición de metadatos no reescribe todo el archivo y usa el mismo mecanismo de actualización in-place que permite que funcionen flujos de objetos y actualizaciones incrementales. Si tus fuentes vienen de Word u otra suite ofimática, su estructura de objetos tiene particularidades importantes antes de editarla; el artículo sobre flujos de referencias cruzadas y actualizaciones incrementales en PDFs de Office explica cómo se organizan y qué sobrevive a una pasada

Hay dos límites claros. Primero, esto es un modelo de edición in-place, no una herramienta de redacción o sanitización: quitar una clave de Info elimina esa clave, pero no limpia valores antiguos que puedan seguir en una generación incremental previa del mismo archivo. Si necesitas eliminación real de metadatos sensibles, esa operación es distinta y más pesada. Segundo, la escritura XMP es literal; la librería confía en tu XML y no lo valida, así que para PDFs destinados a PDF/A o un validador estricto usa una plantilla confiable y verifica la salida. Dentro de estos límites, editar metadatos en sitio es el ajuste adecuado: corrige solo los bytes equivocados y deja intacto el 99 por ciento del archivo que ya era correcto según el productor original

La API de escritura para documentos cargados mostrada aquí se incluye en el HotPDF Component estándar para Delphi y C++Builder, junto con el conjunto completo de métodos de metadata, marcadores y edición de Catalog