技术文章

PDFlibPas 在 Free Pascal 下的 EMF 矢量导入

PDFlibPas 将增强型图元文件逐条记录地转换为真正的 PDF 页面内容,而不是栅格化,这正是导入的图表或 CAD 图纸在任何缩放级别都保持清晰的原因。这个转换器约 6500 行,编写时依赖 VCL,因此当库新增 Free Pascal 目标时,它被归为不可移植并替换成了存根。这个归类是错的,而错在哪里恰好是一堂有用的课:在决定绕开某个依赖重写之前,先审计它

这 6500 行代码实际用到的 VCL 面其实很小:一个位图类(用到其像素格式、流保存、句柄、画布与扫描线);一个图元文件类(用到其宽度、高度与句柄);以及颜色类型和两个常量。这些全部已由库自己的 graphics 单元提供——该单元存在的意义正是让非 VCL 构建有等价物。转换器根本没有卡在 VCL 上,而是卡在 Free Pascal 的 Windows 单元上

沿着代码真正依赖的轴做拆分

所以改动不是重新实现,而只是一条条件:从"无 VCL 构建时编译存根"改成"非 Windows 构建时编译存根"。这才是正确的轴,把理由说清楚,差别就一目了然。增强型图元文件是 Windows 容器;这个转换器从头到尾都是 Windows GDI 记录的解析器。宿主应用用 VCL、用别的控件库还是完全不用控件,与这些记录能否被解析毫无关系;而目标平台是不是 Windows,则与它息息相关

选对轴,好处是顺带而来的。C++Builder 构建(本库中它未定义 Windows 平台符号)保留抛异常的存根,行为与之前完全一致。macOS 正确地保留存根,因为那里没有 GDI 记录可解析。Delphi VCL 构建不受影响。而非 VCL 控件库的 Windows 构建则作为副产品获得了矢量 EMF 导入,没有人需要为此写一行实现。与真实依赖对齐的条件编译把平台工作变成一行改动;对齐到错误的轴,它就变成一个永远排不上期的重写项目

EMF 导入的条件从 VCL 归属改轴到 Windows 平台,其他平台保留存根,非 VCL 的 Windows 构建获得矢量导入
把存根条件的轴改到 Windows 平台,保留所有既有构建行为,并让非 VCL 的 Windows 目标免费获得 EMF 矢量导入

Free Pascal 的缺口是声明,不是逻辑

真正缺失的,是 Delphi 的 Windows 单元有而 Free Pascal 单元没有的 Win32 声明。把它们收进一个单独的兼容单元,而不是把条件编译撒满转换器,才保住了解析器的可读性。这份清单很有启发,因为它展示了两个 RTL 之间头文件覆盖的不均衡:113 个图元文件记录类型常量、两个扩展文本输出标志、三个渐变填充模式常量、一个句柄表指针类型、渐变顶点与图元记录的别名,以及三个 Free Pascal 完全未声明的记录类型,覆盖 alpha 混合、透明位块传输与颜色管理模式

这些声明单独看都无趣。但解析器要编译通过,它们必须全部正确;而兼容单元是它们自然的家,因为可以把它当作一个整体与头文件文档做 diff

Free Pascal 的 Windows 单元缺失的 Win32 声明,集中收进一个兼容单元,供 EMF 转 PDF 矢量转换器使用
记录常量、标志、别名与三个缺失的记录类型都集中在一个兼容单元里,可与头文件文档整体 diff

那个悄悄画错图的声明

其中两个声明不只是缺失,而是存在但对这个用途是错的——即使你从不碰图元文件,这一部分也值得记住

Free Pascal 在画笔创建记录里内嵌了运行时画笔结构,在扩展画笔记录里内嵌了运行时画笔结构。这两个运行时结构都把 hatch 成员声明为指针宽度的整数,因为在实际的 GDI 调用中该成员可以携带句柄。然而图元文件始终存储 32 位形式,因为记录布局是序列化文件格式的一部分,不随进程位数改变

32 位构建下两者一致,无事发生。Win64 上,指针宽度的成员是 8 字节,而文件里只有 4 字节,hatch 成员之后的每个字段都从错误的偏移读取。没有异常、没有解析错误、没有警告。图元文件只是画错了:颜色来自错误的字节,画笔宽度来自错误的字节,整幅图像渲染 bug 而不像结构布局 bug。Delphi 正是为此原因同时提供了两个结构的显式 32 位变体,兼容单元也以同样方式重新声明它们

EMF 画笔记录的字节布局:指针宽度的 hatch 字段在 Win64 上使后续字段偏移四字节,对比固定 32 位布局
序列化记录始终存储 4 字节 hatch,因此指针宽度的运行时结构在 Win64 上悄悄读错后续所有字段
// Win64 上错误:Hatch 是指针宽度,而文件存 32 位,
// 且后续每个字段偏移四字节且不报错
type
  TLogBrushRuntime = record
    lbStyle: UINT;
    lbColor: COLORREF;
    lbHatch: ULONG_PTR;      // 64 位进程中为 8 字节
  end;

// 正确:序列化布局,固定位宽,与进程位数无关
type
  TLogBrush32 = record
    lbStyle: UINT;
    lbColor: COLORREF;
    lbHatch: DWORD;          // 始终 4 字节,与图元文件中的存储一致
  end;

通用规则:任何既作为运行时 API 参数出现、又作为序列化字段布局出现的结构,都需要两份声明,且序列化那份必须全程使用定宽类型。文件格式里的指针宽度成员,永远是一颗等待 64 位构建引爆的雷

签名差异收进包装函数,不要散落在每个调用点

剩下的差异是普通的签名不匹配,消化它们的方式是转发包装函数,而不是在每个调用点写条件。变换组合函数在 Free Pascal 下接收指针,Delphi 下接收引用参数,因此包装函数接收引用再传地址。它还先把两个源参数复制到局部变量,因为转换器里存在目标矩阵同时是源之一的调用点;把同一个地址传两次给一个边读边写的函数,会产出一个细微错误的变换,只在旋转内容上暴露

function CombineTransformCompat(var Dest: TXForm;
  const A, B: TXForm): BOOL;
var
  SrcA, SrcB: TXForm;
begin
  // 先复制:调用方确实可能把 Dest 同时作为 A 或 B 传入
  SrcA := A;
  SrcB := B;
{$IFDEF FPC}
  Result := Windows.CombineTransform(@Dest, @SrcA, @SrcB);
{$ELSE}
  Result := Windows.CombineTransform(Dest, SrcA, SrcB);
{$ENDIF}
end;

矩形和点类型是另一种情况。Free Pascal 把图元文件的矩形和点记录视为与通用图形类型不同的类型,因此八处赋值需要在布局完全相同的记录之间做显式类型转换。两个编译器都接受这种写法,所以这些位置完全不需要条件编译,为此丑一点是值得的

这对 Free Pascal 部署意味着什么

在 Free Pascal 下的 Windows 平台,矢量 EMF 导入正常工作,产出与 Delphi 构建相同的页面内容:路径还是路径,渐变还是图案内容,文本还是文本。Windows 之外仍走栅格路径,这是格式的限制而非移植的缺陷。转换器写入的坐标与裁剪状态在内容流 CTM 与裁剪状态跟踪一文中描述,它输出的矢量图元则见矢量图形、着色器与渐变

如果你在审计自己的代码库寻找同类机会,有用的练习正是引发这一切的那一步:列出你实际用到的、来自你以为所依赖的框架的成员。答案往往比 import 清单暗示的短得多,而真正的约束通常完全在别处。基于设备上下文的导入路径一般见打印预览与设备上下文一文,平台与工具链覆盖情况列在 losLab PDF Developer Library 产品页