Artículo técnico

WebP a PDF en Delphi: decodificador VP8L de HotPDF

HotPDF 2.747.0 decodifica imágenes WebP con un decoder VP8L (WebP lossless) escrito desde cero en Object Pascal, de modo que THotPDF.AddImageFromFile acepta directamente una ruta .webp, sin distribuir una DLL de libwebp ni lanzar un proceso auxiliar. El decoder implementa por completo la sección 3 de RFC 9649: el recorrido del contenedor RIFF, los códigos prefijo canónicos, las referencias inversas LZ77, la color cache y las cuatro transformaciones inversas. Los frames VP8 con pérdida se rechazan de forma explícita en lugar de decodificarse a medias

El desencadenante fue de lo más normal. Una herramienta de diseño exporta todos los recursos como WebP porque es el valor predeterminado moderno, los recursos llegan a un generador de facturas o catálogos que llevaba una década aceptando PNG y JPEG y, de repente, se rechaza la mitad de las entradas. La solución obvia es enlazar libwebp y seguir. También es la solución que convierte un componente VCL autocontenido en algo con una historia de despliegue

¿Por qué implementar VP8L en lugar de enlazar libwebp?

HotPDF implementa el codec en Pascal porque un componente Delphi que los clientes compilan dentro de su propio ejecutable no puede adquirir silenciosamente una DLL de runtime. Una dependencia nativa implica seguir un binario de 32 bits y otro de 64, fijar una versión, explicar una cadena de firma de código a quien ejecute el despliegue y añadir otro fichero que el antivirus de un terminal bloqueado puede decidir que no le gusta. Para un componente cuyo principal argumento es que se incorpora a un proyecto y funciona, es un coste real, no teórico. La otra mitad del argumento es que VP8L es pequeño: un formato de códigos prefijo más LZ77, con cuatro transformaciones inversas y un mapa de distancias de vecindad de 120 entradas, y todo el decoder de HPDFWebP.pas ocupa menos de 900 líneas de Pascal. Dentro de THotPDF.AddImage, la rama WebP está en el mismo dispatch de extensiones que ya dirige .jp2, .j2k, .jpt y .jpc hacia la ruta JPEG 2000, justo en el punto que se describe en el recorrido sobre añadir imágenes JPEG 2000 a PDF en Delphi. Los callers que quieran píxeles sin procesar en lugar de una imagen PDF pueden ir directamente a HPDFDecodeWebPLossless, que rellena un TWebPCardinalArray con valores $AARRGGBB en orden de scanline

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp se dirige al decoder VP8L integrado, sin ninguna DLL
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

¿Por qué un bitstream VP8L se lee en dos direcciones a la vez?

Porque el orden de bits del contenedor y el orden de bits de los códigos prefijo se especifican por separado, y VP8L elige convenciones opuestas para ellos. La sección 3.2 de RFC 9649 lo dice claramente: el bitstream se lee empezando por el bit menos significativo: el lector comienza en el bit 0 de un byte y avanza hacia arriba. Los códigos prefijo canónicos contenidos en ese stream llegan empezando por el bit más significativo, desde la raíz del árbol, así que el recorrido de decodificación desplaza su acumulador a la izquierda y hace OR con cada bit nuevo en la parte inferior. Por tanto, el lector y el recorrido del código avanzan en direcciones opuestas dentro del mismo bucle, algo que parece un bug cada vez que se vuelve a leer

function TWebPBitReader.ReadBit: Integer;
begin
  if BytePos >= Length(Data) then
    raise EWebPDecode.Create('WebP bitstream exhausted');
  Result := (Data[BytePos] shr BitPos) and 1;   // LSB primero, RFC 9649 3.2
  Inc(BitPos);
  if BitPos = 8 then
  begin
    BitPos := 0;
    Inc(BytePos);
  end;
end;

// El recorrido canónico va en la otra dirección: el primer bit que sale del stream
// es el bit más significativo del código
for Len := 1 to 15 do
begin
  Code := (Code shl 1) or BR.ReadBit;
  if Counts[Len] > 0 then
  begin
    if Code - First < Counts[Len] then
      Exit(Symbols[Index + Code - First]);
    First := (First + Counts[Len]) shl 1;
    Index := Index + Counts[Len];
  end
  else
    First := First shl 1;
end;

Tres detalles de RFC que desincronizan el stream en silencio

Tres semánticas de RFC 9649 aparecen escritas una sola vez, son fáciles de pasar por alto y cada una cuesta o ahorra un único bit, suficiente para convertir en ruido todas las tablas posteriores. Las tres aparecieron en el decoder VP8L de HotPDF y las tres producen el mismo síntoma: una imagen de aspecto plausible pero incorrecta en todas partes

  • Una imagen codificada por entropía en un rol no primario no escribe ningún bit de meta-prefijo. La ABNF de entropy-coded-image sencillamente no contiene ese elemento, por lo que leer uno desincroniza el stream en un bit. HotPDF pasa AllowMeta = False para la propia imagen de entropía, para los datos de transformación de predictor y color y para la paleta de indexación de color
  • Un código prefijo de una sola hoja consume cero bits. La sección 3.7.2.1 de RFC 9649 lo dice directamente, y el recorrido canónico leería alegremente un bit para después no saber dónde colocarlo, así que BuildHuff detecta un recuento total de símbolos igual a 1, marca el árbol como Single y decodifica ese único símbolo sin tocar el lector
  • Un valor cache_bits de 0 significa que el tamaño de la color cache es 0, no 1 shl 0. El desplazamiento cómodo da 1, por lo que el alfabeto verde 256 + 24 + CacheSize resulta ser 281 en lugar de 280 y todas las lecturas posteriores de tablas de códigos prefijo quedan desalineadas
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 significa realmente ninguno
if BR.ReadBit = 1 then
begin
  CacheBits := Integer(BR.ReadBits(4));
  if (CacheBits < 1) or (CacheBits > 11) then
    raise EWebPDecode.Create('WebP color cache bits out of range');
  CacheSize := 1 shl CacheBits;
end;

// RFC 9649 3.8.3: solo la imagen codificada espacialmente (ARGB) lleva
// el bit de prefijo meta; los roles codificados por entropía nunca lo escriben
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

// ...
ReadHuffCode(256 + 24 + CacheSize, Groups[I].Green);   // 280, no 281
ReadHuffCode(256, Groups[I].Red);
ReadHuffCode(256, Groups[I].Blue);
ReadHuffCode(256, Groups[I].Alpha);
ReadHuffCode(40, Groups[I].Dist);

En el fixture utilizado durante la puesta en marcha, los tres aparecieron en el bit 47, el bit 81 y el bit 89, en ese orden. Esas cifras son el motivo de esta sección. Ninguno de los tres se anunció como un off-by-one; cada uno se presentó como una imagen que terminaba de decodificarse y parecía estática, y lo único que los separó fue la posición exacta del bit en la que el stream dejó de coincidir con una referencia

¿Qué aporta comparar posiciones de bits?

La comparación de posiciones de bits convierte una pregunta inútil en otra de una sola línea: no «por qué está mal esta imagen», sino «por qué se desvió el stream en el bit 81». La preparación es barata. Pillow escribe cada fixture .webp más un volcado .rgba de su propia decodificación de la misma imagen; un probe Pascal y un pequeño modelo de referencia Python registran un contador de bits acumulado junto a cada lectura; la primera posición en la que discrepan ambos logs es donde vive el bug. Empiece con un fixture que ejercite lo mínimo posible: una imagen plana de 32x32 que solo utilice la ruta de código simple. Cuando esté verde, añada degradados, dimensiones impares y alfa, un fixture cada vez. Adivinar el orden de bits en su lugar es una forma de perder un día

La salvedad honesta es que la referencia también era incorrecta. El modelo Python olvidaba leer cache_bits y su bucle de transformación no llegaba al final, así que algunos puntos de divergencia eran el decoder de referencia perdiendo la sincronización, no el Pascal. Que una implementación de referencia esté mal no convierte en correcta la implementación que se está probando, y ninguna de las dos recibe el beneficio de la duda: cada divergencia tiene que decidirse contra el texto de la RFC. Extraiga también ese texto de la fuente. Los resúmenes de búsqueda suelen deformar las tablas numéricas, y el mapa de distancias de 120 entradas, los 14 modos de predictor y el multiplicador de color cache $1e35a7bd tienen que transcribirse exactamente

Dónde diverge la división entera de Pascal respecto a C

La transformación de color VP8L es de punto fijo 3.5 con deltas con signo, y ahí es donde Pascal y C dejan de coincidir. C desplaza los enteros negativos aritméticamente, lo que hace floor; div de Pascal trunca hacia cero. Para cualquier producto negativo, los dos resultados difieren en uno, por lo que la transformación inversa de color se desvía un paso de canal por píxel en toda la imagen. Por eso HotPDF hace explícito el floor en FloorDiv32 en lugar de confiar en div

// C desplaza aritméticamente y hace floor con negativos; div de Pascal trunca
// hacia cero, así que el caso negativo necesita una corrección explícita
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// Delta de punto fijo 3.5 entre el byte de un elemento de transformación y un
// byte de canal de color, ambos extendidos con signo primero
function ColorDelta(T, C: Integer): Integer;
var
  T8, C8: Integer;
begin
  T8 := T;
  if T8 >= 128 then
    Dec(T8, 256);
  C8 := C;
  if C8 >= 128 then
    Dec(C8, 256);
  Result := FloorDiv32(T8 * C8);
end;

Merece la pena nombrar esta clase de defecto porque es invisible en cualquier prueba cuyos fixtures produzcan por casualidad productos no negativos, donde div y floor coinciden. También es el motivo por el que las pruebas WebP de HotPDF afirman la igualdad exacta de píxeles frente a las decodificaciones de Pillow de los mismos ficheros, en lugar de una tolerancia: degradados, un tamaño impar de 100x37, una imagen de 40x40 con un canal alfa real y una imagen plana de 32x32, cada píxel se compara bit a bit. Un desvío de un paso supera una comprobación perceptual y falla una bitwise

Qué rechaza deliberadamente la compatibilidad WebP

HotPDF decodifica el primer chunk VP8L de un fichero WebP y nada más. Los frames VP8 con pérdida, las animaciones y cualquier contenedor cuyo chunk coincidente no sea VP8L devuelven False desde HPDFDecodeWebPLossless, y AddImage lo convierte en una excepción que nombra el fichero: Failed to decode WebP image (lossless VP8L only). Es un límite deliberado, no un descuido: un fichero con formato equivocado debe fallar donde el caller pueda preconvertirlo, en lugar de producir un rectángulo gris. El campo de versión debe ser 0, la pila de transformaciones está limitada a cuatro entradas y cada violación de límites genera EWebPDecode, que el punto de entrada público convierte en un False simple. Decodificar durante la importación es también la dirección opuesta a extraer imágenes de un documento que se ha abierto, algo que pasa por la ruta de imágenes cargadas descrita en la extracción de imágenes de un PDF cargado y sus filtros de decodificación. Además, cualquier decoder de imágenes es un parser alimentado por ficheros que usted no ha creado: si los recursos WebP llegan de clientes o de internet, estas comprobaciones de límites son el suelo, no el techo, y la respuesta más sólida es ejecutar los codecs de imagen en un proceso worker aislado para que un frame malformado no pueda derribar el host

El resultado práctico es que una aplicación Delphi o C++Builder puede introducir recursos WebP en un PDF igual que introduce PNG: una llamada a AddImageFromFile, una llamada a ShowImage y nada más en el instalador. Si quiere la parte restante de la pipeline de imágenes y documentos que lo rodea, el componente PDF Delphi HotPDF cubre los lados de escritura, carga y renderizado desde el mismo conjunto de unidades