Artículo técnico

Halftone JBIG2 en PDFlibPas: HSKIP y offsets negativos

PDFlibPas corrigió dos fallas independientes en su decoder nativo de regiones halftone JBIG2: en v3.539.37 la máscara de skip HSKIP se indexa como HSKIP[ng, mg] como lo define ITU-T T.88 §6.6.5.1, y en v3.539.38 las cuadrículas que llegan a coordenadas negativas, por un HGX o HGY negativo o por rotación, se colocan con un verdadero floor shift. Antes de esas versiones, las regiones halftone afectadas salían emborronadas o desplazadas, sin que se levantara ningún error. Ambos bugs se escondieron detrás de datos de prueba que resultaban ser simétricos o no negativos, y el segundo enciende una propiedad de Delphi y Free Pascal que muerde mucho más lejos de JBIG2: shr sobre un entero con signo es un shift lógico, no el >> aritmético que el estándar asume

Las regiones halftone son el tipo de región JBIG2 menos común, así que un decoder puede procesar miles de documentos escaneados antes de toparse con una fotografía tramada codificada como tal. Cuando le toca, la falla es desagradable: el archivo parsea, los largos de los segmentos suman, la página tiene el tamaño correcto, y la región es basura

¿Qué decodifica en realidad una región halftone JBIG2?

Una región halftone JBIG2 es una cuadrícula de bitmaps pequeños tomados de un pattern dictionary, y el trabajo real del decoder es calcular un índice para cada celda de la cuadrícula y la posición en píxeles donde esa celda aterriza. El pattern dictionary contiene HNUMPATS patrones de HPW × HPH píxeles. El segmento de región halftone describe entonces una cuadrícula de HGW columnas por HGH filas y una imagen de escala de grises del mismo tamaño, codificada como bitplanes con Gray code. Cada bitplane se decodifica con el procedimiento de región genérica sobre un bitmap de HGW × HGH, el plano más significativo primero, y los planos juntos le dan a cada celda su índice de patrón

La colocación de celdas usa aritmética de punto fijo con una fracción de 8 bits. El origen de la cuadrícula HGX, HGY es un par de valores de 32 bits, y el vector de cuadrícula HRX, HRY describe el paso entre celdas vecinas, lo que permite una cuadrícula rotada. Para la fila de cuadrícula mg y la columna de cuadrícula ng, T.88 §6.6.5 calcula la posición en píxeles como:

  • x = (HGX + mg × HRY + ng × HRX) >> 8
  • y = (HGY + mg × HRX − ng × HRY) >> 8

La máscara de skip entra por el flag opcional HENABLESKIP. Cuando el flag está activo, §6.6.5.1 construye un bitmap HSKIP de HGW × HGH y pone HSKIP[ng, mg] en 1 para cada celda cuyo patrón quede por completo fuera de la región: x + HPW <= 0, x >= HBW, y + HPH <= 0 o y >= HBH. Los bitplanes de escala de grises se decodifican entonces con esa máscara como bitmap de skip de la región genérica, así que el decoder aritmético ni lee ni actualiza contexto para una celda saltada. Decoder y encoder deben concordar en cada bit de HSKIP, o los dos codificadores aritméticos se desencajan

¿Por qué una máscara HSKIP transpuesta solo rompía cuadrículas no cuadradas?

La máscara de skip se escribía con las coordenadas intercambiadas, y solo una cuadrícula no cuadrada la exponía, porque una cuadrícula cuadrada mantiene cada coordenada intercambiada dentro de la máscara. PDFlibPas guarda bitmaps con un accessor de píxeles (column, row), y el código que construía la máscara pasaba (mg, ng), fila primero. El decoder de bitplanes de escala de grises lee la máscara correctamente como (ng, mg). El loop de colocación de patrones la leía de vuelta en el orden intercambiado del constructor, así que los dos concordaban, y una revisión de solo la lógica de colocación la habría aprobado. Una trampa de nombres lo empeoró: en el loop de colocación la variable llamada col itera filas de cuadrícula y Row itera columnas de cuadrícula

Tome la cuadrícula de 5 × 3 de patrones de 4 × 4 sobre una región de 16 × 8 que v3.539.37 usa como caso de regresión. Con HRX = 1024 y HRY = 0, la columna de cuadrícula 4 aterriza en x = 16 y la fila de cuadrícula 2 en y = 8, ambas fuera de la región. La máscara correcta marca siete celdas: toda la columna 4 y toda la fila 2. Las escrituras intercambiadas intentaban pintar píxeles en los índices de fila 3 y 4 de una máscara de solo tres filas de alto, y el setter del bitmap ignoraba en silencio esas escrituras fuera de rango. Lo que sobrevivió fue la columna 2, filas 0 a 2. El decoder entonces saltaba dos celdas que el encoder había codificado, y decodificaba seis celdas que el encoder había saltado

Máscaras de skip halftone JBIG2 de PDFlibPas para una cuadrícula de 5 por 3 donde la HSKIP[ng, mg] correcta marca la columna 4 y la fila 2 como saltadas, mientras que las escrituras transpuestas apuntaban a las filas 3 y 4 de una máscara de tres filas y se descartaban en silencio, dejando solo la columna 2 y desencajando los codificadores aritméticos
Solo una cuadrícula no cuadrada expone una máscara transpuesta, y el desencastre de codificadores resultante emborrona la región en vez de levantar un error

El decoder aritmético no falla cuando eso pasa. Decodifica píxeles extra de bits que pertenecen a celdas posteriores, sus contextos leen vecinos equivocados, y cada índice de patrón después del primer desacuerdo es ruido, razón por la cual el síntoma era una región emborronada en vez de unas pocas celdas fuera de lugar. En una cuadrícula cuadrada el mismo bug suele ser invisible: ninguna coordenada intercambiada sale de la máscara, y cuando las celdas fuera de la región son simétricas respecto de la diagonal, una cuadrícula que sobresale de los bordes derecho e inferior el mismo número de celdas, por ejemplo, la máscara transpuesta es bit a bit la correcta. HENABLESKIP además es opcional, debe ser 0 cuando la imagen de escala de grises está codificada con MMR, y los encoders rara vez lo activan, así que el bug tenía muy pocas formas de salir a la luz. Desde v3.539.37 el constructor escribe HSKIP[ng, mg] y el loop de colocación lee el mismo orden

¿Por qué los offsets de cuadrícula halftone negativos fallan en tres capas?

Una cuadrícula halftone que arranca a la izquierda o arriba de su región rompía PDFlibPas en tres lugares separados, y cada falla escondía la siguiente. T.88 permite esta geometría a propósito. Un encoder que alinea su trama con la página en vez de con la región, o que usa una cuadrícula rotada, naturalmente produce esquinas de celda negativas que la región recorta. v3.539.38 corrigió las tres capas juntas, porque corregir cualquiera sola solo cambiaba el síntoma

Capa 1: un campo con signo leído como sin signo

T.88 §7.4.5.1.2 define HGX y HGY como valores de 32 bits con signo, pero el decoder los leía con el mismo helper de 32 bits que usaba para campos sin signo, y ese helper clampeaba todo resultado negativo a 0. Una cuadrícula que debía arrancar en HGX = -900 se movía en silencio al origen de la región. En el caso de regresión de v3.539.38 la imagen entera salía dos filas más abajo. El clamp también explica por qué las otras dos fallas sobrevivieron tanto: con el origen forzado a no negativo, una coordenada negativa solo podía aparecer por una cuadrícula rotada con HRY > 0, donde y = HGY + mg × HRX − ng × HRY cae bajo cero para las columnas de cuadrícula posteriores

Capa 2: shr no es >> 8

T.88 escribe >> 8 y quiere decir un shift aritmético, que redondea hacia menos infinito. El decoder lo traducía como shr 8. En Delphi y Free Pascal, shr sobre un entero con signo es un shift lógico: el bit de signo entra como cero. Para un Integer que guarda -512, shr 8 da 16777214 en vez de -2. Un patrón que debía dibujarse en y = -2 y recortarse a su mitad inferior se mandaba 16 millones de filas hacia abajo y se descartaba como fuera de región. Nada se colgaba; la fila superior del halftone simplemente desaparecía

Capa 3: comparar punto fijo en vez de píxeles

El test de skip comparaba valores de punto fijo, no posiciones en píxeles, y los dos no son equivalentes en cuanto la fracción es distinta de cero. El código original esquivaba el shift lógico testeando xx + HPW × 256 <= 0 sobre el valor sin shiftear, un supuesto equivalente del test de T.88. Con HGX = -900 y un patrón de 4 píxeles, eso da -900 + 1024 = 124, que es positivo, así que la celda no se salta. El estándar shiftea primero: floor(-900 / 256) = -4, y -4 + 4 = 0 cumple x + HPW <= 0, así que la celda está por completo fuera y debe saltarse. El encoder la saltaba, el decoder la decodificaba, y la imagen de escala de grises derivaba exactamente como en el caso de la máscara transpuesta

Fallas halftone JBIG2 de PDFlibPas para una cuadrícula con HGX negativo: un campo con signo leído por un helper sin signo clampeado a cero, el shift right de T.88 traducido como un shr lógico que mandó un patrón 16 millones de filas abajo, y un test de skip sobre valores de punto fijo que conservó una celda que el encoder había saltado
Cada falla escondía la siguiente, razón por la cual v3.539.38 corrigió las tres capas juntas en un solo helper HalftoneGridPixel compartido por el constructor de la máscara y el loop de colocación

El caso de regresión de v3.539.38 usa una cuadrícula de 4 × 3 de patrones de 4 × 4 en HGX = -900, HGY = -512, HRX = 1024 sobre una región de 12 × 10. Las columnas de cuadrícula aterrizan en x = -4, 0, 4 y 8, así que la columna 0 está por completo fuera y pertenece al HSKIP; las filas de cuadrícula aterrizan en y = -2, 2 y 6, así que la fila 0 debe recortarse a sus dos filas de píxeles inferiores en vez de descartarse. Corregir las capas de a una reproduce la pila:

Fallas corregidasRegión decodificada
Ninguna (antes de v3.539.38)Cuadrícula jalada al origen, imagen entera dos filas más abajo
Solo la lectura con signo de HGX / HGYPrimera fila de cuadrícula faltante, el resto emborronado por la deriva del test de skip
Lectura con signo, floor shift y test de skip en espacio de píxelesIdéntica, píxel por píxel, a la página calculada desde T.88 §6.6.5 y a dos decoders de referencia independientes

La corrección es un solo helper, HalftoneGridPixel, compartido por el constructor de la máscara de skip y el loop de colocación. Acumula la coordenada en Int64 así un producto mg × HRX grande no puede dar la vuelta, divide por 256 redondeando hacia menos infinito, y clampea a ±MaxInt div 2 así una cuadrícula corrupta no puede desbordar la aritmética de bitmap posterior. El test de skip ahora compara esos valores de píxel contra HPW, HPH, HBW y HBH, exactamente como §6.6.5.1 lo enuncia

¿Cómo se escribe un shift right aritmético en Delphi?

Delphi no tiene operador de shift aritmético, así que un shift right con signo correcto hay que escribirlo como una división floor, y el div corriente no es esa división. div trunca hacia cero. Para valores no negativos truncación y floor concuerdan, y también concuerdan para valores negativos que son múltiplos exactos del divisor, razón por la cual -512 div 256 = -2 se ve bien en un test rápido. Discrepan en todo lo demás: -900 div 256 es -3, mientras que el floor es -4, y -1 div 256 es 0, mientras que el floor es -1. Una coordenada JBIG2 con fracción distinta de cero es exactamente el caso donde div da el píxel equivocado

En los compiladores Delphi Win32 y Win64, una variable Integer que guarda -512 shifteada a la derecha por 8 da 16777214, y un Int64 que guarda -512 da 72057594037927934. Free Pascal también define shr como shift lógico y trae SarLongint y SarInt64 en su unidad System para la versión aritmética, pero esas funciones no existen en Delphi, así que el código compartido entre los dos compiladores necesita su propio helper:

// División floor: redondea hacia menos infinito para cualquier signo de A y B.
// B no debe ser 0, y FloorDiv(Low(Integer), -1) desborda igual que div
function FloorDiv(A, B: Integer): Integer;
begin
  Result := A div B;
  if (A mod B <> 0) and ((A < 0) <> (B < 0)) then
    Dec(Result);
end;

// Shift right aritmético (el ">>" de C y T.88 sobre valores con signo).
// Para Value negativo, not Value = -Value - 1 es no negativo, así que
// el shr lógico es seguro allí, y el not exterior mapea el resultado de vuelta
function SarInt32(Value: Integer; Shift: Integer): Integer;  // Shift 0..31
begin
  if Value >= 0 then
    Result := Value shr Shift
  else
    Result := not ((not Value) shr Shift);
end;

function SarInt64(Value: Int64; Shift: Integer): Int64;      // Shift 0..63
begin
  if Value >= 0 then
    Result := Value shr Shift
  else
    Result := not ((not Value) shr Shift);
end;

El truco del not jamás shiftea un número negativo, así que no depende de cómo trate el compilador el bit de signo, y nunca desborda, incluido Low(Integer). Ambos helpers coincidieron con una referencia floor en Int64 sobre varios millones de valores, cada shift de 0 a 31 y los bordes Low(Integer) y High(Integer) en Delphi Win32, Delphi Win64 y Free Pascal x86_64. Un sanity check que vale la pena mantener en cualquier unit test que toque coordenadas:

var
  V: Integer;
begin
  V := -900;
  Writeln(V shr 8);           // 16777212  shift lógico, el bug viejo
  Writeln(V div 256);         // -3        truncación hacia cero
  Writeln(FloorDiv(V, 256));  // -4        lo que T.88 quiere decir con >> 8
  Writeln(SarInt32(V, 8));    // -4
end;
Recta numérica de PDFlibPas para la coordenada -900 shifteada a la derecha por 8: shr da 16777212, div trunca a -3, mientras que FloorDiv y SarInt32 aterrizan ambas en el valor floor -4 al que se refiere ITU-T T.88 con el shift, lo que importa solo cuando la fracción de punto fijo es distinta de cero
Truncación y floor concuerdan solo en múltiplos exactos, así que -512 div 256 pasa un test rápido y -900 div 256 toma el píxel equivocado

Math.Floor(V / 256) también devuelve -4, pero su desvío por Double pierde precisión para valores Int64 arriba de 253, así que la geometría entera debería quedarse en enteros

¿Qué llamadas de PDFlibPas ejecutan el decoder halftone?

El decoder halftone JBIG2 corre cuando PDFlibPas renderiza una página con el renderer integrado, porque renderizar necesita píxeles. RenderPageToFile y RenderPageToStream llegan ambos a él por los image streams JBIG2Decode de la página, así que re-renderizar una página halftone es la forma directa de confirmar que v3.539.38 cambia su salida. El mismo decoder maneja los otros tipos de región JBIG2, cubiertos en las tablas Huffman custom de JBIG2 en el decoder Pascal puro y cómo decodificar archivos JBIG2 random-access en Delphi, y el bitmap renderizado alimenta conversiones como renderizar páginas PDF a monocromo de 1 bit

uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  Page: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('scanned-halftone.pdf', '') <> 1 then
      raise Exception.CreateFmt('Load failed, error %d', [Lib.LastErrorCode]);
    for Page := 1 to Lib.PageCount do
      // El renderizado decodifica cada región JBIG2, medios tonos incluidos
      if Lib.RenderPageToFile(150, Page, PDF_RENDER_PNG,
        Format('page-%.3d.png', [Page])) <> 1 then
        Writeln('Page ', Page, ' was not rendered');
  finally
    Lib.Free;
  end;
end.

La extracción de imágenes normalmente toma otro camino. GetPageImageList devuelve las imágenes JBIG2 en forma nativa, y SaveImageListItemDataToFile o GetImageListItemDataToString le entregan un archivo JBIG2 standalone construido de los bytes del stream: el header del archivo, los datos JBIG2Globals y un segmento end-of-file alrededor de los datos de la página. La propiedad 400 de GetImageListItemIntProperty reporta 6 para un item así. Nada se decodifica en ese camino, así que un .jb2 extraído que se veía correcto en otro visor mientras la página renderizada mostraba ruido era la señal típica de estos dos bugs de halftone:

var
  ListID, I: Integer;
begin
  Lib.SelectPage(1);
  ListID := Lib.GetPageImageList(0);
  if ListID = 0 then
    Exit;
  try
    for I := 1 to Lib.GetImageListCount(ListID) do
      if Lib.GetImageListItemIntProperty(ListID, I, 400) = 6 then  // standalone JBIG2
        Lib.SaveImageListItemDataToFile(ListID, I, 0,
          Format('page1-image%d.jb2', [I]));
  finally
    Lib.ReleaseImageList(ListID);
  end;
end;

Cuando máscaras o conversión de color fuerzan un fallback renderizado, el item vuelve como bitmap decodificado y el decoder halftone sí corre. Más sobre image lists en extracción de texto, imágenes y fuentes PDF en Delphi

Referencia rápida: reglas de la cuadrícula halftone JBIG2

  • Indexe la máscara de skip como HSKIP[ng, mg], columna de cuadrícula primero, y léala de vuelta en el mismo orden dondequiera que se coloquen celdas (T.88 §6.6.5.1, corregido en PDFlibPas v3.539.37)
  • Testee cualquier código de halftone o de cuadrícula con una cuadrícula no cuadrada y un conjunto asimétrico de celdas fuera de la región, porque una cuadrícula cuadrada puede esconder un índice transpuesto por completo
  • Lea HGX y HGY como valores de 32 bits con signo (T.88 §7.4.5.1.2), jamás por un helper sin signo que clampee los negativos
  • Traduzca el >> 8 del estándar como una división floor por 256, no como shr 8 ni como div 256
  • Corra el test de skip sobre posiciones de píxel shifteadas; la forma de punto fijo difiere siempre que la fracción sea distinta de cero, como muestra HGX = -900 con un patrón de 4 píxeles
  • Acumule coordenadas de cuadrícula en Int64 y clampee antes de entregarlas a código de bitmap, así una cuadrícula corrupta no puede desbordar
  • Actualice a v3.539.38 o posterior si sus documentos contienen regiones halftone con HENABLESKIP, orígenes de cuadrícula negativos o cuadrículas rotadas

PDFlibPas renderiza, extrae y edita documentos PDF desde Delphi y C++Builder con un decoder JBIG2 Pascal nativo que ahora maneja máscaras de skip halftone, orígenes de cuadrícula negativos y cuadrículas rotadas como T.88 especifica. Vea la librería PDFlibPas Delphi PDF para características, ediciones y una descarga de prueba