Artigo Técnico

Tm identity em content streams PDF: remoção peephole segura

A PDF Library for Delphi remove um operador de matriz de texto identity, 1 0 0 1 0 0 Tm, durante a otimização peephole dos content streams no momento de guardar apenas quando a text matrix e a text line matrix já são a identidade: logo a seguir a BT, ou logo a seguir a um Tm identity anterior. Um cm identity continua sempre a ser eliminado, porque o cm multiplica a CTM enquanto o Tm substitui ambas as matrizes de texto por completo. Desde a v3.539.28 que qualquer outro Tm identity fica no stream

O bug que isto corrige é do tipo silencioso. Um gerador de relatórios emite BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, confiando no Tm identity para mandar a segunda string de volta à origem do text space antes de aplicar a sua 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 nenhuma, e apagou-o. Nada falhou, nada registou aviso, e a página guardada desenhou «Total» imediatamente a seguir a «Invoice» na mesma linha de base, que é exatamente a classe de defeito que ninguém repara até um cliente imprimir o PDF

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

Um Tm identity só é um no-op quando substituiria duas matrizes que já guardam a identidade, e isso é uma propriedade dos operadores anteriores a ele, não dos seus próprios operandos. A ISO 32000-1 §9.4.1 diz que o BT inicializa tanto a text matrix (Tm) como a text line matrix (Tlm) com a identidade, e a §9.4.2 define o Tm como pondo ambas com os valores dados, e não como concatenar para cima delas. Compare com o cm (§8.4.4), que multiplica à direita a matriz de transformação corrente: multiplicar pela identidade deixa qualquer CTM intacta, por isso 1 0 0 1 0 0 cm pode ser apagado em qualquer sítio. Dentro de um objeto de texto o quadro é diferente. Td, TD, T* e um Tm não identity movem todos a Tlm, e cada operador que mostra texto (Tj, TJ, ', ") avança a Tm pela largura dos glifos que pintou. Depois de qualquer deles, um Tm identity é um reset genuíno para a origem. Se já segue posições de texto à mão com o seguidor de estado de CTM e text matrix do content stream, esta é 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: o cm multiplica a CTM à direita e é um no-op em qualquer sítio, enquanto o Tm substitui a Tm e a Tlm por completo, e cada Tj avança a Tm pela largura que pintou, por isso um Tm identity depois de texto mostrado é um reset genuíno
O gerador de relatórios contava com esse reset: apagar o Tm identity pintava Total imediatamente a seguir a Invoice na mesma linha de base, e nada falhou, registou ou avisou a caminho da impressora do cliente

Como o scan para trás decide que Tm identity eliminar

O TPDFContentPeepholeOptimizer.RemoveIdentityMatrices agora caminha para trás a partir de cada Tm identity e só o elimina se o scan chegar primeiro a BT ou a outro Tm identity. O Tm identity anterior conta quer seja mantido quer tenha ele próprio acabado de ser agendado para eliminação, porque em qualquer dos casos deixou as duas matrizes na identidade, exatamente como o BT faz. A regra separa todos os operadores que pode encontrar em dois grupos:

  • Parar e manter o Tm: Td, TD, T*, um Tm não identity, Tj, TJ, ', ", ET, qualquer operador que o parser não reconheça, ou o início do stream
  • Passar por cima e continuar o scan: operadores que nunca tocam na Tm ou na Tlm, como Tf, Tc, os definidores de cor, gs, operadores de marked-content e cm
O RemoveIdentityMatrices do PDFlibPas caminha para trás a partir de cada Tm identity: Tf, Tc, definidores de cor, gs e cm são passados a cima, enquanto Td, TD, T*, um Tm não identity, Tj, TJ, um operador desconhecido ou ET para o scan e mantém o Tm, e o BT garante a eliminação
Um Tm identity anterior também para o scan, porque mantido ou já agendado para eliminação deixou as duas matrizes na identidade — em qualquer dos casos o otimizador nunca move um glifo

Os casos conservadores são deliberados. Um operador desconhecido podia ser qualquer coisa, por isso o scan recusa raciocinar para lá dele. O ET fecha o objeto de texto, por isso um Tm depois dele não tem BT nenhum a garantir os valores da matriz. O scan também trabalha num content stream de cada vez, o que interessa nas páginas cujo /Contents é uma matriz: uma camada que começa a meio de um objeto de texto, sem BT próprio, mantém o seu Tm identity mesmo quando a camada anterior o teria tornado redundante. Isso custa uns bytes em ficheiros estranhos e nunca move um glifo. Se edita texto de página ao nível da instrução, como no passo a passo do mapeamento de carácter 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: deixar os bytes quietos
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // devolve 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 logo a seguir a BT, o segundo de dois Tm identity seguidos
//   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 identity, ou fora de BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Corra o helper no stream da fatura da abertura e o Tm identity sobrevive, porque o scan para trás encontra o Tj antes de chegar ao BT. Ponha /F1 12 Tf, 2 Tc e 0 g entre o BT e o Tm identity e ele continua a sair, já que nenhum deles toca nas matrizes de texto. 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 identity repõe a matriz que o Td moveu, e só o segundo é redundante

Quando é que o otimizador peephole corre de facto?

O otimizador corre apenas durante o passe de compressão, dentro do TPDFPageTree.Compress, e apenas em content streams que ainda não estejam comprimidos com Flate. O TPDFlib.SetOptimizeContentStreams(1) é o predefinido, e o mesmo interruptor está exposto como o campo OptimizeContentStreams de TPDFlibSaveOptions; tanto o CompressContent como o CompressPage o respeitam. Um stream cujo /Filter já é /FlateDecode é saltado por inteiro, por isso carregar um PDF comprimido existente e voltar a guardá-lo não reescreve os seus operadores. Se o stream falhar a interpretação, os bytes descodificados originais são comprimidos sem alteração. 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 base de comparação útil quando quer ver quanto do ganho de tamanho vem das regras peephole, a par dos ganhos maiores cobertos em otimização do tamanho de ficheiros PDF com font subsetting

O PDFlibPas corre o otimizador peephole apenas dentro do passe de compressão na gravação: o TPDFPageTree.Compress respeita o SetOptimizeContentStreams, um stream já filtrado com /FlateDecode é saltado por inteiro, um stream não interpretável é comprimido com os bytes originais intactos, e o NormalizeContentStreams nunca chama o otimizador
Os streams comprimidos saltados são a parte silenciosa: carregue um PDF existente, guarde-o de novo, e os operadores saem intocados porque o otimizador só reescreve streams que descodificou primeiro
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 gravação incluídas; False recusa
    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 garantia de facto o antigo teste de regressão?

O antigo teste de regressão garantia uma única forma: um Tm identity logo a seguir a BT é removido. O Peephole_RemovesIdentityTextMatrix alimenta o otimizador com BT 1 0 0 1 0 0 Tm (hello) Tj ET e afirma que nenhum Tm resta. Um lançamento anterior já tinha notado que eliminar um Tm identity é inseguro quando a Tlm não é a identidade, e mesmo assim manteve o comportamento porque o teste «trancava» o caso. Relido com atenção, o teste nada diz sobre um Tm identity depois de um Td ou depois de texto mostrado; tratar a cobertura de uma amostra como o contrato de toda a regra foi o erro verdadeiro. A correção mantém esse caso original a passar e acrescenta seis casos que pregam tanto as formas removíveis como as mantidas, incluindo um Tm fora de qualquer objeto de texto e um que segue um ET

A troca é fácil de aceitar logo que está escrita. Geradores que embrulham cada objeto de texto como BT 1 0 0 1 0 0 Tm ... continuam a ver esse operador redundante removido, que é donde veio quase toda a poupança. O que o otimizador abre mão é do Tm identity ocasional a meio de um objeto de texto, um punhado de bytes por página antes de o Flate os ver, em troca de uma garantia que o cabeçalho do módulo afirma sem rodeios: cada 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 rendering com boas taxas de compressão

O parser de content streams, o otimizador peephole e as opções de compressão na gravação aqui descritos vêm todos na PDF Library for Delphi e C++Builder, que expõe igualmente NormalizeContentStreams, CompressContent e TPDFlibSaveOptions para afinar a forma como cada documento é escrito