PDFium Delphi Component 通过把 UTF-8 片段拼接进 AnsiString 来组装 PDF/A 输出的 XMP 数据包;在 Free Pascal 3.2.2 上,只要文档标题带有非 ASCII 字符,这个数据包就会悄悄不再是有效 UTF-8。ISO 19005-1 第 6.7.2 节要求元数据流使用有效 UTF-8,因此文件验证失败。3.103.1 版本在 StringToUtf8 中直接修复了编码器。真正有意思的不是补丁,而是同一行未改动的源代码,在 Delphi 下产生正确字节,在 Lazarus LCL 应用中也产生正确字节,却在使用完全相同单元编译的普通 Free Pascal 控制台程序中产生损坏字节。只有把 Free Pascal 的三种独立字符串行为放在一起,结果才说得通,而它们各自都很合理
同一份元数据代码为什么会在 Delphi 和 FPC 上输出不同字节
因为两个编译器中的 string 不是同一种类型。FPC 3.2.2 在 {$MODE Delphi} 下把 string 编译为带 DefaultSystemCodePage 标签的 AnsiString,而 Delphi 将它编译为 UnicodeString。TPdfASaveOptions 中的每个元数据字段都声明为 string,因此 Title、Author、Subject、Keywords、Creator 和 Producer 在一个编译器中携带 UTF-16 代码单元,在另一个编译器中携带单字节字符和代码页标签。同一个记录、同一个字段,却是不同的载荷。值本身以 UTF-16 形式从文档到达。TPdf.GetTitle 及其同类方法返回 WString,在 FPC 上是 WideString,在 Delphi 上是 string;SaveAsPdfAToStream 会在注入标记前从 Info 字典为任何空选项字段填充值。在 Free Pascal 上,这次赋值是窄化转换,RTL 会通过目标字符串代码页执行它。在 LCL 程序中,LazUTF8 已经将 DefaultSystemCodePage 设为 CP_UTF8,所以窄化结果是 UTF-8,下游恰好全部正确。在普通控制台程序中,同样的窄化会落到 ANSI 代码页,而 StringToUtf8 又因为假定输入已经是 UTF-8,直接原样复制这些八位字节。六个保存桥接都共享这种形状:SaveAsPdfAToStream、SaveAsPdfUaToStream、SaveAsPdfEToStream、SaveAsPdfXToStream、SaveAsPdfRToStream 和 SaveAsPdfVTToStream,每个都有自己的选项记录
// PDFium.pas:文档访问器始终使用 UTF-16
// WString 在 FPC 上是 WideString,在 Delphi 上是 string(UnicodeString)
function TPdf.GetTitle: WString;
// FPdfPdfa.pas:保存选项记录使用 `string` 承载元数据
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // Delphi 上是 UnicodeString
// FPC 上是 AnsiString + DefaultSystemCodePage
Author: string;
Subject: string;
Keywords: string;
Creator: string;
Producer: string;
CreationDate: string;
ModDate: string;
DocumentId: TBytes;
InstanceId: TBytes;
class function Default: TPdfASaveOptions; static;
end;
// SaveAsPdfAToStream 从 Info 字典回填空字段
// 现在将窄化转换明确写出,而不是留给隐式转换
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
让六个桥接都通过一个 WStringToStr 辅助函数,并不会改变 RTL 的行为,但会把转换放到读者看得见的位置,同时清除了 92 个隐式转换警告,而这些警告此前正好掩盖了这类问题。这与我们在PDFium 构建中的 Delphi 与 FPC 跨编译器陷阱笔记里描述的 Delphi 侧损坏相反,后者是 Delphi 上的拼接破坏高字节,而 Free Pascal 会保留它
三个会击败直觉修复的 Free Pascal 行为
直觉修复是调用 UTF8Encode,然后就结束。可是在 FPC 3.2.2 的 Delphi 模式下,它会连续三次失败,而且每一次都是静默失败
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// 陷阱 1:Delphi 模式下 UTF8String 变量就是普通 AnsiString,
// 因此赋值会把八位字节直接转回主机代码页
U := UTF8Encode(W);
// 陷阱 2:S 已经是 AnsiString,因此 UTF8Encode 完全不会做任何事
R := UTF8Encode(S); // 不解码,不编码,不报错
R := UTF8Encode(UnicodeString(S)); // 这一处才真正编码
// 陷阱 3:拼接会将每个操作数统一到目标代码页,
// RawByteString 作为目标也不例外
Xmp := Xmp + R;
end;
陷阱一意味着编码结果必须留在它产生的 AnsiString 或 RawByteString 中。把它在输出路上经过 UTF8String 临时变量,就等于把刚做完的工作撤销。陷阱二最隐蔽,因为 UTF8Encode(S) 能编译、能运行、返回正确长度的值;当参数已经是 AnsiString 时它完全不做转换,只有先扩大到 UnicodeString 才会解码。陷阱三解释了为什么正确的编码器仍然能生成损坏文档:BuildXmpBytes 会在局部 Xmp: AnsiString 中累积数据包,而 Free Pascal 会把拼接的每个操作数都转换到目标变量的代码页,在进入数据包时把多字节序列折回单字节 ANSI
SetCodePage 传入 False 到底保证什么
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) 会在不触碰字节的情况下重新标记字符串。第三个参数是 Convert;传入 False 表示“假定载荷已经属于目标代码页,只修改标签”。这是有意对内容撒谎:这些八位字节实际上是 UTF-8,但将它们标记为主机代码页,正是阻止陷阱三中的拼接进行转换的办法。它们会作为原始字节加入 XMP 缓冲区,再从另一端原样输出
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi:UTF8Encode 已产生带 CP_UTF8 标签的字节,拼接到
// AnsiString 中会保留它们
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC:必须先扩大,否则 UTF8Encode 接收 AnsiString 时是空操作
Result := UTF8Encode(UnicodeString(S));
// 不转码而重新标记,这样字节能存活于组装 XMP 数据包和 PDF
// 字符串对象的 ANSI 标记缓冲区中
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
要明确边界。重新标记只用于 FPC,而且不是混合带标签和未带标签字符串的通用许可。这里之所以可行,是因为下游只有一种消费者模式:追加到 AnsiString,然后以字节形式写出缓冲区。任何试图在主机代码页中将重新标记的值当作文本解释的代码,都会读到乱码,而且这是正确结果。反向方向在两个编译器上都使用另一种方式处理,并且完全相同:用 SetCodePage(..., False) 将输入缓冲区标记为 CP_UTF8,然后调用 UTF8ToString
回归测试为什么也带有同一个陷阱
因为根据源代码字面量构建预期字节的测试,测试的是编译器而不是库。Pascal 源文件中写出的 #$C3#$A9 之类常量带有该源文件的编译时代码页,当它传入 AnsiString 参数时,RTL 会重新编码它,这正是被测转换。预期值必须在运行时逐字节组装并逐字节比较,因为两个带不同标签的 AnsiString 用 = 比较时会先协调代码页,随后返回一个令人安心的假阴性
function BytesPattern(const Values: array of Byte): AnsiString;
var
I: Integer;
begin
SetLength(Result, Length(Values));
for I := 0 to High(Values) do
Result[I + 1] := AnsiChar(Values[I]);
end;
procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
Saved: Word;
Wide: WideString;
Narrowed: string;
Encoded, ExpectedUtf8: AnsiString;
begin
Saved := DefaultSystemCodePage;
try
SetMultiByteConversionCodePage(1252);
Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
Narrowed := Wide; // 被测的窄化
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 作为 UTF-8,在运行时构建,避免字面量被重新编码
ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
SameOctets(Encoded, ExpectedUtf8));
end;
测试工具本身是一个 LCL 程序,因此 DefaultSystemCodePage 是 CP_UTF8,只有测试主动切换它时 bug 才会出现。SetMultiByteConversionCodePage(1252) 在 try..finally 内运行,使一个测试暂时复现普通控制台环境。端到端检查还更进一步,断言两个方向:标记注入产生的 XMP 数据包必须包含 $43 $61 $66 $C3 $A9,且不得包含 $43 $61 $66 $E9,这样未来重新退回原始单字节输出的回归会明确失败,而不会只生成一份在十六进制转储中看似合理的文件。如果你处理非拉丁元数据,同样的扩大纪律也适用于会破坏 Delphi WideChar 处理的 emoji 与 CJK 文本
窄化还会落在哪里
XMP 是最明显的受害者,但同一代码库中任何 TBytes 到 string 的桥接都暴露在相同风险下。v3.103.1 还修正了两个位置:FPdfProduction 中来回转换 XFA 数据集数据包的 Utf8BytesToString 和 StringToUtf8Bytes,这样 MergePdfXfaDatasets 才能替换绑定值;以及 FPdfTrustedList 中去除字节顺序标记后解码欧洲信任列表 XML 的 BytesToUtf8。现在它们都会先把缓冲区放入 RawByteString,在不转换的情况下标记为 CP_UTF8,再使用 UTF8ToString 解码。有一个模块原本就不受影响,原因值得照搬。XFDF 写入器声明了自己的文本类型 XFDFString,在 FPC 下解析为 WideString,在 Delphi 下解析为 UnicodeString,因此其编码器根本不会看到带代码页标签的 AnsiString。结构性修复就是这样:在精确的序列化点之前,一直让文本保持 UTF-16 类型,再让一个窄函数独占转换为字节。这个家族的每个 bug,根因都是一端为 UTF-16、另一端为八位字节的流水线中间出现了 string 字段
检查自己的双编译器 PDF 代码
如果你交付同时运行在两个编译器上、并将元数据写入符合标准 PDF 的 Object Pascal 代码,下面四项检查可以在验证器之前发现大多数此类问题
- 搜索带有
string参数的UTF8Encode。在 FPC 上这次调用是空操作,是最值得优先审计的一行 - 在 Delphi 模式下,把每个
UTF8String变量都视为可疑对象。它在这里是普通AnsiString,把编码字节赋给它会把字节转回去 - 至少在
SetMultiByteConversionCodePage切换到单字节代码页的环境下运行一次回归。LCL 测试工具以CP_UTF8运行,永远复现不了普通控制台程序 - 在运行时构建预期字节向量,并逐八位字节比较。源代码字面量和
=都会经过代码页协调,反而会掩盖正在寻找的缺陷
这不是奇异的 Free Pascal 轶闻,而是一个在 UTF-16 类型之外保留面向字节的字符串类型的语言所要付出的普通代价;两个编译器对 string 应该表示什么做出了合理却不同的选择。对 PDF 工作而言,实际后果很窄却很尖锐:元数据在 IDE 中看起来正常,却可能以无效 UTF-8 进入 XMP 数据包,而 ISO 19005-1 第 6.7.2 节并不在乎把它放进去的是哪个编译器。如果你正在构建归档流水线,编码层值得和周围的PDF/A 归档合规工作流同等重视。PDFium Delphi Component 已将这些转换作为库的一部分,因此 SaveAsPdfA 及其五个标准兄弟入口可以在 Delphi、Lazarus 和普通 Free Pascal 构建中输出符合要求的 UTF-8 元数据,而不需要调用方配置任何代码页。完整 API 文档和当前版本位于 PDFium Delphi Component 产品页