Artículo técnico

Fusión rápida de PDF en Delphi: desplazamiento de referencias a nivel de byte

Combinar PDFs parece que debería ser barato. El contenido de las páginas ya está maquetado, las fuentes ya están incrustadas, las imágenes ya están comprimidas. En teoría, una fusión es solo trabajo administrativo: renumerar los objetos para que los espacios de numeración de dos archivos no choquen, unir los árboles de páginas, ajustar la tabla de referencias cruzadas y escribir el resultado. En la práctica, la mayoría del código de fusión tira por la borda esa ligereza. Para cada objeto de cada archivo de entrada hace un análisis completo en un árbol de objetos tokenizado, modifica un par de referencias indirectas y luego serializa el árbol de nuevo a bytes. El análisis y la serialización son las partes caras y, para la inmensa mayoría de los objetos, producen una secuencia de bytes casi idéntica a la que entró

PDFlibPas es un motor nativo de PDF en Object Pascal para Delphi y C++Builder, y su ruta de fusión rápida existe para saltarse ese ciclo siempre que sea demostrablemente seguro. La idea es estrecha, pero compensa en conjuntos completos de documentos: para un objeto no de flujo sin modificar, toma los bytes originales del origen tal cual y aplica una única reescritura a nivel de byte de las referencias indirectas que contienen, convirtiendo cada N G R en (N+Offset) G R. Sin tokenizador, sin árbol de objetos, sin serializador. Este artículo recorre dónde es legal ese atajo, la máquina de estados del analizador que hace la reescritura sin corromper nada, por qué la fusión de marcadores necesitó un mecanismo distinto por completo y cómo la ruta de fusión normal se reconstruyó de cuadrática a lineal al mismo tiempo

Por qué la renumeración de objetos es el coste real de una fusión

Cada PDF lleva su propio espacio de numeración de objetos. El archivo A tiene el objeto 1, el objeto 2, etc.; el archivo B tiene su propio objeto 1, objeto 2, etc. No puedes meter los objetos de B en el archivo de A sin cambios, porque los números chocarían y toda referencia indirecta dentro de B resolvería ahora hacia el objeto equivocado. La solución es un desplazamiento: si A termina en el recuento de objetos Offset, entonces el objeto N de B pasa a ser el objeto N+Offset en la salida, y toda referencia N G R que aparezca en cualquier parte de los objetos de B debe desplazarse a (N+Offset) G R para coincidir

Ese desplazamiento es toda la tarea semántica de fusionar el cuerpo. Los ajustes del árbol de páginas y la fusión de AcroForm son ediciones pequeñas y acotadas sobre unos pocos objetos. El trabajo grueso es reescribir referencias en miles de objetos, y la forma ingenua de hacerlo es analizar cada objeto para poder encontrar las referencias de manera estructural. MergeFileListFast de PDFlibPas adopta la postura contraria: las referencias también se pueden localizar en los bytes en bruto, si eres cuidadoso con los contextos en los que una secuencia dígito-espacio-dígito-espacio-R no es una referencia. Salta el análisis, desplaza in situ y el coste por objeto se reduce a un único recorrido lineal por bytes que de todos modos ibas a copiar

Cuándo reutilizar bytes de origen es demostrablemente seguro

La ruta de bytes solo se toma cuando se cumplen las tres condiciones para el objeto que se copia de un documento posterior. Si falla una sola, el objeto vuelve a la ruta completa de decodificación y reserialización, así que la corrección siempre vence a la velocidad:

  • Doc2.IsChangedObject(X) es False. Si el motor de fusión ya modificó el objeto en memoria, por ejemplo un objeto de página cuyo /Parent se ha redirigido, el árbol en memoria es la fuente de verdad y los bytes originales ya están obsoletos. Solo califican los objetos intactos
  • Los bytes de origen no contienen la palabra clave stream. El cuerpo de un objeto de flujo es binario opaco delimitado por stream/endstream, y un barrido ingenuo de referencias sobre datos comprimidos o cifrados buscaría y corrompería felizmente patrones de bytes que parecen referencias. Los objetos de flujo siguen la ruta original consciente de flujos
  • Los bytes de origen no contienen ni /StructTreeRoot ni /StructElem. En el perfil rápido, el árbol de estructura de PDF etiquetado se descarta en lugar de fusionarse, así que esos objetos deben pasar por la ruta de decodificación donde el motor puede anularlos a propósito

La decisión vive en el bucle de copia por objeto. Cuando pasan las tres comprobaciones, los bytes del objeto van directos a ShiftIndRefsInSource y luego al escritor; si no, los bytes se descartan y el objeto se reconstruye con GetObject, se desplaza con ShiftIndRef y se serializa. La estructura de esa rama merece verse, porque el orden de las comprobaciones es lo que la mantiene segura:

ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
  ObjectData := FastMergeObjectSource(Reader2, X);
  if (PLPos('stream', ObjectData) > 0) or
     ((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
     ((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
    ObjectData := ''                                  // fall back to decode
  else
    ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;

if ObjectData <> '' then
  Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
  Obj := Doc2.GetObject(X, TempStruct);              // full parse path
  // ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;

Una ObjectData vacía es la señal de que la ruta de bytes rechazó el objeto. Ese único centinela evita que las rutas rápida y lenta se separen: hay exactamente un punto que decide y exactamente una vía de reserva

La máquina de estados de desplazamiento de referencias y sus casos límite

La reescritura en bytes de referencias indirectas es engañosamente fácil de hacer mal, porque R y las secuencias de dígitos aparecen por todo un objeto PDF en contextos que no son referencias. ShiftIndRefsInSource es un pequeño escáner escrito a mano que recorre los bytes una sola vez y solo reescribe un número cuando va seguido, con espacios en blanco PDF entre los tokens, por otro número y luego por un delimitador R. Las salidas rápidas van primero: si el desplazamiento es cero o el origen está vacío, los bytes se devuelven intactos sin entrar en el escáner

La corrección del escáner depende de reconocer los contextos en los que una secuencia con forma de referencia debe dejarse intacta. Estos son los límites más fáciles de pasar por alto, y cada uno se maneja explícitamente:

  • Cadenas literales delimitadas por ( y ) se copian tal cual, siguiendo la profundidad de anidamiento y respetando el escape con barra invertida para que un paréntesis escapado no desajuste el recuento de profundidad. Una cadena como (see object 3 0 R for details) contiene un patrón de referencia de manual que en realidad es solo prosa, y debe sobrevivir byte a byte
  • Cadenas hexadecimales delimitadas por < y > se pasan sin interpretación. Los bytes 52 dentro de una cadena hexagonal son el código ASCII de R, y un escáner que tratara la carga hex como texto podría fabricar una referencia fantasma. El << de apertura de un diccionario se detecta primero para que un diccionario no se confunda con una cadena hexadecimal
  • Objetos nombre que empiezan con / se consumen completos, desde la barra hasta el siguiente espacio en blanco o delimitador. Sin esto, un nombre como /R (una clave de recurso habitual) podría leerse como la R de una referencia
  • Comentarios introducidos por % llegan hasta el final de línea y se omiten como texto opaco
  • La prueba número-luego-R es estricta. Solo se reconoce una referencia como N espacio en blanco G espacio en blanco R con la R terminada por espacio en blanco, un delimitador o el final de la entrada. Si falta el número de generación, o si una R va seguida de una letra, los dígitos se emiten sin cambios. Esto es lo que protege el entero de /Length 1234 y los cuatro números de un MediaBox para que no se incrementen en silencio

El núcleo de esa prueba estricta se parece casi exactamente a cómo lo describe la frase de especificación:

if (P <= N) and (Source[P] = 'R') and
   ((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
  Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);

if Obj1 >= 0 then
begin
  AppendStr(PLIntToStr(Obj1 + Offset));   // shifted object number
  AppendBytes(E1, P - E1);                 // original whitespace + generation
  AppendBytes(P, 1);                       // the 'R'
end;

Solo se reescribe el número de objeto; el número de generación y el espacio en blanco exacto original entre los tokens se copian tal cual, así que la salida es byte idéntica a la entrada salvo por el único entero que había que cambiar. Esa precisión es todo el punto, y es lo que hace que reutilizar bytes de origen sea equivalente a reserializar por completo, no solo parecido. El comportamiento está cubierto por un conjunto centrado de pruebas unitarias que ejercitan referencias aisladas, referencias dentro de matrices, números que no son referencias, cadenas literales, cadenas hexadecimales y números de generación distintos de cero con un desplazamiento aplicado

Por qué los marcadores no podían reutilizar AppendOutline

Fusionar los marcadores de varios documentos en un único árbol de esquema parece un trabajo para el ayudante existente AppendOutline, que ya sabe cómo injertar los marcadores de nivel superior de un documento en otro. Aquí es la herramienta equivocada, y la razón es un desajuste sutil de capas. AppendOutline localiza el último marcador de nivel superior actual recorriendo el lector sobre los bytes originales del archivo. Pero la vía rápida prepara sus ediciones en un búfer de nuevos objetos mediante ChangeObject; el lector nunca ve esas ediciones. Encadena tres o más documentos y cada anexado vuelve a redirigir el último marcador original del primer documento al documento más nuevo, así que todos los marcadores de los documentos intermedios se caen de la cadena; solo el /Count acumulado sigue siendo correcto, lo que hace que el error sea fácil de pasar por alto hasta que alguien abre el panel de marcadores

La ruta rápida lo resuelve con una inyección en dos fases, guiada por metadatos, que nunca vuelve a recorrer el lector. Un primer pase sobre todas las entradas recopila, por documento, el objeto raíz del esquema y los números de generación, los números del primer y del último marcador de nivel superior, y el /Count de la raíz. A partir de ese resumen, el código calcula los números globales de objeto de cada enlace que necesita forjar, el /Parent de nivel superior de cada documento hacia la raíz compartida, el /Prev del primer marcador hacia el último del documento anterior, el /Next del último marcador hacia el primero del documento siguiente, usando pura aritmética de números de objeto. Hay una restricción de orden de escritura detrás de esto: los objetos del primer documento se escriben antes de abrir siquiera el siguiente, así que todas las ediciones del esquema del primer documento, el /Count y el /Last de la raíz, y el /Next del antiguo último marcador, tienen que poder expresarse como aritmética que no necesita tener a mano un documento posterior. Las ediciones de cada documento siguiente se aplican en su sitio después de abrirlo pero antes de escribirlo, así que salen por la misma ruta de cambio de objeto

La invariante de alineación del desplazamiento que lo une todo

Tanto el desplazamiento de referencias como la inyección de marcadores dependen de una única invariante aritmética, y es la suposición más frágil de todo el diseño. Una referencia inyectada en un documento posterior se escribe como número global de objeto de destino menos el Offset de ese documento, de modo que cuando el objeto se desplaza más tarde con ShiftIndRef(Offset) el valor acaba en el número global previsto. El primer documento recibe Offset = 0 y usa directamente los números globales. Para que esa resta sea correcta, la secuencia de offsets en curso usada durante la inyección tiene que coincidir con la secuencia de offsets usada cuando los objetos se escriben al final

Y coincide, por una propiedad de cómo funcionan las fusiones de páginas y formularios: AddPages, AddFields y AddFieldFonts solo modifican los objetos existentes del primer documento; nunca añaden nuevos. Así que el recuento de objetos del primer documento no cambia durante la fase de fusión de páginas, y el offset de cada documento posterior, la suma de todos los documentos anteriores, permanece estable desde la inyección hasta la escritura final. Si rompes eso, si introduces una fase que crea un objeto nuevo a mitad de la fusión, entonces todas las referencias de páginas y marcadores aguas abajo quedarían desfasadas por el número de objetos que añadiste. La invariante es silenciosa, pero es estructural

Tres puntos de entrada sobre un solo motor

La ruta rápida no es una bifurcación del código de fusión. En la misma línea de trabajo, el motor a nivel de byte se factoró en una única rutina interna, MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode), y las APIs públicas se convirtieron en envoltorios delgados que eligen dos banderas:

  • MergeFileListFast llama al motor con la preservación del árbol de estructura desactivada, la ruta más ligera, que descarta el árbol de PDF etiquetado para que la vía de bytes se aplique al mayor número de objetos
  • MergeFileList lo llama con la preservación activada, de modo que el árbol de estructura sobreviva y el resultado siga siendo un PDF etiquetado utilizable. Esta ruta ordinaria también hereda la fusión de marcadores y formularios para varios documentos
  • MergeFileListStrict activa el modo estricto: el primer pase de metadatos se detiene en la primera entrada que no informa de una fusión limpia, así que solo se incluyen los documentos recopilados antes del archivo defectuoso, en lugar de saltarse el archivo malo y seguir

Unificar las rutas también permitió reconstruir la fusión ordinaria a partir de un bucle por pares O(N²), fusionar el archivo uno y dos, fusionar ese resultado con el tres y así sucesivamente, volviendo a analizar el acumulador creciente en cada paso, y convertirla en un único pase lineal que abre cada entrada una sola vez. Los dos puntos de entrada históricos para dos archivos y dos flujos, MergeFiles y MergeStreams, no se tocan y siguen disponibles para quienes realmente quieren una fusión por pares

Una nota honesta sobre el comportamiento del árbol de estructura, porque hizo tropezar al conjunto de pruebas. El descarte de la ruta rápida no es total: elimina la referencia del catálogo del primer documento a /StructTreeRoot, pero el propio objeto del árbol de estructura sigue escribiéndose como huérfano. Así que los bytes de la salida rápida todavía contienen la cadena /StructTreeRoot, y no puedes distinguir la salida rápida de la ordinaria buscando esa cadena; la diferencia real es si el catálogo sigue alcanzando el árbol de estructura, que es lo que determina si el archivo sigue siendo un PDF etiquetado navegable

Cuándo conviene usar cada ruta

La ruta de bytes es una optimización de rendimiento para ensamblar muchos documentos cuando no necesitas conservar el árbol de estructura del PDF etiquetado, como paquetes de informes, tandas de extractos o concatenación por lotes. Medido sobre fusiones repetidas de conjuntos de entrada medianos o grandes, la reutilización de bytes recortó aproximadamente entre un cuatro y un trece por ciento del tiempo real, según la mezcla de objetos, sin nuevos fallos en entradas pequeñas o mal formadas, porque cualquier objeto que el escáner no pueda demostrar seguro vuelve al análisis completo. Si sí necesitas intacto el árbol de estructura por accesibilidad, usa la ruta ordinaria de fusión de PDF etiquetado, que lo conserva; y si trabajas con archivos individuales muy grandes en lugar de muchas entradas, las técnicas de copia de bytes descritas en el artículo complementario sobre fusión y división de PDF grandes con acceso directo al archivo aplican la misma filosofía de "copiar bytes, evitar el árbol de objetos completo" a escala de archivo

Las rutinas de fusión y sus variantes rápida y estricta forman parte de la PDFlibPas Delphi PDF Library, cuya documentación incluye la referencia completa de la API de listas de archivos y de las opciones de fusión descritas aquí