Artigo Técnico

WebP para PDF no Delphi: decoder VP8L do HotPDF

O HotPDF 2.747.0 decodifica imagens WebP com um decoder VP8L (WebP lossless) escrito do zero em Object Pascal, então THotPDF.AddImageFromFile aceita diretamente um caminho .webp, sem DLL libwebp para distribuir e sem processo auxiliar para iniciar. O decoder implementa integralmente a seção 3 da RFC 9649: percurso do contêiner RIFF, códigos de prefixo canônicos, referências reversas LZ77, color cache e as quatro transformações inversas. Frames VP8 com perdas são recusados de forma explícita, em vez de parcialmente decodificados

O gatilho foi banal. Uma ferramenta de design exporta cada asset como WebP porque esse é o default moderno, os assets chegam a um gerador de faturas ou catálogos que há uma década consome PNG e JPEG sem problemas, e de repente metade das entradas é rejeitada. A correção óbvia é vincular libwebp e seguir em frente. A correção óbvia também é a que transforma um componente VCL autocontido em algo com uma história de distribuição

Por que implementar VP8L em vez de vincular libwebp?

O HotPDF implementa o codec em Pascal porque um componente Delphi que os clientes compilam no próprio executável não pode adquirir silenciosamente uma DLL de runtime. Uma dependência nativa significa acompanhar um binário de 32 bits e outro de 64, fixar uma versão, explicar uma cadeia de assinatura de código a quem executa a distribuição e adicionar mais um arquivo que o antivírus de um terminal restrito pode decidir não gostar. Para um componente cujo principal argumento é entrar em um projeto e funcionar, esse é um custo real, não teórico. A outra metade do argumento é que VP8L é pequeno: um formato de códigos de prefixo mais LZ77 com quatro transformações inversas e um mapa de distância de vizinhança com 120 entradas, e o decoder inteiro em HPDFWebP.pas tem menos de 900 linhas de Pascal. Dentro de THotPDF.AddImage, o branch WebP fica no mesmo dispatch de extensão que já encaminha .jp2, .j2k, .jpt e .jpc para o caminho JPEG 2000, portanto o plumbing já existia no mesmo ponto descrito no passo a passo de adicionar imagens JPEG 2000 a PDFs no Delphi. Chamadores que querem pixels brutos em vez de uma imagem PDF podem ir direto para HPDFDecodeWebPLossless, que preenche um TWebPCardinalArray de valores $AARRGGBB na ordem de scan line

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp e encaminhado para o decoder VP8L integrado, sem 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 que um bitstream VP8L é lido em duas direções ao mesmo tempo?

Porque a ordem de bits do contêiner e a ordem de bits do código de prefixo são especificadas de forma independente, e VP8L escolhe convenções opostas para elas. A seção 3.2 da RFC 9649 afirma claramente que o bitstream é lido do bit menos significativo para o mais significativo: o leitor começa no bit 0 de um byte e sobe. Os códigos de prefixo canônicos dentro desse stream chegam do bit mais significativo para o menos significativo, com a raiz da árvore primeiro, então o percurso de decode desloca o acumulador para a esquerda e faz OR de cada bit novo na parte inferior. A leitura e o percurso do código, portanto, seguem direções opostas dentro do mesmo loop, o que parece um bug toda vez que você relê o código

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

// o percurso canonico vai na outra direcao: o primeiro bit retirado do stream
// e o bit mais significativo do codigo
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;

Três detalhes da RFC que dessincronizam o stream silenciosamente

Três semânticas da RFC 9649 são declaradas exatamente uma vez, são fáceis de passar por cima e cada uma custa ou economiza um único bit, o suficiente para transformar toda tabela posterior em ruído. As três foram encontradas no decoder VP8L do HotPDF, e as três produzem o mesmo sintoma: uma imagem com aparência plausível que está errada em todos os pontos

  • Uma imagem codificada por entropia em um papel não primário não grava nenhum bit de meta-prefixo. A ABNF de entropy-coded-image simplesmente não contém o item, então ler um dessincroniza o stream em um bit. O HotPDF passa AllowMeta = False para a própria imagem de entropia, para os dados de transformação de predictor e de cor e para a paleta de indexação de cor
  • Um código de prefixo com uma única folha consome zero bits. A seção 3.7.2.1 da RFC 9649 diz isso diretamente, e o percurso canônico leria um bit e depois não conseguiria posicioná-lo, então BuildHuff detecta uma contagem total de símbolos igual a 1 e marca a árvore como Single, decodificando esse símbolo sem tocar no reader
  • Um valor cache_bits igual a 0 significa que o tamanho do color cache é 0, não 1 shl 0. O shift conveniente produz 1, fazendo o alfabeto verde 256 + 24 + CacheSize resultar em 281 em vez de 280, e toda tabela de códigos de prefixo lida depois fica desalinhada
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 realmente significa nenhum
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: somente a imagem codificada espacialmente (ARGB) carrega o
// bit de meta-prefixo; papeis codificados por entropia nunca o gravam
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

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

No fixture usado durante o bring-up, os três apareceram nos bits 47, 81 e 89, nessa ordem. Esses números são o ponto desta seção. Nenhum dos três se apresentou como um off-by-one; cada um apareceu como uma imagem que terminou de decodificar e parecia estática, e a única coisa que os separou foi a posição exata do bit em que o stream deixou de concordar com uma referência

O que o diff por posição de bit proporciona?

O diff por posição de bit transforma uma pergunta inútil em uma pergunta de uma linha: não "por que esta imagem está errada", mas "por que o stream divergiu no bit 81". A preparação é barata. O Pillow grava cada fixture .webp junto com um dump .rgba do próprio decode da mesma imagem; uma probe Pascal e um pequeno modelo de referência em Python registram um contador de bits corrente ao lado de cada leitura; a primeira posição em que os dois logs discordam é onde o bug está. Comece com um fixture que exerça o mínimo possível: uma imagem plana de 32x32 que use apenas o caminho de código simples. Faça essa passar, depois acrescente gradientes, dimensões ímpares e alpha, um fixture por vez. Adivinhar a ordem dos bits é uma forma de gastar um dia

A ressalva honesta é que a referência também estava errada. O modelo Python esqueceu de ler cache_bits e seu loop de transformação não ia até o fim, então alguns pontos de divergência eram o decoder de referência perdendo a sincronização, não o Pascal. Uma implementação de referência estar errada não torna correta a implementação sob teste, e nenhum dos lados recebe o benefício da dúvida: cada divergência precisa ser julgada contra o texto da RFC. Busque esse texto na fonte também. Resumos de busca frequentemente estragam tabelas numéricas, e o mapa de distância de 120 entradas, os 14 modos de predictor e o multiplicador de color cache $1e35a7bd precisam ser transcritos exatamente

Onde a divisão inteira do Pascal diverge da de C

A transformação de cor VP8L é de ponto fixo 3.5 com deltas com sinal, e é aí que Pascal e C deixam de concordar. C desloca inteiros negativos aritmeticamente, o que arredonda para baixo; o div do Pascal trunca em direção a zero. Para qualquer produto negativo, os dois diferem por um, então a transformação de cor inversa deriva um passo de canal por pixel ao longo de toda a imagem. Por isso o HotPDF explicita o piso em FloorDiv32, em vez de depender de div

// C desloca aritmeticamente e arredonda para baixo nos negativos; Pascal div trunca
// em direcao a zero, portanto o caso negativo precisa de uma correcao explicita
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// delta de ponto fixo 3.5 entre o byte de um elemento de transformacao e o
// byte de um canal de cor, ambos estendidos com sinal primeiro
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;

Vale nomear essa classe de defeito porque ela é invisível em qualquer teste cujos fixtures por acaso produzam produtos não negativos, em que div e piso concordam. Ela também explica por que os testes de WebP do HotPDF afirmam igualdade exata de pixels contra decodes do Pillow dos mesmos arquivos, em vez de uma tolerância: gradientes, um tamanho ímpar de 100x37, uma imagem de 40x40 com um canal alpha real e uma imagem plana de 32x32, cada pixel comparado bit a bit. Um desvio de um passo passa por uma verificação perceptual e falha por uma bitwise

O que o suporte a WebP recusa de propósito

O HotPDF decodifica o primeiro chunk VP8L de um arquivo WebP e nada mais. Frames VP8 com perdas, animações e qualquer contêiner cujo chunk correspondente não seja VP8L retornam False de HPDFDecodeWebPLossless, e AddImage transforma isso em uma exceção que nomeia o arquivo: Failed to decode WebP image (lossless VP8L only). Esse é um limite deliberado, não uma omissão: um arquivo em formato errado deve falhar onde o chamador possa convertê-lo antes, em vez de produzir um retângulo cinza. O campo de versão precisa ser 0, a pilha de transformações é limitada a quatro entradas e toda violação de limites levanta EWebPDecode, que o entry point público converte em um simples False. Decodificar na importação também é o sentido oposto de extrair imagens de um documento aberto, o que passa pelo caminho de imagens carregadas descrito em extrair imagens de um PDF carregado e seus filtros de decode. E qualquer decoder de imagem é um parser alimentado por arquivos que você não criou: se os assets WebP chegam de clientes ou da internet pública, as verificações de limites aqui são o piso, não o teto, e a resposta mais forte é executar codecs de imagem em um processo worker isolado, para que um frame malformado não derrube o host junto com ele

O resultado prático é que uma aplicação Delphi ou C++Builder pode agora colocar assets WebP em um PDF da mesma forma que coloca PNG: uma chamada a AddImageFromFile, uma chamada a ShowImage, nada extra no instalador. Se você quer o restante do pipeline de imagem e documento ao redor disso, o componente PDF Delphi HotPDF cobre os lados de escrita, carregamento e renderização a partir do mesmo conjunto de units