Artículo técnico

Diff visual de PDF en Delphi: píxeles y emparejado

La comparación visual de PDFlibPas renderiza dos PDF al mismo DPI y hace diff de lo que un lector ve de verdad. TPDFlib.CompareRenderedPagesWith compara un par de páginas y devuelve píxeles cambiados, regiones, una máscara y un overlay. TPDFlib.CompareDocumentVisualsWith y TPDFlib.CompareFileVisuals emparejan documentos completos página a página, detectan páginas insertadas y eliminadas y reportan todo como JSON

El caso de uso es el testing de regresión visual. Alguien reescribe la plantilla de facturas, actualiza un paquete de fuentes o cambia el motor de render, y la pregunta no es si cambiaron los bytes (siempre cambian) sino si algún cliente verá una diferencia. Un diff estructural responde otra pregunta. Dos archivos pueden diferir en cada content stream y aun así pintar páginas idénticas, y un solo operando desplazado puede mover un total tres puntos sin tocar nada que un extractor de texto señalaría. Los píxeles son la única capa donde "se ve igual" es comprobable

¿Qué compara en realidad un diff visual de PDF?

PDFlibPas compara el resultado visible sobre una página blanca, no los contenidos en bruto del buffer. Cada página se renderiza mediante RenderPageToString como un BMP sin compresión, se decodifica a un bitmap top-down y cualquier píxel que lleve alfa se compone sobre blanco antes de comparar los canales. Un bitmap RGB y un bitmap RGBA que se ven iguales en pantalla se comparan por tanto como idénticos, que es justo lo que quieres cuando los dos lados vienen de rutas de render distintas

El decodificador BMP tiene una rareza deliberada que conviene conocer. Un bitmap de 32 bits BI_RGB lleva un cuarto byte por píxel, pero el formato deja ese byte sin definir: unos escritores lo rellenan con 0, otros con 255 y otros con residuos. PLDecodeVisualBMP lo ignora y conserva el color. Tratar ese byte como opacidad permitiría que un renderer que escribe ceros borrara cada píxel de la página, y el diff reportaría encantado dos documentos en blanco e idénticos. Si alimentas tus propios bitmaps a la API de más bajo nivel, el alfa solo se respeta cuando tú declaras el formato como vpfRGBA o vpfBGRA

Un píxel cuenta como cambiado cuando cualquiera de sus canales rojo, verde o azul difiere en más de ColorTolerance. Tolerancia 0 significa bit a bit exacto. En la práctica un valor de un dígito absorbe el temblor del anti-aliasing entre builds del renderer sin ocultar un glifo cambiado, porque un cambio real de texto mueve los canales mucho más que eso. Elige el número a partir de una ejecución baseline conocida buena, no a ojo

Comparar un solo par de páginas con CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith es la herramienta cuando ya sabes qué páginas van juntas y quieres detalle: un bounding box, regiones de cambio conexas, una máscara por píxel opcional y un overlay teñido de rojo opcional. El record de opciones y los tipos de resultado viven en la unidad PDFlibVisualCompare, y PLDefaultVisualCompareOptions te da tolerancia 0, tiles de 32 píxeles, sin regiones a ignorar y sin máscara ni overlay

uses
  PDFlibrary, PDFlibVisualCompare;

var
  Baseline, Candidate: TPDFlib;
  Options: TPDFVisualCompareOptions;
  Comparison: TPDFVisualCompareResult;
  ErrorText: WideString;
begin
  Baseline := TPDFlib.Create;
  Candidate := TPDFlib.Create;
  try
    if (Baseline.LoadFromFile('invoice-v1.pdf', '') = 1) and
       (Candidate.LoadFromFile('invoice-v2.pdf', '') = 1) then
    begin
      Options := PLDefaultVisualCompareOptions;
      Options.ColorTolerance := 8;
      Options.IncludeOverlay := True;
      // Enmascara el timestamp de impresión; unidades en píxeles al DPI de abajo
      SetLength(Options.IgnoreRects, 1);
      Options.IgnoreRects[0].Left := 600;
      Options.IgnoreRects[0].Top := 20;
      Options.IgnoreRects[0].Right := 790;
      Options.IgnoreRects[0].Bottom := 50;

      if Baseline.CompareRenderedPagesWith(Candidate, 1, 1, 96, Options,
        Comparison, ErrorText) then
      begin
        if Comparison.ChangedPixels > 0 then
          Writeln(PLVisualCompareResultToJSON(Comparison));
      end
      else
        Writeln('Compare failed: ', ErrorText);
    end;
  finally
    Candidate.Free;
    Baseline.Free;
  end;
end;

Tres detalles muerden aquí. Los rectángulos a ignorar van en píxeles renderizados con origen arriba a la izquierda e intervalos medio abiertos, así que Right y Bottom son excluyentes y el mismo rectángulo cubre otra parte de la página si cambias el DPI. Los rectángulos solapados se cuentan una vez, y los píxeles ignorados aparecen en IgnoredPixels en vez de desaparecer, lo que mantiene honesto a ChangedRatio porque divide solo por píxeles comparados. Por último, la llamada de página única exige que ambos renders tengan las mismas dimensiones; una página que cambió de tamaño falla con error en lugar de estirarse para encajar

Las regiones salen de una pasada de tiles: los píxeles cambiados se agrupan en cuadrados de TileSize y los tiles adyacentes se fusionan en una región con sus propios límites y recuento de píxeles. Tiles mayores dan menos regiones y más gruesas, que suele ser lo que quiere un informe de tests. El overlay es una copia RGB de la segunda página con los píxeles cambiados teñidos de rojo, devuelta como bytes en bruto en Comparison.Overlay; escribirlo a PNG para los artefactos de tu CI es cosa de tu código, no de la biblioteca

¿Cómo gestiona el emparejado a nivel de documento las páginas insertadas?

TPDFlib.CompareDocumentVisualsWith empareja páginas con una subsecuencia común más larga sobre huellas de píxeles exactas, así que una página insertada no convierte todas las siguientes en cambios falsos. Un bucle ingenuo de página 1 con página 1, página 2 con página 2 es inútil en cuanto a una plantilla le añaden una portada: cada página posterior a la inserción queda desfasada en uno y el informe se enciende en rojo de arriba abajo

Cada página se renderiza y se reduce a una clave hecha de su ancho y alto en píxeles y un digest SHA-256 de los píxeles visibles. El tamaño importa más de lo que parece: una página vertical toda blanca y una apaisada toda blanca de la misma área tienen buffers de píxeles idénticos byte a byte, y sin las dimensiones en la clave se anclarían la una a la otra. Las claves idénticas en ambos lados se vuelven anclas, la LCS elige la cadena ordenada más larga de ellas, y las páginas entre dos anclas consecutivas se emparejan en orden. Cuando un hueco guarda más páginas en un lado, el excedente se reporta como added o removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline y Candidate cargados como antes, con ajustes de render coincidentes
  Options := PLDefaultVisualCompareOptions;
  Options.ColorTolerance := 8;
  Options.TileSize := 64;
  Report := Baseline.CompareDocumentVisualsWith(Candidate, 96, Options);
  if Baseline.LastErrorCode <> 0 then
    Writeln('Visual diff failed: ', Report)
  else
    Writeln(Report);
end;

Las anclas son exactas al bit, y eso es una decisión de diseño, no un descuido. ColorTolerance se aplica solo a la comparación de páginas emparejadas, nunca a la huella. Una página sin nada más que ruido de anti-aliasing no anclará, pero sigue cayendo en un hueco, se empareja en orden con su contraparte y luego compara como unchanged bajo tolerancia. Anclas difusas serían más permisivas y mucho menos predecibles, porque páginas casi duplicadas (separadores en blanco, páginas de términos repetidas) empezarían a casarse entre sí

Ten claro qué es este alineamiento. El JSON lo llama exact-raster-lcs-gap-order a propósito: es un alineamiento de ráster, no reconocimiento semántico de páginas. Si un hueco contiene una página reescrita y una insertada, el emparejado en orden puede comparar la reescritura contra la inserción y reportar la original como eliminada. Los documentos llenos de páginas idénticas son igual de ambiguos. Cuando el emparejado importa para un veredicto, mira los pares, no solo los contadores

Leer el informe JSON

El informe de documento tiene una forma fija sobre la que puedes hacer aserciones en un test runner. Con éxito lleva "status":"complete", "complete":true, el nombre del alineamiento, firstPageCount, secondPageCount y los contadores changedPages, addedPages, removedPages y unchangedPages. El array pairs lista luego cada fila con firstPage, secondPage (0 para el lado ausente) y un kind de unchanged, changed, size_changed, added o removed

Las filas de tipo changed y unchanged incrustan un objeto comparison con width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null cuando nada cambió) y una lista regions de bounds más changedPixels. Una fila size_changed no lleva comparison en absoluto; reporta firstWidth, firstHeight, secondWidth y secondHeight y cuenta en changedPages. Estirar una página redimensionada para compararla produciría un muro de píxeles cambiados sin sentido, así que la biblioteca reporta el cambio de geometría y se queda ahí

El informe de documento nunca contiene máscaras ni overlays. CompareDocumentVisualsWith fuerza IncludeMask y IncludeOverlay a off aunque tus opciones lo pidan, así que la memoria se mantiene plana a medida que crece el número de páginas en lugar de guardar una vista previa de página completa por cada par. Usa el informe para encontrar las páginas interesantes y llama luego a CompareRenderedPagesWith solo sobre esas para obtener el overlay. Los fallos vuelven como "status":"error", "complete":false con una cadena error, y LastErrorCode se fija en 401

Comparar contra un archivo con CompareFileVisuals

TPDFlib.CompareFileVisuals es la forma de una llamada para CI: carga el otro PDF, copia sobre él los ajustes de render de la instancia actual y devuelve el mismo JSON de documento. La firma es CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), donde la tolerancia debe ser 0 a 255 y el DPI debe ser finito y positivo

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('baseline\statement.pdf', '') = 1 then
    begin
      Report := Lib.CompareFileVisuals('build\statement.pdf', '', 96, 8);
      Writeln(Report);
    end;
  finally
    Lib.Free;
  end;
end;

La herencia del renderer es la razón para preferir esta llamada a cargar el segundo archivo tú mismo. El documento cargado toma la escala de render, el tipo de crop y los ajustes de gráficos de la instancia actual, más la elección de motor Cairo o PDFium cuando están compilados. Si construyes dos instancias TPDFlib a mano para CompareDocumentVisualsWith, configurar ambas idénticas va por tu cuenta, y un desfase se manifiesta como cada página cambiada. Tampoco admite regiones a ignorar; acude a la API tipada cuando las necesites

Límites, coste y dónde encaja el diff estructural

PDFlibPas acota el trabajo de forma explícita para que una entrada hostil o enorme falle con error en lugar de agotar la memoria. Una página renderizada no puede pasar de 64 Mi píxeles, la rejilla de tiles tiene un tope de 4 Mi tiles y el alineamiento de páginas rechaza entradas donde la suma de páginas pasa de 8192 o la matriz LCS superaría 4 Mi celdas. La comparación de documentos además renderiza cada página dos veces, una para la huella y otra para la comparación del par, así que presupuesta eso a 150 DPI o más. Para la mayoría de suites de regresión, 72 a 96 DPI cazan cambios de maquetación y de texto en una fracción del tiempo

El diff visual y el diff estructural responden preguntas distintas, y una tubería seria normalmente quiere ambos. La comparación de píxeles te dice si un lector notaría algo. No puede decirte si un documento firmado ganó una anotación que se renderiza fuera de página, ni si una actualización incremental sustituyó en silencio el valor de un campo de formulario por el mismo texto visible. Para esa capa, clasificar qué cambió en un PDF después de firmarse recorre el diff a nivel de revisión. Y como la elección del motor gobierna los píxeles, el render multi-motor de PDF en Delphi merece lectura antes de fijar una baseline a un solo motor

FAQ

¿Estira la comparación visual de PDFlibPas las páginas que cambiaron de tamaño?

No. CompareRenderedPagesWith devuelve error para páginas de tamaño en píxeles distinto, y el informe de documento marca esos pares como size_changed con ambas dimensiones en lugar de compararlos

¿Por qué no se usan páginas casi idénticas como anclas?

Las anclas usan una huella SHA-256 exacta de los píxeles visibles más el tamaño de página, así que el alineamiento se mantiene predecible. La tolerancia se aplica después, al comparar las páginas emparejadas, así que las páginas de solo ruido acaban igualmente como unchanged

¿Puedo obtener una imagen overlay para cada página de un documento?

No desde la llamada de documento, que omite deliberadamente máscaras y overlays para mantener la memoria plana. Usa el JSON para encontrar las páginas cambiadas y llama luego a CompareRenderedPagesWith con IncludeOverlay sobre esas páginas

La comparación visual, el emparejado de páginas y el resto de las APIs de render y de documento vienen con la biblioteca PDF PDFlibPas para Delphi