PDF 在抵達列印佇列、封存系統或客戶上傳入口等生產流程邊界時,應在渲染前先稽核。它可能含有會啟動外部程式的 Launch 動作、無法應付列印的低解析度影像、禁止所提交列印工作的加密字典,或名不符實的 PDF/A 標示。這種在文件進入流程前依規則檢查的程序稱為預檢;PDFium C API 讓 Delphi 可直接實作,無須渲染任何頁面
本文建立四類檢查,均以小型程序將發現結果附加到共用清單:互動元素、資源度量、安全狀態與標準標記,並附上可運作的程式碼與運算細節。若需要批次資料夾迴圈、JSON 與 HTML 報告及逐檔隔離,PDFium Component 已提供預檢引擎,批次預檢 CLI 文章則說明配套機制。兩者共用同一套結束代碼,因此本文的稽核器可直接接到批次驅動程式
發現記錄與結束代碼契約
每項檢查都寫入同一個扁平記錄型別;若各自輸出文字,之後便無法計數、篩選或設定門檻。四個欄位已足夠
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 = 0、PDFACTION_GOTO = 1、PDFACTION_REMOTEGOTO = 2、PDFACTION_URI = 3、PDFACTION_LAUNCH = 4 與 PDFACTION_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_METADATA 的 horizontal_dpi 與 vertical_dpi 由完整轉換矩陣推導,比較結果可找出旋轉放置。相同點數對像素運算在渲染時反向使用,詳見JPEG 匯出文章
安全狀態:加密與權限位元
PDF 加密有兩種密碼。使用者密碼控制解密:沒有它檔案無法開啟,FPDF_LoadDocument 回傳 nil,FPDF_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