PDFlibPas 把 PDF/VCR-1 實作成標記結構設定檔:替換定義住在 StructTreeRoot 底下,作為直接子分支,其中範本根帶 /O /GTS_Template 宣告欄位集合,每個佔位符帶 /O /GTS_Replacement 繫結一個資料欄位。這裡沒有任何東西是頁面層級字典
這一句就是大多數實作搞錯的部分,而搞錯的代價不止於驗證失敗。替換計畫被誤讀的變動資料工作不會當機。它會大量印刷,把錯的欄位印進錯的格子,第一個發現的人是握著五萬份郵件的客戶
替換定義實際住在哪裡
PDF/VCR-1 把替換模型放在 ISO 32000-1 §14.7 描述的邏輯結構階層裡,而不是私有的頁面字典。範本根是直接掛在 StructTreeRoot 下的結構元素,以 GTS_Template 這個屬性 owner 區分,並透過 GTS_Fields 在一處宣告整個欄位詞彙表,加上選用的 GTS_Pages 指名驅動頁面選擇的欄位
佔位符是巢狀在那個根內部的結構元素,屬性 owner 為 GTS_Replacement。每個都帶 GTS_Generator(PDFlibPas 要求必須是 PassThrough)、指名恰好一個已宣告欄位的 GTS_Data 值,以及選用的 GTS_BBox。與頁面的繫結也不是頁面註解:元素透過 /Pg 指向它的頁面,透過 /K 指向標記內容序列。選擇結構而非頁面字典,接著帶來一個容易陳述也容易低估的後果:結構樹是從目錄抵達的一般圖形,所以任何消費這個設定檔的東西都必須是樹走訪器,連帶所有那些風險,而不是字典讀取器。如果你沒碰過結構樹,標記 PDF 結構與無障礙裡有機制的完整說明
為什麼只檢查第一個頂層分支會放行壞檔
因為 StructTreeRoot 可能有很多直接子節點,而一個定位到範本根就停下的驗證器永遠看不到其餘部分。這正是 PDFlibPas 刻意要避免的特定失敗,它產生的那種假通過比完全沒檢查更糟:一份回報符合規格的檔案裡藏著第二個範本根,或一個坐在範本分支之外、沒有消費端會理它的佔位符
所以 PDFlibPas 的 ValidatePDFVCRStructure 對同一棵樹跑兩輪。第一輪列舉 StructTreeRoot 的每個直接子節點,數出帶 GTS_Template 的有幾個;不是恰好一個,就在讀任何佔位符之前拒絕。第二輪深入所有頂層分支,不只是範本那一支,同時拒絕兩種東西:帶 GTS_Template 卻不是根部的巢狀元素,以及任何在範本分支外找到的 GTS_Replacement 元素
走訪以 64 層深度與 1,000,000 個節點為上限,並記錄它訪問的每個元素的物件編號與世代編號,所以遇到迴圈或重複參照時是回報而不是追下去。那些數字不是效能調校。結構樹是攻擊者控制的物件圖,一個沒有上限就遞迴的驗證器,是帶副檔名的堆疊溢位
撰寫範本,以及 API 拒絕讓步的限制
PDFlibPas 透過一個括號式 API 暴露撰寫功能,在寫入時就強制結構規則,而不是在儲存時才發現。SetPDFVCRMode 把文件切進這個設定檔,內部要求 ISO 15930-7 的 PDF/X-4 基礎模式並開啟 mark-info 旗標,因為沒有標記內容的 PDF/VCR-1 檔案,佔位符就沒有東西可以指向。接著 BeginPDFVCRTemplate 接收欄位清單與頁面選擇欄位,它在設計資料結構之前就值得知道的幾方面很嚴格。欄位名稱會通過 PDFVCRValidFieldName,只接受 ASCII 字母、數字、底線、連字號與句點,因為這些字元序列化為 PDF 名稱時不會有跳脫歧義。PDFVCRNormalizeFieldList 讓清單維持大小寫敏感並直接拒絕重複,而頁面選擇欄位本身必須出現在宣告的清單裡
var
Lib: TPDFlib;
Diag: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.NewDocument;
if Lib.SetPDFVCRMode(1) <> 1 then
raise Exception.Create('PDF/VCR mode was refused');
Lib.NewPage;
if Lib.BeginPDFVCRTemplate('Salutation,FullName,AccountNo',
'AccountNo') <> 1 then
raise Exception.Create('Field list rejected');
// 一個佔位符恰好繫結一個已宣告的欄位。
Lib.BeginPDFVCRPlaceholder('FullName', 72, 690, 320, 712);
Lib.DrawText(72, 694, 'SAMPLE NAME');
Lib.EndPDFVCRPlaceholder;
Lib.EndPDFVCRTemplate;
Diag := Lib.GetPDFVCRDiagnostics;
if Diag <> '' then
begin
Writeln('PDF/VCR blockers: ', Diag);
Exit;
end;
Lib.SaveToFile('campaign-template.pdf');
finally
Lib.Free;
end;
end;
注意括號式做法買到什麼。BeginPDFVCRPlaceholder 只有在範本啟動且標記堆疊恰好深一層時才願意開啟,這是 PDFlibPas 保證佔位符是範本根直接子節點、永遠不會彼此巢狀的方式。座標也驗證,拒絕 NaN、無限大與反轉矩形,所以壞掉的邊界框在呼叫當下失敗,而不是一週後的 preflight 報告
佔位符為什麼需要自己的 MCID 序列化路徑
因為 PDF/VCR-1 要求 /K 是赤裸的整數,而一般標記 PDF 寫入器不是這樣序列化結構元素與其內容之間的連結。依 ISO 32000-1 §14.6,結構元素通常透過直接 MCID 整數參照標記內容——當元素已經用 /Pg 識別自己的頁面時——或透過明確指名頁面的標記內容參照字典。一個為許多用途輸出標記內容的程式庫,傾向標準化為字典形式,因為它在跨頁時組合得比較好
PDFlibPas 在這裡選了窄路而不是大路。SetCurrentStructElemDirectMCIDKid 標記目前的結構元素,只有帶這個標記的元素會把 /K 序列化成直接 MCID,頁面則放在 /Pg 上;其他一切都維持既有的 MCR 字典路徑不動。這是刻意的取捨,而且是值得大聲說出來的那種:新增設定檔不應該改變其他標記文件已經產生的位元組,所以設定檔得到自己的路徑,而不是全域開關。每份既有標記檔案都保留先前的序列化,回歸面維持在新功能的尺寸
一次走訪,兩個消費端
PDFlibPas 從同一輪兩次走訪推導出替換 manifest 與合規判定,這是 manifest 可以當作工作計畫來信任的原因。走訪在驗證的同時填滿 TPDFVCRManifestData record:Fields 來自範本根,宣告時有 PageSelectionField,以及 TPDFVCRPlaceholderInfo 條目的 Placeholders 陣列,條目帶 FieldName、PageNumber、MCID、HasBBox 與 BBox。合規入口是 CheckCompliancePDFVCR(InputStream, Password, Options, ResultList),它按相依順序執行檢查而不是一次全跑:先做完整的 PDF/X 檢查,再把 GTS_PDFXVersion 對上 PDF/X-4,然後從 http://www.npes.org/pdfvcr/ns/id/ 命名空間讀 GTS_PDFVCRVersion,最後才走結構。把 Options 傳 1 讓它在第一個問題就停,這是批次閘門要的;傳 0 收集全部,這是報告要的
// 驗證收到的範本,而不是自己產出的範本
var
Issues: TStringList;
Stream: TFileStream;
I: Integer;
begin
Issues := TStringList.Create;
Stream := TFileStream.Create('supplier-template.pdf',
fmOpenRead or fmShareDenyWrite);
try
if CheckCompliancePDFVCR(Stream, '', 0, Issues) <> 100 then
for I := 0 to Issues.Count - 1 do
Writeln('PDF/VCR: ', Issues[I]);
finally
Stream.Free;
Issues.Free;
end;
end;
manifest 是契約,所以只為合規檔案存在
BuildPDFVCRManifest 只在問題清單回傳空時回傳 JSON,其餘回傳空字串。這不是為了防禦而防禦。manifest 由會用它驅動真實輸出的組合引擎消費,把從有兩個範本根的檔案裡抽出來的部分欄位清單交出去,等於邀請引擎把不合規結構當成可信替換計畫。透過文件 API,GetPDFVCRManifest(InputFileName, Password) 包住同一個呼叫,檔案不合格時把最後錯誤碼設為 PDFLIB_ERROR_PDFVCR_COMPLIANCE(608),所以提取失敗與空範本是可區分的。JSON 回報 fields、以字串或 null 呈現的 pageSelectionField,以及 placeholders 陣列,其條目給出 field、page、mcid 與四數字的 bbox 或 null
var
Lib: TPDFlib;
Manifest: WideString;
begin
Lib := TPDFlib.Create;
try
Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
if Manifest = '' then
// 608 代表檔案不符合 PDF/VCR-1,
// 401 代表檔案無法開啟
Writeln('No manifest, error ', Lib.LastErrorCode)
else
Writeln(Manifest);
finally
Lib.Free;
end;
end;
有一個細節要帶進你的消費端:page 值遵循程式庫全域的零基底頁面慣例,而不是人類在檢視器看到的 1-based 編號。混用會造成無聲的 off-by-one,讓內容落到錯誤的頁面上,而且任何驗證器都抓不到,因為兩個數字在結構上都合法
界線在哪裡
PDFlibPas 的 PDF/VCR-1 支援涵蓋範本撰寫、儲存閘門、獨立 preflight 與 manifest 提取。它不做替換。把資料代進佔位符並產出個人化實例是組合步驟,刻意活在程式庫之外,因為 manifest 正是讓組合引擎可以獨立撰寫的介面。儲存閘門也是拒絕而非修復。不像有些設定檔可以把錯的註解旗標無聲正規化,PDFVCRReadyForSave 強制的每一條 PDF/VCR-1 限制都是亂猜就會改變文件意義的那種:發明範本根,或替未繫結的佔位符挑欄位,產出的是驗證會過但會說謊的檔案。修復與拒絕的界線在PDF/E-1 作者模式與有界 preflight裡用另一個設定檔討論過,對單一文件跑多種合規檢查的工作流程見合規與簽署工作台
撰寫、有界結構走訪、儲存閘門與 manifest 提取器都隨 PDFlibPas Delphi PDF library 出貨,意思是範本可以在設定檔下產出,再透過一條從未看過寫入器狀態的獨立程式路徑驗證,而這份分離正是合規宣稱之所以有價值的唯一原因