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

PDFlibPas HTML в PDF: как исправлено двойное декодирование

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

Сценарий, в котором это вылезает, — самый обычный. Хелпдеск экспортирует тикеты в PDF, и комментарий клиента идёт в HTML-шаблон. Разработчик сделал как надо и экранировал комментарий, так что <b> превратился в &lt;b&gt;. Внутри рендерера это экранирование тихо отменялось: комментарий выходил жирным, неизвестное имя тега просто исчезало со страницы, а экранированный якорь становился кликабельной link annotation. Ни исключения, ни предупреждения — совершенно валидный PDF, который говорит не то, что в данных

Почему экранированный текст становится настоящим тегом в PDF?

Экранированный текст становился разметкой потому, что рендерер делает два прохода разбора, а шаг нормализации между ними записывал уже декодированный текст обратно в HTML, не экранируя его снова. Каждая декодировка, выполненная первым проходом, доставалась второму проходу как живой синтаксис

Два прохода существуют не зря. Первый разбор строит список элементов-тегов и слов. Затем NormalizeParsedHTML разрешает каскад стилей: сопоставляет правила из блоков <style> с каждым тегом, вливает их с inline-атрибутами 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;
Кодовый спан 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-битный кодовый юнит

Рендерер хранит 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 поперёк двух символов, поэтому побайтовый поиск амперсанда вшивает сущность в середину кодовой точки; сканирование кодовых юнитов проверяет только чётные смещения
Побайтовый поиск находит амперсанд, которого ни один символ не содержал; работайте с целыми кодовыми юнитами, а не с сырыми байтовыми буферами UTF-16

Это не экзотический уголок. Любой символ с нулевым младшим байтом годится на первую половину; U+4E00, один из самых частых CJK-иероглифов, вполне подходит. У угловых скобок та же уязвимость: 00 3C и 00 3E всплывают всякий раз, когда такой символ стоит перед символом из диапазона U+3C00–U+3EFF в CJK Extension A. Исправление в EscapeHTMLWord распаковывает байты в WideString, экранирует посимвольно и упаковывает результат обратно. Сторона декодера была в безопасности изначально: она проверяет паттерны только на чётных границах кодовых юнитов

То же правило касается и вашего кода. Если вы держите UTF-16-текст как TBytes, скажем после TEncoding.BigEndianUnicode.GetBytes, не ищите в нём байтовые паттерны. Сконвертируйте обратно в строку и работайте с символами

Кодовые блоки Markdown и экспорт датасетов: сначала экранируем амперсанд

Начиная с v3.539.47 оба HTML-продюсера внутри PDFlibPas — конвертер Markdown и экспортёр датасетов — экранируют амперсанд до угловых скобок, так что единственная декодировка в рендерере восстанавливает ровно исходный текст

В MarkdownToHTML инлайн-кодовые спаны и блочные кодовые блоки (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 тот же экранированный ввод мог породить живую link annotation — и именно это превращает глитч отображения в проблему безопасности: комментарий тикета не должен уметь сажать кликабельный URL в документ, которому ваш персонал доверяет

Обратите внимание, чего функция не экранирует. Универсальные HTML-экранщики вдобавок конвертируют " в &quot; и ' в &#39; — для браузера это правильно. Текстовый декодер PDFlibPas узнаёт только четыре сущности, перечисленные выше, так что эти две напечатались бы буквально как &quot; и &#39;. Кавычки в текстовом содержимом безвредны; они важны лишь внутри значений атрибутов, а сущности в атрибутах рендерер не декодирует вовсе. Безопасный дизайн поэтому — не экранщик получше, а правило: недоверенные данные никогда не попадают в href, src или style. Если цель ссылки действительно должна приходить из пользовательских данных, валидируйте её сами по белому списку схем и символов и отвергайте всё, что содержит кавычки или угловые скобки

Из правки напрямую следуют два замечания к обновлению:

  • Если ваш код перестал экранировать & из-за того, что старые версии печатали &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→HTML→PDF имеют ту же форму и падают так же, когда сериализатор забывает, что производит разметку

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

Краткая памятка

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

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