Artículo técnico

Por qué algunos flujos de objetos PDF se decodifican como basura en Delphi

Un flujo de objetos PDF que se infla sin error pero que aun así se lee como ruido normalmente le falta un paso: revertir el Predictor de ISO 32000-1. Cuando el diccionario /DecodeParms de un flujo lleva /Predictor 2 o superior, los bytes que devuelve FlateDecode no son los datos originales, son valores diferenciados por fila al estilo PNG o diferenciados horizontalmente al estilo TIFF que necesitan una segunda pasada de reconstrucción antes de que tenga sentido ninguna búsqueda en diccionario. PDFiumPas, la biblioteca de componente PDF VCL nativo para Delphi y C++Builder, añadió esa pasada de reconstrucción en la v2.16.0, específicamente porque los flujos de objetos de PDF 1.5+ se expandían a bytes diferenciados que ningún analizador de diccionarios podía leer

Por qué FlateDecode por sí solo no basta

El propio FlateDecode es solo descompresión DEFLATE (ISO 32000-1 §7.4.4.1): reproduce cualquiera que sea los bytes que el codificador entregó al compresor, nada más. El Predictor vive una capa por encima, en el diccionario /DecodeParms del flujo, y describe una transformación que el codificador aplicó antes de comprimir; la diferenciación convierte largas tiradas de valores estructurados similares, como los enteros densamente empaquetados dentro de un flujo de referencia cruzada o de un flujo de objetos, en largas tiradas de números pequeños que DEFLATE comprime mucho mejor. ISO 32000-1 §7.4.4.3 (Tabla 8) es explícita en que deshacer esta transformación forma parte de decodificar un flujo filtrado, no una pasada de limpieza opcional, y sin embargo es fácil escribir un ayudante de FlateDecode que solo llame a inflate y se detenga ahí

El síntoma es distintivo en cuanto sabéis qué buscar. Los bytes diferenciados por Predictor no son ruido aleatorio, todavía llevan la forma de un flujo comprimido, así que un analizador ingenuo a menudo pasa por delante de unos pocos tokens de aspecto válido antes de toparse con una secuencia de bytes que sencillamente no puede ser un nombre, número o delimitador PDF, y filas distintas fallan en desplazamientos distintos según cuánto hayan resultado diferir los valores subyacentes de sus vecinos. Esa inconsistencia es lo que hace difícil acotar el fallo a partir de un único archivo que falle: dos PDF del mismo productor pueden diferir solo en qué valores resultan repetirse, así que uno se analiza casi por accidente mientras el otro falla de plano

¿Qué hace realmente el parámetro Predictor de PDF?

La entrada /Predictor en /DecodeParms le indica a un lector conforme qué reversión ejecutar, y la Tabla 8 de ISO 32000-1 define los valores que importan en la práctica: 1 significa que no se aplicó ninguna predicción, 2 selecciona el Predictor 2 de TIFF (diferenciación horizontal), y cualquier valor de 10 a 15 selecciona la predicción al estilo PNG. Tres claves más viajan junto a ella, /Colors, /BitsPerComponent y /Columns, y juntas describen la geometría de fila contra la que se calculó la diferenciación, incluso cuando el flujo no contiene ningún dato de imagen en absoluto: un flujo de objetos no es una imagen, pero los escritores de PDF reutilizan la misma maquinaria de predictor basada en filas para él porque diferenciar-y-luego-deflate comprime enteros y desplazamientos de objeto densamente empaquetados mejor que deflate-ar en bruto

El Predictor 2 de TIFF es el más simple de los dos esquemas: cada componente se almacena como la diferencia respecto al mismo componente en el píxel anterior de la misma fila, y cada fila se reinicia en su borde izquierdo en lugar de arrastrar una diferencia desde la fila de arriba. La predicción PNG es más particular, porque el filtro real puede cambiar de fila a fila: cada fila empieza con un único byte de etiqueta, 0 para None, 1 para Sub, 2 para Up, 3 para Average, 4 para Paeth, y es esa etiqueta, no el valor /Predictor declarado, la que decide cómo se reconstruye esa fila concreta. Un /Predictor de 12 en realidad no es más que la pista del codificador de que favoreció el filtro Up, donde cada byte se restaura sumando el byte directamente encima de él en la fila anterior, pero un decodificador correcto todavía tiene que leer la etiqueta en cada fila en lugar de asumir Up en todas partes

¿Por qué hacen invisible los flujos de objetos un Predictor omitido?

Los flujos de objetos agravan el problema en lugar de limitarse a repetirlo. ISO 32000-1 §7.5.7 permite que un escritor de PDF 1.5+ empaquete varios objetos indirectos en un único contenedor comprimido, un /ObjStm, y es habitual que precisamente los objetos que más necesita un validador, el catálogo, /OutputIntents, o un flujo de metadatos XMP /Metadata, viajen a través de ese contenedor con /Predictor 12 adjunto, porque esos objetos son lo bastante cortos y repetitivos como para beneficiarse de la diferenciación por fila. Cuando falta el paso de predictor, expandir el flujo de objetos no lanza ningún error: produce una secuencia de bytes que parece superficialmente plausible pero que no se tokeniza en los objetos esperados, así que lo que fuera que estuviera empaquetado dentro sencillamente no aparece. El renderizado rara vez lo nota, porque un motor de renderizado conforme ya reconstruye los datos diferenciados por predictor antes de que lleguen jamás a la maquetación; el código que sí lo nota es exactamente del tipo en el que este fallo se escondía: un validador, firmante o comprobador de versión que recorre los propios bytes PDF en bruto para responder a una pregunta estructural, sin ningún respaldo en cuanto su propia visión del flujo de objetos vuelve equivocada

PDFiumPas se topó exactamente con este fallo antes de la v2.16.0. Los flujos de objetos construidos con /Predictor 12, el caso habitual para los escritores de PDF 1.5+, se expandían mediante PdfExpandObjectStreams a bytes diferenciados que el escáner estructural no podía analizar, así que el catálogo, /OutputIntents y los objetos /Metadata empaquetados dentro eran efectivamente invisibles para los escaneos de conformidad, sin excepción, sin advertencia, solo un escaneo que en silencio se comportaba como si esos objetos estuvieran ausentes. La mecánica más profunda de cómo resuelve PDFiumPas un flujo de objetos contra la tabla de referencia cruzada activa, incluidos los casos híbridos y de flujo xref puro, se cubre por separado en el artículo sobre la validación de flujos de objetos y xref con PDFiumPas; el paso de predictor descrito aquí se ejecuta después de esa resolución, sobre los bytes que realmente contiene cada objeto comprimido

Revertir filas de Predictor PNG y TIFF en Pascal

PDFiumPas revierte la diferenciación en una única rutina, PdfApplyPredictor, y su aritmética de geometría merece la pena conocerla tanto si la llamáis como si reimplementáis la idea en vuestro propio código Delphi. El ancho de fila en bytes es ceil(Columns × Colors × BitsPerComponent ÷ 8) y el ancho de byte por píxel que usan ambos algoritmos es ceil(Colors × BitsPerComponent ÷ 8), equivocaos en cualquiera de los dos redondeos y la reconstrucción lee a través de un límite de fila en lugar de dentro de una. Un /Predictor por debajo de 2 se deja intacto, ya que 1 significa que el codificador no aplicó ninguna transformación en absoluto; 2 selecciona la rama TIFF mostrada abajo, y cualquier valor de 10 en adelante recae en la reconstrucción de filtro de fila PNG, donde el byte de etiqueta al principio de cada fila, no el valor /Predictor declarado, decide cómo se deshace esa fila concreta

function PdfApplyPredictor(const Src: TBytes;
  Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
  RowLen, Bpp, R, I: Integer;
begin
  Result:= Src;
  if Predictor< 2 then
    Exit;                                   // 1 = no prediction, nothing to undo
  if Colors<= 0 then Colors:= 1;
  if Bpc<= 0 then Bpc:= 8;
  if Columns<= 0 then Columns:= 1;
  if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
    Exit;                                   // reject hostile row geometries
  RowLen:= (Columns* Colors* Bpc+ 7) div 8;  // ceil(), per ISO 32000-1 Table 8
  Bpp:= (Colors* Bpc+ 7) div 8;
  if Predictor= 2 then
  begin
    if Bpc<> 8 then
      Exit;                                 // only the 8-bit layout is reconstructed
    Result:= Copy(Src, 0, Length(Src));
    R:= 0;
    while R+ RowLen<= Length(Result) do
    begin
      for I:= R+ Bpp to R+ RowLen- 1 do
        Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
      Inc(R, RowLen);
    end;
    Exit;
  end;
  // Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
  SrcOfs:= R* (RowLen+ 1);
  DstOfs:= R* RowLen;
  Tag:= Src[SrcOfs];
  Inc(SrcOfs);
  for I:= 0 to RowLen- 1 do
  begin
    if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0;   // byte to the left
    if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0;   // byte above
    case Tag of
    1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A);            // Sub
    2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B);            // Up
    3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
    // Paeth (tag 4) adds whichever of A, B or the byte above-left sits
    // closest to the linear predictor A+ B- C; tag 0 (None) copies the
    // filtered byte through unchanged
    else Result[DstOfs+ I]:= Src[SrcOfs+ I];
    end;
  end;
end;

Qué cambió PDFiumPas en la v2.16.0

La solución distribuida en PDFiumPas v2.16.0 reside dentro de PdfReadAndDecodeStream, la rutina que lee los bytes en bruto de un flujo y los decodifica para cada llamador que necesite inspeccionar la estructura PDF a nivel de byte, incluida la expansión de flujos de objetos; solo intenta la reconstrucción tras confirmar que /Filter es un FlateDecode desnudo, nunca una cascada, porque un filtro encadenado no puede corregirse con seguridad por predictor en esta capa. Leer /Predictor, /Colors, /BitsPerComponent y /Columns de vuelta del diccionario de flujo tampoco necesita un analizador de diccionarios general: PdfDictRefNum encuentra cada clave mediante búsqueda directa de token de nombre dentro del rango de bytes de ese único diccionario, lo cual es seguro aquí precisamente porque esas cuatro claves no pueden repetirse ni anidarse dentro de un único diccionario de flujo. Esa misma búsqueda de token de nombre es mucho más arriesgada en cuanto se apunta a una región más grande o menos acotada de un archivo PDF, que es el tema de el artículo complementario sobre el análisis seguro de diccionarios PDF

// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
  Inflated:= PdfInflate(Raw);
  Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
  if Predictor>= 2 then
  begin
    PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
    PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
    PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
    Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
  end
  else
    Result:= Inflated;
end;

Antes de la v2.16.0, un flujo de objetos construido con /Predictor 12 se expandía a bytes diferenciados sin lanzar ningún error, así que cualquier objeto de catálogo, /OutputIntents o /Metadata empaquetado dentro desaparecía de los escaneos estructurales de PDFiumPas sin ninguna advertencia. Tras la solución, el mismo flujo de objetos se infla y después se reconstruye correctamente, y los objetos empaquetados dentro vuelven a ser visibles para esos escaneos. Unos límites defensivos viajaron junto con la solución: PdfApplyPredictor ahora rechaza de plano /Colors por encima de 64, /BitsPerComponent por encima de 32, y /Columns por encima de 2^24, porque esas combinaciones describen geometrías de fila que ningún productor de PDF real necesita y existen principalmente para hacer que un decodificador reserve muchísima más memoria de la que justifican los bytes de entrada

var
  Pdf: TPdf;
  Report: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'incoming.pdf';
    Pdf.Active := True;
    Report := Pdf.ValidatePdfA;
    if not Report.IsCompliant then
      LogNonCompliance(Report); // caller-supplied handler
  finally
    Pdf.Free;
  end;
end;

Límites que merece la pena conocer

La reconstrucción de predictor de PDFiumPas tiene dos límites que merece la pena conocer antes de confiar en ella. La reconstrucción del Predictor 2 de TIFF solo cubre el caso de 8 bits por componente; PDF permite empaquetados más estrechos, pero los datos diferenciados por TIFF con menos de un byte pasan sin reconstruir en lugar de intentar adivinarse, así que un flujo que declare /Predictor 2 con /BitsPerComponent 1, 2 o 4 hoy no se decodificará correctamente a través de esta vía. La predicción PNG no tiene esa restricción, cada fila suministra su propia etiqueta de filtro, y los cinco tipos definidos se reconstruyen sin importar cuál resulte ser el valor /Predictor declarado entre 10 y 15, lo cual coincide con cómo funciona realmente el filtrado al estilo PNG: el valor declarado se parece más a una pista sobre lo que usó mayoritariamente el codificador que a una promesa sobre cada fila

El motor de renderizado nativo de PDFium ya reconstruye correctamente los datos de imagen y de flujo de contenido diferenciados por predictor, que es exactamente por qué un archivo puede renderizarse perfectamente en cualquier visor ordinario mientras un validador, firmante o comprobador de versión a nivel de byte construido encima lee esos mismos bytes mal. La decodificación consciente de predictor descrita aquí respalda las funciones de validación PDF/A, escaneo estructural y firma de PDFiumPas, el componente PDFium VCL nativo para Delphi y C++Builder