Artículo técnico

Comparar dos archivos PDF en Delphi: estructura y píxeles

HotPDF compara dos documentos PDF desde Delphi mediante THPDFDocComparison, que recorre el grafo de objetos de ambos archivos desde el catálogo hacia fuera y, cuando se solicita, también renderiza cada par de páginas y mide los píxeles que difieren. El resultado es un informe JSON que nombra cada diferencia encontrada, el presupuesto consumido y si la comparación se completó. Ambas pasadas importan, porque una diferencia estructural y una diferencia visual responden a preguntas distintas

La pregunta que hay detrás de esta función suele ser una pregunta de publicación. Un motor de informes recibe un cambio, la salida se regenera, y alguien tiene que decidir si algo se movió. Abrir ambos archivos uno junto al otro funciona hasta unas tres páginas, después la atención falla. Comparar bytes en bruto falla de inmediato, ya que dos ejecuciones del mismo generador producen bytes distintos por motivos que nada tienen que ver con lo que ve un lector

¿Por qué pueden dos PDF ser distintos byte a byte pero visualmente idénticos?

Dos PDF generados de forma independiente que imprimen de manera idéntica difieren habitualmente en sus bytes, y los motivos son estructurales, no cosméticos. Los números de objeto se asignan según el orden en que los objetos se van escribiendo. Los subconjuntos de fuente asignan CID según el orden en que se encuentran por primera vez los glifos, así que un subconjunto construido durante un recorrido ligeramente distinto produce bytes de flujo de contenido distintos para el mismo texto visible. Los desplazamientos de la tabla de referencias cruzadas cambian cada vez que algo anterior modifica su longitud

Por eso los números de objeto no se pueden usar como identidad entre documentos. En su lugar, HotPDF construye cada instantánea recorriendo desde el catálogo, expandiendo los diccionarios en el orden de bytes de sus claves y los arrays por índice, de modo que cada objeto se nombra por la ruta que lo alcanza. Los objetos a los que el recorrido no puede llegar desde la raíz recurren a una ruta sintética $Unreachable[...] que lleva el número de objeto y la generación, lo que mantiene visible en el informe el contenido huérfano en lugar de que desaparezca en silencio

Los flujos no se comparan copiándolos. Cada flujo aporta una firma SHA-256 incremental, calculada mientras se restaura después la posición original del flujo, de modo que comparar dos archivos de doscientos megabytes no implica materializar doscientos megabytes dos veces

Alinear páginas cuando un documento tiene una inserción

Comparar la página 1 con la página 1, la página 2 con la página 2 y así sucesivamente solo es correcto cuando no se ha insertado nada. Basta con insertar una portada para que una comparación ingenua informe de que todas las páginas cambiaron, lo cual es técnicamente cierto e inútil en la práctica

HotPDF alinea las páginas antes de compararlas. Construye una firma por página a partir del texto extraíble, recurre a una firma estructural para las páginas sin texto, y a continuación calcula la subsecuencia creciente más larga sobre los índices de destino emparejados. Las páginas dentro de esa subsecuencia son las que simplemente se desplazaron; las páginas fuera de ella son movimientos genuinos. Esa distinción es lo que hace legible la comparación de un manual de 400 páginas, porque el informe dice que se insertó una página en lugar de que cambiaron cuatrocientas

Ejecutar una comparación estructural

La llamada más sencilla recibe dos documentos cargados y un modo. cmStructural realiza el recorrido del grafo de objetos, cmRenderedImage realiza la comparación de píxeles, cmFull hace ambas cosas, y los modos más ligeros cmPageCount, cmPageText y cmObjectCount existen para comprobaciones rápidas y económicas:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

El informe distingue tres estados que un booleano no puede distinguir. identical indica si algo difirió, comparisonComplete indica si el recorrido terminó, y comparisonBudget nombra el límite que lo detuvo si fue el caso. Una comparación que agota un presupuesto informa a la vez comparisonComplete=false e identical=false, porque un recorrido truncado no tiene base para afirmar igualdad. Cualquier automatización que lea solo identical acabará tratando una parada por presupuesto como una diferencia real, así que conviene leer los tres campos

¿Qué límites acotan el recorrido?

Los valores predeterminados de THPDFStructuralCompareLimits.Default están dimensionados para documentos reales y no para casos adversos, y cada presupuesto semánticamente relevante tiene su propio límite: 250 000 objetos, 2 000 000 de aristas, profundidad 128, 10 000 diferencias notificadas, 64 MB por flujo y 512 MB de bytes de flujo en total, 1 MB por valor y 4096 bytes por ruta. Auméntelos de forma deliberada cuando conozca su corpus, y redúzcalos al comparar archivos procedentes del exterior:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // fallar rápido en CI
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // el valor predeterminado es 72
  Options.ColorTolerance := 2;         // ignorar el ruido de redondeo de 1-2 niveles
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // escribir imágenes superpuestas para revisión

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

La pasada de renderizado estima el número de píxeles a partir de las dimensiones de página y el DPI solicitado antes de reservar ningún mapa de bits, y vuelve a comprobarlo con el mapa de bits real después, de modo que una geometría de página malformada no puede eludir el presupuesto mintiendo sobre su tamaño. Aumentar el DPI aumenta la fidelidad y el coste de forma cuadrática: 150 DPI son cuatro veces los píxeles de 72, y los límites de píxeles por página y totales existen precisamente porque, de lo contrario, un trabajo por lotes a 300 DPI acabaría reservando memoria hasta meterse en problemas

¿Cuánta similitud es suficiente?

Dos páginas cuentan como similares solo cuando se cumplen ambas condiciones: la proporción de píxeles cambiados está en MaxChangedPixelRatio o por debajo, y la similitud está en MinimumSimilarity o por encima. Dos umbrales en lugar de uno, porque un puñado de píxeles catastróficamente erróneos y un baño amplio de pequeños cambios de color son fallos distintos, y cualquiera de los dos por sí solo puede ser aceptable en un flujo de trabajo y descalificante en otro. Las comprobaciones de umbral usan valores sin redondear; los seis decimales del JSON existen para mantener los informes estables y comparables, no para definir la comparación

Los píxeles cambiados se agrupan en regiones usando teselas de tamaño fijo como nodos con adyacencia de cuatro direcciones, en lugar de un relleno por inundación píxel a píxel. Eso mantiene la memoria acotada y la lista de regiones estable entre ejecuciones. Truncar el detalle de región retenido afecta solo al listado, no al recuento de regiones notificado, así que una página con más regiones cambiadas que MaxChangedRegions sigue informando de cuántas hubo

Vale la pena declarar con claridad un comportamiento porque invierte el instinto habitual. Los fallos del renderizador, los fallos de reserva de memoria y los fallos de superposición nunca se ignoran. Cualquier caso de ese tipo se registra como renderError o renderBudget y fuerza renderComparisonComplete=false, porque una página que no se pudo renderizar es una página que nadie comparó, y notificarla como idéntica es peor que no notificar nada

Dónde encaja cada modo en una canalización

La comparación estructural responde qué cambió y es la opción predeterminada adecuada para las suites de regresión: nombra la ruta, el índice de página y los números de objeto implicados, así que un fallo señala directamente al código que lo produjo. La comparación renderizada responde si alguien lo va a notar, que es la pregunta relevante para las aprobaciones y para verificar que un paso de optimización realmente no perdió información

Se combinan bien. Ejecute cmStructural en cada compilación y deje que falle de forma ruidosa ante cambios inesperados a nivel de objeto; ejecute cmFull con mapas de calor antes de una publicación, cuando haya una persona disponible para revisar las superposiciones. Para canalizaciones que ya emiten marcado de página por otros motivos, la salida de texto descrita en exportar páginas PDF a SVG ofrece una tercera vista comparable por humanos, y las comprobaciones automatizadas de la automatización de informes de preimpresión cubren cuestiones de conformidad que ninguno de los dos modos de comparación pretende responder

La comparación, la preimpresión y el renderizado comparten el mismo modelo de objetos de documento cargado, así que una sola pasada sobre un archivo puede alimentar a los tres. La lista completa de funciones para Delphi y C++Builder está en la página del componente PDF para Delphi de HotPDF