Artículo técnico

Actualizaciones incrementales PDF en Delphi: AppendToStream

Las actualizaciones incrementales de PDF permiten que una aplicación Delphi modifique un documento añadiendo solo los objetos cambiados, dejando intacto cada byte original. losLab PDF Library implementa esto mediante AppendToStream, que escribe únicamente la sección incremental definida por ISO 32000-1 §7.5.6, de modo que editar un marcador en un archivo de 2 GB cuesta kilobytes de salida en lugar de una reescritura completa. El mismo mecanismo es la razón por la que los documentos firmados pueden actualizarse sin invalidar sus firmas

El dolor que esto resuelve es concreto. Un guardado completo reescribe todo el archivo: cada objeto se vuelve a serializar, cada desplazamiento de referencia cruzada se recalcula, y la salida no guarda ninguna relación a nivel de byte con la entrada. Para una factura de 40 KB no pasa nada. Para un archivo escaneado de 2 GB en el que solo se corrigió una errata en el título del documento, reescribir dos gigabytes para cambiar veinte bytes es absurdo — y si el archivo llevaba una firma digital, la reescritura acaba de destruirla

¿Por qué guardar un PDF rompe su firma digital?

Una firma digital de PDF no firma el contenido lógico del documento; firma rangos de bytes del archivo físico. La entrada /ByteRange del diccionario de firma registra exactamente qué tramos del archivo cubre el resumen criptográfico. Cualquier operación de guardado que vuelva a serializar esos bytes — incluso una que produzca un documento semánticamente idéntico — cambia el resumen, y todos los validadores informarán de que la firma está rota. Esto es así por diseño: la firma da fe de los bytes que vio el firmante, no de algún modelo abstracto de documento

Las actualizaciones incrementales son la vía de escape que ofrece la especificación PDF. Como un guardado incremental añade datos nuevos después del %%EOF original y nunca toca los rangos de bytes firmados, la firma existente sigue validándose contra los bytes que cubre. Los validadores clasifican entonces por separado los cambios añadidos — una segunda firma, un relleno de formulario, una anotación — y deciden si son modificaciones permitidas. Todo flujo de trabajo multifirma depende de esto: cada firmante añade una sección incremental encima de la anterior. Si estás construyendo pipelines de firma, el artículo complementario sobre firma y validación PAdES en Delphi cubre en detalle cómo interactúan los rangos de bytes de firma y las secciones incrementales

PDF Library for Delphi: diagrama de rangos de bytes que muestra por qué un guardado completo de PDF invalida las firmas digitales mientras las actualizaciones incrementales de AppendToStream las conservan
Como el resumen cubre rangos de bytes fijos, un guardado completo los revuelve y rompe la validación, mientras que una actualización incremental añade datos más allá de la región firmada y todas las firmas encadenadas sobreviven

Cómo funcionan las actualizaciones incrementales según ISO 32000-1 §7.5.6

ISO 32000-1 §7.5.6 define el modelo en tres reglas. Primera, el contenido original del archivo se deja completamente intacto — no se mueve ni un byte. Segunda, los objetos cambiados y los recién creados se añaden después del último %%EOF, cada uno con el mismo número de objeto que tenía antes (los objetos cambiados simplemente reciben una definición más nueva que eclipsa la antigua). Tercera, se añaden una nueva sección de referencias cruzadas y un nuevo trailer; la entrada /Prev del trailer apunta al desplazamiento de bytes de la sección de referencias cruzadas anterior, formando una cadena que un lector recorre de la más nueva a la más antigua para resolver cada objeto a su definición más reciente

De esta estructura se derivan dos propiedades útiles. Las actualizaciones son baratas en proporción a lo que cambió, no al tamaño del documento — el coste de añadir es el tamaño de los objetos modificados más una pequeña sobrecarga de xref/trailer. Y el archivo se convierte en su propio historial de versiones: cada revisión anterior sigue físicamente presente, así que un auditor puede truncar el archivo en cualquier %%EOF anterior y recuperar exactamente el documento que existía en ese momento. Para flujos de trabajo de cumplimiento que deben demostrar cómo era un documento antes de cada enmienda, esta pista de auditoría integrada suele ser el argumento decisivo a favor de los guardados incrementales

Escribir una actualización incremental con AppendToStream

losLab PDF Library expone la salida incremental mediante AppendToStream(AppendMode: Integer; OutStream: TStream): Integer, que devuelve 1 en caso de éxito y 0 en caso de fallo. El parámetro AppendMode selecciona qué aterriza en el stream de destino. El modo 0 escribe un archivo completo: primero se copian al stream los bytes originales de origen y después se añade la sección incremental. El modo 1 escribe solo la propia sección incremental — el delta — y omite por completo los bytes de origen. El modo 2 escribe primero un prefijo suministrado por quien llama y registrado mediante SetAppendInputFromString, y después añade la sección de actualización encima

Comparación de los modos 0, 1 y 2 de AppendToStream escribiendo los bytes de origen, el prefijo del llamador y la sección incremental en un stream de Delphi
AppendMode selecciona lo que recibe el stream, desde una copia completa hasta un prefijo suministrado por el llamador más el delta, con el modo 1 emitiendo la sección incremental pura y portable
var
  Doc: TPDFlib;
  Delta: TMemoryStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('contract.pdf', '') <= 0 then
      Exit;

    // Edición pequeña: el tipo de cambio que no debería
    // provocar una reescritura de todo el archivo
    Doc.SetInformation(3, 'Amended 2026-07-04');  // clave 3 = /Subject

    Delta := TMemoryStream.Create;
    try
      // AppendMode = 1: escribir solo la sección incremental.
      // Bytes originales + Delta = un PDF completo y válido.
      if Doc.AppendToStream(1, Delta) = 1 then
        Delta.SaveToFile('contract.delta.bin');
    finally
      Delta.Free;
    end;
  finally
    Doc.Free;
  end;
end;

El modo 1 es el interesante para el diseño de sistemas. Como el delta es autocontenido, puedes distribuirlo independientemente del original: almacenar revisiones como blobs separados en un almacenamiento de objetos, replicar solo los deltas a un sitio remoto, o reconstruir cualquier revisión concatenando el archivo base con su cadena de incrementos. La regla de reconstrucción es la simple concatenación de bytes — primero el archivo original, luego cada delta en orden — porque esa es exactamente la disposición que §7.5.6 prescribe para un archivo actualizado incrementalmente

¿Cómo calcula la biblioteca los desplazamientos xref sin copiar el archivo original?

Las entradas de referencias cruzadas dentro de una sección incremental deben contener desplazamientos de bytes absolutos — posiciones medidas desde el inicio del archivo completo, no desde el inicio del delta. Eso crea un rompecabezas para el modo 1: el escritor nunca emite los bytes originales, y sin embargo cada desplazamiento que registra tiene que fingir que están ahí. losLab PDF Library resuelve esto con un adaptador de stream interno, TPDFAppendSectionStream, que presenta al serializador un espacio de coordenadas virtual. El adaptador se crea con la longitud en bytes del archivo original como desplazamiento base, informa de su posición y tamaño como esa base más lo que se haya añadido hasta el momento, y reenvía solo los bytes recién escritos al stream de destino del llamador

La consecuencia es que el modo 1 nunca materializa una copia del documento de origen — ni en disco ni en memoria. La implementación ingenua (escribir el archivo completo en un búfer temporal y luego recortar la cola) cargaría con una copia transitoria de todo el PDF original, que para entradas del orden del gigabyte es precisamente el coste que las actualizaciones incrementales existen para evitar. Esta técnica de virtualización de desplazamientos es prima cercana del desplazamiento de referencias de bytes usado en otras partes de la biblioteca; el artículo sobre fusión rápida de PDF con desplazamiento de referencias de bytes muestra la misma idea aplicada a combinar documentos, y la guía sobre fusión y división de PDF grandes con acceso directo a archivo cubre la arquitectura de E/S circundante para archivos que no caben cómodamente en RAM

Guardados completos en streaming con SaveToStream

La salida incremental es la mitad de la historia del streaming; la otra mitad es lo que ocurre en un guardado completo. SaveToStream en losLab PDF Library dirige el serializador del documento directamente contra el stream de destino, en lugar de renderizar primero el documento entero en un AnsiString intermedio y escribir después ese búfer en una sola llamada. El enfoque antiguo funcionaba, pero significaba que cada guardado completo mantenía transitoriamente en memoria una segunda copia completa de la salida — inofensivo a 10 MB, doloroso a 500 MB, y un muro infranqueable para salidas de varios gigabytes en procesos de 32 bits. La serialización directa hace que el pico de memoria siga a las estructuras de objetos del documento en lugar de a su longitud serializada

var
  Doc: TPDFlib;
  Output: TFileStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('archive.pdf', '') <= 0 then
      Exit;

    // ... ediciones que justifican una reescritura completa ...

    Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
    try
      if Doc.SaveToStream(Output) = 0 then
        Writeln('Save failed, error ', Doc.LastErrorCode);
    finally
      Output.Free;
    end;
  finally
    Doc.Free;
  end;
end;

Una lección sobre modos de compartición: cuando AppendToFile devolvía 0

Una regresión en esta área merece contarse porque el patrón de fallo se generaliza. AppendToFile(FileName) añade una actualización incremental directamente a un PDF existente en disco — la llamada natural para un flujo de trabajo de pista de auditoría en el sitio: cargar un archivo, hacer un cambio, añadirlo a la misma ruta. En la v3.71.2 esa secuencia exacta empezó a devolver 0. La causa raíz estaba en el cargador, no en el escritor: para permitir la lectura bajo demanda de documentos grandes, LoadFromFile mantiene abierto el handle del archivo de origen durante toda la vida del objeto documento, y ese handle se abría con fmShareDenyWrite. Cuando AppendToFile intentaba después reabrir el mismo archivo para escritura, el propio modo de compartición del cargador se lo denegaba, y la API fallaba antes de escribir un solo byte

La corrección relajó el modo de compartición del cargador a fmShareDenyNone, lo que es seguro precisamente por lo que es un añadido incremental: agrega bytes estrictamente después del final del archivo y nunca reescribe la región que el handle de larga vida del lector está sirviendo. La lección general para quien envuelva esta biblioteca — o construya cargadores en streaming similares — es que los lectores perezosos que retienen handles y los escritores sobre el mismo archivo están en tensión, y el modo de compartición que se elige al abrir es un contrato de API, no un detalle de implementación. Si AppendToFile devuelve alguna vez 0 en tu código, comprueba primero si algo más en tu proceso sigue reteniendo el archivo de destino con un modo de compartición restrictivo

Los costes honestos: cuándo las actualizaciones incrementales son la herramienta equivocada

Las actualizaciones incrementales cambian tamaño de archivo por eficiencia de escritura, y el intercambio no siempre es favorable. Cada revisión añade sus objetos cambiados mientras las definiciones reemplazadas permanecen en el archivo, así que un documento editado cientos de veces acumula objetos muertos y una larga cadena /Prev que todo lector debe recorrer. Peor aún, el contenido "eliminado" no desaparece: el texto quitado en la revisión cinco sigue físicamente presente en los bytes de la revisión cuatro, recuperable por cualquiera que trunque el archivo. La redacción, el saneamiento o cualquier eliminación de contenido sensible exigen por tanto una reescritura completa — un guardado incremental de una redacción es una fuga de datos con pasos adicionales

Un guardado completo es también la decisión correcta cuando el objetivo es la compactación (exprimir los incrementos acumulados y los objetos no usados), cuando se cambian propiedades de todo el documento como el cifrado — volver a cifrar toca cada cadena y cada stream, así que no queda nada de "incremental" en el cambio — o cuando se produce un entregable limpio en el que el historial de edición no debe viajar con el archivo. Una regla razonable: usa AppendToStream o AppendToFile mientras un documento está vivo y cambiando, sobre todo una vez que lleva firmas; usa una reescritura completa con SaveToStream en los límites del ciclo de vida, cuando el documento sale de tu sistema o su historial debe aplanarse

Guía de decisión de PDF Library for Delphi para elegir entre guardados incrementales con AppendToStream y reescrituras completas con SaveToStream durante el ciclo de vida de un documento
Añade mientras un documento está vivo y firmado, y cambia a una reescritura completa siempre que deban desaparecer bytes, deba aplanarse el historial o cambie el cifrado

Las actualizaciones incrementales, la salida de deltas con desplazamientos virtuales y la serialización directa a stream forman parte de la losLab PDF Library estándar para Delphi, C# y VB.NET; la página del producto enumera toda la superficie de la API de guardado y añadido junto con las funciones de firma y archivos grandes tratadas arriba