技術文章

使用 PDFium 進行自動化 PDF 預檢與風險稽核

PDF 在抵達列印佇列、封存系統或客戶上傳入口等生產流程邊界時,應在渲染前先稽核。它可能含有會啟動外部程式的 Launch 動作、無法應付列印的低解析度影像、禁止所提交列印工作的加密字典,或名不符實的 PDF/A 標示。這種在文件進入流程前依規則檢查的程序稱為預檢;PDFium C API 讓 Delphi 可直接實作,無須渲染任何頁面

本文建立四類檢查,均以小型程序將發現結果附加到共用清單:互動元素、資源度量、安全狀態與標準標記,並附上可運作的程式碼與運算細節。若需要批次資料夾迴圈、JSON 與 HTML 報告及逐檔隔離,PDFium Component 已提供預檢引擎,批次預檢 CLI 文章則說明配套機制。兩者共用同一套結束代碼,因此本文的稽核器可直接接到批次驅動程式

PDF 管線圖:輸入 PDF 扇出經四類檢查,發現項目彙入單一 TPreflightFinding 記錄,對應到以門檻決定的結束碼
稽核讓不受信任的檔案流經四類檢查 — 互動元素、資源量測、安全狀態與標準標記 — 把所有結果收進一份可計數的檢查發現記錄,再折算成單一結束代碼

發現記錄與結束代碼契約

每項檢查都寫入同一個扁平記錄型別;若各自輸出文字,之後便無法計數、篩選或設定門檻。四個欄位已足夠

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // 穩定的機器金鑰,例如 'ACT-LAUNCH'
    Page: Integer;      // 1-based;0 表示文件層級
    Message: string;    // 供人類閱讀;可在版本間自由改寫
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

下游工具依 Code 判斷,不依可改寫的 Message 文字。程序結束代碼採三值契約:0 表示沒有發現,1 表示有發現,2 表示因檔案無法剖析或要求密碼而無法稽核。代碼 2 必須獨立:整個資料夾的損毀掃描檔代表上游掃描器故障,而非合規性崩潰;混在一起會讓人追查錯誤問題

互動元素:指令碼、啟動目標與外部連結

PDFium 以整數型別分類動作,必須精確固定 fpdf_doc.h 常數,抄錯數值會讓掃描器無聲失明。正確列舉為 PDFACTION_UNSUPPORTED = 0PDFACTION_GOTO = 1PDFACTION_REMOTEGOTO = 2PDFACTION_URI = 3PDFACTION_LAUNCH = 4PDFACTION_EMBEDDEDGOTO = 5。其中沒有 JavaScript 成員:文件層級指令碼不是連結動作,不會透過 FPDFAction_GetType 出現,須以另一組呼叫列舉。以虛構 JavaScript 常數比較的稽核器雖可編譯執行,卻永遠找不到項目

const
  PDFACTION_GOTO         = 1;   // in-document jump: harmless
  PDFACTION_REMOTEGOTO   = 2;   // 跳到另一個本機檔案
  PDFACTION_URI          = 3;   // 開啟外部 URL
  PDFACTION_LAUNCH       = 4;   // 啟動外部程式
  PDFACTION_EMBEDDEDGOTO = 5;   // 跳到嵌入檔案

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // 僅目的地連結,不需標記任何項目
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO 依設計保持靜默
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

嚴重性反映政策。Launch 動作是錯誤,因為在 PDF 中點擊後啟動任意程式極危險,且發票不需要它。外部 URI 是警告:正當文件常見,但審核者應在不點擊時看到目標,因可見文字未必等於實際目的地。文件內 GoTo 跳轉屬於結構,不納入報告;對每個目錄項目誤報只會讓使用者忽略預檢。若要讀取 JavaScript 內容並檢查簽章 MDP 與 XFA,請參閱安全風險稽核文章

資源度量:有效影像 DPI

PDF 內影像本身沒有 DPI,只有像素;頁面把像素放入以點為單位的矩形,每 72 點為一英吋。解析度是兩者比率,因此同一張 600 × 400 相片作縮圖很清晰,放滿整頁卻可能模糊。稽核必須取得原始像素尺寸與物件邊界的放置矩形

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // 放置在頁面上的尺寸,單位為 points
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 points = 1 inch, so placed inches = points / 72, and
    // effective DPI = source pixels / placed inches.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // 較差的軸決定列印品質

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

門檻是政策而非物理定律:150 DPI 是辦公室列印明顯像素化的下限,300 是常見商業目標,超過 600 沒有可見品質提升卻增加檔案大小,因此僅報告為資訊性膨脹。FPDFPageObj_GetBounds 回傳軸對齊方框,旋轉放置時計算值會低估密度。FPDF_IMAGEOBJ_METADATAhorizontal_dpivertical_dpi 由完整轉換矩陣推導,比較結果可找出旋轉放置。相同點數對像素運算在渲染時反向使用,詳見JPEG 匯出文章

安全狀態:加密與權限位元

PDF 加密有兩種密碼。使用者密碼控制解密:沒有它檔案無法開啟,FPDF_LoadDocument 回傳 nilFPDF_GetLastError 報告 FPDF_ERR_PASSWORD。擁有者密碼控制權限:檔案可無認證開啟,但帶有相容閱讀器必須遵守的限制位元。因此載入就是第一項安全探測:使用者密碼檔案無法稽核(代碼 2);擁有者密碼檔案可正常稽核並累積發現

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 表示檔案未加密
  begin
    // 以空白密碼開啟但仍已加密:僅限 owner-password
    // 任何人都能讀取,但權限位元會限制符合規範的
    // 讀取器能執行的操作。未加密檔案會回報所有
    // 位元已設定,因此必須先通過 revision gate
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: print
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copy / extract content
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: high-resolution print
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

遮罩來自 ISO 32000-1 表 22:/P 值的第 3 位元遮罩為 4、第 5 位元為 16、第 12 位元為 2048。重要性取決於路由決策。列印服務商應在接收階段退回 SEC-NOPRINT,而非截止前三小時才讓 RIP 失敗。封存系統應將 SEC-ENC 視為阻擋條件,因加密不適合長期保存

標準標記:讀取 PDF/A 宣告

檔案會在 XMP 中繼資料封包以 pdfaid:part(1 至 4)與 pdfaid:conformance(等級字母,例如視覺保真度的 b 或完整結構標記的 a)宣告 PDF/A。PDFium C API 沒有 XMP 存取器;FPDF_GetMetaText 只讀 Info 字典。ISO 19005 規定 XMP 串流必須未壓縮儲存,讓工具無須完整剖析器即可尋找。因此可用原始位元組掃描偵測宣告;若宣告藏在壓縮串流,檔案已違反所宣稱的標準

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // empty = no PDF/A claim present
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP 識別結構
  if P = 0 then
    Exit;
  // Handles both <pdfaid:part>2</pdfaid:part> and pdfaid:part="2":
  // 取得 property name 後的第一個數字
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

這只屬資訊性:宣告是聲明而非檔案屬性。任何產生器都能寫入一行 XMP XML;相容性則需實際符合嵌入字型、裝置獨立色彩與禁止功能等數百規則。偵測宣告只能決定哪些檔案應送交真正驗證。元件內建引擎可驗證 PDF/A、PDF/UA 與 PDF/X,批次 CLI 文章說明如何產出可供稽核者開啟的管線報告

對問題檔案執行一次稽核

驅動程式先做安全檢查,以決定稽核能否執行;接著處理文件層級行為與標準宣告,最後逐頁檢查動作與影像

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // audit failure, not a verdict
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' conformance claimed (declaration only, not validated)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'page failed to parse');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

針對由外部代理商退回的型錄,輸出如下

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

每行都可單獨採取行動,但組合才是真正判定。此檔案宣稱 PDF/A-2,卻帶有加密字典與即時 JavaScript;PDF/A 明確禁止兩者,所以深度驗證器執行前就能證明宣告為假。這正是扁平發現清單能呈現、布林通過/失敗值會隱藏的矛盾

這項稽核無法告訴您的事

誠實界定範圍才能讓預檢工具值得信賴。以上只讀取檔案對自身的宣告:PDFium 剖析結構,本稽核列出內容。它不執行 PDF/A 驗證,例如嵌入字型字形涵蓋範圍、依輸出意圖的色彩空間分析或條款層級規則;這需要元件引擎或 veraPDF 等專用驗證器。權限位元是閱讀器遵守的聲明而非密碼學防線,SEC-NOPRINT 描述意圖而非強制。掃描涵蓋連結註解與文件層級指令碼,表單欄位事件指令碼仍需表單 API。簽章檢查報告的也只是宣告意圖,憑證鏈驗證另當別論。預檢稽核是接收面談而非審判,其職責是讓路由決策具備資訊、快速且可重複

備註:本稽核使用的文件、頁面、註解與影像物件 API,以及高階 Delphi 包裝器和完整標準驗證預檢引擎,均隨附於 PDFium Component