Article technique

PDFlibPas HTML vers PDF : entités décodées deux fois

Les versions de PDF Library for Delphi (PDFlibPas) antérieures à v3.539.47 pouvaient décoder deux fois le texte échappé lors du dessin de HTML ou de Markdown dans un PDF. DrawHTMLText et DrawHTMLTextBox analysent le HTML, le normalisent en HTML, puis le réanalysent, si bien qu’un texte écrit <unsafe> arrivait à la seconde passe comme une vraie balise. Depuis v3.539.47, chaque entité est décodée exactement une fois et le texte est rééchappé partout où il redevient du HTML

Le scénario qui expose ça n’a rien d’exotique. Un help desk exporte des tickets en PDF, et le commentaire client part dans un template HTML. Le développeur a fait ce qu’il fallait et a échappé le commentaire, donc <b> est devenu &lt;b&gt;. Dans le moteur de rendu, cet échappement était tranquillement annulé : le commentaire ressortait en gras, un nom de balise inconnu disparaissait simplement de la page, et une ancre échappée devenait une annotation de lien cliquable. Pas d’exception, pas d’avertissement, un PDF parfaitement valide qui raconte autre chose que les données

Pourquoi un texte échappé devient-il une vraie balise dans le PDF ?

Le texte échappé devenait du balisage parce que le moteur de rendu enchaîne deux passes d’analyse, et que l’étape de normalisation entre les deux réécrivait en HTML du texte déjà décodé sans le rééchapper. Chaque décodage effectué par la première passe se retrouvait ainsi disponible pour la seconde comme syntaxe vivante

Ces deux passes ont une bonne raison d’exister. La première analyse construit une liste d’éléments balises et mots. NormalizeParsedHTML résout ensuite la cascade de feuilles de style : elle rapproche les règles des blocs <style> de chaque balise, les fusionne avec les attributs style inline, stocke le résultat sur la balise, et resérialise toute la liste d’éléments en une chaîne HTML. La passe de mise en page analyse cette chaîne normalisée. C’est la même mécanique qui anime flexbox, CSS grid et les notes de bas de page dans le rendu HTML de PDFlibPas

Le défaut était dans la sérialisation des mots. Les balises étaient réécrites depuis leur forme source d’origine, tandis que les mots étaient réécrits sous leur forme décodée. Un mot que la première analyse avait fait passer de &lt;unsafe&gt; à <unsafe> atterrissait dans le HTML normalisé avec ses chevrons bruts, et la seconde analyse le lisait comme un élément. Autour de ce bug central traînaient trois fuites plus petites qui pointaient dans le même sens :

  • &amp; ne figurait pas dans le jeu d’entités pris en charge, donc R&amp;D s’imprimait littéralement et il était impossible d’écrire en texte une orthographe d’entité littérale comme &lt;
  • L’étape de dessin remplaçait &nbsp; une seconde fois, après que l’analyse était déjà terminée, si bien qu’une orthographe d’entité littérale pouvait encore disparaître au tout dernier moment
  • L’échappement du code Markdown ignorait l’esperluette, et l’exporteur de dataset n’échappait que les chevrons, donc les orthographes d’entités dans le code ou les valeurs de cellules étaient décodées comme du balisage
Pipeline HTML PDFlibPas de DrawHTMLText où la première analyse construit les éléments, NormalizeParsedHTML les resérialise en HTML et la seconde passe met le résultat en page ; avant v3.539.47 les mots décodés étaient réécrits sans rééchappement et devenaient des balises vivantes, depuis v3.539.47 chaque mot est rééchappé à la frontière
Des mots décodés rentrent dans l’analyseur comme syntaxe quand le normalisateur oublie qu’il produit du balisage, c’est ainsi qu’un commentaire échappé passait en gras ou se munissait d’un lien
Entrée arrivant au moteur de renduAvant v3.539.47Depuis v3.539.47
&lt;unsafe&gt;Analysé comme balise, le texte n’atteint jamais la page<unsafe> dessiné comme texte
&lt;b&gt;x&lt;/b&gt;x dessiné en gras<b>x</b> dessiné comme texte
R&amp;DR&amp;D imprimé littéralementR&D
&amp;lt;&amp;lt; imprimé littéralement&lt;
Span de code Markdown contenant &nbsp;Devenait une espace insécable&nbsp; dessiné comme texte
Valeur de cellule de dataset &lt;<&lt;

Comment v3.539.47 rend le décodage des entités HTML en un seul passage

PDFlibPas v3.539.47 rend le décodage des entités en un seul passage avec trois changements coordonnés : l’analyseur décode &amp; en dernier, l’étape de dessin ne décode plus rien, et chaque endroit qui refait des mots décodés du HTML les rééchappe d’abord

Le jeu d’entités pris en charge pour le contenu texte est désormais &lt;, &gt;, &amp; et &nbsp;. Tout le reste, y compris les références numériques comme &#65; et les entités nommées comme &quot;, reste du texte littéral. Cette frontière compte pour la façon d’échapper vos propres entrées, comme montré plus bas

L’ordre à l’intérieur du décodeur est la première correction. Si &amp; était décodé en premier, l’entrée &amp;lt; deviendrait &lt; et le remplacement suivant la transformerait en <, un double décodage qui se produit à l’intérieur d’une seule passe. Le chemin des mots ANSI remplace donc &lt;, &gt; et &nbsp; d’abord et &amp; en dernier, si bien que l’esperluette qu’il produit n’est plus jamais réexaminée. Le chemin des mots UTF-16 est un unique balayage de gauche à droite par pas de deux octets qui réécrit chaque correspondance sur place et passe outre, ce qui donne la même garantie structurellement

Ordre du décodeur PDFlibPas pour une entité chaînée comme &amp;lt; : décoder l’esperluette en premier la réduit à un vrai chevron à l’intérieur d’une seule passe, tandis que décoder lt, gt et nbsp avant l’esperluette garde l’orthographe littérale intacte, si bien que le texte atteint la page décodé exactement une fois
L’esperluette est le caractère d’échappement, donc elle doit être décodée en dernier et échappée en premier, sinon une passe peut décoder deux fois

La seconde correction retire le remplacement tardif de &nbsp; de l’étape de dessin. Le décodage appartient à l’analyseur et à rien d’autre, donc un mot qui atteint le coupeur de lignes est du texte final

La troisième correction est la règle de frontière. NormalizeParsedHTML échappe désormais &, < et > dans chaque mot décodé avant de l’ajouter au HTML normalisé. La seconde analyse le redécode en exactement le même texte, si bien que l’effet net sur tout le pipeline est un décodage unique. La chaîne de continuation suit la même règle : les mots qui ne tenaient pas dans la boîte sont échappés avant d’être ajoutés à LeftOverText, et le reste du reliquat est copié depuis le HTML normalisé, déjà sous forme échappée. La boucle qui collecte ces mots restants est elle aussi bornée par le nombre de mots désormais, là où l’ancienne boucle repeat pouvait dépasser le dernier mot

Pourquoi l’échappement UTF-16BE ne peut-il pas passer par un remplacement au niveau octet ?

L’échappement UTF-16BE ne peut pas passer par un remplacement au niveau octet parce que le motif de deux octets d’une esperluette peut se retrouver à cheval sur deux caractères sans rapport. La seule unité de travail correcte est l’unité de code 16 bits entière

Le moteur stocke les mots Unicode en UTF-16 gros-boutiste empaqueté dans des chaînes d’octets, octet haut d’abord. Une esperluette, c’est 00 26. Prenez maintenant U+0100 (A majuscule latine au macron, octets 01 00) suivi de U+2603 (le bonhomme de neige, octets 26 03). La séquence d’octets est 01 00 26 03, et les octets deux et trois se lisent 00 26. Une recherche d’octets de #0'&' trouve une esperluette qui n’existe pas, greffe les octets de &amp; au milieu de deux caractères, et cisaille d’un octet chaque caractère suivant

Risque d’échappement UTF-16BE dans PDFlibPas où les octets 01 00 26 03 de U+0100 et U+2603 contiennent le motif 00 26 à cheval sur deux caractères, si bien qu’une recherche d’esperluette au niveau octet greffe une entité au milieu d’un point de code ; le balayage par unités de code ne teste que les offsets pairs
Une recherche par octets trouve une esperluette qu’aucun caractère n’a jamais contenue ; travaillez sur des unités de code entières, jamais sur des tampons bruts d’octets UTF-16

Ce n’est pas un cas limite exotique. N’importe quel caractère dont l’octet bas est zéro peut fournir la première moitié ; U+4E00, un des idéogrammes CJK les plus fréquents, en fait partie. Les chevrons ont la même exposition : 00 3C et 00 3E apparaissent dès qu’un tel caractère est suivi d’un autre entre U+3C00 et U+3EFF dans CJK Extension A. La correction dans EscapeHTMLWord déballe les octets en un WideString, échappe caractère par caractère et repaque le résultat. Le côté décodeur était déjà sûr parce qu’il ne teste les motifs qu’aux frontières paires d’unités de code

La même règle vaut pour votre propre code. Si vous tenez un jour du texte UTF-16 en TBytes, par exemple après un TEncoding.BigEndianUnicode.GetBytes, ne le cherchez pas au motif d’octets. Reconvertissez en chaîne et travaillez sur les caractères

Blocs de code Markdown et exports de dataset : échappez l’esperluette en premier

Depuis v3.539.47, les deux producteurs de HTML de PDFlibPas, le convertisseur Markdown et l’exporteur de dataset, échappent l’esperluette avant les chevrons, si bien que le décodage unique du moteur de rendu restitue exactement le texte d’origine

Dans MarkdownToHTML, les spans de code inline et les blocs de code délimités ou indentés associent désormais & à &amp;, < à &lt; et > à &gt;, tandis que les espaces deviennent &nbsp; et qu’une tabulation en devient quatre pour préserver l’indentation. La prose Markdown ordinaire n’échappe que les chevrons, donc du HTML brut dans la prose ne peut pas injecter de balises pendant qu’un auteur peut toujours écrire &amp; à dessein, comme les auteurs Markdown s’y attendent. DrawMarkdownText et DrawMarkdownTextBox utilisent la même conversion, donc le code apparaît dans le PDF exactement comme tapé :

uses
  System.SysUtils, PDFlibrary;

procedure RenderCodeSample;
var
  Lib: TPDFlib;
  Md, Html: WideString;
begin
  Md := 'Comparison helper:' + sLineBreak + sLineBreak +
        '```' + sLineBreak +
        'if (A < B) and (Flags <> 0) then' + sLineBreak +
        '  WriteLn(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // Inspectez le HTML : dans le code, '&' devient '&amp;' et '<' devient '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // origine en haut à gauche, Y croît vers le bas
    Lib.SetMeasurementUnits(0);  // points
    // La page montre le code exactement comme tapé, orthographes d’entités comprises
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

L’exporteur de dataset est le cas instructif. Avant v3.539.47, il n’échappait que les chevrons, et exprès : le moteur ne décodait pas &amp;, donc échapper l’esperluette aurait imprimé &amp; dans chaque cellule qui en contenait une. Le contournement était correct pour l’ancien moteur et faux en général, parce qu’une valeur de cellule qui se trouvait contenir &lt; était décodée en <. Moteur corrigé, l’exporteur échappe & en premier, et une valeur telle que R&D &lt; &amp; &nbsp; atterrit verbatim dans le PDF. Si vous construisez vos rapports ainsi, le pas à pas exporter un TDataSet vers un rapport PDF en Delphi couvre le reste de l’exporteur

Il vaut la peine d’écrire une fois pourquoi l’esperluette doit passer en premier. Échappez < d’abord et vous obtenez &lt; ; échappez & ensuite et ça devient &amp;lt;, qu’un décodage unique correct affiche comme &lt; au lieu de <. Une chaîne de remplacements séquentiels n’est correcte que quand le caractère d’échappement lui-même est traité avant tout ce qui l’introduit

Comment échapper un texte non fiable pour DrawHTMLTextBox ?

Pour le rendu HTML de PDFlibPas, échappez le contenu texte non fiable en remplaçant &, puis <, puis >, exactement une fois, et tenez les données non fiables entièrement à l’écart des valeurs d’attributs

uses
  System.SysUtils, PDFlibrary;

// Échappe un texte non fiable pour le contenu HTML de PDFlibPas.
// '&' doit être remplacé en premier, sinon l’esperluette à l’intérieur
// d’un '&lt;' déjà produit serait échappée une seconde fois
function EscapeHTMLText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
end;

procedure RenderTicket(const CustomerComment: string);
var
  Lib: TPDFlib;
  Html: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetMeasurementUnits(0);
    Html := '<p><b>Customer comment</b></p>' +
            '<p>' + EscapeHTMLText(CustomerComment) + '</p>';
    Lib.DrawHTMLText(50, 50, 495, Html);
    Lib.SaveToFile('ticket.pdf');
  finally
    Lib.Free;
  end;
end;

Sur v3.539.47, un commentaire tel que Try <a href="https://example.com">this</a> & &lt;b&gt; apparaît sur la page caractère pour caractère. Avant v3.539.47, la même entrée échappée pouvait produire une annotation de lien vivante, et c’est ce qui transforme un pépin d’affichage en problème de sécurité : un commentaire de ticket ne doit jamais pouvoir planter une URL cliquable dans un document que votre personnel croit

Notez ce que la fonction n’échappe pas. Les échappeurs HTML généralistes convertissent aussi " en &quot; et ' en &#39;, ce qui est juste pour un navigateur. Le décodage de texte de PDFlibPas ne reconnaît que les quatre entités listées plus haut, donc ces deux-là s’imprimeraient littéralement en &quot; et &#39;. Les guillemets sont inoffensifs dans le contenu texte ; ils ne comptent qu’à l’intérieur des valeurs d’attributs, et le moteur ne décode pas du tout les entités dans les attributs. Le design sûr n’est donc pas un meilleur échappeur mais une règle : les données non fiables ne vont jamais dans href, src ni style. Si une cible de lien doit vraiment venir de données utilisateur, validez-la vous-même contre une liste blanche de schémas et de caractères et rejetez tout ce qui contient guillemets ou chevrons

Deux notes de migration découlent directement de la correction :

  • Si votre code avait cessé d’échapper & parce que les anciennes versions imprimaient &amp; littéralement, remettez-le. Sans lui, un texte utilisateur contenant &lt; s’affiche désormais en <, toujours un texte inoffensif mais plus ce que l’utilisateur a tapé
  • N’échappez pas deux fois. Un texte qui traverse deux échappeurs rend < sous l’orthographe visible &lt;, donc trouvez l’unique frontière où vos données entrent dans le HTML et n’échappez que là

Paginer avec LeftOverText sans casser les échappements

DrawHTMLTextBox renvoie le HTML qui ne tenait pas, qu’on appelle d’habitude LeftOverText, et depuis v3.539.47 ce reliquat préserve les orthographes d’entités littérales et les chevrons échappés quand vous le passez à la boîte suivante. La règle pour l’appelant est simple : le repasser tel quel

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimensionné pour une page A4 en points
  BoxHeight = 740;
  MaxPages = 500;

procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
  Rest: WideString;
  Pages: Integer;
begin
  Lib.SetOrigin(1);
  Lib.SetMeasurementUnits(0);
  Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
  Pages := 1;
  while (Rest <> '') and (Pages < MaxPages) do
  begin
    Lib.NewPage;
    Inc(Pages);
    // LeftOverText est déjà du HTML de moteur échappé : ne jamais rééchapper ni décoder
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Traitez le reliquat comme opaque. C’est le HTML normalisé du moteur, styles déjà résolus, donc ne le passez pas dans votre propre échappeur, ne le décodez pas, et n’y greffez pas de texte utilisateur. Le plafond de pages est une assurance bon marché : si un élément ne peut jamais tenir dans la boîte, une boucle non plafonnée n’a aucune sortie naturelle

Markdown a sa propre continuation. DrawMarkdownTextBox renvoie un token qui commence par un marqueur interne pour que l’appel suivant puisse sauter la conversion ; rendez-le à DrawMarkdownTextBox ou DrawMarkdownText, pas aux points d’entrée HTML, qui dessineraient le marqueur comme du texte

La leçon générale : décoder une fois, réencoder à chaque frontière

Tout pipeline qui analyse du texte, resérialise le résultat dans la même syntaxe puis le réanalyse doit traiter le décodage comme une opération qui se produit en exactement un endroit, et doit réencoder à chaque frontière où le texte décodé redevient de la syntaxe. Les moteurs de templates, les sanitizers HTML et les chaînes Markdown vers HTML vers PDF partagent cette forme et échouent de la même façon quand un sérialiseur oublie qu’il produit du balisage

Les symptômes sont prévisibles dès que vous connaissez la forme. Trop peu de réencodage transforme les données en syntaxe, c’est la direction injection. Trop d’encodage, ou un décodeur qui tourne deux fois, montre au lecteur des orthographes d’entités ou les avale, c’est la direction affichage. Corriger une seule direction casse d’habitude l’autre, c’est pourquoi la correction de PDFlibPas a dû ajouter le décodage de &amp;, le réordonner, retirer le décodage tardif et ajouter le rééchappement dans la même version. Le même principe court dans l’autre sens quand un contenu PDF est exporté en texte structuré, comme dans l’export sémantique PDF vers Markdown et DOCX depuis Delphi, où chaque caractère littéral doit être échappé pour la syntaxe cible exactement une fois

Aide-mémoire en checklist

  • Passez à PDFlibPas v3.539.47 ou ultérieure si vous rendez du HTML ou du Markdown qui contient des données utilisateur
  • Échappez le contenu texte avec & d’abord, puis < et > ; ne convertissez pas les guillemets pour le texte PDFlibPas
  • Échappez une fois, à l’unique endroit où les données entrent dans la chaîne HTML
  • Tenez les valeurs non fiables à l’écart de href, src et style, ou validez-les contre une liste blanche
  • N’attendez le décodage que de &lt;, &gt;, &amp; et &nbsp; dans le texte ; les autres entités restent littérales
  • Repassez LeftOverText à DrawHTMLTextBox tel quel et plafonnez la boucle de pages
  • Ne remettez les tokens de continuation Markdown qu’à DrawMarkdownTextBox ou DrawMarkdownText
  • Ne cherchez jamais de motifs d’octets dans des tampons UTF-16 ; travaillez sur des unités de code entières

Le rendu HTML et Markdown, l’export de rapports de dataset et le reste du moteur de mise en page sont livrés dans le source Pascal natif de PDF Library for Delphi, pour Delphi et Free Pascal. Voyez la page produit PDFlibPas pour les éditions, les plateformes prises en charge et un téléchargement d’essai