Teknisk artikel

PDFlibPas HTML til PDF: rettet dobbelt-dekodede entities

PDF Library for Delphi (PDFlibPas)-versioner før v3.539.47 kunne dekode escaped tekst to gange, når HTML eller Markdown blev tegnet ind i en PDF. DrawHTMLText og DrawHTMLTextBox parser HTML'en, normaliserer den tilbage til HTML og parser den igen, så tekst skrevet som <unsafe> nåede anden parse som et ægte tag. Siden v3.539.47 dekodes hver entity præcis én gang, og tekst re-escapes, hvor den bliver til HTML igen

Scenariet, der afslører det, er helt almindeligt. Et help desk eksporterer tickets til PDF, og kundekommentaren havner i en HTML-skabelon. Udvikleren gjorde det rigtige og escapede kommentaren, så <b> blev til &lt;b&gt;. Inde i rendereren blev den escaping lydløst ugjort: kommentaren kom ud i fed, et ukendt tagnavn forsvandt bare fra siden, og et escaperet anchor blev til et klikbart link-annotation. Ingen exception, ingen advarsel, en fuldt gyldig PDF, der siger noget andet end data

Hvorfor bliver escaped tekst til et ægte tag i PDF'en?

Escaped tekst blev til markup, fordi rendereren kører to parse-gennemløb, og normaliseringstrinnet imellem dem skrev allerede dekodet tekst tilbage til HTML uden at escape den igen. Hver dekodning, første parse havde udført, stod så til rådighed for anden parse som levende syntaks

De to gennemløb findes af en god grund. Første parse bygger en liste af tag- og ord-elementer. NormalizeParsedHTML opløser derefter stylesheet-cascaden: den matcher reglerne fra <style>-blokke mod hvert tag, fletter dem med inline style-attributter, gemmer resultatet på tagget og serialiserer hele elementlisten tilbage til en HTML-streng. Layoutgennemløbet parser den normaliserede streng. Det er samme maskineri, der driver flexbox, CSS grid og fodnote-layout i PDFlibPas' HTML-rendering

Fejlen lå i, hvordan ordene blev serialiseret. Tags blev skrevet tilbage fra deres oprindelige kildeform, mens ord blev skrevet tilbage i deres dekodede form. Et ord, som første parse havde dekodet fra &lt;unsafe&gt; til <unsafe>, landede i den normaliserede HTML som rå vinkelparenteser, og anden parse læste det som et element. Rundt om den kernebug sad tre mindre lækager, der pegede den samme vej:

  • &amp; var ikke med i det understøttede entity-sæt, så R&amp;D blev trykt bogstaveligt, og der var ingen måde at skrive en bogstavelig entity-stavemåde som &lt; på som tekst
  • Tegnetrinnet erstattede &nbsp; en anden gang, efter parsingen allerede var færdig, så en bogstavelig entity-stavemåde stadig kunne forsvinde til allersidst
  • Markdown-kode-escaping sprang ampersanden over, og dataset-eksportøren escapede kun vinkelparenteser, så entity-stavemåder inde i kode- eller celleværdier blev dekodet som markup
PDFlibPas HTML-pipeline for DrawHTMLText, hvor parse ét bygger elementer, NormalizeParsedHTML serialiserer dem tilbage til HTML, og parse to layouter resultatet; før v3.539.47 blev dekodede ord skrevet tilbage unescaperet og blev til levende tags, siden v3.539.47 re-escapes hvert ord ved grænsen
Dekodede ord genindtræder parseren som syntaks, når normalizeren glemmer, at den producerer markup, og det er sådan en escaperet kommentar blev fed eller fik et link
Input der når rendererenFør v3.539.47Siden v3.539.47
&lt;unsafe&gt;Parset som et tag, teksten når aldrig siden<unsafe> tegnet som tekst
&lt;b&gt;x&lt;/b&gt;x tegnet i fed<b>x</b> tegnet som tekst
R&amp;DR&amp;D trykt bogstaveligtR&D
&amp;lt;&amp;lt; trykt bogstaveligt&lt;
Markdown code span indeholdende &nbsp;Blev til et non-breaking space&nbsp; tegnet som tekst
Dataset-celleværdi &lt;<&lt;

Hvordan v3.539.47 gør HTML-entity-dekodning single-pass

PDFlibPas v3.539.47 gør entity-dekodning single-pass med tre koordinerede ændringer: parseren dekoder &amp; sidst, tegnetrinnet dekoder ikke længere noget som helst, og hvert sted, der gør dekodede ord til HTML igen, escaper dem først igen

Det understøttede entity-sæt til tekstindhold er nu &lt;, &gt;, &amp; og &nbsp;. Alt andet, inklusive numeriske referencer som &#65; og navngivne entities som &quot;, forbliver bogstavelig tekst. Grænsen betyder noget for, hvordan du escaper dit eget input, som vist nedenfor

Rækkefølgen inde i dekoderen er det første fix. Hvis &amp; blev dekodet først, ville inputtet &amp;lt; blive til &lt;, og den næste erstatning ville gøre det til < — en dobbelt dekodning, der sker inden for én gennemkørsel. ANSI-ordstien erstatter derfor &lt;, &gt; og &nbsp; først og &amp; sidst, så ampersanden, den producerer, aldrig undersøges igen. UTF-16-ordstien er ét enkelt venstre-mod-højre-scan i to-byte-trin, der omskriver hvert match på stedet og går forbi det, hvilket giver samme garanti strukturelt

PDFlibPas' dekoder-rækkefølge for en kædet entity som &amp;lt;: dekodes ampersanden først, kollapser den til en ægte vinkelparentes inden for én gennemkørsel, mens lt, gt og nbsp dekodes før ampersanden, bevarer den bogstavelige stavemåde sig intakt, så teksten når siden dekodet præcis én gang
Ampersanden er escape-tegnet, så den skal dekodes sidst og escapes først, ellers kan én gennemkørsel dekode to gange

Det andet fix fjerner den sene &nbsp;-erstatning fra tegnetrinnet. Dekodning hører til parseren og intet andet sted, så et ord, der når linjebreakeren, er endelig tekst

Det tredje fix er grænsereglen. NormalizeParsedHTML escaper nu &, < og > i hvert dekodet ord, inden det føjes til den normaliserede HTML. Anden parse dekoder det tilbage til præcis samme tekst, så nettoeffekten over hele pipelinen er én dekodning. Fortsættelsesstrengen følger samme regel: ord, der ikke var plads til i boksen, escapes, inden de føjes til LeftOverText, og resten af remainderen kopieres fra den normaliserede HTML, som allerede er i escaperet form. Løkken, der samler de efterladte ord, er nu også afgrænset af ordantallet, hvor den gamle repeat-løkke kunne træde forbi sidste ord

Hvorfor kan UTF-16BE-escaping ikke bruge erstatning på byte-niveau?

UTF-16BE-escaping kan ikke bruge erstatning på byte-niveau, fordi to-byte-mønsteret for en ampersand kan spænde over to helt uafhængige tegn. Den eneste korrekte arbejdsenhed er hele 16-bit-kodeenheden

Rendereren gemmer Unicode-ord som big-endian UTF-16 pakket i byte-strenge, høj byte først. En ampersand er 00 26. Tag nu U+0100 (stort latin A med macron, bytes 01 00) efterfulgt af U+2603 (semanden, bytes 26 03). Bytesekvensen er 01 00 26 03, og byte to og tre læses som 00 26. En byte-søgning efter #0'&' finder en ampersand, der ikke findes, indsætter bytes til &amp; midt i to tegn og forskubber hvert efterfølgende tegn med én byte

PDFlibPas' UTF-16BE-escaping-fare, hvor bytes 01 00 26 03 for U+0100 og U+2603 indeholder mønsteret 00 26 over to tegn, så en byte-niveau-søgning efter ampersanden indsætter en entity midt i et code point; code unit-scannen tester kun lige offsets
En byte-søgning finder en ampersand, som intet tegn nogensinde indeholdt; arbejd på hele kodeenheder, aldrig på rå UTF-16-byte-buffere

Det er ikke et eksotisk hjørnetilfælde. Ethvert tegn, hvis lav byte er nul, kan levere første halvdel; U+4E00, et af de hyppigste CJK-tegn, kvalificerer sig. Vinkelparenteserne har samme eksponering: 00 3C og 00 3E optræder, når et sådant tegn efterfølges af ét fra U+3C00 til U+3EFF i CJK Extension A. Fixet i EscapeHTMLWord udpakker bytes til en WideString, escaper tegn for tegn og pakker resultatet igen. Dekodersiden var allerede sikker, fordi den kun tester mønstre ved lige kodeenheds-grænser

Samme regel gælder din egen kode. Holder du UTF-16-tekst som TBytes, for eksempel efter TEncoding.BigEndianUnicode.GetBytes, så søg ikke i den efter byte-mønstre. Konvertér tilbage til en streng og arbejd på tegn

Markdown-kodeblokke og dataset-eksporter: escap ampersanden først

Siden v3.539.47 escaper begge HTML-producenter inde i PDFlibPas, Markdown-konverteren og dataset-eksportøren, ampersanden før vinkelparenteserne, så den enkelte dekodning i rendereren genskaber præcis den oprindelige tekst

I MarkdownToHTML mapper inline code spans og fencede eller indrykkede kodeblokke nu & til &amp;, < til &lt; og > til &gt;, mens mellemrum bliver til &nbsp; og et tab til fire af dem for at bevare indrykningen. Almindelig Markdown-prosa escaper kun vinkelparenteserne, så rå HTML i prosa ikke kan injicere tags, mens en forfatter stadig kan skrive &amp; med vilje, stort set som Markdown-forfattere forventer. DrawMarkdownText og DrawMarkdownTextBox bruger samme konvertering, så kode vises i PDF'en præcis 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
    // Undersøg HTML'en: i kode bliver '&' til '&amp;' og '<' til '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // oprindelse øverst til venstre, Y vokser nedad
    Lib.SetMeasurementUnits(0);  // punkter
    // Siden viser koden præcis som skrevet, entity-stavemåder inkluderet
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Dataset-eksportøren er det oplysende tilfælde. Før v3.539.47 escapede den kun vinkelparenteser, og med vilje: rendereren dekodede ikke &amp;, så at escape ampersanden ville have trykket &amp; i hver celle, der indeholdt én. Workarounden var korrekt for den gamle renderer og forkert generelt, fordi en celleværdi, der tilfældigt indeholdt &lt;, blev dekodet til <. Med rendereren rettet escaper eksportøren & først, og en værdi som R&D &lt; &amp; &nbsp; lander i PDF'en ordret. Bygger du rapporter på den måde, dækker gennemgangen eksport af en TDataSet til en PDF-rapport i Delphi resten af eksportøren

Hvorfor ampersanden skal først, er værd at få sagt én gang. Escapes < først, får du &lt;; escaper man & bagefter, bliver det til &amp;lt;, som en korrekt enkelt dekodning viser som &lt; i stedet for <. En sekventiel erstatningskæde er kun korrekt, når escape-tegnet selv håndteres, før noget, der introducerer det

Hvordan skal du escape utroværdig tekst til DrawHTMLTextBox?

I PDFlibPas' HTML-rendering escaper du utroværdigt tekstindhold ved at erstatte &, derefter <, derefter >, præcis én gang, og holder utroværdige data helt ude af attributværdier

uses
  System.SysUtils, PDFlibrary;

// Escaper utroværdig tekst til PDFlibPas HTML-tekstindhold.
// '&' skal erstattes først, ellers bliver ampersanden inde i
// en allerede produceret '&lt;' escaperet en anden gang
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 vises en kommentar som Try <a href="https://example.com">this</a> & &lt;b&gt; på siden tegn for tegn. Før v3.539.47 kunne samme escaperede input producere et levende link-annotation, og det er dét, der gør en visningsfejl til et sikkerhedsproblem: en ticket-kommentar skal aldrig kunne plante et klikbart URL i et dokument, dit personale stoler på

Bemærk, hvad funktionen ikke escaper. Generelle HTML-escapers konverterer også " til &quot; og ' til &#39;, hvilket er rigtigt for en browser. PDFlibPas' tekstdekodning genkender kun de fire entities, der blev nævnt tidligere, så de to ville blive trykt bogstaveligt som &quot; og &#39;. Anførselstegn er harmløse i tekstindhold; de betyder kun noget inde i attributværdier, og rendereren dekoder overhovedet ikke entities i attributter. Det sikre design er derfor ikke en bedre escaper, men en regel: utroværdige data kommer aldrig ind i href, src eller style. Skal et linktarget virkelig komme fra brugerdata, så validér det selv mod en allow-liste af schemes og tegn, og afvis alt, der indeholder anførselstegn eller vinkelparenteser

To opgraderingsnoter følger direkte af fixet:

  • Hvis din kode holdt op med at escape &, fordi ældre versioner trykte &amp; bogstaveligt, så få det tilbage. Uden den vises brugertekst med &lt; nu som < — stadig harmløs tekst, men ikke længere det, brugeren skrev
  • Escap ikke to gange. Tekst, der passerer gennem to escapers, renderer < som den synlige stavemåde &lt;, så find den ene grænse, hvor dine data træder ind i HTML, og escap dér og kun dér

Paginering med LeftOverText uden at ødelægge escapes

DrawHTMLTextBox returnerer den HTML, der ikke var plads til, normalt kaldet LeftOverText, og siden v3.539.47 bevarer den remainder bogstavelige entity-stavemåder og escaperede vinkelparenteser, når du giver den videre til den næste boks. Reglen for kaldere er simpel: giv den videre uændret

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // størrelse til 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 escaperet engine-HTML: escape eller unescape den aldrig
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Behandl remainderen som uigennemsigtig. Det er enginens normaliserede HTML med styles allerede opløst, så kør den ikke gennem din egen escaper, dekod den ikke, og kopier ikke brugertekst ind i den. Sideloftet er billig forsikring: kan et element aldrig blive plads i boksen, har en løkke uden loft ingen naturlig udgang

Markdown har sin egen fortsættelse. DrawMarkdownTextBox returnerer et token, der starter med en intern markør, så næste kald kan springe konverteringen over; giv det tilbage til DrawMarkdownTextBox eller DrawMarkdownText, ikke til HTML-indgangene, som ville tegne markøren som tekst

Den generelle lektie: dekod én gang, re-encod ved hver grænse

Enhver pipeline, der parser tekst, serialiserer resultatet tilbage til samme syntaks og parser den igen, skal behandle dekodning som en operation, der sker på præcis ét sted, og skal re-encode ved hver grænse, hvor dekodet tekst bliver syntaks igen. Template engines, HTML-sanitisere og Markdown-til-HTML-til-PDF-kæder deler denne form og fejler på samme måde, når en serialiserer glemmer, at den producerer markup

Symptomerne er forudsigelige, når du kender formen. For lidt re-encoding gør data til syntaks, hvilket er injectionsretningen. For meget encoding, eller en dekoder, der kører to gange, viser entity-stavemåder til læseren eller sluger dem, hvilket er visningsretningen. At rette én retning alene ødelægger sædvanligvis den anden, og derfor måtte PDFlibPas-fixet tilføje &amp;-dekodning, flytte den i rækkefølge, fjerne den sene dekodning og tilføje re-escaping i samme release. Samme princip løber den anden vej, når PDF-indhold eksporteres som struktureret tekst, som i PDF til Markdown- og DOCX-semantisk eksport fra Delphi, hvor hvert bogstaveligt tegn skal escapes til targetsyntaksen præcis én gang

Hurtig reference-tjekliste

  • Opgradér til PDFlibPas v3.539.47 eller senere, hvis du renderer HTML eller Markdown med brugerdata
  • Escap tekstindhold med & først, derefter < og >; konvertér ikke anførselstegn til PDFlibPas-tekst
  • Escap én gang, ved det ene punkt, hvor data træder ind i HTML-strengen
  • Hold utroværdige værdier ude af href, src og style, eller validér dem mod en allow-liste
  • Forvent kun, at &lt;, &gt;, &amp; og &nbsp; dekodes i tekst; andre entities forbliver bogstavelige
  • Giv LeftOverText tilbage til DrawHTMLTextBox uændret, og sæt loft på sidesløjfen
  • Giv Markdown-fortsættelses-tokens kun til DrawMarkdownTextBox eller DrawMarkdownText
  • Søg aldrig i UTF-16-byte-buffere efter byte-mønstre; arbejd på hele kodeenheder

HTML- og Markdown-rendering, dataset-rapporteeksport og resten af layoutmotoren følger med i den native Pascal-kildekode til PDF Library for Delphi, til Delphi og Free Pascal. Se PDFlibPas-produktsiden for udgaver, platform-understøttelse og en trial-download