Artículo técnico

Archivos JBIG2 de acceso aleatorio: decodificarlos en Delphi

PDFlibPas versión 3.539.23 decodifica archivos JBIG2 independientes que usan la organización de acceso aleatorio de ITU-T T.88 Anexo D.2, donde todos los headers de segmento van primero y los datos de los segmentos siguen en el mismo orden. El decoder Pascal nativo en PDFlibJBIG2.pas indexa los offsets de los headers hasta el header obligatorio de end-of-file, chequea que los números de segmento crezcan y que las longitudes de datos declaradas sumen exactamente los bytes que quedan, y después decodifica cada cuerpo en orden de header sin copiar ni reordenar los datos comprimidos. Antes de esta release, el mismo archivo levantaba un seco "random-access organisation is not supported" apenas se leían los flags del header

Los archivos JBIG2 de acceso aleatorio son raros, que es exactamente por qué duelen cuando aparecen. Salen de pipelines de archivo histórico y sistemas de digitalización de documentos que quieren que un lector vea cada header de segmento, y por lo tanto cada dependencia de página y de diccionario, antes de tocar un solo byte comprimido. Una aplicación Delphi que convierte en lote archivos escaneados a PDF suele toparse con uno de estos en medio de un trabajo, después de que cientos de archivos secuenciales pasaron limpiamente, y un decoder que se planta en seco ante un archivo bien formado es apenas marginalmente mejor que uno que renderiza basura. La misma línea de release acababa de enseñarle al decoder tablas Huffman custom de JBIG2 y códigos de prefijo canónicos, así que el acceso aleatorio era el último hueco de organización que quedaba en los límites de capacidades documentados del decoder

¿Qué es la organización de acceso aleatorio de JBIG2?

La organización de acceso aleatorio es una de las tres formas en que el Anexo D de T.88 permite disponer los mismos segmentos: secuencial (D.1) intercala cada header con sus datos, acceso aleatorio (D.2) pone todos los headers primero y todos los datos después, y embebida (D.3) es la forma sin headers que se usa dentro de otros contenedores como PDF. Un archivo .jb2 independiente empieza con el identificador de ocho bytes 97 4A 42 32 0D 0A 1A 0A, seguido de un byte de flags y, cuando el conteo de páginas es conocido, un conteo de páginas de cuatro bytes. El bit 0 del byte de flags selecciona la organización, con 1 significando secuencial y 0 significando acceso aleatorio; el bit 1 en 1 significa que el número de páginas es desconocido y el conteo de cuatro bytes está ausente. PDFlibPas los lee en checkHeader y setFileHeaderFlags, y los bits reservados 2 a 7 se toleran en lugar de rechazarse

Organizaciones de archivos JBIG2 en PDFlibPas: el byte de flags que lee setFileHeaderFlags elige secuencial D.1 con headers intercalados, acceso aleatorio D.2 con cada header antes del bloque de datos, o embebida D.3, la forma sin headers que usa un stream JBIG2Decode con diccionarios en JBIG2Globals
Los tres layouts llevan los mismos segmentos, pero solo el acceso aleatorio hace que un lector vea cada dependencia de página y de diccionario antes de tocar un byte comprimido, y por eso los pipelines de archivo histórico lo pidieron
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = acceso aleatorio (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = conteo de páginas omitido
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // stream PDF: sin header de archivo, organización embebida, una página
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF en sí nunca lleva este layout. Un stream de imagen JBIG2Decode, descrito en ISO 32000-1 §7.4.7, contiene solo los segmentos de página en organización embebida, con los diccionarios de símbolos compartidos movidos a un stream JBIG2Globals separado y sin header de archivo ni segmentos de end-of-page o end-of-file. Cuando decodeJBIG2 no encuentra el identificador de ocho bytes, asume exactamente eso y fuerza una decodificación secuencial de una sola página. El export de imágenes JBIG2 nativo va en la dirección contraria y envuelve los segmentos del PDF en un archivo independiente cuyo byte de flags es $03, secuencial con conteo de páginas desconocido, seguido de un header de end-of-file agregado al final. Así que el trabajo de acceso aleatorio toca un solo camino: archivos independientes entregados directamente a TPLJBIG2Decoder, típicamente antes de convertirlos o recomprimirlos para PDF, el trabajo que los backends de encoder JBIG2 en PDFlibPas manejan del lado de salida

¿Por qué no se puede leer un archivo de acceso aleatorio en orden de archivo?

Un archivo de acceso aleatorio no puede leerse en orden de archivo porque nada en el stream de bytes marca dónde se detiene el bloque de headers y empieza el bloque de datos, salvo el propio header de segmento de end-of-file. Los headers de segmento JBIG2 tienen longitud variable: el conteo de segmentos referidos puede ser una forma corta de tres bits o una forma larga con un bitmap de retención, los números de segmentos referidos toman uno, dos o cuatro bytes según el número del propio segmento, y el campo de asociación de página es de uno o cuatro bytes. Un lector secuencial ingenuo parsea el primer header, lee su longitud de datos y después trata los primeros bytes del segundo header como los datos de ese segmento. El decoder no puede darse cuenta de que se equivocó hasta mucho después, que es por qué el código viejo rechazaba la organización de plano en lugar de intentarlo

¿Cómo indexa PDFlibPas los headers de segmentos de acceso aleatorio?

PDFlibPas indexa los headers de acceso aleatorio en un solo pre-scan, IndexRandomHeaders, que parsea cada header, registra únicamente su offset de byte y se detiene en el primer header de end-of-file (segmento tipo 51). Cada header se parsea por completo y se descarta, así que el índice es un array de enteros y no una lista de objetos, y el pre-scan acumula las longitudes de datos declaradas sobre la marcha. Cuando el scan termina, el lector queda parado en el primer byte de los datos del primer segmento, y esa posición se convierte en NextBodyOffset

Pre-scan IndexRandomHeaders en PDFlibPas: cada header de segmento se parsea y se descarta guardando solo su offset de byte, los números de segmento deben crecer estrictamente, la longitud desconocida 0xFFFFFFFF se rechaza, el scan se detiene en el header de end-of-file tipo 51, y las longitudes declaradas deben igualar exactamente los bytes restantes
La rigidez es deliberada: en un layout donde los headers son el único mapa de los datos, un byte suelto significa que todos los cuerpos siguientes pueden estar corridos, así que un decoder que lo tolera no puede distinguir padding de desalineación
// 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;      // crece por bloques
    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 chequeo de ese bucle existe porque un archivo de acceso aleatorio tiene menos redundancia que uno secuencial. Los números de segmento deben crecer estrictamente, comparados como valores sin signo, porque dos headers que reclaman el mismo número dejan ambiguo a qué cuerpo se refiere la lista de referidos de una región posterior. El campo de longitud de datos lo lee handleSegmentDataLength, que mapea cualquier valor con el bit alto encendido, incluido el marcador de "longitud desconocida" 0xFFFFFFFF, a -1; en el layout de acceso aleatorio no hay otra forma de encontrar dónde empieza el siguiente cuerpo, así que PDFlibPas rechaza esa longitud de inmediato en lugar de escanear buscando un marcador de fin. El total debe coincidir con los bytes restantes exactamente en ambas direcciones, y un solo byte extra después del último cuerpo falla con "trailing random-access data". Esa rigidez es deliberada: en este layout un desajuste de longitud significa que todo cuerpo después del punto del error está corrido, y un decoder que ignora un byte suelto no tiene forma de saber si es padding inofensivo o el primer síntoma de datos desalineados

¿Por qué desaparecía el último segmento de end-of-page?

El último segmento de end-of-page desaparecía porque la primera versión del bucle de decodificación conservaba el test de terminación secuencial, while not reader.isFinished, y en el layout de acceso aleatorio el stream de datos se agota antes que el índice de headers. Los segmentos de end-of-page (tipo 49) y end-of-file llevan cero bytes de datos, y normalmente son los últimos headers del archivo. Después de consumir el cuerpo de la última región, el lector queda justo al final del buffer, así que el bucle sale y esos segmentos de longitud cero nunca se despachan, dejando la página sin terminar. El fix hace que el bucle de acceso aleatorio cuente headers en lugar de bytes. Cada iteración salta el lector al siguiente header indexado, resetea bitPointer a 7 porque el cuerpo anterior puede haber terminado a mitad de byte, re-parsea ese header, y después mueve bytePointer a NextBodyOffset y lo avanza más allá del cuerpo. Los handlers de segmento existentes, los chequeos de segmentos referidos y los diagnósticos de Context corren sin cambios, y un mensaje de error sigue reportando el offset de byte original del header, no la posición del cuerpo

Bucle de decodificación de acceso aleatorio en PDFlibPas: cada iteración salta a HeaderOffsets del header actual, resetea bitPointer a 7 para deshacer colas a mitad de byte, salta a NextBodyOffset para el cuerpo, y cuenta headers en lugar de bytes para que los segmentos de end-of-page de longitud cero se despachen antes de que el bucle termine
Como los segmentos de end-of-page y end-of-file llevan cero bytes de datos, el stream de datos se agota antes que el índice de headers, y solo un bucle que cuenta headers puede darle turno a esos segmentos finales
// TJBIG2StreamDecoder.readSegments, bucle 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;                    // realinear tras un byte parcial
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // usado en el contexto de error
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // saltar a los datos de este segmento
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... despachar al handler de segmento existente, y luego posicionar en DataEnd
end;

¿Qué prueba realmente la validación de acceso aleatorio?

La validación prueba que los bytes reorganizados decodifican a los mismos píxeles que sus originales secuenciales, y prueba que la entrada de acceso aleatorio malformada falla limpiamente; no prueba cobertura de archivos de acceso aleatorio venidos de encoders arbitrarios. La regresión Pascal compartida usa un archivo sintético de 235 bytes construido sobre un fixture de tabla custom que debe decodificar a una fila de 7 por 1 píxeles negros, tanto con un conteo de páginas conocido como con el campo de conteo removido, y después le alimenta al decoder cada prefijo truncado de ese archivo, un número de segmento duplicado, un byte final extra y una longitud de datos desconocida, afirmando cada vez que LoadFromByteArray devuelve False y deja Width y Height en cero. El caso de imagen real es una imagen de refinement de tabla custom de 500 por 473 cuyos segmentos se reorganizaron a layout de acceso aleatorio conservando cada header original y cada byte comprimido; su SHA-256 coincide exactamente con el baseline secuencial revisado. Ese archivo es un derivado producido por una transformación de organización, no un documento de acceso aleatorio natural encontrado en la naturaleza, y no había disponible una muestra natural así. Las suites pasaron con 1,598 tests para Delphi Win32, 42 para la suite de imágenes de Delphi Win64, 48 para FPC Win32 y 46 para FPC Win64, junto a los tres casos de píxeles secuenciales existentes

Cargar un archivo .jb2 de acceso aleatorio y sus límites

El código de aplicación no cambia: TPLJBIG2Decoder.LoadFromByteArray detecta por su cuenta el header de archivo y la organización, devuelve False ante cualquier entrada rechazada con el motivo en LastError, y expone la página decodificada a través de Width, Height y GetScanline, que devuelve un byte por píxel

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
    // Los archivos independientes secuenciales y de acceso aleatorio usan la misma llamada
    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;

Los límites conviene enunciarlos sin rodeos. El soporte de acceso aleatorio es una función de organización de archivos, no una API de páginas aleatorias: TPLJBIG2Decoder sigue devolviendo el bitmap de la primera página, y no hay llamada para elegir la página 7 de un archivo de 40 páginas ni para decodificar páginas en forma lazy. Los segmentos con longitud de datos desconocida se rechazan en archivos de acceso aleatorio, y los límites existentes sobre longitudes de prefijo Huffman custom y conteos de entradas de tabla quedan igual. Esos límites son lo bastante angostos para que una aplicación Delphi pueda derivar los casos rechazados a otro lado vía LastError, y el resto del pipeline de imágenes, desde la extracción de imágenes PDF hasta la codificación JBIG2, está cubierto en la página de producto de la librería PDF PDFlibPas para Delphi