技术文章

JBIG2 编码器后端与 Free Pascal 链接器

PDFlibPas 可以通过两个不同的后端把双值图像编码为 JBIG2。一个是始终存在的原生 Object Pascal MMR 编码器。另一个是外部符号字典编码器,在扫描文本上产出明显更小的文件,但它是可选的:项目必须链接后端单元它才存在。这个区别正是此功能最常见的意外来源,所以值得首先说明:DefaultJBIG2EncodeOptions 默认请求外部编码器,而后端单元未链接时,该请求会静默回落到 Pascal MMR 路径

在 Delphi 和 C++Builder 上,外部后端是一组预构建的静态目标文件。在 Free Pascal 上它不得不变成 DLL,而得出这个结论的过程是一段链接器故事,对任何尝试把 C++ 目标文件链接进 Free Pascal 程序的人都有用

注册即契约

后端单元在自己的初始化节里调用 RegisterJBIG2EncoderBackend 完成注册。调用方通过选项位 PDF_JBIG2_OPTION_EXTERNAL_ENCODER(值为 4)或扩展图像入口的 UseExternalEncoder 参数来请求它。库的伞单元刻意不引入后端单元,因为是否背上一大组目标文件应由每个项目自行决定;例如在 C++Builder 工程树中,需要它的项目显式 include 它

对调用方的后果是:请求外部编码器只是偏好而非保证,忘记链接单元的构建产出更大的文件而不是报错。如果输出大小重要到你都开口要更好的编码器了,那它也重要到值得检查你是否真的拿到了

PDFlibPas 的 JBIG2 编码请求流程:缺少后端单元时,外部编码器偏好静默回落到原生 Pascal MMR 路径
请求外部符号字典编码器只是一种偏好:链接了输出变小;没链接则静默走 Pascal MMR 路径,文件更大
uses
  PDFlibrary,
{$IFDEF FPC}
  PDFlibJBIG2EncDLL;    // Free Pascal 的动态后端
{$ELSE}
  PDFlibJBIG2EncC;      // Delphi / C++Builder 的静态目标文件集
{$ENDIF}

var
  Pdf: TPDFlib;
  ImageId: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.NewPage;
    // Interpolate、SymbolExtract、UseExternalEncoder、SkipBlackDots、
    // BlackDotSize、LossyLevel
    ImageId := Pdf.AddImageJBIG2FromFileEx('scan-page-1.tif',
      0, 1, 1, 0, 0, 0);
    if ImageId = 0 then
      raise Exception.Create('JBIG2 encoding failed');
    Pdf.SaveToFile('archive.pdf');
  finally
    Pdf.Free;
  end;
end;

编译单元只改两行,符号才是工作量

让后端单元本身在 Free Pascal 下编译通过只花了恰好两处改动:设置汇编方言,以及把基于记录的格式设置构造器换成全局默认变量。这相当公正地反映了朴素的 Pascal 在两个编译器之间的可移植程度

符号侧才是真正的工作。目标文件集引用了 176 个 C 符号。其中 128 个在单元内部已有 Pascal 实现,只需挂上导出名,因为 Delphi 用函数名充当符号名,而 Free Pascal 要求显式的 public name 声明。27 个与 JPEG 2000 编解码器共享,必须从恰好一处导出,因为定义两次会破坏任何同时链接两者的程序。其余 21 个是平台与 C 运行时入口——16 个 Win32 文件函数外加几个标准库调用——它们进了一个新的兼容单元

这些在概念上都不难,但每一件都是链接器愿意尝试之前的必要条件。而链接器正是止步之处

三条链接路线,三个死胡同

Free Pascal 内部链接器读不了这些目标文件,因为生成它们的编译器会产出关联式 COMDAT 节,内部链接器报告不支持。这是干脆的拒绝,不是警告

换外部链接器看似是答案。Free Pascal 自带的 binutils 链接器在对这个归档应用节垃圾回收时直接崩溃,而该标志是 Free Pascal 为 64 位 Windows 目标传递的固定参数集的一部分,无法从命令行移除;文档中记载的抑制开关在这条路径上被忽略。换成新得多的 binutils 则以另一种方式失败:它完全无法处理 Free Pascal 的链接脚本,不带脚本产出空输出,带脚本则是一片重定位错误之墙

过程中发现的一个边界值得知道,即使你永远碰不到那个链接器问题。外部链接器按可执行文件输出目录而非源码树来解析目标文件路径,因此相对路径的 include-object 指令只有在输出目录恰好等于编译期工作目录时才有效。库不能对使用者的项目做这种假设,这本身就足以成为优先选择链接库而非散装目标文件的理由

Free Pascal 下 C++ JBIG2 编码器目标文件的三条失败链接路线,以及通过暴露两个平面 C 入口的 DLL 解决问题
COMDAT 节挫败内部链接器,两个外部链接器也失败,因此 C++ 编码器以单个 DLL 交付,由后端单元动态绑定

为什么换一个 C++ 编译器也无济于事

下一个显而易见的想法是用一个 Free Pascal 能读其目标文件的编译器重建 C++ 侧。这同样行不通,而且原因是根本性的,不是开关问题。一个含模板的最小 C++ 翻译单元,即使关掉所有代码生成特性,仍会产出弱外部符号,因为模板与内联实例化在构造上就会产生它们。Free Pascal 直接拒绝该符号类别。反方向同样失败:主流 C++ 链接器出于同样的 COMDAT 节处理方式,也无法消化另一编译器的目标文件

因此,无论走哪条现有路线,C++ 代码都无法以目标文件形式交付给 Free Pascal。但可以以 DLL 交付,事实也正是如此:编码器与其图像处理依赖被构建成一个暴露两个平面 C 入口的库,Free Pascal 后端单元动态绑定它们,并像静态后端一样完成注册。Delphi 与 C++Builder 路径完全未动,这是正确的结果;一条工具链上的可移植性问题不应扰动已经正常的另一条

极性是唯一会咬你一口的地方

Windows 双值位图与 JBIG2 编码器之间有一个任何类型系统都抓不到的约定错位:每像素 1 位的 DIB 扫描行把置位当作白色,编码器把置位当作黑色。原样交出扫描行,你会得到一幅页面照相底片的完全合法的 JBIG2 流

1 位 DIB 与 JBIG2 的极性约定:置位在扫描行中是白色、在编码器中是黑色,逐字节取反解决
相同字节,相反含义:不逐字节取反,编码器会产出底片效果的有效 JBIG2 流
// 1 位 DIB:置位表示白色。JBIG2 编码器:置位表示
// 黑色。送入前逐字节取反
for I := 0 to RowBytes - 1 do
  Row[I] := Row[I] xor $FF;

验证方法与修复同样重要。比较压缩流长度说明不了任何问题,因为负片图像压缩后大小相近。看一眼页面只能证明它没有明显反色。可靠的检查是把两条编码路径——原生 Pascal 与外部——的输出渲染成 PNG 再逐字节比较:两个编码器对同一源图像都是无损的,所以除完全一致之外的任何差异都是其中之一的 bug。这个比较现在是一个永久回归测试;凡是两个实现被要求完全一致的场景,都值得建这类断言

该用哪个后端

对一般双值内容——抖动半调、线稿、混合图形——原生 Pascal MMR 编码器足够用,且无部署成本。对扫描文本,即 JBIG2 的设计目标场景,外部符号字典编码器才是体积缩减所在,因为它把重复的字形形状因子化进字典,而不是每次出现都重新编码。如果你在生成扫描文档的归档,这个差异大到足以改变存储规划

上游问题——双值图像最初是如何产出的——对输出大小同样关键;基于区域的单色渲染见单色区域渲染一文,文档级体积策略见PDF 文件体积优化与字体子集化。对含重复页面的扫描集合,去重往往胜过更好的压缩,感知图像去重讲的正是这个。各平台的工具链与后端可用性列在 losLab PDF Developer Library 产品页