技術文章

Delphi 的 PDF 靜默載入失敗:請用 PDFium 載入報告

在 Delphi 與 Lazarus 版 PDFium Component 裡,PDF 載入失敗時指派 TPdf.Active := True 永遠不會舉發例外:TPdf.SetActive 把例外全接住,讓元件保持未啟用。想看到真正的錯誤,請改呼叫 TPdf.LoadDocument(Options, Report)。這個多載會重新舉發原始例外,並填好一份 TPdfLoadReport,帶著載入狀態、原生 PDFium 錯誤碼,以及交叉參照表是否必須重建

這個問題通常在批次程式碼裡現形。表格擷取工作用一個共用的 TPdf 走完資料夾裡 13 份真實世界的 PDF,其中 7 份回報失敗。但這 7 份檔案沒有一份真的壞掉。包著載入的 except 區塊一次也沒觸發,日誌把帳算到錯的檔名頭上,第一個看得見的錯誤是關於未啟用元件的裸 EPdfError,從真正失敗那次載入之後好幾行的一次屬性讀取裡丟出來。兩個各自獨立的行為疊出這幅景象,而且兩個都照設計在運作

PDF 載入失敗時,TPdf.Active := True 為什麼不舉發例外?

TPdf.SetActive 用一個吞掉所有例外類別的 try..except 包住 LoadDocument,失敗就讓元件保持未啟用。吞掉是故意的:表單設計器在 IDE 裡切換 Active 時跑的是同一個 setter,一個爛路徑不能把 IDE 弄掛。執行階段 TPdf.Active 只是回報原生文件控制代碼存不存在,所以載入失敗後它讀到 False,其他什麼都不會發生。丟出過的東西就此消失——不論是解析器的 EPdfError、串流錯誤,還是半綁定的 pdfium.dll 丟出的 EAccessViolation。在 Delphi 診斷 pdfium.dll 載入失敗一文描述的那些詳細 DLL 訊息,只有透過不吞例外的呼叫才到得了您的處理器

PDFium Component 的兩條載入路徑:指派 Active := True 在 setter 裡吞掉所有例外,把失敗推遲到第一個受防護的呼叫,由 CheckActive 舉發關於未啟用元件的 EPdfError;LoadDocument 搭配 TPdfLoadOptions 與 TPdfLoadReport 則稽核檔頭、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,能把一筆算錯對象的崩潰變成一條誠實的「failed」記錄。PDFiumPas v3.122.1 之前,成因在這裡就斷了線;v3.122.1 起,失敗的指派會把 LastLoadReport 換成帶著錯誤文字的 plsFailed 報告,成因因此留得住。例外物件本身與位元組層級稽核,仍然需要另一個入口

共用一個 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,日誌就重新對上現實

TPdf.LoadDocument 搭配 TPdfLoadReport 給您什麼?

TPdf.LoadDocument(const Options: TPdfLoadOptions; out Report: TPdfLoadReport) 舉發真正的例外,還用結構化形式告訴您發生了什麼。檔案多載載入 FileName;兄弟多載收 TBytes 或指標加長度,串流則由 LoadCustomDocument(AStream, AOwnsStream, Options, Report) 負責。每一個都先驗選項、確認實例未啟用,對檔頭、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:選項驗證與未啟用檢查在報告誕生之前就舉發,位元組稽核走過檔頭、startxref、xref 區段與檔尾記號,原生載入記下 FPDF_GetLastError,結果分流為載入成功、xref 重建後帶復原載入、嚴格模式拒收或失敗
Status、NativeErrorCode 與問題清單回答了批次日誌要的東西;只有帶選項的多載才做位元組稽核,而 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 與錯誤級的 Issues 連同檔名一起寫進日誌。代價是每個呼叫點多幾行,換來的是每次失敗都歸給正確的檔案與真正的成因

載入報告 API、嚴格模式與位元組層級稽核,連同渲染、文字擷取、表單填寫與 PDF/A 驗證,一起隨 PDFium Component for Delphi, C++Builder and Lazarus 出貨