Artículo técnico

Tablas Huffman de JBIG2 en un decoder PDF puro en Pascal

PDFlibPas versión 3.539.22 decodifica tablas Huffman personalizadas de JBIG2 de forma nativa: el decoder en Pascal puro de PDFlibJBIG2.pas parsea el segmento Tables (tipo 53), asigna códigos de prefijo canónicos en el orden de las líneas de la tabla como exige el Anexo B.3 de ITU-T T.88, consume las referencias a tablas personalizadas en el orden de los selectores para diccionarios de símbolos y regiones de texto, y acota cada lectura por la longitud de segmento declarada en vez de por los bytes que casualmente le sigan

El archivo que forzó este trabajo no tenía nada de especial a simple vista. Un contrato escaneado, comprimido con JBIG2 usando codificación Huffman de símbolos en vez de la codificación aritmética mucho más común, y con el encoder enviando sus propias tablas de códigos en lugar de las tablas estándar B.1 a B.15. Dos decoders independientes discrepaban sobre sus píxeles de refinement, y el decoder de PDFlibPas de entonces producía texto que parecía pasado por una trituradora: fragmentos de glifos corridos unos píxeles, una columna faltante en cada carácter. Nada lanzaba un error. Esa es la clase de bug que sobrevive años, porque un decoder que rechaza un archivo se gana un ticket de soporte, mientras que uno que lo renderiza ligeramente mal se gana un cliente que asume que el escaneo salió mal

¿Qué contiene realmente un segmento Tables de JBIG2?

Un segmento Tables es una descripción compacta de una tabla Huffman: un byte de flags, dos cotas de 32 bits con signo, y luego una seguidilla de pares (longitud de prefijo, longitud de rango) que particiona el intervalo entre las cotas, tal como lo plantea T.88 §7.4.13 y el Anexo B.2. El bit 0 del byte de flags es HTOOB y dice si la tabla tiene un código fuera de banda. Los bits 1 a 3 más uno dan HTPS, la cantidad de bits usada para escribir cada longitud de prefijo; los bits 4 a 6 más uno dan HTRS, el ancho de cada campo de longitud de rango. El bit 7 está reservado, y PDFlibPas rechaza el segmento si viene encendido en lugar de adivinar qué quiso decir una revisión futura con él. HTLOW y HTHIGH siguen como enteros de 32 bits con signo, que es el primer lugar donde un decoder puede equivocarse: leerlos como unsigned hace que una tabla cuyo límite inferior es negativo — algo perfectamente normal para anchos de símbolos delta-coded — parezca empezar en cuatro mil millones. Cada campo pasa por un helper local ReadField que contrasta el pedido contra la posición de bits donde termina el dato del segmento antes de tocar al lector, porque una tabla que leyera más allá de su segmento estaría consumiendo el header del siguiente segmento como longitudes de prefijo

Layout del segmento Tables detrás de la decodificación Huffman personalizada de JBIG2 en PDFlibPas: un byte de flags que carga HTOOB, HTPS y HTRS más un bit reservado que se rechaza, cotas HTLOW y HTHIGH con signo, una seguidilla de pares de longitudes de prefijo y rango, y las líneas de escape jbig2HuffmanLOW, una línea alta fija de 32 bits y el jbig2HuffmanOOB opcional
Cada campo del segmento se lee a través de un helper con verificación de límites porque una tabla que leyera más allá de su final declarado consumiría el header del siguiente segmento como longitudes de prefijo, y las líneas de escape centinela coinciden con las tablas estándar integradas
// TCodeTableSegment.readSegment, PDFlibJBIG2.pas
EndBit := (Int64(decoder.reader.bytePointer) +
  segmentHeader.getSegmentDataLength) * 8;
Flags := ReadField(8);
if (Flags and $80) <> 0 then
  raise EJBIG2DecodeError.Create('reserved custom Huffman table flag');
PrefixBits := ((Flags shr 1) and 7) + 1;   // HTPS
RangeBits  := ((Flags shr 4) and 7) + 1;   // HTRS
LowValue   := Integer(ReadField(32));      // HTLOW con signo
HighValue  := Integer(ReadField(32));      // HTHIGH con signo
if LowValue >= HighValue then
  raise EJBIG2DecodeError.Create('invalid custom Huffman range bounds');
CurrentValue := LowValue;
while CurrentValue < HighValue do
begin
  PrefixLength := ReadField(PrefixBits);
  RangeLength  := ReadField(RangeBits);
  if RangeLength > 32 then
    raise EJBIG2DecodeError.Create('invalid custom Huffman range length');
  AddLine(CurrentValue, PrefixLength, RangeLength);
  Inc(CurrentValue, Int64(1) shl RangeLength);
end;
AddLine(LowValue - 1, ReadField(PrefixBits), jbig2HuffmanLOW);
AddLine(HighValue,   ReadField(PrefixBits), 32);
if (Flags and 1) <> 0 then
  AddLine(0, ReadField(PrefixBits), jbig2HuffmanOOB);

Las dos líneas que se agregan después del ciclo son las líneas de escape del Anexo B.2: la línea de rango inferior empieza en HTLOW menos uno y cuenta hacia abajo, la línea de rango superior empieza en HTHIGH con un rango fijo de 32 bits, y la línea OOB opcional no tiene valor alguno. PDFlibPas las marca con las longitudes de rango centinela jbig2HuffmanLOW ($FFFFFFFD) y jbig2HuffmanOOB ($FFFFFFFE), la misma convención que usan sus quince tablas estándar integradas, así que al ciclo de decodificación no le importa si una tabla vino de la especificación o del archivo

¿Por qué hay que asignar los códigos de prefijo en el orden de las líneas de la tabla?

Porque el encoder nunca escribe los códigos. Un segmento Tables de JBIG2 solo carga longitudes de prefijo, y ambos lados reconstruyen los patrones de bits reales con el procedimiento canónico del Anexo B.3: contar cuántas líneas hay de cada longitud, asignar primero los códigos de longitud uno, luego desplazar a la izquierda y continuar, y dentro de una misma longitud repartir los códigos en el orden en que aparecen las líneas. Cualquier desviación de ese orden produce en silencio una tabla distinta. El decoder no se va a enterar, porque cada patrón de bits que genera sigue siendo un código de prefijo válido, solo que no el que usó el encoder, y la salida es un bitmap de apariencia plausible armado con los símbolos equivocados

Asignación canónica de códigos de prefijo en el decoder JBIG2 de PDFlibPas: al segmento llegan solo longitudes de prefijo, un counting sort estable sobre Counts, Starts y Positions conserva el orden de declaración dentro de cada longitud, los códigos de longitud uno se reparten primero y el código se desplaza a la izquierda por cada longitud, con la sobreasignación rechazada por el chequeo de Kraft
El encoder nunca escribe los patrones de bits, así que cualquier desviación del orden de las líneas de la tabla construye en silencio un código de prefijo distinto pero válido y la salida se ve plausible; las líneas de longitud cero quedan fuera como no usadas y los prefijos de más de 32 bits se rechazan
// THuffmanDecoder.buildTable, PDFlibJBIG2.pas
FillChar(Counts, SizeOf(Counts), 0);
for I := 0 to length - 1 do
begin
  if table[I].prefixLen > 32 then
    raise EJBIG2DecodeError.Create(
      'Huffman prefixes longer than 32 bits are not supported');
  Inc(Counts[table[I].prefixLen]);
end;
Active := 0;
Code := 0;
for Bits := 1 to 32 do
begin
  Starts[Bits]    := Active;
  Positions[Bits] := Active;
  Inc(Active, Counts[Bits]);
  if Code + UInt64(Counts[Bits]) > (UInt64(1) shl Bits) then
    raise EJBIG2DecodeError.Create('oversubscribed Huffman prefix codes');
  Code := (Code + UInt64(Counts[Bits])) shl 1;
end;
SetLength(Result, Active + 1);
for I := 0 to length - 1 do            // estable: se conserva el orden original
  if table[I].prefixLen > 0 then       // dentro de cada longitud de prefijo
  begin
    Result[Positions[table[I].prefixLen]] := table[I];
    Inc(Positions[table[I].prefixLen]);
  end;
Code := 0;
for Bits := 1 to 32 do
begin
  for I := Starts[Bits] to Positions[Bits] - 1 do
  begin
    Result[I].prefix := Cardinal(Code);
    Inc(Code);
  end;
  Code := Code shl 1;
end;
Result[Active].rangeLen := jbig2HuffmanEOT;

THuffmanDecoder.buildTable es un counting sort y no un sort de comparación por una razón: una pasada de conteo sobre Counts, Starts y Positions es estable por construcción, así que las líneas de igual longitud de prefijo caen en el resultado en el orden en que fueron declaradas, que es precisamente el orden por el que el Anexo B.3 asigna los códigos. Las líneas con longitud de prefijo cero se descartan antes de asignar códigos, porque B.3 las define como no usadas y no como códigos de un bit. Dos guardias viven en el mismo ciclo. El chequeo de sobreasignación atrapa una tabla cuyas longitudes reclaman más códigos de los que un código de prefijo de esa profundidad puede sostener, que es la desigualdad de Kraft expresada como comparación de enteros; sin él, una tabla hostil produce un código que coincide con dos líneas y el decoder elige el que primero escanee. El techo de 32 bits existe porque prefix es un Cardinal y el matcher de decodeInt acumula bits en uno. T.88 permite prefijos más largos en el papel, PDFlibPas los rechaza por su nombre, y no se ha visto ningún encoder real emitir uno. La aritmética de valores necesita el mismo cuidado que la de códigos: THuffmanTable.val es un Int64, y la línea de rango inferior se decodifica como val - readBits(32), un offset unsigned de 32 bits restado a HTLOW menos uno. Con intermediarios Integer esa resta da la vuelta, y el valor envuelto se acepta después como un ancho de símbolo. La ruta de 64 bits calcula el valor verdadero, lo contrasta contra el rango con signo de 32 bits y lanza si no cabe, lo que convierte una corrupción silenciosa en un rechazo explícito

¿Por qué las tablas personalizadas nunca se activaban antes de 3.539.22?

Dos defectos se tapaban entre sí. El primero era un bug de una línea en un setter: TTextRegionHuffmanFlags.setFlags recibía su argumento con el mismo nombre del campo donde lo guardaba, así que Self.flagsAsInt := flagsAsInt asignaba el campo sin inicializar a sí mismo y cada selector se leía como cero, lo que mandaba a las regiones de texto que pedían tablas personalizadas por las tablas estándar F, H y K. El segundo defecto implicaba que arreglar solo el primero igual habría producido símbolos corruptos. Cuando un diccionario de símbolos Huffman guarda sus símbolos como un bitmap colectivo sin comprimir, el último byte de cada fila queda parcial, y el viejo ciclo de copia trataba padding, que guarda la cantidad de bits válidos, como la posición del bit válido más bajo; una fila de 63 píxeles de ancho copiaba un bit de su byte final en lugar de siete. El ciclo corregido corre for bitPointer := 7 downto ((8 - padding) and 7), y fixtures sintéticos de 7 y 9 bits de ancho fijan ambos lados de la frontera del byte. Con los selectores leyendo bien, las tablas se entregan en el orden en que la especificación las lista, que T.88 §7.4.3.1.2 fija para regiones de texto como FS, DS, DT, RDW, RDH, RDX, RDY y RSIZE, y §7.4.2.1.1 fija para diccionarios de símbolos como DH, DW, BMSIZE y AGGINST. Cada selector de dos bits significa tabla estándar 0 o 1, reservado el 2 en campos con solo dos tablas estándar, y personalizado el 3, y cada selección personalizada consume el siguiente segmento Tables entre los segmentos referidos en orden de referencia. NextCustomHuffmanTable hace exactamente ese recorrido y lanza missing custom Huffman table reference cuando una región refiere menos tablas de las que sus selectores demandan. Una línea más pertenece al mismo fix: un diccionario de símbolos Huffman cuyos símbolos de entrada y nuevos suman uno calcula una longitud de código de símbolo de cero con la fórmula de log2, mientras que la variante Huffman del formato escribe cada ID de símbolo con al menos un bit, así que if sdHuffman and (symbolCodeLength = 0) then symbolCodeLength := 1 en TSymbolDictionarySegment evita que la ruta de refinement y agregación lea cero bits por ID de símbolo

¿Qué garantiza la frontera del segmento?

PDFlibPas trata la longitud de dato en el header de cada segmento como un contrato que ambas direcciones deben honrar: un segmento no puede leer más allá de su final declarado, y no puede quedarse corto y dejar el siguiente header en un offset impredecible. Las reglas que se desprenden de ese contrato son individualmente pequeñas. Una longitud de dato con el bit 31 encendido es el marcador de longitud desconocida de T.88 §7.2.7, y handleSegmentDataLength la mapea a un valor negativo que readSegments rechaza de plano en lugar de escanear hacia adelante buscando un terminador. Cada número de segmento referido tiene que ser menor que el número del segmento actual y tiene que existir ya, así que una referencia adelantada o colgante falla antes de que cualquier región intente resolverla. END_OF_PAGE y END_OF_FILE deben declarar cero bytes de dato. Un segmento Profiles (tipo 52) carga un conteo de 32 bits seguido de esa cantidad de identificadores de 32 bits y ningún píxel, así que se verifica como 4 más 4 por el conteo contra la longitud declarada, se salta, y se conserva en la lista de segmentos solo para que segmentos posteriores puedan seguir refiriéndose a él por número. Un identificador de perfil desconocido no es una codificación desconocida, y tratarlo como tal rechazaría archivos que decodifican perfectamente bien

// TJBIG2StreamDecoder.readSegments, PDFlibJBIG2.pas
DataLength := segmentHeader.getSegmentDataLength;
if DataLength < 0 then
  raise EJBIG2DecodeError.Create(Context +
    'unknown or oversized segment length is not supported');
if DataLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create(Context + 'truncated segment data');
DataEnd := reader.bytePointer + DataLength;
for I := 0 to noOfReferredToSegments - 1 do
  if (referredToSegments[I] >= segmentHeader.getSegmentNumber) or
     (findSegment(referredToSegments[I]) = nil) then
    raise EJBIG2DecodeError.Create(Context + 'invalid segment reference');
// ... crea el objeto de segmento para este tipo ...
reader.SegmentEnd := DataEnd;
segment.readSegment;
if reader.bytePointer > DataEnd then
  raise EJBIG2DecodeError.Create(Context +
    'decoded data exceeds declared segment length');
if reader.bytePointer < DataEnd then
begin
  reader.bytePointer := DataEnd;   // MMR puede dejar el EOFB sin leer
  reader.bitPointer := 7;
end;

El final de ese ciclo es donde una versión anterior del decoder se equivocaba con las regiones codificadas en MMR. Un decoder MMR sabe que terminó cuando produce el último píxel de la última fila, lo que puede ocurrir antes de haber consumido el terminador EOFB que T.88 §6.2.5.7 coloca al final del dato. El código viejo asumía que el lector estaba posicionado en el siguiente header, así que los bytes sobrantes del terminador se parseaban como un número de segmento y el stream fallaba unos bytes después con un error engañoso. Ahora gana el final declarado: leer más allá es un error, quedarse corto es normal, y el lector se mueve a DataEnd con el puntero de bits reseteado para que el siguiente header se lea desde donde el archivo dijo que estaría. La misma disciplina aparece donde PDFlibPas parsea estructuras PDF no confiables: la longitud declarada es la frontera, y el decoder no sale a buscar una más amigable

¿De dónde lee su tamaño de bitmap el refinement Huffman?

Antes de que arranque el decoder aritmético, y de un campo que solo existe en modo Huffman. Cuando una instancia de región de texto lleva refinement (RI distinto de cero) y SBHUFF está encendido, T.88 §6.4.11 hace que el decoder lea RDW, RDH, RDX y RDY con sus tablas seleccionadas, luego BMSIZE con la tabla RSIZE, luego se alinee a una frontera de byte, y solo entonces ejecute la decodificación genérica de refinement sobre exactamente BMSIZE bytes. Las regiones de texto en modo aritmético no tienen ese campo, y un decoder que comparte una sola ruta de código para ambos modos lo va a saltar, arrancar el decoder aritmético dos o más bytes antes, y refinar cada símbolo contra basura. La ruta del diccionario de símbolos con REFAGG y una única instancia de refinement, descrita en §6.5.8.2.2, tiene el mismo campo BMSIZE con las mismas consecuencias. En PDFlibPas la cota superior de ese tamaño es TStreamReader.SegmentEnd, el final del segmento actual fijado por readSegments, y no el final del stream entero, porque un BMSIZE que solo pueda satisfacerse pidiendo prestados bytes del siguiente segmento está malformado, y validarlo contra la longitud del stream dejaría al decoder aritmético leer dentro del siguiente header. La cota inferior de dos bytes refleja el par de bytes inicial que el decoder aritmético siempre consume, y después del refinement el lector salta a RefinementEnd sin importar cuánto haya adelantado el decoder aritmético, porque su posición final no es la posición del siguiente campo codificado en Huffman

Cotas del refinement en modo Huffman en el decoder JBIG2 de PDFlibPas: RDW, RDH, RDX y RDY se decodifican con sus tablas, BMSIZE se decodifica con la tabla RSIZE y se alinea a byte, luego el decoder aritmético refina exactamente BMSIZE bytes contenidos entre RefinementEnd y SegmentEnd, rechazando tamaños menores a dos o que pasen la frontera del segmento
La cota inferior de dos bytes refleja el par inicial que el decoder aritmético siempre consume, la cota superior es el segmento actual y no el stream completo, y tras el refinement el lector salta a RefinementEnd sin importar cuánto leyó de más
// Decodificación de región de texto TJBIG2Bitmap, ruta de refinement Huffman
RefinementSize := huffmanDecoder.decodeInt(huffmanRSizeTable).intResult;
huffmanDecoder.consumeRemainingBits;
if (RefinementSize < 2) or
   (RefinementSize > huffmanDecoder.reader.SegmentEnd -
                     huffmanDecoder.reader.bytePointer) then
  raise EJBIG2DecodeError.Create('invalid refinement bitmap size');
RefinementEnd := huffmanDecoder.reader.bytePointer + RefinementSize;
arithmeticDecoder.start;
// ... readGenericRefinementRegion ...
if huffmanDecoder.reader.bytePointer > RefinementEnd then
  raise EJBIG2DecodeError.Create('refinement data exceeds declared size');
huffmanDecoder.reader.bytePointer := RefinementEnd;
huffmanDecoder.reader.bitPointer := 7;

Qué se verificó y qué sigue rechazándose

La muestra que originó esto, una imagen JBIG2 de 500 por 473 píxeles con tablas personalizadas y refinement Huffman, ahora decodifica a un bitmap con cero píxeles diferentes frente a un decoder independiente, y los fixtures sintéticos de bitmap colectivo de 7 y 9 bits producen las filas esperadas en ambos casos. Los dos decoders independientes que discrepaban sobre la muestra original siguen discrepando entre sí; PDFlibPas coincide con uno de ellos, y la afirmación honesta es que la salida nativa coincide con una implementación independiente y con la especificación tal como se lee, no que todos los decoders del mundo coincidan. El lado malformado de la suite cubre:

  • un bit de flag reservado o un valor de selector reservado
  • una tabla truncada a media línea
  • longitudes de prefijo sobreasignadas y prefijos de más de 32 bits
  • una región cuyos selectores piden más tablas personalizadas de las que refiere
  • confirmación de que la salida vencida se limpia tras una decodificación fallida en lugar de quedar en su sitio para que el llamador la confunda con un resultado

Tres límites siguen siendo deliberados. La organización de stream de acceso aleatorio, donde todos los headers de segmento preceden a todos los datos de segmento, lanza JBIG2 random-access organisation is not supported apenas se leen los flags del header del archivo, porque no existe una muestra representativa contra la cual validarlo y una ruta implementada a medias es peor que un rechazo con nombre. Las tablas personalizadas tienen un tope de 65.536 líneas y prefijos de 32 bits. Y la entrada pública de decodificación, TPLJBIG2Decoder.LoadFromByteArray, devuelve el bitmap de la primera página en orden de stream a través de getPageAsJBIG2Bitmap(0), el primer segmento de información de página que se encuentre, en lugar de buscar la asociación de página cero; los streams PDF embebidos suelen numerar su única página como 1, y pedir la página 0 por asociación no encontraría nada. El texto de falla cae en TPLJBIG2Decoder.LastError, el diagnóstico interno del decoder que carga el número de segmento, el tipo y el offset de bytes de la falla, y no es lo mismo que el TPDFlib.LastErrorCode a nivel de librería. Nada de esto toca el lado de codificación, que está cubierto en las notas sobre backends de encoder JBIG2 y cómo se enlazan; la ruta de lectura tiene que aceptar lo que el encoder de otro decidió emitir, y comparte sus reglas con el resto del stack de imágenes, incluido el decoder TIFF integrado y sus rechazos de BigTIFF y layout tiled: rechazar por su nombre, jamás pedir bytes prestados a través de una frontera declarada, y mantener la aritmética lo bastante ancha como para que un intermedio envuelto no pase por respuesta válida. Si está evaluando una ruta de lectura JBIG2 nativa para Delphi o C++Builder, el decoder y el resto del manejo de imágenes están documentados en la página de PDF Library for Delphi