Un save de PDF que no cambia nada igual puede corromper los metadatos del documento de dos formas: cuando /CreationDate y /ModDate referen al mismo objeto string, 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 reemplaza 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 perezosa de XMP
El save es la operación menos interesante que hace una librería de PDF: cargar un archivo, guardarlo bajo otro nombre, no tocar nada en el medio. Las páginas se renderizan idénticas antes y después, y los hashes de los content streams coinciden. Un archivo puede pasar cada chequeo de estos y aun así estar mal en dos lugares que ningún renderer le mostraría jamás a usted. Ambas trampas se sientan en la ruta read-modify-write por la que pasa toda edición real, así que cualquier save 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 le cambia el CreationDate?
Porque al diccionario de información del documento se le permite referir un mismo objeto string indirecto desde dos claves, y un escritor que actualiza el objeto en lugar de la clave cambia ambos. ISO 32000-1 §7.3.10 permite que cualquier valor de diccionario sea una referencia indirecta, y nada en la Tabla 317 de §14.3.3 dice que el valor bajo /CreationDate deba ser un objeto distinto del valor bajo /ModDate. Un productor que escribió el mismo timestamp dos veces al momento de crear el archivo puede, perfectamente legalmente, apuntar ambas claves a un único 2728 0 R, y los archivos reales hacen exactamente eso
El disparador es la fecha de modificación automática. Salvo que UserModDate esté activado, SaveToFile llama a SetInfo('ModDate', ...) con la hora actual antes de escribir, lo 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 resuelve en ese momento, y cuando ese objeto es compartido, /CreationDate también reporta la hora del save. El documento sigue abriendo, imprimiendo y renderizando píxel por píxel igual que antes, así que una suite de regresión visual pasa sin parpadear
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 detrás es general: actualizar una entrada de diccionario reemplaza la referencia de esa entrada, jamás el objeto al que casualmente resolvía. El código lee el TPDFStringMode existente para que un hex string siga siendo hex y un literal string siga siendo literal, y luego agrega un string fresco desde 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 SetTo('') sobre él vaciaría el valor para todas las demás claves que sigan apuntando a ese stream. Y el objeto desplazado no se borra, porque la estructura es su dueña y otras referencias pueden seguir necesitándolo
// Ingenuo: mutar el objeto al que la clave resuelve en este momento
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Correcto: conserva la representación, reemplaza solo la referencia de esta clave
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
Una prueba para esto debería construir el aliasing deliberadamente en vez de depender de un archivo que casualmente comparta objetos: un hex string referido desde ambas claves de fecha, un string directo compartido 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 el string actualizado debe seguir siendo hex. La referencia pública de SetInformation afirma la garantía en una frase: actualizar un campo de Info reemplaza solo ese campo, incluso cuando otros campos referen al mismo objeto
¿Por qué un packet XMP existente termina reemplazado por 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 perezosa escrita como XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); se lee natural y está mal: para cuando GetMetadata corre, 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 cualquier identificación de estándares, nunca llega al objeto y se sobrescribe 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. Note detrás de qué se esconde esta trampa: la comparación del diccionario Info de la primera pasa, porque /Author y /Title en /Info quedan intactos. Solo cambió el árbol XMP, y solo una verificación que parsee y compare ese árbol se entera
// 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 guarda ese orden en un solo lugar. TPDFDocument.EnsureXMP captura Source := GetMetadata antes de TPDFlibXMP.Create, y toda inicialización perezosa 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 la ruta 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 único orden de inicialización. Una sola copia correcta de una secuencia de tres líneas vale más que diez copias que hoy por hoy casualmente coinciden
Dos trampas menores en el mismo camino
El serializador XMP en Windows usa el XML writer 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: ISO 16684-1 §7.3.2 hace opcional el wrapper xpacket, y un productor que escribe un elemento <x:xmpmeta> desnudo está dentro del estándar, así que sobre tal packet ese ciclo borra el documento entero, válido y todo. El serializador localiza el ?> de cierre de la declaración y elimina solo eso. Un chequeo de retención que vale la pena copiar corre dos veces, una con el wrapper y otra con este recortado, 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 define en los builds de Free Pascal, pero el backend XMP se rige por el sistema operativo, no por el framework, ya que PDFlibXMP.pas define NO_XMP solo cuando OS_WINDOWS está ausente. Resguardar la sincronización Info-a-XMP en SetInfo con NOVCL dejaría un build de Lazarus en Windows con un objeto XMP funcional y un SetInfo que silenciosamente se salta actualizarlo. El guardia es NO_XMP, así que una aplicación Free Pascal en Windows recibe la misma sincronización que Delphi
¿Cómo se conserva el ModDate original en un save de paso?
Active KeepModDate en TPDFlibSaveOptions y guarde a través de SaveToFileOptions. La opción activa UserModDate durante la llamada, y SaveToFile entonces se salta el timestamp automático, que además es el paso que inicializa perezosamente el objeto XMP. Un documento cuyos metadatos usted nunca tocó, y para el que no se activó ningún modo de cumplimiento, 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 usted 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 perezoso de XMP
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Sea honesto sobre lo que esto le compra. KeepModDate es la elección correcta para un paso de paso cuya salida debe describir la misma revisión que su entrada, y es la elección equivocada para cualquier cosa que edite contenido de verdad, porque §14.3.3 espera que /ModDate refleje la modificación más reciente. Tampoco sustituye 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 save ordinario; la opción es lo que hace honesto un no-op deliberado
¿Cómo se verifica que un save no cambió nada salvo el ModDate?
No 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 verificación que los atrapa es una instantánea de semántica no visual tomada por un parser independiente — uno que no comparte código con la librería bajo prueba — 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 un número de página en vez de a un número de objeto, los named destinations y los destinos de links resueltos de la misma manera, 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, ya que una reescritura completa los renumera todos y una comparación anclada en ellos reportaría ruido
Las exclusiones importan tanto como las inclusiones. /ModDate, xmp:ModifyDate y xmp:MetadataDate se espera que cambien y se descartan antes de comparar; a un archivo cuyo fuente no llevaba XMP alguno no se le cobra haber ganado un packet. Lo que la verificación no afirma es igual de explícito: retener un packet existente no dice nada sobre si ese packet es válido contra el schema ni 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 pasar desapercibida mucho tiempo
Si trabaja al nivel de debajo de estas reglas, la mecánica de cómo un save reescribe objetos está cubierta en las actualizaciones incrementales y el guardado append-only, que es el único modo de save donde un objeto compartido simplemente se queda donde estaba, y en los modification levels y el diff de revisiones, que es el otro lugar donde una fecha vencida o reescrita engaña a un lector. La vista desde la reparación del mismo par Info y XMP, donde las dos mitades se hacen coincidir en lugar de solo preservarse, está en convertir a PDF/A y reparar metadatos
PDF Library for Delphi es una librería de PDF nativa en Pascal para Delphi, C++Builder y Lazarus, y la ruta read-modify-write descrita acá es la misma por la que pasa toda edición en su propio proceso, así que las garantías de arriba aplican ya sea que usted guarde una vez o mil veces al día — vea la página de producto de PDF Library for Delphi para los compiladores y plataformas soportados