Technischer Artikel

PDFlibPas HTML zu PDF: Doppelt dekodierte Entities beheben

Versionen der PDF Library for Delphi (PDFlibPas) vor v3.539.47 konnten escapten Text doppelt dekodieren, wenn HTML oder Markdown in ein PDF gezeichnet wurde. DrawHTMLText und DrawHTMLTextBox parsen das HTML, normalisieren es zurück in HTML und parsen es erneut, sodass als <unsafe> geschriebener Text beim zweiten Parse als echtes Tag ankam. Seit v3.539.47 wird jedes Entity genau einmal dekodiert, und überall dort, wo Text wieder zu HTML wird, wird er neu escaped

Das Szenario, das das aufdeckt, ist gewöhnlich. Ein Helpdesk exportiert Tickets nach PDF, und der Kundenkommentar wandert in ein HTML-Template. Der Entwickler hat alles richtig gemacht und den Kommentar escaped, aus <b> wurde also &lt;b&gt;. Im Renderer wurde dieses Escaping stillschweigend rückgängig gemacht: Der Kommentar kam fett heraus, ein unbekannter Tagname verschwand einfach von der Seite, und ein escaped Anker wurde zu einer klickbaren Link-Annotation. Keine Exception, keine Warnung, ein völlig valides PDF, das etwas anderes sagt als die Daten

Warum wird escapter Text im PDF zu einem echten Tag?

Escapter Text wurde zu Markup, weil der Renderer zwei Parse-Durchläufe fährt und der Normalisierungsschritt dazwischen bereits dekodierten Text ohne erneutes Escaping zurück in HTML schrieb. Jede Dekodierung, die der erste Parse vorgenommen hatte, stand dem zweiten Parse dann als lebende Syntax zur Verfügung

Die beiden Durchläufe existieren aus gutem Grund. Der erste Parse baut eine Liste von Tag- und Word-Elementen auf. NormalizeParsedHTML löst dann die Stylesheet-Kaskade auf: Es matcht die Regeln aus <style>-Blöcken gegen jedes Tag, verschmilzt sie mit inline style-Attributen, legt das Ergebnis am Tag ab und serialisiert die ganze Elementliste zurück in einen HTML-String. Der Layout-Durchlauf parst diesen normalisierten String. Es ist dieselbe Maschinerie, die Flexbox, CSS Grid und Fußnoten-Layout im PDFlibPas-HTML-Rendering antreibt

Der Fehler steckte darin, wie Wörter serialisiert wurden. Tags wurden aus ihrer ursprünglichen Quellform zurückgeschrieben, Wörter dagegen in ihrer dekodierten Form. Ein Wort, das der erste Parse von &lt;unsafe&gt; zu <unsafe> dekodiert hatte, landete als rohe spitze Klammern im normalisierten HTML, und der zweite Parse las es als Element. Um diesen Kern-Bug herum saßen drei kleinere Lecks, die in dieselbe Richtung wiesen:

  • &amp; war nicht in der unterstützten Entity-Menge, R&amp;D wurde also wörtlich gedruckt, und es gab keinen Weg, eine wörtliche Entity-Schreibweise wie &lt; als Text zu schreiben
  • Die Zeichenstufe ersetzte &nbsp; ein zweites Mal, nachdem das Parsen längst fertig war, eine wörtliche Entity-Schreibweise konnte also ganz am Ende noch verschwinden
  • Das Markdown-Code-Escaping übersprang das kaufmännische Und, und der Dataset-Exporter escapte nur spitze Klammern, Entity-Schreibweisen in Code oder Zellwerten wurden also als Markup dekodiert
PDFlibPas-HTML-Pipeline für DrawHTMLText, bei der der erste Parse Elemente baut, NormalizeParsedHTML sie zurück in HTML serialisiert und der zweite Parse das Ergebnis layoutet; vor v3.539.47 wurden dekodierte Wörter unescaped zurückgeschrieben und wurden zu lebenden Tags, seit v3.539.47 wird jedes Wort an der Grenze neu escaped
Dekodierte Wörter gelangen als Syntax zurück in den Parser, wenn der Normalisierer vergisst, dass er Markup produziert — so wurde ein escaped Kommentar fett oder bekam einen Link
Eingabe, die den Renderer erreichtVor v3.539.47Seit v3.539.47
&lt;unsafe&gt;Als Tag geparst, der Text erreicht nie die Seite<unsafe> als Text gezeichnet
&lt;b&gt;x&lt;/b&gt;x fett gezeichnet<b>x</b> als Text gezeichnet
R&amp;DR&amp;D wörtlich gedrucktR&D
&amp;lt;&amp;lt; wörtlich gedruckt&lt;
Markdown-Code-Span mit &nbsp;Wurde ein geschütztes Leerzeichen&nbsp; als Text gezeichnet
Dataset-Zellwert &lt;<&lt;

Wie v3.539.47 das HTML-Entity-Decoding auf einen Durchlauf bringt

PDFlibPas v3.539.47 bringt das Entity-Decoding mit drei koordinierten Änderungen auf einen einzigen Durchlauf: Der Parser dekodiert &amp; zuletzt, die Zeichenstufe dekodiert nichts mehr, und jede Stelle, die dekodierte Wörter zurück in HTML verwandelt, escapet sie vorher erneut

Die unterstützte Entity-Menge für Textinhalt ist jetzt &lt;, &gt;, &amp; und &nbsp;. Alles andere, inklusive numerischer Referenzen wie &#65; und benannter Entities wie &quot;, bleibt wörtlicher Text. Diese Grenze ist wichtig dafür, wie Sie Ihre eigene Eingabe escapen, wie unten gezeigt

Die Reihenfolge im Dekoder ist der erste Fix. Würde &amp; zuerst dekodiert, würde die Eingabe &amp;lt; zu &lt;, und die nächste Ersetzung machte daraus < — eine Doppeldekodierung, die innerhalb eines Durchlaufs passiert. Der ANSI-Wortpfad ersetzt daher zuerst &lt;, &gt; und &nbsp; und &amp; zuletzt, das kaufmännische Und, das er produziert, wird also nie wieder angesehen. Der UTF-16-Wortpfad ist ein einziger Scan von links nach rechts in Zweibyte-Schritten, der jeden Treffer an Ort und Stelle umschreibt und daran vorbeigeht, was dieselbe Garantie strukturell liefert

PDFlibPas-Dekoderreihenfolge für ein verkettetes Entity wie &amp;lt;: Dekodiert man das kaufmännische Und zuerst, kollabiert es innerhalb eines Durchlaufs zu einer echten spitzen Klammer, während lt, gt und nbsp vor dem kaufmännischen Und dekodiert die wörtliche Schreibweise intakt hält, sodass der Text genau einmal dekodiert auf der Seite ankommt
Das kaufmännische Und ist das Escape-Zeichen, es muss also zuletzt dekodiert und zuerst escaped werden, sonst kann ein Durchlauf doppelt dekodieren

Der zweite Fix nimmt die späte &nbsp;-Ersetzung aus der Zeichenstufe. Dekodieren gehört zum Parser und nirgendwohin sonst, ein Wort, das den Line Breaker erreicht, ist also finaler Text

Der dritte Fix ist die Grenzregel. NormalizeParsedHTML escapet jetzt &, < und > in jedem dekodierten Wort, bevor es an das normalisierte HTML angehängt wird. Der zweite Parse dekodiert es zurück zu exakt demselben Text, der Nettoeffekt über die ganze Pipeline ist also eine Dekodierung. Der Fortsetzungsstring folgt derselben Regel: Wörter, die nicht in die Box passten, werden escaped, bevor sie an LeftOverText angehängt werden, und der Rest des Rests wird aus dem normalisierten HTML kopiert, das bereits in escapter Form vorliegt. Die Schleife, die diese übrig gebliebenen Wörter einsammelt, ist jetzt zudem durch die Wortanzahl begrenzt, wo die alte Repeat-Schleife über das letzte Wort hinausgehen konnte

Warum kann UTF-16BE-Escaping kein Replace auf Byte-Ebene verwenden?

UTF-16BE-Escaping kann kein Replace auf Byte-Ebene verwenden, weil das Zweibyte-Muster für ein kaufmännisches Und über zwei zusammenhangslose Zeichen hinwegliegen kann. Die einzige korrekte Arbeitseinheit ist die ganze 16-Bit-Code-Einheit

Der Renderer speichert Unicode-Wörter als Big-Endian-UTF-16, gepackt in Byte-Strings, hohes Byte zuerst. Ein kaufmännisches Und ist 00 26. Nehmen Sie nun U+0100 (lateinisches A mit Makron, Bytes 01 00), gefolgt von U+2603 (der Schneemann, Bytes 26 03). Die Bytefolge ist 01 00 26 03, und Byte zwei und drei lesen sich als 00 26. Eine Bytesuche nach #0'&' findet ein kaufmännisches Und, das nicht existiert, fügt die Bytes für &amp; mitten in zwei Zeichen ein und schert jedes folgende Zeichen um ein Byte

PDFlibPas-UTF-16BE-Escaping-Falle, bei der die Bytes 01 00 26 03 für U+0100 und U+2603 das Muster 00 26 über zwei Zeichen hinweg enthalten, sodass eine Suche auf Byte-Ebene das kaufmännische Und mitten in einen Code Point einfügt; der Code-Unit-Scan testet nur gerade Offsets
Eine Bytesuche findet ein kaufmännisches Und, das nie ein Zeichen enthielt; arbeiten Sie auf ganzen Code-Einheiten, nie auf rohen UTF-16-Byte-Puffern

Das ist kein exotischer Grenzfall. Jedes Zeichen, dessen niedriges Byte null ist, kann die erste Hälfte liefern; U+4E00, eines der häufigsten CJK-Ideogramme, qualifiziert sich. Die spitzen Klammern haben dieselbe Exposition: 00 3C und 00 3E tauchen auf, sobald auf so ein Zeichen eines aus U+3C00 bis U+3EFF aus CJK Extension A folgt. Der Fix in EscapeHTMLWord entpackt die Bytes in einen WideString, escapet Zeichen für Zeichen und packt das Ergebnis wieder. Die Dekoderseite war bereits sicher, weil sie Muster nur an geraden Code-Unit-Grenzen testet

Dieselbe Regel gilt für Ihren eigenen Code. Wenn Sie UTF-16-Text als TBytes halten, etwa nach TEncoding.BigEndianUnicode.GetBytes, durchsuchen Sie ihn nicht nach Byte-Mustern. Konvertieren Sie zurück in einen String und arbeiten Sie auf Zeichen

Markdown-Codeblöcke und Dataset-Exporte: das kaufmännische Und zuerst escapen

Seit v3.539.47 escapen beide HTML-Produzenten in PDFlibPas, der Markdown-Konverter und der Dataset-Exporter, das kaufmännische Und vor den spitzen Klammern, sodass die einzige Dekodierung im Renderer exakt den Originaltext wiederherstellt

In MarkdownToHTML mappen inline Code-Spans und fenced oder eingerückte Codeblöcke jetzt & auf &amp;, < auf &lt; und > auf &gt;, während Leerzeichen zu &nbsp; werden und ein Tab zu deren Vieren, um die Einrückung zu halten. Gewöhnliche Markdown-Prosa escapet nur die spitzen Klammern, rohes HTML in Prosa kann also keine Tags injizieren, während ein Autor &amp; weiterhin absichtlich schreiben kann, so wie Markdown-Autoren das erwarten. DrawMarkdownText und DrawMarkdownTextBox benutzen dieselbe Konversion, Code erscheint im PDF also exakt wie getippt:

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
    // HTML ansehen: im Code wird '&' zu '&amp;' und '<' zu '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // Ursprung oben links, Y wächst nach unten
    Lib.SetMeasurementUnits(0);  // Punkte
    // Die Seite zeigt den Code exakt wie getippt, Entity-Schreibweisen eingeschlossen
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Der Dataset-Exporter ist der lehrreiche Fall. Vor v3.539.47 escapte er nur spitze Klammern, und zwar mit Absicht: Der Renderer dekodierte &amp; nicht, das Escapen des kaufmännischen Und hätte also in jeder Zelle, die eines enthält, &amp; gedruckt. Der Workaround war für den alten Renderer korrekt und im Allgemeinen falsch, denn ein Zellwert, der zufällig &lt; enthielt, wurde zu < dekodiert. Mit dem reparierten Renderer escapet der Exporter zuerst &, und ein Wert wie R&D &lt; &amp; &nbsp; landet wortwörtlich im PDF. Wenn Sie Reports auf diese Weise bauen, behandelt die Anleitung zum Export eines TDataSet in einen PDF-Report in Delphi den Rest des Exporters

Warum das kaufmännische Und zuerst kommen muss, lohnt sich einmal ausbuchstabieren. Escapen Sie < zuerst, erhalten Sie &lt;; escapen Sie & als Zweites, wird daraus &amp;lt;, was eine korrekte einzelne Dekodierung als &lt; statt < anzeigt. Eine sequenzielle Replace-Kette ist nur korrekt, wenn das Escape-Zeichen selbst vor allem behandelt wird, was es einführt

Wie escapen Sie nicht vertrauenswürdigen Text für DrawHTMLTextBox?

Für das PDFlibPas-HTML-Rendering escapen Sie nicht vertrauenswürdigen Textinhalt, indem Sie &, dann <, dann > ersetzen, genau einmal, und halten Sie nicht vertrauenswürdige Daten vollständig aus Attributwerten heraus

uses
  System.SysUtils, PDFlibrary;

// Escapet nicht vertrauenswürdigen Text für PDFlibPas-HTML-Textinhalt.
// '&' muss zuerst ersetzt werden, sonst würde das kaufmännische Und
// in einem bereits erzeugten '&lt;' ein zweites Mal escaped
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;

Auf v3.539.47 erscheint ein Kommentar wie Try <a href="https://example.com">this</a> & &lt;b&gt; zeichen für zeichen auf der Seite. Vor v3.539.47 konnte dieselbe escaped Eingabe eine lebende Link-Annotation erzeugen, und genau das macht aus einem Anzeige-Glitch ein Sicherheitsproblem: Ein Ticketkommentar sollte nie in der Lage sein, eine klickbare URL in ein Dokument zu pflanzen, dem Ihr Personal vertraut

Beachten Sie, was die Funktion nicht escapet. Allgemeine HTML-Escaper konvertieren auch " zu &quot; und ' zu &#39;, was für einen Browser richtig ist. Die PDFlibPas-Textdekodierung erkennt nur die vier zuvor aufgeführten Entities, diese beiden würden also wörtlich als &quot; und &#39; gedruckt. Anführungszeichen sind in Textinhalten harmlos; sie zählen nur innerhalb von Attributwerten, und der Renderer dekodiert Entities in Attributen überhaupt nicht. Das sichere Design ist daher kein besserer Escaper, sondern eine Regel: Nicht vertrauenswürdige Daten gehen nie in href, src oder style. Muss ein Linkziel wirklich aus Benutzerdaten kommen, validieren Sie es selbst gegen eine Allow-Liste von Schemata und Zeichen und weisen alles zurück, das Anführungszeichen oder spitze Klammern enthält

Zwei Upgrade-Hinweise folgen direkt aus dem Fix:

  • Falls Ihr Code aufgehört hat, & zu escapen, weil ältere Versionen &amp; wörtlich druckten, nehmen Sie es zurück auf. Ohne es zeigt Benutzertext mit &lt; jetzt < an, weiterhin harmloser Text, aber nicht mehr das, was der Benutzer getippt hat
  • Escapen Sie nicht doppelt. Text, der durch zwei Escaper läuft, rendert < als die sichtbare Schreibweise &lt;, finden Sie also die eine Grenze, an der Ihre Daten ins HTML eintreten, und escapen Sie nur dort

Paginieren mit LeftOverText, ohne Escapes zu brechen

DrawHTMLTextBox gibt das HTML zurück, das nicht hineinpasste, meist LeftOverText genannt, und seit v3.539.47 bewahrt dieser Rest wörtliche Entity-Schreibweisen und escaped spitze Klammern, wenn Sie ihn an die nächste Box übergeben. Die Regel für Aufrufer ist simpel: unverändert zurückgeben

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // bemessen für eine A4-Seite in Punkten
  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 ist bereits escaped Engine-HTML: nie escapen oder unescapen
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Behandeln Sie den Rest als opak. Es ist das normalisierte HTML der Engine, mit bereits aufgelösten Styles, lassen Sie ihn also nicht durch Ihren eigenen Escaper laufen, dekodieren Sie ihn nicht und fügen Sie keinen Benutzertext hinein. Das Seitenlimit ist billige Versicherung: Wenn ein Element nie in die Box passt, hat eine unlimitierte Schleife keinen natürlichen Ausgang

Markdown hat seine eigene Fortsetzung. DrawMarkdownTextBox gibt ein Token zurück, das mit einem internen Marker beginnt, damit der nächste Aufruf die Konversion überspringen kann; geben Sie es an DrawMarkdownTextBox oder DrawMarkdownText zurück, nicht an die HTML-Einstiegspunkte, die den Marker als Text zeichnen würden

Die allgemeine Lektion: einmal dekodieren, an jeder Grenze neu kodieren

Jede Pipeline, die Text parst, das Ergebnis zurück in dieselbe Syntax serialisiert und es erneut parst, muss Dekodieren als eine Operation behandeln, die an genau einer Stelle passiert, und muss an jeder Grenze neu kodieren, an der dekodierter Text wieder zu Syntax wird. Template-Engines, HTML-Sanitizer und Markdown-zu-HTML-zu-PDF-Ketten haben dieselbe Form und scheitern auf dieselbe Weise, wenn ein Serialisierer vergisst, dass er Markup produziert

Die Symptome sind vorhersehbar, sobald Sie die Form kennen. Zu wenig Re-Kodieren verwandelt Daten in Syntax, das ist die Injektionsrichtung. Zu viel Kodieren oder ein Dekoder, der zweimal läuft, zeigt dem Leser Entity-Schreibweisen oder frisst sie, das ist die Anzeigerichtung. Nur eine Richtung zu fixen bricht meist die andere, deshalb musste der PDFlibPas-Fix &amp;-Dekodierung ergänzen, sie umordnen, die späte Dekodierung entfernen und Re-Escaping ergänzen — alles im selben Release. Dasselbe Prinzip läuft umgekehrt, wenn PDF-Inhalt als strukturierter Text exportiert wird, wie in PDF zu Markdown und DOCX semantischer Export aus Delphi, wo jedes wörtliche Zeichen für die Zielsyntax exakt einmal escaped werden muss

Kurzreferenz-Checkliste

  • Aktualisieren Sie auf PDFlibPas v3.539.47 oder später, wenn Sie HTML oder Markdown mit Benutzerdaten rendern
  • Escapen Sie Textinhalt zuerst mit &, dann < und >; konvertieren Sie für PDFlibPas-Text keine Anführungszeichen
  • Escapen Sie einmal, an der einzigen Stelle, an der Daten in den HTML-String eintreten
  • Halten Sie nicht vertrauenswürdige Werte aus href, src und style heraus, oder validieren Sie sie gegen eine Allow-Liste
  • Rechnen Sie damit, dass nur &lt;, &gt;, &amp; und &nbsp; im Text dekodiert werden; andere Entities bleiben wörtlich
  • Geben Sie LeftOverText unverändert an DrawHTMLTextBox zurück und begrenzen Sie die Seiten-Schleife
  • Geben Sie Markdown-Fortsetzungs-Token nur an DrawMarkdownTextBox oder DrawMarkdownText
  • Durchsuchen Sie UTF-16-Byte-Puffer nie nach Byte-Mustern; arbeiten Sie auf ganzen Code-Einheiten

HTML- und Markdown-Rendering, Dataset-Report-Export und der Rest der Layout-Engine kommen im nativen Pascal-Quelltext der PDF Library for Delphi, für Delphi und Free Pascal. Editionen, Plattformunterstützung und einen Testdownload finden Sie auf der PDFlibPas-Produktseite