Artículo técnico

Halftone JBIG2 en PDFlibPas: HSKIP y offsets negativos

PDFlibPas arregló dos fallos independientes en su decodificador nativo de regiones halftone JBIG2: en v3.539.37 la máscara de skip HSKIP se indexa como HSKIP[ng, mg], tal como la define ITU-T T.88 §6.6.5.1, y en v3.539.38 las rejillas que alcanzan coordenadas negativas, por un HGX o HGY negativo o por rotación, se colocan con un verdadero shift de suelo. Antes de esas versiones, las regiones halftone afectadas salían emborronadas o desplazadas, sin que se lanzara ningún error. Ambos bugs se escondían tras datos de test que resultaban ser simétricos o no negativos, y el segundo se apoya en una propiedad de Delphi y Free Pascal que muerde mucho más allá 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 decodificador puede procesar miles de documentos escaneados antes de toparse con una fotografía de trama codificada como una de ellas. Cuando le toca, el fallo es desagradable: el archivo parsea, las longitudes de segmento suman, la página tiene el tamaño correcto, y la región es basura

¿Qué decodifica realmente una región halftone JBIG2?

Una región halftone JBIG2 es una rejilla de bitmaps pequeños escogidos de un pattern dictionary, y el trabajo real del decodificador es calcular un índice para cada celda de la rejilla y la posición de píxel donde esa celda aterriza. El pattern dictionary contiene HNUMPATS patrones de HPW × HPH píxeles. El segmento de región halftone describe entonces una rejilla de HGW columnas por HGH filas y una imagen de escala de grises del mismo tamaño, codificada como bitplanes con Gray coding. Cada bitplane se decodifica con el procedimiento de región genérica sobre un bitmap de HGW × HGH, primero el plano más significativo, y los planos juntos 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 rejilla HGX, HGY es un par de valores de 32 bits, y el vector de rejilla HRX, HRY describe el paso entre celdas vecinas, lo que permite una rejilla rotada. Para la fila de rejilla mg y la columna de rejilla ng, T.88 §6.6.5 calcula la posición de píxel 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] a 1 para toda celda cuyo patrón caiga 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 skip bitmap de la región genérica, así que el decodificador aritmético ni lee ni actualiza contexto para una celda saltada. Decodificador y codificador deben concordar en cada bit de HSKIP, o los dos codificadores aritméticos pierden el paso

¿Por qué una máscara HSKIP traspuesta solo rompía rejillas no cuadradas?

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

Tome la rejilla 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 rejilla 4 aterriza en x = 16 y la fila de rejilla 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, 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 decodificador se saltaba por tanto dos celdas que el codificador había codificado, y decodificaba seis que el codificador había saltado

Máscaras de skip halftone JBIG2 de PDFlibPas para una rejilla de 5 por 3 donde la HSKIP[ng, mg] correcta marca como saltadas la columna 4 y la fila 2, mientras que las escrituras traspuestas apuntaban a las filas 3 y 4 de una máscara de tres filas se descartaban en silencio y solo sobrevivía la columna 2, desincronizando los codificadores aritméticos
Solo una rejilla no cuadrada destapa una máscara traspuesta, y la desincronización de codificadores resultante emborrona la región en lugar de lanzar un error

El decodificador aritmético no falla cuando eso ocurre. Decodifica píxeles extra a costa de bits que pertenecen a celdas posteriores, sus contextos leen vecinos equivocados, y cada índice de patrón tras el primer desacuerdo es ruido, que es por lo que el síntoma era una región emborronada y no unas pocas celdas descolocadas. En una rejilla cuadrada el mismo bug suele ser invisible: ninguna coordenada intercambiada sale de la máscara, y cuando las celdas fuera de región son simétricas respecto a la diagonal, una rejilla que sobresale de los bordes derecho e inferior el mismo número de celdas por ejemplo, la máscara traspuesta es bit a bit la correcta. HENABLESKIP además es opcional, debe ser 0 cuando la imagen de escala de grises va codificada en MMR, y los codificadores rara vez lo activan, así que el bug tenía muy pocas maneras de asomar. Desde v3.539.37 el constructor escribe HSKIP[ng, mg] y el bucle de colocación lee el mismo orden

¿Por qué los offsets de rejilla halftone negativos fallan en tres capas?

Una rejilla halftone que empieza a la izquierda o por encima de su región rompía PDFlibPas en tres sitios distintos, y cada fallo escondía el siguiente. T.88 permite esta geometría a propósito. Un codificador que alinea su trama con la página en lugar de con la región, o que usa una rejilla rotada, produce con naturalidad esquinas de celda negativas que la región recorta. v3.539.38 arregló las tres capas juntas, porque arreglar una 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 decodificador los leía con el mismo ayudante de 32 bits que usaba para campos sin signo, y ese ayudante clampeaba todo resultado negativo a 0. Una rejilla pensada para empezar en HGX = -900 se trasladaba discretamente 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 explica también por qué los otros dos fallos sobrevivieron tanto: con el origen forzado a no negativo, una coordenada negativa solo podía aparecer vía una rejilla rotada con HRY > 0, donde y = HGY + mg × HRX − ng × HRY cae bajo cero en las columnas de rejilla posteriores

Capa 2: shr no es >> 8

T.88 escribe >> 8 y quiere decir un shift aritmético, que redondea hacia menos infinito. El decodificador 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 un cero. Para un Integer que contenga -512, shr 8 da 16777214 en lugar de -2. Un patrón que debía dibujarse en y = -2 y recortarse a su mitad inferior se enviaba 16 millones de filas hacia abajo y se descartaba como fuera de región. Nada se colgaba; la fila superior del halftone sencillamente desaparecía

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

El test de skip comparaba valores de punto fijo, no posiciones de píxel, y ambos 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 desplazar, 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 desplaza 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 codificador la saltaba, el decodificador la decodificaba, y la imagen de escala de grises se descolgaba exactamente igual que en el caso de la máscara traspuesta

Fallos del halftone JBIG2 en PDFlibPas para una rejilla con HGX negativo: un campo con signo leído por un ayudante sin signo clampeado a cero, el shift a la derecha de T.88 traducido como un shr lógico que enviaba un patrón 16 millones de filas abajo, y un test de skip sobre valores de punto fijo que conservaba una celda que el codificador había saltado
Cada fallo escondía el siguiente, y por eso v3.539.38 arregló las tres capas juntas en un único ayudante HalftoneGridPixel compartido por el constructor de la máscara y el bucle de colocación

El caso de regresión de v3.539.38 usa una rejilla 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 rejilla aterrizan en x = -4, 0, 4 y 8, así que la columna 0 está por completo fuera y pertenece a HSKIP; las filas de rejilla aterrizan en y = -2, 2 y 6, así que la fila 0 debe recortarse a sus dos filas de píxeles inferiores en lugar de descartarse. Arreglar las capas de una en una reproduce la pila:

Fallos arregladosRegión decodificada
Ninguno (antes de v3.539.38)Rejilla arrastrada al origen, imagen entera dos filas más abajo
Solo lectura con signo de HGX / HGYFalta la primera fila de rejilla, el resto emborronado por la deriva del test de skip
Lectura con signo, shift de suelo y test de skip en espacio de píxelesIdéntica, píxel a píxel, a la página calculada de T.88 §6.6.5 y a dos decodificadores de referencia independientes

El arreglo es un único ayudante, HalftoneGridPixel, compartido por el constructor de la máscara de skip y el bucle de colocación. Acumula la coordenada en Int64 para que un producto mg × HRX grande no pueda desbordar, divide por 256 redondeando hacia menos infinito, y clampea a ±MaxInt div 2 para que una rejilla corrupta no desborde después la aritmética de bitmaps. El test de skip compara ahora esos valores de píxel contra HPW, HPH, HBW y HBH, exactamente como lo plantea §6.6.5.1

¿Cómo se escribe un shift a la derecha aritmético en Delphi?

Delphi no tiene operador de shift aritmético, así que un shift a la derecha con signo correcto hay que escribirlo como una división de suelo, y el div corriente no es esa división. div trunca hacia cero. Para valores no negativos truncamiento y suelo concuerdan, y también concuerdan para valores negativos que son múltiplos exactos del divisor, que es por lo que -512 div 256 = -2 parece estar bien en un test rápido. Discrepan en todo lo demás: -900 div 256 es -3, mientras el suelo es -4, y -1 div 256 es 0, mientras el suelo 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 contenga -512 desplazada a la derecha 8 da 16777214, y una Int64 que contenga -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 ayudante:

// División de suelo: redondea hacia menos infinito con 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 a la derecha aritmético (el ">>" de C y T.88 sobre valores con signo).
// Con Value negativo, not Value = -Value - 1 no es negativo, así que el
// shr lógico es seguro ahí, 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 nunca desplaza 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 ayudantes igualaron una referencia de suelo 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 merece la pena guardar en cualquier unit test que toque coordenadas:

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

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

¿Qué llamadas de PDFlibPas ejecutan el decodificador halftone?

El decodificador halftone JBIG2 corre cuando PDFlibPas renderiza una página con el renderer incorporado, porque renderizar necesita píxeles. RenderPageToFile y RenderPageToStream llegan ambos a él a través de los image streams JBIG2Decode de la página, así que re-renderizar una página halftone es la vía directa de confirmar que v3.539.38 cambia su resultado. El mismo decodificador maneja los demás tipos de región JBIG2, cubiertos en las tablas Huffman personalizadas de JBIG2 en el decodificador Pascal puro y decodificar archivos JBIG2 de acceso aleatorio 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
      // Renderizar decodifica cada región JBIG2, halftones 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 toma normalmente otro camino. GetPageImageList devuelve las imágenes JBIG2 en forma nativa, y SaveImageListItemDataToFile o GetImageListItemDataToString le entregan un archivo JBIG2 independiente construido con los bytes del stream: la cabecera del archivo, los datos JBIG2Globals y un segmento end-of-file alrededor de los datos de página. La propiedad 400 de GetImageListItemIntProperty informa 6 para ese tipo de elemento. En ese camino no se decodifica nada, así que un .jb2 extraído que se veía bien en otro visor mientras la página renderizada mostraba ruido era el signo típico 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  // JBIG2 standalone
        Lib.SaveImageListItemDataToFile(ListID, I, 0,
          Format('page1-image%d.jb2', [I]));
  finally
    Lib.ReleaseImageList(ListID);
  end;
end;

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

Referencia rápida: reglas de la rejilla halftone JBIG2

  • Indexe la máscara de skip como HSKIP[ng, mg], columna de rejilla primero, y léala en el mismo orden dondequiera que se coloquen celdas (T.88 §6.6.5.1, arreglado en PDFlibPas v3.539.37)
  • Testee cualquier código de halftone o de rejilla con una rejilla no cuadrada y un conjunto asimétrico de celdas fuera de región, porque una rejilla cuadrada puede esconder un índice traspuesto por completo
  • Lea HGX y HGY como valores de 32 bits con signo (T.88 §7.4.5.1.2), nunca a través de un ayudante sin signo que clampee los negativos
  • Traduzca el >> 8 del estándar como una división de suelo por 256, no como shr 8 ni como div 256
  • Ejecute el test de skip sobre posiciones de píxel desplazadas; 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 rejilla en Int64 y clampee antes de entregarlas a código de bitmap, para que una rejilla corrupta no pueda desbordar
  • Actualice a v3.539.38 o posterior si sus documentos contienen regiones halftone con HENABLESKIP, orígenes de rejilla negativos o rejillas rotadas

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