PDF Library for Delphi v3.539.18 y v3.539.20 arreglan dos formas en las que un save que no cambia nada igual podía corromper los metadatos del documento: cuando /CreationDate y /ModDate referían al mismo objeto string, la actualización automática de ModDate reescribía ambos, y cuando el objeto XMP se creaba antes de leer el stream /Metadata original, un packet por defecto reemplazaba al original. Los fixes reemplazan referencias de diccionario en lugar de mutar objetos compartidos, y capturan 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 renderizaban idénticas antes y después. Los hashes de los content streams coincidían. El archivo pasaba todas las verificaciones que teníamos, y aun así estaba mal en dos lugares que ningún renderer le iba a mostrar a nadie. Ambos defectos vivían en la ruta read-modify-write por la que pasa toda edición real, así que cualquier save bastaba para dispararlos, y solo se encontraron cuando un segundo parser independiente comparó la semántica no visual de los dos archivos
¿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 la librería estaba actualizando el objeto en lugar de la clave. 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, que es exactamente 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é activado, 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 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;
El fix en TPDFDocument.SetRawInfo es pequeño 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 nuevo 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 como el cambio principal. La vieja rama para una entrada con valor de stream limpiaba el stream con SetTo('') antes de reemplazarlo, lo que habría vaciado el valor para todas las demás claves que siguieran apuntando a ese stream, así que ese limpiado desapareció. Y el objeto desplazado no se borra, porque la estructura es su dueña y otras referencias pueden seguir necesitando
// Antes: mutar el objeto al que la clave resuelve en este momento
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Después: 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));
La regresión en Tests\SharedInfoSemantics.inc construye el aliasing deliberadamente en vez de depender de un archivo del corpus: 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 ahora 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. Varios sitios de llamada se inicializaban de forma perezosa con XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, que 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 este defecto: la comparación del diccionario Info del primer bug 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);
El fix hace dos cosas. TPDFDocument.EnsureXMP ahora captura Source := GetMetadata antes de TPDFlibXMP.Create, y toda inicialización perezosa 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 la ruta 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 ú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 encontradas 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. El código viejo la quitaba borrando caracteres hasta llegar a <?xpacket. 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 el ciclo borraba el documento entero, válido y todo. El serializador ahora localiza el ?> de cierre de la declaración y elimina solo eso. Tests\XMPRetentionSemantics.inc corre su chequeo de retención 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 era un símbolo de preprocesador: la sincronización Info-a-XMP en SetInfo estaba resguardada por NOVCL, que 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. Un build de Lazarus en Windows tenía entonces un objeto XMP funcional y un SetInfo que silenciosamente se saltaba actualizarlo. El guardia ahora 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 arregla retroactivamente una librería que muta objetos compartidos; solo evita la única escritura que exponía el defecto. Ambos fixes de arriba son lo que hace seguro un save ordinario, y 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 ambos defectos dejan cada página y cada content stream byte a byte idénticos. La verificación que los atrapó 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 el primer bug se escondió tanto tiempo como se escondió. Del lado de la librería, las dos regresiones ahora corren 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 para el benchmark del corpus de documentos reales
Si trabaja al nivel de debajo de estos fixes, 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