Artigo Técnico

Arquivos JBIG2 random-access: como decodificar no Delphi

O PDFlibPas versão 3.539.23 decodifica arquivos JBIG2 standalone que usam a organização random-access do Anexo D.2 da ITU-T T.88, em que todo segment header vem primeiro e os dados dos segmentos seguem na mesma ordem. O decoder Pascal nativo em PDFlibJBIG2.pas indexa os offsets dos headers até o header end-of-file obrigatório, confere que os números de segmento crescem e que os tamanhos de dados declarados somam exatamente os bytes que restam, e então decodifica cada corpo na ordem dos headers sem copiar nem reordenar os dados comprimidos. Antes desta release o mesmo arquivo levantava um erro seco de "random-access organisation is not supported" no momento em que os flags do header eram lidos

Arquivos JBIG2 random-access são raros, e é exatamente por isso que eles machucam quando aparecem. Eles saem de pipelines de arquivamento e sistemas de imageamento de documentos que querem que o leitor veja todo segment header, e portanto toda dependência de página e de dicionário, antes de tocar num único byte comprimido. Uma aplicação Delphi que converte arquivos escaneados em lote para PDF costuma encontrar um desses no meio de um job, depois que centenas de arquivos sequenciais passaram limpamente, e um decoder que para morto num arquivo bem-formado é só marginalmente melhor que um que renderiza lixo. A mesma linha de release tinha acabado de ensinar ao decoder tabelas Huffman customizadas e códigos de prefixo canônicos do JBIG2, então random access era a última lacuna de organização restante nos limites de capacidade documentados do decoder

O que é a organização random-access do JBIG2?

A organização random-access é uma das três formas como o Anexo D do T.88 permite dispor os mesmos segmentos: sequential (D.1) intercala cada header com os dados dele, random-access (D.2) põe todos os headers primeiro e todos os dados depois, e embedded (D.3) é a forma sem header usada dentro de outros containers como o PDF. Um arquivo .jb2 standalone começa com o identificador de oito bytes 97 4A 42 32 0D 0A 1A 0A, seguido de um byte de flags e, quando a contagem de páginas é conhecida, uma contagem de páginas de quatro bytes. O bit 0 do byte de flags seleciona a organização, com 1 significando sequential e 0 significando random-access; o bit 1 setado significa que o número de páginas é desconhecido e a contagem de quatro bytes está ausente. O PDFlibPas lê isso em checkHeader e setFileHeaderFlags, e os bits reservados de 2 a 7 são tolerados em vez de rejeitados

Organizações de arquivo JBIG2 no PDFlibPas: o byte de flags que o setFileHeaderFlags lê escolhe sequential D.1 com headers intercalados, random-access D.2 com todo header antes do bloco de dados, ou embedded D.3, a forma sem header que um stream JBIG2Decode usa com dicionários em JBIG2Globals
Os três layouts carregam os mesmos segmentos, mas só o random access faz o leitor ver toda dependência de página e de dicionário antes de tocar num byte comprimido, e é por isso que os pipelines de arquivamento pediram por ele
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = contagem de páginas omitida
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // stream PDF: sem header de arquivo, organização embedded, uma página
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

O PDF em si nunca carrega esse layout. Um stream de imagem JBIG2Decode, descrito na ISO 32000-1 §7.4.7, guarda apenas os segmentos de página em organização embedded, com os symbol dictionaries compartilhados movidos para um stream JBIG2Globals separado e sem header de arquivo, nem segmentos end-of-page ou end-of-file. Quando o decodeJBIG2 não encontra o identificador de oito bytes, ele assume exatamente isso e força decodificação sequential de página única. O export de imagem JBIG2 nativo vai no outro sentido e embrulha os segmentos do PDF num arquivo standalone cujo byte de flags é $03, sequential com contagem de páginas desconhecida, seguido de um header end-of-file acrescentado no final. Então o trabalho de random-access toca um único caminho: arquivos standalone entregues direto ao TPLJBIG2Decoder, tipicamente antes de serem convertidos ou recomprimidos para PDF, o trabalho que os backends de encoder JBIG2 do PDFlibPas fazem do lado do output

Por que um arquivo random-access não pode ser lido na ordem do arquivo?

Um arquivo random-access não pode ser lido na ordem do arquivo porque nada no byte stream marca onde o bloco de headers para e o bloco de dados começa, exceto o próprio header do segmento end-of-file. Headers de segmento JBIG2 têm tamanho variável: a contagem de segmentos referidos pode ser uma forma curta de três bits ou uma forma longa com um bitmap de retenção, os números de segmentos referidos ocupam um, dois ou quatro bytes dependendo do número do próprio segmento, e o campo de associação de página é de um ou quatro bytes. Um leitor sequential ingênuo parseia o primeiro header, lê o tamanho de dados dele e então trata os primeiros bytes do segundo header como os dados daquele segmento. O decoder só percebe que errou muito depois, e é por isso que o código antigo recusava a organização de cara em vez de tentar

Como o PDFlibPas indexa os segment headers random-access?

O PDFlibPas indexa os headers random-access numa única pré-varredura, o IndexRandomHeaders, que parseia todo header, registra só o byte offset dele e para no primeiro header end-of-file (segmento tipo 51). Cada header é parseado por completo e descartado, então o índice é um array de inteiros em vez de uma lista de objetos, e a pré-varredura acumula os tamanhos de dados declarados conforme avança. Quando a varredura termina, o leitor está posicionado no primeiro byte dos dados do primeiro segmento, e essa posição vira o NextBodyOffset

Pré-varredura do IndexRandomHeaders no PDFlibPas: todo segment header é parseado e descartado enquanto só o byte offset dele é guardado, os números de segmento precisam crescer estritamente, o tamanho desconhecido 0xFFFFFFFF é recusado, a varredura para no header end-of-file tipo 51, e os tamanhos declarados precisam ser iguais aos bytes restantes exatamente
O rigor é deliberado: num layout em que os headers são o único mapa dos dados, um byte solto significa que todo corpo posterior pode estar deslocado, então um decoder que o tolera não consegue distinguir padding de desalinhamento
// IndexRandomHeaders, local a TJBIG2StreamDecoder.readSegments
while not reader.isFinished do
begin
  Offset := reader.bytePointer;
  Header := TSegmentHeader.Create;
  try
    readSegmentHeader(Header);
    if reader.BufferOverrun then
      raise EJBIG2DecodeError.CreateFmt(
        'JBIG2 truncated random-access header at byte %d', [Offset]);
    if (HeaderCount > 0) and
       (Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
      raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
    PreviousNumber := Header.getSegmentNumber;
    Count := Header.getSegmentDataLength;
    if Count < 0 then
      raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
    Inc(TotalLength, Count);                   // acumulador Int64
    HeaderOffsets[HeaderCount] := Offset;      // cresce em chunks
    Inc(HeaderCount);
    if Header.getSegmentType = JBIG2_END_OF_FILE then
    begin
      if Count <> 0 then
        raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
      FoundEnd := True;
      Break;
    end;
  finally
    Header.Free;
  end;
end;
if not FoundEnd then
  raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;

Toda checagem naquele loop existe porque um arquivo random-access tem menos redundância que um sequential. Os números de segmento precisam crescer estritamente, comparados como valores unsigned, porque dois headers reivindicando o mesmo número tornam ambíguo qual corpo a lista de referidos de uma região posterior significa. O campo de tamanho de dados é lido pelo handleSegmentDataLength, que mapeia qualquer valor com o bit mais alto setado, incluindo o marcador de "unknown length" 0xFFFFFFFF, para -1; no layout random-access não há outro jeito de descobrir onde o próximo corpo começa, então o PDFlibPas rejeita esse tamanho imediatamente em vez de varrer procurando um marcador de fim. O total precisa casar com os bytes restantes exatamente nas duas direções, e um único byte extra depois do último corpo falha com "trailing random-access data". O rigor é deliberado: nesse layout uma discrepância de tamanho significa que todo corpo depois do ponto do erro está deslocado, e um decoder que dá de ombros para um byte solto não tem como saber se ele é padding inofensivo ou o primeiro sintoma de dados desalinhados

Por que o último segmento end-of-page sumia?

O último segmento end-of-page sumia porque a primeira versão do loop de decodificação mantinha o teste de término sequential, o while not reader.isFinished, e no layout random-access o stream de dados acaba antes do índice de headers. Segmentos end-of-page (tipo 49) e end-of-file carregam zero bytes de dados, e normalmente são os últimos headers do arquivo. Depois que o corpo da última região é consumido, o leitor fica exatamente no fim do buffer, então o loop sai e esses segmentos de tamanho zero nunca são despachados, deixando a página inacabada. A correção faz o loop random-access contar headers em vez de bytes. Cada iteração salta o leitor para o próximo header indexado, reseta o bitPointer para 7 porque o corpo anterior pode ter terminado no meio de um byte, re-parseia esse header, então move o bytePointer para o NextBodyOffset e o avança para depois do corpo. Os handlers de segmento existentes, as checagens de segmentos referidos e os diagnósticos de Context rodam inalterados, e uma mensagem de erro ainda reporta o byte offset original do header, não a posição do corpo

Loop de decodificação random-access no PDFlibPas: cada iteração busca o HeaderOffsets do header atual, reseta o bitPointer para 7 para desfazer caudas em meio byte, salta para o NextBodyOffset do corpo, e conta headers em vez de bytes para que segmentos end-of-page de tamanho zero sejam despachados antes de o loop acabar
Como os segmentos end-of-page e end-of-file carregam zero bytes de dados, o stream de dados acaba antes do índice de headers, e só um loop que conta headers dá a esses segmentos finais a vez deles
// TJBIG2StreamDecoder.readSegments, loop principal
if randomAccessOrganisation then
  IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
      ((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
  if randomAccessOrganisation then
  begin
    reader.bytePointer := HeaderOffsets[HeaderIndex];
    reader.bitPointer := 7;                    // realinha depois de um byte parcial
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // usado no contexto de erro
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // salta para os dados deste segmento
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... despacha para o handler de segmento existente, então busca DataEnd
end;

O que a validação random-access realmente prova?

A validação prova que os bytes reorganizados decodificam para os mesmos pixels que os originais sequential, e prova que input random-access malformado falha limpatamente; ela não prova cobertura de arquivos random-access vindos de encoders arbitrários. A regressão Pascal compartilhada usa um arquivo sintético de 235 bytes construído sobre um fixture de tabela customizada que precisa decodificar para uma fileira de 7 por 1 pixels pretos, tanto com a contagem de páginas conhecida quanto com o campo de contagem removido, e depois alimenta o decoder com todo prefixo truncado desse arquivo, um número de segmento duplicado, um byte final extra e um tamanho de dados desconhecido, asserindo a cada vez que o LoadFromByteArray retorna False e deixa Width e Height em zero. O caso de imagem real é uma imagem de refinement de tabela customizada de 500 por 473 cujos segmentos foram reorganizados em layout random-access com todo header original e byte comprimido preservados; o SHA-256 dela casa exatamente com o baseline sequential revisado. Esse arquivo é um derivado produzido por uma transformação de organização, não um documento random-access natural achado na natureza, e nenhuma amostra natural dessas estava disponível. As suítes passaram com 1.598 testes no Delphi Win32, 42 na suíte de imagens do Delphi Win64, 48 no FPC Win32 e 46 no FPC Win64, junto com os três casos de pixel sequential existentes

Carregando um arquivo .jb2 random-access e seus limites

O código da aplicação não muda: o TPLJBIG2Decoder.LoadFromByteArray detecta o header de arquivo e a organização por conta própria, retorna False em qualquer input rejeitado com o motivo em LastError, e expõe a página decodificada por meio de Width, Height e GetScanline, que retorna um byte por pixel

uses
  SysUtils, Classes, PDFlibJBIG2;

function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
  FS: TFileStream;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, FS.Size);
    if Length(Result) > 0 then
      FS.ReadBuffer(Result[0], Length(Result));
  finally
    FS.Free;
  end;
end;

function CountBlackPixels(const FileName: string): Integer;
var
  Decoder: TPLJBIG2Decoder;
  Row: TJBIG2ByteArray;
  X, Y: Integer;
begin
  Result := 0;
  Decoder := TPLJBIG2Decoder.Create;
  try
    // Arquivos standalone sequential e random-access usam a mesma chamada
    if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
      raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
    for Y := 0 to Decoder.Height - 1 do
      if Decoder.GetScanline(Y, Row) then
        for X := 0 to Decoder.Width - 1 do
          if Row[X] = 1 then
            Inc(Result);
  finally
    Decoder.Free;
  end;
end;

Vale explicitar os limites sem rodeio. Suporte a random-access é um recurso de organização de arquivo, não uma API de páginas aleatórias: o TPLJBIG2Decoder ainda retorna o bitmap da primeira página, e não existe chamada para pegar a página 7 de um arquivo de 40 páginas nem para decodificar páginas lazy. Segmentos com tamanho de dados desconhecido são rejeitados em arquivos random-access, e os limites existentes de comprimento de prefixo Huffman customizado e de contagem de entradas de tabela permanecem inalterados. Esses limites são estreitos o bastante para que uma aplicação Delphi encaminhe os casos rejeitados para outro lugar via LastError, e o resto do pipeline de imagens, da extração de imagens de PDF à codificação JBIG2, está coberto na página de produto da biblioteca de PDF em Delphi PDFlibPas