Artigo Técnico

Renderizador de PDF Não Desenha Nada: Quatro Bugs Silenciosos em Delphi

Um renderizador de PDF que não desenha nada normalmente não tem nenhum bug no seu código de desenho. No HotPDF Delphi Component para Delphi e C++Builder, quatro defeitos separados faziam as páginas renderizarem em branco enquanto cada linha de log se mantinha limpa: operandos de nome a transportar uma barra inicial, uma concatenação cm invertida, e um índice de token que lia zero. Nenhum deles disparava exceção. Nenhum deles registava nada. O content stream tokenizava corretamente, o despachante de operadores reconhecia cada operador, o XObject de imagem era descodificado num bitmap válido, e depois a página saía vazia. Essa combinação — um pipeline que reporta sucesso em cada fase e não produz nada visível — é a assinatura de uma pesquisa ou um índice que falha silenciosamente em vez de falhar com erro. Isto é um post-mortem de uma dessas famílias, e da disciplina de testes que a deixou sobreviver 38 versões

Porque é que um renderizador de PDF não desenha absolutamente nada?

Porque uma pesquisa de recurso falhada num renderizador de PDF é indistinguível de uma página vazia. Os operandos de nome do content stream e as chaves do dicionário de recursos são dois espaços de strings diferentes, e o HotPDF estava a compará-los entre si sem normalizar. O tokenizer lê /Im0 e mantém a barra, porque é isso que o token é; o dicionário /Resources /XObject carregado guarda a chave como Im0, porque o parser retira o delimitador quando constrói as chaves do dicionário. Cada FindValue contra um nome de operando devolvia por isso -1. O raio da explosão foi mais largo do que imagens. O ISO 32000-1 §8.9 cobre Do, o §8.4 cobre gs e a sua pesquisa de /ExtGState, o §8.6 cobre cs e CS, e o §8.7.4.3 cobre sh. Os cinco operadores indexavam o seu subdicionário de recursos pelo operando em bruto, pelo que os cinco falhavam. Os espaços de cor nomeados recuavam para DeviceGray, o que transforma 1 scn em tinta branca sobre uma página branca. Os XObjects de imagem nunca chegavam sequer a ser pintados — o caminho de imagem em bitmap tinha, na prática, nunca funcionado desde o dia em que foi lançado. A correção é um helper ao nível da unit aplicado em cada pesquisa indexada por operando, que é a única forma de impedir que a convenção volte a divergir

HotPDF: Operando do fluxo de conteúdo com barra Im0 a não corresponder à chave Im0 sem barra do dicionário de recursos, pelo que FindValue devolve -1 e os cinco operadores Do, gs, cs, CS e sh falham todos as suas pesquisas de recursos
O tokenizer mantém a solidus no operando enquanto o analisador de recursos a retira das chaves do dicionário, pelo que cada pesquisa bruta de operando devolve -1 e todos os cinco operadores de recursos falham em silêncio
// Content stream da página, o idioma habitual para colocar imagens:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// O token do operando é '/Im0'. A chave no resource dictionary é 'Im0'.

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

// Todas as procuras de recursos por nome de operando passam pelo helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// O sub-dicionário /XObject pode ser, ele próprio, uma indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Uma segunda falha, relacionada, ficava uma camada abaixo. O renderizador tinha resolvedores tipados apenas para streams e dicionários, pelo que uma referência indireta a apontar para um objeto array de topo — o comum /CS0 5 0 R com [/Separation ...] do outro lado — resolvia para nil através de ambos e recuava para o link não resolvido. Acrescentar um resolvedor de objetos genérico corrigiu espaços de cor nomeados e arrays de função num único movimento. Se estiver a ligar dicionários de shading, a mesma disciplina de resolução aplica-se ao caminho de shading axial e radial, onde a entrada /Function é muitas vezes indireta

O operador cm e uma concatenação escrita ao contrário

O segundo defeito colocava imagens aproximadamente cem mil pixels fora da página, o que parece exatamente igual a não as desenhar. O ISO 32000-1 §8.3.4 define as transformações do PDF com vetores linha, e o operador cm concatena a sua matriz operando M sobre a matriz de transformação atual como M × CTM — M produz efeito primeiro, o CTM existente depois. O HotPDF compõe matrizes através de HPDFMatMul(A, B), que aplica B antes de A. A chamada correta passa, portanto, o CTM antigo como A. O código distribuído passava a matriz do operando como A, produzindo CTM × M

A ordem invertida é inofensiva para um único cm e catastrófica para o idioma padrão em dois passos. Coloque uma imagem com 1 0 0 1 x y cm seguido de w 0 0 h 0 0 cm e a cascata correta escala o quadrado unitário por (w, h) e depois translada-o por (x, y). Sob a cascata invertida a translação entra primeiro e a escala multiplica-a, pelo que uma imagem nominalmente em (60, 400) escalada para 200 por 120 aterra em (12000, 48000). O teste de clip no topo do blit rejeita-a, o blit é saltado, e nada em lado nenhum reporta um problema

HotPDF: Ordem de concatenação da matriz cm do PDF mostrando o contrato ISO de novo CTM igual a M vezes CTM, HPDFMatMul a aplicar primeiro o seu argumento B, e maquetas de página onde a imagem aterra em 60 400 sob a cascata correta versus 12000 48000 sob a invertida
HPDFMatMul aplica primeiro o seu argumento B, pelo que passar a matriz operando como A inverte a cascata e uma colocação de imagem em dois passos aterra cerca de cem mil píxeis fora da página, onde o teste de recorte a salta silenciosamente
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Errado, e assim foi durante 38 versões:
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)));

O que torna este caso instrutivo é que o mesmo ficheiro fonte já continha a ordem correta. A entrada /Matrix de um Form XObject tinha a mesma composição invertida, mas o caminho de glifos Type 3 e o caminho de contornos de glifo incorporados acertaram ambos desde o início, porque a colocação de glifos colapsa visivelmente para a origem quando se inverte e alguém já tinha sido obrigado a corrigi-lo. Duas convenções coexistiram numa única unit durante três dezenas de versões, cada uma correta na sua própria função, e nenhum revisor reparou porque nenhum dos pontos de chamada parecia errado isoladamente

O que acontece quando um índice de token está errado por um?

Obtém-se doze operadores que são sintaticamente tratados e semanticamente mortos. O acessor de operando no renderizador é NumAt(Back), que lê Tokens[OpIndex - Back], e OpIndex é o índice do próprio token de operador. Um operador de operando único encontra, portanto, o seu número em back 1. Doze deles estavam escritos como NumAt(0), que lê o token do operador, falha a verificação de tipo ctOperandNumber, e devolve o valor por defeito zero. A lista é Tc, Tw, Tz, TL, Ts e Tr dos operadores de estado de texto do ISO 32000-1 §9.3, mais w, J, j, M, ri e i dos operadores de estado gráfico do §8.4.3. O espaçamento entre carateres e entre palavras tornou-se um no-op, a escala horizontal nunca se aplicava, o interlinhamento ficava a zero pelo que o T* nunca avançava uma linha, o levantamento de texto não fazia nada, o modo de renderização era sempre preenchimento, e cada traço em cada documento saía como uma linha fina de 1 pixel independentemente da largura de linha declarada. Operadores multi-operando como m, rg e Tm usavam NumAt(1..6) e estavam todos corretos, pelo que um revisor a percorrer a função via uma parede de aritmética de índices plausível com doze entradas erradas embutidas nela

HotPDF: Indexação de tokens de NumAt desfasada em um, em que OpIndex endereça o próprio token do operador, pelo que NumAt(0) falha a verificação do tipo de operando e devolve zero, anulando doze operadores de operando único de Tc Tw Tz TL Ts Tr a w J j M ri e i
Como OpIndex endereça o próprio token do operador, NumAt(0) lê o operador, falha a verificação do tipo de operando e devolve o zero predefinido, deixando doze operadores de texto e de estado gráfico semanticamente mortos
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 aponta para o token do operador, por isso um operando isolado fica em 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)

Porque é que a suite de testes se manteve verde durante 38 versões?

Porque os asserts eram demasiado fracos para distinguir uma página renderizada de uma página parcialmente renderizada. Os testes de fumo de renderização afirmavam coisas como o bitmap de saída não é inteiramente preto, ou a página não está em branco, ou o digest da imagem não é zero. Todas essas condições mantêm-se quando o texto renderiza e as imagens não. O texto desenhava-se bem, pelo que o frame buffer nunca era uniforme, o digest nunca era zero, e a suite reportava sucesso enquanto todo o pipeline de imagens era, na prática, código morto. Asserts fracos são sedutores para gráficos precisamente porque os fortes parecem frágeis. Ninguém quer um teste que quebre quando uma aresta de anti-aliasing se desloca um pixel, pelo que o recuo natural é afirmar algo que nenhuma mudança razoável poderia violar — e esse recuo deixa-o em predicados que também nenhuma mudança não razoável consegue violar. Um teste de espaço de cor de separação afirmava que a saída era distinguível de preto; cinzento sobre branco passava-o, e branco sobre branco também. O teste não estava a medir se a cor certa tinha sido pintada. Estava a medir se algo, seja o que for, tinha acontecido na tela

Como se escreve um assert de renderização que realmente falha?

Contando pixels da cor esperada, na quantidade esperada, e deixando a posição e o tamanho resultarem da contagem. A disciplina de substituição é um PDF mínimo construído à mão, um facto visual por ficheiro, e um assert sobre quantos pixels caem dentro de uma tolerância de um triplo RGB específico. Uma imagem de 200 por 120 de vermelho puro colocada num offset conhecido tem de produzir cerca de 24000 pixels vermelhos. Se a pesquisa de recurso falhar, a contagem é 0. Se a cascata cm estiver invertida, a contagem é 0. Se a imagem renderizar no espaço de cor errado, a contagem é 0. Um único número apanha os três, e a banda de tolerância absorve o ruído de anti-aliasing que fazia as pessoas hesitar em relação à comparação exata em primeiro 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;

// Uma imagem vermelha de 200x120 colocada em 60,400 tem de pintar cerca de 24000 pixels vermelhos.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Quatro testes de fumo foram reescritos desta forma — uma transformação de tinta Type 4, uma colocação de imagem Do, um caso de visibilidade de conteúdo opcional e um modo de traço Tr — e entre eles expuseram toda a família. Essa é a lição real, e generaliza-se para além desta base de código: num pipeline de renderização, o assert tem de nomear a cor. Qualquer coisa mais suave é uma verificação de que o renderizador correu, não uma verificação de que desenhou. Se estiver a construir o seu próprio arnês de página-para-bitmap, o percurso de rasterização de páginas é o sítio natural para aparafusar um helper de contagem de pixels à sua primeira regressão

Fronteiras honestas

Vale a pena declarar dois limites com clareza. Os modos de renderização de clipping de texto 4 a 7 são desenhados como o seu modo base de preenchimento ou traço, porque o renderizador não modela caminhos de clip acumulados a partir de contornos de glifo; documentos que dependem de clipping em forma de texto vão renderizar o texto em vez da arte recortada por baixo. E a disciplina de contagem de pixels aqui descrita é uma técnica de teste de fumo, não uma suite de conformidade — prova que um facto visual específico chegou ao frame buffer, o que é uma fasquia muito mais baixa do que provar que a saída corresponde a um rasterizador de referência. É, no entanto, exatamente a fasquia que estes quatro bugs falharam em ultrapassar durante três anos de versões

O renderizador aqui discutido vem incluído no HotPDF Delphi Component padrão para Delphi e C++Builder; a página de produto contém a referência de API de renderização de páginas completa, incluindo a cache de bitmap e os pontos de entrada de prefetch em segundo plano