技术文章

在 Delphi 中使用 PDFium 组件操作 PDF 附件:读取、添加、删除

PDF 文件附件存储在文档的嵌入文件树中,这是一种大多数查看器都作为曲别针面板或附件侧边栏呈现出来的结构;从 Delphi 代码中,PDFium 组件通过 TPdf 上的一小组索引属性公开了该树:您可以通过整数索引进行迭代、读取名称 and 字节负载、创建新槽以及删除现有槽;API 表面很窄,在为其编写 production 代码之前,仅有少数排序约束和一个清理规则值得了解

从打开的文档中读取附件

AttachmentCount 极大地给出了文档声明的嵌入文件数量;它直接从 PDFium 的底层调用中读取,因此仅反映了 PDF 实际包含的内容;从那里开始, AttachmentName[Index] 将显示名称作为 WString 返回,而 Attachment[Index] 将原始字节作为 TBytes 数组递交;两者都是从零开始的;在您查询这两个属性之前,文档必须是打开的(Pdf.Active = True);在关闭的文档上调用它们会给您零或空白的结果且没有异常

有一点要记住: Attachment[Index] 在每次读取时都会分配并返回完整的文件负载;对于携带大型嵌入资产的文档,通过迭代所有附件来构建显示列表意味着每次调用都要付出该分配开销;如果您仅出于显示目的需要名称,请首先读取 AttachmentName 并将字节获取推迟到用户实际请求文件时

procedure ListAttachments(Pdf: TPdf);
var
  I: Integer;
  Data: TBytes;
begin
  if not Pdf.Active then
    Exit;

  for I := 0 to Pdf.AttachmentCount - 1 do
  begin
    Data := Pdf.Attachment[I];
    Writeln(Format('%d: %s (%d bytes)',
      [I, Pdf.AttachmentName[I], Length(Data)]));
  end;
end;

将附件提取到磁盘

没有 SaveAttachment 辅助函数;您读取这些字节并将其写入您需要的任何地方,这使路径构建和清理完全落在您的代码上;当附件名称来自不受信任的文档时,这一点很重要;PDF 附件名称是存储在文件内部的字符串,它们可以包含路径分隔符、相似的 Unicode 字符以及其他如果直接传递给 TFileStream.Create 将产生意外结果的字符;在构建任何输出路径之前,始终通过 ExtractFileName 过滤名称,并考虑拒绝以点开头或包含系统预期之外的字符的名称

Attachment[Index] 返回的字节数组由调用者拥有;使用普通的 TFileStream 将其写出,它就是您的了,您可以按您的喜好进行处理,包括检查负载的前几个字节以验证实际的文件格式,而不是相信声明的名称

procedure ExtractAttachment(Pdf: TPdf; Index: Integer; const OutputDir: string);
var
  SafeName: string;
  OutPath: string;
  Data: TBytes;
  FS: TFileStream;
begin
  SafeName := ExtractFileName(Pdf.AttachmentName[Index]);
  if SafeName = '' then
    SafeName := Format('attachment_%d', [Index]);

  OutPath := IncludeTrailingPathDelimiter(OutputDir) + SafeName;
  Data := Pdf.Attachment[Index];

  FS := TFileStream.Create(OutPath, fmCreate);
  try
    if Length(Data) > 0 then
      FS.WriteBuffer(Data[0], Length(Data));
  finally
    FS.Free;
  end;
end;

添加附件和两步写入

创建附件需要两次调用,而不是一次; CreateAttachment(Name) 在嵌入文件树中注册一个新槽,并在成功时返回 True;该槽开始时是空的;然后您通过写入 Attachment[AttachmentCount - 1] (指向最近创建的条目)来分配负载;如果 CreateAttachment 返回 False,则该槽未被创建,赋值操作将损坏碰巧是最后一个的索引处的附件

修改附件列表后,更改仅保留在内存中;调用 SaveAs 以写入带有更新的嵌入文件树的新文件;PDFium 组件目前不支持保存回当前打开的同一个文件,因为引擎持有对源文件的读取句柄;就地更新的标准模式是保存到临时路径、关闭文档、删除或重命名原始文件,然后将临时文件重命名就位并重新打开

procedure AddFileAttachment(Pdf: TPdf; const FilePath: string);
var
  FS: TFileStream;
  Data: TBytes;
  AttachName: string;
begin
  if not Pdf.Active then
    Exit;

  FS := TFileStream.Create(FilePath, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Data, FS.Size);
    if FS.Size > 0 then
      FS.ReadBuffer(Data[0], FS.Size);
  finally
    FS.Free;
  end;

  AttachName := ExtractFileName(FilePath);
  if Pdf.CreateAttachment(AttachName) then
    Pdf.Attachment[Pdf.AttachmentCount - 1] := Data;
end;

附件类型信息

除了名称和字节负载, AttachmentType[Index] 还会返回存储在 PDF 嵌入文件字典中的 MIME 类型字符串(如果在最初附加文件时记录了该类型);许多生成器将此字段留空,或将其设置为像 application/octet-stream 这样的通用值,因此您无法在生产流水线中依赖它来进行格式检测;对于可靠的标识,请读取负载的前几个字节并检查已知的文件签名:嵌套 PDF 对应 %PDF,Office Open XML 文档对应 ZIP 本地文件头 PK\x03\x04,传统复合文件二进制对应 \xD0\xCF\x11\xE0;来自字典的类型信息呈现在 UI 标签中是可以的,但在您有实际字节可用时不应驱动处理决策

删除附件

DeleteAttachment(Index) 移除该位置的条目,并在成功时返回 True;删除后,其余条目向下移动,因此如果您在循环中删除多个附件,则必须从最后一个索引向下迭代,而不是向前迭代,以避免在每次移动后跳过条目;在您调用 SaveAs 之前,更改都在内存中

在文档处理流水线中,一个常见的场景是在将传入的 PDF 传递到下游之前剥离所有附件(出于安全或大小原因);在循环前计数一次,然后反向迭代:

procedure StripAllAttachments(Pdf: TPdf);
var
  I: Integer;
begin
  for I := Pdf.AttachmentCount - 1 downto 0 do
    Pdf.DeleteAttachment(I);
end;

PDF 附件在实践中出现在哪里

附件 API 适用于 PDFium 可以打开的任何 PDF,但是您实际遇到嵌入文件的文档集中在几个特定案例中;PDF/A-3 (ISO 19005-3) 显式允许符合规范的嵌入文件,作为将源数据与归档呈现打包在一起的机制;ZUGFeRD 和 Factur-X 电子发票正是依赖这一点将结构化 XML 负载嵌入到人类可读的 PDF 布局中;源自电子邮件的 PDF 有时会携带其转发到嵌入文件树中的原始邮件附件;源自结构化创作系统的技术文档偶尔也会以同样的方式捆绑支持性资产

当您的应用程序处理来自组织外部的入站 PDF时,作为文档接收的一部分检查 AttachmentCount 是值得做的,这出于两个独立的原因;首先,嵌入的文件可能携带您想要提取和处理的数据,例如发票 PDF 内部 the XML;其次,嵌入文件可以携带任意的可执行内容,因此即使您从未打算提取它,了解存在什么内容也是很重要的;这两个原因都不需要您做任何复杂的事情:读取计数、检查名称并决定如何处理这些字节

这里显示的附件属性是适用于 Delphi 和 C++Builder 的 PDFium Component 的一部分