Versioner av PDF Library for Delphi (PDFlibPas) före v3.539.47 kunde avkoda eskapad text två gånger när HTML eller Markdown ritades in i en PDF. DrawHTMLText och DrawHTMLTextBox tolkar HTML:en, normaliserar den tillbaka till HTML och tolkar den igen, så text skriven som <unsafe> nådde den andra tolkningen som en riktig tagg. Sedan v3.539.47 avkodas varje entitet exakt en gång och text eskapas om varhelst den går tillbaka till HTML
Scenariot som avslöjar detta är vardagligt. En supportavdelning exporterar ärenden till PDF, och kundkommentaren hamnar i en HTML-mall. Utvecklaren gjorde rätt och eskapade kommentaren, så <b> blev <b>. Inuti renderaren ångrades det eskapandet i tysthet: kommentaren kom ut i fetstil, ett okänt taggnamn försvann bara från sidan, och ett eskapat ankare blev en klickbar länkannotering. Ingen exception, ingen varning, en helt korrekt PDF som säger något annat än datat
Varför blir eskapad text en riktig tagg i PDF:en?
Eskapad text blev markup för att renderaren kör två tolkningspass, och normaliseringssteget emellan dem skrev redan avkodad text tillbaka till HTML utan att eskapa den igen. Varje avkodning som första passet utfört låg då öppen för andra passet som levande syntax
De två passen finns av en god anledning. Den första tolkningen bygger en lista av tagg- och ordelement. NormalizeParsedHTML löser sedan stylesheet-kaskaden: den matchar reglerna från <style>-block mot varje tagg, slår ihop dem med inline-style-attribut, lagrar resultatet på taggen och serialiserar hela elementlistan tillbaka till en HTML-sträng. Layoutpasset tolkar den normaliserade strängen. Det är samma maskineri som driver flexbox, CSS grid och fotnotslayout i PDFlibPas HTML-rendering
Felet låg i hur orden serialiserades. Taggar skrevs tillbaka från sin ursprungliga källform, medan orden skrevs tillbaka i sin avkodade form. Ett ord som första tolkningen avkodat från <unsafe> till <unsafe> hamnade i den normaliserade HTML:en som råa vinkelparenteser, och den andra tolkningen läste det som ett element. Runt det kärnfelet satt tre mindre läckor som pekade åt samma håll:
&fanns inte i den stödda entitetsmängden, såR&Dtrycktes bokstavligen och det fanns inget sätt att skriva en bokstavlig entitetsskrivning som<som text- Ritsteget bytte ut
en andra gång, efter att tolkningen redan var klar, så en bokstavlig entitetsskrivning kunde fortfarande försvinna allra på slutet - Markdown-kodeskapning hoppade över et-tecknet, och datasetexportören eskapade bara vinkelparenteser, så entitetsskrivningar i kod eller cellvärden avkodades som markup
| Indata som når renderaren | Före v3.539.47 | Sedan v3.539.47 |
|---|---|---|
<unsafe> | Tolkad som en tagg, texten når aldrig sidan | <unsafe> ritad som text |
<b>x</b> | x ritad i fetstil | <b>x</b> ritad som text |
R&D | R&D tryckt bokstavligen | R&D |
&lt; | &lt; tryckt bokstavligen | < |
Markdown-kodspann som innehåller | Blev ett hårt mellanslag | ritad som text |
Dataset-cellvärde < | < | < |
Hur v3.539.47 gör HTML-entitetsavkodning enpassad
PDFlibPas v3.539.47 gör entitetsavkodning enpassad med tre samordnade ändringar: tolkaren avkodar & sist, ritsteget avkodar inte längre något, och varje ställe som gör avkodade ord till HTML igen eskapar dem om först
Den stödda entitetsmängden för textinnehåll är nu <, >, & och . Allt annat, inklusive numeriska referenser som A och namngivna entiteter som ", stannar bokstavlig text. Den gränsen spelar roll för hur du eskapar din egen indata, som visas nedan
Ordningen inuti avkodaren är första fixen. Hade & avkodats först skulle indatan &lt; bli < och nästa utbyte skulle göra den till <, en dubbelavkodning som händer inuti ett enda pass. ANSI-ordvägen byter därför ut <, > och först och & sist, så att et-tecknet den framställer aldrig granskas igen. UTF-16-ordvägen är en enda skanning från vänster till höger i tvåbytessteg som skriver om varje träff på plats och passerar förbi den, vilket ger samma garanti strukturellt
Andra fixen tar bort det sena -utbytet från ritsteget. Avkodning tillhör tolkaren och ingen annan, så ett ord som når radbrytaren är slutlig text
Tredje fixen är gränsregeln. NormalizeParsedHTML eskapar nu &, < och > i varje avkodat ord innan det läggs till den normaliserade HTML:en. Andra tolkningen avkodar det tillbaka till exakt samma text, så nettoeffekten över hela pipelinen är en avkodning. Fortsättningssträngen följer samma regel: ord som inte fick plats i rutan eskapas innan de läggs till LeftOverText, och resten av resten kopieras från den normaliserade HTML:en, som redan är i eskapad form. Loopen som samlar de överblivna orden begränsas nu också av ordantalet, där den gamla repeat-loopen kunde stega förbi sista ordet
Varför kan UTF-16BE-eskapning inte använda ett byte-nivåutbyte?
UTF-16BE-eskapning kan inte använda ett byte-nivåutbyte eftersom tvåbytesmönstret för ett et-tecken kan spänna över två orelaterade tecken. Den enda korrekta arbetsenheten är hela 16-bitars code unit
Renderaren lagrar Unicode-ord som big-endian UTF-16 packade i bytesträngar, hög byte först. Ett et-tecken är 00 26. Ta nu U+0100 (latin versal A med makron, bytes 01 00) följt av U+2603 (snögubben, bytes 26 03). Bytesekvensen är 01 00 26 03, och byte två och tre läser 00 26. En bytesökning efter #0'&' hittar ett et-tecken som inte finns, klistrar bytena för & in i mitten av två tecken och klipper varje efterföljande tecken med en byte
Det är inget exotiskt specialfall. Vart tecken vars låga byte är noll kan leverera första halvan; U+4E00, en av de vanligaste CJK-ideograferna, kvalificerar. Vinkelparenteserna har samma exponering: 00 3C och 00 3E dyker upp närhelst ett sådant tecken följs av ett från U+3C00 till U+3EFF i CJK Extension A. Fixen i EscapeHTMLWord packar upp bytena till en WideString, eskapar tecken för tecken och packar resultatet igen. Avkodarsidan var redan säker eftersom den bara testar mönster på jämna code unit-gränser
Samma regel gäller din egen kod. Håller du UTF-16-text som TBytes, till exempel efter TEncoding.BigEndianUnicode.GetBytes, sök inte i den efter bytemönster. Konvertera tillbaka till en sträng och arbeta på tecken
Markdown-kodblock och datasetexporter: eskapa et-tecknet först
Sedan v3.539.47 eskapar båda HTML-producenterna inuti PDFlibPas, Markdown-konverteraren och datasetexportören, et-tecknet före vinkelparenteserna, så att den enda avkodningen i renderaren återställer exakt originaltexten
I MarkdownToHTML mappar inline-kodspann och fenced eller indenterade kodblock nu & till &, < till < och > till >, medan mellanslag blir och en tab fyra av dem för att bevara indenteringen. Vanlig Markdown-prosa eskapar bara vinkelparenteserna, så rå HTML i prosa inte kan injicera taggar medan en författare fortfarande kan skriva & med flit, i princip som Markdown-författare förväntar sig. DrawMarkdownText och DrawMarkdownTextBox använder samma konvertering, så kod visas i PDF:en exakt som inskriven:
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
// Granska HTML:en: i kod blir '&' till '&' och '<' till '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // ursprung övre vänster, Y växer nedåt
Lib.SetMeasurementUnits(0); // punkter
// Sidan visar koden exakt som inskriven, entitetsskrivningar inkluderade
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
Datasetexportören är det instruktiva fallet. Före v3.539.47 eskapade den bara vinkelparenteser, och med flit: renderaren avkodade inte &, så att eskapa et-tecknet skulle ha tryckt & i varje cell som innehöll ett. Workarounden var korrekt för den gamla renderaren och fel i allmänhet, för ett cellvärde som råkade innehålla < avkodades till <. Med renderaren fixad eskapar exportören & först, och ett värde som R&D < & landar orört i PDF:en. Bygger du rapporter på det sättet täcker genomgången om att exportera en TDataSet till en PDF-rapport i Delphi resten av exportören
Varför et-tecknet måste gå först är värt att stava ut en gång. Eskapa < först och du får <; eskapa & senare och det blir &lt;, som en korrekt enkel avkodning visar som < i stället för <. En sekventiell ersättningskedja är bara korrekt när escape-tecknet självt hanteras före allt som introducerar det
Hur ska du eskapa opålitlig text för DrawHTMLTextBox?
För PDFlibPas HTML-rendering, eskapa opålitligt textinnehåll genom att byta ut &, sedan <, sedan >, exakt en gång, och håll opålitliga data helt borta från attributvärden
uses
System.SysUtils, PDFlibrary;
// Eskapar opålitlig text för PDFlibPas HTML-textinnehåll.
// '&' måste bytas först, annars skulle et-tecknet inuti
// en redan framställd '<' eskapas en andra gång
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 visas en kommentar som Try <a href="https://example.com">this</a> & <b> på sidan tecken för tecken. Före v3.539.47 kunde samma eskapade indata producera en levande länkannotering, vilket är det som gör ett visningsfel till ett säkerhetsproblem: en ärendekommentar ska aldrig kunna plantera en klickbar URL i ett dokument din personal litar på
Notera vad funktionen inte eskapar. Allmänna HTML-eskapare konverterar också " till " och ' till ', vilket är rätt för en webbläsare. PDFlibPas textavkodning känner bara igen de fyra entiteter som listats tidigare, så de två skulle tryckas bokstavligen som " och '. Citationstecken är ofarliga i textinnehåll; de spelar bara roll inuti attributvärden, och renderaren avkodar inte entiteter i attribut alls. Den säkra designen är därför inte en bättre eskapare utan en regel: opålitliga data går aldrig in i href, src eller style. Måste ett länkmål verkligen komma från användardata, validera det själv mot en tillåtlista av scheman och tecken och avvisa allt som innehåller citationstecken eller vinkelparenteser
Två uppgraderingsanteckningar följer direkt av fixen:
- Om din kod slutade eskapa
&för att äldre versioner tryckte&bokstavligen, lägg tillbaka det. Utan det visar användartext som innehåller<nu som<, fortfarande ofarlig text men inte längre vad användaren skrev - Eskapa inte två gånger. Text som passerar två eskapare renderar
<som den synliga skrivningen<, så hitta den enda gräns där dina data går in i HTML:en och eskapa bara där
Paginera med LeftOverText utan att förstöra eskapningar
DrawHTMLTextBox returnerar den HTML som inte fick plats, vanligen kallad LeftOverText, och sedan v3.539.47 bevarar den resten bokstavliga entitetsskrivningar och eskapade vinkelparenteser när du skickar den till nästa ruta. Regeln för anropare är enkel: skicka tillbaka den oförändrad
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // storleksatt för en A4-sida 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 är redan eskapad motor-HTML: eskapa eller aveska 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;
Behandla resten som opak. Det är motorns normaliserade HTML, med stilar redan lösta, så kör den inte genom din egen eskapare, avkoda den inte, och klistra inte in användartext i den. Sidtaket är billig försäkring: kan något element aldrig få plats i rutan har en otaklad loop inget naturligt utträde
Markdown har sin egen fortsättning. DrawMarkdownTextBox returnerar en token som börjar med en intern markör så att nästa anrop kan hoppa över konverteringen; lämna tillbaka den till DrawMarkdownTextBox eller DrawMarkdownText, inte till HTML-ingångarna, som skulle rita markören som text
Den allmänna lärdomen: avkoda en gång, koda om vid varje gräns
Varje pipeline som tolkar text, serialiserar resultatet tillbaka till samma syntax och tolkar den igen måste behandla avkodning som en operation som händer på exakt ett ställe, och måste koda om vid varje gräns där avkodad text blir syntax igen. Templatesmotorer, HTML-sanitizerare och Markdown-till-HTML-till-PDF-kedjor delar den formen och misslyckas på samma sätt när en serialiserare glömmer att den framställer markup
Symtomen är förutsägbara när du väl känner formen. För lite omkodning gör data till syntax, vilket är injektionsriktningen. För mycket kodning, eller en avkodare som körs två gånger, visar entitetsskrivningar för läsaren eller äter dem, vilket är visningsriktningen. Att fixa en riktning ensam bryter vanligen den andra, vilket är varför PDFlibPas-fixen var tvungen att lägga till &-avkodning, ordna om den, ta bort den sena avkodningen och lägga till omeskapning i samma release. Samma princip löper åt andra hållet när PDF-innehåll exporteras som strukturerad text, som i PDF till Markdown- och DOCX-semantisk export från Delphi, där vart bokstavligt tecken måste eskapas för målsyntaxen exakt en gång
Snabbreferenschecklista
- Uppgradera till PDFlibPas v3.539.47 eller senare om du renderar HTML eller Markdown som innehåller användardata
- Eskapa textinnehåll med
&först, sedan<och>; konvertera inte citationstecken för PDFlibPas-text - Eskapa en gång, på den enda punkt där data går in i HTML-strängen
- Håll opålitliga värden borta från
href,srcochstyle, eller validera dem mot en tillåtlista - Räkna med att bara
<,>,&och avkodas i text; andra entiteter stannar bokstavliga - Skicka
LeftOverTexttillbaka tillDrawHTMLTextBoxoförändrad och taklägg sidloopen - Skicka Markdown-fortsättningstokens bara till
DrawMarkdownTextBoxellerDrawMarkdownText - Sök aldrig i UTF-16-bytebuffertar efter bytemönster; arbeta på hela code units
HTML- och Markdown-rendering, datasetrapportexport och resten av layoutmotorn medföljer den nativa Pascal-källkoden i PDF Library for Delphi, för Delphi och Free Pascal. Se PDFlibPas-produktsidan för utgåvor, plattformsstöd och en testnedladdning