Article technique

Comment fonctionne un PDF : du startxref aux pixels

Ouvrez la même facture PDF sur Windows et sur un téléphone, et les retours à la ligne, les polices et le logo atterrissent aux mêmes endroits. Un fichier PDF est un ensemble d'objets numérotés plus un index de décalages d'octets, et un lecteur tel que le moteur PDFium à l'intérieur de PDFium Component l'ouvre depuis la fin : startxref, table xref, catalog, arbre de pages, flux de contenu, puis pixels

Alors, qu'est-ce qu'un fichier PDF, et pourquoi a-t-il l'air identique partout ? Les documents Word et RTF stockent le texte et les styles et laissent la mise en page à la machine qui les ouvre, si bien qu'une police manquante ou un pilote d'imprimante différent refait couler les pages. Le PDF stocke à la place le résultat fini. Chaque glyphe et chaque ligne a une position absolue dans un espace de coordonnées mesuré en points, les programmes de police voyagent à l'intérieur du fichier, et les images portent leurs propres données de couleur et filtres de compression. Rien n'est mis en page au moment de la visualisation, voilà pourquoi n'importe quel moteur de rendu conforme sur n'importe quel système d'exploitation peint la même page. Le format a commencé comme PDF 1.0 avec Acrobat en 1993, bâti sur le modèle d'imagerie de PostScript, et chaque révision a ajouté une partie de la machinerie décrite ci-dessous :

  • PDF 1.1 à 1.4 ont apporté le chiffrement, les formulaires interactifs, les signatures numériques avec structure logique, et la transparence
  • PDF 1.5 a ajouté les flux d'objets et les flux de références croisées ; PDF 1.6 a ajouté le chiffrement AES
  • PDF 1.7 est devenu ISO 32000-1:2008, et PDF 2.0 est devenu ISO 32000-2:2017, révisé en 2020
  • PDF/A (ISO 19005), PDF/X (ISO 15930) et PDF/UA (ISO 14289) restreignent cette base pour l'archivage, la production imprimée et l'accessibilité

Que se passe-t-il quand un lecteur ouvre un fichier PDF ?

Ouvrir un PDF est une chaîne de consultations qui démarre aux derniers octets du fichier et se termine en un bitmap. Presque chaque maillon de cette chaîne est un dictionnaire, l'un des types d'objets de base de l'ISO 32000-1 §7.3, et les dictionnaires se désignent entre eux par des références indirectes telles que 7 0 R (objet 7, génération 0). Le fichier se comporte donc comme un graphe d'objets plutôt que comme une séquence de pages. L'en-tête, le corps, la table de références croisées et le trailer sont disposés dans la vue d'ensemble de la structure d'un fichier PDF, et un PDF minimal annoté ligne par ligne montre à quoi ressemblent obj, endobj et stream dans un éditeur de texte. Voici l'ordre que tout lecteur suit

  1. Se positionner à la fin du fichier et lire le décalage d'octets écrit après le mot-clé startxref
  2. Charger les données de références croisées à ce décalage, qui mappe chaque numéro d'objet à une position dans le fichier
  3. Lire le trailer et suivre /Root vers le catalog du document
  4. Suivre l'entrée /Pages du catalog à travers l'arbre de pages jusqu'à l'objet page demandé
  5. Décoder le flux de contenu de la page et interpréter ses opérateurs dans l'ordre
  6. Résoudre les polices, images et Form XObjects que ces opérateurs nomment au travers de /Resources
  7. Rastériser les chemins, contours de glyphes et images en pixels à la résolution du périphérique

Étape une : startxref et la table de références croisées

Un lecteur ne scanne jamais un PDF depuis le haut. Un fichier bien formé se termine par le mot-clé startxref, un nombre décimal tel que 48211, et %%EOF, et ce nombre est le décalage d'octets des données de références croisées. Une recherche arrière naïve de xref s'arrête à l'intérieur de startxref, donc cherchez le mot-clé complet. La table classique emploie des entrées à largeur fixe (§7.5.4), ce qui permet à un lecteur de sauter à l'entrée de n'importe quel numéro d'objet sans parser les autres ; la disposition en octets de chaque entrée est couverte dans la vue d'ensemble de la structure. Le trailer après la table nomme /Size, /Root et, en option, /Encrypt, /ID et /Prev

Comment fonctionne un PDF : anatomie verticale d'un fichier PDF depuis l'en-tête et le corps d'objets jusqu'à la table de références croisées et au trailer
La table de références croisées convertit les numéros d'objets en décalages d'octets, si bien qu'un lecteur se positionne droit sur n'importe quelle page sans balayer le reste

Chaque entrée est une position d'octets absolue, si bien que tout ce qui décale des octets, comme un éditeur qui réécrit les fins de ligne, laisse l'index pointer vers les mauvais endroits. Les lecteurs dotés d'une passe de réparation la reconstruisent en scannant les mots-clés obj ; ceux qui n'en ont pas refusent le fichier, voilà pourquoi un PDF abîmé peut s'ouvrir dans un visionneur et échouer dans un autre. Ce qui se trouve au décalage startxref vous dit dans quel cas vous êtes : le mot-clé xref pour une table classique, un en-tête N 0 obj pour un flux de références croisées PDF 1.5, et tout le reste pour un décalage périmé. Le helper ci-dessous est votre propre code, bâti sur des flux RTL ordinaires

uses
  System.SysUtils, System.Classes;

// Votre propre helper : Offset est le nombre écrit après startxref.
// Classifie ce que le lecteur trouvera à cette position
function DescribeXrefAt(const FileName: string; Offset: Int64): string;
var
  FS: TFileStream;
  Buf: TBytes;
  Head: string;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Offset >= FS.Size then
      Exit('offset beyond end of file: the file needs a repair pass');
    FS.Position := Offset;
    SetLength(Buf, 32);
    SetLength(Buf, FS.Read(Buf[0], Length(Buf)));
  finally
    FS.Free;
  end;
  Head := TEncoding.ASCII.GetString(Buf);
  if Head.StartsWith('xref') then
    Result := 'classic cross-reference table'
  else if Head.Contains(' obj') then
    Result := 'cross-reference stream (PDF 1.5 or later)'
  else
    Result := 'stale offset: the file needs a repair pass';
end;

Un flux de références croisées (§7.5.8) est un objet compressé avec /Type /XRef, un tableau /W donnant la largeur en octets de chaque colonne, et des rangées binaires qui pointent soit vers un décalage d'octets, soit vers un flux d'objets (§7.5.7). Les clés du trailer migrent dans son dictionnaire, si bien que le fichier n'a plus aucun mot-clé trailer. HotPDF écrit cette forme quand UseXRefStream est posé sur THotPDF. Pour les fichiers qui quittent votre contrôle, comme les pièces jointes de mail ou les envois vers des imprimeurs, la table classique reste le choix plus sûr parce que les lecteurs antérieurs à PDF 1.5 savent encore la parser. PDF/A-1 tranche la question pour les archives : l'ISO 19005-1 se construit sur PDF 1.4, et son §6.1.4 énonce que les flux de références croisées ne doivent pas être employés, si bien que HotPDF écrit une table classique dès qu'un profil PDF/A-1 est actif

48 0 obj
<< /Type /XRef /Size 49 /Root 1 0 R
   /W [1 3 1] /Index [0 49]
   /Filter /FlateDecode /Length 162 >>
stream
...binary rows: 1 + 3 + 1 bytes each...
endstream
endobj
startxref
48211
%%EOF

De /Root à la bonne page : catalog et arbre de pages

Le /Root du trailer pointe vers le catalog du document, et le /Pages du catalog pointe vers la racine de l'arbre de pages. Chaque nœud /Pages intermédiaire porte un /Count des pages feuilles en dessous de lui, si bien qu'un lecteur qui cherche la page 5 000 soustrait les comptes en descendant et saute des sous-arbres entiers sans les ouvrir. L'ISO 32000-1 §7.7.3 n'exige pas un arbre équilibré ; un seul tableau /Kids à 10 000 entrées est légal, juste plus lent à parcourir. Le code qui ne lit que la feuille tombe dans un piège. /Resources, /MediaBox, /CropBox et /Rotate sont héritables (§7.7.3.4), si bien qu'une page sans son propre /MediaBox reste valide parce que la valeur vit sur un ancêtre

12 0 obj
<< /Type /Page
   /Parent 3 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 7 0 R >> >>
   /Contents 13 0 R
>>
endobj

La media box fixe l'espace de coordonnées de tout ce qui est dessiné sur la page : origine en bas à gauche, Y montant vers le haut, une unité égale à 1/72 de pouce sauf si un /UserUnit PDF 1.6 la met à l'échelle. PDFium Component accomplit toute la descente quand vous demandez une page par numéro. TPdf.PageMetrics renvoie la taille de la page en points avec les boîtes héritées déjà résolues, plus la rotation de la page, si bien que le listing ci-dessous rapporte des tailles correctes même pour les fichiers dont les pages feuilles ne portent aucune boîte propre

uses
  System.SysUtils, PDFium;

// Imprime le compte de pages et la taille de chaque page en points
procedure ListPages(const FileName: string);
const
  Degrees: array[TRotation] of Integer = (0, 90, 180, 270);
var
  Pdf: TPdf;
  I: Integer;
  M: TPdfPageMetrics;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;           // parse les données xref, le trailer et le catalog
    if not Pdf.Active then        // un chargement échoué laisse Active = False
      raise Exception.Create('Cannot open ' + FileName);
    Writeln(Pdf.PageCount, ' pages');
    for I := 1 to Pdf.PageCount do
    begin
      M := Pdf.PageMetrics[I];    // à base 1 ; charge la page au travers de l'arbre
      Writeln(Format('Page %d: %.1f x %.1f pt, /Rotate %d',
        [I, M.Width, M.Height, Degrees[M.Rotation]]));
    end;
  finally
    Pdf.Free;
  end;
end;

Comment un PDF dessine-t-il une page à l'écran ?

Le contenu visible d'une page est un petit programme : des opérateurs postfixés dans un ou plusieurs flux de contenu, interprétés dans l'ordre après que le lecteur a exécuté le décodeur nommé dans /Filter, le plus souvent /FlateDecode. BT ouvre un objet texte, Tf sélectionne une ressource de police et une taille, Td déplace la position du texte, Tj peint une chaîne, et ET ferme l'objet. Les chemins se construisent avec m, l, c et re, puis se remplissent avec f ou se tracent avec S. L'état graphique (§8.4) contient ce que ces opérateurs lisent implicitement, comme la matrice de transformation, les couleurs et le chemin de découpe. q empile une copie et Q la dépile, si bien qu'une transformation posée à l'intérieur de la paire ne peut pas fuir dans le contenu ultérieur

q
1 0 0 1 72 700 cm     % déplace l'origine en (72, 700)
BT
  /F1 12 Tf           % ressource de police F1 à 12 points
  0 0 Td
  (Invoice 2026-0417) Tj
ET
0.2 0.2 0.8 RG        % couleur de trait, bornée par q/Q
0 -6 m 140 -6 l S
Q
Comment un PDF dessine une page : extrait de flux de contenu où les opérateurs BT, Tf, Td et Tj mènent aux marques qu'ils peignent
Les opérandes précèdent les opérateurs et les paires q/Q cloisonnent les changements d'état, empêchant les transformations locales de fuir dans le contenu voisin

Les noms comme /F1 sont des clés dans les /Resources de la page. Un dictionnaire de police mène au travers de son descripteur de police (§9.8) vers le programme de police embarqué, et une image est peinte d'un seul Do une fois ses données JPEG ou Flate décodées. La rastérisation vient en dernier. Le moteur de rendu applique la matrice de transformation à chaque chemin et contour de glyphe, mappe les points en pixels du périphérique, et remplit le résultat avec anticrénelage. La page étant encore des données vectorielles à ce stade, un zoom à 400 pour cent réexécute le flux de contenu à une plus grande échelle au lieu d'étirer un bitmap. La résolution cible est choisie par l'appelant : 96 ou 150 pixels par pouce pour un écran, 300 ou plus pour l'impression. Dans PDFium Component, cette étape est TPdf.RenderPage, qui prend la taille de sortie en pixels

uses
  System.SysUtils, Vcl.Graphics, PDFium;

// Rend la page 1 de FileName à Dpi pixels par pouce et la sauvegarde en BMP
procedure RenderFirstPage(const FileName: string; Dpi: Integer);
var
  Pdf: TPdf;
  Bmp: TBitmap;
  W, H: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Cannot open ' + FileName);
    Pdf.PageNumber := 1;        // à base 1
    // PageWidth et PageHeight sont en points (1/72 pouce)
    W := Round(Pdf.PageWidth * Dpi / 72);
    H := Round(Pdf.PageHeight * Dpi / 72);
    Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
    try
      Bmp.SaveToFile(ChangeFileExt(FileName, '-p1.bmp'));
    finally
      Bmp.Free;                 // la forme fonction renvoie un nouveau bitmap
    end;
  finally
    Pdf.Free;
  end;
end;

Pourquoi le copier-coller rend-il du charabia alors que la page a l'air correcte ?

Le copier-coller échoue quand le fichier enregistre quel glyphe dessiner mais pas quel caractère il représente. Le rendu n'a besoin que des formes de glyphes du programme de police embarqué. L'extraction a besoin d'Unicode, qui vient d'un encodage standard ou d'une CMap /ToUnicode sur le dictionnaire de police (§9.10.3). Une police composite avec /Encoding /Identity-H écrit des identifiants de glyphes bruts dans le flux de contenu, et sans table ToUnicode ces identifiants n'ont aucune relation définie avec un caractère. L'écran a l'air parfait pendant que le presse-papiers se remplit de symboles. Les polices en sous-ensemble rendent cela plus probable, puisqu'elles ne gardent que les glyphes que le document emploie et se repèrent au préfixe de six lettres dans des noms comme /BaseFont /AAAAAA+ArialMT (§9.6.4). Le guide de la gestion des polices PDF couvre en profondeur les polices CJK et l'extraction cassée

Mises à jour incrémentales et plage d'octets de signature

Les signatures dépendent de la façon d'ajout seul dont les PDF sont édités : une mise à jour incrémentale (§7.5.6) ajoute des objets et une nouvelle section de références croisées après le dernier %%EOF, et la vue d'ensemble de la structure explique la chaîne des /Prev et pourquoi le contenu supprimé survit dans les révisions antérieures. Le /ByteRange d'un dictionnaire de signature, écrit comme [0 a b c], hache tout sauf la chaîne hexadécimale /Contents qui porte la signature CMS, si bien que la révision signée se termine à l'octet b + c et qu'un validateur la reconstruit en tronquant le fichier là. Les révisions ultérieures atterrissent après ce point, tandis qu'une réécriture complète, un linéariseur ou une passe d'optimisation déplacent des octets que le signataire a hachés et invalident d'un coup toutes les signatures existantes

Comment fonctionnent les mises à jour incrémentales PDF : générations de sauvegarde empilées montrant les objets ajoutés, les trailers rafraîchis et la plage d'octets couverte par une signature numérique
Les sauvegardes ajoutent au lieu de réécrire, ce qui permet aux validateurs d'exposer chaque changement post-signature tandis que les modifications autorisées gardent les signatures vivantes

Quand une application Delphi ou Lazarus a besoin de toute cette chaîne, du parsing des données de références croisées et de la traversée de l'arbre de pages au rendu et à l'extraction de texte, le PDFium Component pour Delphi et Lazarus l'enveloppe dans le composant document TPdf employé dans les listings ci-dessus et le contrôle visionneur TPdfView pour l'affichage à l'écran