技术文章

在 Delphi 中使用 PDFium 组件拆分 PDF 文档

PDFium 组件拆分 PDF 时提供了一个方法 ImportPages。无论你要抽取单页、按任意边界切分,还是按文档书签结构分段,核心都在于把哪些页放入每个输出文件里,执行机制保持一致,先学明白这一点就能少走很多弯路

拆分循环的工作方式

无论怎样拆分源文档,模式都一致。先创建一个新的 TPdf 实例,对它调用 CreateDocument 初始化空 PDF,再用 ImportPages 导入目标页面,调用 SaveAs 保存,然后在下次循环前把 Active 重置为 False。很多人会漏掉最后这步,CreateDocument 不会自动关闭内存中的旧文档,所以在再次调用前必须先保存并显式复位。外层复用同一个 TPdf 实例可以减少大任务下的分配开销

下面是按页拆分的精简示例

procedure SplitIntoPages(Source: TPdf; const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;

      // Range is a 1-based page number string; insertion point 1 = first position
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;   // reset before next CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

ImportPagesRange 参数使用 PDFium 的内部字符串格式:以英文逗号分隔的页码或以连字符表示范围,且都为 1-based。'3' 代表第 3 页,'1-5' 代表 1 到 5 页,'2,5,8' 代表 2、5、8 三页。第三个参数是目标文档中的插入位置,传入 1 会把导入内容放在空文档开头,这正是这里需要的行为

按页码范围拆分

当调用方给出像 1-12,13-24,25-36 这样的范围串时,把它切成起止对后复用同一循环即可

procedure SplitByRanges(Source: TPdf; const RangeList: array of string;
  const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(RangeList) do
    begin
      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

在进入 ImportPages 前做校验很有价值。若范围里出现超出 Source.PageCount 的页码,ImportPages 返回 False,不会抛异常,也不会自动产出可从文件名判断的明显错误文件。因此要检查 SaveAs 的返回并单独记录失败,否则空白输出不一定会被立刻发现

按书签边界拆分

第三种方式是直接使用文档内的书签结构。每个顶级书签都有目标页号,定义的区段从该页开始,到下一个书签页号减 1 结束;最后一个书签则延续到文档末尾

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  I, StartPage, EndPage: Integer;
  PdfOut: TPdf;
  RangeStr, OutFile, SafeTitle: string;
begin
  Bm := Source.Bookmarks;
  if Length(Bm) = 0 then
    Exit;

  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(Bm) do
    begin
      StartPage := Bm[I].PageNumber;
      if I < High(Bm) then
        EndPage := Bm[I + 1].PageNumber - 1
      else
        EndPage := Source.PageCount;

      if (StartPage < 1) or (EndPage < StartPage) then
        Continue;

      RangeStr := Format('%d-%d', [StartPage, EndPage]);

      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // skip a malformed section instead of writing an empty file
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

文档没有书签不应当视作错误,只说明该模式无效,可通过 Length(Bm) = 0 分支静默返回。真正该报告的问题是书签页码越界,这通常发生在文档删页后没有同步书签的异常文件中。StartPageEndPage 的边界检查就是为这类脏数据保留的

输出命名与 Active 重置

书签名经常包含在 PDF 字符串中合法但不适合做文件名的字符。至少要替换正斜杠、反斜杠和冒号,Windows 还要额外处理 *?"<>|,固定字符循环替换即可,无需借助正则

每次循环末尾的 Active := False 是关键约定。若 Active 在下一次 CreateDocument 之前仍是 True,上一次的输出文档虽然还在内存中,但未被可靠地关闭或保存,行为定义不明确,因此必须在每次循环间显式重置。把它视为 try/finally 的伙伴:后者负责释放外层对象,前者负责清理内层文档状态

这种模式下,拆分过程中只会同时持有一个输出文档,内存占用保持平稳。源文档始终保持打开且只读,ImportPages 将页面数据复制到新文档而不改写源。若源文档加密,先用密码打开它,拆分结果通常会是未加密的副本,方便分发给多个接收方

再强调一次 SaveAs。它返回 Boolean,例如输出目录不存在、路径包含非法字符或磁盘满都会返回 False 而不抛异常。批量拆分 200 页为 200 个文件时,某页静默失败很容易被忽略,最好每次记录返回值,并在结束时和预期总数核对

本文展示的 ImportPagesCreateDocument 均属于 PDFium 组件 的功能范围,支持 Delphi 与 C++Builder 使用