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 <b>. 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 <unsafe> 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:
&war nicht in der unterstützten Entity-Menge,R&Dwurde also wörtlich gedruckt, und es gab keinen Weg, eine wörtliche Entity-Schreibweise wie<als Text zu schreiben- Die Zeichenstufe ersetzte
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
| Eingabe, die den Renderer erreicht | Vor v3.539.47 | Seit v3.539.47 |
|---|---|---|
<unsafe> | Als Tag geparst, der Text erreicht nie die Seite | <unsafe> als Text gezeichnet |
<b>x</b> | x fett gezeichnet | <b>x</b> als Text gezeichnet |
R&D | R&D wörtlich gedruckt | R&D |
&lt; | &lt; wörtlich gedruckt | < |
Markdown-Code-Span mit | Wurde ein geschütztes Leerzeichen | als Text gezeichnet |
Dataset-Zellwert < | < | < |
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 & 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 <, >, & und . Alles andere, inklusive numerischer Referenzen wie A und benannter Entities wie ", 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 & zuerst dekodiert, würde die Eingabe &lt; zu <, und die nächste Ersetzung machte daraus < — eine Doppeldekodierung, die innerhalb eines Durchlaufs passiert. Der ANSI-Wortpfad ersetzt daher zuerst <, > und und & 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
Der zweite Fix nimmt die späte -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 & mitten in zwei Zeichen ein und schert jedes folgende Zeichen um ein Byte
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 &, < auf < und > auf >, während Leerzeichen zu 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 & 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// HTML ansehen: im Code wird '&' zu '&' und '<' zu '<'
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 & nicht, das Escapen des kaufmännischen Und hätte also in jeder Zelle, die eines enthält, & gedruckt. Der Workaround war für den alten Renderer korrekt und im Allgemeinen falsch, denn ein Zellwert, der zufällig < enthielt, wurde zu < dekodiert. Mit dem reparierten Renderer escapet der Exporter zuerst &, und ein Wert wie R&D < & 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 <; escapen Sie & als Zweites, wird daraus &lt;, was eine korrekte einzelne Dekodierung als < 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 '<' ein zweites Mal escaped
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [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> & <b> 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 " und ' zu ', 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 " und ' 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&wörtlich druckten, nehmen Sie es zurück auf. Ohne es zeigt Benutzertext mit<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<, 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 &-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,srcundstyleheraus, oder validieren Sie sie gegen eine Allow-Liste - Rechnen Sie damit, dass nur
<,>,&und im Text dekodiert werden; andere Entities bleiben wörtlich - Geben Sie
LeftOverTextunverändert anDrawHTMLTextBoxzurück und begrenzen Sie die Seiten-Schleife - Geben Sie Markdown-Fortsetzungs-Token nur an
DrawMarkdownTextBoxoderDrawMarkdownText - 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