Technischer Artikel

Identity-Tm im Content-Stream: Peephole ohne Risiko

Die PDF Library for Delphi entfernt einen Identity-Textmatrix-Operator, 1 0 0 1 0 0 Tm, während der Peephole-Optimierung des Content-Streams zur Speicherzeit nur dann, wenn die Textmatrix und die Textzeilenmatrix bereits die Identity sind: direkt nach BT oder direkt nach einem früheren Identity-Tm. Ein Identity-cm wird nach wie vor immer entfernt, denn cm multipliziert die CTM, während Tm beide Textmatrizen komplett ersetzt. Seit v3.539.28 bleibt jedes andere Identity-Tm im Stream

Der Bug, den das behebt, ist die stille Sorte. Ein Reportgenerator emittiert BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET und verlässt sich darauf, dass das Identity-Tm den zweiten String zurück zum Ursprung des Textspace schickt, bevor seine eigene Positionierlogik greift. Der ältere Optimizer sah sechs Zahlen, die die Identity-Matrix buchstabieren, entschied, der Operator könne unmöglich etwas ändern, und löschte ihn. Nichts schlug fehl, nichts loggte eine Warnung, und die gespeicherte Seite zeichnete „Total“ unmittelbar nach „Invoice“ auf derselben Grundlinie — genau die Sorte Defekt, die niemand bemerkt, bis ein Kunde die PDF ausdruckt

Warum ist 1 0 0 1 0 0 Tm nicht immer ein No-op?

Ein Identity-Tm ist nur dann ein No-op, wenn es zwei Matrizen ersetzen würde, die bereits die Identity halten, und das ist eine Eigenschaft der Operatoren davor, nicht seiner eigenen Operanden. ISO 32000-1 §9.4.1 sagt, dass BT sowohl die Textmatrix (Tm) als auch die Textzeilenmatrix (Tlm) auf die Identity initialisiert, und §9.4.2 definiert Tm als Setzen beider auf die angegebenen Werte, nicht als Verkettung darauf. Vergleichen Sie das mit cm (§8.4.4), das die aktuelle Transformationsmatrix rechtsmultipliziert: Multiplikation mit der Identity lässt jede CTM unverändert, 1 0 0 1 0 0 cm ist also überall gefahrlos löschbar. Innerhalb eines Textobjekts sieht die Lage anders aus. Td, TD, T* und ein Nicht-Identity-Tm verschieben alle Tlm, und jeder Textanzeige-Operator (Tj, TJ, ', ") rückt Tm um die Breite der gemalten Glyphen weiter. Danach ist ein Identity-Tm ein echter Reset zum Ursprung. Wenn Sie Textpositionen schon einmal von Hand mit dem Content-Stream-CTM- und Textmatrix-State-Tracker nachverfolgt haben, ist das dieselbe Unterscheidung zwischen verkettetem und ersetztem Zustand

PDFlibPas behandelt 1 0 0 1 0 0 cm und 1 0 0 1 0 0 Tm unterschiedlich: cm rechtsmultipliziert die CTM und ist überall ein No-op, während Tm Tm und Tlm komplett ersetzt, und jedes Tj rückt Tm um die gemalte Breite weiter — ein Identity-Tm nach angezeigtem Text ist also ein echter Reset
Der Reportgenerator verließ sich auf diesen Reset: Ohne das Identity-Tm landete Total unmittelbar nach Invoice auf derselben Grundlinie, und nichts schlug fehl, loggte oder warnte, bis hin zum Drucker des Kunden

Wie der Rückwärtsscan entscheidet, welches Identity-Tm fällt

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices läuft jetzt von jedem Identity-Tm rückwärts und verwirft es nur, wenn der Scan zuerst BT oder ein anderes Identity-Tm erreicht. Das frühere Identity-Tm zählt dabei, egal ob es behalten wurde oder selbst gerade zur Löschung vorgemerkt war, denn in beiden Fällen ließ es beide Matrizen auf der Identity, genau wie BT. Die Regel sortiert jeden Operator, dem sie begegnen kann, in eine von zwei Gruppen:

  • Stoppen und Tm behalten: Td, TD, T*, ein Nicht-Identity-Tm, Tj, TJ, ', ", ET, jeder Operator, den der Parser nicht kennt, oder der Anfang des Streams
  • Weitersteppen und weiterscannen: Operatoren, die Tm und Tlm nie anfassen, etwa Tf, Tc, Farb-Setter, gs, Marked-Content-Operatoren und cm
PDFlibPas RemoveIdentityMatrices läuft von jedem Identity-Tm rückwärts: Tf, Tc, Farb-Setter, gs und cm werden übergangen, während Td, TD, T*, ein Nicht-Identity-Tm, Tj, TJ, ein unbekannter Operator oder ET den Scan stoppt und das Tm behält, und BT bürgt für das Entfernen
Ein früheres Identity-Tm stoppt den Scan ebenfalls, denn ob behalten oder bereits zur Löschung vorgemerkt — es ließ beide Matrizen auf der Identity, und in keinem Fall verschiebt der Optimizer eine Glyphe

Die konservativen Fälle sind Absicht. Ein unbekannter Operator könnte alles Mögliche sein, also weigert sich der Scan, über ihn hinweg zu schließen. ET schließt das Textobjekt, einem Tm danach bürgt also kein BT für die Matrixwerte. Der Scan arbeitet außerdem immer nur an einem Content-Stream, was für Seiten zählt, deren /Contents ein Array ist: Eine Ebene, die mitten in einem Textobjekt beginnt und kein eigenes BT hat, behält ihr Identity-Tm, selbst wenn die vorherige Ebene es redundant gemacht hätte. Das kostet auf seltsamen Dateien ein paar Bytes und verschiebt nie eine Glyphe. Wenn Sie Seitentext auf Instruktionsebene editieren, wie im Character-to-Content-Byte-Mapping-Walkthrough, ist dasselbe geparste TPDFContentProgram-Modell das, was der Optimizer umschreibt

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; // beschädigter Stream: die Bytes in Ruhe lassen
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // liefert die Anzahl der entfernten Instruktionen
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // eine Instruktion pro Zeile
  finally
    Prog.Free;
  end;
end;

// Entfernt: Tm direkt nach BT, die zweite von zwei Identity-Tm in Folge
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Behalten: Tm nach Td, nach Tj, nach einem Nicht-Identity-Tm oder außerhalb von BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Setzt man den Helfer auf den Invoice-Stream aus der Einleitung an, überlebt das Identity-Tm, denn der Rückwärtsscan trifft auf Tj, bevor er BT erreicht. Schieben Sie /F1 12 Tf, 2 Tc und 0 g zwischen BT und das Identity-Tm, fällt es trotzdem, denn keines davon fasst die Textmatrizen an. Eine Sequenz wie BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm verliert exakt einen Operator: Das erste Identity-Tm setzt die Matrix zurück, die Td verschoben hat, und nur das zweite ist redundant

Wann läuft der Peephole-Optimizer überhaupt?

Der Optimizer läuft nur im Kompressionsdurchlauf, innerhalb von TPDFPageTree.Compress, und nur auf Content-Streams, die nicht bereits Flate-komprimiert sind. TPDFlib.SetOptimizeContentStreams(1) ist der Default, derselbe Schalter liegt auch als OptimizeContentStreams-Feld von TPDFlibSaveOptions offen; sowohl CompressContent als auch CompressPage ehren ihn. Ein Stream, dessen /Filter bereits /FlateDecode ist, wird komplett übersprungen — eine vorhandene komprimierte PDF laden und erneut speichern schreibt ihre Operatoren also nicht um. Scheitert das Parsen des Streams, werden die originalen dekodierten Bytes unverändert komprimiert. TPDFlib.NormalizeContentStreams parst Content und emittiert ihn mit kanonischen Abständen und Zahlen, ruft den Optimizer aber nie auf, was es zu einem nützlichen Baseline-Vergleich macht, wenn Sie sehen wollen, wie viel Größendifferenz die Peephole-Regeln beitragen — neben den größeren Gewinnen aus PDF-Dateigrößenoptimierung mit Font-Subsetting

PDFlibPas startet den Peephole-Optimizer nur innerhalb des Kompressionsdurchlaufs zur Speicherzeit: TPDFPageTree.Compress ehrt SetOptimizeContentStreams, ein Stream mit bereits gesetztem /FlateDecode-Filter wird komplett übersprungen, ein unparsebarer Stream wird mit unveränderten Original-Bytes komprimiert, und NormalizeContentStreams ruft den Optimizer nie auf
Übersprungene komprimierte Streams sind der stille Teil: Vorhandene PDF laden, erneut speichern — die Operatoren kommen unangetastet heraus, denn der Optimizer schreibt nur Streams um, die er zuvor selbst dekodiert hat
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Unkomprimierte Streams laufen durch die Peephole-Regeln, dann durch Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Dieselbe Wahl über die gebündelten Save-Options; False meldet ab
    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;

Was hat der alte Regressionstest wirklich garantiert?

Der alte Regressionstest garantierte genau eine Form: Ein Identity-Tm direkt nach BT wird entfernt. Peephole_RemovesIdentityTextMatrix füttert den Optimizer mit BT 1 0 0 1 0 0 Tm (hello) Tj ET und stellt sicher, dass kein Tm übrig bleibt. Ein früheres Release hatte bereits vermerkt, dass das Entfernen eines Identity-Tm unsicher ist, wenn Tlm nicht die Identity ist, und das Verhalten trotzdem beibehalten, weil der Test es „einfroren“ hatte. Aufmerksam gelesen sagt der Test nichts über ein Identity-Tm nach Td oder nach angezeigtem Text; die Abdeckung eines einzelnen Samples als Vertrag der ganzen Regel zu behandeln, war der eigentliche Fehler. Der Fix lässt diesen ursprünglichen Fall weiterlaufen und ergänzt sechs Fälle, die sowohl die entfernbaren als auch die behaltenen Formen festnageln, darunter ein Tm außerhalb jedes Textobjekts und eines, das auf ET folgt

Der Trade-off ist leicht zu akzeptieren, einmal aufgeschrieben. Generatoren, die jedes Textobjekt als BT 1 0 0 1 0 0 Tm ... verpacken, bekommen diesen redundanten Operator weiterhin entfernt — daher kam praktisch die gesamte Ersparnis. Worauf der Optimizer verzichtet, ist das gelegentliche Identity-Tm mitten im Textobjekt, eine Handvoll Bytes pro Seite, bevor Flate sie überhaupt sieht, im Tausch gegen eine Garantie, die der Modul-Header klar ausspricht: Jede Transformation ist ausgabeäquivalent und ändert nie die sichtbare Seite. Ein Größen-Optimizer, der Text verschiebt, ist kein Optimizer, sondern ein Rendering-Bug mit guten Kompressionsraten

Der Content-Stream-Parser, der Peephole-Optimizer und die Kompressionsoptionen zur Speicherzeit, die hier beschrieben werden, kommen alle in der PDF Library for Delphi and C++Builder, die auch NormalizeContentStreams, CompressContent und TPDFlibSaveOptions offenlegt, um zu tunen, wie jedes einzelne Dokument geschrieben wird