一份發票封存可以轉譯乾淨、能印、內嵌了每套字型、帶著有效的輸出意圖,PDF/A 驗證器卻照樣拒收它,只因為某個資源字典裝了 4,096 個條目。上限是 4,095。PDF/A 從 PDF Reference 繼承了一小張實作限制表,任何一項超出一個單位的檔案就不合格,即使沒有任何檢視器抱怨
另一種掉進同一個「看起來明明沒問題」桶子的失敗,是帶著 /Encoding 項目的符號式 TrueType 字型。核取方塊、條碼與裝飾符號在螢幕上完全正確,驗證器還是點名那個字型字典。兩個問題都住在物件圖裡,不在人能在頁面上看到的任何東西裡,所以值得在檔案進封存之前機械化檢查
PDF/A 繼承的數字,以及邊界在哪裡
PDFium Component 在每次 PDF/A 驗證檢查四個容器限制:名稱記號最長 127 位元組、陣列最多 8,191 個元素、字典最多 4,095 個條目、陣列與字典最多巢狀 28 層。四個邊界全都含端點。127 位元組的名稱通過、128 位元組的失敗;恰好 8,191 個元素的陣列通過,多一個元素就失敗
名稱、陣列與字典限制來自那個年代——合格閱讀器只被要求處理到那些大小的結構。現代桌面檢視器也許不吭聲地開啟大得多的陣列,但封存檔被期待能在還沒有人測試過的閱讀器裡打開,包括記錄系統可能還在跑的舊款。這就是 PDF/A 保留歷史天花板而不放寬的原因
含端點與否正是手寫檢查常出錯的地方。寫成 Count >= 8191 而不是 Count > 8191 的測試會拒收本來合格的檔案,而會拒收合格檔案的驗證器偏偏容易被相信,於是產生器為一個它從來沒有的問題被「修好」。FPdfPdfa 單元裡的 ValidatePdfACompliance 函式嚴格地數過上限才算違規,而且直接讀檔案位元組,所以檢查不必載入 PDFium DLL 就能跑
uses
System.Classes, FPdfPdfa;
procedure CheckContainerLimits(const FileName: string; Log: TStrings);
var
Src: TFileStream;
Res: TPdfAValidationResult;
begin
Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Res := ValidatePdfACompliance(Src); // 位元組掃描,不需要 PDFium DLL
if pvaiNameOverLimit in Res.Issues then
Log.Add('A name token is longer than 127 bytes');
if pvaiArrayOverLimit in Res.Issues then
Log.Add('An array holds more than 8191 elements');
if pvaiDictOverLimit in Res.Issues then
Log.Add('A dictionary holds more than 4095 entries');
if pvaiNestingOverLimit in Res.Issues then
Log.Add('Arrays and dictionaries nest deeper than 28 levels');
if Res.IsCompliant then
Log.Add('No PDF/A findings');
finally
Src.Free;
end;
end;
四個旗標是 TPdfAValidationResult 上 Issues 集合的成員,與其他所有 PDF/A 發現並列;IsCompliant 只在找到 PDF/A 識別且集合為空時回傳 True。完全沒有 PDF/A 識別的檔案按最嚴格的 PDF/A-1 規則集檢查,所以容器限制對它照樣適用
哪些產生器會撞上這些限制?
幾乎總是在迴圈裡建結構的那些。幾千個欄位的表單會產出超過 8,191 個條目的 AcroForm /Fields 陣列或頁面 /Annots 陣列。每個產生物件都註冊一個字型或影像資源、從不重用既有條目的報表引擎,會把 /Resources 子字典養過 4,095 個鍵。以遞迴走訪巢狀資料模型產生結構樹的標籤文件,可以在無人注意時越過 28 層
長名稱的來源不同:資料被塞進名稱記號。以客戶參考編號命名的專色色料、以檔案路徑命名的可選內容群組、由六層階層拼出的欄位標籤。名稱生成不花成本,而一旦牽涉帶變音符號的 UTF-8 標籤,127 位元組很快用完,因為那些字元每個要兩三個位元組。127 位元組從 / 導入符之後算起,到下一個分隔符或空白為止
解藥每次都是結構性的:拆陣列、拆字典、攤平遞迴、縮短名稱。往檔案裡注入 PDF/A 標記對這些發現毫無幫助,因為問題是物件圖的形狀,而 XMP 中繼資料對此隻字未提
掃描怎麼數元素與條目
想用十六進位編輯器重現一個發現時,計數規則很重要。巢狀陣列或字典算作其父的一個元素——限制就是這麼陳述的:10 個元素、每個都是 1000 元素陣列的陣列沒問題,因為沒有任何單一陣列超過 8,191。字典裡只數鍵,所以恰好是名稱的值不會灌水條目總數。<FEFF0041> 這類十六進位字串當單一記號跳過,所以它的角括號永遠不會被誤認成字典巢狀
掃描涵蓋整份檔案的物件結構,包括存在壓縮物件串流裡的物件——計數前先展開。串流本體(如頁面內容與影像資料)先被清空,所以碰巧含 / 或 [ 的一段二進位位元組不會觸發假的名稱或陣列發現
同一套驗證還有另一個相鄰的數值限制,用自己的旗標回報。檔案宣告 PDF/A-2 或更後面的部分時,任何落在 32 位元有號範圍之外——即 -2,147,483,648 到 2,147,483,647 之外——的整數記號會觸發 pvaiIntegerImplementationLimit。把位元組偏移或大計數器寫成普通整數的產生器是常見來源
為什麼符號式 TrueType 字型必須拿掉 /Encoding?
ISO 19005-1 §6.3.7 只承認字型程式內建的 cmap 作為符號式 TrueType 字型的字符映射。符號式字型按自己的方式把碼映射到字形——Symbolic 旗標宣告的就是這件事。加上 /Encoding 項目之後,檔案對「位元組 0x41 選哪個字形」給出兩個答案,而檔案裡沒有任何東西說哪個贏。閱讀器各按各的方式解衝突,同一個核取方塊在一個檢視器裡是勾、在另一個裡是字母 A
ValidatePdfACompliance 找出每個帶 /Subtype /TrueType 的字型字典,檢查它有沒有 /Encoding 鍵,然後讀字型描述器裡 /Flags 項目的第 3 位(值 4)。描述器無論行內寫出或以間接物件參照都會被跟到底。保留 /WinAnsiEncoding 或 /MacRomanEncoding 的非符號 TrueType 字型不會被這個檢查點名,因為對非符號字型,PDF/A 期待的正是那種編碼
// Res 來自 ValidatePdfACompliance,同第一個範例
if pvaiSymbolicTrueTypeEncoding in Res.Issues then
Log.Add('A symbolic TrueType font carries /Encoding; PDF/A admits only ' +
'the font''s built-in cmap (ISO 19005-1 6.3.7)');
// 相鄰的另一條字型規則,觸發條件不同:Encoding 字典帶
// /Differences 的非符號 TrueType 字型
if pvaiTrueTypeDifferencesForbidden in Res.Issues then
Log.Add('A non-symbolic TrueType Encoding dictionary carries /Differences');
踩中這條規則的字型可以預測:Symbol、Wingdings、條碼字型、圖示字型。它們以核取方塊、評分星星與條碼的樣貌出現在商業文件裡,而一視同仁處理每套 TrueType 字型的子集化步驟,也會為它們寫出編碼。驗證器說「字型問題」時沒人會看的也正是這些字型,因為內文字型總是先被檢查
相反的錯誤也真實存在:生產端把真的以 WinAnsi 編碼的普通內文字型設成了 Symbolic 旗標。PDF/A 檢查按條文照寫的方式回報那套字型,而這裡修復屬於描述器:改正描述器裡的旗標,而不是刪編碼。從被貼錯標籤的內文字型上移除 /Encoding,會讓文字依賴子集碰巧含有的任何 cmap——比動手前那份檔案更糟。決定移除矛盾哪一半之前,先看字型的字符集
在預檢報告裡讀這些發現
預檢報告單元隨企業版出貨,ValidatePdfACompliance 則每個版本都有。FPdfPreflightReport 單元裡的報告把四個容器限制歸進 Structure 類別、把符號式 TrueType 編碼問題歸進 Content 類別。這個分法是刻意的:結構類發現通常回到維護產生器的人,內容類發現通常回到供字型與範本的人,把兩者混在一起的報告會讓兩個人各自去追不屬於自己的問題
每個問題以 Code 帶它的列舉名,附一句以具體語言點名解藥的建議,例如「Split dictionaries so none carries more than 4095 entries」。注意 Structure 類別還有另外兩個發現——Level A 宣告缺少標籤結構、以及 PDF/A-1 檔案裡出現 PDF 1.5 交叉參照或物件串流——所以只要四個限制時請按代碼過濾
uses
System.SysUtils, System.Classes, PDFium, FPdfPreflightReport;
procedure ReportStructureFindings(const FileName: string; Log: TStrings);
var
Pdf: TPdf;
Report: TPdfPreflightReport;
Issues: TPdfPreflightIssues;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True; // 這條路徑會載入 PDFium DLL
if not Pdf.Active then
Exit;
Report := BuildPdfPreflightReport(Pdf, [ppsPdfA]);
Issues := Report.FilterByCategory('Structure').FlattenIssues;
for I := 0 to High(Issues) do
Log.Add(Issues[I].Code + ': ' + Issues[I].Recommendation);
Report.SaveJsonToFile(ChangeFileExt(FileName, '.preflight.json'));
finally
Pdf.Free;
end;
end;
兩條路徑回答的問題略有不同。BuildPdfPreflightReport 走 TPdf.ValidatePdfA:把開著的文件存成暫存副本、對副本跑同一場位元組掃描,然後加上需要 PDFium 引擎的檢查,例如頁面上用到卻沒內嵌的字型。ValidatePdfACompliance 判的是磁碟上確切的位元組。實作限制要信的是原始檔案上的位元組路徑:它回報的是你即將封存的那份檔案,而且能在無法部署原生 DLL 的組建步驟或鎖死的服務裡跑。還要引擎級檢查與 JSON、SARIF 或 JUnit 輸出接管線時,用報告路徑
下一步去哪裡
跨資料夾跑這些檢查、決定哪些發現擋下發行、把結果變成人願意讀的報告,見 Delphi 中的 PDF/A 預檢驗證 逐步解說與 批次預檢報告 CLI。一開始該瞄準哪個 PDF/A 部分與符合等級,是 PDF/A 封存合規 筆記的主題;那個選擇順帶決定整數限適不適用
這裡展示的驗證器隨適用 Delphi、C++Builder 與 Lazarus 的 PDFium Component 出貨,與 PDFium 引擎之上的轉譯、表單填寫與編輯並列。PDFium Component 產品頁 列出支援的符合標準、版本與編譯器版本