Tekninen artikkeli

PDFlibPas HTML-PDF: entiteettien kaksoisdekoodauksen korjaus

PDF Library for Delphi (PDFlibPas) -versiot ennen v3.539.47:ää saattoivat dekoodata escapetun tekstin kahdesti piirtäessään HTML:ää tai Markdownia PDF:ään. DrawHTMLText ja DrawHTMLTextBox jäsentävät HTML:n, normalisoivat sen takaisin HTML:ksi ja jäsentävät sen sitten uudelleen, joten teksti, joka oli kirjoitettu muodossa <unsafe>, saapui toiseen jäsennykseen aitona tagina. Versiosta v3.539.47 alkaen jokainen entiteetti dekoodataan täsmälleen kerran, ja teksti escapataan uudelleen jokaisessa paikassa, jossa se muuttuu takaisin HTML:ksi

Tilanne, joka paljastaa vian, on tavallinen. Help desk vie tikettejä PDF:ään, ja asiakkaan kommentti menee HTML-mallipohjaan. Kehittäjä teki oikein ja escapasi kommentin, joten <b> muuttui muodoksi &lt;b&gt;. Rendererin sisällä escapaus kumottiin hiljaisesti: kommenti tuli ulos lihavoituna, tuntematon taginimi katosi sivulta ja escapattu ankkuri muuttui napsautettavaksi linkkiannotaatioksi. Ei poikkeusta, ei varoitusta, täysin kelvollinen PDF, joka sanoo jotain muuta kuin data

Miksi escapattu teksti muuttuu oikeaksi tagiksi PDF:ssä?

Escapattu teksti muuttui merkinnäksi, koska rendereri ajaa kaksi jäsennysvaihetta, ja niiden välinen normalisointivaihe kirjoitti jo dekoodatun tekstin takaisin HTML:ksi escapamatta sitä uudelleen. Jokainen ensimmäisen jäsennyksen suorittama dekoodaus oli silloin toisen jäsennyksen käytettävissä elävänä syntaksina

Kaksi vaihetta on olemassa hyvän syyn takia. Ensimmäinen jäsennys rakentaa luettelon tagi- ja san-elementeistä. NormalizeParsedHTML ratkaisee sitten tyylisivukaskadin: se sovittaa <style>-lohkojen säännöt jokaista tagia vasten, yhdistää ne inline-style-attribuutteihin, tallettaa tuloksen tagiin ja serialisoi koko elementtiluettelon takaisin HTML-merkkijonoksi. Asetteluvaihe jäsentää kyseisen normalisoidun merkkijonon. Kyseinen koneisto pyörittää myös artikkelin flexbox, CSS grid ja alaviiteasettelu PDFlibPasin HTML-renderöinnissä kuvaamaa toimintoa

Vika oli siinä, miten sanat serialisoitiin. Tagit kirjoitettiin takaisin niiden alkuperäisessä lähdemuodossa, kun taas sanat kirjoitettiin takaisin dekoodattussa muodossaan. Sana, jonka ensimmäinen jäsennys oli dekoodannut muodosta &lt;unsafe&gt; muotoon <unsafe>, päätyi normalisoituun HTML:ään raakoina kulmasulkeina, ja toinen jäsennys luki sen elementtinä. Tämän ydinbugin ympärillä kyti kolme pienempää vuotoa, jotka osoittivat samaan suuntaan:

  • &amp; ei kuulunut tuettujen entiteettien joukkoon, joten R&amp;D tulostui kirjaimellisena eikä ollut mitään tapaa kirjoittaa kirjaimellista entiteettimuotoa kuten &lt; tekstinä
  • Piirtovaihe korvasi &nbsp;in toistamiseen vasta jäsennyksen jo päätyttyä, joten kirjaimellinen entiteettimuoto saattoi vielä kadota aivan lopussa
  • Markdownin koodiescapaus ohitti et-merkin ja dataset-viejä escapasi vain kulmasulkeet, joten entiteettimuodot koodin sisällä tai soluarvoissa dekoodattiin merkinnäksi
PDFlibPasin HTML-putki DrawHTMLTextille, jossa ensimmäinen jäsennys rakentaa elementit, NormalizeParsedHTML serialisoi ne takaisin HTML:ksi ja toinen jäsennys asettelee tuloksen; ennen v3.539.47 dekoodatut sanat kirjoitettiin takaisin escapamattomina ja muuttuivat eläviksi tageiksi, versiosta v3.539.47 alkaen jokainen sana escapataan uudelleen rajapinnassa
Dekoodatut sanat palaavat parseriin syntaksina, kun normalisoija unohtaa tuottavansa merkintää, ja juuri näin escapattu kommenti muuttui lihavaksi tai kasvatti linkin
Rendereriin saapuva syöteEnnen v3.539.47Versiosta v3.539.47 alkaen
&lt;unsafe&gt;Jäsennettiin tagina, teksti ei koskaan päädy sivulle<unsafe> piirretään tekstinä
&lt;b&gt;x&lt;/b&gt;x piirrettiin lihavoituna<b>x</b> piirretään tekstinä
R&amp;DR&amp;D tulostui kirjaimellisenaR&D
&amp;lt;&amp;lt; tulostui kirjaimellisena&lt;
Markdownin koodialue, joka sisältää &nbsp;Muuttui kiinteäksi välilyönniksi&nbsp; piirretään tekstinä
Dataset-solun arvo &lt;<&lt;

Miten v3.539.47 tekee HTML-entiteettien dekoodauksesta yksivaiheisen

PDFlibPas v3.539.47 tekee entiteettidekoodauksesta yksivaiheisen kolmella yhteen sovitetulla muutoksella: parseri dekoodaa &amp;in viimeisenä, piirtovaihe ei enää dekoodaa mitään, ja jokainen paikka, joka kääntää dekoodatut sanat takaisin HTML:ksi, escapaa ne ensin uudelleen

Tekstisisällön tuettu entiteettijoukko on nyt &lt;, &gt;, &amp; ja &nbsp;. Kaikki muu, mukaan lukien numeeriset viittaukset kuten &#65; ja nimetyt entiteetit kuten &quot;, pysyy kirjaimellisena tekstinä. Kyseinen raja merkitsee paljon sille, miten escapaaat omaa syötettäsi, kuten alla näkyy

Järjestys dekooderin sisällä on ensimmäinen korjaus. Jos &amp; dekoodattaisiin ensin, syöte &amp;lt; muuttuisi muodoksi &lt; ja seuraava korvaus kääntäisi sen muotoon < — kaksoisdekoodaus, joka tapahtuu yhden vaiheen sisällä. ANSI-sanapolku korvaa siksi muodot &lt;, &gt; ja &nbsp; ensin ja &amp;in viimeisenä, joten sen tuottamaa et-merkkiä ei koskaan tutkita uudelleen. UTF-16-sanapolku on yksi vasemmalta oikealle kulkeva pyyhkäisy kaksitavuisin askelin, joka kirjoittaa jokaisen osuman paikalleen ja siirtyy sen ohi, mikä antaa saman takeen rakenteellisesti

PDFlibPasin dekooderin järjestys ketjutetulle entiteetille kuten &amp;lt;: et-merkin dekoodaus ensin luhistaa sen oikeaksi kulmasulkeeksi yhden vaiheen sisällä, kun taas lt:n, gt:n ja nbsp:n dekoodaaminen ennen et-merkkiä pitää kirjaimellisen muodon ehjänä, joten teksti päätyy sivulle täsmälleen kerran dekoodattuna
Et-merkki on escape-merkki, joten se on dekoodattava viimeisenä ja escapattava ensimmäisenä, muuten yksi vaihe voi dekodoida kahdesti

Toinen korjaus poistaa myöhäisen &nbsp;in korvauksen piirtovaiheesta. Dekoodaus kuuluu parserille eikä kenellekään muulle, joten sana, joka saapuu rivinjakajalle, on lopullista tekstiä

Kolmas korjaus on rajapintasääntö. NormalizeParsedHTML escapaa nyt muodot &, < ja > jokaisesta dekoodatusta sanasta ennen sen liittämistä normalisoituun HTML:ään. Toinen jäsennys dekoodaa sen takaisin täsmälleen samaksi tekstiksi, joten kokonaisvaikutus koko putken yli on yksi dekoodaus. Jatkomerkkijono noudattaa samaa sääntöä: sanat, jotka eivät mahtuneet laatikkoon, escapataan ennen niiden liittämistä muuttujaan LeftOverText, ja loput jäljelle jäävästä kopioidaan normalisoidusta HTML:stä, joka on jo escapatussa muodossa. Jälkiä sanoja keräävällä silmukalla on nyt myös sanamäärään sidottu raja, kun vanha repeat-silmukka saattoi astua viimeisen sanan yli

Miksi UTF-16BE-escapaus ei voi käyttää tavutason korvausta?

UTF-16BE-escapaus ei voi käyttää tavutason korvausta, koska et-merkin kaksitavuinen kuvio voi ulottua kahden toisiinsa liittymättömän merkin yli. Ainoa oikea työpilkku on koko 16-bittinen koodiyksikkö

Rendereri tallettaa Unicode-sanat big-endian UTF-16:nä paketoituna tavumerkkijonoihin, korkeampi tavu ensin. Et-merkki on 00 26. Ota nyt U+0100 (latinalainen iso A macronilla, tavut 01 00) ja sen perään U+2603 (lumimies, tavut 26 03). Tavujono on 01 00 26 03, ja tavut kaksi ja kolme lukevat 00 26. Tavuhaku muodolle #0'&' löytää et-merkin, jota ei ole olemassa, liittää &amp;in tavut kahden merkin keskelle ja siirtää jokaista seuraavaa merkkiä yhdellä tavulla

PDFlibPasin UTF-16BE-escapauksen vaarapaikka, jossa U+0100:n ja U+2603:n tavut 01 00 26 03 sisältävät kuvion 00 26 kahden merkin yli, joten et-merkin tavutason haku liittää entiteetin koodipisteen keskelle; koodiyksikköpyyhkäisy testaa vain parillisia offseitteja
Tavuhaku löytää et-merkin, jota yksikään merkki ei koskaan sisältänyt; työskentele kokonaisten koodiyksiköiden kanssa, ei koskaan raakojen UTF-16-tavupuskurien kanssa

Kyse ei ole eksoottisesta reunatapauksesta. Mikä tahansa merkki, jonka alhainen tavu on nolla, kelpaa toisen puolen toimittajaksi; U+4E00, yksi yleisimmistä CJK-merkeistä, täyttää ehdot. Kulmasulkeilla on sama haavoittuvuus: 00 3C ja 00 3E ilmestyvät aina, kun tällaisen merkin jälkeen tulee yksi merkki väliltä U+3C00–U+3EFF CJK Extension A:sta. Korjaus funktiossa EscapeHTMLWord purkaa tavut WideStringiksi, escapaa merkki kerrallaan ja paketoi tuloksen uudelleen. Dekooderin puoli oli jo turvallinen, koska se testaa kuvioita vain parillisilla koodiyksikkörajoilla

Sama sääntö pätee omaan koodiisi. Jos joskus pidät UTF-16-tekstiä TBytesina, esimerkiksi kutsun TEncoding.BigEndianUnicode.GetBytes jälkeen, älä etsi siitä tavukuvioita. Muunna takaisin merkkijonoksi ja työskentele merkeillä

Markdownin koodilohkot ja dataset-viennit: escapaa et-merkki ensin

Versiosta v3.539.47 alkaen molemmat PDFlibPasin sisäiset HTML-tuottajat, Markdown-muunnin ja dataset-viejä, escapaa et-merkin ennen kulmasulkeita, joten rendererin yksittäinen dekoodaus palauttaa täsmälleen alkuperäisen tekstin

Funktiossa MarkdownToHTML inline-koodialueet ja aitatut tai sisennetyt koodilohkot kuvaavat nyt muodon & muotoon &amp;, muodon < muotoon &lt; ja muodon > muotoon &gt;, välilyönnit muuttuvat muodoksi &nbsp; ja sarkain neljäksi niistä sisennyksen säilyttämiseksi. Tavallinen Markdown-proosa escapaa vain kulmasulkeet, joten proosan raaka HTML ei voi ruiskuttaa tageja, mutta kirjoittaja voi yhä kirjoittaa muodon &amp; tahallaan, aivan kuten Markdown-kirjoittajat odottavat. DrawMarkdownText ja DrawMarkdownTextBox käyttävät samaa muunnosta, joten koodi ilmestyy PDF:ään täsmälleen kirjoitetussa muodossa:

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
    // Tutki HTML:ää: koodissa '&' muuttuu muodoksi '&amp;' ja '<' muodoksi '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // vasen yläkulma alkupisteenä, Y kasvaa alaspäin
    Lib.SetMeasurementUnits(0);  // pisteet
    // Sivu näyttää koodin täsmälleen kirjoitetussa muodossa, entiteettimuodot mukaan lukien
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Dataset-viejä on opettavainen tapaus. Ennen v3.539.47 se escapasi vain kulmasulkeet, ja tahallaan: rendereri ei dekoodannut muotoa &amp;, joten et-merkin escapaminen olisi tulostanut muodon &amp; jokaiseen sellaisen sisältävään soluun. Kiertotie oli oikea vanhalle rendererille mutta väärä yleisesti, sillä soluarvo, joka sattui sisältämään muodon &lt;, dekoodautui muodoksi <. Kun rendereri korjattiin, viejä escapaa muodon & ensin, ja arvo kuten R&D &lt; &amp; &nbsp; päätyy PDF:ään sellaisenaan. Jos rakennat raportit noin, läpikäynti artikkelissa TDataSetin vienti PDF-raportiksi Delphissä kattaa viejän loput

Se, miksi et-merkin on mentävä ensin, on syytä selittää kerran. Escapaa < ensin, ja saat muodon &lt;; escapaa & toisena, ja siitä tulee &amp;lt;, jonka oikea yksittäinen dekoodaus näyttää muodossa &lt; muodon < sijaan. Peräkkäinen korvausketju on oikea vain, kun escape-merkki itse käsitellään ennen kaikkea muuta, mikä sitä tuottaa

Miten escapata luottamaton teksti DrawHTMLTextBoxia varten?

PDFlibPasin HTML-renderöinnissä escapata luottamaton tekstisisältö korvaamalla &, sitten <, sitten >, täsmälleen kerran, ja pidä luottamaton data kokonaan poissa attribuuttiarvoista

uses
  System.SysUtils, PDFlibrary;

// Escapaa luottamaton teksti PDFlibPasin HTML-tekstisisältöä varten.
// '&' on korvattava ensin, muuten jo tuotetun '<':n sisällä
// oleva et-merkki escapataan toistamiseen
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;

Versiossa v3.539.47 kommentti kuten Try <a href="https://example.com">this</a> & &lt;b&gt; ilmestyy sivulle merkki merkiltä. Ennen v3.539.47 sama escapattu syöte saattoi tuottaa elävän linkkiannotaation, ja juuri se kääntää näyttövian tietoturvaongelmaksi: tikettikommentin ei pidä koskaan saada istuttaa napsautettavaa URL:ia asiakirjaan, johon henkilökuntasi luottaa

Huomaa, mitä funktio ei escapaa. Yleiskäyttöiset HTML-escaperit muuntavat myös muodon " muotoon &quot; ja muodon ' muotoon &#39;, mikä on oikein selaimelle. PDFlibPasin tekstin dekoodaus tunnistaa vain aiemmin luetellut neljä entiteettiä, joten kyseiset kaksi tulostuisivat kirjaimellisesti muodossa &quot; ja &#39;. Lainausmerkit ovat harmitonta tekstisisältöä; ne merkitsevät vain attribuuttiarvojen sisällä, eikä rendereri dekoodaa entiteettejä attribuuteissa yhtään. Turvallinen suunnittelu on siksi ei parempi escaper vaan sääntö: luottamatonta dataa ei koskaan viedä kohteisiin href, src tai style. Jos linkkikohdan on aidosti tultava käyttäjädasta, validoi se itse skeemojen ja merkkien sallittujen arvojen luetteloa vasten ja hylkää kaikki, mikä sisältää lainausmerkkejä tai kulmasulkeita

Kaksi päivityshuomiota seuraa korjauksesta suoraan:

  • Jos koodisi lopetti muodon & escapamisen, koska vanhemmat versiot tulostivat muodon &amp; kirjaimellisena, lisää se takaisin. Ilman sitä käyttäjäteksti, joka sisältää muodon &lt;, näkyy nyt muodossa <, yhä harmitonta tekstiä mutta ei enää sitä, mitä käyttäjä kirjoitti
  • Älä escapaa kahdesti. Teksti, joka kulkee kahden escaperin läpi, renderöi muodon < näkyvänä muotona &lt;, joten etsi se yksi rajapinta, jossa dataasi siirtyy HTML:ään, ja escapaa vain siellä

Sivutus LeftOverTextilla rikkomatta escapauksia

DrawHTMLTextBox palauttaa HTML:n, joka ei mahtunut, yleensä nimellä LeftOverText, ja versiosta v3.539.47 alkaen jäljelle jäävä osa säilyttää kirjaimelliset entiteettimuodot ja escapetut kulmasulkeet, kun viet sen seuraavaan laatikkoon. Sääntö kutsujille on yksinkertainen: vie se takaisin muuttumattomana

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // mitoitettu A4-sivulle pisteinä
  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 on jo escapattua moottorin HTML:ää: älä koskaan escapaa tai unescapaa sitä
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Käsittele jäljelle jäävää osaa läpinäkymättömänä. Se on moottorin normalisoitua HTML:ää, tyylit jo ratkaistuina, joten älä aja sitä oman escaperisi läpi, älä dekoodaa sitä äläkä liimaa siihen käyttäjätekstiä. Sivukatto on halpa vakuutus: jos jokin elementti ei koskaan mahdu laatikkoon, kattamattomalla silmukalla ei ole luonnollista poistumista

Markdownilla on oma jatkonsa. DrawMarkdownTextBox palauttaa tokenin, joka alkaa sisäisellä merkillä, jotta seuraava kutsu voi ohittaa muunnoksen; anna se takaisin funktiolle DrawMarkdownTextBox tai DrawMarkdownText, ei HTML-sisääntulopisteille, jotka piirtäisivät merkin tekstinä

Yleinen oppitunti: dekoodaa kerran, enkoodaa uudelleen jokaisella rajapinnalla

Jokaisen putken, joka jäsentää tekstiä, serialisoi tuloksen takaisin samaan syntaksiin ja jäsentää sen uudelleen, on kohdeltava dekoodausta operationa, joka tapahtuu täsmälleen yhdessä paikassa, ja sen on enkoodattava uudelleen jokaisella rajapinnalla, jossa dekoodattu teksti muuttuu takaisin syntaksiksi. Templaattimoottorit, HTML-sanitizerit ja Markdown–HTML–PDF-ketjut jakavat tämän muodon ja epäonnistuvat samalla tavalla, kun serialisoija unohtaa tuottavansa merkintää

Oireet ovat ennustettavissa, kun tunnet muodon. Liian vähäinen uudelleenkoodaus kääntää dataa syntaksiksi, mikä on injektiosuunta. Liiallinen koodaus tai kahdesti ajettava dekooderi näyttää entiteettimuodot lukijalle tai nielaisee ne, mikä on näyttösuunta. Vain toisen suunnan korjaaminen rikkoo yleensä toisen, minkä vuoksi PDFlibPasin korjauksen on täytynyt lisätä &amp;in dekoodaus, järjestää se uudelleen, poistaa myöhäinen dekoodaus ja lisätä uudelleenescapaus samassa julkaisussa. Sama periaate kulkee toiseen suuntaan, kun PDF-sisältö viedään jäsenneltynä tekstinä, kuten artikkelissa PDF:n semanttinen vienti Markdowniksi ja DOCX:ksi Delphista, jossa jokainen kirjaimellinen merkki on escapattava kohdesyntaksia varten täsmälleen kerran

Pikamuistilista

  • Päivitys PDFlibPas v3.539.47:ään tai uudempaan, jos renderöit käyttäjädataa sisältävää HTML:ää tai Markdownia
  • Escapaa tekstisisältö ensin muodolla &, sitten < ja >; älä muunna lainausmerkkejä PDFlibPasin tekstiä varten
  • Escapaa kerran, siinä yhdessä kohdassa, jossa data siirtyy HTML-merkkijonoon
  • Pidä luottamattomat arvot poissa kohteista href, src ja style, tai validoi ne sallittujen arvojen luetteloa vasten
  • Oleta, että vain muodot &lt;, &gt;, &amp; ja &nbsp; dekoodataan tekstissä; muut entiteetit pysyvät kirjaimellisina
  • Vie LeftOverText takaisin funktiolle DrawHTMLTextBox muuttumattomana ja aseta sivusilmukalle katto
  • Anna Markdown-jatkotokenit vain funktioille DrawMarkdownTextBox tai DrawMarkdownText
  • Älä koskaan etsi UTF-16-tavupuskureista tavukuvioita; työskentele kokonaisten koodiyksiköiden kanssa

HTML- ja Markdown-renderöinti, dataset-raporttien vienti ja asettelumoottorin loput toimitetaan PDF Library for Delphille natiivina Pascal-lähdekoodina Delphille ja Free Pascalille. Katso PDFlibPas-tuotesivulta versiot, alustatuki ja kokeiluversion lataus