技术文章

在 Delphi 中使用 PDFium 组件并排对比 PDF 文档

两份文档可同时打开并保持同一页码,每份文档显示在各自可滚动的面板里,这就是并排对比查看器的核心。PDFium 组件通过一个非常直接的对象模型提供这条能力:TPdf 持有文件对象,TPdfView 持有显示对象。每份文档对应一对 TPdfTPdfView。如果你想看三份文档,就用三对。真正需要处理的不是 API 调用本身,而是窗口尺寸变化时的布局计算,以及你选择哪个面板同步翻页时的逻辑

表单布局

窗体中放置三个并列 TScrollBox,每个里边都放一个 TPdfView,并将其 Align 设置为 alClient 以填满容器。在两个面板之间放置两个 TSplitter,便于用户在运行时拖拽调节列宽。工具栏位于面板上方,包含打开按钮、缩放控制和双列 / 三列切换

三列模式是窗体内部的布尔状态。切换时你需要重新计算每列宽度,并决定第三列是否可见。最稳妥的方式是:先清空所有 Align,暂时隐藏分隔条,再用绝对坐标设置三列布局

procedure TFormMain.UpdateLayout;
var
  TotalWidth: Integer;
begin
  TotalWidth := ClientWidth;

  if ThreeViewMode then
  begin
    ScrollBox3.Visible := True;
    ScrollBox1.Left   := 0;
    ScrollBox1.Width  := TotalWidth div 3;
    ScrollBox2.Left   := ScrollBox1.Width;
    ScrollBox2.Width  := TotalWidth div 3;
    ScrollBox3.Left   := ScrollBox2.Left + ScrollBox2.Width;
    ScrollBox3.Width  := TotalWidth - ScrollBox3.Left;
    // Apply the same (ClientHeight - toolbar height) to all three Height values
  end
  else
  begin
    ScrollBox3.Visible := False;
    ScrollBox1.Left   := 0;
    ScrollBox1.Width  := TotalWidth div 2;
    ScrollBox2.Left   := ScrollBox1.Width;
    ScrollBox2.Width  := TotalWidth - ScrollBox2.Left;
  end;
end;

在执行布局计算前先把三栏都改成 Align := alNone,避免 VCL 对齐约束与你的尺寸修改冲突。若你希望在两列模式下继续允许拖拽,可在定位完成后再显示分隔条

每个滚动框的高度应使用客户区高度减去工具栏高度。由于工具栏停靠在顶部(alTop),ClientHeight - PanelButtons.Height 即为可用高度,这个结果应在同一次 UpdateLayout 调用中写入所有三个框,以防止某一帧中出现高度不一致导致抖动

打开文档

每个面板对应一套打开逻辑。流程很短:先停用组件、设置文件名、再次激活,然后检测 Active。若返回 false,说明可能需要密码,再弹窗重试。需要注意 TPdfView.Active 只决定渲染是否运行,而真实文件打开由 TPdf.Active 负责,二者是独立状态。若底层 TPdf 仍未激活,就把 PdfView.Active := True 设置为 true 不会报错,但不会显示内容

procedure TFormMain.OpenPdfFile(PdfComponent: TPdf;
  PdfViewComponent: TPdfView);
var
  Password: string;
begin
  if not OpenDialog.Execute then
    Exit;

  PdfComponent.Active   := False;
  PdfComponent.FileName := OpenDialog.FileName;
  PdfComponent.Password := '';
  PdfComponent.Active   := True;

  // Load failures are silent: Active stays False instead of raising.
  if not PdfComponent.Active then
  begin
    // Most likely a password-protected file; give the user one retry.
    if InputQuery('Password', 'Enter document password:', Password) then
    begin
      PdfComponent.Password := Password;
      PdfComponent.Active   := True;
    end;
  end;

  if not PdfComponent.Active then
  begin
    ShowMessage('Could not open ' + OpenDialog.FileName +
      ' (damaged file or wrong password)');
    Exit;
  end;

  PdfViewComponent.PageNumber := 1;
  SetActivePdfView(PdfViewComponent);
end;

无论如何都应在赋值后检查 PdfComponent.Active。对于损坏或密码错误文件,默认流程会静默失败而不抛异常。成功打开后把 PdfViewComponent.PageNumber := 1 设定为 1,避免使用旧文档残留的页码

结尾弹窗的作用很明确:不应让损坏或不支持的文件被吞掉。若用户看到空白面板,无法判断是文档真的为空,还是组件拒绝加载,显式提示可避免误解

活动面板追踪

用户在某个面板内点击时,这个面板变为活动面板。窗体记录一个私有字段 FActivePdfView: TPdfView。给活动面板所在的 TScrollBox 边框设置 clHighlight,其他面板为 clWindow,可形成可见反馈。该逻辑要同时接入每个 TPdfView.OnClick 和打开流程,保证新打开文档后活动面板与焦点同步

有些命令应作用于全部可见面板,而不仅是当前活动面板。窗体布尔字段 FAllViewsMode 负责选择分支。为 true 时,缩放和翻页命令会分发给每个处于活动状态的面板

procedure TFormMain.ApplyZoomToAll(NewZoom: Double);
begin
  if PdfView1.Active then PdfView1.Zoom := NewZoom;
  if PdfView2.Active then PdfView2.Zoom := NewZoom;
  if ThreeViewMode and PdfView3.Active then PdfView3.Zoom := NewZoom;
end;

同步页码导航

同步导航对部分场景非常实用,尤其是多文档版本对照时页码区间一致。逻辑应放在源视图导航完成后触发的事件中。当一个源视图的 PageNumber 变化后,将该值传播到其它视图,但要先校验目标视图是否有足够页数,否则忽略它

TPdfView.PageNumberTPdf.PageNumber 是两个独立属性。前者表示界面当前显示页,后者表示组件内部追踪的页号。做导航同步时应使用视图属性而不是文档属性

“同步翻页”复选框可让用户选择是否联动。未勾选时每个面板独立翻页。该独立模式对页码不一致的文档非常重要,也适合用来对比不同语言版本中的同段内容,因为它们未必起始页一致。若强制同步,用户体验会变差

注意:在同步处理器中给 PdfView.PageNumber 赋值会再次触发该视图的 change 事件,必须用布尔标志在赋值前后包裹,防止循环触发。该标志应按窗体级管理,因为三视图共用同一事件处理器

面板独立缩放

每个 TPdfView 有自己的 Zoom,类型为 Double,其中 Zoom := 100 表示 100% 显示。赋值会覆盖当前生效的 FitMode。对于当前面板的“适应宽度”,可以读取 PdfView.PageWidthZoom[PdfView.PageNumber] 并赋回。适应页面则使用 PageZoom[PageNumber]。两者都是以页码为下标的数组属性,注意访问前页码不能为 0

导出当前页到图片时可从视图读取旋转值,但实际调用应是 TPdfRenderPage,不能在视图上直接处理。TPdf.RenderPage 会返回一个位图,并接收像素尺寸、TRotationTRenderOptions。其函数式重载返回的 TBitmap 归调用方所有,保存完成后必须由你释放

procedure TFormMain.SaveActiveViewAsImage;
var
  Pdf: TPdf;
  Bmp: TBitmap;
  Jpeg: TJpegImage;
begin
  if not Assigned(FActivePdfView) or not FActivePdfView.Active then
    Exit;

  Pdf := FActivePdfView.Pdf;
  Pdf.PageNumber := FActivePdfView.PageNumber;

  Bmp := Pdf.RenderPage(
    0, 0,
    Round(Pdf.PageWidth * 2),
    Round(Pdf.PageHeight * 2),
    FActivePdfView.Rotation, [], clWhite);
  try
    if SavePictureDialog.Execute then
    begin
      Jpeg := TJpegImage.Create;
      try
        Jpeg.Assign(Bmp);
        Jpeg.CompressionQuality := 90;
        Jpeg.SaveToFile(SavePictureDialog.FileName);
      finally
        Jpeg.Free;
      end;
    end;
  finally
    Bmp.Free;
  end;
end;

采用 2 倍缩放是为了提升文字较小文档的清晰度。try/finally 是必需的:即使用户取消 TSaveDialog,释放逻辑也会执行,避免位图资源泄漏

DLL 要求

PDFium Component 会加载原生 pdfium 库,32 位主进程使用 pdfium32.dll,64 位主进程使用 pdfium64.dll。启用 V8 引擎的版本会带 v8 后缀,体积约 23-27 MB;标准版本约 5-6 MB

将 DLL 与可执行文件放在同目录,或放在系统 PATH 可访问位置都可。组件在首个 TPdf 激活时按需加载 DLL,因此缺失通常在首次打开文档时出现而非程序启动时出现。若你使用安装包,建议在安装阶段将 DLL 写入应用目录,而非依赖管理员可能清理的系统目录

V8 版本主要适用于需要执行 PDF JavaScript 动作的场景,例如表单计算或提交处理。对只读比对查看器而言可将 Pdf.FormFill := False,这样就不会初始化 JS 引擎,即使加载标准 DLL 也可避免无意义的 JavaScript 开销。该策略同样适用于任何不需要编辑功能的只读页面查看器

更多 PDFium 组件与完整 API 说明请访问 Delphi PDFium Component 产品页

`r`n`r`n