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 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

// 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;

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

// 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)));

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

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)

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;

// 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');

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 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