Teknisk artikkel

PDFlibPas HTML til PDF: Fikse dobbelt-dekodede entiteter

Versjoner av PDF Library for Delphi (PDFlibPas) før v3.539.47 kunne dekode rømt tekst to ganger ved tegning av HTML eller Markdown inn i en PDF. DrawHTMLText og DrawHTMLTextBox parser HTML-en, normaliserer den tilbake til HTML, og parser den igjen, så tekst skrevet som <unsafe> nådde andre parsing som en ekte tag. Siden v3.539.47 dekodes hver entitet nøyaktig én gang, og teksten escapes på nytt overalt der den blir HTML igjen

Scenariet som avdekker dette, er helt vanlig. En help desk eksporterer saker til PDF, og kundekommentaren går inn i en HTML-mal. Utvikleren gjorde det riktige og escapet kommentaren, så <b> ble &lt;b&gt;. Inne i rendereren ble den escapingen i stillhet ugjort: kommentaren kom ut i fet, et ukjent tagnavn forsvant bare fra siden, og et escapet anker ble til en klikkbar lenkeannotering. Ingen unntak, ingen advarsel, en helt gyldig PDF som sier noe annet enn dataene

Hvorfor blir rømt tekst til en ekte tag i PDF-en?

Rømt tekst ble markup fordi rendereren kjører to parsepasjer, og normaliseringssteget mellom dem skrev allerede dekodet tekst tilbake til HTML uten å escape den igjen. Hver dekoding første parsing gjorde, sto dermed til disposisjon for andre parsing som levende syntaks

De to pasjene finnes av en god grunn. Første parsing bygger en liste av tag- og wordelementer. NormalizeParsedHTML løser så stylesheet-kaskaden: den matcher reglene fra <style>-blokker mot hver tag, slår dem sammen med inline style-attributter, lagrer resultatet på taggen og serialiserer hele elementlisten tilbake til en HTML-streng. Layoutpasjen parser den normaliserte strengen. Det er samme maskineri som driver flexbox, CSS grid og fotnotelayout i PDFlibPas HTML-rendering

Feilen lå i hvordan ordene ble serialisert. Tagger ble skrevet tilbake fra sin opprinnelige kildeform, mens ord ble skrevet tilbake i dekodet form. Et ord som første parsing hadde dekodet fra &lt;unsafe&gt; til <unsafe>, landet i den normaliserte HTML-en som rå vinkelparenteser, og andre parsing leste det som et element. Rundt den kjernen satt tre mindre lekkasjer som pekte samme vei:

  • &amp; var ikke i det støttede entitetssettet, så R&amp;D ble skrevet bokstavelig, og det fantes ingen måte å skrive en bokstavelig entitetsskriving som &lt; som tekst
  • Tegnestegene erstattet &nbsp; en gang til, etter at parsingen allerede var ferdig, så en bokstavelig entitetsskriving kunne fortsatt forsvinne helt på slutten
  • Markdown-kode-escaping hoppet over ampersanden, og datasett-eksportøren escapet bare vinkelparenteser, så entitetsskrivinger inne i kode eller celleverdier ble dekodet som markup
PDFlibPas HTML-pipeline for DrawHTMLText der parse én bygger elementer, NormalizeParsedHTML serialiserer dem tilbake til HTML og parse to legger resultatet ut; før v3.539.47 ble dekodete ord skrevet tilbake uten escaping og ble levende tagger, siden v3.539.47 escapes hvert ord på nytt ved grensen
Dekodete ord går inn i parseren igjen som syntaks når normalisatoren glemmer at den produserer markup, og det er slik en escapet kommentar ble fet eller fikk en lenke
Input som når rendererenFør v3.539.47Siden v3.539.47
&lt;unsafe&gt;Tolket som en tag, teksten når aldri siden<unsafe> tegnet som tekst
&lt;b&gt;x&lt;/b&gt;x tegnet i fet<b>x</b> tegnet som tekst
R&amp;DR&amp;D skrevet bokstaveligR&D
&amp;lt;&amp;lt; skrevet bokstavelig&lt;
Markdown-kodespenn som inneholder &nbsp;Ble et hardt mellomrom&nbsp; tegnet som tekst
Datasett-celleverdi &lt;<&lt;

Hvordan v3.539.47 gjør HTML-entitetsdekoding til én pass

PDFlibPas v3.539.47 gjør entitetsdekoding til én pass med tre koordinerte endringer: parseren dekoder &amp; sist, tegnestegene dekoder ikke lenger noe, og hvert sted som gjør dekodete ord om til HTML igjen, escaper dem først

Det støttede entitetssettet for tekstinnhold er nå &lt;, &gt;, &amp; og &nbsp;. Alt annet, inkludert numeriske referanser som &#65; og navngitte entiteter som &quot;, forblir bokstavelig tekst. Den grensen betyr noe for hvordan du escaper din egen input, som vist nedenfor

Rekkefølgen i dekoderen er den første fiksen. Ble &amp; dekodet først, ville input &amp;lt; blitt &lt;, og neste erstatning ville gjort det om til < — en dobbel dekoding som skjer inne i én pass. ANSI-ordstien erstatter dermed &lt;, &gt; og &nbsp; først og &amp; sist, så ampersanden den produserer, aldri undersøkes igjen. UTF-16-ordstien er én enkelt skanning fra venstre til høyre i to-byte-steg som omskriver hvert treff der det står og går forbi det, noe som gir samme garanti strukturelt

PDFlibPas dekoderrekkefølge for en lenket entitet som &amp;lt;: dekoding av ampersanden først kollapser den til en ekte vinkelparentes inne i én pass, mens dekoding av lt, gt og nbsp før ampersanden holder den bokstavelige skrivingen intakt slik at teksten når siden dekodet nøyaktig én gang
Ampersanden er escape-tegnet, så den må dekodes sist og escapes først, ellers kan én pass dekode to ganger

Den andre fiksen fjerner den sene &nbsp;-erstatningen fra tegnestegene. Dekoding hører hjemme i parseren og ingen andre steder, så et ord som når linjebryteren, er endelig tekst

Den tredje fiksen er grenseregelen. NormalizeParsedHTML escaper nå &, < og > i hvert dekodet ord før det legges til den normaliserte HTML-en. Andre parsing dekoder det tilbake til nøyaktig samme tekst, så nettoeffekten over hele pipelinen er én dekoding. Fortsettelsesstrengen følger samme regel: ord som ikke fikk plass i boksen, escapes før de legges til LeftOverText, og resten av resten kopieres fra den normaliserte HTML-en, som allerede er i escapet form. Løkken som samler de gjenværende ordene, er også avgrenset av ordantallet nå, der den gamle repeat-løkken kunne gå forbi siste ord

Hvorfor kan ikke UTF-16BE-escaping bruke erstatning på byte-nivå?

UTF-16BE-escaping kan ikke bruke erstatning på byte-nivå fordi toe-bytes-mønsteret for en ampersand kan spenne over to helt urelaterte tegn. Den eneste korrekte arbeidsenheten er hele 16-bits kodeenheten

Rendereren lagrer Unicode-ord som big-endian UTF-16 pakket inn i bytestrenger, high byte først. En ampersand er 00 26. Ta nå U+0100 (stor latin A med makron, bytene 01 00) etterfulgt av U+2603 (snømannen, bytene 26 03). Bytesekvensen er 01 00 26 03, og byte to og tre leses som 00 26. Et bytesøk etter #0'&' finner en ampersand som ikke finnes, fletter bytene for &amp; inn midt i to tegn og forskjøver hvert tegn etterpå med én byte

PDFlibPas UTF-16BE-escaping-fare der bytene 01 00 26 03 for U+0100 og U+2603 inneholder mønsteret 00 26 over to tegn, så et søk på byte-nivå etter ampersanden fletter en entitet inn midt i et kodepunkt; kodeenhetsskanningen tester bare partalls-offsets
Et bytesøk finner en ampersand som ingen karakter noensinne inneholdt; arbeid på hele kodeenheter, aldri på rå UTF-16-bytebuffere

Dette er ikke et eksotisk hjørnetilfelle. Ethvert tegn hvis low byte er null, kan levere første halvdel; U+4E00, ett av de mest hyppige CJK-ideogrammene, kvalifiserer. Vinkelparentesene har samme eksponering: 00 3C og 00 3E dukker opp når et slikt tegn etterfølges av ett fra U+3C00 til U+3EFF i CJK Extension A. Fiksen i EscapeHTMLWord pakker ut bytene til en WideString, escaper tegn for tegn og pakker resultatet igjen. Dekodersiden var allerede trygg, for den tester bare mønstre på partalls kodeenhetsgrenser

Samme regel gjelder din egen kode. Holder du noensinne UTF-16-tekst som TBytes, for eksempel etter TEncoding.BigEndianUnicode.GetBytes, så ikke søk i den etter bytemønstre. Konverter tilbake til en streng og arbeid på tegn

Markdown-kodeblokker og datasetteksporter: escape ampersanden først

Siden v3.539.47 escaper begge HTML-produsentene inne i PDFlibPas, Markdown-konvertereren og datasett-eksportøren, ampersanden før vinkelparentesene, slik at den enkel dekodingen i rendereren gjenoppretter nøyaktig den opprinnelige teksten

I MarkdownToHTML mapper inline kodespenn og fensede eller innrykkede kodeblokker nå & til &amp;, < til &lt; og > til &gt;, mens mellomrom blir &nbsp; og en tab blir fire av dem for å bevare innrykk. Vanlig Markdown-prose escaper bare vinkelparentesene, så rå HTML i prose ikke kan injisere tagger, mens en forfatter fortsatt kan skrive &amp; med vilje, omtrent slik Markdown-forfattere forventer. DrawMarkdownText og DrawMarkdownTextBox bruker samme konvertering, så kode dukker opp i PDF-en akkurat som skrevet:

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
    // Se på HTML-en: i kode blir '&' til '&amp;' og '<' til '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // øvre venstre origo, Y vokser nedover
    Lib.SetMeasurementUnits(0);  // punkter
    // Siden viser koden akkurat som skrevet, entitetsskrivinger inkludert
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Datasett-eksportøren er det lærerike tilfellet. Før v3.539.47 escapet den bare vinkelparenteser, og med vilje: rendereren dekodet ikke &amp;, så å escape ampersanden ville skrevet &amp; i hver celle som inneholdt én. Omveien var korrekt for den gamle rendereren og gal i det store, fordi en celleverdi som tilfeldigvis inneholdt &lt;, ble dekodet til <. Med rendereren fikset, escaper eksportøren & først, og en verdi som R&D &lt; &amp; &nbsp; lander i PDF-en ordrett. Bygger du rapporter slik, dekker gjennomgangen om å eksportere en TDataSet til en PDF-rapport i Delphi resten av eksportøren

Hvorfor ampersanden må gå først, er verdt å skrive ut én gang. Escaper < først, får du &lt;; escaper & som nummer to, blir det &amp;lt;, som en korrekt enkelt dekoding viser som &lt; i stedet for <. En sekvensiell erstattingskjede er bare korrekt når escape-tegnet selv håndteres før noe som introduserer det

Hvordan bør du escape uklarert tekst for DrawHTMLTextBox?

For PDFlibPas HTML-rendering escaper du uklarert tekstinnhold ved å erstatte &, så <, så >, nøyaktig én gang, og holder uklarerte data helt utenfor attributtverdier

uses
  System.SysUtils, PDFlibrary;

// Escaper uklarert tekst for PDFlibPas HTML-tekstinnhold.
// '&' må erstattes først, ellers blir ampersanden inne i
// en allerede produsert '&lt;' escapet en gang til
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;

På v3.539.47 dukker en kommentar som Try <a href="https://example.com">this</a> & &lt;b&gt; opp på siden tegn for tegn. Før v3.539.47 kunne samme escapet input produsere en levende lenkeannotering, og det er det som gjør en visuell glipp om til et sikkerhetsproblem: en saketekst skal aldri kunne plante en klikkbar URL i et dokument personalet ditt stoler på

Merk deg hva funksjonen ikke escaper. Generelle HTML-escapere konverterer også " til &quot; og ' til &#39;, noe som er riktig for en nettleser. PDFlibPas tekstdekoding gjenkjenner bare de fire entitetene listet opp tidligere, så de to ville blitt skrevet bokstavelig som &quot; og &#39;. Anførselstegn er harmløse i tekstinnhold; de betyr bare noe inne i attributtverdier, og rendereren dekoder ikke entiteter i attributter i det hele tatt. Det trygge designet er derfor ikke en bedre escaper, men en regel: uklarerte data går aldri inn i href, src eller style. Må et lenkemål virkelig komme fra brukerdata, valider det selv mot en allow-liste av skjemaer og tegn, og avvis alt som inneholder anførselstegn eller vinkelparenteser

To oppgraderingsnotater følger direkte av fiksen:

  • Hvis koden din sluttet å escape & fordi eldre versjoner skrev &amp; bokstavelig, legg det tilbake. Uten det viser brukertekst som inneholder &lt; nå som <, fortsatt harmløs tekst, men ikke lenger det brukeren skrev
  • Ikke escape to ganger. Tekst som passerer gjennom to escapere, rendrer < som den synlige skrivingen &lt;, så finn det ene grensepunktet der dataene dine går inn i HTML, og escape bare der

Paginere med LeftOverText uten å ødelegge escapingen

DrawHTMLTextBox returnerer HTML-en som ikke fikk plass, vanligvis kalt LeftOverText, og siden v3.539.47 bevarer resten bokstavelige entitetsskrivinger og escapete vinkelparenteser når du sender den til neste boks. Regelen for kallere er enkel: send den tilbake uendret

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimensjonert for en A4-side i punkter
  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 er allerede escapet engine-HTML: aldri escape eller unescape den
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Behandle resten som opak. Det er motorens normaliserte HTML, med stiler allerede løst, så ikke kjør den gjennom din egen escaper, ikke dekod den, og ikke flett brukertekst inn i den. Sidegrensen er billig forsikring: hvis et element aldri kan få plass i boksen, har en løkke uten grense ingen naturlig utgang

Markdown har sin egen fortsettelse. DrawMarkdownTextBox returnerer en token som begynner med en intern markør slik at neste kall kan hoppe over konverteringen; gi den tilbake til DrawMarkdownTextBox eller DrawMarkdownText, ikke til HTML-inngangene, som ville tegnet markøren som tekst

Den generelle lærdommen: dekod én gang, re-encoding ved hver grense

Enhver pipeline som parser tekst, serialiserer resultatet tilbake til samme syntaks og parser det igjen, må behandle dekoding som en operasjon som skjer på nøyaktig ett sted, og må re-encode ved hver grense der dekodet tekst blir syntaks igjen. Template-motorer, HTML-sanitizerere og Markdown-til-HTML-til-PDF-kjeder deler denne formen og feiler på samme måte når en serialisator glemmer at den produserer markup

Symptomene er forutsigbare når du kjenner formen. For lite re-encoding gjør data om til syntaks, det er injeksjonsretningen. For mye encoding, eller en dekoder som kjører to ganger, viser entitetsskrivinger til leseren eller spiser dem, det er visningsretningen. Å fikse én retning alene ødelegger som regel den andre, og det er derfor PDFlibPas-fiksen måtte legge til &amp;-dekoding, omstokke rekkefølgen, fjerne den sene dekodingen og legge til re-escaping i samme utgivelse. Samme prinsipp går motsatt vei når PDF-innhold eksporteres som strukturert tekst, som i PDF til Markdown og DOCX semantisk eksport fra Delphi, der hvert bokstavelig tegn må escapes for mål-syntaksen nøyaktig én gang

Hurtigreferanse-sjekkliste

  • Oppgrader til PDFlibPas v3.539.47 eller senere hvis du rendrer HTML eller Markdown som inneholder brukerdata
  • Escaper tekstinnhold med & først, så < og >; ikke konverter anførselstegn for PDFlibPas-tekst
  • Escape én gang, på det ene punktet der data går inn i HTML-strengen
  • Hold uklarerte verdier utenfor href, src og style, eller valider dem mot en allow-liste
  • Forvent at bare &lt;, &gt;, &amp; og &nbsp; dekodes i tekst; andre entiteter forblir bokstavelige
  • Send LeftOverText tilbake til DrawHTMLTextBox uendret og sett en grense for side-løkken
  • Gi Markdown-fortsettelsestokens bare til DrawMarkdownTextBox eller DrawMarkdownText
  • Søk aldri i UTF-16-bytebuffere etter bytemønstre; arbeid på hele kodeenheter

HTML- og Markdown-rendering, datasettrapporteksport og resten av layoutmotoren følger med i den native Pascal-kilden til PDF Library for Delphi, for Delphi og Free Pascal. Se PDFlibPas produktsiden for utgaver, plattformstøtte og en prøvenedlasting