技术文章

PDFium 库配置:Brotli 何时悄悄换掉 Skia

在 PDFium Component for Delphi 里,打开 TPdfLibraryConfiguration 的 BrotliEnabled 或 IsolatePerDocument 曾会把随附的 Skia 构建无声切到 AGG 渲染器,因为这两个选项把 FPDF_LIBRARY_CONFIG 抬到了 PDFium 按字面读 m_RendererType 的版本。自 v3.123.0 起,默认渲染器保持 DLL 自己的默认;自 v3.125.0 起,DLL 兑现不了的 Skia 或 Fontations 请求会抛可捕获的 EPdfError,而不是弄死进程

两个 bug 都不声不响。第一个产出看着没问题的页面——只是出自另一个光栅化器,抗锯齿和文字边缘与你出厂测试的那份构建略有出入。第二个倒是声势浩大地自报家门:从原生初始化内部把宿主进程带走。两者同源:一个带版本的 C 结构,字段只有在版本号点头时才作数,而它的零值不是「未设置」,是真实的选择

FPDF_LIBRARY_CONFIG 怎么决定 PDFium 用哪个渲染器?

FPDF_InitLibraryWithConfig 只在结构的 Version 字段为 4 或更高时才看 m_RendererType,而从那个版本起它就按写的字面用值。版本 4 以下 PDFium 无视该字段、取构建默认——以 PDF_USE_SKIA 编译的构建是 Skia,其余地方是 AGG

后面的每个字段都循同一模式。结构一次长出一个能力,每个能力都带着新版本号一起到来。PDFium Component 在 LoadLibrary 里从你的 TPdfLibraryConfiguration 搭建原生结构,版本只抬到你设置的选项所要求的程度

结构版本新增字段何时写入
2m_pIsolate, m_v8EmbedderSlot恒定写入;V8Isolate, V8EmbedderSlot
3m_pPlatformV8Platform 非 nil
4m_RendererTypeRenderer 非 prpDefault
5m_FontLibraryTypeFontBackend 非 pfbpDefault
6m_BrotliEnabledBrotliEnabled = True
7m_IsolatePerDocumentIsolatePerDocument = True

陷阱在最后两行。版本是累积的:version 6 结构同时也是 version 4 和 version 5 结构,所以 PDFium 会读 m_RendererType 和 m_FontLibraryType,哪怕你只想要 Brotli。那一刻这两个字段里坐着什么,什么就成了渲染器和字体后端,不管你有没有打算选

PDFium Component 的 FPDF_LIBRARY_CONFIG 版本阶梯示意,从 version 2 到 version 7:哪个 TPdfLibraryConfiguration 选项引入 m_RendererType、m_FontLibraryType、m_BrotliEnabled 和 m_IsolatePerDocument,以及为什么在累积版本下任何构建上清零的渲染器字段都是一个刻意的 AGG 选择而非未设置
每个选项抬一次结构版本,更早的字段全部保持生效,于是 m_RendererType 里的零以一条显式 AGG 请求的身份抵达 PDFium

打开 Brotli 为什么会把渲染器换成 AGG?

v3.123.0 之前,PDFium Component 对 prpDefault 往 m_RendererType 里写 FPDF_RENDERERTYPE_AGG,于是任何把结构推到 version 6 或 7 的配置都会在 Skia 构建上强加 AGG。组件随附的 pdfium.dll 和 pdfium.v8.dll 运行时是 Skia 构建,所以挨打的是默认部署,不是什么冷门配置

这段映射落笔时看着无害。version 2 或 3 时字段从不被读,prpDefault 确实意味着「DLL 爱咋咋」。BrotliEnabled(version 6) 或 IsolatePerDocument(version 7) 一进场,同一段代码就把「没有偏好」变成了显式的 AGG 请求。什么都没失败。PDFium 正常初始化、渲染每一页、不返回任何错误码——因为在它看来,调用方要了 AGG,也得到了 AGG

像素哈希能在截图看不见的地方让这场调包现形。同一份示例文档的首页在三种配置下渲染,结果是:

  • 默认配置:哈希 502D77C3711B4ACF
  • BrotliEnabled = True 且 Renderer 留在 prpDefault:哈希 F75B5EB4728ADE87
  • 显式 prpAgg:哈希 F75B5EB4728ADE87,与 Brotli 那次完全相同

v3.123.0 的修复是公开函数 PdfNativeRendererType,它把 TPdfRendererPreference 解析成写进 m_RendererType 的值。prpAgg 和 prpSkia 一一对应。prpDefault 现在在加载的 DLL 导出 FPDF_RenderPageSkia 时映射为 Skia,否则 AGG。那个导出与 Skia 默认本身在同一个 PDF_USE_SKIA 条件下编译,这使它成为唯一一个能从 DLL 外部观测到的构建属性。修复之后,Brotli 配置产出与默认配置相同的哈希

PDFium Component 像素哈希对比:默认 Skia 渲染哈希 502D77C3711B4ACF,v3.123.0 之前的 BrotliEnabled 配置与显式 prpAgg 运行同哈希 F75B5EB4728ADE87,修复后的包装层经 FPDF_RenderPageSkia 导出把 prpDefault 解析回原 Skia 哈希
像素哈希抓得住截图藏住的东西:打开 Brotli 曾让每一页都用 AGG 渲染,修复后的默认配置与未动的配置一致

字体后端从没闹过同样的毛病。m_FontLibraryType 从 version 5 起被读,它的零值 FPDF_FONTBACKENDTYPE_FREETYPE 恰好也是字段完全不被读时 PDFium 的默认。所以对 pfbpDefault 写 FreeType 正好复刻原生默认。零值不一定错,只是从来不会自动对

有了 v3.123.0 或更新版本,你自然会写的那段启动代码终于言行一致:

uses
  PDFium;

procedure ConfigurePdfiumAtStartup;
var
  Config: TPdfLibraryConfiguration;
begin
  // 必须在任何东西加载原生库之前运行
  Config := TPdfLibraryConfiguration.Default;
  Config.BrotliEnabled := True;   // 把 FPDF_LIBRARY_CONFIG 提到 version 6
  // Renderer 保持 prpDefault:导出 FPDF_RenderPageSkia 的构建解析为 Skia,
  // 仅 AGG 构建解析为 AGG
  SetLength(Config.UserFontPaths, 1);
  Config.UserFontPaths[0] := 'C:\ProgramData\MyApp\Fonts';
  ConfigurePdfLibrary(Config);
end;

记住 BrotliEnabled 只有在 DLL 本身以 PDF_ENABLE_BROTLI 构建时,才能让 PDF 2.0 的 /BrotliDecode 流变得可解。标志是请求,在不支持 Brotli 的构建上没有任何效果。TPdfLibraryConfiguration.Hardened 与 Default 相同,只是 AllowMachineTime 为 False,挡住文档 JavaScript 读真实时钟;作为服务端处理不可信文件的起点很合适

要了 DLL 里没有的后端会怎样?

构建里缺渲染器或字体后端时,PDFium 不返回错误:FPDF_InitLibraryWithConfig 会挂掉一个原生 CHECK,在 Windows 上表现为断点异常,调用周围没有结构化异常处理就终止进程。头文件把话挑明了,警告不支持的值「will similarly fail with an immediate crash」

两个具体情形:仅 AGG 构建收到 FPDF_RENDERERTYPE_SKIA,无 Fontations 构建收到 FPDF_FONTBACKENDTYPE_FONTATIONS。随附的 Skia 运行时属于第二类:它用 Skia 渲染、字体却用 FreeType。对它同时要 prpSkia 加 pfbpFontations,Delphi 这边得到 External exception 80000003。调试器或异常处理器碰巧接住时,局面照样无可挽回:

  • PDFium 处于半初始化状态
  • 进程级配置已经封印,ConfigurePdfLibrary 拒绝修正后的配置
  • 同一进程内换配置重试已无可能

这是与 Brotli bug 相反的失败。那边字段里坐着一个没人选的值,PDFium 无声接受;这边字段里是调用方刻意选的值,PDFium 连商量都不给。两者都是包装层必须在原生调用之前解决的问题,因为调用之后已无可捕获之物

PDFium Component 如何预检 Skia 与 Fontations

从 v3.125.0 起,LoadLibrary 在绑定 DLL 导出之后、调用 FPDF_InitLibraryWithConfig 之前校验配置,把不支持的渲染器或字体后端变成一条 EPdfError,消息点名肇事的设置和可选替代。DLL 被卸载、配置解除封印,调用方可以另选设置再次加载

判定本身住在纯函数 PdfLibraryConfigurationSupportError 里:输入配置加两个描述构建的布尔值,组合安全时返回空串。因为它不碰任何原生状态,你可以在自己的测试里用任意能力组合调它。LoadLibrary 内部那两个布尔值来自不同种类的证据,信任等级也理应不同:

  • Skia 由 FPDF_RenderPageSkia 导出的在场与否判定,与 PdfNativeRendererType 用的是同一信号。导出与 Skia 渲染器在同一条件下编译,检查因此精确
  • Fontations 没有自己的导出。它留下的唯一痕迹是被拽进二进制的 Rust 字体 crate,所以 PDFium Component 在加载的库文件里扫 crate 名 skrifa 和 read-fonts(也作 read_fonts)。扫描只在请求 pfbpFontations 时进行,读不了的文件按「无 Fontations」计

Fontations 检查是启发式的,它只可能往一个方向出错:被剥光这些字符串的 Fontations 构建会被拒之门外,尽管它本可以工作。这个取舍是故意的。假拒成本是你捕获一个异常、回落 FreeType;假受成本是整个进程

解封与检查同样要紧。LoadLibrary 在加载最开头就封印配置,没有重置的话,一次能力拒绝会让 ConfigurePdfLibrary 对每次重试都回以 EPdfError「PDFium library configuration is already sealed」。拒绝路径先调 UnloadLibrary;此刻调 FPDF_DestroyLibrary 是安全的,因为 PDFium 尚未初始化、立即返回。其他加载失败——比如 DLL 缺失或架构不匹配——保持封印,所以重试循环必须分清两者:

uses
  SysUtils, PDFium;

function StartPdfiumPreferringSkia: TPdfRendererPreference;
var
  Config: TPdfLibraryConfiguration;
begin
  Config := TPdfLibraryConfiguration.Default;
  Config.Renderer := prpSkia;
  ConfigurePdfLibrary(Config);
  try
    PDFium.LoadLibrary;   // 带单元限定:Windows.LoadLibrary 同名
    Result := prpSkia;
  except
    on E: EPdfError do
    begin
      // 能力拒绝会卸载 DLL 并解除配置封印。
      // 根本没加载成的 DLL 保持封印:重试无济于事
      if PdfLibraryConfigurationSealed then
        raise;
      Config.Renderer := prpAgg;
      ConfigurePdfLibrary(Config);
      PDFium.LoadLibrary;
      Result := prpAgg;
    end;
  end;
end;

注意显式的 PDFium.LoadLibrary。在同时 uses Windows 或 Winapi.Windows 的单元里,不带限定的 LoadLibrary 解析到 uses 子句里最后出现的那个单元;轮到 Win32 函数时,无参调用编不过,报一个与 PDFium 毫无关系的参数个数错误

PDFium Component 的 LoadLibrary 预检流程:ConfigurePdfLibrary 封印配置,能力检查测试 FPDF_RenderPageSkia 导出与 skrifa 字符串证据,不支持的请求抛可捕获的 EPdfError 并解封以待重试,而根本没加载的 DLL 让 PdfLibraryConfigurationSealed 保持 true
校验在导出绑定之后、初始化之前进行,缺后端于是以能捕获的 EPdfError 失败,而不是以弄死进程的原生 CHECK

更早一步的校验

ConfigurePdfLibrary 在任何 DLL 登场之前就拒绝某些组合,一律抛 EPdfError。显式 FontBackend(含 pfbpFreeType)要求 Renderer = prpSkia,因为 PDFium 只对 Skia 渲染器过问字体后端。IsolatePerDocument 要求 V8Isolate 为 nil,因为 PDFium 按文档自建 isolate,你再塞一个就挂原生 CHECK。UserFontPaths 里的空字符串被拒。首次加载尝试之后的任何调用都以「PDFium library configuration is already sealed」失败

最后这条规则有个现实后果:你不能先探 DLL 再配它。GetSkiaRenderCapabilities、V8FeaturesAvailable、打开文档和大多数其他入口在内部调 LoadLibrary,当场封印配置。之后再调 UnloadLibrary 也不会重开封印。先配置、再加载、后提问——诊断例程正该按这个顺序来:

uses
  SysUtils, PDFium, FPdfView;

function DescribePdfiumState: string;
var
  Config: TPdfLibraryConfiguration;
  Renderer: string;
begin
  Config := GetPdfLibraryConfiguration;   // 一份副本,检视无虞
  if not PDFium.Loaded then
  begin
    if PdfLibraryConfigurationSealed then
      Exit('PDFium failed to load; configuration is sealed');
    Exit('PDFium not loaded; configuration can still change');
  end;
  // 与 LoadLibrary 构建 FPDF_LIBRARY_CONFIG 时相同的解析
  if PdfNativeRendererType(Config.Renderer,
    GetSkiaRenderCapabilities.PageRender) = FPDF_RENDERERTYPE_SKIA then
    Renderer := 'Skia'
  else
    Renderer := 'AGG';
  Result := Format('Renderer=%s Brotli=%s IsolatePerDocument=%s',
    [Renderer, BoolToStr(Config.BrotliEnabled, True),
     BoolToStr(Config.IsolatePerDocument, True)]);
end;

启动时记一行这种日志很便宜,而工单里写「服务器上文字看着不一样」时,你第一想要的就是它。PDFium.Loaded 带限定出于与 LoadLibrary 相同的理由:在窗体或组件方法内部,光杆 Loaded 会绑到 TComponent.Loaded

带版本的 C 配置结构出错的两种方式

每个带版本的配置结构——无论是 FPDF_LIBRARY_CONFIG、Win32 的 cbSize 记录还是插件 ABI——都以两种对称的方式出错,包装层必须两头都防。其一:填了字段、版本却太低;其二:抬了版本、字段却留着被库读成刻意选择的零值

  1. 字段设了、版本太低。往 version 2 结构里写 m_BrotliEnabled = 1,PDFium 看都不会看。调用成功,Brotli 流依旧解不开。防御之道是从实际用到的字段推导版本——LoadLibrary 正是这么做的——而不是写死一个
  2. 版本够了、零值有意义。版本抬到 6,到 version 6 为止的每个字段全部生效。FillChar 把 m_RendererType 清成 FPDF_RENDERERTYPE_AGG——一个真实渲染器,不是「未设置」。防御之道是给所选版本覆盖的每个字段都写上有意图的值,并对照真实构建解析「默认」,而非想当然

对可能让被调方崩溃的值,还有第三条规则:调用之前对照二进制实际能做什么来校验,用拿得到的最强证据,并在代码和文档里如实说明该证据是启发式。导出符号是证明。字符串表里的 crate 名是不错的猜测

速查:PDFium Component 库配置

  • 在任何东西加载 DLL 之前调一次 ConfigurePdfLibrary;任何能力查询或文档加载都会封印它
  • 设了 BrotliEnabled 或 IsolatePerDocument、并期待随附运行时产出 Skia 输出的,升级到 v3.123.0 或更新
  • 没有特定光栅化器需求就让 Renderer 留在 prpDefault;现在在任何结构版本下它都解析为构建默认
  • 拿 PdfNativeRendererType 配 GetSkiaRenderCapabilities.PageRender 记录实际生效的渲染器
  • v3.125.0 起,仅 AGG DLL 上要 prpSkia、非 Fontations DLL 上要 pfbpFontations,等来的是 EPdfError 而非崩溃
  • 能力拒绝之后 PdfLibraryConfigurationSealed 为 False、可重新配置;DLL 加载失败之后保持 True
  • 把 Fontations 检测当启发式对待,备好 FreeType 回落
  • PDFium.LoadLibrary 和 PDFium.Loaded 带单元名写,避开 Win32 与 TComponent 的同名冲突

要是 DLL 在配置还没出场时就加载失败,从诊断 Delphi 里的 PDFium DLL 加载失败入手;组件在各平台上怎么找到正确的二进制,见在任意目标上加载 PDFium 原生库。渲染器落定之后,渲染缓存与平滑缩放战术讲怎么让查看器的页面渲染保持快

PDFium Component 为 Delphi 和 C++Builder 包装 PDFium 引擎,带着上面这类配置检查,让原生初始化以你能处理的 Pascal 异常失败,而不是进程退出。产品详情与下载见 PDFium Component for Delphi 产品页