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 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 orden de selector para symbol dictionaries y text regions, y acota cada lectura por la longitud declarada del segmento en vez de por los bytes que vengan después

El archivo que forzó este trabajo no pintaba nada raro. Un contrato escaneado, comprimido con JBIG2 usando codificación Huffman de símbolos en lugar de la codificación aritmética mucho más común, y con el encoder enviando sus propias tablas de código en vez de las tablas estándar B.1 a B.15. Dos decoders independientes discrepaban sobre sus píxeles de refinement, y el decoder PDFlibPas de entonces producía texto con pinta de haber pasado por una destructora de documentos: fragmentos de glifos desplazados unos píxeles, una columna de cada carácter desaparecida. Nada levantaba un error. Esa es la pinta del bug que sobrevive años, porque un decoder que rechaza un archivo genera un ticket de soporte, mientras que un decoder que lo renderiza ligeramente mal genera un cliente que asume que el escaneo era malo

¿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 tanda de pares (longitud de prefijo, longitud de rango) que particionan el intervalo entre las cotas, tal como lo disponen 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 out-of-band. Los bits 1 a 3 más uno dan HTPS, el número de bits con el que se escribe 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 está a uno en lugar de adivinar qué quiso decir una revisión futura con él. HTLOW y HTHIGH llegan después como enteros de 32 bits con signo, que es el primer sitio donde un decoder puede equivocarse: leerlos como unsigned hace que una tabla con cota baja negativa, algo perfectamente normal para anchos de símbolo delta-codificados, parezca empezar en cuatro mil millones. Cada campo pasa por un helper local ReadField que comprueba la petición contra la posición de bit 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 la cabecera del siguiente segmento como longitudes de prefijo

Layout del segmento Tables tras la decodificación Huffman personalizada de JBIG2 en PDFlibPas: un byte de flags con HTOOB, HTPS y HTRS más un bit reservado que se rechaza, cotas HTLOW y HTHIGH con signo, una tanda de pares de longitud de prefijo y de rango, y las líneas de escape jbig2HuffmanLOW, una línea alta fija de 32 bits y el opcional jbig2HuffmanOOB
Cada campo del segmento se lee a través de un helper con comprobación de límites porque una tabla que leyera más allá de su final declarado consumiría la cabecera 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 añaden tras el bucle son las líneas de escape del Anexo B.2: la línea de rango inferior arranca en HTLOW menos uno y cuenta hacia abajo, la línea de rango superior arranca 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 bucle de decodificación le da igual de dónde vino la tabla, si de la especificación o del archivo

¿Por qué hay que asignar los códigos 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 lleva longitudes de prefijo, y ambas partes 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 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 enterará, porque cada patrón de bits que genera sigue siendo un código prefijo válido, solo que no el que usó el encoder, y la salida es un bitmap de aspecto plausible montado con los símbolos equivocados

Asignación canónica de códigos prefijo en el decoder JBIG2 de PDFlibPas: al segmento solo llegan 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 longitud, con la sobreasignación rechazada por la comprobación de Kraft
El encoder nunca escribe los patrones de bits, así que cualquier desviación del orden de las líneas construye en silencio un código prefijo distinto pero válido y la salida parece plausible; las líneas de longitud cero se descartan como sin uso 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 en lugar de un sort por comparación por una sola 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 se declararon, que es precisamente el orden por el que el Anexo B.3 asigna códigos. Las líneas de longitud de prefijo cero se descartan antes de la asignación de códigos, porque B.3 las define como sin uso y no como códigos de un bit. Dos guardias se sientan en el mismo bucle. La comprobación de sobreasignación caza una tabla cuyas longitudes reclaman más códigos de los que un código prefijo de esa profundidad puede sostener, que es la desigualdad de Kraft expresada como comparación de enteros; sin ella, una tabla hostil produce un código que casa con dos líneas y el decoder se queda con la primera que 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 sobre 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 aritmética 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 hace wrap, y el valor envuelto se acepta luego como ancho de símbolo. El camino de 64 bits calcula el valor verdadero, lo comprueba contra el rango de 32 bits con signo, y levanta si no cabe, lo que convierte una corrupción silenciosa en un rechazo explícito

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

Dos defectos se tapaban el uno al otro. El primero era un bug de setter de una línea: TTextRegionHuffmanFlags.setFlags recibía su argumento con el mismo nombre que el campo en el que lo guardaba, así que Self.flagsAsInt := flagsAsInt asignaba el campo sin inicializar a sí mismo y todos los selectores se leían como cero, lo que mandaba a las text regions que pedían tablas personalizadas por las tablas estándar F, H y K. El segundo defecto implicaba que arreglar solo el primero habría seguido produciendo símbolos corruptos. Cuando un symbol dictionary Huffman almacena sus símbolos como un collective bitmap sin comprimir, el último byte de cada fila es parcial, y el viejo bucle de copia trataba padding, que guarda el número 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 bucle corregido corre for bitPointer := 7 downto ((8 - padding) and 7), y fixtures sintéticos de anchos de 7 y 9 bits fijan ambos lados de la frontera del byte. Con los selectores leyendo correctamente, las tablas se reparten en el orden en que la especificación las lista, que T.88 §7.4.3.1.2 fija para text regions como FS, DS, DT, RDW, RDH, RDX, RDY y RSIZE y §7.4.2.1.1 fija para symbol dictionaries como DH, DW, BMSIZE y AGGINST. Cada selector de dos bits significa tabla estándar 0 o 1, reservado para 2 en campos con solo dos tablas estándar, y personalizada para 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 levanta missing custom Huffman table reference cuando una región refiere menos tablas de las que sus selectores exigen. Una línea más pertenece al mismo fix: un symbol dictionary 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 el if sdHuffman and (symbolCodeLength = 0) then symbolCodeLength := 1 en TSymbolDictionarySegment evita que el camino 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 de cada cabecera de 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 la siguiente cabecera en un offset impredecible. Las reglas que caen de ese contrato son individualmente pequeñas. Una longitud de dato con el bit 31 a uno 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 delante buscando un terminador. Cada número de segmento referido debe ser menor que el número del segmento actual y debe existir ya, así que una referencia hacia delante 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) lleva un count de 32 bits seguido de otros tantos identificadores de 32 bits y ningún píxel, así que se comprueba como 4 más 4 por el count contra la longitud declarada, se salta, y se mantiene 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

// 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');
// ... crear 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 bucle es donde una versión anterior del decoder se equivocaba con las regiones codificadas MMR. Un decoder MMR sabe que ha terminado cuando produce el último píxel de la última fila, lo que puede pasar 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 la siguiente cabecera, así que los bytes del terminador sobrante se parseaban como un número de segmento y el stream fallaba unos bytes después con un error equívoco. 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 bit reseteado para que la siguiente cabecera se lea desde donde el archivo dijo que estaría. La misma disciplina aparece dondequiera que PDFlibPas parsea estructuras PDF no confiables: la longitud declarada es la frontera, y el decoder no va a buscar una más amable

¿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 text region lleva refinement (RI distinto de cero) y SBHUFF está activo, 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 de refinement genérica sobre exactamente BMSIZE bytes. Las text regions en modo aritmético no tienen ese campo, y un decoder que comparte un solo camino de código para ambos modos se lo saltará, arrancará el decoder aritmético dos o más bytes antes, y refinará cada símbolo contra basura. El camino del symbol dictionary con REFAGG y una única instancia de refinement, descrito 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 tal como lo fija readSegments, no el final de todo el stream, porque un BMSIZE que solo pueda satisfacerse pidiendo bytes prestados del segmento siguiente está malformado y validarlo contra la longitud del stream dejaría al decoder aritmético leer dentro de la siguiente cabecera. La cota inferior de dos bytes refleja el par de bytes inicial que el decoder aritmético siempre consume, y tras el refinement el lector salta a RefinementEnd sin importar cuánto haya leído por delante el decoder aritmético, porque su posición final no es la posición del siguiente campo codificado Huffman

Cotas del refinement en modo Huffman en el decoder JBIG2 de PDFlibPas: RDW, RDH, RDX y RDY decodifican desde sus tablas, BMSIZE decodifica desde 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 de 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 todo el stream, y tras el refinement el lector salta a RefinementEnd sin importar la lectura anticipada
// Decodificación de text region en TJBIG2Bitmap, camino 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 collective bitmap de 7 y 9 bits producen las filas esperadas en ambos. Los dos decoders independientes que discrepaban sobre la muestra original siguen discrepando entre sí; PDFlibPas coincide con uno de ellos, y la declaración honesta es que la salida nativa concuerda con una implementación independiente y con la especificación tal como se lee, no que todos los decoders del mundo concuerden. El lado malformado de la suite cubre:

  • un bit de flag reservado o un valor de selector reservado
  • una tabla truncada en mitad de una 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 vieja se limpia tras una decodificación fallida en lugar de quedarse en su sitio para que el caller la confunda con un resultado

Tres límites siguen siendo deliberados. La organización de stream de acceso aleatorio, donde todas las cabeceras de segmento preceden a todos los datos de segmento, levanta JBIG2 random-access organisation is not supported en cuanto se leen los flags de la cabecera del archivo, porque no existe ninguna muestra representativa contra la que validarlo y un camino medio implementado es peor que un rechazo con nombre. Las tablas personalizadas están topadas en 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 page-information encontrado, en lugar de buscar la asociación de página cero; los streams PDF incrustados numeran rutinariamente su única página como 1, y pedir la página 0 por asociación no encontraría nada. El texto de fallo aterriza en TPLJBIG2Decoder.LastError, el diagnóstico interno del decoder que lleva el número de segmento, el tipo y el offset de byte del fallo, y no es lo mismo que el TPDFlib.LastErrorCode a nivel de biblioteca. 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; el camino de lectura tiene que aceptar lo que el encoder de otro decidió emitir, y comparte sus reglas con el resto de la pila de imágenes, incluido el decoder TIFF integrado y sus rechazos de BigTIFF y layout teselado: rechazar con nombre, jamás pedir bytes prestados a través de una frontera declarada, y mantener la aritmética lo bastante ancha para que un intermediario con wrap no pueda pasar por respuesta válida. Si estás evaluando un camino de lectura JBIG2 nativo 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