Artículo técnico

Archivos JBIG2 de acceso aleatorio: decodificarlos en Delphi

Este es un error que un lector JBIG2 escrito a mano comete con el primer archivo inusual que encuentra. Parsea el header del archivo, parsea el primer header de segmento, lee la longitud de datos de cuatro bytes y toma esa cantidad de bytes como cuerpo del segmento. En la mayoría de los archivos .jb2 eso es exactamente correcto. En un archivo cuyo byte de flags tiene el bit 0 en cero, esos bytes del "cuerpo" son en realidad el segundo, tercer y cuarto header de segmento, y el decoder aritmético los convierte en un bitmap de ruido o se sale del final del buffer. El archivo no está roto. Usa la organización de acceso aleatorio del Anexo D.2 de ITU-T T.88, donde todos los headers de segmento van primero y todos los datos de los segmentos siguen después, en el mismo orden

Los archivos de acceso aleatorio son raros. Salen de sistemas de archivo histórico y de digitalización de documentos que quieren que un lector vea cada dependencia de página y de diccionario antes de tocar un byte comprimido. Si un trabajo de conversión mezcla archivos de una fuente así con escaneos ordinarios, un archivo de acceso aleatorio puede quedar en medio de un lote que por lo demás es todo secuencial. El decoder que los lee es el mismo decoder Pascal nativo que maneja tablas Huffman custom de JBIG2 y códigos de prefijo canónicos, así que el lado de la codificación no cambia; solo difiere el orden en que se encuentran los segmentos

Tres layouts de archivo detrás de un byte de flags

PDF Library for Delphi lee archivos JBIG2 independientes a través de TPLJBIG2Decoder en la unidad PDFlibJBIG2, y el decoder deduce el layout por su cuenta a partir del header del archivo. El Anexo D de T.88 permite disponer los mismos segmentos de tres maneras: secuencial (D.1) pone cada header justo delante de 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. Un archivo independiente empieza con el identificador de ocho bytes 97 4A 42 32 0D 0A 1A 0A, luego un byte de flags y luego un conteo de páginas de cuatro bytes, a menos que los flags digan que el conteo es desconocido

El bit 0 de ese byte de flags elige la organización: 1 significa secuencial, 0 significa 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, así que el primer header de segmento arranca en el byte 9 en lugar del 13. Los bits 2 a 7 están reservados; el decoder acepta los archivos que los encienden en lugar de rechazarlos, porque un bit reservado suelto no dice nada sobre si los segmentos en sí son legibles

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

PDF en sí nunca lleva el layout de acceso aleatorio. Un stream de imagen JBIG2Decode, descrito en ISO 32000-1 §7.4.7, contiene solo segmentos de página en forma 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 el decoder no encuentra el identificador de ocho bytes, trata la entrada exactamente como ese tipo de stream: secuencial, de una página. En la dirección contraria, cuando PDF Library for Delphi exporta una imagen JBIG2Decode como archivo .jb2 nativo, escribe el byte de flags $03 (secuencial, conteo de páginas desconocido) y agrega al final un header de segmento de end-of-file. Así que el acceso aleatorio solo llega desde afuera, como un archivo independiente entregado directamente a TPLJBIG2Decoder, normalmente camino a ser convertido o recomprimido para PDF con los backends de encoder JBIG2 en PDFlibPas

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

Porque nada en el stream de bytes marca dónde termina el bloque de headers y empieza el bloque de datos, salvo el propio header de segmento de end-of-file (tipo 51). Los headers de segmento JBIG2 no tienen tamaño fijo. El conteo de segmentos referidos es o una forma corta de tres bits o una forma larga seguida de un bitmap de retención; cada número de segmento referido toma uno, dos o cuatro bytes según qué tan grande sea el número del propio segmento actual; el campo de asociación de página es de uno o cuatro bytes. Un lector tiene que parsear cada campo de un header solo para saber dónde arranca el siguiente

Un lector secuencial que recorre el archivo en orden acierta el primer header y se equivoca en todo lo que sigue. Peor aún, puede tardar en notarlo: un decoder de región genérica alimentado con bytes de header produce píxeles, no un error, hasta que alguna comprobación posterior de longitud o referencia finalmente se dispara. Nuestra opinión es que un decoder que no indexa los headers primero debería rechazar de plano los archivos de acceso aleatorio. Un rechazo claro cuesta un archivo en un lote; un bitmap de aspecto plausible pero equivocado puede terminar en un archivo histórico y quedarse ahí

Indexar cada header antes del primer cuerpo

Cuando el byte de flags dice acceso aleatorio, PDF Library for Delphi hace una pasada sobre el bloque de headers antes de decodificar nada. Cada header se parsea por completo y luego se descarta; lo único que se conserva es su offset de byte en el archivo. Los datos comprimidos nunca se copian a un segundo buffer reordenado, lo que importa en escaneos de archivo grandes donde el bloque de datos puede ser la mayor parte del archivo. La pasada se detiene en el primer header de end-of-file, y en ese punto la posición de lectura queda sobre el primer byte de los datos del primer segmento

Un archivo de acceso aleatorio tiene menos redundancia que uno secuencial, así que el pre-scan es estricto. Rechaza el archivo cuando:

  • un header queda cortado por el final del buffer, reportado como JBIG2 truncated random-access header at byte N con el offset donde empezaba ese header
  • los números de segmento no crecen estrictamente (comparados como valores sin signo), porque dos headers que reclaman el mismo número vuelven ambigua la lista de referidos de una región posterior
  • una longitud de datos tiene el bit más alto encendido, lo que incluye el marcador de "longitud desconocida" 0xFFFFFFFF; en este layout no hay otra forma de encontrar dónde arranca el siguiente cuerpo, así que el decoder lo rechaza de inmediato en lugar de escanear hacia adelante buscando un marcador de fin
  • el header de end-of-file declara datos, o nunca aparece (JBIG2 random-access file is missing its end-of-file header)
  • las longitudes declaradas suman más que los bytes que quedan, o menos
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

La última regla es con la que la gente tropieza. Un solo byte extra después del último cuerpo falla con JBIG2 trailing random-access data. Eso parece duro al lado de la ruta secuencial, donde la decodificación simplemente se detiene en el segmento de end-of-file y cualquier cosa después se ignora. La asimetría tiene una razón. En el layout secuencial cada header está justo al lado de sus propios datos, así que la basura al final no puede haber corrido nada. En el layout de acceso aleatorio los headers son el único mapa del bloque de datos, y si las longitudes no cuadran, el decoder no puede distinguir un relleno inofensivo de un byte perdido o agregado mucho antes que corrió todos los cuerpos siguientes

¿Por qué un bucle que cuenta bytes pierde el último segmento de end-of-page?

Un bucle que corre "mientras queden bytes" termina demasiado pronto en un archivo de acceso aleatorio, porque los datos se agotan 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 van al final. Una vez consumido el cuerpo de la última región, la posición de lectura queda exactamente al final del buffer; un bucle que cuenta bytes sale ahí, y el segmento de end-of-page de longitud cero nunca se despacha, lo que deja la página sin terminar

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

Así que para los archivos de acceso aleatorio el decoder cuenta headers, no bytes. Cada paso salta de vuelta al siguiente header indexado, se realinea a un límite de byte (un cuerpo codificado aritméticamente puede terminar a mitad de byte), parsea el header de nuevo y luego salta hacia adelante hasta donde arranca el siguiente cuerpo y lo pasa de largo según la longitud declarada. Un cuerpo cuyo decoder termina antes se salta hasta su fin declarado; uno que lee más allá se rechaza. Desde ahí el segmento pasa por los mismos handlers y comprobaciones de referencias que en un archivo secuencial. Los errores lanzados al decodificar un cuerpo llevan la posición original del header, en la forma JBIG2 segment N (type T) at byte B: ..., así que el offset en LastError apunta a algo que usted puede encontrar en un editor hexadecimal

Cargar un archivo .jb2 de acceso aleatorio en Delphi

El código de aplicación no cambia con la organización. TPLJBIG2Decoder.LoadFromByteArray detecta el header del archivo y el layout, 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 llena un byte por píxel con 1 para negro

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;

Si quiere saber qué layout usa un archivo, para registrarlo o para ordenar un corpus antes de una corrida de conversión, lea el byte de flags usted mismo. La biblioteca no expone la organización que detectó, y nueve bytes son todo lo que necesita. El helper de abajo es algo para agregar a su propio código; simplemente repite las reglas de header de T.88:

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// Helper propio, no es parte de PDFlibJBIG2: clasifica un buffer por su header de archivo T.88
function DetectJb2Layout(const Data: TJBIG2ByteArray;
  out PageCountKnown: Boolean): TJb2Layout;
const
  Signature: array[0..7] of Byte = ($97, $4A, $42, $32, $0D, $0A, $1A, $0A);
var
  I: Integer;
begin
  Result := jlNoFileHeader;          // p. ej. un stream embebido sacado de un PDF
  PageCountKnown := False;
  if Length(Data) < 9 then
    Exit;
  for I := 0 to 7 do
    if Data[I] <> Signature[I] then
      Exit;
  PageCountKnown := (Data[8] and 2) = 0;   // bit 1 en 1: sin conteo de páginas de cuatro bytes
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

Trabajos por lotes: buffers con relleno y archivos rechazados

La regla de los datos finales es donde el código por lotes tiende a equivocarse, y cuando un archivo que abre en otro lado se rechaza así, mire el buffer antes que el archivo. Un .jb2 sacado de una columna BLOB de base de datos, una lectura de red de tamaño fijo o un formato contenedor con relleno de alineación puede llegar con unos cuantos bytes cero después del final real. Un archivo secuencial se encoge de hombros. Un archivo de acceso aleatorio con el mismo relleno falla con JBIG2 trailing random-access data, y lo mismo pasa con un buffer cortado unos bytes antes, que falla como truncado. Pase exactamente los bytes del archivo, dimensionados según la longitud real de la fuente, nunca según un tamaño de chunk o una capacidad

Un archivo rechazado tampoco deja nada atrás. LoadFromByteArray reinicia Width, Height y las scanlines antes de empezar a decodificar, así que una llamada fallida reporta dimensiones cero en lugar de la página del archivo anterior, y la misma instancia del decoder puede seguir directo con el siguiente archivo:

procedure DecodeBatch(const Files, Log, Quarantine: TStrings);
var
  Decoder: TPLJBIG2Decoder;
  I: Integer;
begin
  Decoder := TPLJBIG2Decoder.Create;
  try
    for I := 0 to Files.Count - 1 do
      if Decoder.LoadFromByteArray(ReadJb2(Files[I])) then
        Log.Add(Format('%s: %d x %d', [Files[I], Decoder.Width, Decoder.Height]))
      else
      begin
        // Width y Height son 0 aquí; nada del archivo anterior sobrevive
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // revise cómo se almacenaron los bytes antes de culpar al archivo
      end;
  finally
    Decoder.Free;
  end;
end;

Comprobar esto en su propio pipeline no necesita archivos de muestra raros. Reorganice un .jb2 secuencial al layout de acceso aleatorio, conservando cada header y cada byte comprimido, mueva los headers de end-of-page y end-of-file al final del bloque de headers, y los píxeles decodificados deben coincidir exactamente con el original secuencial, tanto con el campo de conteo de páginas como sin él. Luego aliméntele cada prefijo truncado, un número de segmento duplicado, un byte extra al final y una longitud 0xFFFFFFFF; cada uno debería devolver False con Width y Height en cero

Qué devuelve el decoder, y qué espera

El acceso aleatorio aquí es una propiedad del layout del archivo. TPLJBIG2Decoder devuelve el bitmap de la primera página sea cual sea la organización que use el archivo, y Width y Height describen esa página. Cada segmento debe declarar su longitud de datos; T.88 permite que una región genérica inmediata use el marcador 0xFFFFFFFF, y el decoder rechaza ese marcador en cualquiera de los dos layouts con unknown or oversized segment length is not supported. Las longitudes de prefijo Huffman custom y los conteos de entradas de tabla se limitan de la misma manera que en los archivos secuenciales

Decodificar JBIG2 es una etapa de una ruta de imágenes más larga que también cubre la extracción de imágenes PDF, la recompresión y la codificación JBIG2; la página de producto de PDF Library for Delphi cubre el resto de ese pipeline y los formatos de imagen soportados