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

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

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

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

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

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