Technical Article

Editar metadatos de un PDF cargado en Delphi sin reescribirlo

Tienes diez mil PDF de contratos procedentes de una docena de generadores distintos, y el departamento legal quiere que todos lleven el Author correcto, una cadena Producer corregida y un modo de lectura que abra el panel de marcadores al iniciarse. La solución ingenua es cargar cada archivo, volver a maquetar las páginas y escribir un documento nuevo. Haz eso y habrás tirado por la borda todos los números de objeto existentes, el historial de actualizaciones incrementales, cualquier firma digital y el xref cuidadosamente ajustado que emitió la herramienta original. Las páginas parecerán idénticas y el archivo será, estructuralmente, otro distinto. Para una edición de metadatos, ese es el peor trato posible.

Lo correcto es tratar el documento cargado como un grafo de objetos que mutas en su sitio: entra en el diccionario Info, en el flujo /Metadata y en el Catalog, cambia las pocas entradas que te interesan y vuelve a escribir el resultado. HotPDF, el componente PDF nativo de VCL para Delphi y C++Builder, expone exactamente esa superficie mediante su API de escritura sobre documentos cargados. Este artículo explica cómo usarla bien y también el error que comete casi todo el mundo: editar el diccionario Info y olvidar que existe una segunda copia de esos mismos metadatos en XMP.

Dos lugares guardan los mismos metadatos, y no siempre coinciden

PDF almacena la información del documento en dos ubicaciones paralelas, y esa es la raíz de la mayoría de los casos de "he cambiado el título pero Acrobat sigue mostrando el anterior". La primera es el diccionario de información del documento, el clásico /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 almacenado como un flujo colgando del Catalog bajo /Metadata, definido en §14.3.2 y construido sobre el modelo de datos XMP de Adobe.

Ambos pueden contener un título. Nada en la especificación obliga a que coincidan. Los visores modernos y la mayoría de validadores PDF/A prefieren el paquete XMP cuando está presente y recurren al diccionario Info cuando no lo está. Así que, si actualizas solo /Info, que es lo que hace la inmensa mayoría del código para "establecer metadatos PDF", un lector que confíe en XMP seguirá mostrando el valor antiguo, y un comprobador PDF/A señalará la discrepancia. La operación correcta en cualquier archivo que ya tenga un paquete XMP es una escritura doble, cambiar la entrada Info y regenerar el XMP para que ambos sigan siendo coherentes. HotPDF te da ambas mitades, pero la disciplina de usarlas juntas depende de ti.

Editar el diccionario Info

Los ayudantes de la parte Info son simples y predecibles. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator y SetLoadedProducer toman cada uno una única AnsiString y escriben la clave correspondiente en el diccionario Info cargado, sustituyendo el valor si la clave existe y añadiéndola si no existe. Para eliminar una clave por completo, por ejemplo un /Creator que delata tu herramienta interna, llama a RemoveLoadedInfoKey con el nombre de la clave a secas. Ninguno de estos métodos toca XMP, trabajan únicamente sobre el objeto /Info que LoadFromFile localizó al analizar 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, estos métodos reciben AnsiString. Para títulos ASCII eso no supone ningún problema, pero las cadenas PDF que necesiten caracteres no latinos deben codificarse como exige la especificación, UTF-16BE con un marcador de orden de bytes, o PDFDocEncoding, antes de pasárselas al componente. La biblioteca escribe los bytes que le entregas en un objeto de cadena, no adivina la codificación por ti. Si tus títulos están en inglés sencillo, esto no te afecta. Si llevan caracteres acentuados o CJK, codifícalos de forma explícita y pruébalos en un visor real.

Reescribir el paquete XMP

SetLoadedXMPMetadata es la otra mitad de la escritura doble. Pásale el paquete XMP completo como AnsiString y hará una de dos cosas: si el Catalog ya referencia un flujo /Metadata, reemplazará su contenido en el sitio, conservando el mismo número de objeto; si no existe ese flujo de metadatos, creará uno, lo marcará como /Type /Metadata y /Subtype /XML, asignará un número de objeto y lo enlazará desde el Catalog. En cualquiera de los casos acabarás con un objeto de metadatos válido que los visores podrán leer.

El XML lo aportas tú, así que controlas el esquema, dc:title, dc:creator, xmp:CreatorTool y demás. Eso concentra poder y responsabilidad en un solo punto, porque la biblioteca no analiza ni valida tu paquete, y además escribe los bytes sin comprimir, sin aplicar ningún filtro de flujo. Un paquete mal formado pasará la llamada sin problemas y más tarde acabará en una queja de metadatos dañados. Construye el XML con cuidado y refleja exactamente los valores que escribiste en el diccionario Info para que ambas vistas nunca entren en conflicto.

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, primero Info, después XMP y por último guardar, es el patrón que conviene interiorizar. Las dos llamadas son independientes, la coherencia solo existe porque les diste exactamente las mismas cadenas. Si omites la llamada a XMP en un archivo que ya trae un paquete XMP, vuelves al error de desactualización silenciosa que toda esta sección intenta evitar.

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
Los metadatos viven en dos sitios, el diccionario Info y el flujo XMP, además de las pistas de lectura a nivel Catalog y el árbol de esquema de marcadores. Una edición en el sitio toca cada uno sin reconstruir el documento.

Controlar cómo se abre el archivo

Tres entradas del Catalog deciden lo que ve el lector en el instante en que se abre el documento, y las tres son cambios de una línea sobre el grafo cargado. SetLoadedPageMode escribe /PageMode como un objeto nombre: pasa 'UseOutlines' para mostrar el panel de marcadores, 'UseThumbs' para la banda de miniaturas, 'FullScreen' para el modo de presentación o 'UseAttachments' para mostrar el panel de adjuntos (ISO 32000-1 §7.7.3.1, Table 28). SetLoadedPageLayout escribe /PageLayout del mismo modo, 'SinglePage', 'OneColumn', 'TwoColumnLeft' y los demás. Ambos toman el nombre sin barra inicial, la biblioteca la añade al generar la salida.

SetLoadedLanguage escribe la entrada /Lang del Catalog, la etiqueta de idioma natural del documento en su conjunto, 'en-US', 'de-DE', una etiqueta BCP 47. Fíjate en la diferencia de tipo que suele despistar: /PageMode y /PageLayout son objetos PDF de tipo name, mientras que /Lang es una string. HotPDF lo maneja correctamente por dentro, pero si alguna vez inspeccionas la salida verás /PageMode /UseOutlines frente a /Lang (en-US), y ahora ya sabes por qué. La entrada /Lang importa más de lo que parece: es lo que lee la tecnología de asistencia para elegir la pronunciación y es un requisito obligatorio para la conformidad de accesibilidad 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;

Renombrar marcadores sin tocar el árbol

Los títulos de los marcadores son una corrección rutinaria, un error tipográfico en un encabezado o un capítulo renumerado después de construir el esquema. SetLoadedOutlineTitle toma un índice basado en cero dentro de las entradas de esquema de nivel superior y un título nuevo, recorre la cadena Catalog → /Outlines/First/Next hasta esa posición y reemplaza la cadena /Title de la entrada. Solo cambia el título, el destino, el estado abierto o cerrado y la estructura hija permanecen intactos.

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

Renombrar es seguro precisamente porque nunca toca los contadores estructurales. Borrar una entrada de esquema es el caso peligroso, y merece la pena entenderlo incluso cuando solo estás renombrando, porque te dice qué no debes editar a mano. Cada nodo del esquema lleva un /Count y, según ISO 32000-1 §12.3.3, ese contador no es el número de hijos inmediatos. Es el número total de descendientes visibles: un /Count positivo de N significa que ahora mismo hay N descendientes expuestos, mientras que un valor negativo significa que el nodo tiene descendientes pero está contraído. Cuando se elimina una entrada de nivel superior, el contador raíz de /Outlines no puede simplemente decrementarse en uno, hay que recalcularlo sumando, para cada nodo de nivel superior que sobreviva, "uno por el propio nodo más su /Count positivo", omitiendo los descendientes de cualquier nodo contraído con contador negativo. Si te equivocas aquí, el total de marcadores que muestra el lector se desvía, y puede saltar más de uno por cada borrado. Renombrar evita todo esto, que es otra razón para preferir el ayudante específico en lugar de hurgar tú mismo en el diccionario.

Cómo la escritura se queda en su sitio

Toda la edición anterior muta objetos en memoria, nada llega al disco hasta que se ejecuta SaveLoadedDocument. La razón por la que este enfoque es barato es que la escritura no regenera el documento, preserva los números de objeto existentes y la estructura que HotPDF analizó al cargar, y devuelve el mismo grafo con el puñado de objetos modificados y los recién asignados. Eso es lo que evita que un paso de metadatos reescriba el archivo entero, y es la misma maquinaria de actualización en el sitio que hace funcionar los flujos de objetos y las actualizaciones incrementales. Si tus archivos de origen salen de Word o de otra suite ofimática, su disposición de objetos tiene peculiaridades propias que conviene conocer antes de editarlos, el artículo sobre flujos híbridos de referencias cruzadas en PDFs de Office explica cómo están estructurados esos archivos y qué sobrevive a una ida y vuelta.

Hay dos límites que conviene respetar. Primero, este es un modelo de edición en el sitio, no una herramienta de redacción ni de saneado: eliminar una clave Info elimina esa clave, pero no limpia valores anteriores que puedan seguir presentes en una generación previa de actualizaciones incrementales del mismo archivo. Si tu requisito es eliminar de verdad metadatos sensibles, eso es otra operación distinta y más costosa. Segundo, la escritura XMP es literal, la biblioteca confía en tu XML y no lo valida, así que para cualquier cosa destinada a PDF/A o a un validador estricto, genera el paquete a partir de una plantilla conocida y verifica la salida. Usado dentro de esos límites, la edición de metadatos en el sitio es la herramienta adecuada: corrige los pocos bytes que están mal y deja exactamente como los escribió el productor original el noventa y nueve por ciento del archivo que ya era correcto.

La API de escritura sobre documentos cargados que se muestra aquí se incluye con el HotPDF Component estándar para Delphi y C++Builder, junto con el conjunto completo de métodos para editar metadatos, esquemas de marcadores y el Catalog.