PDF Library for Delphi v3.539.18 y v3.539.20 arreglan dos formas en las que un guardado de PDF que no cambia nada podía corromper igualmente los metadatos del documento: cuando /CreationDate y /ModDate referían el mismo objeto de cadena, la actualización automática de ModDate reescribía ambas, y cuando el objeto XMP se creaba antes de leer el stream /Metadata original, un packet por defecto sustituía al original. Los fixes reemplazan referencias de diccionario en lugar de mutar objetos compartidos, y capturan 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 renderizaban idénticas antes y después. Los hashes de los content streams coincidían. El archivo pasaba todas las comprobaciones que teníamos, y aun así estaba mal en dos sitios que ningún renderizador te enseñaría jamás. Ambos defectos se sentaban en el camino read-modify-write por el que pasa toda edición real, así que cualquier guardado bastaba para dispararlos, y ambos se encontraron solo cuando un segundo parser independiente comparó la semántica no visual de los dos archivos
¿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 la biblioteca estaba actualizando el objeto y no la clave. 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, que es justo lo que hacía un documento de diseño CJK de nuestro corpus local
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. El viejo SetRawInfo buscaba el objeto bajo la clave y, si encontraba un TPDFString, le llamaba 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
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;
El fix en TPDFDocument.SetRawInfo es pequeño y el principio que hay detrás es general: actualizar una entrada de diccionario reemplaza la referencia de esa entrada, nunca el objeto al que resultara resolver. El código nuevo 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 como el cambio principal. La vieja rama para entradas con valor de stream limpiaba el stream con SetTo('') antes de reemplazarlo, lo que habría vaciado el valor para cualquier otra clave que siguiera apuntando a ese stream, así que esa limpieza desapareció. Y el objeto desplazado no se borra, porque lo posee la estructura y otras referencias pueden necesitarlo todavía
// Antes: mutar el objeto al que la clave resuelva ahora mismo
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Después: 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));
La regresión en Tests\SharedInfoSemantics.inc construye el aliasing deliberadamente en lugar de fiarse de un archivo del corpus: 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 ahora 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. Varios puntos de llamada se inicializaban lazy con XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, lo que 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 este defecto tiene detrás: la comparación del diccionario Info del primer bug 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
// 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);
El fix hace dos cosas. TPDFDocument.EnsureXMP ahora captura Source := GetMetadata antes de TPDFlibXMP.Create, y toda inicialización lazy del documento fue reemplazada por una llamada a é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 ya pasaban 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 encontradas 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. El código viejo la eliminaba borrando caracteres hasta llegar a <?xpacket. 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 el bucle borraba el documento entero, válido. El serializador ahora localiza el ?> de cierre de la declaración y elimina solo eso. Tests\XMPRetentionSemantics.inc corre su comprobación de retención 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 era un símbolo de preprocesador: la sincronización Info-a-XMP en SetInfo estaba guardada por NOVCL, que 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. Un build Lazarus en Windows tenía por tanto un objeto XMP funcional y un SetInfo que se saltaba actualizarlo en silencio. El guardia es ahora 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 arregla retroactivamente una biblioteca que muta objetos compartidos; solo evita la única escritura que exponía el defecto. Ambos fixes de arriba son lo que hace seguro un guardado ordinario, y 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 ambos defectos dejan cada página y cada content stream byte a byte idénticos. La comprobación que los cazó 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
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 el primer bug se escondió tanto tiempo como se escondió. En el lado de la biblioteca las dos regresiones corren ya en cada pasada dirigida sobre Delphi Win32 y Win64 y Free Pascal Win32 y Win64, y la comparación semántica es condición de paso del benchmark de corpus de documentos reales
Si trabajas al nivel de debajo de estos fixes, 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