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 <b>. Î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 <unsafe> î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:
&nu era în mulțimea de entități suportate, deciR&Dse tipărea literal și nu exista nicio cale de a scrie o ortografie de entitate literală precum<ca text- Etapa de desenare înlocuia
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
| Input care ajunge la renderer | Înainte de v3.539.47 | Din v3.539.47 |
|---|---|---|
<unsafe> | Parsat ca tag, textul nu ajunge niciodată pe pagină | <unsafe> desenat ca text |
<b>x</b> | x desenat bold | <b>x</b> desenat ca text |
R&D | R&D tipărit literal | R&D |
&lt; | &lt; tipărit literal | < |
Code span Markdown care conține | Devenea un spațiu non-breaking | desenat ca text |
Valoare de celulă de dataset < | < | < |
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ă & 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 <, >, & și . Orice altceva, inclusiv referințele numerice precum A și entitățile cu nume precum ", 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ă & ar fi decodat primul, input-ul &lt; ar deveni <, 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 <, > și primele și & 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
A doua reparare elimină înlocuirea târzie a lui 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 & în mijlocul a două caractere și taie cu un octet fiecare caracter următor
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 &, < la < și > la >, în timp ce spațiile devin , 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 & 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Examinați HTML-ul: în cod, '&' devine '&', iar '<' devine '<'
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 &, deci escaparea ampersand-ului ar fi tipărit & î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ă < era decodată în <. Cu renderer-ul reparat, exportatorul escapează & primul, iar o valoare precum R&D < & 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 <; escapați & al doilea și acela devine &lt;, pe care o decodare unică corectă îl afișează ca < î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 '<' deja produs ar fi escapeat a doua oară
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;
Pe v3.539.47 un comentariu precum Try <a href="https://example.com">this</a> & <b> 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 " și ' în ', 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 " și '. 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&literal, adăugați-l înapoi. Fără el, textul de utilizator care conține<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ă<, 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 &, 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șistyle, sau validați-le contra unei allow-list - Așteptați-vă ca doar
<,>,&și să fie decodate în text; celelalte entități rămân literale - Pasați
LeftOverTextînapoi laDrawHTMLTextBoxneschimbat și plafonați bucla de pagini - Pasați token-urile de continuare Markdown doar la
DrawMarkdownTextBoxsauDrawMarkdownText - 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ă