Artigo Técnico

Tm identidade em PDF: remoção peephole segura no Delphi

A PDF Library for Delphi remove um operador de text matrix identidade, 1 0 0 1 0 0 Tm, durante a otimização peephole de content stream na hora do save só quando a text matrix e a text line matrix já são a identidade: logo depois de um BT, ou logo depois de um Tm identidade anterior. Um cm identidade continua sempre descartado, porque cm multiplica a CTM enquanto Tm substitui as duas text matrices de uma vez. Desde a v3.539.28 todo outro Tm identidade fica no stream

O bug que isso corrige é do tipo silencioso. Um gerador de relatórios emite BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, contando com o Tm identidade para mandar a segunda string de volta à origem do text space antes de aplicar a própria lógica de posicionamento. O otimizador antigo via seis números que soletravam a matriz identidade, decidiu que o operador não podia mudar coisa alguma e o apagou. Nada falhou, nada registrou aviso, e a página salva desenhou "Total" imediatamente depois de "Invoice" na mesma baseline, que é exatamente a classe de defeito que ninguém nota até um cliente imprimir o PDF

Por que 1 0 0 1 0 0 Tm nem sempre é um no-op?

Um Tm identidade só é um no-op quando ele substituiria duas matrizes que já guardam a identidade, e isso é uma propriedade dos operadores antes dele, não dos operandos dele. A ISO 32000-1 §9.4.1 diz que o BT inicializa tanto a text matrix (Tm) quanto a text line matrix (Tlm) com a identidade, e a §9.4.2 define Tm como definir as duas com os valores dados, não concatenar sobre elas. Compare com o cm (§8.4.4), que multiplica à direita a current transformation matrix: multiplicar pela identidade deixa qualquer CTM inalterada, então 1 0 0 1 0 0 cm pode ser apagado em qualquer lugar. Dentro de um objeto de texto o quadro é diferente. Td, TD, T* e um Tm não identidade movem a Tlm, e todo operador que mostra texto (Tj, TJ, ', ") avança a Tm pela largura dos glyphs que pintou. Depois de qualquer um deles, um Tm identidade é um reset genuíno para a origem. Se você já rastreou posições de texto à mão com o rastreador de estado de CTM e text matrix de content stream, é a mesma distinção entre concatenar estado e substituí-lo

O PDFlibPas trata 1 0 0 1 0 0 cm e 1 0 0 1 0 0 Tm de forma diferente: cm multiplica a CTM à direita e é um no-op em qualquer lugar, enquanto Tm substitui Tm e Tlm de uma vez, e todo Tj avança a Tm pela largura que pintou, então um Tm identidade depois de texto mostrado é um reset genuíno
O gerador de relatórios contava com esse reset: apagar o Tm identidade pintava Total imediatamente depois de Invoice na mesma baseline, e nada falhou, registrou ou avisou no caminho até a impressora do cliente

Como a varredura para trás decide qual Tm identidade descartar

O TPDFContentPeepholeOptimizer.RemoveIdentityMatrices agora caminha para trás a partir de cada Tm identidade e o descarta só se a varredura chega primeiro em BT ou em outro Tm identidade. O Tm identidade anterior conta esteja ele mantido ou ele mesmo recém-agendado para deleção, porque de qualquer jeito ele deixou as duas matrizes na identidade, exatamente como o BT faz. A regra classifica todo operador que pode encontrar num de dois grupos:

  • Parar e manter o Tm: Td, TD, T*, um Tm não identidade, Tj, TJ, ', ", ET, qualquer operador que o parser não reconhece, ou o começo do stream
  • Pular e continuar varrendo: operadores que nunca tocam Tm ou Tlm, como Tf, Tc, setters de cor, gs, operadores de marked-content e cm
O RemoveIdentityMatrices do PDFlibPas caminha para trás a partir de cada Tm identidade: Tf, Tc, setters de cor, gs e cm são pulados, enquanto Td, TD, T*, um Tm não identidade, Tj, TJ, um operador desconhecido ou ET para a varredura e mantém o Tm, e o BT atesta o descarte
Um Tm identidade anterior também para a varredura, porque mantido ou já agendado para deleção ele deixou as duas matrizes na identidade — de qualquer jeito o otimizador nunca move um glyph

Os casos conservadores são deliberados. Um operador desconhecido pode ser qualquer coisa, então a varredura se recusa a raciocinar além dele. O ET fecha o objeto de texto, então um Tm depois dele não tem BT nenhum atestando os valores de matriz. A varredura também trabalha num content stream por vez, o que importa para páginas cujo /Contents é um array: uma camada que começa no meio de um objeto de texto, sem BT próprio, mantém o Tm identidade dela mesmo quando a camada anterior a tornaria redundante. Isso custa alguns bytes em arquivos estranhos e nunca move um glyph. Se você edita texto de página no nível de instrução, como no passo a passo de mapeamento de caractere para byte de conteúdo, o mesmo modelo TPDFContentProgram interpretado é o que o otimizador reescreve

uses
  PDFlibContentModel, PDFlibContentOptimize;

function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
  Prog: TPDFContentProgram;
  Optimizer: TPDFContentPeepholeOptimizer;
begin
  Result := Source;
  Prog := TPDFContentProgram.Create;
  try
    if not Prog.Parse(Source) then
      Exit; // stream danificado: deixa os bytes em paz
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // retorna o número de instruções removidas
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // uma instrução por linha
  finally
    Prog.Free;
  end;
end;

// Removido: Tm direto depois de BT, o segundo de dois identity Tm em sequência
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Mantido: Tm depois de Td, depois de Tj, depois de um Tm não identidade, ou fora de BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Rode o helper no stream da fatura da abertura e o Tm identidade sobrevive, porque a varredura para trás encontra Tj antes de chegar no BT. Ponha /F1 12 Tf, 2 Tc e 0 g entre o BT e o Tm identidade e ele ainda vai embora, já que nenhum deles toca as text matrices. Uma sequência como BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm perde exatamente um operador: o primeiro Tm identidade recupera a matriz que o Td moveu, e só o segundo é redundante

Quando o peephole optimizer roda de fato?

O otimizador roda só durante a passagem de compressão, dentro do TPDFPageTree.Compress, e só em content streams que ainda não estão comprimidos com Flate. O TPDFlib.SetOptimizeContentStreams(1) é o default, e o mesmo interruptor aparece como o campo OptimizeContentStreams de TPDFlibSaveOptions; tanto CompressContent quanto CompressPage o respeitam. Um stream cujo /Filter já é /FlateDecode é pulado por completo, então carregar um PDF comprimido existente e salvá-lo de novo não reescreve os operadores dele. Se o stream falha ao ser interpretado, os bytes decodificados originais são comprimidos sem mudança. O TPDFlib.NormalizeContentStreams interpreta e reemite conteúdo com espaçamento e números canônicos mas nunca chama o otimizador, o que o torna uma baseline útil quando você quer ver quanto da diferença de tamanho as regras peephole contribuem, ao lado dos ganhos maiores cobertos em otimização de tamanho de arquivo PDF com font subsetting

O PDFlibPas roda o peephole optimizer só dentro da passagem de compressão na hora do save: o TPDFPageTree.Compress respeita SetOptimizeContentStreams, um stream já filtrado com /FlateDecode é pulado por completo, um stream não interpretável é comprimido com os bytes originais sem mudança, e o NormalizeContentStreams nunca chama o otimizador
Streams comprimidos pulados são a parte silenciosa: carregue um PDF existente, salve de novo, e os operadores saem intocados porque o otimizador só reescreve streams que decodificou antes
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Streams não comprimidos passam pelas regras peephole, depois Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // A mesma escolha pelas opções de save embutidas; False desliga
    Options.CompressContent := True;
    Options.CompressFonts := True;
    Options.CompressImages := True;
    Options.Linearize := False;
    Options.KeepModDate := False;
    Options.OptimizeContentStreams := False;
    Options.GarbageCollect := False;
    Options.PackObjectStreams := True;
    Lib.SaveToFileOptions('report-plain.pdf', Options);
  finally
    Lib.Free;
  end;
end;

O que o teste de regressão antigo realmente garantia?

O teste de regressão antigo garantia uma única forma: um Tm identidade direto depois de BT é removido. O Peephole_RemovesIdentityTextMatrix alimenta BT 1 0 0 1 0 0 Tm (hello) Tj ET ao otimizador e afirma que nenhum Tm sobra. Um release anterior já havia notado que descartar um Tm identidade é inseguro quando a Tlm não é a identidade, e mesmo assim manteve o comportamento porque o teste "travava" isso. Relido com atenção, o teste não diz nada sobre um Tm identidade depois de Td ou depois de texto mostrado; tratar a cobertura de uma amostra como o contrato da regra inteira foi o erro de verdade. O fix mantém aquele caso original passando e adiciona seis casos que cravam tanto as formas removíveis quanto as mantidas, incluindo um Tm fora de qualquer objeto de texto e um que segue um ET

O trade-off é fácil de aceitar depois de escrito. Geradores que embrulham todo objeto de texto como BT 1 0 0 1 0 0 Tm ... continuam tendo esse operador redundante removido, que é de onde veio quase toda a economia. O que o otimizador abre mão é o Tm identidade ocasional no meio de um objeto de texto, uns poucos bytes por página antes de o Flate sequer vê-los, em troca de uma garantia que o header do módulo declara sem rodeio: toda transformação é output-equivalente e nunca muda a página visível. Um otimizador de tamanho que move texto não é um otimizador, é um bug de renderização com boas taxas de compressão

O parser de content stream, o peephole optimizer e as opções de compressão na hora do save descritas aqui vêm todos na PDF Library for Delphi e C++Builder, que também expõe NormalizeContentStreams, CompressContent e TPDFlibSaveOptions para ajustar como cada documento é gravado