Techninis straipsnis

PDFlibPas HTML į PDF: dvigubo dekodavimo taisymas

PDF Library for Delphi (PDFlibPas) versijos iki v3.539.47 galėjo dekoduoti apsaugotą tekstą dukart, kai HTML arba Markdown piešiamas į PDF. DrawHTMLText ir DrawHTMLTextBox išanalizuoja HTML, suvienodina jį atgal į HTML, tada analizuoja dar kartą, todėl tekstas, parašytas kaip <unsafe>, pas antrą analizę atkeldavo kaip tikra žymė. Nuo v3.539.47 kiekvienas entitetas dekoduojamas lygiai vieną kartą, o tekstas vėl apsaugomas visur, kur jis virsta atgal į HTML

Scenarijus, kuris tai išryškina, visiškai kasdieniškas. Pagalbos tarnyba eksportuoja kreipimuisi į PDF, ir kliento komentaras keliauja į HTML šabloną. Kūrėjas padarė teisingai ir komentarą apsaugojo, tad <b> virto &lt;b&gt;. Atvaizduoklio viduje ta apsauga buvo tylioje atšaukta: komentas išeidavo pusjuodis, nežinomas žymės pavadinimas tiesiog dingdavo iš puslapio, o apsaugota nuoroda virto spausčiamąja nuorodos anotacija. Jokios išimties, jokio įspėjimo – visiškai taisyklingas PDF, kuris pasako ką nors kita, nei duomenys

Kodėl apsaugotas tekstas PDF viduje tampa tikra žyme?

Apsaugotas tekstas virto žyme todėl, kad atvaizduoklis turi dvi analizės fazes, o tarp jų esantis suvienodinimo žingsnis jau dekoduotą tekstą rašydavo atgal į HTML be pakartotinės apsaugos. Kiekviena dekodavimo operacija, kurią atlikdavo pirmoji analizė, antrajai atrodavo kaip gyva sintaksė

Dvi fazės egzistuoja dėl geros priežasties. Pirmoji analizė sukuria žymių ir žodžių elementų sąrašą. NormalizeParsedHTML tada išspręndžia stilių lentelės kaskadą: ji sugretina taisykles iš <style> blokų su kiekviena žyme, sujungia jas su inline style atributais, rezultatą užrašo ant žymės ir visą elementų sąrašą vėl užrašo į HTML eilutę. Išdėstymo fazė analizuoja tą suvienodintą eilutę. Tai ta pati mašinerija, kuri varo flexbox, CSS grid ir išnašų išdėstymą PDFlibPas HTML atvaizdavime

Brokas slypėjo tame, kaip užrašomi žodžiai. Žymės buvo rašomos atgal iš jų pirminės šaltinio formos, o žodžiai – jau dekoduota forma. Žodis, kurį pirmoji analizė buvo dekodavusi iš &lt;unsafe&gt; į <unsafe>, atkeldavo į suvienodintą HTML kaip neapsaugoti smailieji skliaustai, o antroji analizė jį skaitydavo kaip elementą. Aplink tą pagrindinį broką sėdėjo trys mažesnės nuotėkės, rodančios ta pačia kryptimi:

  • &amp; nebuvo palaikomų entitetų aibėje, tad R&amp;D spausdinta pažodžiui ir nebuvo kaip parašyti pažodinį entiteto užrašą, tokį kaip &lt;, kaip tekstą
  • Piešimo etapas &nbsp; pakeisdavo antrą kartą, jau pasibaigus analizei, todėl pažodinis entiteto užrašas vis tiek galėjo dingsti pačioje pabaigoje
  • Markdown kodo apsauga praleisdavo ampersandą, o duomenų rinkinio eksportuotojas apsaugodavo vien smailiuosius skliaustus, todėl entitetų užrašai kode ar langelių reikšmėse būdavo dekoduojami kaip žymė
PDFlibPas HTML srautas DrawHTMLText atveju, kai pirmoji analizė sukuria elementus, NormalizeParsedHTML juos užrašo atgal į HTML, o antroji analizė išdėsto rezultatą; iki v3.539.47 dekoduoti žodžiai būdavo rašomi atgal be apsaugos ir virsdavo gyvomis žymėmis, nuo v3.539.47 kiekvienas žodis riboje vėl apsaugomas
Dekoduoti žodžiai grįžta į analizatorių kaip sintaksė, kai suvienodintojas užmiršta, kad gamina žymę – taip apsaugotas komentaras išeidavo pusjuodis arba įsigydavo nuorodą
Įvedimas, pasiekiantis atvaizduoklįIki v3.539.47Nuo v3.539.47
&lt;unsafe&gt;Suvokiamas kaip žymė, tekstas nepasiekia puslapio<unsafe> piešiamas kaip tekstas
&lt;b&gt;x&lt;/b&gt;x piešiamas pusjuodžiu<b>x</b> piešiamas kaip tekstas
R&amp;DR&amp;D spausdinta pažodžiuiR&D
&amp;lt;&amp;lt; spausdinta pažodžiui&lt;
Markdown kodo intervalas su &nbsp;Tapo nelaužoma tarpine&nbsp; piešiamas kaip tekstas
Duomenų rinkinio langelio reikšmė &lt;<&lt;

Kaip v3.539.47 HTML entitetų dekodavimą sutraukia į vieną praėjimą

PDFlibPas v3.539.47 entitetų dekodavimą sutraukia į vieną praėjimą trimis susietais pakeitimais: analizatorius &amp; dekoduoja paskutinį, piešimo etapas daugiau nieko nedekoduoja, o kiekviena vieta, kuri dekoduotus žodžius vėl paverčia HTML, juos pirmiausia vėl apsaugo

Teksto turiniui palaikoma entitetų aibė dabar yra &lt;, &gt;, &amp; ir &nbsp;. Viskas kita, įskaitant skaitines nuorodas, tokias kaip &#65;, ir vardinius entitetus, tokius kaip &quot;, lieka pažodiniu tekstu. Ta riba svarbi tai, kaip apsaugosite savąjį įvedimą, kaip ir parodyta žemiau

Tvarka dekoduotojo viduje – pirmasis taisymas. Jei &amp; būtų dekoduojamas pirmiausia, įvedimas &amp;lt; taptų &lt;, o sekantis pakeitimas jį paverstų < – dvigubas dekodavimas, įvykstantis vieno praėjimo viduje. Todėl ANSI žodžių kelias &lt;, &gt; ir &nbsp; pakeičia pirmiausia, o &amp; – paskutinį, tad jo pagamintas ampersandas daugiau niekada nebetiriamas. UTF-16 žodžių kelias yra vienas iš kairės į dešinę ėjimas dvejų baitų žingsniais, kuris kiekvieną atitikmenį perrašo vietoje ir pro jį peržengia – tai ta pati garantija, tik struktūrinė

PDFlibPas dekoduotojo tvarka grandininiam entitetui, tokiam kaip &amp;lt;: ampersandas, dekoduotas pirmiausia, suvirstų į tikrąjį smailųjį skliaustą vieno praėjimo viduje, o lt, gt ir nbsp dekodavimas prieš ampersandą palieka pažodinį užrašą sveiką, tad tekstas pasiekia puslapį tik vieną kartą dekoduotas
Ampersandas yra escape simbolis, todėl jis turi būti dekoduojamas paskutinį ir apsaugomas pirmiausia, kitaip vienas praėjimas gali dekoduoti dukart

Antras taisymas pašalina vėlyvąjį &nbsp; pakeitimą iš piešimo etapo. Dekodavimas priklauso analizatoriui ir niekam kitam, todėl žodis, pasiekęs eilučių skaidytoją, yra galutinis tekstas

Trečias taisymas – ribos taisyklė. NormalizeParsedHTML dabar kiekvieną dekoduotą žodį prieš prijungdamas prie suvienodinto HTML apsaugoja &, < ir >. Antroji analizė jį dekoduoja atgal iki visai to paties teksto, tad grynas viso srauto efektas – vienas dekodavimas. Tęstinė eilutė laikosi tos pačios taisyklės: žodžiai, kurie netelpa į langą, prieš prijungiant prie LeftOverText apsaugojami, o likusios dalys kopijuojamos iš suvienodinto HTML, kuris jau yra apsaugota forma. Ciklas, surenkantis tuos likusius žodžius, dabar ribojamas ir žodžių skaičiumi, kur senasis repeat ciklas galėjo peržengti paskutinį žodį

Kodėl UTF-16BE apsauga negali naudoti baitų lygio pakeitimo?

UTF-16BE apsauga negali naudoti baitų lygio pakeitimo, nes dviejų baitų ampersando raštas gali persidengti per du nesusijusius simbolius. Vienintelis teisingas darbo vienetas – visa 16 bitų kodo vienetas

Atvaizduoklis Unicode žodžius saugo kaip big-endian UTF-16, supakuotus į baitų eilutes, pirmiausia aukštasis baitas. Ampersandas yra 00 26. Imkime U+0100 (didžioji lotynų A su brūkšniu, baitai 01 00), o po jos U+2603 (sniego senis, baitai 26 03). Baitų seka yra 01 00 26 03, o antras ir trečias baitai skaitosi 00 26. Baitų paieška po #0'&' randa ampersandą, kurio nėra, įklijuoja &amp; baitus į dviejų simbolių vidurį ir kiekvieną paskesnį simbolį pastumsia per baitą

PDFlibPas UTF-16BE apsaugos pavojus, kai baitai 01 00 26 03 simboliams U+0100 ir U+2603 turi raštą 00 26 per du simbolius, todėl baitų lygio ampersando paieška entitetą įklijuoja į kodo taško vidurį; kodo vienetų skenavimas tiria tik lyginius poslinkius
Baitų paieška randa ampersandą, kurio joks simbolis niekada nenešė; dirbkite tikrais kodo vienetais, niekada su žaliais UTF-16 baitų buferiais

Tai ne egzotiškas pakraštys. Bet koks simbolis, kurio žemasis baitas nulinis, gali parodyti pirmąją pusę; U+4E00, vienas dažniausių CJK ideogramų, tinka. Smailieji skliaustai turi tą patį paviršių: 00 3C ir 00 3E atsiranda, kai po tokio simbolio eina vienas iš U+3C00 iki U+3EFF iš CJK Extension A. Taisymas EscapeHTMLWord viduje baitus išpakoja į WideString, apsaugoja simbolis po simbolio ir rezultatą supakuoja iš naujo. Dekoduotojo pusė jau buvo saugi, nes raštus ji tiria tik ties lyginiais kodo vienetų ribomis

Ta pati taisyklė galioja jūsų pačių kodui. Jeigu kada laikote UTF-16 tekstą kaip TBytes, pavyzdžiui po TEncoding.BigEndianUnicode.GetBytes, neieškokite jame baitų raštų. Konvertuokite atgal į eilutę ir dirbkite su simboliais

Markdown kodo blokai ir duomenų rinkinio eksportai: pirmiausia apsaugokite ampersandą

Nuo v3.539.47 abu HTML gamintojai PDFlibPas viduje – Markdown konverteris ir duomenų rinkinio eksportuotojas – ampersandą apsaugoja prieš smailiuosius skliaustus, tad vienintelis dekodavimas atvaizduoklyje atkuria lygiai pirminį tekstą

MarkdownToHTML viduje inline kodo intervalai ir aptverti arba įtraukti kodo blokai dabar & paverčia &amp;, < – &lt;, o > – &gt;; tarpai tampa &nbsp;, o tabuliatorius – keturiais jais, kad įtrauka išliktų. Paprasta Markdown proza apsaugoja tik smailiuosius skliaustus, tad žalia HTML prozoje negali suleisti žymių, o autorius vis dar gali tyčia parašyti &amp;, ko ir tikisi Markdown autorių. DrawMarkdownText ir DrawMarkdownTextBox naudoja tą pačią konversiją, tad kodas PDF atsiranda lygiai taip, kaip surinkta:

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
    // Apžiūrėkite HTML: kode '&' tampa '&amp;', o '<' – '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // viršuje kairėje, Y auga žemyn
    Lib.SetMeasurementUnits(0);  // taškai
    // Puslapis rodo kodą lygiai taip, kaip surinkta, su entitetų užrašais
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Duomenų rinkinio eksportuotojas – instruktyviausias atvejis. Iki v3.539.47 jis apsaugodavo vien smailiuosius skliaustus, ir tyčia: atvaizduoklis &amp; nedekoduodavo, tad ampersando apsauga kiekviename langelyje, kuriame jis būtų, atspausdintų &amp;. Apeinamasis kelias senajam atvaizduokliui buvo teisingas, o apskritai – klaidingas, nes langelio reikšmė, kuri atsitiktinai turėdavo &lt;, būdavo dekoduojama į <. Atvaizduokliui pataisius, eksportuotojas & apsaugoja pirmiausia, ir tokia reikšmė kaip R&D &lt; &amp; &nbsp; atsiduria PDF pažodžiui. Jeigu tokiu būdu statote ataskaitas, apžvalga kaip eksportuoti TDataSet į PDF ataskaitą Delphi aprašo likusią eksportuotojo dalį

Kodėl ampersandas turi eiti pirmiausia, verta vieną kartą išdėstyti aiškiai. Apsaugokite < pirmiausia ir gausite &lt;; tada apsaugokite & ir tai taps &amp;lt;, ką teisingas vienkartinis dekodavimas parodys kaip &lt;, o ne kaip <. Nuosekli pakeitimų grandinė teisinga tik tada, kai pats escape simbolis sutvarkomas anksčiau už viską, kas jį sukelia

Kaip turėtumėte apsaugoti nepatikimą tekstą DrawHTMLTextBox?

PDFlibPas HTML atvaizdavimui nepatikimą teksto turinį apsaugokite pakeisdami &, tada <, paskui > – lygiai vieną kartą – ir nepatikimų duomenų į atributų reikšmes visai neįleiskite

uses
  System.SysUtils, PDFlibrary;

// Apsaugo nepatikimą tekstą PDFlibPas HTML tekstui.
// '&' turi būti pakeičiamas pirmiausia, kitaip ampersandas
// jau pagaminto '&lt;' viduje būtų apsaugotas antrą kartą
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;

v3.539.47 komentaras, toks kaip Try <a href="https://example.com">this</a> & &lt;b&gt;, puslapyje atsiranda simbolis po simbolio. Iki v3.539.47 tas pats apsaugotas įvedimas galėjo pagimdyti gyvą nuorodos anotaciją – būtent tai paverčia rodymo triktį saugumo problema: kreipimosi komentaras niekada neturėtų galėti įkalti spausčiamos nuorodos į dokumentą, kuriuo jūsų personalas pasitiki

Pastebėkite, ko funkcija neapsaugoja. Universalūs HTML apsaugotojai dar paverčia " į &quot;, o ' – į &#39;, kas naršyklei teisinga. PDFlibPas teksto dekodavimas atpažįsta tik keturis anksčiau išvardytus entitetus, tad šie du atspausdintų pažodžiui kaip &quot; ir &#39;. Kabutės teksto turinyje nekenksmingos; jos svarbios tik atributų reikšmėse, o atvaizduoklis entitetų atributuose iš viso nedekoduoja. Saugus dizainas todėl – ne geresnis apsaugotojas, o taisyklė: nepatikimi duomenys niekada nekeliauja į href, src ar style. Jeigu nuorodos tikslas tikrai privalo ateiti iš naudotojo duomenų, patys patikrinkite jį pagal schemų ir simbolių leistinąjį sąrašą ir atmeskite viską, kas turi kabučių ar smailiųjų skliaustų

Iš taisymo tiesiogiai išplaukia dvi atnaujinimo pastabos:

  • Jeigu jūsų kodas nustojo apsaugoti &, nes senesnės versijos spausdino &amp; pažodžiui, grąžinkite tai atgal. Be to, naudotojo tekstas, kuriame yra &lt;, dabar rodomas kaip < – vis dar nekenksmingas tekstas, bet nebe tai, ką naudotojas surinko
  • Neapsaugokite dukart. Tekstas, praėjęs pro du apsaugotojus, < atvaizduoja kaip matomą užrašą &lt;, tad suraskite vienintelę ribą, kurioje jūsų duomenys įžengia į HTML, ir apsaugokite tik ten

Skirstymas į puslapius su LeftOverText nesugadinus apsaugų

DrawHTMLTextBox grąžina HTML, kuris netelpo, paprastai vadinamą LeftOverText, ir nuo v3.539.47 ta liekana išlaiko pažodinius entitetų užrašus ir apsaugotus smailiuosius skliaustus, kai perduodate ją kitam langui. Taisyklė kviečiantiesiems paprasta: grąžinkite ją nepakeistą

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dydis A4 puslapiui taškais
  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 jau apsaugotas variklio HTML: niekada jo neapsaugokite ar neiškoduokite
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Į liekaną žiūrėkite kaip į nepermatomą daiktą. Tai suvienodintas variklio HTML, jau su išspręstais stiliais, tad neleiskite jos pro savąjį apsaugotoją, nedekoduokite ir nesujunkite su naudotojo tekstu. Puslapių riba – pigus draudimas: jei kuris elementas niekada netelpa į langą, neribotas ciklas neturi natūralaus išėjimo

Markdown turi savą tęstinumą. DrawMarkdownTextBox grąžina tokeną, prasidedantį vidiniu žymekliu, kad kitas kvietimas galėtų praleisti konversiją; grąžinkite jį DrawMarkdownTextBox arba DrawMarkdownText, o ne į HTML įėjimo taškus, kurie žymeklį nupieštų kaip tekstą

Bendra pamoka: dekoduoti vieną kartą, kiekvienoje riboje koduoti iš naujo

Bet koks srautas, kuris analizuoja tekstą, rezultatą užrašo atgal į tą pačią sintaksę ir analizuoja dar kartą, turi dekodavimą traktuoti kaip operaciją, vykstančią lygiai vienoje vietoje, ir turi koduoti iš naujo kiekvienoje riboje, kurioje dekoduotas tekstas vėl tampa sintakse. Šablonų varikliai, HTML sanitarai ir Markdown–HTML–PDF grandinės turi tą patį pavidalą ir klysta taip pat, kai serializatorius užmiršta, kad gamina žymę

Simptomai numanomi, kai tik žinai pavidalą. Per mažai perkodavimo – duomenys virsta sintakse, tai injekcijos kryptis. Per daug kodavimo arba dukart paleistas dekoduotojas – skaitytojas mato entitetų užrašus arba jie pradingsta, tai rodymo kryptis. Vienos krypties taisymas paprastai sulaužo kitą, todėl PDFlibPas taisymui teko viename leidime pridėti &amp; dekodavimą, perrūšiuoti jį, pašalinti vėlyvąjį dekodavimą ir pridėti pakartotinę apsaugą. Tas pats principas veikia priešinga kryptimi, kai PDF turinys eksportuojamas kaip struktūrinis tekstas, kaip PDF į Markdown ir DOCX semantiniame eksporte iš Delphi, kur kiekvienas pažodinis simbolis turi būti apsaugotas tikslinės sintaksės lygiai vieną kartą

Trumpa atmintinė

  • Atnaujinkite iki PDFlibPas v3.539.47 ar vėlesnės, jeigu atvaizduojate HTML arba Markdown su naudotojo duomenimis
  • Teksto turinį apsaugokite iš pradžių &, paskui < ir >; PDFlibPas tekstui kabučių nekonvertuokite
  • Apsaugokite vieną kartą, vienintelėje vietoje, kur duomenys įžengia į HTML eilutę
  • Nepatikimų reikšmių neleiskite į href, src ir style arba patikrinkite jas pagal leistinąjį sąrašą
  • Tekste tikėkitės dekoduojamų vien &lt;, &gt;, &amp; ir &nbsp;; kiti entitetai lieka pažodiniai
  • LeftOverText grąžinkite DrawHTMLTextBox nepakeistą ir ribokite puslapių ciklą
  • Markdown tęstinumo tokenus perduokite tik DrawMarkdownTextBox arba DrawMarkdownText
  • Niekada neieškokite UTF-16 baitų buferiuose baitų raštų; dirbkite tikrais kodo vienetais

HTML ir Markdown atvaizdavimas, duomenų rinkinio ataskaitų eksportas ir likusioji išdėstymo variklio dalis keliauja natyvioje PDF Library for Delphi Pascal šaltininko pakuotėje, skirtoje Delphi ir Free Pascal. Leidimams, platformų palaikymui ir bandomajam parsisiuntimui žiūrėkite PDFlibPas produkto puslapį