Artículo técnico

El renderizador PDF no dibuja nada: cuatro bugs silenciosos

Un renderizador de PDF que no dibuja nada normalmente no tiene ningún bug en su código de dibujo. En el HotPDF Component para Delphi y C++Builder, cuatro defectos independientes hacían que las páginas se renderizaran en blanco mientras cada línea de log se mantenía limpia: operandos de nombre con una barra inicial, una concatenación cm invertida, y un índice de token que leía cero. Ninguno de ellos lanzaba una excepción. Ninguno dejaba constancia en el log. El content stream se tokenizaba correctamente, el despachador de operadores reconocía cada operador, el XObject de imagen se decodificaba en un bitmap válido, y luego la página salía vacía. Esa combinación —un pipeline que reporta éxito en cada etapa y no produce nada visible— es la firma de una búsqueda o un índice que falla silenciosamente en lugar de fallar de forma explícita. Esto es la autopsia de una de esas familias de bugs, y de la disciplina de tests que le permitió sobrevivir 38 versiones

¿Por qué un renderizador de PDF no dibuja absolutamente nada?

Porque una búsqueda de recurso fallida en un renderizador de PDF es indistinguible de una página vacía. Los operandos de nombre del content stream y las claves del diccionario de recursos son dos espacios de cadenas distintos, y HotPDF los comparaba entre sí sin normalizar. El tokenizador lee /Im0 y conserva la barra, porque eso es literalmente el token; el diccionario /Resources /XObject cargado almacena la clave como Im0, porque el parser elimina el delimitador al construir las claves del diccionario. Por tanto, cada FindValue contra un nombre de operando devolvía -1. El radio de impacto era más amplio que las imágenes. ISO 32000-1 §8.9 cubre Do, §8.4 cubre gs y su búsqueda en /ExtGState, §8.6 cubre cs y CS, y §8.7.4.3 cubre sh. Los cinco operadores indexaban su subdiccionario de recursos por el operando en crudo, así que los cinco fallaban. Los espacios de color con nombre caían por defecto a DeviceGray, lo que convierte 1 scn en tinta blanca sobre página blanca. Los XObjects de imagen nunca se pintaban en absoluto —la ruta de imágenes de bitmap, en la práctica, nunca había funcionado desde el día en que se incorporó. La corrección es un helper a nivel de unidad aplicado en cada búsqueda indexada por operando, que es la única manera de evitar que la convención vuelva a desviarse

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Un segundo fallo, relacionado, se encontraba una capa más abajo. El renderizador tenía resolutores tipados solo para streams y diccionarios, así que una referencia indirecta que apuntaba a un objeto array de nivel superior —el habitual /CS0 5 0 R con [/Separation ...] al otro extremo— resolvía a nil en ambos casos y caía de vuelta al enlace sin resolver. Añadir un resolutor de objeto genérico corrigió de una sola vez los espacios de color con nombre y los arrays de función. Si estás conectando diccionarios de shading, la misma disciplina de resolución se aplica a la ruta de shadings axiales y radiales, donde la entrada /Function es muy a menudo indirecta

El operador cm y una concatenación escrita al revés

El segundo defecto colocaba las imágenes a unos cien mil píxeles fuera de la página, lo cual se ve exactamente igual que no dibujarlas en absoluto. ISO 32000-1 §8.3.4 define las transformaciones de PDF con vectores fila, y el operador cm concatena su matriz operando M sobre la matriz de transformación actual como M × CTM —M surte efecto primero, la CTM existente después. HotPDF compone matrices mediante HPDFMatMul(A, B), que aplica B antes que A. La llamada correcta, por tanto, pasa la CTM antigua como A. El código publicado pasaba la matriz operando como A, produciendo CTM × M

El orden invertido es inofensivo para un único cm y catastrófico para el modismo estándar de dos pasos. Coloca una imagen con 1 0 0 1 x y cm seguido de w 0 0 h 0 0 cm y la cascada correcta escala el cuadrado unitario por (w, h) y después lo traslada por (x, y). Bajo la cascada invertida la traslación entra primero y la escala la multiplica, así que una imagen nominalmente en (60, 400) escalada a 200 por 120 acaba en (12000, 48000). El test de recorte en la parte superior del blit la rechaza, el blit se omite, y nada en ningún sitio reporta un problema

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

Lo que hace este caso instructivo es que el mismo fichero de código fuente ya contenía el orden correcto. La entrada /Matrix de un Form XObject tenía la misma composición invertida, pero la ruta de glifos Type 3 y la ruta de contornos de glifo incrustados acertaban ambas desde el principio, porque la colocación de glifos colapsa visiblemente hacia el origen cuando la inviertes y alguien ya se había visto obligado a corregirlo. Dos convenciones coexistían en una misma unidad durante tres docenas de versiones, cada una correcta en su propia función, y ningún revisor lo notó porque ninguno de los dos puntos de llamada parecía incorrecto de forma aislada

¿Qué ocurre cuando un índice de token está desplazado en uno?

Obtienes doce operadores gestionados sintácticamente y muertos semánticamente. El accesor de operando del renderizador es NumAt(Back), que lee Tokens[OpIndex - Back], y OpIndex es el índice del propio token del operador. Un operador de un solo operando encuentra por tanto su número en back 1. Doce de ellos estaban escritos como NumAt(0), que lee el token del operador, falla la comprobación de tipo ctOperandNumber, y devuelve el valor por defecto cero. La lista es Tc, Tw, Tz, TL, Ts y Tr de los operadores de estado de texto de ISO 32000-1 §9.3, más w, J, j, M, ri e i de los operadores de estado gráfico de §8.4.3. El espaciado de caracteres y de palabras se convirtió en un no-op, el escalado horizontal nunca se aplicaba, el interlineado se quedaba en cero por lo que T* nunca avanzaba una línea, la elevación de texto no hacía nada, el modo de renderizado siempre era relleno, y cada trazo en cada documento salía como una línea de 1 píxel independientemente del grosor de línea declarado. Los operadores multioperando como m, rg y Tm usaban NumAt(1..6) y eran todos correctos, así que un revisor que examinara la función veía un muro de aritmética de índices plausible con doce entradas incorrectas incrustadas en ella

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

¿Por qué la batería de tests se mantuvo en verde durante 38 versiones?

Porque las aserciones eran demasiado débiles para distinguir una página renderizada de una renderizada solo parcialmente. Los smoke tests de renderizado aseveraban cosas como que el bitmap de salida no es enteramente negro, o que la página no está en blanco, o que el digest de la imagen es distinto de cero. Todas esas condiciones se cumplen cuando el texto se renderiza y las imágenes no. El texto se dibujaba bien, así que el frame buffer nunca era uniforme, el digest nunca era cero, y la batería reportaba éxito mientras todo el pipeline de imágenes era, en la práctica, código muerto. Las aserciones débiles son seductoras para los gráficos precisamente porque las fuertes parecen frágiles. Nadie quiere un test que se rompa cuando un borde de antialiasing se desplaza un píxel, así que el repliegue natural es afirmar algo que ningún cambio razonable podría violar —y ese repliegue te deja en predicados que tampoco viola ningún cambio irrazonable. Un test de espacio de color de separación afirmaba que la salida era distinguible del negro; el gris sobre blanco lo superaba, y también el blanco sobre blanco. El test no medía si se había pintado el color correcto. Medía si algo, lo que fuera, había ocurrido en el lienzo

¿Cómo se escribe una aserción de renderizado que realmente falle?

Contando píxeles del color esperado, en la cantidad esperada, y dejando que posición y tamaño se deduzcan del recuento. La disciplina de reemplazo es un PDF mínimo construido a mano, un hecho visual por fichero, y una aserción sobre cuántos píxeles caen dentro de una tolerancia de un triple RGB específico. Una imagen de 200 por 120 en rojo puro colocada en un offset conocido debe producir aproximadamente 24000 píxeles rojos. Si la búsqueda de recurso falla, el recuento es 0. Si la cascada cm está invertida, el recuento es 0. Si la imagen se renderiza en el espacio de color equivocado, el recuento es 0. Un solo número atrapa los tres casos, y la banda de tolerancia absorbe el ruido de antialiasing que en primer lugar hacía que la gente rehuyera la comparación exacta

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Cuatro smoke tests se reescribieron de esta manera —una transformación de tinta Type 4, una colocación Do de imagen, un caso de visibilidad de contenido opcional y un modo de trazo Tr— y entre ellos expusieron toda la familia de bugs. Esa es la lección real, y se generaliza más allá de esta base de código: en un pipeline de renderizado, la aserción tiene que nombrar el color. Cualquier cosa más laxa es una comprobación de que el renderizador se ejecutó, no de que dibujó. Si estás construyendo tu propio arnés de página a bitmap, el recorrido por la rasterización de páginas es el sitio natural para acoplar un helper de conteo de píxeles a tu primera regresión

Límites honestos

Merece la pena exponer dos límites con claridad. Los modos de renderizado de recorte de texto 4 a 7 se dibujan como su modo base de relleno o trazo, porque el renderizador no modela rutas de recorte acumuladas a partir de contornos de glifo; los documentos que dependen de recorte con forma de texto renderizarán el texto en lugar del arte recortado subyacente. Y la disciplina de conteo de píxeles descrita aquí es una técnica de smoke test, no una suite de conformidad —demuestra que un hecho visual específico llegó al frame buffer, lo cual es un listón mucho más bajo que demostrar que la salida coincide con un rasterizador de referencia. Es, sin embargo, exactamente el listón que estos cuatro bugs no lograron superar durante tres años de versiones

El renderizador tratado aquí se incluye como parte del HotPDF Component estándar para Delphi y C++Builder; la página del producto incluye la referencia completa de la API de renderizado de páginas, incluidos los puntos de entrada de la caché de bitmap y la precarga en segundo plano