Articol tehnic

PDFlibPas HTML în PDF: entități decodate de două ori

Versiunile PDF Library for Delphi (PDFlibPas) anterioare lui v3.539.47 puteau decoda textul escapat de două ori când desenau HTML sau Markdown într-un PDF. DrawHTMLText și DrawHTMLTextBox parsează HTML-ul, îl normalizează înapoi în HTML, apoi îl parsează din nou, astfel încât textul scris ca <unsafe> ajungea la a doua parsare ca un tag real. Din v3.539.47 fiecare entitate e decodată exact o dată, iar textul e re-escapat oriunde se întoarce în HTML

Scenariul care expune asta e banal. Un help desk exportă tichete în PDF, iar comentariul clientului intră într-un șablon HTML. Dezvoltatorul a făcut lucrul corect și a escapat comentariul, deci <b> a devenit &lt;b&gt;. În interiorul renderer-ului escaparea aceea era desfăcută pe furiș: comentariul ieșea bold, un nume de tag necunoscut dispărea pur și simplu de pe pagină, iar un anchor escapat se transforma într-o adnotare de link pe care se poate da click. Nicio excepție, niciun avertisment, un PDF perfect valid care spune altceva decât datele

De ce devine textul escapat un tag real în PDF?

Textul escapat devenea markup pentru că renderer-ul rulează două treceri de parsare, iar pasul de normalizare dintre ele scria textul deja decodat înapoi în HTML fără să-l escape din nou. Fiecare decodare pe care prima parsare o făcea era apoi disponibilă celei de-a doua parsări drept sintaxă vie

Cele două treceri există cu un motiv bun. Prima parsare construiește o listă de elemente tag și word. NormalizeParsedHTML rezolvă apoi cascada de stylesheet: potrivește regulile din blocurile <style> cu fiecare tag, le îmbină cu atributele inline style, stochează rezultatul pe tag și serializează toată lista de elemente înapoi într-un șir HTML. Trecerea de layout parsează acel șir normalizat. E aceeași mașinărie care alimentează flexbox, CSS grid și layout-ul de note de subsol în randarea HTML din PDFlibPas

Defectul stătea în felul în care erau serializate cuvintele. Tag-urile erau scrise înapoi din forma lor sursă originală, în timp ce cuvintele erau scrise înapoi în forma decodată. Un cuvânt pe care prima parsare îl decodase din &lt;unsafe&gt; în <unsafe> ateriza în HTML-ul normalizat ca paranteze unghiulare brute, iar a doua parsare îl citea ca element. În jurul bug-ului central stăteau trei scurgeri mai mici care arătau în aceeași direcție:

  • &amp; nu era în mulțimea de entități suportate, deci R&amp;D se tipărea literal și nu exista nicio cale de a scrie o ortografie de entitate literală precum &lt; ca text
  • Etapa de desenare înlocuia &nbsp; a doua oară, după ce parsarea se terminase deja, astfel încât o ortografie de entitate literală putea totuși dispărea chiar la final
  • Escaparea codului Markdown sărita ampersand-ul, iar exportatorul de dataset escapa doar parantezele unghiulare, astfel încât ortografiile de entități din cod sau din valorile celulelor erau decodate ca markup
Pipeline-ul HTML PDFlibPas pentru DrawHTMLText, în care parsarea unu construiește elemente, NormalizeParsedHTML le serializează înapoi în HTML, iar parsarea doi așază rezultatul; înainte de v3.539.47 cuvintele decodate erau scrise înapoi neescapate și deveneau tag-uri vii, din v3.539.47 fiecare cuvânt e re-escapat la graniță
Cuvintele decodate reintră în parser ca sintaxă când normalizatorul uită că produce markup, exact felul în care un comentariu escapat ieșea bold sau își sprăunea un link
Input care ajunge la rendererÎnainte de v3.539.47Din v3.539.47
&lt;unsafe&gt;Parsat ca tag, textul nu ajunge niciodată pe pagină<unsafe> desenat ca text
&lt;b&gt;x&lt;/b&gt;x desenat bold<b>x</b> desenat ca text
R&amp;DR&amp;D tipărit literalR&D
&amp;lt;&amp;lt; tipărit literal&lt;
Code span Markdown care conține &nbsp;Devenea un spațiu non-breaking&nbsp; desenat ca text
Valoare de celulă de dataset &lt;<&lt;

Cum face v3.539.47 decodarea entităților HTML într-o singură trecere

PDFlibPas v3.539.47 face decodarea entităților într-o singură trecere cu trei schimbări coordonate: parser-ul decodează &amp; ultima, etapa de desenare nu mai decodează nimic, iar fiecare loc care întoarce cuvintele decodate în HTML le escapează mai întâi

Mulțimea de entități suportate pentru conținutul de text e acum &lt;, &gt;, &amp; și &nbsp;. Orice altceva, inclusiv referințele numerice precum &#65; și entitățile cu nume precum &quot;, rămâne text literal. Granița aceea contează pentru felul în care escapați input-ul propriu, după cum se arată mai jos

Ordinea din interiorul decoder-ului e prima reparare. Dacă &amp; ar fi decodat primul, input-ul &amp;lt; ar deveni &lt;, iar următoarea înlocuire l-ar transforma în <, o dublă decodare care se întâmplă în interiorul unei singure treceri. Calea de cuvinte ANSI înlocuiește deci &lt;, &gt; și &nbsp; primele și &amp; ultima, astfel încât ampersand-ul pe care îl produce nu mai e examinat niciodată. Calea de cuvinte UTF-16 e o singură scanare de la stânga la dreapta în pași de doi octeți, care rescrie fiecare potrivire în loc și trece mai departe, ceea ce dă aceeași garanție structural

Ordinea decoder-ului PDFlibPas pentru o entitate înlănțuită precum &amp;lt;: decodarea ampersand-ului primul îl colapsează într-o paranteză unghiulară reală în interiorul unei singure treceri, în timp ce decodarea lt, gt și nbsp înaintea ampersand-ului păstrează intactă ortografia literală, astfel încât textul ajunge pe pagină decodat exact o dată
Ampersand-ul e caracterul de escape, deci trebuie decodat ultimul și escapat primul, altfel o trecere poate decoda de două ori

A doua reparare elimină înlocuirea târzie a lui &nbsp; din etapa de desenare. Decodarea aparține parser-ului și nimănui altcuiva, deci un cuvânt care ajunge la line breaker e text final

A treia reparare e regula de graniță. NormalizeParsedHTML escapează acum &, < și > în fiecare cuvânt decodat înainte să-l adauge la HTML-ul normalizat. A doua parsare îl decodează înapoi la exact același text, deci efectul net peste tot pipeline-ul e o singură decodare. Șirul de continuare urmează aceeași regulă: cuvintele care nu au încăput în casetă sunt escapate înainte să fie adăugate la LeftOverText, iar restul remainder-ului e copiat din HTML-ul normalizat, care e deja în formă escapată. Bucla care adună cuvintele rămase e limitată acum și de numărul de cuvinte, pe când vechea buclă repeat putea trece peste ultimul cuvânt

De ce nu poate escaparea UTF-16BE folosi o înlocuire la nivel de octet?

Escaparea UTF-16BE nu poate folosi o înlocuire la nivel de octet pentru că tiparul pe doi octeți al unui ampersand poate umbla peste două caractere fără legătură. Singura unitate de lucru corectă e unitatea de cod completă pe 16 biți

Renderer-ul stochează cuvintele Unicode ca UTF-16 big-endian împachetat în șiruri de octeți, octetul înalt primul. Un ampersand e 00 26. Luați acum U+0100 (A mare latină cu macron, octeții 01 00) urmat de U+2603 (omul de zăpadă, octeții 26 03). Secvența de octeți e 01 00 26 03, iar octeții doi și trei se citesc 00 26. O căutare pe octeți după #0'&' găsește un ampersand care nu există, inserează prin splice octeții pentru &amp; în mijlocul a două caractere și taie cu un octet fiecare caracter următor

Pericolul escapării UTF-16BE din PDFlibPas, unde octeții 01 00 26 03 pentru U+0100 și U+2603 conțin tiparul 00 26 peste două caractere, astfel încât o căutare la nivel de octet a ampersand-ului inserează prin splice o entitate în mijlocul unui code point; scanarea pe unități de cod testează doar offset-urile pare
O căutare pe octeți găsește un ampersand pe care niciun caracter nu l-a conținut vreodată; lucrați pe unități de cod întregi, niciodată pe buffere brute de octeți UTF-16

Nu e un caz-limită exotic. Orice caracter al cărui octet jos e zero poate furniza prima jumătate; U+4E00, unul dintre cele mai frecvente ideografe CJK, se califică. Parantezele unghiulare au aceeași expunere: 00 3C și 00 3E apar ori de câte ori un asemenea caracter e urmat de unul din U+3C00 până la U+3EFF din CJK Extension A. Repararea din EscapeHTMLWord despachetează octeții într-un WideString, escapează caracter cu caracter și reîmpachetează rezultatul. Partea de decoder era deja sigură pentru că testează tiparele doar la frontiere pare de unități de cod

Aceeași regulă se aplică codului propriu. Dacă vreodată țineți text UTF-16 ca TBytes, de exemplu după TEncoding.BigEndianUnicode.GetBytes, nu-l căutați după tipare de octeți. Convertiți-l înapoi în string și lucrați pe caractere

Blocuri de cod Markdown și exporturi de dataset: escapați mai întâi ampersand-ul

Din v3.539.47 ambele producătoare de HTML din interiorul PDFlibPas, convertorul Markdown și exportatorul de dataset, escapează ampersand-ul înaintea parantezelor unghiulare, astfel încât decodarea unică din renderer restaurează exact textul original

În MarkdownToHTML, code span-urile inline și blocurile de cod fenced sau indentate mapează acum & la &amp;, < la &lt; și > la &gt;, în timp ce spațiile devin &nbsp;, iar un tab devine patru dintre ele pentru a păstra indentarea. Proza Markdown obișnuită escapează doar parantezele unghiulare, deci HTML-ul brut din proză nu poate injecta tag-uri, în timp ce un autor poate scrie în continuare &amp; din principiu, cam cum se așteaptă autorii de Markdown. DrawMarkdownText și DrawMarkdownTextBox folosesc aceeași conversie, deci codul apare în PDF exact cum a fost tastat:

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
    // Examinați HTML-ul: în cod, '&' devine '&amp;', iar '<' devine '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // origine stânga-sus, Y crește în jos
    Lib.SetMeasurementUnits(0);  // puncte
    // Pagina arată codul exact cum a fost tastat, ortografiile de entități incluse
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Exportatorul de dataset e cazul edificator. Înainte de v3.539.47 escapea doar parantezele unghiulare, și anume intenționat: renderer-ul nu decoda &amp;, deci escaparea ampersand-ului ar fi tipărit &amp; în fiecare celulă care conținea unul. Soluția de ocolere era corectă pentru vechiul renderer și greșită în general, pentru că o valoare de celulă care se întâmpla să conțină &lt; era decodată în <. Cu renderer-ul reparat, exportatorul escapează & primul, iar o valoare precum R&D &lt; &amp; &nbsp; ajunge în PDF întocmai. Dacă construiți rapoarte așa, plimbarea ghidată pe exportul unui TDataSet într-un raport PDF în Delphi acoperă restul exportatorului

De ce trebuie să meargă ampersand-ul primul merită rostit o dată. Escapați < primul și obțineți &lt;; escapați & al doilea și acela devine &amp;lt;, pe care o decodare unică corectă îl afișează ca &lt; în loc de <. Un lanț de înlocuiri secvențial e corect doar când caracterul de escape însuși e tratat înaintea oricui îl introduce

Cum ar trebui să escapați textul nesigur pentru DrawHTMLTextBox?

Pentru randarea HTML din PDFlibPas, escapați conținutul de text nesigur înlocuind &, apoi <, apoi >, exact o dată, și țineți datele nesigure complet departe de valorile atributelor

uses
  System.SysUtils, PDFlibrary;

// Escapează textul nesigur pentru conținutul de text HTML din PDFlibPas.
// '&' trebuie înlocuit primul, altfel ampersand-ul din interiorul
// unui '&lt;' deja produs ar fi escapeat a doua oară
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;

Pe v3.539.47 un comentariu precum Try <a href="https://example.com">this</a> & &lt;b&gt; apare pe pagină caracter cu caracter. Înainte de v3.539.47 același input escapat putea produce o adnotare de link vie, ceea ce e partea care transformă o defecțiune de afișare într-o problemă de securitate: un comentariu de tichet nu ar trebui să poată niciodată planta un URL pe care se poate da click într-un document în care personalul are încredere

Remarcați ce nu escapează funcția. Escaperele HTML de uz general convertesc și " în &quot; și ' în &#39;, ceea ce e corect pentru un browser. Decodarea de text din PDFlibPas recunoaște doar cele patru entități enumerate mai devreme, deci acelea două s-ar tipări literal ca &quot; și &#39;. Ghilimelele sunt inofensive în conținutul de text; contează doar în interiorul valorilor de atribute, iar renderer-ul nu decodează deloc entități în atribute. Design-ul sigur e deci nu un escaper mai bun, ci o regulă: datele nesigure nu intră niciodată în href, src sau style. Dacă o țintă de link chiar trebuie să vină din date de utilizator, validați-o singuri contra unei allow-list de scheme și caractere și respingeți orice conține ghilimele sau paranteze unghiulare

Două note de upgrade decurg direct din reparare:

  • Dacă codul propriu a încetat să escapeze & pentru că versiunile mai vechi tipăreau &amp; literal, adăugați-l înapoi. Fără el, textul de utilizator care conține &lt; se afișează acum ca <, text în continuare inofensiv, dar nu mai e ce a tastat utilizatorul
  • Nu escapați de două ori. Textul care trece prin două escapere randează < ca ortografia vizibilă &lt;, deci găsiți singura graniță la care datele proprii intră în HTML și escapați doar acolo

Paginarea cu LeftOverText fără a rupe escapările

DrawHTMLTextBox întoarce HTML-ul care nu a încăput, numit de obicei LeftOverText, iar din v3.539.47 remainder-ul acela păstrează ortografiile de entități literale și parantezele unghiulare escapate când îl pasați către următoarea casetă. Regula pentru apelanți e simplă: pasați-l înapoi neschimbat

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimensionat pentru o pagină A4 în puncte
  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 e deja HTML de motor escapat: nu-l escapa și nu-l de-escapa niciodată
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Tratați remainder-ul ca pe ceva opac. E HTML-ul normalizat al motorului, cu stilurile deja rezolvate, deci nu-l treceți prin escaperul propriu, nu-l decoda și nu introduce prin splice text de utilizator în el. Plafonul de pagini e o asigurare ieftină: dacă vreun element nu poate înceta să încape niciodată în casetă, o buclă neplafonată nu are o ieșire naturală

Markdown are propria continuare. DrawMarkdownTextBox întoarce un token care începe cu un marker intern, astfel încât apelul următor poate sări conversia; dați-l înapoi la DrawMarkdownTextBox sau DrawMarkdownText, nu punctelor de intrare HTML, care ar desena markerul ca text

Lecția generală: decodați o dată, re-encodați la fiecare graniță

Orice pipeline care parsează text, serializează rezultatul înapoi în aceeași sintaxă și îl parsează din nou trebuie să trateze decodarea ca pe o operațiune care se întâmplă în exact un singur loc și trebuie să re-encode la fiecare graniță la care textul decodat devine din nou sintaxă. Motoarele de șabloane, sanitizer-ele HTML și lanțurile Markdown spre HTML spre PDF partajează această formă și eșuează la fel când un serializer uită că produce markup

Simptomele sunt previzibile odată ce cunoașteți forma. Prea puțină re-encodare transformă datele în sintaxă, direcția de injecție. Prea multă encodare, sau un decoder care rulează de două ori, arată cititorului ortografii de entități sau le mănâncă, direcția de afișare. Repararea unei singure direcții rupe de obicei cealaltă, motiv pentru care repararea din PDFlibPas a trebuit să adauge decodarea lui &amp;, să o reordoneze, să elimine decodarea târzie și să adauge re-escaparea în aceeași versiune. Același principiu curge și în sens invers când conținutul PDF e exportat ca text structurat, ca în exportul semantic PDF spre Markdown și DOCX din Delphi, unde fiecare caracter literal trebuie escapat pentru sintaxa țintă exact o dată

Listă de verificare rapidă

  • Faceți upgrade la PDFlibPas v3.539.47 sau mai nou dacă randați HTML sau Markdown care conține date de utilizator
  • Escapați conținutul de text cu & primul, apoi < și >; nu convertiți ghilimelele pentru textul PDFlibPas
  • Escapați o dată, în singurul punct la care datele intră în șirul HTML
  • Țineți valorile nesigure departe de href, src și style, sau validați-le contra unei allow-list
  • Așteptați-vă ca doar &lt;, &gt;, &amp; și &nbsp; să fie decodate în text; celelalte entități rămân literale
  • Pasați LeftOverText înapoi la DrawHTMLTextBox neschimbat și plafonați bucla de pagini
  • Pasați token-urile de continuare Markdown doar la DrawMarkdownTextBox sau DrawMarkdownText
  • Nu căutați niciodată tipare de octeți în buffere de octeți UTF-16; lucrați pe unități de cod întregi

Randarea HTML și Markdown, exportul de rapoarte din dataset și restul motorului de layout vin în sursa Pascal nativă a PDF Library for Delphi, pentru Delphi și Free Pascal. Veziți pagina de produs PDFlibPas pentru ediții, suport de platforme și o descărcare de probă