Article technique

Le moteur de rendu PDF ne dessine rien : quatre bugs silencieux

Un moteur de rendu PDF qui ne dessine rien n'a généralement aucun bogue dans son code de dessin. Dans le composant HotPDF pour Delphi et C++Builder, quatre défauts distincts faisaient rendre des pages vides pendant que chaque ligne de journal restait propre : des opérandes de noms portant une barre oblique de tête, une concaténation cm inversée, et un index de jeton qui lisait zéro. Aucun d'eux ne levait d'exception. Aucun d'eux ne journalisait quoi que ce soit. Le flux de contenu se tokenisait correctement, le répartiteur d'opérateurs reconnaissait chaque opérateur, l'XObject image se décodait en un bitmap valide, et pourtant la page ressortait vide. Cette combinaison — un pipeline qui rapporte un succès à chaque étape et ne produit rien de visible — est la signature d'une recherche ou d'un index qui échoue silencieusement plutôt que de lever une erreur. Ceci est un post-mortem d'une telle famille de bogues, et de la discipline de test qui lui a permis de survivre pendant 38 versions

Pourquoi un moteur de rendu PDF ne dessine-t-il absolument rien ?

Parce qu'une recherche de ressource échouée dans un moteur de rendu PDF est indiscernable d'une page vide. Les opérandes de noms du flux de contenu et les clés du dictionnaire de ressources sont deux espaces de chaînes différents, et HotPDF les comparait entre eux sans normalisation. Le tokeniseur lit /Im0 et conserve la barre oblique, car c'est ce qu'est le jeton ; le dictionnaire chargé /Resources /XObject stocke la clé sous la forme Im0, car l'analyseur retire le délimiteur lorsqu'il construit les clés du dictionnaire. Chaque FindValue contre un nom d'opérande retournait donc -1. Le rayon de l'explosion dépassait largement les images. ISO 32000-1 §8.9 couvre Do, §8.4 couvre gs et sa recherche /ExtGState, §8.6 couvre cs et CS, et §8.7.4.3 couvre sh. Les cinq opérateurs indexaient leur sous-dictionnaire de ressources par l'opérande brut, si bien que les cinq échouaient. Les espaces colorimétriques nommés retombaient sur DeviceGray, ce qui transforme 1 scn en encre blanche sur une page blanche. Les XObjects image n'étaient jamais peints du tout — le chemin des images bitmap n'avait, en pratique, jamais fonctionné depuis le jour de sa mise en service. La correction est un utilitaire au niveau de l'unité appliqué à chaque recherche indexée par opérande, ce qui est le seul moyen d'empêcher la convention de dériver à nouveau

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

Un second manquement, apparenté, se trouvait une couche plus bas. Le moteur de rendu ne disposait de résolveurs typés que pour les flux et les dictionnaires, si bien qu'une référence indirecte pointant vers un objet tableau de premier niveau — le classique /CS0 5 0 R avec [/Separation ...] à l'autre bout — se résolvait à nil dans les deux cas et retombait sur le lien non résolu. Ajouter un résolveur d'objet générique a corrigé d'un seul coup les espaces colorimétriques nommés et les tableaux de fonctions. Si vous câblez des dictionnaires de dégradé, la même discipline de résolution s'applique au chemin de rendu des dégradés axiaux et radiaux, où l'entrée /Function est très souvent indirecte

L'opérateur cm et une concaténation écrite à l'envers

Le deuxième défaut plaçait les images à environ cent mille pixels hors de la page, ce qui ressemble exactement à ne pas les dessiner du tout. ISO 32000-1 §8.3.4 définit les transformations PDF avec des vecteurs ligne, et l'opérateur cm concatène sa matrice opérande M sur la matrice de transformation courante sous la forme M × CTM — M prend effet en premier, la CTM existante ensuite. HotPDF compose les matrices via HPDFMatMul(A, B), qui applique B avant A. L'appel correct doit donc passer l'ancienne CTM comme A. Le code livré passait la matrice opérande comme A, produisant CTM × M

Un ordre inversé est inoffensif pour un cm isolé et catastrophique pour l'idiome standard en deux étapes. Placez une image avec 1 0 0 1 x y cm suivi de w 0 0 h 0 0 cm, et la cascade correcte met à l'échelle le carré unité par (w, h) puis le translate par (x, y). Sous la cascade inversée, la translation entre en premier et l'échelle la multiplie, si bien qu'une image nominalement à (60, 400) mise à l'échelle à 200 sur 120 atterrit à (12000, 48000). Le test de découpage (clip) en tête du blit la rejette, le blit est sauté, et rien nulle part ne signale de problème

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

Ce qui rend ce cas instructif, c'est que le même fichier source contenait déjà le bon ordre. L'entrée /Matrix d'un Form XObject portait la même composition inversée, mais le chemin des glyphes Type 3 et le chemin des contours de glyphes incorporés avaient tous deux bien fait les choses dès le départ, car le placement des glyphes s'effondre visiblement vers l'origine quand on l'inverse, et quelqu'un avait déjà dû le corriger. Deux conventions coexistaient dans une seule unité pendant trois douzaines de versions, chacune correcte dans sa propre fonction, et aucun relecteur ne l'a remarqué car aucun des deux points d'appel n'avait l'air faux isolément

Que se passe-t-il quand un index de jeton est décalé de un ?

On obtient douze opérateurs syntaxiquement traités et sémantiquement morts. L'accesseur d'opérande dans le moteur de rendu est NumAt(Back), qui lit Tokens[OpIndex - Back], et OpIndex est l'index du jeton opérateur lui-même. Un opérateur à un seul opérande trouve donc son nombre en arrière de 1. Douze d'entre eux étaient écrits comme NumAt(0), qui lit le jeton opérateur, échoue au contrôle de type ctOperandNumber, et retourne la valeur par défaut zéro. La liste est Tc, Tw, Tz, TL, Ts et Tr parmi les opérateurs d'état de texte d'ISO 32000-1 §9.3, plus w, J, j, M, ri et i parmi les opérateurs d'état graphique du §8.4.3. L'espacement des caractères et des mots devenait un no-op, la mise à l'échelle horizontale ne s'appliquait jamais, l'interlignage restait à zéro si bien que T* n'avançait jamais d'une ligne, l'élévation de texte ne faisait rien, le mode de rendu était toujours remplissage, et chaque trait de chaque document ressortait comme une ligne fine d'un pixel quelle que soit la largeur de ligne déclarée. Les opérateurs à opérandes multiples tels que m, rg et Tm utilisaient NumAt(1..6) et étaient tous corrects, si bien qu'un relecteur parcourant la fonction voyait un mur d'arithmétique d'index plausible avec douze entrées erronées incrustées dedans

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

Pourquoi la suite de tests est-elle restée au vert pendant 38 versions ?

Parce que les assertions étaient trop faibles pour distinguer une page rendue d'une page partiellement rendue. Les tests de fumée de rendu vérifiaient des choses comme le bitmap de sortie n'est pas entièrement noir, ou la page n'est pas vide, ou le condensé de l'image est non nul. Chacune de ces conditions est vraie quand le texte se rend et que les images ne se rendent pas. Le texte se dessinait correctement, si bien que le tampon d'image n'était jamais uniforme, le condensé n'était jamais nul, et la suite rapportait un succès alors que tout le pipeline d'images était, en pratique, du code mort. Les assertions faibles sont séduisantes pour le graphisme précisément parce que les assertions fortes ont l'air fragiles. Personne ne veut d'un test qui casse quand un bord d'anticrénelage se décale d'un pixel, si bien que le repli naturel consiste à vérifier quelque chose qu'aucun changement raisonnable ne pourrait violer — et ce repli vous fait atterrir sur des prédicats qu'aucun changement déraisonnable ne peut violer non plus. Un test d'espace colorimétrique de séparation vérifiait que la sortie était distinguable du noir ; du gris sur blanc le validait, tout comme du blanc sur blanc. Le test ne mesurait pas si la bonne couleur avait été peinte. Il mesurait si quoi que ce soit s'était produit sur le canevas

Comment écrire une assertion de rendu qui échoue réellement ?

Comptez les pixels de la couleur attendue, en la quantité attendue, et laissez la position et la taille se déduire du compte. La discipline de remplacement est un PDF minimal construit à la main, un fait visuel par fichier, et une assertion sur le nombre de pixels tombant dans une tolérance autour d'un triplet RVB précis. Une image de 200 sur 120 en rouge pur placée à un décalage connu doit produire environ 24000 pixels rouges. Si la recherche de ressource échoue, le compte est 0. Si la cascade cm est inversée, le compte est 0. Si l'image se rend dans le mauvais espace colorimétrique, le compte est 0. Un seul nombre attrape les trois, et la bande de tolérance absorbe le bruit d'anticrénelage qui faisait initialement hésiter à comparer exactement

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Quatre tests de fumée ont été réécrits de cette façon — une transformation de teinte Type 4, un placement d'image via Do, un cas de visibilité de contenu optionnel et un mode de trait Tr — et à eux quatre ils ont exposé toute la famille de bogues. C'est la vraie leçon, et elle se généralise au-delà de cette base de code : dans un pipeline de rendu, l'assertion doit nommer la couleur. Tout ce qui est plus mou est une vérification que le moteur de rendu s'est exécuté, pas une vérification qu'il a dessiné. Si vous construisez votre propre harnais page-vers-bitmap, le tour d'horizon de la rastérisation de page est l'endroit naturel où greffer un utilitaire de comptage de pixels sur votre premier test de non-régression

Limites honnêtes

Deux limites méritent d'être énoncées clairement. Les modes de rendu de découpage de texte 4 à 7 sont dessinés selon leur mode de remplissage ou de trait de base, car le moteur de rendu ne modélise pas les chemins de découpage accumulés issus des contours de glyphes ; les documents qui s'appuient sur un découpage en forme de texte rendront le texte plutôt que l'illustration découpée en dessous. Et la discipline de comptage de pixels décrite ici est une technique de test de fumée, pas une suite de conformité — elle prouve qu'un fait visuel précis a atteint le tampon d'image, ce qui est une barre bien plus basse que de prouver que la sortie correspond à un rastériseur de référence. C'est néanmoins exactement la barre que ces quatre bogues n'ont pas réussi à franchir pendant trois ans de versions

Le moteur de rendu abordé ici fait partie du composant HotPDF standard pour Delphi et C++Builder ; la page produit propose la référence API complète de rendu de page, y compris le cache de bitmap et les points d'entrée de préchargement en arrière-plan