Articolo tecnico

PDFlibPas HTML in PDF: entità decodificate due volte

Le versioni di PDF Library for Delphi (PDFlibPas) precedenti alla v3.539.47 potevano decodificare due volte il testo con escape quando si disegnava HTML o Markdown in un PDF. DrawHTMLText e DrawHTMLTextBox analizzano l'HTML, lo normalizzano di nuovo in HTML, poi lo analizzano un'altra volta, così il testo scritto come <unsafe> arrivava alla seconda analisi come un tag vero e proprio. Dalla v3.539.47 ogni entità viene decodificata esattamente una volta e il testo viene ri-sottoposto a escape ovunque torni a essere HTML

Lo scenario che lo espone è banale. Un help desk esporta i ticket in PDF, e il commento del cliente finisce in un template HTML. Lo sviluppatore ha fatto la cosa giusta e ha messo in escape il commento, così <b> è diventato &lt;b&gt;. Dentro il renderer quell'escape veniva silenziosamente annullato: il commento usciva in grassetto, un nome di tag sconosciuto spariva semplicemente dalla pagina, e un'ancora con escape diventava un'annotazione link cliccabile. Nessuna eccezione, nessun avviso, un PDF perfettamente valido che dice una cosa diversa dai dati

Perché il testo con escape diventa un tag vero nel PDF?

Il testo con escape diventava markup perché il renderer esegue due passate di analisi, e il passo di normalizzazione tra le due riscriveva in HTML testo già decodificato senza sottoporlo di nuovo a escape. Ogni decodifica compiuta dalla prima passata era così disponibile alla seconda come sintassi viva

Le due passate esistono per un buon motivo. La prima analisi costruisce una lista di elementi tag e parola. NormalizeParsedHTML poi risolve la cascata del foglio di stile: confronta le regole dei blocchi <style> con ogni tag, le fonde con gli attributi style inline, memorizza il risultato sul tag e riserializza l'intera lista di elementi in una stringa HTML. La passata di layout analizza quella stringa normalizzata. È la stessa macchina che guida flexbox, CSS grid e layout delle note a piè di pagina nel rendering HTML di PDFlibPas

Il difetto stava nel modo in cui le parole venivano serializzate. I tag venivano riscritti nella loro forma sorgente originale, mentre le parole venivano riscritte nella forma decodificata. Una parola che la prima analisi aveva decodificato da &lt;unsafe&gt; a <unsafe> atterrava nell'HTML normalizzato come parentesi angolari crude, e la seconda analisi la leggeva come elemento. Attorno a questo bug centrale stavano tre perdite minori che puntavano nella stessa direzione:

  • &amp; non era nell'insieme di entità supportate, così R&amp;D si stampava letteralmente e non c'era modo di scrivere come testo una grafia di entità letterale come &lt;
  • La fase di disegno sostituiva &nbsp; una seconda volta, dopo che l'analisi era già finita, così una grafia di entità letterale poteva ancora sparire all'ultimo
  • L'escape del codice Markdown saltava la e commerciale, e l'esportatore di dataset metteva in escape solo le parentesi angolari, così le grafie di entità dentro il codice o i valori delle celle venivano decodificate come markup
Pipeline HTML di PDFlibPas per DrawHTMLText in cui la prima analisi costruisce gli elementi, NormalizeParsedHTML li riserializza in HTML e la seconda analisi impagina il risultato; prima della v3.539.47 le parole decodificate venivano riscritte senza escape e diventavano tag vivi, dalla v3.539.47 ogni parola viene ri-sottoposta a escape al confine
Le parole decodificate rientrano nel parser come sintassi quando il normalizzatore dimentica di produrre markup, ed è così che un commento con escape finiva in grassetto o si piantava sopra un link
Input che arriva al rendererPrima della v3.539.47Dalla v3.539.47
&lt;unsafe&gt;Analizzato come tag, il testo non arriva mai alla pagina<unsafe> disegnato come testo
&lt;b&gt;x&lt;/b&gt;x disegnato in grassetto<b>x</b> disegnato come testo
R&amp;DR&amp;D stampato letteralmenteR&D
&amp;lt;&amp;lt; stampato letteralmente&lt;
Code span Markdown contenente &nbsp;Diventava uno spazio non divisibile&nbsp; disegnato come testo
Valore di cella del dataset &lt;<&lt;

Come la v3.539.47 rende la decodifica delle entità HTML a passata unica

PDFlibPas v3.539.47 rende la decodifica delle entità a passata unica con tre modifiche coordinate: il parser decodifica &amp; per ultimo, la fase di disegno non decodifica più nulla, e ogni punto che riconverte le parole decodificate in HTML le sottopone prima di nuovo a escape

L'insieme di entità supportate per il contenuto testuale ora è &lt;, &gt;, &amp; e &nbsp;. Tutto il resto, inclusi i riferimenti numerici come &#65; e le entità nominate come &quot;, resta testo letterale. Quel confine conta per il modo in cui metti in escape il tuo input, come mostrato sotto

L'ordine dentro il decoder è la prima correzione. Se &amp; venisse decodificato per primo, l'input &amp;lt; diventerebbe &lt; e la sostituzione successiva lo trasformerebbe in <, una doppia decodifica che avviene dentro una singola passata. Il percorso delle parole ANSI quindi sostituisce &lt;, &gt; e &nbsp; per prime e &amp; per ultima, così la e commerciale che produce non viene più esaminata. Il percorso delle parole UTF-16 è una singola scansione da sinistra a destra a passi di due byte che riscrive ogni corrispondenza sul posto e la salta, il che dà la stessa garanzia strutturalmente

Ordine del decoder PDFlibPas per una entità concatenata come &amp;lt;: decodificare prima la e commerciale la collassa in una vera parentesi angolare dentro una singola passata, mentre decodificare lt, gt e nbsp prima della e commerciale mantiene intatta la grafia letterale così il testo arriva alla pagina decodificato esattamente una volta
La e commerciale è il carattere di escape, quindi va decodificata per ultima e sottoposta a escape per prima, altrimenti una passata può decodificare due volte

La seconda correzione rimuove la tardiva sostituzione di &nbsp; dalla fase di disegno. La decodifica appartiene al parser e a nessun altro, quindi una parola che raggiunge il line breaker è testo definitivo

La terza correzione è la regola del confine. NormalizeParsedHTML ora mette in escape &, < e > in ogni parola decodificata prima di aggiungerla all'HTML normalizzato. La seconda analisi la decodifica di nuovo nell'esatto stesso testo, quindi l'effetto netto sull'intera pipeline è una decodifica. La stringa di continuazione segue la stessa regola: le parole che non entravano nella box vengono sottoposte a escape prima di essere aggiunte a LeftOverText, e il resto del residuo viene copiato dall'HTML normalizzato, che è già in forma con escape. Il ciclo che raccoglie quelle parole residue ora è anche limitato dal conteggio delle parole, mentre il vecchio ciclo repeat poteva scavalcare l'ultima parola

Perché l'escape UTF-16BE non può usare una sostituzione a livello di byte?

L'escape UTF-16BE non può usare una sostituzione a livello di byte perché il pattern a due byte di una e commerciale può cavalcare due caratteri tra loro estranei. L'unica unità di lavoro corretta è l'intera code unit a 16 bit

Il renderer memorizza le parole Unicode come UTF-16 big-endian impacchettato in stringhe di byte, byte alto prima. Una e commerciale è 00 26. Prendi ora U+0100 (A maiuscola latina con macron, byte 01 00) seguita da U+2603 (il pupazzo di neve, byte 26 03). La sequenza di byte è 01 00 26 03, e i byte due e tre si leggono 00 26. Una ricerca per byte di #0'&' trova una e commerciale che non esiste, innesta i byte di &amp; in mezzo a due caratteri e trancia di un byte ogni carattere successivo

Rischio dell'escape UTF-16BE in PDFlibPas dove i byte 01 00 26 03 di U+0100 e U+2603 contengono il pattern 00 26 attraverso due caratteri, così una ricerca a livello di byte della e commerciale innesta una entità in mezzo a un code point; la scansione a code unit testa solo gli offset pari
Una ricerca per byte trova una e commerciale che nessun carattere ha mai contenuto; lavora su code unit intere, mai su buffer di byte UTF-16 crudi

Non è un caso limite esotico. Qualsiasi carattere il cui byte basso è zero può fornire la prima metà; U+4E00, uno degli ideogrammi CJK più frequenti, ci sta. Le parentesi angolari hanno la stessa esposizione: 00 3C e 00 3E compaiono ogni volta che un tale carattere è seguito da uno tra U+3C00 e U+3EFF in CJK Extension A. La correzione in EscapeHTMLWord spacchetta i byte in una WideString, sottopone a escape carattere per carattere e ri-impacchetta il risultato. Il lato decoder era già al sicuro perché testa i pattern solo ai confini pari delle code unit

La stessa regola vale per il tuo codice. Se mai tieni testo UTF-16 come TBytes, per esempio dopo TEncoding.BigEndianUnicode.GetBytes, non cercarci pattern di byte. Riconverti in stringa e lavora sui caratteri

Blocchi di codice Markdown ed export di dataset: prima l'escape della e commerciale

Dalla v3.539.47 entrambi i produttori HTML dentro PDFlibPas, il convertitore Markdown e l'esportatore di dataset, sottopongono a escape la e commerciale prima delle parentesi angolari, così la singola decodifica nel renderer ripristina esattamente il testo originale

In MarkdownToHTML, i code span inline e i blocchi di codice recintati o indentati ora mappano & a &amp;, < a &lt; e > a &gt;, mentre gli spazi diventano &nbsp; e una tab diventa quattro di questi per conservare l'indentazione. La prosa Markdown ordinaria mette in escape solo le parentesi angolari, così l'HTML crudo nella prosa non può iniettare tag mentre un autore può ancora scrivere &amp; di proposito, più o meno come gli autori Markdown si aspettano. DrawMarkdownText e DrawMarkdownTextBox usano la stessa conversione, quindi il codice appare nel PDF esattamente come digitato:

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
    // Ispeziona l'HTML: nel codice, '&' diventa '&amp;' e '<' diventa '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // origine in alto a sinistra, Y cresce verso il basso
    Lib.SetMeasurementUnits(0);  // punti
    // La pagina mostra il codice esattamente come digitato, grafie di entità incluse
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

L'esportatore di dataset è il caso istruttivo. Prima della v3.539.47 metteva in escape solo le parentesi angolari, e di proposito: il renderer non decodificava &amp;, quindi mettere in escape la e commerciale avrebbe stampato &amp; in ogni cella che ne conteneva una. La soluzione di ripiego era corretta per il vecchio renderer e sbagliata in generale, perché un valore di cella che per caso conteneva &lt; veniva decodificato in <. Con il renderer corretto, l'esportatore mette in escape & per primo, e un valore come R&D &lt; &amp; &nbsp; atterra nel PDF alla lettera. Se costruisci i report in quel modo, la guida su esportare un TDataSet in un report PDF in Delphi copre il resto dell'esportatore

Perché la e commerciale debba andare per prima merita di essere detto una volta. Metti in escape < per primo e ottieni &lt;; metti in escape & per secondo e quello diventa &amp;lt;, che una corretta decodifica singola mostra come &lt; invece che <. Una catena di sostituzioni sequenziali è corretta solo quando il carattere di escape stesso viene gestito prima di tutto ciò che lo introduce

Come mettere in escape testo non fidato per DrawHTMLTextBox?

Per il rendering HTML di PDFlibPas, metti in escape il contenuto testuale non fidato sostituendo &, poi <, poi >, esattamente una volta, e tieni i dati non fidati del tutto fuori dai valori degli attributi

uses
  System.SysUtils, PDFlibrary;

// Fa l'escape del testo non fidato per il contenuto HTML di PDFlibPas.
// '&' va sostituito per primo, altrimenti l'ampersand dentro
// un '&lt;' già prodotto verrebbe sottoposto a escape una seconda volta
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;

Con la v3.539.47 un commento come Try <a href="https://example.com">this</a> & &lt;b&gt; appare sulla pagina carattere per carattere. Prima della v3.539.47 lo stesso input con escape poteva produrre una annotazione link viva, ed è la parte che trasforma un difetto di visualizzazione in un problema di sicurezza: un commento di ticket non dovrebbe mai poter piantare un URL cliccabile in un documento di cui il tuo staff si fida

Nota ciò che la funzione non mette in escape. Gli escaper HTML di uso generale convertono anche " in &quot; e ' in &#39;, che è giusto per un browser. La decodifica testuale di PDFlibPas riconosce solo le quattro entità elencate prima, quindi quelle due si stamperebbero letteralmente come &quot; e &#39;. I virgoletti sono innocui nel contenuto testuale; contano solo dentro i valori degli attributi, e il renderer non decodifica affatto le entità negli attributi. Il progetto sicuro quindi non è un escaper migliore ma una regola: i dati non fidati non entrano mai in href, src o style. Se un target di link deve davvero venire dai dati utente, validalo tu stesso contro una allow-list di schemi e caratteri e rifiuta tutto ciò che contiene virgolette o parentesi angolari

Due note di aggiornamento discendono direttamente dalla correzione:

  • Se il tuo codice ha smesso di mettere in escape & perché le versioni precedenti stampavano &amp; letteralmente, rimetticelo. Senza, il testo utente contenente &lt; ora si mostra come <, testo ancora innocuo ma non più ciò che l'utente ha digitato
  • Non fare l'escape due volte. Il testo che passa attraverso due escaper rende < come la grafia visibile &lt;, quindi trova l'unico confine in cui i tuoi dati entrano nell'HTML e fallo lì soltanto

Impaginare con LeftOverText senza rompere gli escape

DrawHTMLTextBox restituisce l'HTML che non è entrato, di solito chiamato LeftOverText, e dalla v3.539.47 quel residuo conserva le grafie di entità letterali e le parentesi angolari con escape quando lo passi alla box successiva. La regola per chi chiama è semplice: ripassalo invariato

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimensionato per una pagina A4 in punti
  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 è già HTML con escape del motore: mai rifargli escape o decodificarlo
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Tratta il residuo come opaco. È l'HTML normalizzato del motore, con gli stili già risolti, quindi non farlo passare dal tuo escaper, non decodificarlo e non innestarci dentro testo utente. Il tetto di pagine è un'assicurazione economica: se qualche elemento non può mai entrare nella box, un ciclo senza tetto non ha uscita naturale

Il Markdown ha la sua continuazione. DrawMarkdownTextBox restituisce un token che inizia con un marcatore interno così che la chiamata successiva possa saltare la conversione; ripassalo a DrawMarkdownTextBox o DrawMarkdownText, non ai punti d'ingresso HTML, che disegnerebbero il marcatore come testo

La lezione generale: decodifica una volta, ri-codifica a ogni confine

Qualsiasi pipeline che analizza testo, riserializza il risultato nella stessa sintassi e lo analizza di nuovo deve trattare la decodifica come un'operazione che avviene in esattamente un posto, e deve ri-codificare a ogni confine in cui il testo decodificato ridiventa sintassi. I motori di template, i sanitizzatori HTML e le catene Markdown-to-HTML-to-PDF condividono questa forma e falliscono allo stesso modo quando un serializzatore dimentica di produrre markup

I sintomi sono prevedibili una volta che conosci la forma. Troppo poca ri-codifica trasforma dati in sintassi, ed è la direzione dell'injection. Troppa codifica, o un decoder che gira due volte, mostra al lettore le grafie di entità o se le mangia, ed è la direzione della visualizzazione. Correggere una sola direzione di solito rompe l'altra, ed è per questo che la correzione di PDFlibPas ha dovuto aggiungere la decodifica di &amp;, riordinarla, rimuovere la decodifica tardiva e aggiungere il ri-escape nella stessa release. Lo stesso principio gira al contrario quando il contenuto PDF viene esportato come testo strutturato, come in esportazione semantica da PDF a Markdown e DOCX in Delphi, dove ogni carattere letterale va sottoposto a escape per la sintassi di destinazione esattamente una volta

Checklist di riferimento rapido

  • Passa a PDFlibPas v3.539.47 o successiva se rendi HTML o Markdown che contiene dati utente
  • Metti in escape il contenuto testuale con & per primo, poi < e >; non convertire i virgoletti per il testo di PDFlibPas
  • Escape una volta sola, nell'unico punto in cui i dati entrano nella stringa HTML
  • Tieni i valori non fidati fuori da href, src e style, oppure validali contro una allow-list
  • Aspettati che in testo vengano decodificate solo &lt;, &gt;, &amp; e &nbsp;; le altre entità restano letterali
  • Ripassa LeftOverText invariato a DrawHTMLTextBox e limita il ciclo di pagine
  • Ripassa i token di continuazione Markdown solo a DrawMarkdownTextBox o DrawMarkdownText
  • Non cercare mai pattern di byte nei buffer UTF-16; lavora su code unit intere

Il rendering HTML e Markdown, l'export di report da dataset e il resto del motore di layout viaggiano nel sorgente Pascal nativo di PDF Library for Delphi, per Delphi e Free Pascal. Vedi la pagina prodotto di PDFlibPas per edizioni, piattaforme supportate e un download di prova