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 <b>. 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 <unsafe> 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:
&var ikke med i det understøttede entity-sæt, såR&Dblev trykt bogstaveligt, og der var ingen måde at skrive en bogstavelig entity-stavemåde som<på som tekst- Tegnetrinnet erstattede
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
| Input der når rendereren | Før v3.539.47 | Siden v3.539.47 |
|---|---|---|
<unsafe> | Parset som et tag, teksten når aldrig siden | <unsafe> tegnet som tekst |
<b>x</b> | x tegnet i fed | <b>x</b> tegnet som tekst |
R&D | R&D trykt bogstaveligt | R&D |
&lt; | &lt; trykt bogstaveligt | < |
Markdown code span indeholdende | Blev til et non-breaking space | tegnet som tekst |
Dataset-celleværdi < | < | < |
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 & 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 <, >, & og . Alt andet, inklusive numeriske referencer som A og navngivne entities som ", 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 & blev dekodet først, ville inputtet &lt; blive til <, og den næste erstatning ville gøre det til < — en dobbelt dekodning, der sker inden for én gennemkørsel. ANSI-ordstien erstatter derfor <, > og først og & 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
Det andet fix fjerner den sene -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 & midt i to tegn og forskubber hvert efterfølgende tegn med én byte
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 &, < til < og > til >, mens mellemrum bliver til 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 & 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Undersøg HTML'en: i kode bliver '&' til '&' og '<' til '<'
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 &, så at escape ampersanden ville have trykket & i hver celle, der indeholdt én. Workarounden var korrekt for den gamle renderer og forkert generelt, fordi en celleværdi, der tilfældigt indeholdt <, blev dekodet til <. Med rendereren rettet escaper eksportøren & først, og en værdi som R&D < & 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 <; escaper man & bagefter, bliver det til &lt;, som en korrekt enkelt dekodning viser som < 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 '<' escaperet en anden gang
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [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> & <b> 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 " og ' til ', 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 " og '. 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&bogstaveligt, så få det tilbage. Uden den vises brugertekst med<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<, 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 &-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,srcogstyle, eller validér dem mod en allow-liste - Forvent kun, at
<,>,&og dekodes i tekst; andre entities forbliver bogstavelige - Giv
LeftOverTexttilbage tilDrawHTMLTextBoxuændret, og sæt loft på sidesløjfen - Giv Markdown-fortsættelses-tokens kun til
DrawMarkdownTextBoxellerDrawMarkdownText - 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