技术文章

Free Pascal 与 Lazarus 上的 HotPDF:Win64 支持边界

对那张支持工单的简短回答是:能,但有限制。HotPDF 2.730.0 在 Free Pascal 3.2.2 和 Lazarus 4.6 上为 Win64 构建,核心的创建、加载、保存路径可用。不随行的,是任何落在静态链接原生编解码目标文件或 Delphi 匿名方法上的东西

这个问题通常以同一种方式到来:一个团队为跨平台工具标准化到 Lazarus,或接手了一个 Free Pascal 代码库,想要他们已为 Delphi 购买授权的同一个 PDF 组件。移植一个成熟的 Delphi 库很少是语法问题。有意思的是移植暴露了库曾悄悄耦合在单一工具链的哪些位置,而在这个案例里耦合坐在两个非常具体的地方:捆绑编解码器的目标文件 ABI,以及藏在一个版本符号背后的编译器特性

能力矩阵对比 HotPDF 的 Delphi 构建与 Free Pascal 3.2.2、Lazarus 4.6 Win64 构建,显示哪些文档路径共享,哪些编解码、压缩、并行渲染与匿名方法 API 走到抛异常的存根
核心的创建、加载、保存路径在两个构建上完全一致,差距全部落在静态链接的编解码器和匿名方法 API 上

HPDFDoc 编译之前,Free Pascal 3.2.2 需要什么

HotPDF 只在 Delphi 模式下、且 Lazarus LCL 单元目录在搜索路径上时才在 Free Pascal 下编译。两条都不可谈判。HotPDF.inc 在其 {$IFDEF FPC} 块内用 {$MODE DELPHI}{$H+} 切换编译器,并在 FPC_FULLVERSION 低于 30202 时以 {$FATAL} 拒绝任何更老的版本,于是 3.0.x 安装会响亮地失败,而不是产出一个坏单元。Lazarus 运行时包 HotPDFLaz.lpk 编码了其余要求:LCL 作为必需包,-Mdelphi 作为自定义选项

LCL 要求让只想要控制台输出的人意外,但它是结构性的。HPDFFPCCompat 提供 Free Pascal 没有对等物的 Delphi VCL 类型,把 TMetafileTMetafileCanvas 映射到 LCL 位图和画布类,把 TRichEdit 别名为 TMemo,而 HPDFDocTPNGObject 别名为 Graphics.TPortableNetworkGraphic。把这些当作编译期垫片,不是功能对等:一个由位图背书的图元文件类让单元能编译,并不会让图元文件路径表现得像在 Delphi 上那样。连非 GUI 冒烟测试都会拉入 Interfaces,构建脚本为 lcl\units\x86_64-win64 和 lazutils 输出目录传 -Fu

为什么 D2009+ 不能兼任版本闸门

把 Free Pascal 构建当现代编译器、直接定义最新的 Delphi 特性符号是诱人的。HotPDF 不这么做,理由值得明说:D2009+ 不只意味着 Unicode 字符串,它还门控着公开 API 以匿名方法表达的那些单元。Free Pascal 3.2.2 既不支持 Delphi 匿名方法,也不支持那些 API,借用这个符号会拖进编译不了的代码。因此 HPDFDoc 的 uses 子句带两条独立的条件尾巴,它们之间的重叠是刻意的而非巧合

uses
  // ...
  HPDFJavaScript,
  HPDFFormCalcGraph
{$IFDEF FPC}
  , HPDFFPCCodecStubs,
  HPDFCMS,
  HPDFWinCertSigner
{$ENDIF}
{$IFDEF D2009+}
  , HPDFXFARuntime,
  HPDFCMS,
  HPDFWinCertSigner,
  HPDFSignVerify,
  HPDFSignatureBatch
{$ENDIF};

为什么原生编解码器止步于链接器?

因为它们是某一个特定工具链产出的 Win64 COFF 目标文件,而 Win64 上两个 Free Pascal 链接器都不消费它们:内部链接器不行,外部 GNU ld 路径也不行。这是目标文件 ABI 问题,不是 Pascal 问题,多少条件源码都修不了。库走了唯一诚实的路。每个拉入静态编解码目标文件的 {$L} 指令都包在 {$IFNDEF FPC} 里,于是 Free Pascal 构建直接略过它们,HPDFFPCCodecStubs 再把每个缺失的外部符号补成一个只抛异常不返回的存根

// HPDFFPCCodecStubs.pas
function HPDFFPCNativeCodecUnavailable: PtrUInt;
begin
  raise ENotSupportedException.Create(
    'This native codec is not available in the Free Pascal build');
end;

function HPDFFPCStub_deflate: PtrUInt; cdecl;
  public name 'deflate';
begin
  Result := HPDFFPCNativeCodecUnavailable;
end;

那张存根表很长,读一遍就知道今天哪些能力仅限 Delphi:zlib-ng 和 zopfli 的 deflate 入口点、libjpeg 压缩与解压、OpenJPEG JPEG 2000 编解码器、libtiff 及其按压缩类型的初始化器、JBIG2 编码与解码、Little-CMS 颜色变换入口点、AES 原语。存根背后的设计选择比清单更重要。链接期缺符号会给你一面来自你从没碰过的单元的未定义引用之墙;抛 ENotSupportedException 的存根给你一个能跑的构建、一条指名原因的消息、一个指向调用点的栈回溯。它还意味着 Free Pascal 构建绝不会在 Delphi 构建产出正确字节的地方悄悄产出错误字节。二阶效应也值得注意:在隔离进程中运行不可信图像编解码器是一个只在 Delphi 构建上才会出现的决策,因为 Free Pascal 构建首先就没有可供沙箱的进程内原生解码器

Delphi 上 HotPDF 的静态编解码目标文件链接进来原生运行,而 Free Pascal Win64 构建跳过链接指令,把每个缺失外部符号路由到一个在调用点抛具名异常的存根
跳过链接指令并为每个外部符号打存根,把一面未定义引用之墙变成一个能运行、能报出自己边界的构建

压缩:要改的第一行是 cmNone

移植任何其他东西之前,先把 Compression 设为 cmNoneTHPDFCompressionMethod 恰好提供两个值,cmNonecmFlateDecode,第二个直接路由进 Free Pascal 构建中是存根的 deflate 入口点。先关掉压缩验证核心对象模型,再决定你还需要什么。这也是随附冒烟测试的顺序:创建一个一页未压缩文档,重新加载,断言页数回来是一。未压缩输出更大,但它仍是完全有效的 PDF

program HotPDFLazarusSmoke;

{$mode delphi}
{$H+}

uses
  Interfaces, SysUtils, HPDFDoc;

var
  Pdf, Reloaded: THotPDF;
  OutputFile: string;
  PageCount: Integer;
begin
  OutputFile := IncludeTrailingPathDelimiter(GetTempDir) +
    'HotPDF-FPC-Smoke.pdf';
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := OutputFile;
    Pdf.Compression := cmNone;   // cmFlateDecode 会碰到存根符号
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(72, 72, 0, 'HotPDF Free Pascal smoke test');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;

  Reloaded := THotPDF.Create(nil);
  try
    PageCount := Reloaded.LoadFromFile(OutputFile);
    if PageCount <> 1 then
      raise Exception.CreateFmt('Expected one page, got %d', [PageCount]);
  finally
    Reloaded.Free;
  end;
end.

并行页面渲染会怎样?

它仍能编译,仍返回正确位图,只是不再并行。THotPDF.RenderLoadedPagesParallelTHotPDF.RenderLoadedPagesParallelOrdered 建立在 TThread.CreateAnonymousThread 加内联 procedure 闭包之上,Free Pascal 3.2.2 表达不了,所以 Free Pascal 分支跑确定性的串行回退:按顺序走页面索引,对每个调 RenderLoadedPageToBitmap,数成功数。API 形状、返回值和输出数组都不变,这正是单一代码库能两种构建的原因

同一个 HotPDF 并行渲染调用在 Delphi 下跑在重叠的工作线程上,在 Free Pascal 下按索引串行走页面,管线信息记录如实报告工作线程数为一,而不是掩盖回退
Free Pascal 分支保持 API 形状和输出数组,同时报告工作线程数为一,于是已经在读信息记录的代码看到的是真相
var
  Bitmaps: THPDFBitmapArray;
  Info: THPDFParallelRenderPipelineInfo;
  Rendered: Integer;
begin
  Rendered := Pdf.RenderLoadedPagesParallel([0, 1, 2, 3], 150, 4,
    Bitmaps, Info);
  // Delphi:Info.WorkerCount 取决于内存预算允许多少
  // Free Pascal:Info.WorkerCount 恒为 1,页面按索引顺序
  if Info.WorkerCount = 1 then
    LogSerialFallback(Rendered, Info.RequestedWorkerCount);

回退不是静默的,这正是值得围绕它设计的部分。它诚实地填 THPDFParallelRenderPipelineInfoPageCount 来自请求,RequestedWorkerCount 回显你要的数,WorkerCount 设为 1,完成数和交付数与实际回来的一致。已经在查看 Info 来给进度条或内存预算定尺寸的代码继续工作,读到的是真相而不是假设。如果你的吞吐计划押在并行渲染管线及其背压模型上,那是个 Delphi 计划;在 Free Pascal 上,按把一页渲染成位图的单线程成本乘以页数做预算

实际该交付哪个构建?

按能力选,不按偏好选。如果你的工作流是文档组装、文本和矢量绘制、表单填充、加载保存,Win64 上的 Free Pascal 构建覆盖得了,但你应该先在关掉压缩的情况下验证,再打开任何开关。如果涉及 JPEG、JPEG 2000、TIFF、JBIG2 图像、ICC 颜色变换、压缩输出,或依赖多核的吞吐,暂时留在 Delphi 或 C++Builder。边界由一个目标文件 ABI 和一个缺失的语言特性画出,两者都在源码里可见而不是埋在支持矩阵里,两者都以具名错误失败而不是以错误结果失败

Free Pascal 和 Lazarus 包与 Delphi、C++Builder 单元在同一发行包中交付,于是一份授权覆盖两边,你可以在承诺之前先拿自己的文档测试 Lazarus 路径;HotPDF Delphi PDF 组件产品页载有当前编译器支持矩阵和完整 API 参考