Article technique

Sortie PDF linéarisée en Delphi : hint tables de HotPDF

Le premier objet d'un PDF linéarisé doit énoncer la longueur exacte du fichier dans lequel il se trouve, à l'octet près, et il doit apparaître dans les 1024 premiers octets. L'annexe F de l'ISO 32000-1 nomme cette entrée /L, et un lecteur qui la trouve en désaccord avec la vraie taille du fichier est prié de traiter le document comme un PDF ordinaire et d'ignorer chaque hint de linéarisation dedans. L'écrivain doit donc imprimer la longueur d'un fichier qu'il n'a pas fini d'écrire, à côté du décalage d'un flux de hints, de la fin de la première page et de la position d'une table de références vers la fin du fichier. Les quatre nombres bougent dès que quelque chose devant eux change de taille

La récompense est Fast Web View. Un PDF ordinaire garde sa table de références à la fin, si bien qu'un navigateur à qui on remet un rapport scanné de 200 pages ne peut pas localiser la page 1 avant l'arrivée du dernier octet ; un fichier linéarisé déplace la page 1 et une petite section de références croisées vers l'avant, si bien qu'un client qui utilise des requêtes HTTP par plage peint la première page tôt. Ce qui suit est le côté écriture. Le tableau côté lecture se trouve dans l'explication de la linéarisation PDF et de Fast Web View

Comment HotPDF dispose un fichier linéarisé

HotPDF écrit une sortie linéarisée quand THotPDF.LinearizeOutput est True, et le fichier suit l'ordre physique que l'annexe F prescrit, partie par partie. Après l'en-tête viennent le dictionnaire de paramètres de linéarisation, puis la section de références croisées de première page et son trailer, le catalogue avec les autres objets de niveau document, le flux de hints primaire, la première page avec chaque objet dont elle a besoin, les pages restantes dans l'ordre, les objets partagés entre les pages ultérieures, tout le reste, et enfin la table de références croisées principale

Diagramme d'ordre des octets d'un PDF linéarisé écrit par HotPDF en Delphi, du dictionnaire de paramètres de linéarisation et du flux de hints primaire jusqu'à la table de références croisées principale
HotPDF émet les parties de l'annexe F dans un ordre d'octets fixe, avec le dictionnaire de paramètres et le flux de hints primaire placés assez tôt pour que les lecteurs par plages d'octets commencent à rendre la page un presque immédiatement

Rien dans le modèle de document ne dit à quelle partie appartient un objet. L'écrivain le déduit du graphe de références quand EndDoc enregistre, en marchant depuis chaque objet page et en comptant combien de pages atteignent chaque objet indirect. Un objet atteint depuis une page va dans la section de cette page. Un objet atteint depuis plusieurs pages est partagé, avec une torsion qu'une lecture rapide de la spécification manque : quand la page 1 est parmi les pages qui l'utilisent, l'objet va quand même dans la section de première page, parce que la page 1 doit se rendre depuis les seuls octets de tête. Seuls les objets partagés purement entre pages ultérieures attendent dans la partie partagée. Le catalogue, quelles que soient les entrées /ViewerPreferences, /OpenAction, /Threads et /AcroForm vers lesquelles il pointe, et le dictionnaire de chiffrement d'un document protégé composent le groupe de niveau document. Les nœuds de l'arbre de pages sont poussés dans la partie finale pour qu'ils ne gonflent jamais la première page

Ouvrez la sortie dans un éditeur hexadécimal et la numérotation paraît à l'envers. Le bloc de tête, du dictionnaire de linéarisation au flux de hints, porte les numéros d'objets les plus élevés du fichier, tandis que tout ce qui suit est numéroté à partir de 1. La petite section de références croisées au début couvre exactement ce bloc haut, et la table principale couvre 1 à N

Un piège d'initialisation d'enregistrement dans les listes de partition

Des listes comme celles-ci s'écrivent naturellement comme un enregistrement tenant un tableau dynamique plus un compte séparé, et cette forme cache un piège Delphi. Le compilateur initialise les champs gérés d'un enregistrement local. L'Integer simple assis à côté ne reçoit rien. Sous Win32, le slot de pile contient souvent par hasard zéro, si bien que le premier append atterrit à l'index 0 et chaque test passe ; sous Win64, le slot peut contenir des restes et le premier append écrit à un index arbitraire

type
  TPartList = record
    Values: TArray<Integer>;  // géré : effacé par le compilateur
    Count: Integer;           // non géré : ce que la pile contenait
  end;

procedure Append(var List: TPartList; Value: Integer);
begin
  if List.Count = Length(List.Values) then
    SetLength(List.Values, List.Count * 2 + 16);
  List.Values[List.Count] := Value;
  Inc(List.Count);
end;

procedure BuildParts;
var
  FirstPage, Shared: TPartList;
begin
  FirstPage := Default(TPartList);   // requis, pas cosmétique
  Shared := Default(TPartList);
  Append(FirstPage, 12);
  Append(Shared, 40);
end;

L'écrivain de HotPDF assigne Default à chaque liste de partition avant d'y toucher. Une exécution verte en Win32 ne dit rien de savoir si un tel enregistrement a été initialisé

Pourquoi la mise en page doit-elle converger avant que le premier octet soit écrit ?

Parce que le début du fichier porte des décalages qui pointent vers l'arrière, et le nombre de chiffres de ces décalages déplace tout ce qui est derrière eux. Le trailer de première page porte une entrée /Prev avec le décalage en octets de la table de références croisées principale. Écrivez ce décalage 99998, puis découvrez que la table atterrit en réalité à 100003, et le trailer grossit d'un chiffre. La section de références croisées de première page grossit avec lui, chaque objet derrière elle bouge d'un octet, et la table principale se trouve maintenant à 100004. Une seule passe ne fait cela juste que par chance

HotPDF casse le cycle en deux gestes. Le dictionnaire de paramètres va dans un slot fixe de 384 octets rembourré d'espaces, si bien que /L, /H, /E et /T peuvent prendre les chiffres qu'il leur faut sans bouger le moindre objet ; si le texte du dictionnaire devait un jour dépasser 384 octets, l'écriture lèverait plutôt que de décaler le fichier. Il reste /Prev comme seule variable libre, et elle passe par une boucle de point fixe. Chaque objet est d'abord sérialisé dans un flux de comptage qui note les longueurs et ne garde aucun octet. Une passe de mise en page assigne ensuite les décalages section par section et rapporte où la table principale atterrirait, et cette réponse devient l'entrée de la passe suivante. La boucle s'arrête quand le décalage proposé cesse de bouger et abandonne avec une exception après huit tentatives

Organigramme de la boucle de convergence de mise en page linéarisée de HotPDF en Delphi, où les décalages de références croisées proposés sont mesurés à répétition jusqu'à atteindre un point fixe ou jusqu'à ce qu'une exception avorte l'écriture
Jusqu'à huit passes de mesure convergent le décalage de la table de références principale avant qu'un octet soit commis ; la non-convergence lève au lieu d'écrire des décalages devinés

Seul un changement de nombre de chiffres déplace quoi que ce soit, si bien qu'une troisième passe n'est nécessaire que près d'une frontière de puissance de dix, comme dans le cas 99998. Après la convergence, l'écrivain exécute une passe de confirmation, reconstruit le flux de hints avec les vrais décalages et vérifie que sa longueur n'a pas changé ; les décalages se trouvent dans des champs à largeur fixe, si bien que la version fantoche et la finale doivent coïncider. Puis la sortie va droit dans le flux de destination sans seconde copie bufferisée, et à chaque frontière de section les octets écrits sont comparés au décalage promis. Une discordance lève depuis EndDoc

Tables de hints, et où l'annexe F se lit de deux façons

Le flux de hints primaire porte deux tables. La table de hints de décalages de pages note, pour chaque page, combien d'objets sa section contient, combien elle fait d'octets, et de quels objets partagés elle dépend. La table de hints d'objets partagés liste la longueur de chaque groupe d'objets partagés. Avec les deux, un lecteur peut demander directement la page 37, et l'entrée /S du flux donne la position dans le flux où la table partagée démarre

Deux détails de l'annexe F, F.4, sont faciles à rater. Le premier est que les décalages stockés dans les tables de hints sont des décalages logiques, calculés comme si le flux de hints primaire n'existait pas. Le premier objet page et le premier objet partagé se trouvent physiquement tous deux après le flux de hints, si bien que l'écrivain soustrait la longueur de l'objet de hints avant de noter leurs décalages. Un écrivain qui noterait des décalages physiques fait atterrir chaque requête de plage résultante décalée d'exactement la taille de l'objet de hints

Le second est une vraie ambiguïté. Le texte du F.4 décrit chaque table comme un unique flux de bits continu, bit de poids fort en premier, et liste les groupes partagés de première page et les groupes partagés ultérieurs comme deux séquences séparées. Les flux de hints en circulation bourrent chaque colonne sur une frontière d'octet et traitent les groupes partagés comme une liste combinée unique, et c'est la forme que les lecteurs consommateurs de hints acceptent de façon connue. HotPDF écrit la disposition alignée sur les octets et combinée. C'est le bon côté à choisir : une table qui satisfait la lettre du F.4 mais se désaligne pour les clients qui la lisent échoue exactement là où la linéarisation était censée payer, et un téléchargement du fichier entier n'expose jamais le problème

Activer la sortie linéarisée depuis Delphi

Côté API, l'interrupteur est un booléen. LinearizeOutput vaut False par défaut, et l'écrivain le consulte quand EndDoc sérialise le document, alors posez-le avec les autres options de sortie avant BeginDoc et n'y touchez plus. Chaque échec décrit ci-dessus (non-convergence, dictionnaire de paramètres trop gros, discordance de frontière, le plafond 4 Gio couvert plus bas) remonte comme une exception depuis EndDoc, ce qui rend un repli simple

procedure WriteReport(const FileName: string; Linearize: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := FileName;
    PDF.Version := pdf17;
    PDF.LinearizeOutput := Linearize;   // à poser avant BeginDoc
    PDF.BeginDoc;                       // crée la page 1
    PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
    PDF.EndDoc;                         // la mise en page et l'écriture arrivent ici
  finally
    PDF.Free;
  end;
end;

procedure PublishReport(const FileName: string);
begin
  try
    WriteReport(FileName, True);
  except
    on E: Exception do
    begin
      LogWarning('Linearization failed: ' + E.Message);  // votre propre logger
      WriteReport(FileName, False);                       // PDF simple à la place
    end;
  end;
end;

Le repli produit un PDF ordinaire que toute visionneuse ouvre ; il perd seulement la première page rapide. Pour un pipeline de rapports, le compromis est facile, puisqu'une première peinture lente est une nuisance et un rapport manquant est un incident

Le côté déploiement prime sur le code. La linéarisation ne paie que si le serveur honore les requêtes HTTP Range ; un point de terminaison qui streame le fichier en entier vous achète une écriture plus lente et un fichier plus grand pour rien. Laissez LinearizeOutput éteint pour les fichiers qui partent par mail, en pièce jointe ou en téléchargement avant que quiconque les voie. Allumez-le pour les documents qu'un navigateur ouvre directement depuis une URL, après avoir confirmé que la réponse porte Accept-Ranges: bytes

Pourquoi LinearizeOutput éteint-il les object streams ?

Parce que chaque entrée de table de hints décrit une plage d'octets, et qu'un objet emballé dans un conteneur /ObjStm n'a pas de plage d'octets en propre ; il n'existe que comme tranche d'un autre flux compressé qui doit être récupéré et gonflé en entier. Avec LinearizeOutput actif, HotPDF écrit des tables de références croisées texte traditionnelles et des objets indirects non emballés même quand UseXRefStream ou UseObjectStreams est aussi posé. Le chemin linéarisé prend le pas au moment de l'enregistrement, si bien que votre code n'a aucun conflit à résoudre

Le coût est la taille de fichier, puisque les petits dictionnaires qu'un object stream compresserait ensemble restent maintenant détachés. Si la sortie compacte compte plus que la latence de première page, le compromis est posé dans object streams et mises à jour incrémentales dans HotPDF. Les exportateurs Office rencontrent la même tension de l'autre côté, voilà pourquoi certains émettent la disposition décrite dans les flux de références croisées hybrides dans les PDF générés par Office

La linéarisation demande aussi PDF 1.2 ou plus. Avec un Version plus bas, HotPDF promeut le document à 1.2 à l'enregistrement. Avec StrictVersionLock posé, il lève une exception à la place, parce qu'une version que vous avez figée exprès ne devrait pas changer dans votre dos

Le plafond de 4 Gio de la sortie linéarisée

Les tables de hints stockent décalages et longueurs dans des champs 32 bits, si bien qu'un fichier linéarisé ne peut adresser rien au-delà de 4 Gio, et HotPDF refuse d'en produire un plutôt que d'écrire des décalages qui rebouclent. Le plafond vient des largeurs de champs que l'annexe F définit

Diagramme des trois points de contrôle HotPDF qui mesurent tailles d'objets, longueurs de sections de page et longueur finale du fichier contre la limite de 4 Gio des champs 32 bits des tables de hints de linéarisation en Delphi
Les mesures d'objet, de section de page et de fichier entier sont chacune vérifiées contre le plafond 32 bits des champs de hints avant que la sortie soit produite

Le contrôle tourne à trois points, tous avant que le premier octet n'atteigne la destination : sur chaque objet dès que sa longueur sérialisée est connue, sur chaque longueur de section de page pendant que les entrées de hints sont bâties, et sur la longueur finale du fichier une fois la table de références principale dimensionnée. Des décalages rebouclés seraient la pire façon d'échouer : une visionneuse qui télécharge le fichier entier peut ignorer les hints, tandis que le client par plages d'octets saute vers les mauvais octets, si bien que le test sur bureau ne reproduit jamais la panne. Pour une sortie de plusieurs gigaoctets, la route de streaming de la Direct File API pour les gros flux PDF est l'endroit où regarder

Vérifier si un fichier entrant est linéarisé

THotPDF.IsLoadedLinearized rapporte si le document chargé se déclare linéarisé. Le contrôle arrive avant l'analyse : HotPDF lit jusqu'à 1024 octets depuis la position zéro de la source, trouve le premier objet, et cherche dans cet objet /Linearized avec la valeur 1. Les commentaires, chaînes littérales et chaînes hexadécimales sont sautés, si bien que le nom apparaissant dans une chaîne ne compte pas, et le scan s'arrête à stream ou endobj. Seul 1 ou un équivalent comme 1.0 est accepté

La réponse est mise en cache au chargement, parce qu'au moment où le code de l'application demande, l'analyseur a déplacé la position du flux et LoadFromFile a déjà relâché son flux source

Le drapeau rapporte ce que le fichier prétend. L'annexe F ajoute une seconde condition : /L doit correspondre à la longueur réelle du fichier, et une mise à jour incrémentale appendue après la linéarisation casse cette correspondance, si bien que les lecteurs reviennent à traiter le fichier comme ordinaire. Un pipeline qui accepte des fichiers d'éditeurs qui appendent leurs enregistrements peut comparer les deux nombres lui-même

// Votre propre aide, pas partie de HotPDF : compare /L à la vraie taille
// Analyse simplifiée ; elle suppose une espace unique après /L
function DeclaredLengthMatches(const FileName: string): Boolean;
var
  FS: TFileStream;
  Head: AnsiString;
  ActualSize: Int64;
  P, Q: Integer;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    ActualSize := FS.Size;
    SetLength(Head, 1024);
    SetLength(Head, FS.Read(Head[1], 1024));
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/L '), Head);
  Result := P > 0;
  if not Result then
    Exit;
  Inc(P, 3);
  Q := P;
  while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
    Inc(Q);
  Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;

procedure InspectUpload(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    if PDF.LoadFromFile(FileName) <= 0 then
      Exit;
    if not PDF.IsLoadedLinearized then
      Writeln('Not linearized')
    else if not DeclaredLengthMatches(FileName) then
      Writeln('Linearized, but modified after linearization')
    else
      Writeln('Ready for Fast Web View');
  finally
    PDF.Free;
  end;
end;

LinearizeOutput et IsLoadedLinearized font partie du HotPDF Delphi Component pour Delphi et C++Builder, et sa page produit documente comment ils interagissent avec les flux de références croisées, les object streams et le verrou de version