Le symptôme est apparu dans un utilitaire de copie de page construit sur le Composant HotPDF : demander la page 1 d'un document de trois pages produisait systématiquement la page 2. La vérification de la logique d'indexation n'a rien révélé d'anormal. L'appel utilisait un index logique basé sur 0, l'arithmétique était correcte, les conditions aux limites (boundary conditions) étaient bonnes. Pourtant, la mauvaise page sortait à chaque fois
Le bogue n'était pas du tout dans le code de copie. Il se trouvait dans la façon dont HotPDF construisait son tableau de pages interne lors du chargement du 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)
Le document déclenchant le bogue avait cette structure d'arborescence de pages :
{ Pages tree root, object 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
C'est exactement ce que faisaient les deux chemins d'analyse internes de HotPDF. Le chemin traditionnel, utilisé pour les fichiers PDF 1.3/1.4, et le chemin moderne, utilisé pour les documents de flux d'objets (object-stream documents) (PDF 1.5+), construisaient chacun PageArr en parcourant les objets indirects dans l'ordre du fichier physique plutôt qu'en suivant la chaîne /Kids
Confirmation de l'hypothèse
Avant de toucher à toute correction, l'inadéquation (mismatch) devait être prouvée plutôt que supposée. L'outil en ligne de commande qpdf rend cela simple :
{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }
qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }
L'extraction individuelle de chaque page et la vérification de la taille des fichiers ont confirmé le mappage : ce que PageArr[0] a produit était le contenu appartenant à la page logique 2, et PageArr[2] contenait la page logique 1. Le décalage circulaire (circular shift) était la preuve irréfutable (smoking gun). Cela expliquait également pourquoi le problème apparaissait sur plusieurs documents sources différents : tout PDF où les objets de page se trouvaient avoir des numéros d'objet inférieurs à une page logique antérieure le déclencherait
Il y a une raison simple pour laquelle les PDF finissent dans cet état. Les sauvegardes incrémentielles (Incremental saves) ajoutent des objets mis à jour avec de nouveaux numéros d'objet, laissant les anciens emplacements dans la table de références croisées pointer nulle part. Les éditeurs qui ajoutent une page de couverture (cover page) l'insèrent avec un numéro d'objet élevé, quelle que soit sa position dans le tableau Kids. 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 les oblige pas à faire autrement
La solution : suivre le tableau Kids
L'approche correcte consiste à construire PageArr en parcourant la chaîne /Kids à partir de la racine du catalogue, et non en analysant les objets indirects. Après que les deux chemins d'analyse aient terminé leur passe initiale, une étape de post-traitement résout l'ordre logique :
procedure THotPDF.ReorderPageArrByPagesTree;
var
PagesObj : THPDFDictionaryObject;
KidsArray : THPDFArrayObject;
NewPageArr: array of THPDFDictArrItem;
I, J, PageIndex, KidsIndex: Integer;
RefObj : THPDFLink;
PageObjNum: Integer;
Found : Boolean;
begin
{ Locate root /Pages dictionary via FRootIndex }
PagesObj := FindPagesRootFromCatalog;
if PagesObj = nil then Exit;
KidsIndex := PagesObj.FindValue('Kids');
if KidsIndex < 0 then Exit;
KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));
SetLength(NewPageArr, KidsArray.Items.Count);
PageIndex := 0;
for I := 0 to KidsArray.Items.Count - 1 do
begin
RefObj := THPDFLink(KidsArray.GetIndexedItem(I));
PageObjNum := RefObj.Value.ObjectNumber;
Found := False;
for J := 0 to Length(PageArr) - 1 do
begin
if PageArr[J].PageLink.ObjectNumber = PageObjNum then
begin
NewPageArr[PageIndex] := PageArr[J];
Inc(PageIndex);
Found := True;
Break;
end;
end;
{ Non-page Kids (intermediate /Pages nodes) produce no match; skip }
end;
if PageIndex > 0 then
begin
SetLength(PageArr, PageIndex);
for I := 0 to PageIndex - 1 do
PageArr[I] := NewPageArr[I];
end;
end;
L'appel intervient à la fin de chaque chemin d'analyse, après que tous les objets aient été catalogués mais avant qu'aucune opération de page ne soit desservie :
{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Modern path (object streams) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
L'étape de réorganisation (reorder step) est O(n * m) où n est le nombre de Kids et m est la longueur actuelle de PageArr, mais pour tout document avec une arborescence de pages plate (toutes les feuilles à la profondeur 1, ce qui couvre la très grande majorité des PDF du monde réel), les deux sont la même valeur et le coût est négligeable. Les arborescences de pages profondément imbriquées (Deeply nested page trees) nécessitent une marche récursive (recursive walk) plutôt que l'approche à un seul niveau (single-level) présentée ici ; l'implémentation de production gère ce cas séparément
Utilisation de CopyPageFromDocument après la correction
Avec ReorderPageArrByPagesTree en place, les indices de page logiques fonctionnent comme prévu. Le niveau supérieur CopyPageFromDocument prend un index logique basé sur 0 et copie la page correcte 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;
{ Copy logical page 0 (first page the user sees) }
Dest.CopyPageFromDocument(Source, 0, 0);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
CopyPageFromDocument interroge en interne l'ordre de l'arborescence des pages plutôt que de s'appuyer sur l'index PageArr brut, il se comporte donc 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. Le bogue 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 entièrement cette classe de problèmes. La correction d'une régression d'ordre des pages nécessite donc 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. Un document qui a déclenché le bogue d'origine doit rester dans la suite de régression en permanence
La page du Composant HotPDF couvre l'API complète pour les opérations de page, y compris CopyPageFromDocument, InsertPagesFromDocument et MovePage