Artigo Técnico

Renderer de PDF Não Desenha Nada: Quatro Bugs Silenciosos no Delphi

Um renderer de PDF que não desenha nada geralmente não tem bug algum no seu código de desenho. No HotPDF Delphi Component para Delphi e C++Builder, quatro defeitos separados faziam páginas renderizarem em branco enquanto toda linha de log permanecia limpa: operandos de nome carregando uma barra à frente, uma concatenação cm invertida, e um índice de token que lia zero. Nenhum deles disparava exceção. Nenhum deles logava. O content stream tokenizava corretamente, o dispatcher de operadores reconhecia todo operador, o image XObject era decodificado em um bitmap válido, e então a página saía vazia. Essa combinação — um pipeline que reporta sucesso em todo estágio e não produz nada visível — é a assinatura de um lookup ou um índice que erra silenciosamente em vez de falhar. Este é um post-mortem de uma família assim, e da disciplina de teste que a deixou sobreviver por 38 releases

Por que um renderer de PDF não desenha nada?

Porque um lookup de recurso que falha em um renderer de PDF é indistinguível de uma página vazia. Operandos de nome do content stream e chaves do resource dictionary são dois espaços de string diferentes, e o HotPDF estava comparando entre eles sem normalizar. O tokenizer lê /Im0 e mantém a barra, porque é isso que o token é; o dictionary carregado /Resources /XObject guarda a chave como Im0, porque o parser remove o delimitador quando constrói chaves de dictionary. Todo FindValue contra um nome de operando, portanto, retornava -1. O raio de impacto foi maior que imagens. A ISO 32000-1 §8.9 cobre Do, a §8.4 cobre gs e seu lookup /ExtGState, a §8.6 cobre cs e CS, e a §8.7.4.3 cobre sh. Todos os cinco operadores indexavam seu sub-dictionary de recurso pelo operando bruto, então todos os cinco erravam. Color spaces nomeados caíam de volta para DeviceGray, o que transforma 1 scn em tinta branca em uma página branca. Image XObjects nunca eram pintados de forma alguma — o caminho de imagem bitmap, na prática, nunca tinha funcionado desde o dia em que chegou. A correção é um helper no nível de unit aplicado em todo lookup indexado por operando, que é a única forma de impedir que a convenção se desvie de novo

HotPDF: operando do fluxo de conteúdo com prefixo de barra Im0 não correspondendo à chave do dicionário de recursos sem barra Im0, de modo que FindValue retorna -1 e os cinco operadores Do, gs, cs, CS e sh erram todas as suas buscas de recursos
O tokenizer mantém a solidus no operando enquanto o parser de recursos a remove das chaves do dicionário, então toda busca de operando bruto retorna -1 e todos os cinco operadores de recursos falham silenciosamente
// Content stream da página, o jeito comum de posicionar imagem:
//   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;

// Toda busca de recurso por nome de operando passa pelo helper
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// O sub-dicionário /XObject pode ser, ele mesmo, uma indirect reference
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Um segundo erro relacionado ficava uma camada abaixo. O renderer tinha resolvedores tipados só para streams e dictionaries, então uma referência indireta apontando para um objeto array de nível superior — o comum /CS0 5 0 R com [/Separation ...] do outro lado — resolvia para nil em ambos e caía de volta ao link não resolvido. Adicionar um resolvedor de objeto genérico corrigiu color spaces nomeados e arrays de função em um único movimento. Se você está montando shading dictionaries, a mesma disciplina de resolução se aplica ao caminho de shading axial e radial, onde a entrada /Function é muito frequentemente indireta

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

O segundo defeito posicionava imagens cerca de cem mil pixels fora da página, o que parece exatamente como não desenhá-las. A ISO 32000-1 §8.3.4 define transformações de PDF com vetores linha, e o operador cm concatena sua matriz de operando M sobre a matriz de transformação atual como M × CTM — M entra em vigor 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, portanto, passa o CTM antigo como A. O código lançado passava a matriz de operando como A, produzindo CTM × M

Ordem invertida é inofensiva para um único cm e catastrófica para o idioma padrão de dois passos. Posicione 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 o translada por (x, y). Sob a cascata invertida a translação entra primeiro e a escala a multiplica, então uma imagem nominalmente em (60, 400) escalada para 200 por 120 cai em (12000, 48000). O teste de clip no topo do blit a rejeita, o blit é pulado, e nada em lugar nenhum reporta um problema

HotPDF: ordem de concatenação da matriz cm do PDF mostrando o contrato ISO de nova CTM igual a M vezes CTM, HPDFMatMul aplicando seu argumento B primeiro, e maquetes de página em que a imagem aterrissa em 60 400 sob a cascata correta versus 12000 48000 sob a invertida
HPDFMatMul aplica seu argumento B primeiro, então passar a matriz operando como A inverte a cascata e um posicionamento de imagem em duas etapas cai a cerca de cem mil pixels da página, onde o teste de clip o ignora 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 ficou assim por 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 arquivo de código-fonte já continha a ordem correta. A entrada /Matrix de um Form XObject tinha a mesma composição invertida, mas o caminho de glifo Type 3 e o caminho de contorno de glifo embutido acertaram desde o início, porque o posicionamento de glifo colapsa visivelmente para a origem quando você o inverte e alguém já tinha sido forçado a corrigi-lo. Duas convenções coexistiram em uma unit por três dezenas de releases, cada uma correta em sua própria função, e nenhum revisor notou porque nenhum call site parecia errado isoladamente

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

Você tem doze operadores que são tratados sintaticamente e semanticamente mortos. O acessor de operando no renderer é NumAt(Back), que lê Tokens[OpIndex - Back], e OpIndex é o índice do próprio token de operador. Um operador de operando único, portanto, encontra seu número em back 1. Doze deles foram escritos como NumAt(0), que lê o token de operador, falha na checagem de tipo ctOperandNumber, e retorna o padrão zero. A lista é Tc, Tw, Tz, TL, Ts e Tr dos operadores de estado de texto da ISO 32000-1 §9.3, mais w, J, j, M, ri e i dos operadores de estado gráfico da §8.4.3. Espaçamento de caractere e palavra viraram no-ops, escala horizontal nunca se aplicou, o leading ficou em zero então T* nunca avançou uma linha, o text rise não fazia nada, o render mode era sempre fill, e todo traço em todo documento saía como uma linha fina de 1 pixel independentemente da largura de linha declarada. Operadores de múltiplos operandos como m, rg e Tm usavam NumAt(1..6) e estavam todos corretos, então um revisor escaneando a função via uma parede de aritmética de índice plausível com doze entradas erradas embutidas nela

HotPDF: indexação de tokens do NumAt com erro de um, em que OpIndex endereça o próprio token do operador, de modo que NumAt(0) falha na verificação do tipo de operando e retorna zero, zerando 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 na verificação do tipo de operando e retorna o padrão zero, deixando doze operadores de texto e 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 endereça o token do operador, então um operando solto 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)

Por que a suíte de testes ficou verde por 38 versões?

Porque os asserts eram fracos demais para distinguir uma página renderizada de uma parcialmente renderizada. Os smokes de renderização afirmavam coisas como o bitmap de saída não é totalmente preto, ou a página não está em branco, ou o digest da imagem não é zero. Cada um desses se mantém verdadeiro quando o texto renderiza e as imagens não. O texto desenhava bem, então o frame buffer nunca era uniforme, o digest nunca era zero, e a suíte reportava sucesso enquanto o pipeline de imagem inteiro era código morto na prática. Asserts fracos são sedutores para gráficos precisamente porque asserts fortes parecem frágeis. Ninguém quer um teste que quebra quando uma borda de anti-aliasing se desloca em um pixel, então a retirada natural é afirmar algo que nenhuma mudança razoável poderia violar — e essa retirada te leva a predicados que nenhuma mudança irrazoável consegue violar também. Um teste de color space de separação afirmava que a saída era distinguível de preto; cinza sobre branco passava, e branco sobre branco também. O teste não estava medindo se a cor certa foi pintada. Estava medindo se algo, qualquer coisa, tinha acontecido na tela

Como você escreve um assert de renderização que de fato falha?

Conte pixels da cor esperada, na quantidade esperada, e deixe posição e tamanho decorrerem da contagem. A disciplina de substituição é um PDF mínimo construído à mão, um fato visual por arquivo, e um assert sobre quantos pixels caem dentro de uma tolerância de um RGB específico. Uma imagem de 200 por 120 de vermelho puro posicionada em um deslocamento conhecido precisa produzir aproximadamente 24000 pixels vermelhos. Se o lookup de recurso falha, a contagem é 0. Se a cascata cm está invertida, a contagem é 0. Se a imagem renderiza no color space errado, a contagem é 0. Um número pega os três, e a faixa de tolerância absorve o ruído de anti-aliasing que fazia as pessoas hesitarem em fazer 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 200x120 colocada em 60,400 tem que pintar uns 24000 pixels vermelhos
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Quatro smokes foram reescritos dessa forma — uma transformação de tinta Type 4, um posicionamento de imagem Do, um caso de visibilidade de optional-content e um modo de traço Tr — e juntos eles expuseram a família inteira. Essa é a lição real, e ela generaliza para além dessa base de código: em um pipeline de renderização, o assert precisa nomear a cor. Qualquer coisa mais suave é uma checagem de que o renderer rodou, não uma checagem de que ele desenhou. Se você está construindo seu próprio harness de página-para-bitmap, o passo a passo de rasterização de página é o lugar natural para acoplar um helper de contagem de pixel na sua primeira regressão

Limites honestos

Vale declarar dois limites claramente. Os modos de render de clipping de texto de 4 a 7 são desenhados como seu modo base de fill ou stroke, porque o renderer 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 pixel descrita aqui é uma técnica de smoke test, não uma suíte de conformidade — ela prova que um fato visual específico chegou ao frame buffer, o que é uma barra muito mais baixa do que provar que a saída corresponde a um rasterizador de referência. É, porém, exatamente a barra que esses quatro bugs deixaram de superar por três anos de releases

O renderer discutido aqui vem como parte do HotPDF Delphi Component padrão para Delphi e C++Builder; a página de produto traz a referência completa da API de renderização de página, incluindo o cache de bitmap e os pontos de entrada de prefetch em background