Articolo tecnico

Identity Tm nei content stream: rimozione peephole sicura

PDF Library for Delphi rimuove un operatore text matrix identity, 1 0 0 1 0 0 Tm, durante la sua ottimizzazione peephole del content stream al salvataggio solo quando la text matrix e la text line matrix sono già identity: subito dopo BT, o subito dopo un Tm identity precedente. Un cm identity viene comunque sempre eliminato, perché cm moltiplica la CTM mentre Tm sostituisce entrambe le text matrix senza passaggi intermedi. Dalla v3.539.28 ogni altro Tm identity resta nel flusso

Il bug corretto qui è del tipo silenzioso. Un generatore di report emette BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, contando sul Tm identity per rimandare la seconda stringa all'origine dello text space prima di applicare la propria logica di posizionamento. Il vecchio optimizer vedeva sei numeri che compongono la matrice identity, decideva che l'operatore non poteva in nessun modo cambiare qualcosa, e lo cancellava. Niente fallimenti, niente warning nei log, e la pagina salvata disegnava «Total» subito dopo «Invoice» sulla stessa baseline, che è esattamente la classe di difetto che nessuno nota finché un cliente non stampa il PDF

Perché 1 0 0 1 0 0 Tm non è sempre un no-op?

Un Tm identity è un no-op solo quando sostituirebbe due matrici che già valgono identity, e questa è una proprietà degli operatori che lo precedono, non dei suoi stessi operandi. ISO 32000-1 §9.4.1 dice che BT inizializza sia la text matrix (Tm) sia la text line matrix (Tlm) all'identity, e §9.4.2 definisce Tm come impostazione di entrambe ai valori dati, non concatenazione su di esse. Confronta con cm (§8.4.4), che moltiplica a destra la current transformation matrix: moltiplicare per l'identity lascia qualunque CTM invariata, quindi 1 0 0 1 0 0 cm si può cancellare ovunque. Dentro un text object il quadro cambia. Td, TD, T* e un Tm non identity muovono tutti Tlm, e ogni operatore che mostra testo (Tj, TJ, ', ") avanza Tm della larghezza dei glyph disegnati. Dopo uno qualsiasi di questi, un Tm identity è un vero reset all'origine. Se hai mai tracciato a mano le posizioni del testo con il tracker di stato CTM e text matrix del content stream, è la stessa distinzione tra concatenare stato e sostituirlo

PDFlibPas tratta 1 0 0 1 0 0 cm e 1 0 0 1 0 0 Tm in modo diverso: cm moltiplica a destra la CTM ed è un no-op ovunque, mentre Tm sostituisce Tm e Tlm senza passaggi, e ogni Tj avanza Tm della larghezza disegnata, così un Tm identity dopo testo mostrato è un vero reset
Il generatore di report contava su quel reset: cancellare il Tm identity disegnava Total subito dopo Invoice sulla stessa baseline, e nulla falliva, loggava o avvertiva lungo la strada verso la stampante del cliente

Come la scansione all'indietro decide quale Tm identity eliminare

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices ora cammina all'indietro da ogni Tm identity e lo elimina solo se la scansione raggiunge prima BT o un altro Tm identity. Il Tm identity precedente conta che venga conservato o che sia stato appena messo in coda per l'eliminazione, perché in entrambi i casi ha lasciato entrambe le matrici all'identity, esattamente come fa BT. La regola colloca ogni operatore che può incontrare in uno di due gruppi:

  • Fermati e conserva il Tm: Td, TD, T*, un Tm non identity, Tj, TJ, ', ", ET, qualunque operatore che il parser non riconosce, o l'inizio del flusso
  • Salta e continua a scansionare: operatori che non toccano mai Tm o Tlm, come Tf, Tc, i setter di colore, gs, gli operatori marked-content e cm
PDFlibPas RemoveIdentityMatrices cammina all'indietro da ogni Tm identity: Tf, Tc, i setter di colore, gs e cm vengono saltati, mentre Td, TD, T*, un Tm non identity, Tj, TJ, un operatore sconosciuto o ET ferma la scansione e conserva il Tm, e BT garantisce l'eliminazione
Anche un Tm identity precedente ferma la scansione, perché conservato o già in coda per l'eliminazione ha lasciato entrambe le matrici all'identity — in ogni caso l'optimizer non muove mai un glyph

I casi conservativi sono voluti. Un operatore sconosciuto potrebbe essere qualsiasi cosa, quindi la scansione rifiuta di ragionare oltre. ET chiude il text object, quindi un Tm dopo di esso non ha nessun BT che garantisca i valori delle matrici. La scansione lavora anche su un content stream alla volta, il che conta per le pagine il cui /Contents è un array: un layer che inizia in mezzo a un text object, senza un BT proprio, conserva il suo Tm identity anche quando il layer precedente l'avrebbe reso ridondante. Costa qualche byte sui file strani e non muove mai un glyph. Se modifichi il testo di pagina a livello di istruzioni, come nel percorso guidato sulla mappatura character-to-content-byte, lo stesso modello TPDFContentProgram analizzato è ciò che l'optimizer riscrive

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 danneggiato: lascia stare i byte
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // restituisce il numero di istruzioni rimosse
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // una istruzione per riga
  finally
    Prog.Free;
  end;
end;

// Rimosso: Tm direttamente dopo BT, la seconda di due identity Tm di fila
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Conservato: Tm dopo Td, dopo Tj, dopo un Tm non identity, o fuori da BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Esegui l'helper sul flusso della fattoria del paragrafo iniziale e il Tm identity sopravvive, perché la scansione all'indietro incontra Tj prima di arrivare a BT. Metti /F1 12 Tf, 2 Tc e 0 g tra BT e il Tm identity e viene comunque eliminato, perché nessuno di questi tocca le text matrix. Una sequenza come BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm perde esattamente un operatore: il primo Tm identity ripristina la matrice che Td aveva spostata, e solo il secondo è ridondante

Quando gira davvero il peephole optimizer?

L'optimizer gira solo durante il passaggio di compressione, dentro TPDFPageTree.Compress, e solo su content stream che non sono già compressi con Flate. TPDFlib.SetOptimizeContentStreams(1) è il default, e lo stesso interruttore è esposto come campo OptimizeContentStreams di TPDFlibSaveOptions; sia CompressContent sia CompressPage lo rispettano. Uno stream il cui /Filter è già /FlateDecode viene saltato del tutto, quindi caricare un PDF compresso esistente e salvarlo di nuovo non riscrive i suoi operatori. Se lo stream non riesce a essere analizzato, i byte decodificati originali vengono compressi invariati. TPDFlib.NormalizeContentStreams analizza e riemette il contenuto con spaziatura e numeri canonici ma non chiama mai l'optimizer, il che lo rende una baseline utile quando vuoi vedere quanto della differenza di dimensioni contribuiscono le regole peephole, accanto ai guadagni maggiori trattati in ottimizzazione delle dimensioni dei file PDF con font subsetting

PDFlibPas esegue il peephole optimizer solo dentro il passaggio di compressione al salvataggio: TPDFPageTree.Compress rispetta SetOptimizeContentStreams, uno stream già filtrato con /FlateDecode viene saltato del tutto, uno stream non analizzabile viene compresso con i suoi byte originali invariati, e NormalizeContentStreams non chiama mai l'optimizer
Gli stream compressi saltati sono la parte silenziosa: carica un PDF esistente, salvalo di nuovo, e i suoi operatori escono intatti perché l'optimizer riscrive solo stream che ha prima decodificato
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Gli stream non compressi passano per le regole peephole, poi Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // La stessa scelta attraverso le opzioni di salvataggio in bundle; False rinuncia
    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;

Che cosa garantiva davvero il vecchio test di regression?

Il vecchio test di regression garantiva una sola forma: un Tm identity direttamente dopo BT viene rimosso. Peephole_RemovesIdentityTextMatrix passa BT 1 0 0 1 0 0 Tm (hello) Tj ET all'optimizer e asserisce che non resta nessun Tm. Una release precedente aveva già notato che eliminare un Tm identity non è sicuro quando Tlm non è identity, per poi mantenere comunque il comportamento perché il test lo «bloccava». Riletto con attenzione, il test non dice nulla su un Tm identity dopo Td o dopo testo mostrato; trattare la copertura di un campione come il contratto dell'intera regola era il vero errore. La correzione mantiene quel caso originale verde e aggiunge sei casi che bloccano sia le forme rimovibili sia quelle conservate, compreso un Tm fuori da qualsiasi text object e uno che segue ET

Il trade-off è facile da accettare una volta scritto. I generatori che avvolgono ogni text object come BT 1 0 0 1 0 0 Tm ... ottengono ancora la rimozione di quell'operatore ridondante, che è da dove veniva quasi tutto il risparmio. Ciò a cui l'optimizer rinuncia è l'occasionale Tm identity in mezzo a un text object, una manciata di byte per pagina prima ancora che Flate li veda, in cambio di una garanzia che l'header del modulo dichiara senza sconti: ogni trasformazione è output-equivalente e non cambia mai la pagina visibile. Un optimizer di dimensioni che muove il testo non è un optimizer, è un bug di rendering con buoni rapporti di compressione

Il parser del content stream, il peephole optimizer e le opzioni di compressione al salvataggio descritti qui sono tutti inclusi in PDF Library for Delphi and C++Builder, che espone anche NormalizeContentStreams, CompressContent e TPDFlibSaveOptions per regolare come viene scritto ogni documento