Artigo Técnico

Halftone JBIG2 no PDFlibPas: HSKIP e offsets negativos

O PDFlibPas corrigiu duas falhas independentes no seu descodificador nativo de regiões halftone JBIG2: na v3.539.37 a máscara de salto HSKIP é indexada como HSKIP[ng, mg], como a ITU-T T.88 §6.6.5.1 a define, e na v3.539.38 grelhas que alcancem coordenadas negativas, por um HGX ou HGY negativo ou por rotação, são colocadas com um floor shift verdadeiro. Antes dessas versões, as regiões halftone afetadas saíam embaralhadas ou deslocadas, sem nenhum erro levantado. Os dois bugs esconderam-se atrás de dados de teste que por acaso eram simétricos ou não negativos, e o segundo accende uma propriedade de Delphi e Free Pascal que morde bem para fora do JBIG2: shr sobre um inteiro com sinal é um shift lógico, não o >> aritmético que a norma assume

As regiões halftone são o tipo de região JBIG2 menos comum, por isso um descodificador pode processar milhares de documentos digitalizados antes de encontrar uma fotografia screenada codificada como tal. Quando encontra, a falha é desagradável: o ficheiro analisa bem, os comprimentos de segmento somam certo, a página tem o tamanho certo, e a região é lixo

O que é que uma região halftone JBIG2 descodifica afinal?

Uma região halftone JBIG2 é uma grelha de pequenos bitmaps retirados de um pattern dictionary, e o verdadeiro trabalho do descodificador é calcular um índice para cada célula da grelha e a posição em pixels onde essa célula cai. O pattern dictionary guarda HNUMPATS padrões de HPW × HPH pixels. O segmento de região halftone descreve então uma grelha de HGW colunas por HGH linhas e uma imagem em tons de cinzento do mesmo tamanho, codificada como bitplanes com codificação de Gray. Cada bitplane é descodificado com o procedimento de região genérica sobre um bitmap HGW × HGH, o plano mais significativo primeiro, e os planos em conjunto dão a cada célula o seu índice de padrão

A colocação de células usa aritmética de vírgula fixa com uma fração de 8 bits. A origem da grelha HGX, HGY é um par de valores de 32 bits, e o vetor da grelha HRX, HRY descreve o passo entre células vizinhas, o que permite uma grelha rodada. Para a linha de grelha mg e a coluna de grelha ng, a T.88 §6.6.5 calcula a posição em pixels como:

  • x = (HGX + mg × HRY + ng × HRX) >> 8
  • y = (HGY + mg × HRX − ng × HRY) >> 8

A máscara de salto entra pela flag opcional HENABLESKIP. Quando a flag está ligada, a §6.6.5.1 constrói um bitmap HSKIP de HGW × HGH e põe HSKIP[ng, mg] a 1 para cada célula cujo padrão esteja inteiramente fora da região: x + HPW <= 0, x >= HBW, y + HPH <= 0 ou y >= HBH. Os bitplanes em tons de cinzento são então descodificados com essa máscara como o bitmap de salto da região genérica, por isso o descodificador aritmético nem lê nem atualiza contexto numa célula saltada. Descodificador e codificador têm de concordar em todos os bits do HSKIP, ou os dois codificadores aritméticos descarrilam

Porque é que uma máscara HSKIP transposta só partiu grelhas não quadradas?

A máscara de salto era escrita com as coordenadas trocadas, e só uma grelha não quadrada a expunha, porque uma grelha quadrada mantém cada coordenada trocada dentro da máscara. O PDFlibPas guarda bitmaps com um acessor de pixels (column, row), e o código que construía a máscara passava (mg, ng), linha primeiro. O descodificador de bitplanes em tons de cinzento lê a máscara corretamente como (ng, mg). O loop de colocação de padrões lia-a de volta na ordem trocada do construtor, por isso os dois concordavam, e uma revisão só da lógica de colocação deixaria passar. Uma armadilha de nomes piorou isso: no loop de colocação a variável chamada col itera linhas de grelha e Row itera colunas de grelha

Tome a grelha de 5 × 3 de padrões 4 × 4 numa região de 16 × 8 que a v3.539.37 usa como caso de regressão. Com HRX = 1024 e HRY = 0, a coluna de grelha 4 cai em x = 16 e a linha de grelha 2 em y = 8, ambos fora da região. A máscara correta marca sete células: a coluna 4 inteira e a linha 2 inteira. As escritas trocadas tentavam pôr pixels nos índices de linha 3 e 4 numa máscara de só três linhas de altura, e o setter do bitmap ignorava em silêncio essas escritas fora do intervalo. O que sobreviveu foi a coluna 2, linhas 0 a 2. O descodificador saltava por isso duas células que o codificador tinha codificado, e descodificava seis células que o codificador tinha saltado

Máscaras de salto halftone JBIG2 do PDFlibPas para uma grelha de 5 por 3 em que a HSKIP[ng, mg] correta marca a coluna 4 e a linha 2 como saltadas, enquanto as escritas transpostas visadas às linhas 3 e 4 de uma máscara de três linhas eram silenciosamente descartadas e só a coluna 2 sobreviveu, dessincronizando os codificadores aritméticos
Só uma grelha não quadrada expõe uma máscara transposta, e a dessincronização de codificadores resultante embaralha a região em vez de levantar um erro

O descodificador aritmético não falha quando isso acontece. Descodifica pixels extra a partir de bits que pertencem a células posteriores, os seus contextos leem vizinhos errados, e todos os índices de padrão depois da primeira discórdia são ruído, razão pela qual o sintoma era uma região embaralhada em vez de algumas células fora do sítio. Numa grelha quadrada o mesmo bug é muitas vezes invisível: nenhuma coordenada trocada sai da máscara, e quando as células fora da região são simétricas quanto à diagonal — uma grelha que transborde as margens direita e inferior pelo mesmo número de células, por exemplo — a máscara transposta é bit a bit a correta. O HENABLESKIP é também opcional, tem de ser 0 quando a imagem em tons de cinzento é codificada em MMR, e raramente é ligado pelos codificadores, por isso o bug tinha muito poucas maneiras de aparecer. Desde a v3.539.37 o construtor escreve HSKIP[ng, mg] e o loop de colocação lê a mesma ordem

Porque é que offsets de grelha halftone negativos falham em três camadas?

Uma grelha halftone que comece à esquerda ou acima da sua região partia o PDFlibPas em três sítios separados, e cada falha escondia a seguinte. A T.88 permite esta geometria de propósito. Um codificador que alinhe o seu screen à página em vez de à região, ou use uma grelha rodada, produz naturalmente cantos de célula negativos que a região recorta. A v3.539.38 corrigiu as três camadas em conjunto, porque corrigir qualquer uma sozinha só mudava o sintoma

Camada 1: um campo com sinal lido como sem sinal

A T.88 §7.4.5.1.2 define HGX e HGY como valores de 32 bits com sinal, mas o descodificador lia-os com o mesmo helper de 32 bits que usava para campos sem sinal, e esse helper amarrava cada resultado negativo a 0. Uma grelha destinada a começar em HGX = -900 era silenciosamente movida para a origem da região. No caso de regressão da v3.539.38 a imagem inteira saía duas linhas abaixo. O clamp também explica porque as outras duas falhas sobreviveram tanto tempo: com a origem forçada a não negativa, uma coordenada negativa só podia aparecer através de uma grelha rodada com HRY > 0, onde y = HGY + mg × HRX − ng × HRY desce abaixo de zero para colunas de grelha posteriores

Camada 2: shr não é >> 8

A T.88 escreve >> 8 e quer dizer um shift aritmético, que arredonda para menos infinito. O descodificador traduzia-o como shr 8. Em Delphi e Free Pascal, shr sobre um inteiro com sinal é um shift lógico: o bit de sinal entra como zero. Para um Integer com -512, shr 8 dá 16777214 em vez de -2. Um padrão que devia ser desenhado em y = -2 e recortado à sua metade inferior era enviado 16 milhões de linhas abaixo e descartado como fora da região. Nada crashava; a linha de topo do halftone simplesmente desaparecia

Camada 3: comparar vírgula fixa em vez de pixels

O teste de salto comparava valores de vírgula fixa, não posições em pixels, e os dois deixam de ser equivalentes assim que a fração é diferente de zero. O código original desviava-se do shift lógico testando xx + HPW × 256 <= 0 sobre o valor sem shift, um suposto equivalente do teste da T.88. Com HGX = -900 e um padrão de 4 pixels, isso dá -900 + 1024 = 124, que é positivo, por isso a célula não é saltada. A norma faz primeiro o shift: floor(-900 / 256) = -4, e -4 + 4 = 0 cumpre x + HPW <= 0, por isso a célula está inteiramente fora e tem de ser saltada. O codificador saltava-a, o descodificador descodificava-a, e a imagem em tons de cinzento derivava exatamente como no caso da máscara transposta

Falhas halftone JBIG2 do PDFlibPas para uma grelha em HGX negativo: um campo com sinal lido por um helper sem sinal amarrado a zero, o shift à direita da T.88 traduzido como um shr lógico que enviou um padrão 16 milhões de linhas abaixo, e um teste de salto sobre valores de vírgula fixa que manteve uma célula que o codificador saltou
Cada falha escondia a seguinte, razão pela qual a v3.539.38 corrigiu as três camadas em conjunto num único helper HalftoneGridPixel partilhado pelo construtor da máscara e pelo loop de colocação

O caso de regressão da v3.539.38 usa uma grelha de 4 × 3 de padrões 4 × 4 em HGX = -900, HGY = -512, HRX = 1024 numa região de 12 × 10. As colunas de grelha caem em x = -4, 0, 4 e 8, por isso a coluna 0 está inteiramente fora e pertence ao HSKIP; as linhas de grelha caem em y = -2, 2 e 6, por isso a linha 0 tem de ser recortada às suas duas linhas de pixels inferiores em vez de descartada. Corrigir as camadas uma a uma reproduz a pilha:

Falhas corrigidasRegião descodificada
Nenhuma (antes da v3.539.38)Grelha puxada para a origem, imagem inteira duas linhas abaixo
Só leitura com sinal de HGX / HGYPrimeira linha de grelha em falta, o resto embaralhado pela deriva do teste de salto
Leitura com sinal, floor shift e teste de salto em espaço de pixelsIdêntica, pixel a pixel, à página calculada a partir da T.88 §6.6.5 e a dois descodificadores de referência independentes

A correção é um único helper, HalftoneGridPixel, partilhado pelo construtor da máscara de salto e pelo loop de colocação. Acumula a coordenada em Int64 para um produto mg × HRX grande não poder transbordar, divide por 256 arredondando para menos infinito, e amarra a ±MaxInt div 2 para uma grelha corrompida não transbordar a aritmética de bitmap posterior. O teste de salto compara agora esses valores em pixels contra HPW, HPH, HBW e HBH, exatamente como a §6.6.5.1 o enuncia

Como se escreve um shift à direita aritmético em Delphi?

Delphi não tem operador de shift aritmético, por isso um shift à direita com sinal correto tem de ser escrito como uma divisão de floor, e o div simples não é essa divisão. O div trunca para zero. Para valores não negativos o truncamento e o floor concordam, e também concordam para valores negativos que sejam múltiplos exatos do divisor, razão pela qual -512 div 256 = -2 parece bem num teste rápido. Discordam em todo o resto: -900 div 256 é -3, enquanto o floor é -4, e -1 div 256 é 0, enquanto o floor é -1. Uma coordenada JBIG2 com fração diferente de zero é exatamente o caso em que o div dá o pixel errado

Nos compiladores Delphi Win32 e Win64, uma variável Integer com -512 deslocada à direita por 8 dá 16777214, e um Int64 com -512 dá 72057594037927934. O Free Pascal também define shr como shift lógico e traz SarLongint e SarInt64 na sua unidade System para a versão aritmética, mas essas funções não existem em Delphi, por isso código partilhado entre os dois compiladores precisa do seu próprio helper:

// Divisão de floor: arredonda para menos infinito para qualquer sinal de A e B.
// B não pode ser 0, e FloorDiv(Low(Integer), -1) transborda tal como div
function FloorDiv(A, B: Integer): Integer;
begin
  Result := A div B;
  if (A mod B <> 0) and ((A < 0) <> (B < 0)) then
    Dec(Result);
end;

// Shift aritmético à direita (o ">>" de C e T.88 em valores com sinal).
// Para Value negativo, not Value = -Value - 1 é não negativo, por isso o
// shr lógico é aí seguro, e o not exterior mapeia o resultado de volta
function SarInt32(Value: Integer; Shift: Integer): Integer;  // Shift 0..31
begin
  if Value >= 0 then
    Result := Value shr Shift
  else
    Result := not ((not Value) shr Shift);
end;

function SarInt64(Value: Int64; Shift: Integer): Int64;      // Shift 0..63
begin
  if Value >= 0 then
    Result := Value shr Shift
  else
    Result := not ((not Value) shr Shift);
end;

O truque do not nunca desloca um número negativo, por isso não depende de como um compilador trata o bit de sinal, e nunca transborda, incluindo para Low(Integer). Ambos os helpers bateram com uma referência de floor em Int64 ao longo de vários milhões de valores, todos os shifts de 0 a 31 e as bordas Low(Integer) e High(Integer) em Delphi Win32, Delphi Win64 e Free Pascal x86_64. Um sanity check que vale a pena manter em qualquer teste unitário que toque em coordenadas:

var
  V: Integer;
begin
  V := -900;
  Writeln(V shr 8);           // 16777212  shift lógico, o bug antigo
  Writeln(V div 256);         // -3        truncamento para zero
  Writeln(FloorDiv(V, 256));  // -4        o que T.88 quer dizer com >> 8
  Writeln(SarInt32(V, 8));    // -4
end;
Reta numérica do PDFlibPas para a coordenada -900 deslocada à direita por 8: shr dá 16777212, div trunca para -3, enquanto FloorDiv e SarInt32 caem ambas no valor de floor -4 que a ITU-T T.88 quer dizer com o shift, o que só interessa quando a fração de vírgula fixa é diferente de zero
Truncamento e floor só concordam em múltiplos exatos, por isso -512 div 256 passa num teste rápido e -900 div 256 escolhe o pixel errado

O Math.Floor(V / 256) também devolve -4, mas o seu desvio pelo Double perde precisão para valores Int64 acima de 253, por isso a geometria inteira deve ficar em inteiros

Que chamadas do PDFlibPas acionam o descodificador halftone?

O descodificador halftone JBIG2 corre quando o PDFlibPas renderiza uma página com o renderer incorporado, porque renderizar precisa de pixels. O RenderPageToFile e o RenderPageToStream chegam ambos a ele através dos image streams JBIG2Decode da página, por isso renderizar de novo uma página halftone é a forma direta de confirmar que a v3.539.38 muda o seu output. O mesmo descodificador trata os outros tipos de região JBIG2, cobertos nas tabelas de Huffman personalizadas JBIG2 no descodificador Pascal puro e na descodificação de ficheiros JBIG2 de acesso aleatório em Delphi, e o bitmap renderizado alimenta conversões como renderizar páginas PDF para monocromático de 1 bit

uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  Page: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('scanned-halftone.pdf', '') <> 1 then
      raise Exception.CreateFmt('Load failed, error %d', [Lib.LastErrorCode]);
    for Page := 1 to Lib.PageCount do
      // A renderização descodifica todas as regiões JBIG2, halftones incluídos
      if Lib.RenderPageToFile(150, Page, PDF_RENDER_PNG,
        Format('page-%.3d.png', [Page])) <> 1 then
        Writeln('Page ', Page, ' was not rendered');
  finally
    Lib.Free;
  end;
end.

A extração de imagens normalmente segue outro caminho. O GetPageImageList devolve imagens JBIG2 em forma nativa, e o SaveImageListItemDataToFile ou o GetImageListItemDataToString entregam-lhe um ficheiro JBIG2 standalone construído a partir dos bytes do stream: o cabeçalho do ficheiro, os dados JBIG2Globals e um segmento de end-of-file em volta dos dados da página. A propriedade 400 do GetImageListItemIntProperty reporta 6 para tal item. Nada é descodificado nesse caminho, por isso um .jb2 extraído que parecesse correto noutro viewer enquanto a página renderizada mostrava ruído era um sinal típico destes dois bugs halftone:

var
  ListID, I: Integer;
begin
  Lib.SelectPage(1);
  ListID := Lib.GetPageImageList(0);
  if ListID = 0 then
    Exit;
  try
    for I := 1 to Lib.GetImageListCount(ListID) do
      if Lib.GetImageListItemIntProperty(ListID, I, 400) = 6 then  // standalone JBIG2
        Lib.SaveImageListItemDataToFile(ListID, I, 0,
          Format('page1-image%d.jb2', [I]));
  finally
    Lib.ReleaseImageList(ListID);
  end;
end;

Quando máscaras ou conversão de cor forçam um fallback renderizado, o item volta como bitmap descodificado e o descodificador halftone corre de facto. Mais sobre listas de imagens está na extração de texto, imagens e fontes PDF em Delphi

Referência rápida: regras de grelha halftone JBIG2

  • Indexe a máscara de salto como HSKIP[ng, mg], coluna de grelha primeiro, e leia-a de volta na mesma ordem onde quer que células sejam colocadas (T.88 §6.6.5.1, corrigido no PDFlibPas v3.539.37)
  • Teste qualquer código halftone ou de grelha com uma grelha não quadrada e um conjunto assimétrico de células fora da região, porque uma grelha quadrada pode esconder um índice transposto por completo
  • Leia HGX e HGY como valores de 32 bits com sinal (T.88 §7.4.5.1.2), nunca através de um helper sem sinal que amarre negativos
  • Traduza o >> 8 da norma como uma divisão de floor por 256, não como shr 8 nem como div 256
  • Corra o teste de salto sobre posições em pixels deslocadas; a forma de vírgula fixa difere sempre que a fração é diferente de zero, como HGX = -900 com um padrão de 4 pixels mostra
  • Acumule coordenadas de grelha em Int64 e amarre antes de as entregar a código de bitmap, para uma grelha corrompida não transbordar
  • Faça upgrade para a v3.539.38 ou posterior se os seus documentos contêm regiões halftone com HENABLESKIP, origens de grelha negativas ou grelhas rodadas

O PDFlibPas renderiza, extrai e edita documentos PDF a partir de Delphi e C++Builder com um descodificador JBIG2 Pascal nativo que agora trata máscaras de salto halftone, origens de grelha negativas e grelhas rodadas como a T.88 especifica. Veja a PDFlibPas Delphi PDF library para funcionalidades, edições e download de trial