Техническа статия

PDFlibPas HTML към PDF: двойно декодирани entities

Версии на PDF Library for Delphi (PDFlibPas) преди v3.539.47 можеха да декодират екраниран текст два пъти, когато рисуват HTML или Markdown в PDF. DrawHTMLText и DrawHTMLTextBox парсват HTML-а, нормализират го обратно в HTML и после го парсват отново, така че текст, записан като <unsafe>, стигаше до втория проход като истински таг. От v3.539.47 всяко entity се декодира точно веднъж, а текстът се екранира наново навсякъде, където се превръща обратно в HTML

Сценарият, който изважда това наяве, е съвсем обикновен. Help desk експортира тикети в PDF и коментарът на клиента отива в HTML шаблон. Разработчикът е направил правилното и е екранирал коментара, така че <b> става &lt;b&gt;. Вътре в рендерера това екраниране тихо се отменяше: коментарът излизаше удебелен, непознато име на таг просто изчезваше от страницата, а екраниран anchor се превръщаше в кликваема link annotation. Без exception, без предупреждение — съвсем валиден PDF, който казва нещо различно от данните

Защо екранираният текст става истински таг в PDF-а?

Екранираният текст ставаше markup, защото рендерерът прави два прохода на парсване, а стъпката за нормализация между тях записваше вече декодирания текст обратно в HTML, без да го екранира пак. Всяко декодиране, извършено от първия проход, после беше на разположение на втория като действащ синтаксис

Двата прохода съществуват с основателна причина. Първият парс строи списък от елементи тагове и думи. NormalizeParsedHTML после разрешава каскадата от stylesheet: съпоставя правилата от <style> блокове с всеки таг, слива ги с inline style атрибути, записва резултата върху тага и сериализира целия списък от елементи обратно в HTML string. Layout проходът парсва този нормализиран string. Това е същият механизъм, който задвижва flexbox, CSS grid и layout на бележки под линия в HTML рендирането на PDFlibPas

Дефектът беше в това как се сериализираха думите. Таговете се записваха обратно от оригиналната си source форма, а думите — в декодираната си форма. Дума, която първият парс беше декодирал от &lt;unsafe&gt; на <unsafe>, отиваше в нормализирания HTML като сурови ъглови скоби, а вторият парс я четеше като елемент. Около този основен бъг имаше три по-малки теча, сочащи в същата посока:

  • &amp; не беше в поддържаното множество от entities, така че R&amp;D се печатеше буквално и нямаше как да запишете буквално изписване на entity като &lt; като текст
  • Рисуващата стъпка заменяше &nbsp; втори път, след като парсването вече беше свършило, така че буквално изписване на entity можеше да изчезне още в самия край
  • Екранирането в Markdown код прескачаше амперсанда, а dataset експортерът екранираше само ъгловите скоби, така че изписвания на entity вътре в код или стойности на клетки се декодираха като markup
PDFlibPas HTML pipeline за DrawHTMLText, при който първият парс строи елементите, NormalizeParsedHTML ги сериализира обратно в HTML, а вторият парс подрежда резултата; преди v3.539.47 декодираните думи се записваха обратно неекранирани и ставаха действащи тагове, а от v3.539.47 всяка дума се екранира наново на границата
Декодирани думи влизат отново в парсера като синтаксис, когато нормализаторът забрави, че произвежда markup — така екраниран коментар ставаше удебелен или му поникваше линк
Вход, стигащ рендерераПреди v3.539.47От v3.539.47
&lt;unsafe&gt;Парсва се като таг, текстът никога не стига страницата<unsafe> рисуван като текст
&lt;b&gt;x&lt;/b&gt;x рисуван удебелен<b>x</b> рисуван като текст
R&amp;DR&amp;D печатан буквалноR&D
&amp;lt;&amp;lt; печатан буквално&lt;
Markdown code span, съдържащ &nbsp;Ставаше неразделителен интервал&nbsp; рисуван като текст
Стойност в dataset клетка &lt;<&lt;

Как v3.539.47 прави декодирането на HTML entities еднопроходно

PDFlibPas v3.539.47 прави декодирането на entities еднопроходно с три съгласувани промени: парсерът декодира &amp; последен, рисуващата стъпка вече не декодира нищо, а всяко място, което превръща декодираните думи обратно в HTML, първо ги екранира пак

Поддържаното множество от entities за текстово съдържание сега е &lt;, &gt;, &amp; и &nbsp;. Всичко останало, включително числови референции като &#65; и именувани entities като &quot;, остава буквален текст. Тази граница има значение за това как екранирате собствения си вход, както е показано по-долу

Редът вътре в декодера е първата поправка. Ако &amp; се декодираше първи, входът &amp;lt; щеше да стане &lt;, а следващата замяна щеше да го превърне в < — двойно декодиране, случващо се вътре в един проход. ANSI пътят за думи затова заменя първо &lt;, &gt; и &nbsp;, а &amp; последен, така че амперсандът, който той произвежда, никога не се разглежда пак. UTF-16 пътят за думи е едно единствено сканиране отляво надясно на двубайтови стъпки, което презаписва всяко съвпадение на място и минава напред — структурно същата гаранция

Ред на декодиране в PDFlibPas за верижно entity като &amp;lt;: декодирането на амперсанда първо го свива до истинска ъглова скоба вътре в един проход, а декодирането на lt, gt и nbsp преди амперсанда пази буквалното изписване непокътнато, така че текстът стига страницата декодиран точно веднъж
Амперсандът е escape знакът, затова трябва да се декодира последен и да се екранира пръв, иначе един проход може да декодира два пъти

Втората поправка премахва късната замяна на &nbsp; от рисуващата стъпка. Декодирането е работа на парсера и на никой друг, така че дума, стигнала до разбивача на редове, е финален текст

Третата поправка е правилото за границата. NormalizeParsedHTML вече екранира &, < и > във всяка декодирана дума, преди да я добави към нормализирания HTML. Вторият парс я декодира обратно до точно същия текст, така че нетният ефект през целия pipeline е едно декодиране. Продължителният string следва същото правило: думите, които не се събраха в кутията, се екранират, преди да се добавят към LeftOverText, а остатъкът от остатъка се копира от нормализирания HTML, който вече е в екранирана форма. Цикълът, който събра тези останали думи, също е ограничен по броя думи сега, докато старият repeat цикъл можеше да стъпи зад последната дума

Защо UTF-16BE екранирането не може да ползва замяна на байтово ниво?

UTF-16BE екранирането не може да ползва замяна на байтово ниво, защото двубайтовият шаблон на амперсанд може да се разтегне върху два съвсем различни знака. Единствената коректна единица за работа е целият 16-битов code unit

Рендерерът пази Unicode думите като big-endian UTF-16, пакетиран в байтови string-ове, високият байт първи. Амперсанд е 00 26. Вземете сега U+0100 (латинска главна A с макрон, байтове 01 00), следван от U+2603 (снежният човечец, байтове 26 03). Байтовата последователност е 01 00 26 03, а байтове два и три четат 00 26. Байтово търсене на #0'&' намира амперсанд, който не съществува, вмъква байтовете на &amp; в средата на два знака и отрязва по един байт от всеки следващ знак

Рискове при UTF-16BE екраниране в PDFlibPas, където байтовете 01 00 26 03 за U+0100 и U+2603 съдържат шаблона 00 26 през два знака, така че байтово търсене на амперсанда вмъква entity в средата на code point; сканирането по code units тества само четни отмествания
Байтово търсене намира амперсанд, който никой знак никога не е съдържал; работете върху цели code units, никога върху сурови UTF-16 байтови буфери

Това не е екзотичен ъглов случай. Всеки знак, чийто нисък байт е нула, може да даде първата половина; U+4E00, една от най-честите CJK йероглифи, отговаря на условието. Ъгловите скоби са изложени по същия начин: 00 3C и 00 3E се появяват всеки път, когато такъв знак е последван от знак от U+3C00 до U+3EFF в CJK Extension A. Поправката в EscapeHTMLWord разопакова байтовете в WideString, екранира знак по знак и пакува резултата пак. Страната на декодера вече беше безопасна, защото тества шаблоните само на четни code unit граници

Същото правило важи и за вашия код. Ако някога държите UTF-16 текст като TBytes, например след TEncoding.BigEndianUnicode.GetBytes, не го търсете за байтови шаблони. Конвертирайте обратно към string и работете върху знаци

Markdown кодови блокове и dataset експорти: екранирайте амперсанда първи

От v3.539.47 и двата HTML производителя вътре в PDFlibPas — Markdown конверторът и dataset експортерът — екранират амперсанда преди ъгловите скоби, така че единственото декодиране в рендерера възстановява точно оригиналния текст

В MarkdownToHTML inline code span-овете и fenced или отместените кодови блокове вече мапват & към &amp;, < към &lt; и > към &gt;, а интервалите стават &nbsp; и табът става четири от тях, за да пази отместването. Обикновеният Markdown текст екранира само ъгловите скоби, така че суров HTML в текста не може да инжектира тагове, докато автор пак може да напише &amp; нарочно, точно както Markdown авторите очакват. DrawMarkdownText и DrawMarkdownTextBox ползват същата конверсия, така че кодът се появява в PDF-а точно както е набран:

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
    // Огледайте HTML-а: в код '&' става '&amp;' и '<' става '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // начало в горния ляв ъгъл, Y расте надолу
    Lib.SetMeasurementUnits(0);  // пунктове
    // Страницата показва кода точно както е набран, с изписванията на entities
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Dataset експортерът е поучителният случай. Преди v3.539.47 той екранираше само ъгловите скоби, и то нарочно: рендерерът не декодираше &amp;, така че екранирането на амперсанда щеше да отпечата &amp; във всяка клетка, съдържаща такъв. Заобиколното решение беше коректно за стария рендерер и грешно по принцип, защото клетъчна стойност, случайно съдържаща &lt;, се декодираше в <. С оправения рендерер експортерът екранира & първи, а стойност като R&D &lt; &amp; &nbsp; отива в PDF-а дословно. Ако строите отчети по този начин, ръководството за експортиране на TDataSet към PDF отчет в Delphi покрива останалата част от експортера

Защо амперсандът трябва да е първи си заслужава да се изясни веднъж. Екранирайте < първи и получавате &lt;; екранирайте & втори и това става &amp;lt;, което коректно единично декодиране показва като &lt; вместо <. Последователна верига от замени е коректна само когато самият escape знак се обработи преди всичко, което го въвежда

Как трябва да екранирате недоверен текст за DrawHTMLTextBox?

За HTML рендирането на PDFlibPas екранирайте недовереното текстово съдържание, като замените &, после <, после >, точно веднъж, и дръжте недоверените данни изцяло извън стойностите на атрибути

uses
  System.SysUtils, PDFlibrary;

// Екранира недоверен текст за текстовото съдържание на PDFlibPas HTML.
// '&' трябва да се замени първи, иначе амперсандът вътре в
// вече произведен '&lt;' щеше да се екранира втори път
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;

На v3.539.47 коментар като Try <a href="https://example.com">this</a> & &lt;b&gt; се появява на страницата знак по знак. Преди v3.539.47 същият екраниран вход можеше да породи действаща link annotation — а това е частта, която превръща дефект в показването в проблем със сигурността: коментар в тикет не бива никога да може да засади кликваем URL в документ, на който персоналът ви разчита

Обърнете внимание какво функцията не екранира. Универсалните HTML екранатори преобразуват и " в &quot; и ' в &#39;, което е правилно за браузър. Текстовото декодиране на PDFlibPas разпознава само четирите изброени по-горе entities, така че тези две щяха да се печатат буквално като &quot; и &#39;. Кавичките са безобидни в текстово съдържание; имат значение само вътре в стойности на атрибути, а рендерерът изобщо не декодира entities в атрибути. Безопасният дизайн затова не е по-добър екранатор, а правило: недоверени данни никога не отиват в href, src или style. Ако целта на линк наистина трябва да идва от потребителски данни, валидирайте я сами срещу allow-list от схеми и знаци и отхвърлете всичко, съдържащо кавички или ъглови скоби

Две бележки за upgrade следват направо от поправката:

  • Ако кодът ви е престанал да екранира &, защото по-старите версии печатат &amp; буквално, върнете го. Без него текст от потребител, съдържащ &lt;, вече се показва като < — пак безобиден текст, но вече не това, което потребителят е набрал
  • Не екранирайте два пъти. Текст, минал през два екранатора, изобразява < като видимото изписване &lt;, затова намерете единствената граница, на която данните ви влизат в HTML, и екранирайте само там

Пагинация с LeftOverText, без да чупите екранирането

DrawHTMLTextBox връща HTML-а, който не се събрал, обикновено наричан LeftOverText, а от v3.539.47 този остатък пази буквалните изписвания на entities и екранираните ъглови скоби, когато го подадете на следващата кутия. Правилото за извикващите е просто: подавайте го обратно непроменен

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // оразмерено за A4 страница в пунктове
  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 вече е екраниран engine HTML: никога не го екранирайте или декодирайте
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Третирайте остатъка като opaque. Това е нормализираният HTML на engine-а, със стиловете вече разрешени, затова не го пускайте през собствения си екранатор, не го декодирайте и не вмъквайте потребителски текст в него. Таванът на страниците е евтина застраховка: ако някой елемент никога не може да се събере в кутията, цикъл без таван няма естествен изход

Markdown има собствен continuation. DrawMarkdownTextBox връща token, който започва с вътрешен маркер, така че следващото извикване да може да прескача конверсията; подавайте го обратно на DrawMarkdownTextBox или DrawMarkdownText, не на HTML входните точки, които щяха да нарисуват маркера като текст

Общият урок: декодирай веднъж, прекодирай на всяка граница

Всеки pipeline, който парсва текст, сериализира резултата обратно в същия синтаксис и го парсва пак, трябва да третира декодирането като операция, случваща се в точно едно място, и трябва да прекодира на всяка граница, където декодираният текст отново става синтаксис. Template engines, HTML санитайзери и вериги Markdown-към-HTML-към-PDF споделят тази форма и се чупят по същия начин, когато един сериализатор забрави, че произвежда markup

Симптомите са предвидими, щом познавате формата. Твърде малко прекодиране превръща данните в синтаксис — това е посоката на инжектиране. Твърде много кодиране, или декодер, който минава два пъти, показва на читателя изписвания на entities или ги изяжда — това е посоката на показването. Поправката само на една посока обикновено чупи другата, затова поправката в PDFlibPas трябваше да добави декодиране на &amp;, да подреди реда му, да премахне късното декодиране и да добави повторно екраниране в същата версия. Същият принцип върви и в обратната посока, когато PDF съдържание се експортира като структуриран текст, както в PDF към Markdown и DOCX семантичен експорт от Delphi, където всеки буквален знак трябва да се екранира за целевия синтаксис точно веднъж

Кратък справочен списък

  • Надградете към PDFlibPas v3.539.47 или по-нова, ако рендирате HTML или Markdown, съдържащ потребителски данни
  • Екранирайте текстовото съдържание с & първи, после < и >; не преобразувайте кавички за текст на PDFlibPas
  • Екранирайте веднъж, в единствената точка, където данните влизат в HTML string-а
  • Дръжте недоверените стойности извън href, src и style, или ги валидирайте срещу allow-list
  • Очаквайте само &lt;, &gt;, &amp; и &nbsp; да се декодират в текста; другите entities остават буквални
  • Подавайте LeftOverText обратно на DrawHTMLTextBox непроменен и ограничете цикъла по страници
  • Подавайте Markdown continuation token-и само на DrawMarkdownTextBox или DrawMarkdownText
  • Никога не търсете байтови шаблони в UTF-16 байтови буфери; работете върху цели code units

HTML и Markdown рендиране, dataset отчетен експорт и останалата част от layout engine-а идват в native Pascal изходния код на PDF Library for Delphi, за Delphi и Free Pascal. Вижте продуктовата страница на PDFlibPas за издания, поддръжка на платформи и trial изтегляне