O PDFlibPas versão 3.539.23 descodifica ficheiros JBIG2 standalone que usam a organização random-access do Anexo D.2 do ITU-T T.88, em que todos os cabeçalhos de segmento vêm primeiro e os dados dos segmentos seguem pela mesma ordem. O decoder Pascal nativo no PDFlibJBIG2.pas indexa os offsets dos cabeçalhos até ao cabeçalho obrigatório de end-of-file, verifica que os números de segmento crescem e que os comprimentos de dados declarados somam exatamente os bytes que restam, e depois descodifica cada corpo pela ordem dos cabeçalhos sem copiar nem reordenar os dados comprimidos. Antes desta versão o mesmo ficheiro levantava um erro seco de «random-access organisation is not supported» mal os flags do cabeçalho eram lidos
Os ficheiros JBIG2 random-access são raros, o que é precisamente a razão por que doem quando aparecem. Saem de pipelines de arquivo e de sistemas de document imaging que querem que um leitor veja todos os cabeçalhos de segmento, e portanto todas as dependências de páginas e dicionários, antes de tocar num único byte comprimido. Uma aplicação Delphi que converta em lote arquivos digitalizados para PDF normalmente encontra um destes a meio de um job, depois de centenas de ficheiros sequenciais terem passado limpos, e um decoder que morre a soletrar num ficheiro bem-formado é apenas marginalmente melhor do que um que renderiza lixo. A mesma linha de versão tinha acabado de ensinar ao decoder as tabelas Huffman à medida e os códigos de prefixo canónicos do JBIG2, por isso o random access era a última lacuna de organização que restava nos limites de capacidades documentados do decoder
O que é a organização random-access do JBIG2?
A organização random-access é uma de três maneiras como o Anexo D do T.88 permite dispor os mesmos segmentos: sequencial (D.1) intercala cada cabeçalho com os seus dados, random-access (D.2) põe todos os cabeçalhos primeiro e todos os dados depois, e embedded (D.3) é a forma sem cabeçalho usada dentro de outros contentores como o PDF. Um ficheiro .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, de uma contagem de páginas de quatro bytes. O bit 0 do byte de flags seleciona a organização, com 1 a significar sequencial e 0 a significar random-access; o bit 1 posto a 1 significa que o número de páginas é desconhecido e que a contagem de quatro bytes está ausente. O PDFlibPas lê isto no checkHeader e no setFileHeaderFlags, e os bits reservados 2 a 7 são tolerados em vez de rejeitados
// 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 cabeçalho de ficheiro, 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 transporta esta disposição. 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 dicionários de símbolos partilhados movidos para um stream JBIG2Globals separado e sem cabeçalho de ficheiro, de end-of-page ou de end-of-file. Quando o decodeJBIG2 não encontra o identificador de oito bytes assume exatamente isso e força descodificação sequencial de página única. A exportação nativa de imagens JBIG2 vai no sentido inverso e embrulha os segmentos do PDF num ficheiro standalone cujo byte de flags é $03, sequencial com contagem de páginas desconhecida, seguido de um cabeçalho de end-of-file acrescentado no fim. Por isso o trabalho de random-access toca num único caminho: ficheiros standalone entregues directamente ao TPLJBIG2Decoder, tipicamente antes de serem convertidos ou recomprimidos para PDF, o trabalho que os backends de encoder JBIG2 no PDFlibPas tratam do lado do output
Porque é que um ficheiro random-access não pode ser lido pela ordem do ficheiro?
Um ficheiro random-access não pode ser lido pela ordem do ficheiro porque nada no byte stream marca onde o bloco de cabeçalhos para e o bloco de dados começa, excepto o próprio cabeçalho de segmento end-of-file. Os cabeçalhos de segmento JBIG2 têm comprimento 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 dos segmentos referidos ocupam um, dois ou quatro bytes consoante o número do próprio segmento, e o campo de associação de página é de um ou quatro bytes. Um leitor sequencial ingénuo faz o parse do primeiro cabeçalho, lê o seu comprimento de dados, e depois trata os primeiros bytes do segundo cabeçalho como os dados desse segmento. O decoder só descobre muito mais tarde que algo correu mal, e é por isso que o código antigo recusava a organização à partida em vez de a tentar
Como é que o PDFlibPas indexa os cabeçalhos de segmentos random-access?
O PDFlibPas indexa os cabeçalhos random-access numa única pré-varredura, o IndexRandomHeaders, que faz o parse de todos os cabeçalhos, regista apenas o byte offset de cada um, e para no primeiro cabeçalho end-of-file (tipo de segmento 51). Cada cabeçalho é analisado por completo e deitado fora, por isso o índice é um array de inteiros e não uma lista de objetos, e a pré-varredura acumula os comprimentos de dados declarados à medida que avança. Quando a varredura termina, o leitor está no primeiro byte dos dados do primeiro segmento, e essa posição passa a ser o NextBodyOffset
// 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; // expandido em blocos
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;
Cada verificação naquele ciclo existe porque um ficheiro random-access tem menos redundância do que um sequencial. Os números de segmento têm de crescer estritamente, comparados como valores sem sinal, porque dois cabeçalhos que reclamem o mesmo número tornam ambíguo a que corpo a lista de referidos de uma região posterior se refere. O campo de comprimento de dados é lido pelo handleSegmentDataLength, que mapeia qualquer valor com o bit superior posto, incluindo o marcador de comprimento desconhecido 0xFFFFFFFF, para -1; em disposição random-access não há outra maneira de descobrir onde o corpo seguinte começa, por isso o PDFlibPas rejeita logo esse comprimento em vez de andar à procura de um marcador de fim. O total tem de bater certo com os bytes restantes nas duas direções, e um único byte extra depois do último corpo falha com «trailing random-access data». Esse rigor é deliberado: nesta disposição um desfasamento de comprimento significa que todos os corpos depois do ponto de erro estão deslocados, e um decoder que encolha os ombros a um byte extra não tem maneira de saber se é padding inofensivo ou o primeiro sintoma de dados desalinhados
Porque é que o último segmento end-of-page desaparecia?
O último segmento end-of-page desaparecia porque a primeira versão do ciclo de descodificação mantinha o teste de terminação sequencial, while not reader.isFinished, e em disposição random-access o stream de dados acaba antes do índice de cabeçalhos. Os segmentos end-of-page (tipo 49) e end-of-file transportam zero bytes de dados, e são normalmente os últimos cabeçalhos do ficheiro. Depois de o último corpo de região ser consumido o leitor fica exatamente no fim do buffer, por isso o ciclo termina e esses segmentos de comprimento zero nunca são despachados, deixando a página por acabar. A correção faz o ciclo random-access contar cabeçalhos em vez de bytes. Cada iteração salta o leitor para o cabeçalho indexado seguinte, repõe o bitPointer em 7 porque o corpo anterior pode ter acabado a meio de um byte, volta a fazer o parse desse cabeçalho, e depois move o bytePointer para o NextBodyOffset e avança-o para lá do corpo. Os handlers de segmentos existentes, as verificações de segmentos referidos e os diagnósticos do Context correm sem alterações, e uma mensagem de erro continua a reportar o byte offset original do cabeçalho, e não a posição do corpo
// TJBIG2StreamDecoder.readSegments, ciclo 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; // realinhar depois de um byte parcial
Inc(HeaderIndex);
end;
SegmentOffset := reader.bytePointer; // usado no contexto de erro
readSegmentHeader(segmentHeader);
if randomAccessOrganisation then
reader.bytePointer := NextBodyOffset; // salto para os dados deste segmento
DataLength := segmentHeader.getSegmentDataLength;
DataEnd := reader.bytePointer + DataLength;
NextBodyOffset := DataEnd;
// ... despacho para o handler de segmentos existente, depois seek para DataEnd
end;
O que é que a validação do random-access prova de facto?
A validação prova que os bytes reorganizados descodificam para os mesmos pixels que os seus originais sequenciais, e prova que input random-access malformado falha de forma limpa; não prova cobertura de ficheiros random-access vindos de encoders arbitrários. A regressão Pascal partilhada usa um ficheiro sintético de 235 bytes construído sobre um fixture de tabela à medida que tem de descodificar para uma linha de 7 por 1 pixels pretos, tanto com contagem de páginas conhecida como com o campo de contagem removido, e depois alimenta o decoder com todos os prefixos truncados desse ficheiro, um número de segmento duplicado, um byte final a mais e um comprimento de dados desconhecido, afirmando a cada vez que o LoadFromByteArray devolve False e deixa Width e Height a zero. O caso de imagem real é uma imagem de refinement de tabela à medida de 500 por 473 cujos segmentos foram reorganizados para disposição random-access com todos os cabeçalhos originais e bytes comprimidos preservados; o seu SHA-256 coincide exatamente com a baseline sequencial revista. Esse ficheiro é um derivado produzido por uma transformação de organização, e não um documento random-access natural apanhado na natureza, e nenhuma amostra natural dessas estava disponível. As suites passaram com 1.598 testes para Delphi Win32, 42 para a suite de imagens Delphi Win64, 48 para FPC Win32 e 46 para FPC Win64, ao lado dos três casos de pixels sequenciais existentes
Carregar um ficheiro .jb2 random-access e os seus limites
O código da aplicação não muda: o TPLJBIG2Decoder.LoadFromByteArray detecta o cabeçalho de ficheiro e a organização por si, devolve False em qualquer input rejeitado com o motivo no LastError, e expõe a página descodificada através de Width, Height e GetScanline, que devolve 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
// Ficheiros standalone sequenciais 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;
As fronteiras valem a pena ser ditas com clareza. O suporte de random-access é uma funcionalidade de organização de ficheiro, e não uma API de páginas aleatórias: o TPLJBIG2Decoder continua a devolver o bitmap da primeira página, e não há chamada nenhuma para escolher a página 7 de um ficheiro de 40 ou para descodificar páginas preguiçosamente. Segmentos com comprimento de dados desconhecido são rejeitados em ficheiros random-access, e os limites existentes nos comprimentos de prefixo Huffman à medida e nas contagens de entradas de tabela não mudam. Esses limites são suficientemente estreitos para que uma aplicação Delphi encaminhe os casos rejeitados para outro sítio pelo LastError, e o resto do pipeline de imagem, da extração de imagens PDF à codificação JBIG2, está coberto na página do produto da biblioteca PDF PDFlibPas para Delphi