Article technique

Ordre des pages PDF : structure physique ou logique

Le symptôme classique ressemble à un off-by-one : une routine de copie de pages demande la page 1 d'un document de trois pages et obtient systématiquement la page 2. La logique d'indexation semble correcte. L'appel utilise un index logique basé sur 0, l'arithmétique est correcte, les conditions aux limites (boundary conditions) sont bonnes. Pourtant, la mauvaise page sort à chaque fois

La cause n'est pas du tout dans le code de copie. Elle se trouve dans la façon dont le loader construit son tableau de pages, et c'est l'un des pièges les plus faciles à attraper dans l'analyse PDF

Concept de l'ordre des pages PDF : différence entre l'ordre physique et l'ordre logique
Ordre des pages PDF : le tableau /Kids dans l'arborescence Pages définit la séquence logique, indépendamment de la façon dont les objets sont numérotés ou stockés dans le fichier

Deux classements (orderings), une source de confusion

Un fichier PDF est une collection d'objets indirects, chacun identifié par un numéro d'objet. La structure du fichier n'impose aucune obligation à ces numéros de refléter l'ordre de lecture. L'objet 1 peut contenir la page 2 ; l'objet 20 peut contenir la page 1. Ce qui définit réellement l'ordre de lecture est l'arborescence des pages (page tree) : une hiérarchie de dictionnaires /Pages dont les tableaux /Kids répertorient les références de page dans la séquence qu'une visionneuse doit afficher (ISO 32000-1 §7.7.3)

Voici une arborescence de pages qui expose le piège :

{ Racine de l'arbre Pages, objet 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

Le fichier s'est avéré répertorier l'objet 1 et l'objet 4 avant l'objet 20 dans le flux d'octets. Tout analyseur (parser) qui itérait à travers des objets indirects dans l'ordre du fichier et les estampillait (stamped them) dans un PageArr au fur et à mesure qu'il trouvait des dictionnaires de type page finirait avec l'objet 1 à l'index 0, l'objet 4 à l'index 1, et l'objet 20 à l'index 2. La page logique 1 se trouve à PageArr[2]. Demander l'index de page 0 récupère la page logique 2 à la place

Diagramme contrastant l'ordre de scan d'objets avec la logique du tableau Kids quand une bibliothèque PDF Delphi construit son tableau de pages
Le chargement par balayage d'objets estampille PageArr par position de fichier tandis que /Kids seul définit l'ordre des pages du PDF, décalant chaque requête indexée d'un emplacement

Confirmation de l'hypothèse

Avant de changer le moindre code, prouvez l'inadéquation (mismatch) plutôt que de la supposer. L'outil en ligne de commande qpdf rend cela simple :

{ shell }
qpdf --show-pages input.pdf
{ La sortie révèle l'ordre des Kids : 20 0 R, puis 1 0 R, puis 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Affiche le dictionnaire Pages avec /Kids dans l'ordre de lecture }

L'extraction individuelle de chaque page et la comparaison des tailles de fichiers confirment le mappage : ce que le PageArr[0] d'un balayage d'objets produit est le contenu appartenant à la page logique 2, et PageArr[2] contient la page logique 1. Le décalage circulaire (circular shift) est la preuve irréfutable (smoking gun). Cela explique aussi pourquoi le symptôme apparaît sur de nombreux documents sources différents : tout PDF où les objets de page se trouvent avoir des numéros d'objet inférieurs à une page logique antérieure le déclenche

Chronologie de diagnostic montrant comment qpdf révèle que l'ordre des pages /Kids diverge de l'ordre physique des objets derrière une routine de copie de pages
La vérification transforme le soupçon en preuve : la séquence de l'arbre de pages diverge de l'ordre physique des objets sur ce document

Il y a une raison simple pour laquelle les PDF finissent dans cet état. Une mise à jour incrémentielle conserve les numéros d'objet des objets qu'elle modifie, mais tout ce qu'elle ajoute, une page de couverture (cover page) insérée au début par exemple, reçoit un nouveau numéro supérieur à tout ce qui est déjà dans le fichier, tandis que sa référence va au début du tableau Kids. Les outils qui fusionnent ou réorganisent des documents renumérotent les objets dans l'ordre qui leur convient. Certains générateurs écrivent simplement des pages dans un ordre pratique pour le streaming de contenu plutôt que pour la séquence de pages logique. Le format PDF ne leur demande pas de faire autrement

La bonne approche : suivre le tableau Kids

Construisez la liste des pages en parcourant la chaîne /Kids à partir de la racine du catalogue, et non en balayant les objets indirects. Le parcours doit être récursif, car un document volumineux répartit généralement ses pages entre des nœuds /Pages intermédiaires, et il doit se prémunir contre un arbre malformé qui pointerait vers l'un de ses propres ancêtres. Dans le croquis ci-dessous, TPdfDict et TPdfArray représentent les types dictionnaire et tableau utilisés par votre analyseur :

type
  TPageList = array of TPdfDict;

procedure CollectPages(Node: TPdfDict; var Pages: TPageList;
  Depth: Integer = 0);
var
  Kids: TPdfArray;
  Child: TPdfDict;
  I: Integer;
begin
  if Depth > 64 then  // un cycle ou un arbre pathologiquement profond
    raise EPdfError.Create('Page tree too deep or cyclic');
  Kids := Node.GetArray('Kids');
  if Kids = nil then
    Exit;
  for I := 0 to Kids.Count - 1 do
  begin
    Child := Kids.ResolveDict(I);  // suit la référence indirecte
    if Child = nil then
      Continue;
    if Child.GetName('Type') = 'Pages' then
      CollectPages(Child, Pages, Depth + 1)  // nœud intermédiaire
    else
    begin
      SetLength(Pages, Length(Pages) + 1);   // feuille : page suivante dans l'ordre d'affichage
      Pages[High(Pages)] := Child;
    end;
  end;
end;

// Utilisation : partir de l'entrée /Pages du catalogue
SetLength(Pages, 0);
CollectPages(Catalog.ResolveDict('Pages'), Pages);
if Length(Pages) <> Catalog.ResolveDict('Pages').GetInteger('Count') then
  LogWarning('Page tree /Count disagrees with the pages actually found');

Deux détails sont délibérés. Une feuille sans entrée /Type est quand même traitée comme une page, car certains rédacteurs l'omettent et les visionneuses ouvrent ces fichiers malgré tout. Et la comparaison finale du /Count ne coûte rien mais attrape les arbres tronqués ou édités à la main avant qu'un appelant n'y indexe

Si vous chargez des documents avec HotPDF, cet ordre est déjà résolu au chargement du document : les indices de pages suivent l'ordre d'affichage de l'arborescence des pages, nœuds /Pages imbriqués inclus, si bien que l'indice 0 est toujours la première page qu'une visionneuse affiche. Le parcours ci-dessus importe lorsque votre propre code analyse directement la structure PDF

Copier par index logique avec CopyPageFromDocument

Comme HotPDF résout l'ordre des pages au chargement, les indices de pages logiques signifient ce que les appelants attendent. Le CopyPageFromDocument de haut niveau prend un index logique basé sur 0 et copie la bonne page dans le document de destination :

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copie la page logique 0 (la première page que voit l'utilisateur) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Parce que l'indice est un indice d'ordre d'affichage, CopyPageFromDocument se comporte correctement même avec les documents où l'ordre physique et logique divergent. Pour les opérations par lots (batch operations), InsertPagesFromDocument accepte un tableau d'indices logiques et les copie en un seul passage

Ce que cela révèle sur l'analyse (parsing) PDF

La spécification PDF est explicite : l'ordre des pages logiques est défini par le tableau /Kids de l'arborescence des pages, et non par des numéros d'objet ou des décalages d'octets (ISO 32000-1 §7.7.3.2). Tout analyseur (parser) qui utilise un classement (ordering) différent comme raccourci produira des résultats corrects sur la majorité des documents qu'il voit, car la plupart des générateurs écrivent des pages dans l'ordre naturel et attribuent des numéros d'objet séquentiels. L'erreur se cache jusqu'à ce que quelqu'un charge un PDF qui a été édité de manière incrémentielle, réorganisé par un autre outil ou généré par un logiciel qui a choisi une disposition (layout) différente

Les tests effectués uniquement sur des PDF auto-générés ne permettent pas de détecter cette classe de problèmes. La gestion de l'ordre des pages a besoin d'un corpus de documents provenant de sources variées : sauvegardes incrémentielles, documents numérisés avec pages de couverture (cover pages) insérées, PDF produits par des outils qui linéarisent ou optimisent différemment le graphe d'objets. Tout document dont l'ordre physique et logique diverge mérite une place permanente dans ce corpus

La page du Composant HotPDF couvre l'API complète pour les opérations de page, y compris CopyPageFromDocument, InsertPagesFromDocument et MovePage