PDF Library for Delphi(PDFlibPas)在 v3.539.47 之前的版本,把 HTML 或 Markdown 画进 PDF 时可能把转义文本解码两次。DrawHTMLText 和 DrawHTMLTextBox 先解析 HTML,把结果规范化回 HTML,再解析一遍,于是写成 <unsafe> 的文本到第二轮解析时就成了一个真标签。从 v3.539.47 起,每个实体恰好只解码一次,凡是有机会变回 HTML 的地方,文本都会重新转义
暴露这个问题的场景再普通不过:技术支持部门把工单导出成 PDF,客户评论被塞进一个 HTML 模板。开发者做了正确的事,把评论转义了,于是 <b> 变成了 <b>。而在渲染器内部,这次转义被悄悄撤销:评论以粗体出现,一个不认识的标签名干脆从页面上消失,一个转义过的锚点变成了可点击的链接注解。没有异常,没有警告,只有一份完全合法、说的却不是数据的 PDF
转义文本为什么会在 PDF 里变成真标签?
转义文本变成标记,是因为渲染器跑两轮解析,夹在中间的规范化步骤把已解码的文本写回 HTML 时没有再次转义。第一轮解析做过的每一次解码,都成了第二轮解析眼里的活语法
两轮解析的存在是有道理的。第一轮解析构建标签与单词元素列表。NormalizeParsedHTML 随后解决样式表级联:把 <style> 块里的规则逐个匹配到标签上,与内联的 style 属性合并,把结果存到标签上,再把整个元素列表序列化回一个 HTML 字符串。排版那一轮解析的就是这份规范化后的字符串。驱动 PDFlibPas HTML 渲染的 flexbox、CSS grid 与脚注排版的正是同一套机器
缺陷出在单词的序列化方式上。标签按原始源形式写回,单词却按解码后的形式写回。第一轮解析从 <unsafe> 解码成 <unsafe> 的单词,落进规范化 HTML 时成了裸角括号,第二轮解析就把它当成一个元素。围绕这个核心 bug,还有三个方向一致的小漏洞:
&不在受支持的实体集合里,所以R&D按字面打印,也没法把<这类实体写法当成正文写出来- 绘制阶段在解析早已结束后又替换了一次
,所以字面实体写法还是可能在最后一刻消失 - Markdown 代码转义跳过了 & 符号,数据集导出器只转义角括号,于是代码或单元格值里的实体写法被当成标记解码
| 到达渲染器的输入 | v3.539.47 之前 | v3.539.47 起 |
|---|---|---|
<unsafe> | 被当成标签解析,文本根本到不了页面 | 以文本形式画出 <unsafe> |
<b>x</b> | x 以粗体画出 | 以文本形式画出 <b>x</b> |
R&D | R&D 按字面打印 | R&D |
&lt; | &lt; 按字面打印 | < |
含 的 Markdown 代码 span | 变成了不换行空格 | 以文本形式画出 |
数据集单元格值 < | < | < |
v3.539.47 如何让 HTML 实体解码单趟完成
PDFlibPas v3.539.47 用三个协同的改动让实体解码单趟完成:解析器最后才解码 &,绘制阶段不再解码任何东西,所有把解码后的单词变回 HTML 的地方都先重新转义
正文文本受支持的实体集合现在是 <、>、& 和 。其余一切——包括 A 这类数字引用和 " 这类命名实体——都保持字面文本。这条边界关系到你该怎么转义自己的输入,见下文
解码器内部的顺序是第一处修复。若 & 先被解码,输入 &lt; 就会变成 <,下一次替换再把它变成 <——单趟之内发生的二次解码。所以 ANSI 单词路径先替换 <、> 和 ,最后才替换 &,它产出的 & 符号不会再被检视。UTF-16 单词路径则是单次从左到右、按两字节步进的扫描,原地改写每个匹配然后跳过它,从结构上给出同样的保证
第二处修复把迟到的 替换从绘制阶段移除。解码只属于解析器,别无他处,到达断行器的单词就是最终文本
第三处修复是边界规则。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'&' 会找到一个并不存在的 & 符号,把 & 的字节拼进两个字符中间,再让后面每个字符错位一个字节
这并非刁钻的角落案例。低字节为零的任何字符都能凑出前一半;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 与围栏或缩进代码块现在把 & 映射为 &、< 映射为 <、> 映射为 >,空格变成 ,制表符变成四个,以保留缩进。普通 Markdown 散文只转义角括号,所以散文里的裸 HTML 注入不了标签,而作者仍能有意写出 &,与 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// 检查生成的 HTML:代码里 '&' 变成 '&','<' 变成 '<'
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 之前它只转义角括号,而且是故意的:渲染器不解码 &,转义 & 符号会让每个含 & 的单元格打印出 &。这个变通办法对旧渲染器是对的,一般情形下却是错的,因为恰好含 < 的单元格值会被解码成 <。渲染器修好之后,导出器先转义 &,像 R&D < & 这样的值会原样落进 PDF。如果你就这样做报表,在 Delphi 里把 TDataSet 导出成 PDF 报表一文覆盖了导出器的其余部分
& 符号为什么必须排第一,值得讲一遍。先转义 < 得到 <;再转义 &,它就变成 &lt;,而正确的单次解码会把它显示成 < 而不是 <。顺序替换链只有在一个前提下才正确:转义字符本身先于任何会引入它的东西被处理
给 DrawHTMLTextBox 转义不可信文本,该怎么做?
对 PDFlibPas 的 HTML 渲染,转义不可信正文的做法是依次替换 &、然后 <、然后 >,恰好一次;不可信数据则完全不要放进属性值
uses
System.SysUtils, PDFlibrary;
// 为 PDFlibPas HTML 正文转义不可信文本。
// '&' 必须最先替换,否则已产出的 '<'
// 里面的 & 符号会被再次转义
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;
在 v3.539.47 上,像 Try <a href="https://example.com">this</a> & <b> 这样的评论会逐字符出现在页面上。v3.539.47 之前,同样的转义输入可能产出一个活的链接注解——这正是把显示毛病升级成安全问题的那一环:工单评论绝不该能在员工信任的文档里埋一个可点击的 URL
注意这个函数没转义什么。通用 HTML 转义器还会把 " 转成 "、把 ' 转成 ',这对浏览器是对的。PDFlibPas 的文本解码只认前面列的四个实体,这两个会按字面打印成 " 和 '。引号在正文里无害;它们只在属性值里才要紧,而渲染器根本不解码属性里的实体。所以安全的设计不是更好的转义器,而是一条规矩:不可信数据永远不进 href、src 或 style。如果链接目标确实必须来自用户数据,自己对照 scheme 与字符的白名单校验,凡含引号或角括号的一律拒绝
顺着这次修复,还有两条升级注意事项:
- 如果你的代码因为旧版本会把
&按字面打印而停止转义&,把它加回来。不加的话,含<的用户文本现在会显示成<,虽仍是无害文本,但已不是用户打的内容 - 不要转义两次。过了两道转义器的文本会把
<渲染成可见写法<,所以找准数据进入 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 的修复必须在同一个版本里补上 & 解码、调整它的顺序、移除迟到解码并加上重新转义的原因。同一原则反过来也成立:把 PDF 内容导出成结构化文本时——如 从 Delphi 做 PDF 到 Markdown 与 DOCX 的语义导出——每个字面字符都必须恰好为目标语法转义一次
速查清单
- 渲染含用户数据的 HTML 或 Markdown 时,升级到 PDFlibPas v3.539.47 或更高
- 正文转义先
&,再<和>;对 PDFlibPas 文本不要转换引号 - 只转义一次,就在数据进入 HTML 字符串的那一个点
- 不可信值别进
href、src和style,否则就对照白名单校验 - 正文里只有
<、>、&和 会被解码;其他实体保持字面 LeftOverText原样传回DrawHTMLTextBox,并给分页循环设上限- Markdown 续排 token 只交给
DrawMarkdownTextBox或DrawMarkdownText - 绝不在 UTF-16 字节缓冲里搜字节模式;在完整码元上工作
HTML 与 Markdown 渲染、数据集报表导出和排版引擎的其余部分,都在 PDF Library for Delphi 的原生 Pascal 源码里,支持 Delphi 与 Free Pascal。版本、平台支持与试用下载见 PDFlibPas 产品页