Article technique

Erreurs de contrôle de plage dans les bibliothèques PDF Delphi : Causes profondes

Les erreurs de contrôle de plage (range check errors) dans les bibliothèques PDF Delphi ont la réputation d'être difficiles à cerner car elles ne suivent pas un modèle d'entrée cohérent. Le même document les produit sur une machine et pas sur une autre ; le même chemin de code déclenche l'exception sur un fichier de 3 pages mais s'exécute proprement sur un fichier de 12 pages. Cette incohérence remonte presque toujours à une seule cause profonde : les objets de page PDF ne sont pas stockés dans l'ordre du fichier. Si la bibliothèque construit son tableau de pages interne en analysant les objets séquentiellement plutôt qu'en parcourant l'arborescence des pages déclarée par le catalogue, elle construit un index dont la plage valide ne correspond pas à ce que les appelants attendent, et la vérification de plage intercepte cette inadéquation au pire moment possible

Comment fonctionne le contrôle de plage dans Delphi

Avec la directive de compilation {$R+} active (par défaut en configuration Debug), la RTL Delphi valide chaque index de tableau, indice de chaîne et affectation énumérée à l'exécution. Un accès hors limites déclenche une erreur ERangeError plutôt que de lire silencieusement la mémoire adjacente. Ce comportement est précieux : il fait remonter les bogues latents très tôt au lieu de les laisser corrompre une structure de données qui n'échouera qu'une centaine de lignes plus loin. La partie frustrante est que l'exception se déclenche au site d'accès, et non au point où l'index a été calculé de manière incorrecte. Lorsque la pile d'appels (call stack) affiche une méthode profondément imbriquée dans une unité PDF, la véritable erreur se situe généralement plusieurs trames (frames) en arrière

Les conditions booléennes composées aggravent les choses. Delphi évalue les expressions and de gauche à droite avec une sémantique de court-circuit, mais le court-circuit n'ignore l'évaluation que lorsque le côté gauche est False. Une expression comme :

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

semble sûre, mais elle ne protège contre un index hors limites que si FDocStarted est True et DestIndex est positif ou nul. La vérification DestIndex < Length(PageArr) ne fait rien lorsque DestIndex est négatif, car comparer un entier négatif à une longueur non négative renvoie True en arithmétique signée et l'accès au tableau suivant déclenche toujours l'erreur de plage. Déplacer la vérification des limites à la position la plus externe est la correction appropriée :

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

C'est la correction mécanique. Elle arrête le plantage. Elle n'explique pas pourquoi DestIndex a reçu une valeur en dehors de la plage valide en premier lieu

La véritable cause : l'ordre des objets par rapport à l'ordre des pages

La norme ISO 32000-1 §7.7.3 définit l'arborescence des pages comme une arborescence de nœuds Pages dont les tableaux Kids listent les objets de page dans l'ordre d'affichage. Le fichier stocke ces objets aux décalages (offsets) que le rédacteur (writer) a choisi ; l'objet numéro 20 peut physiquement précéder l'objet numéro 3 dans le flux d'octets. Une bibliothèque qui construit sa liste de pages en itérant la table de références croisées (cross-reference table) dans l'ordre des numéros d'objets plutôt qu'en suivant la chaîne Kids produira une séquence qui diverge de ce que l'utilisateur attend. Sur les documents où le générateur a par hasard écrit les pages dans l'ordre, tout fonctionne. Sur les documents où ce n'est pas le cas, l'écart entre la numérotation des pages de la bibliothèque et la numérotation des pages de l'appelant produit des index qui tombent en dehors de PageArr

La bonne approche consiste à partir du catalogue, à résoudre la référence indirecte /Pages et à parcourir le tableau Kids de manière récursive. Pour un document plat sans nœuds Pages intermédiaires, le parcours est simple :

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

Après cette exécution, PageArr[0] est la première page qu'une visionneuse afficherait, indépendamment de l'endroit où se trouve cet objet dans le flux d'octets. Les index passés par les appelants qui supposent l'ordre d'affichage sont maintenant correctement mis en correspondance, et les erreurs de plage s'arrêtent

Les solutions de contournement codées en dur aggravent le problème

Dans les bases de code où la cause profonde n'a jamais été identifiée, il est courant de trouver des correctifs heuristiques : échanger la première et la dernière page si le nombre total est égal à 3, faire pivoter l'index pour les documents d'un générateur spécifique, appliquer un décalage lorsque le premier numéro d'objet dépasse un certain seuil. Chacun de ces correctifs correspond exactement à l'ensemble des fichiers de test qui étaient sous la main lorsqu'il a été écrit. Ajoutez une source PDF différente et l'un des correctifs se déclenche au mauvais moment, produisant un index qui est maintenant doublement faux : faux parce qu'il a été calculé à partir d'un tableau en désordre, et faux à nouveau parce qu'un mappage inapplicable a été appliqué par-dessus. Le vérificateur de plage l'intercepte quelque part en aval et la trace de la pile ne pointe nulle part d'utile

La seule voie productive consiste à supprimer chaque mappage heuristique et à remplacer la construction du tableau de pages par un parcours d'arborescence approprié. Une fois les index corrects par construction, aucun correctif n'est nécessaire et le vérificateur de plage devient un atout plutôt qu'un obstacle

Si vous maintenez une bibliothèque qui présente ce modèle, activez temporairement la vérification de plage (range checking) dans un build Release et exécutez-le sur un corpus varié de PDF : documents produits par Word, par LaTeX, par le firmware de scanners, par des utilitaires de division (split) PDF vers PDF. Les fichiers qui déclenchent des exceptions sont ceux dont l'ordre des objets de page diverge de l'ordre de parcours supposé par votre code. Chacun d'eux est un point de données, et non un bogue distinct

Pour un nouveau code qui appelle une bibliothèque PDF Delphi, le conseil pratique est de traiter le nombre de pages de la bibliothèque comme faisant autorité et de ne jamais passer un index dérivé d'une opération arithmétique sur des données externes sans avoir d'abord confirmé qu'il se situe dans la plage 0..PageCount - 1. Le composant HotPDF expose le nombre de pages résolu via THotPDF.PageCount après BeginDoc ou après le chargement d'un document ; cette valeur reflète toujours le parcours de l'arborescence des pages et peut être utilisée en toute sécurité comme limite supérieure pour toute arithmétique d'index