在 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 消息,只有经由一个不吞异常的调用才能到达你的处理程序
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 也行,但一文档一实例让每个文件从构造上就彼此隔离
带 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
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 校验也都在同一个组件里