Artigo Técnico

Descodificação de Predictor e LZWDecode de PDF em Delphi com o HotPDF

Um stream marcado com /Predictor 12 não significa que cada linha usa o filtro PNG 2. O HotPDF, o componente VCL nativo de PDF para Delphi e C++Builder, trata os valores de predictor de 10 a 15 como uma só família: a tag de filtro real, de 0 a 4, é o primeiro byte de cada linha codificada, e HPDFDecodePredictor lê e valida essa tag linha a linha. Essa distinção é a forma de quase todos os bugs neste canto do PDF, porque nada dispara um erro quando se erra. A cadeia de filtros corre, o raster tem o tamanho esperado, e a imagem sai como estática diagonal ou um gradiente que se desvia cada vez mais a cada linha de varrimento. Os cinco números em /DecodeParms (ISO 32000-1 §7.4.4) mudam sobretudo o significado dos bytes e não o seu comprimento, pelo que um valor errado produz lixo plausível em vez de um erro

Porque é que /Predictor 12 não significa o filtro PNG 2 em todas as linhas?

Porque o número do predictor só diz "está a ser usada predição PNG", não qual filtro. Os codificadores PNG escolhem um filtro por linha de varrimento e o filtro PDF herda isso, pelo que os valores de predictor 10 (None), 11 (Sub), 12 (Up), 13 (Average), 14 (Paeth) e 15 (Optimum) descodificam-se todos de forma idêntica: o byte de tag inicial de cada linha é o que o descodificador tem de obedecer. A consequência ao nível do layout importa tanto como a semântica. Cada linha codificada tem 1 + RowBytes bytes de comprimento, a entrada excede portanto a saída exatamente pelo número de linhas, e um stream cujo comprimento não é um múltiplo inteiro de RowBytes + 1 está truncado por definição. O HotPDF verifica essa fronteira antes de tocar num byte, rejeita qualquer tag acima de 4 com Invalid PNG predictor row tag, e lê a linha anterior diretamente do único buffer de saída em vez de materializar um array de linhas bidimensional. Os filtros 1 e 3 vão buscar BytesPerPixel atrás dentro da linha atual, o filtro 2 lê diretamente acima, o filtro 4 corre a escolha de Paeth sobre esquerda, cima e cima-esquerda — e os quatro operam sobre saída já reconstruída, e é por isso que a linha de cima tem de ser a linha descodificada e nunca a entrada filtrada

uses
  HPDFPredictor;

var
  Filtered, Raster: AnsiString;
  ErrorText: string;
begin
  // /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
  if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
       Int64(1024) * 3 * 8192, Raster, ErrorText) then
    ConsumeRaster(Raster)
  else
    LogStreamDefect('predictor', ErrorText);   // no exception, no partial raster
end;

O argumento MaxOutputBytes não é decoração. Uma fase de predictor é uma fase de descompressão disfarçada, e um valor de /Columns hostil ou simplesmente avariado transforma alguns quilobytes de entrada num pedido de alocação de vários gigabytes. O HotPDF calcula bits por linha, bytes por linha e tamanho total do raster primeiro em Int64, recusa geometria que provoque overflow, e respeita o teto fornecido pelo chamador. Passe um limite real derivado do dicionário de imagem e o modo de falha passa a ser uma mensagem registada em vez de uma janela de falta de memória numa máquina de cliente

Porque é que o TIFF Predictor 2 corrompe imagens de 4 bits?

Porque o Predictor 2 é diferenciação horizontal por amostra, não por byte, e a 1, 2 ou 4 bits por componente várias amostras partilham um byte. A implementação comum soma o byte N-Colors ao byte N, o que por acaso está correto a 8 bits por componente e está silenciosamente errado em todo o resto. Um scan RGB de 8 bits descodifica-se na perfeição, e depois o mesmo código destrói uma imagem indexada de 4 bits na primeira vez que uma aparece em produção

A aritmética correta funciona dentro do campo de bits. O HotPDF percorre amostras do índice Colors até Colors * Columns - 1, extrai a amostra e o seu vizinho à esquerda do mesmo componente com uma máscara de (1 shl BitsPerComponent) - 1 no deslocamento apropriado, soma-os módulo essa máscara, e escreve o resultado de volta sem perturbar as outras amostras empacotadas no mesmo byte. A cauda também importa: uma linha é preenchida até uma fronteira de byte, pelo que os bits de preenchimento depois da última amostra têm de sobreviver intocados em vez de serem incorporados na aritmética. A 16 bits por componente cada amostra é um par de bytes big-endian e a soma dá a volta em $FFFF através do par em vez de transportar entre bytes de forma independente; a 8 bits a recorrência simples de byte está certa, avançando de Colors em Colors para que o vermelho se acumule contra o vermelho e o alfa contra o alfa. Em todas as variantes o primeiro pixel de uma linha é um literal, nunca uma diferença, e a recorrência reinicia em cada fronteira de linha — a predição TIFF nunca lê a linha acima, o que é toda a diferença entre ela e a família PNG

O que é que o EarlyChange realmente controla no LZWDecode?

Controla quando o leitor alarga o seu tamanho de código em um bit, e estar um código fora de sincronia corrompe tudo o que se segue. O HotPDF exprime a regra como um único invariante: depois de acrescentar uma entrada ao dicionário, a próxima leitura alarga quando NextCode atinge (1 shl CodeSize) - Ord(EarlyChange). Com /EarlyChange 1, a predefinição do ISO 32000-1 §7.4.4, a mudança acontece um código mais cedo; com /EarlyChange 0 acontece exatamente na fronteira. Ambos aparecem em ficheiros reais e nada no bitstream diz qual deles o codificador usou. O resto da máquina de estados tem de se mover em sincronia: um código de clear reinicia o tamanho de código, a máscara de bits, o próximo código livre e o armazenamento de frases em conjunto, e o código de fim-de-informação é lido com a largura que estiver atual nesse momento, não nos 9 bits iniciais. O HotPDF começa em InitialCodeSize 9, limita o tamanho de código a 12 e o dicionário a 4096 entradas, e assume por defeito FillOrder como foTop porque o PDF empacota códigos com o bit de ordem mais alta primeiro — foBottom existe para streams ao estilo TIFF que não o fazem

uses
  HPDFLZW;

var
  Decoder: TPDFLZWDecompressor;
  Parms: TPDFLZWParms;
  Plain: AnsiString;
begin
  Decoder := TPDFLZWDecompressor.Create;
  try
    Decoder.EarlyChange := True;      // /EarlyChange 1 is the PDF default
    Decoder.FillOrder := foTop;       // high-order bit first
    Decoder.MaxOutputBytes := 256 * 1024 * 1024;
    Decoder.RequireInitialClear := False;
    Decoder.RequireEndOfInformation := False;

    Parms.Predictor := 12;
    Parms.Colors := 3;
    Parms.BitsPerComponent := 8;
    Parms.Columns := 1024;
    Parms.ExpandedTo8Bit := False;
    Parms.ColorSpace := 'DeviceRGB';

    if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
      LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
        Decoder.KwKwKExpansions, Decoder.OutputBytes)
    else
      LogStreamDefect('lzw', Decoder.LastError);
  finally
    Decoder.Free;
  end;
end;

As estatísticas existem para triagem, não por vaidade. Quando um ficheiro descodifica para o comprimento certo mas os pixels errados, PeakCodeSize e DictionaryAdds dizem imediatamente se o leitor alguma vez alargou onde o escritor o fez. Troque o EarlyChange, descodifique de novo, compare os dois: se os números mudarem, tem a sua resposta numa única execução em vez de percorrer passo a passo um leitor de bits

O ramo KwKwK, e quando um stream deve simplesmente falhar

O único caso legal que parece ilegal é Code = NextCode, e o HotPDF trata-o construindo a entrada antes de a emitir. Um codificador pode emitir o código para uma frase que está a definir no mesmo passo, o que acontece sempre que a entrada contém um padrão da forma K w K w K; o descodificador não consegue procurar esse código porque ele ainda não existe, pelo que tem de construir Previous + First(Previous), acrescentá-lo como a nova entrada, e emitir a entrada que acabou de criar. O HotPDF conta essas ocorrências em KwKwKExpansions e verifica de forma cruzada que o código que acrescentou é o código que lhe foi pedido. Tudo acima de NextCode é corrupção, e aí um descodificador deve parar em vez de improvisar: o HotPDF dispara um erro num código futuro, num prefixo de dicionário que aponte para fora da área de frases, num dicionário cheio, e num primeiro código que não seja um literal. Dois interruptores de rigor estão deliberadamente desligados por predefinição, RequireInitialClear e RequireEndOfInformation, porque muitos PDFs de produção omitem o código de clear inicial ou ficam sem dados sem um terminador. Ative-os ao validar a sua própria saída, deixe-os desligados ao consumir ficheiros vindos do mundo real

Onde o /DecodeParms é de facto lido do lado do documento carregado

O HotPDF resolve /DecodeParms ou a sua abreviatura /DP no dicionário do stream de imagem, aceita tanto um dicionário como um array e usa o último elemento quando é um array, depois transporta Predictor, Colors, BitsPerComponent, Columns e EarlyChange para o caminho do raster. O caso do array é aquele que as pessoas esquecem: um stream filtrado por [/ASCII85Decode /FlateDecode] transporta um array de parâmetros paralelo, e as definições do predictor pertencem ao último filtro, não ao primeiro

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
      for I := 0 to Pdf.GetLoadedImageCount - 1 do
        if Pdf.GetLoadedImageInfo(I, Info) then
        begin
          Bmp := Pdf.ExtractLoadedImage(I);   // nil when the raster is unusable
          if Bmp <> nil then
          try
            Bmp.SaveToFile(Format('image-%d.bmp', [I]));
          finally
            Bmp.Free;
          end;
        end;
  finally
    Pdf.Free;
  end;
end;

Vale a pena nomear um defeito histórico nesse caminho, porque a classe de bug repete-se. A antiga rotina de Flate com parâmetros criava um stream de descompressão e depois copiava a partir da entrada comprimida original, pelo que a fase do predictor recebia bytes comprimidos e desfazia obedientemente a predição neles: sempre errado, nunca disparava um erro. O código atual só lê a partir do descodificador antes de entregar o resultado ao predictor partilhado, e rejeita um raster mais curto do que o tamanho calculado em vez de recuar para os bytes ainda comprimidos — um fallback que costumava transformar uma falha de descodificação num bitmap corrompido. Essa mesma implementação de predictor serve agora também as cross-reference streams, o que é uma coerência útil se também trabalhar com object streams e atualizações incrementais, e a maquinaria de extração em redor está coberta na peça complementar sobre extrair imagens carregadas e os seus filtros de descodificação. Imagens que chegam como DCTDecode ou JPXDecode nunca chegam de todo ao predictor; transportam o seu próprio modelo de pixels comprimido

Débito: uma área de frases contígua contra strings por entrada

Substituir o dicionário de strings por entrada por uma área de frases contígua mediu cerca de 1,61 vezes mais rápido numa entrada patológica: 1558 MiB/s contra 969 MiB/s num benchmark cuja frase mais longa isolada atinge 7 370 880 bytes. A forma dessa entrada explica a diferença, porque as implementações clássicas escolhem uma de duas más trocas. Um dicionário de valores AnsiString aloca e copia uma string nova para cada uma de até 4096 entradas, cada nova entrada a copiar o seu pai inteiro; uma pilha de prefixo/sufixo evita essa memória por completo mas reconstrói cada frase percorrendo a cadeia ao contrário um byte de cada vez e depois invertendo-a, o que está bem para texto normal e é penoso quando uma frase chega a megabytes. O HotPDF acrescenta cada frase de forma contígua a uma área que cresce geometricamente, indexa entradas por offset e comprimento, e emite uma frase com um único Move para o buffer de saída. O custo honesto é a memória: uma área que guarda cada frase na íntegra é limitada pela soma de todos os comprimentos de frase em vez de pela contagem de entradas, e é precisamente por isso que MaxOutputBytes existe tanto no descompressor como no predictor. Derive esse limite do que o dicionário de imagem afirma que o raster deveria ser, e um stream mentiroso falha rapidamente

O descompressor LZW, o predictor partilhado e o caminho de extração de imagens carregadas aqui mostrados vêm incluídos no HotPDF Component padrão para Delphi e C++Builder, com a referência completa de filtros e DecodeParms na página de produto