Artículo técnico

Archivos JBIG2 de acceso aleatorio: decodificarlos en Delphi

Este es el fallo que un lector JBIG2 escrito a mano comete con el primer fichero raro que se encuentra. Parsea la cabecera del fichero, parsea la primera cabecera de segmento, lee la longitud de datos de cuatro bytes y toma esos bytes como cuerpo del segmento. En la mayoría de ficheros .jb2 eso es exactamente correcto. En un fichero cuyo byte de flags tiene el bit 0 a cero, esos bytes de "cuerpo" son en realidad las cabeceras del segundo, tercer y cuarto segmento, y el decodificador aritmético los convierte en un bitmap de ruido o se sale del final del búfer. El fichero no está roto. Usa la organización de acceso aleatorio del Anexo D.2 de ITU-T T.88, donde todas las cabeceras de segmento van primero y todos los datos de segmento van después, en el mismo orden

Los ficheros de acceso aleatorio son raros. Salen de sistemas de archivo y de imagen documental que quieren que un lector vea cada página y cada dependencia de diccionario antes de tocar un byte comprimido. Si un trabajo de conversión mezcla ficheros de semejante origen con escaneos ordinarios, un fichero de acceso aleatorio puede quedar en mitad de un lote que por lo demás es todo secuencial. El decodificador que los lee es el mismo decodificador Pascal nativo que se encarga de las tablas Huffman personalizadas y códigos de prefijo canónicos de JBIG2, así que la parte de codificación no cambia; solo difiere el orden en que se encuentran los segmentos

Tres disposiciones de fichero detrás de un byte de flags

PDF Library for Delphi lee ficheros JBIG2 independientes a través de TPLJBIG2Decoder en la unidad PDFlibJBIG2, y el decodificador averigua la disposición por su cuenta a partir de la cabecera del fichero. El Anexo D de T.88 permite ordenar los mismos segmentos de tres formas: secuencial (D.1) coloca cada cabecera justo delante de sus datos, acceso aleatorio (D.2) pone todas las cabeceras primero y todos los datos después, y embebido (D.3) es la forma sin cabecera que se usa dentro de otros contenedores. Un fichero independiente empieza con el identificador de ocho bytes 97 4A 42 32 0D 0A 1A 0A, luego un byte de flags y después un contador de páginas de cuatro bytes, salvo que los flags digan que el contador es desconocido

El bit 0 de ese byte de flags elige la organización: 1 significa secuencial, 0 significa acceso aleatorio. El bit 1 a uno significa que el número de páginas es desconocido y el contador de cuatro bytes está ausente, así que la primera cabecera de segmento empieza en el byte 9 en lugar del byte 13. Los bits 2 a 7 están reservados; el decodificador acepta los ficheros que los activan en lugar de rechazarlos, porque un bit reservado perdido no dice nada sobre si los segmentos en sí son legibles

Organizaciones de archivo JBIG2 en PDFlibPas: el byte de flags que lee setFileHeaderFlags elige secuencial D.1 con cabeceras intercaladas, acceso aleatorio D.2 con toda cabecera antes del bloque de datos, o embebida D.3, la forma sin cabecera que usa un stream JBIG2Decode con diccionarios en JBIG2Globals
Las tres disposiciones llevan los mismos segmentos, pero solo el acceso aleatorio hace que un lector vea cada dependencia de página y diccionario antes de tocar un byte comprimido, que es la razón por la que los pipelines de archivo lo pidieron

El PDF en sí nunca lleva la disposición de acceso aleatorio. Un stream de imagen JBIG2Decode, descrito en ISO 32000-1 §7.4.7, solo contiene segmentos de página en forma embebida, con los diccionarios de símbolos compartidos movidos a un stream JBIG2Globals aparte y sin cabecera de fichero ni segmentos de fin de página o de fin de fichero. Cuando el decodificador no encuentra el identificador de ocho bytes, trata la entrada exactamente como ese tipo de stream: secuencial, una página. En el otro sentido, cuando PDF Library for Delphi exporta una imagen JBIG2Decode como fichero .jb2 nativo, escribe el byte de flags $03 (secuencial, contador de páginas desconocido) y añade una cabecera de segmento de fin de fichero. Así que el acceso aleatorio solo llega desde fuera, como fichero independiente entregado directamente a TPLJBIG2Decoder, normalmente de camino a convertirse o recomprimirse para PDF con los backends de codificación JBIG2 de PDFlibPas

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

Porque nada en el stream de bytes marca dónde termina el bloque de cabeceras y empieza el bloque de datos, salvo la propia cabecera del segmento de fin de fichero (tipo 51). Las cabeceras de segmento JBIG2 no tienen tamaño fijo. El conteo de segmentos referidos es una forma corta de tres bits o una forma larga seguida de un bitmap de retención; cada número de segmento referido ocupa uno, dos o cuatro bytes según lo grande que sea el número del propio segmento actual; el campo de asociación de página es de un byte o de cuatro. Un lector tiene que parsear cada campo de una cabecera solo para saber dónde empieza la siguiente

Un lector secuencial que recorre el fichero en orden acierta con la primera cabecera y falla con todo lo demás. Peor aún: puede no enterarse durante un rato, porque un decodificador de región genérica alimentado con bytes de cabecera produce píxeles, no un error, hasta que alguna comprobación posterior de longitud o de referencia tropieza por fin. Nuestra opinión es que un decodificador que no indexa las cabeceras primero debería rechazar los ficheros de acceso aleatorio sin más. Un rechazo claro cuesta un fichero en un lote; un bitmap con aspecto plausible pero equivocado puede acabar en un archivo y quedarse ahí

Indexar cada cabecera antes del primer cuerpo

Cuando el byte de flags dice acceso aleatorio, PDF Library for Delphi hace una pasada sobre el bloque de cabeceras antes de decodificar nada. Cada cabecera se parsea por completo y luego se tira; lo único que se conserva es su offset de byte en el fichero. Los datos comprimidos nunca se copian a un segundo búfer reordenado, lo cual importa en escaneos de archivo grandes donde el bloque de datos puede ser la mayor parte del fichero. La pasada se detiene en la primera cabecera de fin de fichero, y en ese momento la posición de lectura está sobre el primer byte de los datos del primer segmento

Un fichero de acceso aleatorio tiene menos redundancia que uno secuencial, así que el preescaneo es estricto. Rechaza el fichero cuando:

  • una cabecera queda cortada por el final del búfer, reportado como JBIG2 truncated random-access header at byte N con el offset donde empezaba esa cabecera
  • los números de segmento no crecen estrictamente (comparados como valores sin signo), porque dos cabeceras que reclamen el mismo número dejan ambigua la lista de referidos de una región posterior
  • una longitud de datos tiene el bit superior a uno, lo que incluye el marcador 0xFFFFFFFF de "longitud desconocida"; en esta disposición no hay otra forma de encontrar dónde empieza el siguiente cuerpo, así que el decodificador lo rechaza de inmediato en lugar de escanear buscando un marcador de fin
  • la cabecera de fin de fichero declara datos, o no aparece nunca (JBIG2 random-access file is missing its end-of-file header)
  • las longitudes declaradas suman más que los bytes que quedan, o menos
Preescaneo IndexRandomHeaders en PDFlibPas: cada cabecera de segmento se parsea y se descarta guardando solo su offset en bytes, los números de segmento deben crecer estrictamente, la longitud desconocida 0xFFFFFFFF se rechaza, el escaneo se para en la cabecera de fin de archivo de tipo 51, y las longitudes declaradas deben igualar exactamente los bytes restantes
La severidad es deliberada: en una disposición donde las cabeceras dan el único mapa de los datos, un byte suelto significa que todos los cuerpos posteriores pueden estar desplazados, así que un decodificador que lo tolere no distingue padding de desalineación

La última regla es la que la gente tropieza. Un solo byte de más tras el cuerpo final falla con JBIG2 trailing random-access data. Junto al camino secuencial, donde la decodificación simplemente se detiene en el segmento de fin de fichero y todo lo que venga después se ignora, parece severo. La asimetría tiene su motivo. En la disposición secuencial cada cabecera está justo al lado de sus propios datos, así que la basura del final no puede haber desplazado nada. En la disposición de acceso aleatorio las cabeceras son el único mapa del bloque de datos, y si las longitudes no cuadran, el decodificador no puede distinguir un relleno inofensivo de un byte perdido o añadido mucho antes que haya desplazado todos los cuerpos posteriores

¿Por qué un bucle que cuenta bytes pierde el último segmento de fin de página?

Un bucle que corre "mientras queden bytes" termina demasiado pronto en un fichero de acceso aleatorio, porque los datos se agotan antes que el índice de cabeceras. Los segmentos de fin de página (tipo 49) y de fin de fichero llevan cero bytes de datos y normalmente van al final. Una vez consumido el último cuerpo de región, la posición de lectura está exactamente al final del búfer; un bucle contado por bytes sale ahí, y el segmento de fin de página 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 busca en HeaderOffsets la cabecera actual, reajusta bitPointer a 7 para deshacer colas a mitad de byte, salta a NextBodyOffset para el cuerpo, y cuenta cabeceras en lugar de bytes para que los segmentos de fin de página de longitud cero se despachen antes de que el bucle acabe
Como los segmentos de fin de página y de fin de archivo llevan cero bytes de datos, el stream de datos se acaba antes que el índice de cabeceras, y solo un bucle que cuente cabeceras puede darles turno a esos segmentos finales

Así que para los ficheros de acceso aleatorio el decodificador cuenta cabeceras, no bytes. Cada paso salta a la siguiente cabecera indexada, se realinea a un límite de byte (un cuerpo codificado aritméticamente puede terminar en mitad de un byte), parsea la cabecera de nuevo, y luego salta hacia adelante a donde empieza el siguiente cuerpo y lo avanza según la longitud declarada. Un cuerpo cuyo decodificador termina antes se salta hasta su final declarado; uno que lee más allá se rechaza. Desde ahí el segmento pasa por los mismos manejadores y comprobaciones de referencia que en un fichero secuencial. Los errores elevados mientras se decodifica un cuerpo llevan la posición original de la cabecera, con la forma JBIG2 segment N (type T) at byte B: ..., así que el offset en LastError apunta a algo que puedes encontrar en un editor hexadecimal

Cargar un fichero .jb2 de acceso aleatorio en Delphi

El código de aplicación no cambia con la organización. TPLJBIG2Decoder.LoadFromByteArray detecta la cabecera del fichero y la disposició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 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 quieres saber qué disposición usa un fichero, para registrarla o para ordenar un corpus antes de una pasada de conversión, lee el byte de flags tú mismo. La biblioteca no expone la organización que detectó, y nueve bytes son todo lo que hace falta. La función auxiliar de abajo es para añadirla a tu propio código; se limita a reproducir las reglas de cabecera de T.88:

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// Función auxiliar tuya, no forma parte de PDFlibJBIG2: clasifica un búfer por su cabecera de fichero 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 extraído 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 a uno: sin contador de páginas de cuatro bytes
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

Trabajos por lotes: búferes con relleno y ficheros rechazados

La regla de datos finales es donde el código por lotes suele fallar, y cuando un fichero que abre en otros sitios se rechaza por este motivo, mira el búfer antes que el fichero. Un .jb2 sacado de una columna BLOB de base de datos, de una lectura de red de tamaño fijo o de un formato contenedor con relleno de alineación puede llegar con unos pocos bytes cero tras el final real. Un fichero secuencial se encoge de hombros. Un fichero de acceso aleatorio con el mismo relleno falla con JBIG2 trailing random-access data, y también falla un búfer cortado unos bytes, que falla como truncado. Pasa exactamente los bytes del fichero, dimensionados por la longitud real del origen, nunca por un tamaño de chunk ni una capacidad

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

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 valen 0 aquí; nada del fichero anterior sobrevive
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // comprueba cómo se almacenaron los bytes antes de culpar al fichero
      end;
  finally
    Decoder.Free;
  end;
end;

Comprobar esto en tu propio pipeline no necesita ficheros de muestra raros. Reorganiza un .jb2 secuencial a disposición de acceso aleatorio, conservando cada cabecera y cada byte comprimido, mueve las cabeceras de fin de página y de fin de fichero al final del bloque de cabeceras, y los píxeles decodificados deben coincidir exactamente con el original secuencial, tanto con como sin el campo de contador de páginas. Luego dale cada prefijo truncado, un número de segmento duplicado, un byte final de más y una longitud 0xFFFFFFFF; cada uno debe devolver False con Width y Height a cero

Qué devuelve el decodificador y qué espera

El acceso aleatorio aquí es una propiedad de la disposición del fichero. TPLJBIG2Decoder devuelve el bitmap de la primera página use la organización que use el fichero, 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 decodificador rechaza ese marcador en cualquiera de las dos disposiciones con unknown or oversized segment length is not supported. Las longitudes de prefijo Huffman personalizadas y los conteos de entradas de tabla se limitan igual que en los ficheros secuenciales

Decodificar JBIG2 es una etapa de un camino de imagen más largo que cubre también extracción de imágenes PDF, recompresión y 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