Технічна стаття

PDFlibPas: HTML у PDF без подвійного декодування сутностей

Версії PDF Library for Delphi (PDFlibPas) до v3.539.47 могли декодувати екранований текст двічі під час малювання HTML чи Markdown у PDF. DrawHTMLText і DrawHTMLTextBox розбирають HTML, нормалізують його назад у HTML, а потім розбирають знову, тож текст, записаний як <unsafe>, доходив до другого розбору вже як справжній тег. З v3.539.47 кожна сутність декодується рівно один раз, а текст повторно екранується скрізь, де він знову стає HTML

Сценарій, який це викриває, найзвичайніший. Help desk експортує тікети в PDF, і коментар клієнта потрапляє в HTML-шаблон. Розробник зробив усе правильно і екранував коментар, тож <b> став &lt;b&gt;. Усередині рендерера те екранування тихо скасувалося: коментар вийшов жирним, невідоме ім'я тега просто зникло зі сторінки, а екранований якір перетворився на клікабельну анотацію-посилання. Жодного виключення, жодного попередження, цілком валідний PDF, що каже не те, що кажуть дані

Чому екранований текст стає справжнім тегом у PDF?

Екранований текст ставав розміткою тому, що рендерер виконує два проходи розбору, а крок нормалізації між ними писав уже декодований текст назад у HTML без повторного екранування. Кожне декодування, яке зробив перший прохід, тоді було доступне другому проходу як живий синтаксис

Два проходи існують не просто так. Перший розбудовує список елементів тегів і слів. NormalizeParsedHTML потім розрішує каскад таблиць стилів: зіставляє правила з блоків <style> із кожним тегом, зливає їх з інлайн-атрибутами style, зберігає результат на тезі та серіалізує весь список елементів назад у HTML-рядок. Проход верстки розбирає той нормалізований рядок. Це та сама машинерія, що рухає flexbox, CSS grid і верстку виносок у HTML-рендерингу PDFlibPas

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

  • &amp; не було в підтримуваному наборі сутностей, тож R&amp;D друкувався буквально, і не було способу написати буквальне написання сутності, як-от &lt;, як текст
  • Стадія малювання заміняла &nbsp; вдруге, вже після завершення розбору, тож буквальне написання сутності могло зникнути в самому кінці
  • Екранування коду в Markdown пропускав амперсанд, а експортер датасетів екранував лише кутові дужки, тож написання сутностей у коді чи значеннях клітинок декодувалися як розмітка
Конвеєр HTML у PDFlibPas для DrawHTMLText, де перший розбір будує елементи, NormalizeParsedHTML серіалізує їх назад у HTML, а другий розіб верстає результат; до v3.539.47 декодовані слова писалися назад без екранування і ставали живими тегами, з v3.539.47 кожне слово повторно екранується на межі
Декодовані слова повертаються в парсер як синтаксис, коли нормалізувач забуває, що виробляє розмітку — саме так екранований коментар ставав жирним чи обростав посиланням
Вхід, що доходить до рендерераДо 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;
Code span у Markdown, що містить &nbsp;Стали нерозривним пробілом&nbsp; намальований як текст
Значення клітинки датасету &lt;<&lt;

Як v3.539.47 робить декодування HTML-сутностей однопрохідним

PDFlibPas v3.539.47 робить декодування сутностей однопрохідним трьома скоординованими змінами: парсер декодує &amp; останнім, стадія малювання більше нічого не декодує, і кожне місце, що перетворює декодовані слова назад у HTML, спершу знову їх екранує

Підтримуваний набір сутностей для текстового вмісту тепер — &lt;, &gt;, &amp; і &nbsp;. Усе інше, зокрема числові посилання, як-от &#65;, і іменовані сутності, як-от &quot;, лишається буквальним текстом. Та межа важлива для того, як ви екрануєте власне введення — покажемо нижче

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

Порядок декодера PDFlibPas для ланцюгової сутності на кшталт &amp;lt;: декодування амперсанда першим стискає його в справжню кутову дужку всередині одного проходу, тоді як декодування lt, gt і nbsp перед амперсандом зберігає буквальне написання, тож текст доходить до сторінки, декодований рівно один раз
Амперсанд — це символ екранування, тож декодувати його треба останнім, а екранувати першим, інакше один прохід може декодувати двічі

Друге виправлення прибирає пізню заміну &nbsp; зі стадії малювання. Декодування належить парсеру і нікому більше, тож слово, що доходить до розбивача рядків, — це остаточний текст

Третє виправлення — правило межі. NormalizeParsedHTML тепер екранує &, < і > у кожному декодованому слові перед додаванням його до нормалізованого HTML. Другий розбір декодує це назад у точно той самий текст, тож чистий ефект по всьому конвеєру — одне декодування. Рядок продовження дотримується того ж правила: слова, що не вмістилися в бокс, екрануються перед додаванням до LeftOverText, а решта залишку копіюється з нормалізованого HTML, який уже в екранованій формі. Цикл, що збирає ті слова-залишки, тепер також обмежений кількістю слів, тоді як старий repeat-цикл міг ступити за останнє слово

Чому екранування UTF-16BE не може використовувати побайтову заміну?

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

Рендерер зберігає Unicode-слова як big-endian UTF-16, запакований у байтові рядки, старшим байтом уперед. Амперсанд — це 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 поперек двох символів, тож побайтовий пошук амперсанда вшиває сутність у середину code point; сканування code unit перевіряє лише парні зміщення
Побайтовий пошук знаходить амперсанд, якого жоден символ ніколи не містив; працюйте з цілими code unit, а не з сирими байтовими буферами 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, не шукайте в ньому байтові шаблони. Перетворіть назад на рядок і працюйте з символами

Кодові блоки Markdown і експорт датасетів: спершу екрануйте амперсанд

З v3.539.47 обидва виробники HTML усередині PDFlibPas — конвертер Markdown і експортер датасетів — екранують амперсанд перед кутовими дужками, тож одне декодування в рендерері відновлює точно оригінальний текст

У MarkdownToHTML інлайн 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);  // пункти
    // Сторінка показує код точно як набрано, з написаннями сутностей
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Експортер датасетів — найповчальніший випадок. До v3.539.47 він екранував лише кутові дужки, і навмисно: рендерер не декодував &amp;, тож екранування амперсанда надрукувало б &amp; у кожній клітинці, де він є. Обхідний шлях був правильним для старого рендерера і неправильним загалом, бо значення клітинки, що випадково містило &lt;, декодувалося у <. З полагодженим рендерером експортер екранує & першим, і значення на кшталт R&D &lt; &amp; &nbsp; потрапляє в PDF дослівно. Якщо ви будуєте звіти так, огляд експорту TDataSet у PDF-звіт у Delphi покриває решту експортера

Чому амперсанд має йти першим, варто промовити один раз. Екрануйте < першим — отримаєте &lt;; екрануйте & другим — і те стане &amp;lt;, що коректне одинарне декодування покаже як &lt; замість <. Послідовний ланцюг замін коректний, лише коли сам символ екранування обробляється перед усім, що його вводить

Як екранувати недовірений текст для DrawHTMLTextBox?

Для HTML-рендерингу PDFlibPas екрануйте недовірений текстовий вміст, замінюючи &, потім <, потім >, рівно один раз, і тримайте недовірені дані повністю поза значеннями атрибутів

uses
  System.SysUtils, PDFlibrary;

// Екранує недовірений текст для текстового вмісту HTML у PDFlibPas.
// '&' треба замінювати першим, інакше амперсанд усередині
// уже створеного '&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 той самий екранований вхід міг породити живу анотацію-посилання, і саме це перетворює зсув відображення на проблему безпеки: коментар тікета не повинен мати змоги посадити клікабельний URL у документ, якому ваш персонал довіряє

Зверніть увагу, чого функція не екранує. Універсальні HTML-екранувальники перетворюють ще й " на &quot; та ' на &#39;, що правильно для браузера. Текстове декодування PDFlibPas розпізнає лише чотири сутності, перелічені вище, тож ці дві надрукувалися б буквально як &quot; і &#39;. Лапки в текстовому вмісті нешкідливі; вони важать лише всередині значень атрибутів, а рендерер узагалі не декодує сутності в атрибутах. Тож безпечна конструкція — не кращий екранувальник, а правило: недовірені дані ніколи не потрапляють у href, src чи style. Якщо ціль посилання справді має прийти з даних користувача, валідуйте її самі проти allow-list схем і символів та відхиляйте все, що містить лапки чи кутові дужки

Дві нотатки про оновлення випливають із виправлення безпосередньо:

  • Якщо ваш код перестав екранувати &, бо старіші версії друкували &amp; буквально, поверніть це. Без нього текст користувача, що містить &lt;, тепер показується як < — досі нешкідливий текст, але вже не те, що набрав користувач
  • Не екрануйте двічі. Текст, що проходить через два екранувальники, рендерить < як видиме написання &lt;, тож знайдіть ту єдину межу, де ваші дані входять у HTML, і екрануйте лише там

Пагінація з LeftOverText без ламання екранування

DrawHTMLTextBox повертає HTML, що не вмістився, — його зазвичай звуть LeftOverText, — і з v3.539.47 той залишок зберігає буквальні написання сутностей та екрановані кутові дужки, коли ви передаєте його в наступний бокс. Правило для викликачів просте: передавайте назад без змін

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 — уже екранований HTML рушія: ніколи не екрануйте і не розекрановуйте його
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Ставтеся до залишку як до непрозорого. Це нормалізований HTML рушія, зі стилями вже розв'язаними, тож не ганяйте його крізь власний екранувальник, не декодуйте його і не вшивайте в нього текст користувача. Обмеження сторінок — дешева страховка: якщо якийсь елемент ніколи не вміститься в бокс, цикл без обмеження не має природного виходу

У Markdown своє продовження. DrawMarkdownTextBox повертає токен, що починається з внутрішнього маркера, щоб наступний виклик міг пропустити конверсію; віддавайте його назад у DrawMarkdownTextBox чи DrawMarkdownText, а не в HTML-входи, які намалюють маркер як текст

Загальний урок: декодуй один раз, перекодовуй на кожній межі

Будь-який конвеєр, що розбирає текст, серіалізує результат назад у той самий синтаксис і розбирає знову, мусить ставитися до декодування як до операції, яка відбувається рівно в одному місці, і мусить перекодовувати на кожній межі, де декодований текст знову стає синтаксисом. Шаблонні рушії, HTML-санитайзери та ланцюги Markdown-to-HTML-to-PDF мають цю саму форму і падають так само, коли серіалізатор забуває, що виробляє розмітку

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

Шпаргалка-чекліст

  • Оновіться до PDFlibPas v3.539.47 чи новішої, якщо рендерите HTML чи Markdown із даними користувача
  • Екрануйте текстовий вміст спершу &, потім < і >; не перетворюйте лапки для тексту PDFlibPas
  • Екрануйте один раз, у єдиній точці, де дані входять у HTML-рядок
  • Тримайте недовірені значення поза href, src і style, або валідуйте їх проти allow-list
  • Очікуйте, що в тексті декодуються лише &lt;, &gt;, &amp; і &nbsp;; інші сутності лишаються буквальними
  • Передавайте LeftOverText назад у DrawHTMLTextBox без змін і обмежуйте цикл сторінок
  • Передавайте токени продовження Markdown лише в DrawMarkdownTextBox чи DrawMarkdownText
  • Ніколи не шукайте в байтових буферах UTF-16 байтові шаблони; працюйте з цілими code unit

HTML- і Markdown-рендеринг, експорт датасетних звітів та решта рушія верстки постачаються в нативних Pascal-джерелах PDF Library for Delphi, для Delphi та Free Pascal. Видання, підтримку платформ і пробне завантаження дивіться на сторінці продукту PDFlibPas