Artículo técnico

Por qué un guardado PDF no-op puede corromper Info y XMP

Un guardado de PDF que no cambia nada puede corromper igualmente los metadatos del documento de dos formas: cuando /CreationDate y /ModDate referencian el mismo objeto de cadena, una actualización automática de ModDate que escribe en ese objeto reescribe ambas, y cuando el objeto XMP se crea antes de leer el stream /Metadata original, un packet por defecto sustituye al original. PDF Library for Delphi reemplaza referencias de diccionario en lugar de mutar objetos compartidos, y captura el packet existente antes de la inicialización lazy de XMP

El guardado es la operación menos interesante que hace una biblioteca PDF: cargar un archivo, guardarlo con otro nombre, no tocar nada en medio. Las páginas renderizan idénticas antes y después, y los hashes de los content streams coinciden. Un archivo puede pasar todas esas comprobaciones y aun así estar mal en dos sitios que ningún renderizador te enseñaría jamás. Ambas trampas se sientan en el camino read-modify-write por el que pasa toda edición real, así que cualquier guardado basta para dispararlas, y solo un segundo parser independiente que compare la semántica no visual de los dos archivos las revela

¿Por qué guardar un PDF cambia su CreationDate?

Porque al diccionario de información del documento se le permite referenciar un mismo objeto de cadena indirecto desde dos claves, y un writer que actualiza el objeto en lugar de la clave cambia ambas. La ISO 32000-1 §7.3.10 deja que cualquier valor de diccionario sea una referencia indirecta, y nada en la §14.3.3 Tabla 317 dice que el valor bajo /CreationDate deba ser un objeto distinto del valor bajo /ModDate. Un producer que escribió el mismo timestamp dos veces en la creación puede, perfectamente legalmente, apuntar ambas claves a un único 2728 0 R, y archivos reales hacen exactamente eso

El disparador es la fecha de modificación automática. Salvo que UserModDate esté activo, SaveToFile llama a SetInfo('ModDate', ...) con la hora actual antes de escribir, que llega a SetRawInfo. Un SetRawInfo ingenuo busca el objeto bajo la clave y, si encuentra un TPDFString, le llama SetTo. Eso es una escritura in situ sobre el objeto al que la clave resuelva en ese momento, y cuando ese objeto es compartido, /CreationDate pasa a reportar también la hora del guardado. El documento sigue abriendo, imprimiendo y renderizando píxel a píxel igual que antes, así que una suite de regresión visual pasa sin pestañear

Mutación de cadena Info compartida en PDFlibPas: /CreationDate y /ModDate referencian legalmente un mismo objeto de cadena 2728 0 R, un SetTo in situ sobre lo que resuelva la clave reescribiría ambas fechas con la hora del guardado, así que SetRawInfo añade una cadena fresca bajo la clave conservando el modo de cadena hex
Actualizar una entrada de diccionario reemplaza la referencia de esa entrada en lugar de mutar el objeto compartido, así que una escritura automática de ModDate no puede cambiar CreationDate, y el objeto desplazado se conserva para otras referencias
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate, 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

La regla en TPDFDocument.SetRawInfo es pequeña y el principio que hay detrás es general: actualizar una entrada de diccionario reemplaza «that entry's reference» —la referencia de esa entrada—, nunca el objeto al que resultara resolver. El código lee el TPDFStringMode existente para que una cadena hex siga siendo hex y una literal siga siendo literal, y luego añade una cadena fresca de FStructure.NewString(Value, StringMode) bajo la clave. Otros dos detalles importan tanto. Una entrada con valor de stream se reemplaza sin limpiar antes el stream viejo, porque un SetTo('') sobre él vaciaría el valor para cualquier otra clave que siga apuntando a ese stream. Y el objeto desplazado no se borra, porque lo posee la estructura y otras referencias pueden necesitarlo todavía

// Ingenuo: mutar el objeto al que la clave resuelva ahora mismo
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Correcto: conservar la representación, reemplazar solo la referencia de esta clave
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Un test para esto debería construir el aliasing deliberadamente en lugar de fiarse de un archivo que comparta objetos de casualidad: una cadena hex referida desde ambas claves de fecha, una cadena directa compartida por /Title y /Subject, un stream compartido por /Author y /Keywords. Tras actualizar una clave de cada par, la otra debe seguir leyendo su valor original y la cadena actualizada debe seguir siendo hex. La referencia pública de SetInformation declara la garantía en una frase: actualizar un campo Info reemplaza solo ese campo, incluso cuando otros campos referencian el mismo objeto

¿Por qué un packet XMP existente acaba sustituido por los valores por defecto?

Por el orden de dos líneas. TPDFDocument.GetMetadata tiene un camino rápido: cuando el campo XMP ya está asignado, devuelve XMP.SaveToString en lugar de decodificar el stream /Metadata del catálogo. Una inicialización lazy escrita como XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); se lee natural y está mal: para cuando corre GetMetadata, XMP ya está asignado, así que la «fuente» que se carga es el packet por defecto serializado de un objeto creado una línea antes. El packet original, con su dc:creator, sus namespaces personalizados y su identificación de estándares, nunca llega al objeto y se sobreescribe al guardar. La misma fecha de modificación automática basta para dispararlo, porque SetInfo inicializa XMP antes de tocar el diccionario Info para que xmp:ModifyDate vaya a la par con /ModDate. Fíjate en lo que esta trampa esconde detrás: la comparación del diccionario Info de la primera pasa, porque /Author y /Title de /Info no se tocan. Solo cambió el árbol XMP, y solo una comprobación que parsee y compare ese árbol lo nota

Orden de inicialización lazy de XMP en PDFlibPas: crear el objeto XMP antes de llamar a GetMetadata hace que el camino rápido serialice un packet por defecto y pierda dc:creator, namespaces personalizados e identificación de estándares, mientras que capturar Source antes de TPDFlibXMP.Create carga el stream /Metadata original del catálogo
Cualquier guardado puede disparar el cambio porque SetInfo inicializa XMP para mantener xmp:ModifyDate a la par con /ModDate, así que toda inicialización lazy del documento pasa por un único EnsureXMP que captura el packet existente antes de crear el objeto
// Mal: GetMetadata ahora serializa el objeto creado en la línea anterior
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Bien: captura primero el stream /Metadata, luego crea y carga
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

PDF Library for Delphi mantiene ese orden en un único sitio. TPDFDocument.EnsureXMP captura Source := GetMetadata antes de TPDFlibXMP.Create, y toda inicialización lazy del documento pasa por él: SetInfo, SetXMPInformation, GetXMPInformation, los setters de modos PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR y PDF/UA, y el camino de reparación de metadatos. Los puntos de entrada públicos como SetXMPProperty también pasan por EnsureXMP, y GetXMPProperty lee a través de GetDocumentMetadata, así que toda la superficie comparte un orden de inicialización. Una única copia correcta de una secuencia de tres líneas vale más que diez copias que hoy por hoy coinciden de casualidad

Dos trampas menores en el mismo camino

El serializador XMP en Windows usa el writer XML de la plataforma, que emite una declaración XML que el packet no debe llevar. La forma obvia de quitarla, borrar caracteres hasta llegar a <?xpacket, falla con entrada válida: la ISO 16684-1 §7.3.2 hace opcional el wrapper xpacket, y un producer que escribe un elemento <x:xmpmeta> desnudo está dentro del estándar, así que en tal packet ese bucle borra el documento entero, válido. El serializador localiza el ?> de cierre de la declaración y elimina solo eso. Una comprobación de retención digna de copiar corre dos veces, una con el wrapper y otra con él cortado, y afirma que un marcador de namespace personalizado y el autor original sobreviven a SetInfo, GetMetadata, SaveToString y una recarga. La segunda trampa es un símbolo de preprocesador. NOVCL se activa en builds Free Pascal, pero el backend XMP depende del sistema operativo, no del framework, ya que PDFlibXMP.pas solo define NO_XMP cuando OS_WINDOWS falta. Guardar la sincronización Info-a-XMP en SetInfo con NOVCL dejaría un build Lazarus en Windows con un objeto XMP funcional y un SetInfo que se salta actualizarlo en silencio. El guardia es NO_XMP, así que una aplicación Free Pascal en Windows recibe la misma sincronización que Delphi

¿Cómo conservas el ModDate original en un guardado de paso?

Activa KeepModDate en TPDFlibSaveOptions y guarda a través de SaveToFileOptions. La opción activa UserModDate durante la llamada, y SaveToFile se salta entonces el timestamp automático, que es también el paso que inicializa lazy el objeto XMP. Un documento cuyos metadatos nunca tocaste, y para el que no se activó ningún modo de compliance, conserva tanto su diccionario Info como su stream /Metadata tal como se cargaron. Llamar a SetInformation(8, ...) tiene el mismo efecto de forma permanente, porque fijar tú mismo la fecha de modificación la marca como controlada por el usuario

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // sin /ModDate automático, sin init lazy de XMP
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Sé honesto con lo que esto te compra. KeepModDate es la elección correcta para un paso de paso cuyo resultado debe describir la misma revisión que su entrada, y es la elección equivocada para cualquier cosa que edite contenido de verdad, porque la §14.3.3 espera que /ModDate refleje la modificación más reciente. Tampoco sustituye a un manejo correcto de referencias, ya que solo evita la única escritura que dispara la actualización automática. Reemplazar referencias en lugar de mutar objetos compartidos, y capturar el packet antes de la inicialización, es lo que hace seguro un guardado ordinario; la opción es lo que hace honesto un no-op deliberado

¿Cómo verificas que un guardado no cambió nada salvo el ModDate?

Ni con píxeles ni con hashes de streams, porque ambas trampas dejan cada página y cada content stream byte a byte idénticos. La comprobación que los caza es una instantánea de semántica no visual tomada por un parser independiente, uno que no comparte código con la biblioteca bajo test, del archivo fuente y del archivo guardado, seguida de una comparación estructural. La instantánea cubre el diccionario Info con /ModDate excluido, el árbol de outline con cada bookmark resuelto a número de página en lugar de número de objeto, los destinos con nombre y los destinos de enlaces resueltos igual, los valores de campos de formulario, los bytes de adjuntos como hashes, y el packet XMP parseado como árbol en lugar de comparado como texto. Los números de objeto deliberadamente no forman parte, porque un full rewrite los renumera todos y una comparación apoyada en ellos reportaría ruido

Verificación semántica no visual de guardados de PDFlibPas: un parser independiente sin código compartido toma instantánea del diccionario Info menos /ModDate, páginas de outline y destinos, valores de formulario, hashes de adjuntos y árbol XMP, luego compara fuente contra archivo guardado descartando /ModDate, xmp:ModifyDate y xmp:MetadataDate como cambios esperados
Los píxeles y los hashes de streams se mantienen byte a byte idénticos a través de ambas trampas, así que la comparación trabaja sobre semántica resuelta y no sobre números de objeto, y los metadatos que sobreviven se reportan honestamente como retenidos, no como válidos para el schema ni conformes a PDF/UA y PDF/A

Las exclusiones importan tanto como las inclusiones. /ModDate, xmp:ModifyDate y xmp:MetadataDate se espera que cambien y se descartan antes de comparar; un archivo cuyo fuente no llevaba nada de XMP no se penaliza por ganar un packet. Lo que la comprobación no afirma es igualmente explícito: retener un packet existente no dice nada sobre si ese packet es válido para el schema o sobre si el documento cumple PDF/UA o alguna parte de PDF/A. Son preguntas separadas con herramientas separadas, y confundir «los metadatos sobrevivieron» con «los metadatos son conformes» es cómo una reescritura de fecha compartida puede quedarse escondida mucho tiempo

Si trabajas al nivel de debajo de estas reglas, la mecánica de cómo un guardado reescribe objetos está cubierta en actualizaciones incrementales y guardado append-only, que es el único modo de guardado donde un objeto compartido simplemente se queda donde estaba, y en modification levels y diffing de revisiones, que es el otro sitio donde una fecha vieja o reescrita despista a un lector. La vista desde la reparación del mismo par Info y XMP, donde las dos mitades se hacen concordar en lugar de meramente conservarse, está en convertir a PDF/A y reparar metadatos

PDF Library for Delphi es una biblioteca PDF nativa en Pascal para Delphi, C++Builder y Lazarus, y el camino read-modify-write descrito aquí es el mismo por el que pasa cada edición de tu propio proceso, así que las garantías de arriba aplican tanto si guardas una vez como mil veces al día; mira la página de producto de PDF Library for Delphi para los compiladores y plataformas soportados