Tehnički članak

PDFlibPas HTML u PDF: dvostruko dekodirani entiteti

Verzije PDF Library for Delphi (PDFlibPas) prije v3.539.47 mogle su escapirani tekst dekodirati dvaput pri crtanju HTML-a ili Markdowna u PDF. DrawHTMLText i DrawHTMLTextBox parsiraju HTML, normaliziraju ga natrag u HTML, pa ga ponovno parsiraju, pa je tekst zapisan kao <unsafe> došao do drugog parsiranja kao pravi tag. Od v3.539.47 svaki se entitet dekodira točno jednom, a tekst se ponovno escapira gdje god se vraća u HTML

Scenarij koji to izlaže svakidašnji je. Help desk izvozi tickete u PDF, a komentar kupca završi u HTML predlošku. Programer je učinio pravu stvar i escapirao komentar, pa je <b> postao &lt;b&gt;. Unutar renderera to escapiranje tiho je poništeno: komentar je izašao boldan, nepoznato ime taga jednostavno je nestalo sa stranice, a escapirani anchor pretvorio se u klikabilu anotaciju poveznice. Bez iznimke, bez upozorenja, sasvim valjan PDF koji govori nešto drugačije od podataka

Zašto escapirani tekst postaje pravi tag u PDF-u?

Escapirani tekst postao je markup jer renderer izvodi dva pars prolaza, a korak normalizacije između njih zapisao je već dekodirani tekst natrag u HTML bez ponovnog escapiranja. Svako dekodiranje koje je izvršilo prvo parsiranje tada je bilo dostupno drugom parsiranju kao živa sintaksa

Ta dva prolaza postoje s dobrim razlogom. Prvo parsiranje gradi listu elemenata tagova i riječi. NormalizeParsedHTML zatim razrješuje kaskadu stylesheeta: pravila iz <style> blokova uspoređuje sa svakim tagom, spaja ih s inline style atributima, rezultat sprema na tag i cijelu listu elemenata serijalizira natrag u HTML string. Prolaz rasporeda parsira taj normalizirani string. To je isti mehanizam koji pogoni flexbox, CSS grid i raspored fusnota u PDFlibPas HTML renderiranju

Manjkavost je bila u tome kako su riječi serijalizirane. Tagovi zapisivani su natrag u svom izvornom obliku, dok su riječi zapisivane u dekodiranom obliku. Riječ koju je prvo parsiranje dekodiralo iz &lt;unsafe&gt; u <unsafe> dospjela je u normalizirani HTML kao sirovi uglate zagrade, a drugo je parsiranje pročitalo kao element. Oko te glavne greške sjedila su tri manja curenja koja su pokazivala istim smjerom:

  • &amp; nije bio u podržanom skupu entiteta, pa se R&amp;D ispisivao doslovno i nije bilo načina da se doslovni zapis entiteta poput &lt; napiše kao tekst
  • Faza crtanja zamjenjivala je &nbsp; drugi put, nakon što je parsiranje već bilo gotovo, pa je doslovni zapis entiteta mogao još nestati na samom kraju
  • Escapiranje Markdown koda preskakalo je ampersand, a izvoznik skupa podataka escapirao je samo uglate zagrade, pa su zapisi entiteta unutar koda ili vrijednosti ćelija dekodirani kao markup
PDFlibPas HTML pipeline za DrawHTMLText gdje prvo parsiranje gradi elemente, NormalizeParsedHTML ih serijalizira natrag u HTML, a drugo parsiranje rezultat slaže; prije v3.539.47 dekodirane riječi zapisivane su natrag bez escapiranja i postajale živi tagovi, od v3.539.47 svaka se riječ ponovno escapira na granici
Dekodirane riječi ponovno ulaze u parser kao sintaksa kad normalizator zaboravi da proizvodi markup, pa je escapirani komentar postao boldan ili izniknuo poveznicu
Ulaz koji dolazi do rendereraPrije v3.539.47Od v3.539.47
&lt;unsafe&gt;Parsiran kao tag, tekst nikad ne doseže stranicu<unsafe> nacrtan kao tekst
&lt;b&gt;x&lt;/b&gt;x nacrtan boldano<b>x</b> nacrtan kao tekst
R&amp;DR&amp;D ispisano doslovnoR&D
&amp;lt;&amp;lt; ispisano doslovno&lt;
Markdown code span koji sadrži &nbsp;Postao neprelomivi razmak&nbsp; nacrtan kao tekst
Vrijednost ćelije skupa podataka &lt;<&lt;

Kako v3.539.47 svodi dekodiranje HTML entiteta na jedan prolaz

PDFlibPas v3.539.47 svodi dekodiranje entiteta na jedan prolaz s tri koordinirane izmjene: parser dekodira &amp; posljednji, faza crtanja više ništa ne dekodira, a svako mjesto koje dekodirane riječi vraća u HTML najprije ih ponovno escapira

Podržani skup entiteta za tekstualni sadržaj sada je &lt;, &gt;, &amp; i &nbsp;. Sve ostalo, uključujući numeričke reference poput &#65; i imenovane entitete poput &quot;, ostaje doslovan tekst. Ta granica važna je za to kako escapirate vlastiti ulaz, kao što je dolje prikazano

Redoslijed unutar dekodera prvi je popravak. Da je &amp; dekodiran prvi, ulaz &amp;lt; postao bi &lt;, a sljedeća bi ga zamjena pretvorila u <, dvostruko dekodiranje unutar jednog prolaza. ANSI put riječi stoga najprije zamjenjuje &lt;, &gt; i &nbsp;, a &amp; posljednji, pa ampersand koji proizvede nikad više nije ispitan. UTF-16 put riječi jest jedno skeniranje s lijeva na desno u dvobajtnim koracima koje svako podudaranje prepisuje na mjestu i prelazi preko njega, što istu garanciju daje strukturalno

PDFlibPas redoslijed dekodera za ulančani entitet poput &amp;lt;: dekodiranje ampersanda prvim urušava ga u pravu uglatu zagradu unutar jednog prolaza, dok dekodiranje lt, gt i nbsp prije ampersanda zadržava doslovni zapis netaknutim pa tekst doseže stranicu točno jednom dekodiran
Ampersand jest escape znak, pa se mora dekodirati posljednji i escapirati prvi, ili jedan prolaz može dekodirati dvaput

Drugi popravak uklanja kasnu zamjenu &nbsp; iz faze crtanja. Dekodiranje pripada parseru i nikome drugome, pa je riječ koja dođe do lomitelja linija konačni tekst

Treći popravak jest pravilo granice. NormalizeParsedHTML sada escapira &, < i > u svakoj dekodiranoj riječi prije nego što je doda normaliziranom HTML-u. Drugo parsiranje dekodira je natrag u potpuno isti tekst, pa je neto učinak preko cijelog pipelinea jedno dekodiranje. Nastavni string slijedi isto pravilo: riječi koje se nisu uklopile u okvir escapiraju se prije nego što se dodaju u LeftOverText, a ostatak ostatka kopira se iz normaliziranog HTML-a, koji je već u escapiranom obliku. Petlja koja skuplja te zaostale riječi sada je omeđena i brojem riječi, dok je stara repeat petlja mogla koraknuti iza posljednje riječi

Zašto UTF-16BE escapiranje ne može koristiti zamjenu na razini bajtova?

UTF-16BE escapiranje ne može koristiti zamjenu na razini bajtova jer dvobajtni uzorak za ampersand može zahvatiti dva nepovezana znaka. Jedina ispravna jedinica rada jest cijela 16-bitna kodna jedinica

Renderer pohranjuje Unicode riječi kao big-endian UTF-16 spakiran u bajtne stringove, visoki bajt prvi. Ampersand je 00 26. Uzmite sada U+0100 (latinično veliko A s makronom, bajtovi 01 00) pa U+2603 (snjegović, bajtovi 26 03). Bajtni niz jest 01 00 26 03, a bajtovi dva i tri čitaju se 00 26. Bajtna pretraga za #0'&' pronaći će ampersand koji ne postoji, ušiti bajtove za &amp; u sredinu dva znaka i odrezati svaki sljedeći znak za jedan bajt

PDFlibPas UTF-16BE zamka escapiranja gdje bajtovi 01 00 26 03 za U+0100 i U+2603 sadrže uzorak 00 26 preko dva znaka, pa bajtna pretraga ampersanda ušije entitet u sredinu kodne točke; skeniranje kodnih jedinica ispituje samo parne pomake
Bajtna pretraga pronaći će ampersand koji nijedan znak nikad nije sadržavao; radite na cijelim kodnim jedinicama, nikad na sirovim UTF-16 bajtnim međuspremnicima

To nije egzotični rubni slučaj. Svaki znak čiji je niski bajt nula može dati prvu polovicu; U+4E00, jedan od najčešćih CJK ideograma, kvalificira se. Uglate zagrade imaju istu izloženost: 00 3C i 00 3E pojavljuju se svaki put kad takvom znaku slijedi jedan od U+3C00 do U+3EFF u CJK Extension A. Popravak u EscapeHTMLWord raspakira bajtove u WideString, escapira znak po znak i rezultat ponovno pakira. Strana dekodera bila je već sigurna jer testira uzorke samo na parnim granicama kodnih jedinica

Isto pravilo vrijedi i za vaš vlastiti kod. Ako ikad držite UTF-16 tekst kao TBytes, na primjer nakon TEncoding.BigEndianUnicode.GetBytes, ne pretražujte ga za bajtnim uzorcima. Pretvorite natrag u string i radite na znakovima

Markdown kodni blokovi i izvozi skupa podataka: escapirajte prvo ampersand

Od v3.539.47 oba HTML proizvođača unutar PDFlibPasa, Markdown pretvarač i izvoznik skupa podataka, escapiraju ampersand prije uglatih zagrada, pa jedno dekodiranje u rendereru vraća točno izvorni tekst

U MarkdownToHTML, inline code spanovi i fenced ili uvučeni kodni blokovi sada mapiraju & u &amp;, < u &lt; i > u &gt;, dok razmaci postaju &nbsp;, a tab četiri od njih da zadrži uvlačenje. Obična Markdown proza escapira samo uglate zagrade, pa sirovi HTML u prozi ne može ubaciti tagove, a autor i dalje može napisati &amp; namjerno, otprilike kako Markdown autori i očekuju. DrawMarkdownText i DrawMarkdownTextBox koriste istu pretvorbu, pa se kod u PDF-u prikazuje točno kako je utipkan:

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
    // Pogledajte HTML: u kodu, '&' postaje '&amp;' a '<' postaje '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // ishodište gore lijevo, Y raste prema dolje
    Lib.SetMeasurementUnits(0);  // točke
    // Stranica prikazuje kod točno kako je utipkan, s doslovnim zapisima entiteta
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Izvoznik skupa podataka je poučan slučaj. Prije v3.539.47 escapirao je samo uglate zagrade, i to namjerno: renderer nije dekodirao &amp;, pa bi escapiranje ampersanda ispisalo &amp; u svakoj ćeliji koja ga sadrži. Workaround bio je ispravan za stari renderer i pogrešan općenito, jer je vrijednost ćelije koja je slučajno sadržavala &lt; bila dekodirana u <. S popravljenim rendererom izvoznik escapira & prvi, i vrijednost poput R&D &lt; &amp; &nbsp; završava u PDF-u doslovno. Ako tako gradite izvještaje, uputstvo na izvozu TDataSet-a u PDF izvještaj u Delphiju pokriva ostatak izvoznika

Zašto ampersand mora ići prvi vrijedi jednom reći. Escapirajte < prvi i dobijete &lt;; escapirajte & drugi i to postaje &amp;lt;, što ispravno jedno dekodiranje prikazuje kao &lt; umjesto <. Sekvencijalni lanac zamjena ispravan je samo kad se sam escape znak obradi prije svega što ga uvodi

Kako escapirati nepouzdani tekst za DrawHTMLTextBox?

Za PDFlibPas HTML renderiranje escapirajte nepouzdani tekstualni sadržaj zamjenom &, zatim <, pa >, točno jednom, i držite nepouzdane podatke potpuno izvan vrijednosti atributa

uses
  System.SysUtils, PDFlibrary;

// Escapira nepouzdani tekst za tekstualni sadržaj PDFlibPas HTML-a.
// '&' mora se zamijeniti prvi, inače bi ampersand unutar
// već proizvedenog '&lt;' bio escapiran drugi put
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;

Na v3.539.47 komentar poput Try <a href="https://example.com">this</a> & &lt;b&gt; pojavljuje se na stranici znak po znak. Prije v3.539.47 isti escapirani ulaz mogao je proizvesti živu anotaciju poveznice, što je dio koji pretvara grešku prikaza u sigurnosni problem: komentar tiketa nikad ne smije moći zasaditi klikabilni URL u dokumentu kojem vaše osoblje vjeruje

Primijetite što funkcija ne escapira. Općenamjenski HTML escaperi pretvaraju i " u &quot; te ' u &#39;, što je ispravno za preglednik. PDFlibPas dekodiranje teksta prepoznaje samo četiri ranije navedena entiteta, pa bi se ta dva ispisala doslovno kao &quot; i &#39;. Navodnici su bezopasni u tekstualnom sadržaju; važe samo unutar vrijednosti atributa, a renderer entitete u atributima uopće ne dekodira. Siguran dizajn stoga nije bolji escaper nego pravilo: nepouzdani podaci nikad ne idu u href, src ni style. Ako cilj poveznice stvarno mora doći iz korisničkih podataka, sami ga validirajte protiv allow-liste shema i znakova i odbijte sve što sadrži navodnike ili uglate zagrade

Dvije napomene o nadogradnji slijede izravno iz popravka:

  • Ako je vaš kod prestao escapirati & jer su starije verzije ispisivale &amp; doslovno, dodajte ga natrag. Bez njega se korisnički tekst koji sadrži &lt; sada prikazuje kao <, još uvijek bezopasan tekst, ali više nije ono što je korisnik utipkao
  • Ne escapirajte dvaput. Tekst koji prođe kroz dva escapera renderira < kao vidljivi zapis &lt;, pa pronađite jedinu granicu na kojoj vaši podaci ulaze u HTML i escapirajte samo tamo

Paginacija s LeftOverTextom bez lomljenja escapiranja

DrawHTMLTextBox vraća HTML koji se nije uklopio, obično nazvan LeftOverText, i od v3.539.47 taj ostatak čuva doslovne zapise entiteta i escapirane uglate zagrade kad ga prenesete u sljedeći okvir. Pravilo za pozivatelje jednostavno je: prenesite ga natrag nepromijenjen

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimenzionirano za A4 stranicu u točkama
  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 već je escapirani HTML mehanizma: nikad ga ne escapirajte ni ne de-escapirajte
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Prema ostatku postupajte kao prema neprozirnom. To je normalizirani HTML mehanizma, s već razriješenim stilovima, pa ga ne provlačite kroz vlastiti escaper, ne dekodirajte ga i ne ušivajte u njega korisnički tekst. Ograničenje stranica jeftino je osiguranje: ako neki element nikad ne može stati u okvir, petlja bez ograničenja nema prirodnog izlaza

Markdown ima svoj nastavak. DrawMarkdownTextBox vraća token koji počinje internim markerom da sljedeći poziv može preskočiti pretvorbu; predajte ga natrag DrawMarkdownTextBoxu ili DrawMarkdownTextu, a ne HTML ulaznim točkama, koje bi marker nacrtale kao tekst

Općenita lekcija: dekodiraj jednom, ponovno kodiraj na svakoj granici

Svaki pipeline koji parsira tekst, serijalizira rezultat natrag u istu sintaksu i ponovno ga parsira mora dekodiranje tretirati kao operaciju koja se događa na točno jednom mjestu i mora ponovno kodirati na svakoj granici gdje dekodirani tekst opet postaje sintaksa. Template enginei, HTML sanitizeri i lanci Markdown-u-HTML-u-PDF dijele taj oblik i padaju na isti način kad serijalizator zaboravi da proizvodi markup

Simptomi su predvidivi jednom kad znate oblik. Premalo ponovnog kodiranja pretvara podatke u sintaksu, što je smjer injekcije. Previše kodiranja, ili dekoder koji izvodi dvaput, prikazuje čitatelju zapise entiteta ili ih poždere, što je smjer prikaza. Popraviti samo jedan smjer obično slomi drugi, pa je PDFlibPas popravak morao u istom izdanju dodati dekodiranje &amp;u, promijeniti mu redoslijed, ukloniti kasno dekodiranje i dodati ponovno escapiranje. Isti princip teče i obrnutim smjerom kad se PDF sadržaj izvozi kao strukturirani tekst, kao u PDF-u u Markdown i DOCX semantičkom izvozu iz Delphija, gdje svaki doslovni znak mora biti escapiran za ciljnu sintaksu točno jednom

Brza referentna kontrolna lista

  • Nadogradite na PDFlibPas v3.539.47 ili kasniji ako renderirate HTML ili Markdown koji sadrži korisničke podatke
  • Escapirajte tekstualni sadržaj s & prvim, pa < i >; ne pretvarajte navodnike za PDFlibPas tekst
  • Escapirajte jednom, na jedinoj točki gdje podaci ulaze u HTML string
  • Držite nepouzdane vrijednosti izvan href, src i style, ili ih validirajte protiv allow-liste
  • Očekujte da će se u tekstu dekodirati samo &lt;, &gt;, &amp; i &nbsp;; drugi entiteti ostaju doslovni
  • Prenosite LeftOverText natrag u DrawHTMLTextBox nepromijenjen i ograničite petlju stranica
  • Markdown tokene nastavka predajte samo DrawMarkdownTextBoxu ili DrawMarkdownTextu
  • Nikad ne pretražujte UTF-16 bajtne međuspremnike za bajtnim uzorcima; radite na cijelim kodnim jedinicama

HTML i Markdown renderiranje, izvoz izvještaja iz skupa podataka i ostatak mehanizma rasporeda isporučuju se u nativnom Pascal izvornom kodu PDF Library for Delphi, za Delphi i Free Pascal. Pogledajte stranicu proizvoda PDFlibPas za izdanja, podršku platformama i probno preuzimanje