Article technique

Tm identité dans les flux PDF : suppression peephole sûre

PDF Library for Delphi retire un opérateur de matrice de texte identité, 1 0 0 1 0 0 Tm, pendant son optimisation peephole des flux de contenu à la sauvegarde, uniquement quand la matrice de texte et la matrice de ligne de texte valent déjà l'identité : juste après BT, ou juste après un Tm identité antérieur. Un cm identité, lui, est toujours jeté, parce que cm multiplie la CTM tandis que Tm remplace carrément les deux matrices de texte. Depuis la v3.539.28, tout autre Tm identité reste dans le flux

Le bug corrigé ici est du genre discret. Un générateur de rapports émet BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET, en comptant sur le Tm identité pour renvoyer la seconde chaîne à l'origine de l'espace texte avant d'appliquer sa propre logique de positionnement. L'ancien optimiseur voyait six nombres qui épellent la matrice identité, décidait que l'opérateur ne pouvait rien changer, et le supprimait. Rien n'échouait, rien ne journalisait d'avertissement, et la page sauvegardée dessinait « Total » immédiatement après « Invoice » sur la même ligne de base, exactement la classe de défaut que personne ne remarque avant qu'un client n'imprime le PDF

Pourquoi 1 0 0 1 0 0 Tm n'est-il pas toujours un no-op ?

Un Tm identité n'est un no-op que s'il va remplacer deux matrices qui portent déjà l'identité, et c'est une propriété des opérateurs qui le précèdent, pas de ses propres opérandes. ISO 32000-1 §9.4.1 dit que BT initialise à la fois la matrice de texte (Tm) et la matrice de ligne de texte (Tlm) à l'identité, et §9.4.2 définit Tm comme le fait de mettre les deux aux valeurs données, pas de les concaténer. Comparez avec cm (§8.4.4), qui multiplie à droite la matrice de transformation courante : multiplier par l'identité laisse toute CTM inchangée, donc 1 0 0 1 0 0 cm peut être supprimé n'importe où. Dans un objet texte, le tableau est différent. Td, TD, T* et un Tm non identité déplacent tous Tlm, et chaque opérateur d'affichage de texte (Tj, TJ, ', ") avance Tm de la largeur des glyphes qu'il a peints. Après n'importe lequel d'entre eux, un Tm identité est une vraie remise à zéro vers l'origine. Si vous avez déjà tracé des positions de texte à la main avec le suivi d'état CTM et matrice de texte des flux de contenu, c'est la même distinction entre concaténer un état et le remplacer

PDFlibPas traite 1 0 0 1 0 0 cm et 1 0 0 1 0 0 Tm différemment : cm multiplie à droite la CTM et n'a nul effet n'importe où, tandis que Tm remplace carrément Tm et Tlm, et chaque Tj avance Tm de la largeur peinte, si bien qu'un Tm identité après du texte affiché est une vraie remise à zéro
Le générateur de rapports comptait sur cette remise à zéro : supprimer le Tm identité peignait Total immédiatement après Invoice sur la même ligne de base, et rien n'a échoué, journalisé ou averti jusqu'à l'imprimante du client

Comment le scan arrière décide quel Tm identité jeter

TPDFContentPeepholeOptimizer.RemoveIdentityMatrices marche maintenant à reculons depuis chaque Tm identité et ne le jette que si le scan atteint BT ou un autre Tm identité en premier. Le Tm identité antérieur compte, qu'il soit gardé ou qu'il vienne lui-même d'être programmé pour suppression, car dans les deux cas il a laissé les deux matrices à l'identité, exactement comme BT. La règle range chaque opérateur qu'elle peut rencontrer dans l'un de deux groupes :

  • S'arrêter et garder le Tm : Td, TD, T*, un Tm non identité, Tj, TJ, ', ", ET, tout opérateur que le parseur ne reconnaît pas, ou le début du flux
  • Passer outre et continuer à scanner : les opérateurs qui ne touchent jamais Tm ni Tlm, tels que Tf, Tc, les réglages de couleur, gs, les opérateurs de contenu marqué et cm
PDFlibPas RemoveIdentityMatrices remonte à reculons depuis chaque Tm identité : Tf, Tc, les réglages de couleur, gs et cm sont passés outre, tandis que Td, TD, T*, un Tm non identité, Tj, TJ, un opérateur inconnu ou ET arrête le scan et garde le Tm, et BT plaide pour le jeter
Un Tm identité antérieur arrête aussi le scan, car gardé ou déjà programmé pour suppression il a laissé les deux matrices à l'identité — quoi qu'il en soit, l'optimiseur ne déplace jamais un glyphe

Les cas conservateurs sont délibérés. Un opérateur inconnu pourrait être n'importe quoi, donc le scan refuse de raisonner au-delà. ET ferme l'objet texte, donc un Tm après lui n'a aucun BT pour plaider les valeurs de matrice. Le scan travaille aussi sur un flux de contenu à la fois, ce qui compte pour les pages dont /Contents est un tableau : une couche qui démarre au milieu d'un objet texte, sans BT à elle, garde son Tm identité même si la couche précédente l'aurait rendu redondant. Ça coûte quelques octets sur des fichiers bizarres et ne déplace jamais un glyphe. Si vous éditez du texte de page au niveau des instructions, comme dans le parcours du mapping caractère-vers-octet de contenu, c'est le même modèle TPDFContentProgram parsé que l'optimiseur réécrit

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; // flux endommagé : on laisse les octets tranquilles
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // renvoie le nombre d'instructions retirées
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // une instruction par ligne
  finally
    Prog.Free;
  end;
end;

// Retiré : Tm juste après BT, le second de deux Tm identité d'affilée
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// Gardé : Tm après Td, après Tj, après un Tm non identité, ou hors BT
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

Faites tourner le helper sur le flux de la facture du début et le Tm identité survit, parce que le scan arrière bute sur Tj avant d'atteindre BT. Placez /F1 12 Tf, 2 Tc et 0 g entre BT et le Tm identité et il part quand même, car rien de tout cela ne touche les matrices de texte. Une séquence telle que BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm perd exactement un opérateur : le premier Tm identité remet à zéro la matrice que Td avait déplacée, et seul le second est redondant

Quand l'optimiseur peephole tourne-t-il réellement ?

L'optimiseur ne tourne que pendant la passe de compression, dans TPDFPageTree.Compress, et seulement sur les flux de contenu qui ne sont pas déjà compressés en Flate. TPDFlib.SetOptimizeContentStreams(1) est la valeur par défaut, et le même interrupteur est exposé comme champ OptimizeContentStreams de TPDFlibSaveOptions ; CompressContent et CompressPage l'honorent tous les deux. Un flux dont le /Filter est déjà /FlateDecode est carrément sauté, donc charger un PDF compressé existant et le sauvegarder à nouveau ne réécrit pas ses opérateurs. Si le flux refuse de se parser, les octets décodés d'origine sont compressés tels quels. TPDFlib.NormalizeContentStreams parse et réémet le contenu avec espacement et nombres canoniques mais n'appelle jamais l'optimiseur, ce qui en fait une base utile quand vous voulez voir combien les règles peephole contribuent au gain de taille, à côté des gains plus gros couverts dans l'optimisation de taille des fichiers PDF avec sous-ensembles de polices

PDFlibPas ne fait tourner l'optimiseur peephole que dans la passe de compression à la sauvegarde : TPDFPageTree.Compress honore SetOptimizeContentStreams, un flux déjà filtré en /FlateDecode est carrément sauté, un flux imparsable est compressé avec ses octets d'origine inchangés, et NormalizeContentStreams n'appelle jamais l'optimiseur du tout
Les flux compressés sautés sont la part silencieuse : chargez un PDF existant, sauvegardez-le à nouveau, et ses opérateurs ressortent intacts parce que l'optimiseur ne réécrit que les flux qu'il a décodés lui-même
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // Les flux non compressés passent par les règles peephole, puis Flate
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // Le même choix via les options de sauvegarde groupées ; False désactive
    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;

Que garantissait réellement l'ancien test de régression ?

L'ancien test de régression ne garantissait qu'une seule forme : un Tm identité juste après BT est retiré. Peephole_RemovesIdentityTextMatrix passe BT 1 0 0 1 0 0 Tm (hello) Tj ET à l'optimiseur et affirme qu'il ne reste aucun Tm. Une version antérieure avait déjà noté que jeter un Tm identité n'est pas sûr quand Tlm n'est pas l'identité, puis avait gardé le comportement quand même parce que le test le « verrouillait ». Relu avec attention, le test ne dit rien d'un Tm identité après Td ou après du texte affiché ; traiter la couverture d'un échantillon comme le contrat de toute la règle était la vraie erreur. La correction garde ce cas d'origine au vert et ajoute six cas qui épinglent à la fois les formes amovibles et les formes gardées, dont un Tm hors de tout objet texte et un qui suit ET

Le compromis est facile à accepter une fois écrit. Les générateurs qui emballent chaque objet texte en BT 1 0 0 1 0 0 Tm ... se font toujours retirer cet opérateur redondant, et c'est de là que venait presque tout le gain. Ce à quoi l'optimiseur renonce, c'est l'occasionnel Tm identité au milieu d'un objet texte, une poignée d'octets par page avant même que Flate les voie, en échange d'une garantie que l'en-tête du module énonce clairement : chaque transformation est équivalente en sortie et ne change jamais la page visible. Un optimiseur de taille qui déplace du texte n'est pas un optimiseur, c'est un bug de rendu avec de bons ratios de compression

Le parseur de flux de contenu, l'optimiseur peephole et les options de compression à la sauvegarde décrits ici sont livrés dans la PDF Library for Delphi and C++Builder, qui expose aussi NormalizeContentStreams, CompressContent et TPDFlibSaveOptions pour régler l'écriture de chaque document