技术文章

PDFium 可选导出:Delphi 中的能力门控

你的 pdfium.dll 加载得好好的,却依然缺一个过程。PDFium Component 的处理方式,是把它的绑定分成两类:通过 CheckGetProcAddress 解析的必需导出,缺失时会直接中止加载;通过 TryGetProcAddress 解析的可选导出,缺失时留下的是一个 nil 指针和一次能力检查,而不是直接崩掉

这和一个找不到的 DLL 是不同的问题。如果你的应用程序死于一个坏的 EXE 格式错误、一个缺失的文件,或者架构不匹配,那个故事在部署 pdfium.dll 与诊断加载失败的姊妹篇里讲过。这里说的是加载器已经成功了。模块句柄是有效的,几百个导出都解析成功了,但运行过程仍然在你第一页渲染出来之前就结束了,因为某个在更新的 PDFium 构建里才出现的入口点,不在磁盘上那份二进制文件里

为什么一个缺失的导出会拖垮整个库

因为一个必需绑定是一份硬性契约,而它是在一次要么全部成功、要么全部失败的绑定序列中被强制执行的。PDFium Component 会在 LoadLibrary 内部解析它整张导出表,一次接一次地调用 CheckGetProcAddress。第一个 nil 结果就会抛出 EPdfError,而且会在抛出之前先调用 UnloadLibrary,这是刻意的:否则一次部分绑定会留下一些已经解析好的指针,指向一个即将被释放的模块,悄悄地废掉下游每一处 Assigned 防护

由此产生的失败模式,正是把人们带到这里的原因。你升级了组件,发布的还是两年来一直在发布的同一份 pdfium.dll,结果应用程序起不来。错误信息点名的是一个你从来没调用过的特性所对应的导出。你在调用点做任何事都无济于事,因为调用点根本没跑到;失败发生在绑定阶段,早在任何文档被打开之前

function CheckGetProcAddress(const Name: string): Pointer;
begin
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
  if Result = nil then
  begin
    // A missing required export means the deployed pdfium.dll is older
    // than this build of the binding. Drop every pointer resolved so far
    // so no caller can reach into the module we are about to free.
    UnloadLibrary;
    raise EPdfError.Create('Required PDFium export not found: ' + Name);
  end;
end;

function TryGetProcAddress(const Name: string): Pointer;
begin
  // Optional export. nil is a legitimate answer here; every caller is
  // required to test Assigned() before dereferencing the variable.
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
end;

必需还是可选:这条界线到底划在哪里

PDFium Component 采用的规则很直白。当一个导出的缺失会让组件无法完成它存在的意义时,这个导出就是必需的;当它的缺失只是去掉一个末梢特性时,就是可选的。FPDF_InitLibraryFPDF_LoadDocumentFPDF_RenderPageBitmapFPDF_ClosePage 是必需的,在这些上面大声失败是正确的:一个渲染不出内容的查看器不是一个降级的查看器,而是一个坏掉的查看器

今天经由这套宽容加载器触及到的一切都是末梢特性。FPDFBookmark_GetColor 是在 M109 之后才出现的,它只提供大纲条目里那个可选的 /C 颜色数组,所以一份比它更早的 DLL,只会报告没有书签颜色。V8 相关的辅助函数 FPDF_GetRecommendedV8FlagsFPDF_GetArrayBufferAllocatorSharedInstance,以及 XFA 字符串辅助函数 FPDF_BStr_InitFPDF_BStr_SetFPDF_BStr_Clear,按构造方式在任何非 V8 构建里就必然不存在,所以把它们当作必需,就会让普通的 pdfium.dll 根本加载不起来。而促成本文的那一对:FPDFAttachment_SetDescriptionFPDFAttachment_GetDescription,是上游在 2026-07-13 添加的,比本项目在 DLLs/Win32 和 DLLs/Win64 下发布的全部四份 PDFium 二进制文件的构建日期都要晚。最后这个案例正是这个问题的普遍形态,不是个例:绑定层跟踪的是持续变动的上游头文件,而你安装包里的 DLL 只会在有人重新构建它时才做离散式跳跃。总会存在这样一段窗口期,Pascal 这一侧知道有些导出,而部署的二进制文件却没有,提前决定好每一个新导出该落在必需还是可选这条线的哪一侧,是唯一能让这段窗口期安然度过的办法

FPDFDoc_GetAttachmentCount    := CheckGetProcAddress('FPDFDoc_GetAttachmentCount');
FPDFDoc_AddAttachment         := CheckGetProcAddress('FPDFDoc_AddAttachment');
FPDFAttachment_GetName        := CheckGetProcAddress('FPDFAttachment_GetName');
FPDFAttachment_GetStringValue := CheckGetProcAddress('FPDFAttachment_GetStringValue');
// Attachment descriptions were added after the bundled DLL revision.
// Keep them optional so older deployments continue to load.
FPDFAttachment_SetDescription := TryGetProcAddress('FPDFAttachment_SetDescription');
FPDFAttachment_GetDescription := TryGetProcAddress('FPDFAttachment_GetDescription');
FPDFAttachment_SetFile        := CheckGetProcAddress('FPDFAttachment_SetFile');
FPDFAttachment_GetFile        := CheckGetProcAddress('FPDFAttachment_GetFile');

调用点上,一道能力门控该做什么

它应该是不对称的,而这种不对称正是整个设计的核心。一次跑不起来的读取,有一个诚实的空答案。一次跑不起来的写入,则完全没有诚实的答案,所以它必须抛出异常。PDFium Component 把附件描述这个属性恰好沿这条线一分为二,正是这个拆分,让一个缺失的导出不至于变成悄无声息的数据丢失。TPdf.GetAttachmentDescription 检测 Assigned(FPDFAttachment_GetDescription),不满足就返回一个空的 WString。这不是撒谎:在一份没有这个导出的 DLL 上,组件确实无法判断这个附件是否带有一个 /Desc 条目,而一个空描述读出来和一个从来就没有描述的附件是一样的。附件 API 的其余部分——在 Delphi 中用 PDFium Component 处理 PDF 附件一文有讲——照常不受影响地继续工作

TPdf.SetAttachmentDescription 走的是相反的路线。它对同一个 Assigned 检测调用 Check,不满足就抛出 EPdfError,文本是"Attachment descriptions are not supported by the loaded PDFium DLL"。在这里悄无声息地返回,会是最糟糕的选择:调用方设置了一个描述,没收到任何错误,保存了文件,最终发出去的 PDF 里那个描述根本不存在。没有人会注意到,直到下游某个消费方去问它去哪儿了

function TPdf.GetAttachmentDescription(Index: Integer): WString;
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  Result := '';

  // Read side degrades: an old DLL cannot report /Desc, and '' is
  // indistinguishable from an attachment that carries no description.
  if not Assigned(FPDFAttachment_GetDescription) then
    Exit;
  // ... two-pass buffer sizing against FPDFAttachment_GetDescription ...
end;

procedure TPdf.SetAttachmentDescription(Index: Integer; const Value: WString);
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  // Write side refuses: silently dropping the value would produce a file
  // the caller believes carries a description and does not.
  Check(Assigned(FPDFAttachment_SetDescription),
    'Attachment descriptions are not supported by the loaded PDFium DLL');
  // ... FPDFDoc_GetAttachment, then FPDFAttachment_SetDescription ...
end;

在提供某个特性之前先探测这项能力

靠捕获异常来发现你的部署环境能做什么,是一种很糟糕的方式,所以 PDFium Component 把同样的检测暴露成一个命名清晰的函数。AttachmentDescriptionFeaturesAvailable 调用 LoadLibrary,返回这一对导出是否两个都解析成功。它和 V8FeaturesAvailableXfaBStrHelpersAvailableXfaFeaturesAvailable 放在一起,这几个函数各自对自己那组可选特性遵循完全相同的模式。给探测函数起个名字,其重要性超出表面看起来的样子:一个叫 AttachmentDescriptionFeaturesAvailable 的布尔值,会告诉下一个维护者这个特性取决于部署的二进制文件,而一个埋在属性 setter 里的裸 Assigned 检测永远做不到这一点。它也给了 UI 层一个可以绑定的对象,这样描述编辑框可以在一开始就被禁用,而不是先接受输入、再在保存时拒绝它

procedure TAttachmentFrame.SyncCapabilities;
begin
  // Ask once, at form setup, instead of discovering the limit on save.
  DescriptionEdit.Enabled := AttachmentDescriptionFeaturesAvailable;
  if not DescriptionEdit.Enabled then
    DescriptionEdit.TextHint := 'Requires a newer pdfium.dll';
end;

procedure TAttachmentFrame.SaveDescription(Pdf: TPdf; Index: Integer);
begin
  if not AttachmentDescriptionFeaturesAvailable then
    Exit;
  Pdf.AttachmentDescription[Index] := DescriptionEdit.Text;
end;

为什么绑定覆盖率必须靠工具来证明

因为这些数字已经大到不能再交给人工去信任了。PDFium Component 依据一份 2026-07-29 的上游基线,审计了 21 份公开 PDFium 头文件,找出了 470 个导出的 C ABI 函数。绑定层当时已经覆盖了其中的 468 个。没有人是靠读头文件找出这两个缺口的;是一个脚本在一秒钟之内找出来的,而且下次上游更新时它还会再找一遍。tools/audit_pdfium_public_api.py 刻意写得很小:它在公开目录下的每个头文件里用正则匹配 FPDF_EXPORT ... FPDF_CALLCONV name(,在 PDFium.pas 里用正则匹配每一处 CheckGetProcAddress('Name')TryGetProcAddress('Name'),然后打印出这两个集合的差集:missing 表示没有绑定的导出,stale 表示上游已经不存在、但绑定里还留着的导出。任一个集合非空时它就以非零状态退出,所以可以毫不费力地嵌进构建步骤里。目前的结果是 470 个导出全部绑定,缺失 0 个,过期 0 个

stale 这个方向和 missing 一样有价值。一个被上游移除的导出,会留下一行 CheckGetProcAddress,它会让此后每一次加载都硬性失败,而这种腐坏在有人升级 DLL 那天之前都不可见。人工复核能找到你正惦记着的那个函数;找不到你没想到的那个。还要注意,这个审计工具刻意把两种加载器都算作已覆盖,这对追踪 API 漂移来说是正确的做法,也是为什么必需/可选这条拆分必须是一个写清楚的决定,而不是随便谁加了那一行代码之后顺带产生的副作用

可选绑定的诚实边界在哪里

有两条边界值得明说,因为这个模式很容易被过度套用。第一条是,一个 nil 函数指针只有在字面意义上每一条触及它的路径都先测试 Assigned 时才是安全的。在一个声明了几百个 cdecl 函数变量的单元里,一次没做防护的调用,就是一次发生在堆栈跟踪里毫无意义的地址上的访问违规。跨越 C 边界时管理调用约定和生命周期所遵循的那套纪律,在这里同样适用,这也是加固 PDFium 绑定以应对 ABI 与内存安全故障一文的主题

第二条边界是范围。可选绑定不是一张可以让一切都变得宽容的通行证。如果 FPDF_RenderPageBitmap 是可选的,组件会愉快地加载起来,然后在每一页上都失败,把一个清清楚楚的启动期错误,变成一堆散落各处、原因不明的运行期错误。默认应该是必需。可选是你在一个特性确实是末梢特性、缺失时在读取一侧有一个站得住脚的降级行为、而写入一侧能够用一条点名原因的消息拒绝执行时,才会去用的例外

这里描述的加载器设计、能力探测函数和审计工具,都作为面向 Delphi 和 C++Builder 的 PDFium Component 的一部分随附提供;产品页列出了随附的 PDFium 二进制文件以及它们暴露的完整 API