技术文章

TPdf.Active 静默失败与 TPdfLoadReport 加载报告

在 PDFium Component for Delphi and Lazarus 里,PDF 加载失败时给 TPdf.Active := True 赋值永远不会抛异常:TPdf.SetActive 捕获一切异常、让组件保持未激活。想看到真正的错误,请改调 TPdf.LoadDocument(Options, Report)。这个重载会重抛原始异常,并往 TPdfLoadReport 里填入加载状态、原生 PDFium 错误码,以及交叉引用表是否经历过重建

问题通常在批量代码里现形。一个表格抽取任务用一个共享 TPdf 遍历文件夹里的 13 份真实 PDF,结果 7 份报失败。这 7 个文件其实一个都没坏。加载周围的 except 块从未触发,日志把失败算到了错误的文件名头上,第一条可见错误是一条关于组件未激活的干巴巴的 EPdfError,从真正失败的那次加载之后好几行的一次属性读取里抛出。两个互不相干的行为叠加出这幅图景,而它们都按设计在工作

TPdf.Active := True 在加载失败时为什么不抛异常?

TPdf.SetActive 把 LoadDocument 包进一个 try..except,吞掉所有异常类,只让组件保持未激活。吞掉是刻意的:窗体设计器在 IDE 里切换 Active 时走的也是同一个 setter,坏路径不能把 IDE 带崩。运行时 TPdf.Active 只报告原生文档句柄是否存在,所以加载失败后它读出 False,其他什么也不发生。抛出去的东西就此消失——可能是解析器的 EPdfError、流错误,也可能是半绑定的 pdfium.dll 抛的 EAccessViolation。在 Delphi 中诊断 pdfium.dll 加载失败里那些详细的 DLL 消息,只有经由一个不吞异常的调用才能到达你的处理程序

PDFium Component 的两条加载路径:给 Active 赋 True 时 setter 吞掉一切异常、把失败推迟到第一个受保护调用——CheckActive 在那里抛出组件未激活的 EPdfError;而带 TPdfLoadOptions 与 TPdfLoadReport 的 LoadDocument 审计 header、startxref、xref 与文件尾标记,然后带着真实原因重抛原始异常
吞掉是刻意的,因为 IDE 设计器与批处理共用这个 setter;批量代码需要那个会抛异常、会报告、会讲清文件真实遭遇的重载
Pdf.FileName := FileName;
try
  Pdf.Active := True;       // SetActive 会吞掉一切加载异常
except
  on E: Exception do
    Log.Add(FileName + ': ' + E.Message);   // 永远不会执行
end;
// 失败反而在这里现形,成了一条干巴巴的 EPdfError:
// 'Cannot perform this operation on an inactive Pdf1 component'
Log.Add(Format('%s: %d pages', [FileName, Pdf.PageCount]));

// 既有代码的最小修复:赋值后立刻测试 Active;
// v3.122.1 起 LastLoadReport 保存被吞异常的文本
Pdf.Active := True;
if not Pdf.Active then
  Log.Add(FileName + ': load failed: ' + Pdf.LastLoadReport.ErrorMessage);

失败最终在第一个受保护调用处现形。TPdf.PageCount 和大多数文档属性一样,开头是 CheckActive,抛出的 EPdfError 点名组件,却不提文件、更不提原因。赋值后立刻测试 Pdf.Active,就能把一次错误归因的崩溃变成一条诚实的「失败」记录。PDFiumPas v3.122.1 之前,原因在这里丢失;v3.122.1 起,失败的赋值会用带错误文本的 plsFailed 报告替换 LastLoadReport,原因得以幸存。异常对象本身和字节级审计仍需要别的入口

为什么复用同一个 TPdf 从第二个文件起就失败?

TPdf.FileName 只能在组件未激活时赋值,所以共享实例在尝试加载第二个文件之前就把它拒了。TPdf.SetFileName 开头是 CheckInactive,同一个守卫也护着 Password 和 FormFill。第一次加载成功后实例保持激活,下一次赋值抛异常,如果批量循环接住这个异常继续走,错误就落在新文件名名下,而旧文档还开着。与被吞的加载失败混在一起,日志就和现实脱节了。在 13 文件复现里,共享实例报了 7 个失败,而每份文档新建 TPdf.Create(nil) 则 13 个全开。文件之间设 Active := False 也行,但一文档一实例让每个文件从构造上就彼此隔离

共享 PDFium TPdf 从第二个文件起失败的时间线:首次加载后实例保持激活,下一次 FileName 赋值在任何加载尝试之前就在 CheckInactive 抛出,批量循环把错误记在新文件名名下而旧文档仍开着——13 文件批次里 7 个假失败背后的陷阱
SetFileName 用 CheckInactive 把守,共享实例没试就把第二个文件拒了;每份文档配自己的 TPdf,日志重新对上现实

带 TPdfLoadReport 的 TPdf.LoadDocument 给你什么?

TPdf.LoadDocument(const Options: TPdfLoadOptions; out Report: TPdfLoadReport) 抛出真实异常,还以结构化形式告诉你发生了什么。文件重载加载 FileName;兄弟重载接收 TBytes 或指针加长度;流由 LoadCustomDocument(AStream, AOwnsStream, Options, Report) 覆盖。每个重载都先校验选项、检查实例未激活,对 header、startxref、xref 节和 %%EOF 标记做字节级审计,然后执行原生加载。审计的边界与不受信 PDF 的解析器资源预算里讨论的那类限制同源:TPdfLoadOptions.Default 把 AuditByteLimit 设为 256 MiB、MaxIssues 设为 256、MaxXrefSections 设为 1024、MaxXrefEntries 设为 4,000,000。失败时方法置 Report.Status := plsFailed 并重抛;因为 Report 是原地写入的,其内容挺得过异常,同时有一份拷贝存进 TPdf.LastLoadReport

报告字段回答的正是批量日志真正要问的问题。Status 取 plsNotAttempted、plsLoaded、plsLoadedWithRecovery、plsRejected 或 plsFailed 之一。NativeErrorCode 存 FPDF_GetLastError 的结果,于是 FPDF_ERR_PASSWORD(4)能把缺密码或密码错误,与报为 FPDF_ERR_FORMAT(3)的文件损坏区分开。UsedRecovery、CrossReferenceTableValid 和 RecoveryRoute 说明 PDFium 是否被迫重建了 xref 表;Issues 逐条列出审计发现,带 Code、Severity、Offset、ObjectNumber 和 MessageText,列表被 MaxIssues 截断时置 IssuesTruncated

PDFium Component 的 LoadDocument 管线与其 TPdfLoadReport:选项校验与未激活检查在任何报告存在之前就抛出;字节审计走过 header、startxref、xref 节与文件尾标记;原生加载记录 FPDF_GetLastError;结局分流为 loaded、xref 重建后带恢复的 loaded、严格拒收或失败
Status、NativeErrorCode 与 issue 列表回答批量日志要问的问题;只有带选项的重载才做字节审计;v3.122.1 起,失败的 Active := True 也会在 LastLoadReport 里记下 plsFailed
uses
  SysUtils, Classes, TypInfo, FPdfView, PDFium;

procedure ProcessBatch(Files, Log: TStrings);
var
  I: Integer;
  Pdf: TPdf;
  Options: TPdfLoadOptions;
  Report: TPdfLoadReport;
begin
  Options := TPdfLoadOptions.Default(plmCompatible);
  for I := 0 to Files.Count - 1 do
  begin
    Pdf := TPdf.Create(nil);          // 一份文档一个实例
    try
      Pdf.FileName := Files[I];
      try
        Pdf.LoadDocument(Options, Report);
      except
        on E: Exception do
        begin
          // 即使 LoadDocument 抛了异常,Report 也已填好
          if Report.NativeErrorCode = FPDF_ERR_PASSWORD then
            Log.Add(Files[I] + ': password required')
          else
            Log.Add(Format('%s: %s (%s)', [Files[I],
              GetEnumName(TypeInfo(TPdfLoadStatus), Ord(Report.Status)),
              E.Message]));
          Continue;
        end;
      end;
      if Report.UsedRecovery then
        Log.Add(Files[I] + ': opened after PDFium rebuilt the xref table');
      ExtractTables(Pdf, Log);
    finally
      Pdf.Free;
    end;
  end;
end;

什么时候该用 plmStrict 加载?

凡是「悄悄修复过的文件」比「被拒收的文件」更糟的场景——归档入库、证据处理、签名管线——都该用 plmStrict。PDFium 会靠扫描文件里的对象悄悄重建损坏的交叉引用表(ISO 32000-1 §7.5.4),这对阅读器是福音,对任何必须原样处理手头字节的东西是麻烦。原生加载之后,组件询问 FPDF_DocumentHasValidCrossReferenceTable。plmCompatible 模式下,重建产出 plsLoadedWithRecovery 加一条 plicNativeCrossReferenceRebuild 警告。plmStrict 模式下,组件卸载文档、置 plsRejected、加 plicStrictModeRejected,并抛出 "Strict PDF load rejected the document" 的 EPdfError。严格模式还拒收任何审计错误,TPdfLoadOptions.Default(plmStrict) 会打开 RequireFinalEndOfFileMarker,把缺失 %%EOF 或最后一个标记之后还有数据(§7.5.5)从警告升格为错误。xref 审计与用 PDFium VCL 校验对象流与 xref 流中的对象级检查互为补充

function AcceptForArchive(const FileName: string; out Reason: string): Boolean;
var
  Pdf: TPdf;
  Report: TPdfLoadReport;
  I: Integer;
begin
  Result := False;
  Reason := '';
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    try
      Pdf.LoadDocument(TPdfLoadOptions.Default(plmStrict), Report);
      Result := True;               // xref 有效,无审计错误
    except
      on E: EPdfError do
      begin
        Reason := E.Message;
        for I := 0 to High(Report.Issues) do
          if Report.Issues[I].Severity = plisError then
            Reason := Reason + sLineBreak + Format('  at offset %d: %s',
              [Report.Issues[I].Offset, Report.Issues[I].MessageText]);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

TPdf.LastLoadReport 在哪里不再说真话?

TPdf.LastLoadReport 只有在接收选项的 LoadDocument 重载之后才完整,因为只有这些重载跑字节审计。成功的 Active := True 写入的是兼容模式报告、没有字节审计,所以 AuditAttempted 停在 False。PDFiumPas v3.122.1 之前,失败的赋值什么都不写——这意味着共享实例上 LastLoadReport 描述的还是上一个文件,常常还带着一条令人安心的 plsLoaded。v3.122.1 起每次失败加载都会替换报告:失败的 Active := True(仍然不抛异常、组件保持未激活)和失败的普通 LoadDocument 或 LoadCustomDocument 调用都会记 plsFailed 加错误文本,同样没有审计。实践里还有两个缺口。选项校验和 CheckInactive 在报告初始化之前运行,所以负的 AuditByteLimit 或已激活的实例会直接抛出、不产出报告。NativeErrorCode 只在 PDFium 真正尝试解析时才有意义;文件缺失时包装层在 PDFium 运行之前就抛了,这种情况请记 ErrorMessage 和异常文本

实用规则很短。绑定设计器的阅读器继续用 Active := True——那里组件未激活是可以接受的结果。其他一切地方,尤其是批量与服务器代码:每份文档建一个 TPdf,调 LoadDocument(Options, Report),接住它抛的异常,把 Report.Status、NativeErrorCode 和 error 级 Issues 连同文件名一起记日志。代价是每个调用点多几行,换来的是每次失败都归到正确的文件、带着真实原因

加载报告 API、严格模式与字节级审计随PDFium Component for Delphi, C++Builder and Lazarus 交付,渲染、文本抽取、表单填写与 PDF/A 校验也都在同一个组件里