Un renderizador de PDF que no dibuja nada por lo general no tiene ningún error en su código de dibujo. En el HotPDF Component para Delphi y C++Builder, cuatro defectos separados hacían que las páginas se renderizaran en blanco mientras cada línea de registro se mantenía limpia: operandos de nombre que llevaban una barra diagonal inicial, una concatenación cm invertida, y un índice de token que leía cero. Ninguno lanzaba una excepción. Ninguno registraba nada. El flujo de contenido se tokenizaba correctamente, el despachador de operadores reconocía cada operador, el XObject de imagen se decodificaba en un mapa de bits válido, y luego la página salía vacía. Esa combinación (una canalización que reporta éxito en cada etapa y no produce nada visible) es la firma de una búsqueda o un índice que falla en silencio en lugar de fallar de forma abierta. Esto es una autopsia de una de esas familias de errores, y de la disciplina de pruebas que le permitió sobrevivir durante 38 versiones
Por qué un renderizador de PDF no dibuja absolutamente nada
Porque una búsqueda de recursos fallida en un renderizador de PDF es indistinguible de una página vacía. Los operandos de nombre del flujo de contenido 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 diagonal, porque eso es lo que es el token; el diccionario /Resources /XObject cargado almacena la clave como Im0, porque el analizador elimina el delimitador cuando construye las claves del diccionario. Cada FindValue contra un nombre de operando, por lo tanto, devolvía -1. El radio de impacto fue más amplio que las imágenes. ISO 32000-1 §8.9 cubre Do, §8.4 cubre gs y su búsqueda de /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 crudo, así que los cinco fallaban. Los espacios de color con nombre caían por defecto en DeviceGray, lo que convierte 1 scn en tinta blanca sobre una página blanca. Los XObjects de imagen nunca se pintaban en absoluto: la ruta de imagen de mapa de bits, en la práctica, nunca había funcionado desde el día en que se implementó. La corrección es un asistente 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 flujos y diccionarios, así que una referencia indirecta que apuntaba a un objeto de arreglo de nivel superior, el común /CS0 5 0 R con [/Separation ...] del otro lado, resolvía a nil a través de ambos y caía de vuelta al enlace sin resolver. Agregar un resolutor de objetos genérico corrigió de un solo movimiento los espacios de color con nombre y los arreglos de función. Si estás conectando diccionarios de sombreado, la misma disciplina de resolución aplica a la ruta de sombreados axiales y radiales, donde la entrada /Function muy a menudo es indirecta
El operador cm y una concatenación escrita al revés
El segundo defecto colocaba imágenes a unos cien mil píxeles fuera de la página, lo cual se ve exactamente igual a no dibujarlas. 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 lo tanto, pasa la CTM antigua como A. El código enviado a producción pasaba la matriz operando como A, produciendo CTM × M
El orden invertido es inofensivo para un solo 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 luego 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 termina en (12000, 48000). La prueba de recorte en la parte superior del blit la rechaza, el blit se omite, y nada en ningún lado 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 instructivo a este caso es que el mismo archivo 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 ambas lo hacían bien desde el principio, porque la colocación de glifos colapsa visiblemente hacia el origen cuando se invierte y alguien ya se había visto obligado a corregirlo. Dos convenciones coexistieron en una sola 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 se veía mal por separado
Qué sucede cuando un índice de token está desfasado por uno
Obtienes doce operadores que se manejan sintácticamente y están semánticamente muertos. El accesor de operando en el 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, por lo tanto, encuentra 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 volvió un no-op, el escalado horizontal nunca se aplicó, el interlineado se quedó en cero así que T* nunca avanzó una línea, el desplazamiento 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 sin importar el ancho de línea declarado. Los operadores de múltiples operandos como m, rg y Tm usaban NumAt(1..6) y estaban todos correctos, así que un revisor que examinara la función veía un muro de aritmética de índices verosímil con doce entradas equivocadas incrustadas en él
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 pruebas se mantuvo en verde durante 38 versiones
Porque las aserciones eran demasiado débiles para distinguir una página renderizada de una parcialmente renderizada. Las pruebas de humo de renderizado afirmaban cosas como que el mapa de bits de salida no es completamente negro, o que la página no está en blanco, o que el digest de la imagen no es cero. Cada una de ellas se cumple cuando el texto se renderiza y las imágenes no. El texto se dibujaba bien, así que el búfer de cuadro nunca era uniforme, el digest nunca era cero, y la batería reportaba éxito mientras toda la canalización de imágenes era código muerto en la práctica. Las aserciones débiles son seductoras para gráficos precisamente porque las fuertes parecen frágiles. Nadie quiere una prueba que se rompa cuando un borde de suavizado se desplaza un píxel, así que la retirada natural es afirmar algo que ningún cambio razonable podría violar, y esa retirada te deja sobre predicados que ningún cambio irrazonable puede violar tampoco. Una prueba de espacio de color de separación afirmaba que la salida era distinguible del negro; gris sobre blanco la pasaba, y también lo hacía blanco sobre blanco. La prueba 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 escribir una aserción de renderizado que realmente falle
Cuenta píxeles del color esperado, en la cantidad esperada, y deja que la posición y el tamaño se deduzcan del conteo. La disciplina de reemplazo es un PDF mínimo construido a mano, un hecho visual por archivo, 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 desplazamiento conocido debe producir aproximadamente 24000 píxeles rojos. Si la búsqueda de recursos falla, el conteo es 0. Si la cascada de cm está invertida, el conteo es 0. Si la imagen se renderiza en el espacio de color equivocado, el conteo es 0. Un solo número atrapa a los tres, y la banda de tolerancia absorbe el ruido del suavizado que hacía que la gente rehuyera la comparación exacta en primer lugar
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 pruebas de humo se reescribieron de esta manera (una transformación de matiz Type 4, una colocación de imagen mediante Do, un caso de visibilidad de contenido opcional y un modo de trazo Tr) y entre las cuatro expusieron toda la familia de errores. Esa es la lección real, y se generaliza más allá de esta base de código: en una canalización de renderizado, la aserción tiene que nombrar el color. Cualquier cosa más suave es una comprobación de que el renderizador se ejecutó, no una comprobación de que dibujó algo. Si estás construyendo tu propio arnés de página a mapa de bits, el recorrido por la rasterización de páginas es el lugar natural para acoplar un asistente de conteo de píxeles a tu primera regresión
Límites honestos
Vale la pena establecer 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 debajo. Y la disciplina de conteo de píxeles descrita aquí es una técnica de prueba de humo, no una batería de conformidad: demuestra que un hecho visual específico llegó al búfer de cuadro, que 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 errores no lograron superar durante tres años de versiones
El renderizador descrito aquí se incluye como parte del HotPDF Component estándar para Delphi y C++Builder; la página de producto incluye la referencia completa de la API de renderizado de páginas, incluidos los puntos de entrada de la caché de mapas de bits y la precarga en segundo plano