技术文章

Delphi 中的 PDF DLL、ActiveX 与 dylib 绑定

PDF 库一旦离开它的母语环境,下面这个问题就会立刻冒出来。你有一套在 Windows 上从 C# 调用完全正常的绑定。现在需要在 macOS 上用 Python 调同样的函数,于是你把 Windows 的声明文件复制过去,换掉二进制文件名,跑起来。每个符号都解析成功了。第一次调用返回一堆乱码,第二次直接以访问违规崩溃,而你的 PDF 代码一行都没改。故障出在 PDF 下面一层:Windows 导出用的是 Stdcall 约定,macOS dylib 把同样的函数按 Cdecl 导出并带一个前导下划线,而任何一处细节写错的外部函数声明,都会在第一份文档被打开之前就破坏调用栈

这一整类故障源自一个值得提前弄明白的设计决定。PDF Library for Delphi 是 losLab 面向 Delphi 与 C++Builder 的源码可得 PDF 引擎,它把整个对象模型包进一个扁平的门面类 TPDFlib,再把这个门面以三种二进制形态发布:一个导出约 1,250 个函数的 Windows DLL、一个 COM/ActiveX 自动化对象,以及一个 macOS dylib。三者的 PDF 语义完全一致。会咬人的部分藏在底下的 ABI 里:调用约定、字符串编码、句柄归属,以及哪一侧有权释放哪一块缓冲区

一个门面,三种二进制形态

TPDFlib 的每一个公开函数都有一个扁平对应物,名字是 DL 加方法名。LoadFromFile 变成 DLLoadFromFileEncrypt 变成 DLEncryptNewSignProcessFromFile 变成 DLNewSignProcessFromFile。几乎每个导出函数的第一个参数都是由 DLCreateLibrary 返回的 InstanceID,它替代了 Delphi 调用方本来会持有的那个对象引用。请尽早把这套映射关系记牢。它意味着 Delphi API 参考同时也是其他所有语言的文档:类能做的事,DLL 都能以一个可预测的名字做到,而你读一个 Pascal 方法签名,就能推断出在 Python 或 C# 里该怎么调用

Windows 构建会产出 PDFlibDLL32.dllPDFlibDLL64.dll;请选与宿主进程位数相符的那一个,因为 64 位的 Java 或 .NET 进程无论声明怎么写都加载不了 32 位库

架构示意图:同一个 TPDFlib 门面分别以 Stdcall Windows DLL、Safecall ActiveX 自动化对象和 Cdecl macOS dylib 三种形态对外暴露
三个二进制共享同一个扁平 PDF 门面,却在调用约定、字符串处理和注册要求上各不相同

Windows:Stdcall 实例与 W/A 函数对

每个接受字符串的导出函数都存在两份。宽字符版本接受 PWideChar(UTF-16,天然适配 .NET、Java 以及 Python 的 c_wchar_p),带 A 后缀的版本接受 PAnsiChar。两者语义完全相同,只在编码上有区别,而这恰恰是把它们混用之后极难追查的原因:不抛异常,不返回错误码,你只会在元数据里看到乱码,或者对任何含有纯 ASCII 之外字符的路径收到一句莫名其妙的找不到文件。团队第一次踩到这种编码问题,通常要搭进去一个下午,因为症状指向数据,病根却在声明里

// Windows 绑定(PDFlibDLL64.dll):Stdcall,导出名不加修饰
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// macOS 绑定:同一个函数,改用 Cdecl,导出名前多一个下划线
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

为每个宿主选定一种字符宽度,并把它固化进绑定生成器。一条实用规则:如果宿主语言原生使用 UTF-16 字符串,那就全线绑定 W 版本,从此再也不碰 A 系列

macOS:同样的名字,不同的 ABI

dylib 导出的是同一套 DL 函数,只有两处系统性差异。调用约定是 Cdecl 而不是 Stdcall,并且每个导出名都带一个前导下划线(_DLCreateLibrary_DLLoadFromFile 等等)。这两处差异都纯属机械变换,因此非常适合交给生成式绑定,也因此对手工改过的 Windows 声明副本格外危险。如果工具链允许,就维护一份规范的函数清单,再由它派生出各平台的声明。省掉这一步,你得到的正是本文开头描述的那种栈破坏,而且只在 CI 覆盖最少的那个平台上复现

COM 与 ActiveX 宿主:Safecall 与 Olevariant 载荷

面向 VB.NET、C#、VBScript 以及老式自动化宿主,OCX 构建把同一个门面包成一个 IDispatch 自动化对象 IPDFlibrary,其中每个方法都声明为 Safecall。这个约定改变了错误传到你手里的方式。Safecall 会把内部失败翻译成一个 COM HRESULT,于是 C# 调用方捕获到的是异常,而扁平 DLL 在同样情形下只会安静地返回一个需要调用方自己记得检查的整数。同一个操作,两种失败惯例,取决于你加载的是哪个二进制

二进制数据遵循第二条 COM 专有规则。自动化接口完全没有指针参数。任何二进制内容,无论是送进去的图像字节还是取出来的 PDF 字节,都要以 Olevariant 的形式跨越边界,走 AddImageFromVariantAppendToVariant 这类方法。在 .NET 里把字节数组封送进 variant 只是一行代码。若因为反正都在同一个进程里就想直接递一个裸指针,分发层要么拒绝这次调用,要么把它弄坏。还有一处注册细节常常绊倒部署:COM 注册是按位数分开的,用 32 位 regsvr32 注册的 OCX 对 64 位宿主是不可见的。这种不匹配在客户机器上表现为那句出了名的毫无帮助的类未注册,而那时距离它离开你的机器已经过去很久了

句柄纪律:实例拥有文档

扁平 API 建立在整数句柄之上。DLCreateLibrary 返回一个实例。加载文件返回该实例内部的一个文档 ID。签名过程、字符串列表和直接访问文件各自返回自己的整数句柄,全部限定在同一个实例的作用域内。这套生命周期在任何 FFI 宿主里看起来都一样,这里用 Pascal 展示,是因为它读起来最清晰:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // 每个工作线程一个实例
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // 返回 DocumentID,失败时为 0
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // 释放该实例拥有的每一份文档
  end;
end;

这棵归属树带来两个推论。DLReleaseLibrary 是你严格意义上唯一必须调用的清理函数,因为它会一次性拆掉该实例下的每一个文档和过程句柄。在一段短脚本里这就够了。在一个长期运行的服务里,它会变成一处带着额外仪式感的缓慢泄漏,所以要在用完文档时就释放它,而不是任由它们堆积到实例销毁为止。实例同时也是线程隔离的天然单位。给每个工作线程分配它自己的 InstanceID,没有外部加锁就绝不要跨线程共享,理由和你绝不会在线程间共享单个 TPDFlib 对象完全一样

返回的字符串是借来的,不归你所有

返回文本的函数,比如 DLGetPageText,交还给你的是一个 PWideCharPAnsiChar,它指向的缓冲区由库实例所有,并且会被回收复用。约定是:立刻复制,绝不释放

PDF Library for Delphi 时间线对比:立刻复制借来的 DLGetPageText 指针,与一直持有它直到库回收底层缓冲区
返回的字符指针借用的是实例会回收的存储,因此复制必须发生在下一次库调用之前
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // 指向库所拥有的缓冲区
  PageText := P;                 // 现在就复制;后续调用可能复用该缓冲区
end;

在 C# 里,这意味着要在下一次库调用之前把 IntPtr 封送成托管字符串。在 Python ctypes 里,意味着立刻从指针中把宽字符串切出来。跨调用持有裸指针,你就写下了一个能通过全部单元测试、然后在生产环境中两个请求第一次重叠时失败的缺陷,因为第二次调用回收了第一次还在读的那块缓冲区。通过 DLSetProgressCallback 注册的回调,其归属规则方向相反但同样成立。库递进你回调里的任何指针,只在该回调函数体执行期间有效;而回调对象本身必须在实例还可能调用它的整段时间里保持存活,在带垃圾回收的宿主里就要把它钉住。一个在任务中途被回收的委托,正是那种在某个已经干净运行数月的 .NET 绑定里冒出来的随机访问违规的教科书成因

把冒烟测试直接内建进绑定,并在任何生成的声明集发布之前跑一遍。从每一类最容易暴露 ABI 错误的调用中各取一个来试:一个无参函数如 DLCreateLibrary,用来证明约定正确;一个喂入含非 ASCII 字符路径的入参字符串函数,用来证明编码正确;一个出参字符串函数,用来证明借用缓冲区的处理正确;再加一个故意失败的操作,好让你看清错误是怎么传到宿主里的。这是十五分钟的工作量,却能拦下那些否则会在几个月后以客户崩溃转储形式送到你面前的调用约定与编码故障

PDF Library for Delphi 绑定冒烟测试的二乘二探针矩阵,覆盖调用约定、字符串编码、借用缓冲区和错误暴露
四个低成本探针能在生成的声明抵达客户机器之前抓出约定、编码和归属方面的故障

具体到 Python ctypes 这一例

Python ctypes 是我见过手写得最多的一种绑定,它也让跨平台的分裂最容易演示。在 Windows 上用 ctypes.WinDLL 加载库,让 ctypes 采用 Stdcall,绑定无后缀的 W 函数,并把每个字符串参数声明为 c_wchar_p。在 macOS 上改用 ctypes.CDLL 以获得 Cdecl,保持函数清单完全一致,并按不带前导下划线的名字解析符号。包括 ctypes 在内的多数 FFI 层会在 macOS 上替你把下划线约定补回去,但这正是那种应当先用一次成功的符号解析确认过、再在其上生成成百上千条声明的假设

绑定工作之后还跟着两个部署问题,答案都很干脆。普通 DLL 不需要注册:regsvr32 只适用于 ActiveX 构建,DLL 靠文件复制就能发布,这也是 Windows 服务和容器场景优先选它的主要理由,因为在那些地方你根本不想碰注册表。线程安全归结为上文已经用到的那条规则,一个线程一个实例。实例句柄持有引擎跟踪的全部可变状态,包括选中的文档、渲染选项和提取设置,所以两个线程共享一个实例就会互相穿插彼此的状态,哪怕每一次单独调用都返回成功

绑定一旦稳固,边界另一侧的操作就正是 Delphi 系列文章深入讲解的那些内容,包括施加并审计 PDF 加密以及从既有文档中提取文本和图像

三个集成层的二进制下载包都随库一同提供;版本与授权详见 PDF Library for Delphi 产品页