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) >> 8y = (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
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
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 corregidas | Regió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 / HGY | Primera 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íxeles | Idé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;
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
HGXyHGYcomo 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
>> 8del estándar como una división floor por 256, no comoshr 8ni comodiv 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 = -900con un patrón de 4 píxeles - Acumule coordenadas de cuadrícula en
Int64y 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