技术文章

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;。而在渲染器内部,这次转义被悄悄撤销:评论以粗体出现,一个不认识的标签名干脆从页面上消失,一个转义过的锚点变成了可点击的链接注解。没有异常,没有警告,只有一份完全合法、说的却不是数据的 PDF

转义文本为什么会在 PDF 里变成真标签?

转义文本变成标记,是因为渲染器跑两轮解析,夹在中间的规范化步骤把已解码的文本写回 HTML 时没有再次转义。第一轮解析做过的每一次解码,都成了第二轮解析眼里的活语法

两轮解析的存在是有道理的。第一轮解析构建标签与单词元素列表。NormalizeParsedHTML 随后解决样式表级联:把 <style> 块里的规则逐个匹配到标签上,与内联的 style 属性合并,把结果存到标签上,再把整个元素列表序列化回一个 HTML 字符串。排版那一轮解析的就是这份规范化后的字符串。驱动 PDFlibPas HTML 渲染的 flexbox、CSS grid 与脚注排版的正是同一套机器

缺陷出在单词的序列化方式上。标签按原始源形式写回,单词却按解码后的形式写回。第一轮解析从 &lt;unsafe&gt; 解码成 <unsafe> 的单词,落进规范化 HTML 时成了裸角括号,第二轮解析就把它当成一个元素。围绕这个核心 bug,还有三个方向一致的小漏洞:

  • &amp; 不在受支持的实体集合里,所以 R&amp;D 按字面打印,也没法把 &lt; 这类实体写法当成正文写出来
  • 绘制阶段在解析早已结束后又替换了一次 &nbsp;,所以字面实体写法还是可能在最后一刻消失
  • Markdown 代码转义跳过了 & 符号,数据集导出器只转义角括号,于是代码或单元格值里的实体写法被当成标记解码
PDFlibPas 的 DrawHTMLText HTML 流水线:第一轮解析构建元素,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;
含 &nbsp; 的 Markdown 代码 span变成了不换行空格以文本形式画出 &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 单词存成按大端 UTF-16 打包进字节串的形式,高字节在前。& 符号是 00 26。现在取 U+0100(带长音符的拉丁大写 A,字节 01 00)后面跟 U+2603(雪人,字节 26 03)。字节序列是 01 00 26 03,第二、第三个字节读出来正是 00 26。按字节搜索 #0'&' 会找到一个并不存在的 & 符号,把 &amp; 的字节拼进两个字符中间,再让后面每个字符错位一个字节

PDFlibPas 的 UTF-16BE 转义隐患:U+0100 与 U+2603 的字节 01 00 26 03 横跨两个字符含有 00 26 模式,字节级搜索 & 符号会把实体拼进码点中间;码元扫描只测偶数偏移
字节搜索找到的 & 没有任何一个字符真正含有它;要在完整码元上工作,别碰原始 UTF-16 字节缓冲

这并非刁钻的角落案例。低字节为零的任何字符都能凑出前一半;U+4E00——最常用的汉字之一——就符合条件。角括号有同样的暴露面:只要这类字符后面跟着 CJK 扩展 A 区 U+3C00 到 U+3EFF 之间的字符,就会出现 00 3C 和 00 3E。EscapeHTMLWord 里的修复把字节解包成 WideString,逐字符转义后再打包回去。解码侧本来就是安全的,因为它只在偶数码元边界上测模式

同样的规则适用于你自己的代码。如果你把 UTF-16 文本存成 TBytes——比如经过 TEncoding.BigEndianUnicode.GetBytes 之后——不要在它里面搜字节模式。转回字符串,在字符上工作

Markdown 代码块与数据集导出:& 符号最先转义

从 v3.539.47 起,PDFlibPas 内部的两个 HTML 生产者——Markdown 转换器和数据集导出器——都先把 & 符号转义再转义角括号,于是渲染器里的那一次解码还原出的正是原始文本

在 MarkdownToHTML 里,行内代码 span 与围栏或缩进代码块现在把 & 映射为 &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。如果你就这样做报表,在 Delphi 里把 TDataSet 导出成 PDF 报表一文覆盖了导出器的其余部分

& 符号为什么必须排第一,值得讲一遍。先转义 < 得到 &lt;;再转义 &,它就变成 &amp;lt;,而正确的单次解码会把它显示成 &lt; 而不是 <。顺序替换链只有在一个前提下才正确:转义字符本身先于任何会引入它的东西被处理

给 DrawHTMLTextBox 转义不可信文本,该怎么做?

对 PDFlibPas 的 HTML 渲染,转义不可信正文的做法是依次替换 &、然后 <、然后 >,恰好一次;不可信数据则完全不要放进属性值

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 之前,同样的转义输入可能产出一个活的链接注解——这正是把显示毛病升级成安全问题的那一环:工单评论绝不该能在员工信任的文档里埋一个可点击的 URL

注意这个函数没转义什么。通用 HTML 转义器还会把 " 转成 &quot;、把 ' 转成 &#39;,这对浏览器是对的。PDFlibPas 的文本解码只认前面列的四个实体,这两个会按字面打印成 &quot; 和 &#39;。引号在正文里无害;它们只在属性值里才要紧,而渲染器根本不解码属性里的实体。所以安全的设计不是更好的转义器,而是一条规矩:不可信数据永远不进 href、src 或 style。如果链接目标确实必须来自用户数据,自己对照 scheme 与字符的白名单校验,凡含引号或角括号的一律拒绝

顺着这次修复,还有两条升级注意事项:

  • 如果你的代码因为旧版本会把 &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 返回一个以内部标记开头的 token,下次调用可以借此跳过转换;把它交回 DrawMarkdownTextBox 或 DrawMarkdownText,别交给 HTML 入口——那会把标记当文本画出来

一般性教训:只解码一次,在每个边界重新编码

任何解析文本、把结果序列化回同一语法、再解析一遍的流水线,都必须把解码当成恰好发生在一处的操作,并在每个解码文本重新变成语法的边界重新编码。模板引擎、HTML sanitizer 和 Markdown→HTML→PDF 链条都是这个形状,序列化器一旦忘了自己产出的是标记,就会以同样的方式翻车

知道这个形状之后,症状就可预测了。重新编码太少,数据变成语法,这是注入方向。编码太多,或者解码器跑两遍,实体写法要么展示给读者、要么被吃掉,这是显示方向。只修一个方向通常会把另一个弄坏,这就是 PDFlibPas 的修复必须在同一个版本里补上 &amp; 解码、调整它的顺序、移除迟到解码并加上重新转义的原因。同一原则反过来也成立:把 PDF 内容导出成结构化文本时——如 从 Delphi 做 PDF 到 Markdown 与 DOCX 的语义导出——每个字面字符都必须恰好为目标语法转义一次

速查清单

  • 渲染含用户数据的 HTML 或 Markdown 时,升级到 PDFlibPas v3.539.47 或更高
  • 正文转义先 &,再 < 和 >;对 PDFlibPas 文本不要转换引号
  • 只转义一次,就在数据进入 HTML 字符串的那一个点
  • 不可信值别进 href、src 和 style,否则就对照白名单校验
  • 正文里只有 &lt;、&gt;、&amp; 和 &nbsp; 会被解码;其他实体保持字面
  • LeftOverText 原样传回 DrawHTMLTextBox,并给分页循环设上限
  • Markdown 续排 token 只交给 DrawMarkdownTextBox 或 DrawMarkdownText
  • 绝不在 UTF-16 字节缓冲里搜字节模式;在完整码元上工作

HTML 与 Markdown 渲染、数据集报表导出和排版引擎的其余部分,都在 PDF Library for Delphi 的原生 Pascal 源码里,支持 Delphi 与 Free Pascal。版本、平台支持与试用下载见 PDFlibPas 产品页