HotPDF Component 透過 THPDFHeadlessDocument.SaveFDFDifferencesToFile 從 Delphi 套用 FDF 的 /Differences 流:這個方法先檢查已載入的 PDF 是否仍與您以精確位元組長度和 SHA-256 指定的基準修訂版相符,把累積的修訂位元組恰好附加一次,再讓結果通過加密權限與 DocMDP/FieldMDP 准入檢查,最後原子式發布。重複提交同一份 FDF 是逐位元組的 no-op
這個功能服務的流程平凡而常見。您把合約或檢驗表單以 PDF 寄出,某人在他自己的閱讀器裡填寫並簽章,寄回來的不是 PDF,而是一個 FDF 檔。欄位值搭 /Fields 的便車,但被簽署的修訂版本身——CMS 簽章實際涵蓋的那些位元組——搭的是 /Differences。合併做錯了,您要嘛弄壞簽章,要嘛更糟,接受了從來沒有簽章涵蓋過的欄位值
FDF 的 /Differences 流到底裝了什麼?
FDF 的 /Differences 流裝的是目標 PDF 的原始增量更新位元組,不是 FDF 層級的歷史。ISO 32000-1 §12.7.7.3 的表 243 把 FDF 字典的 Differences 條目定義為底層 PDF 自打開以來的增量變更,形式與 §7.5.6 的 PDF 增量更新相同:新增與替換的物件、一個交叉參照節,以及一個 /Prev 指回基準檔案的 trailer
FDF 容器本身正好相反。§12.7.7.1 禁止對 FDF 檔做增量更新,所以 FDF 只有單一 trailer、只有 generation 0 物件、物件編號唯一。HotPDF Component 在它的 FDF 解析器裡嚴格執行這一點,從不為了讓 /Differences 好做而放寬。增量資料住在一個嚴格非增量的容器裡的一條流中,把這兩層分清楚就是一半的工作
這條流的兩個性質決定了下游的每個設計決策:
- 這條流是累積的:它裝著目標 PDF 打開以來附加的每個位元組,所以同一會話的第二次提交會重複您已提交的修訂版,並把新的接在後面
- 同一份 FDF 外層的
/Fields與/Annots條目不保證與/Differences一致,所以您輕鬆讀到的欄位值,不是簽章涵蓋的資料
第二點值得說得直白些。如果您的匯入程式碼從 /Fields 讀 /V、又因為簽章隨同一檔案到達就把結果當成已簽署,您已經建好了一條偽造通道。HotPDF Component 刻意把兩條路徑分開:FDF 註解匯入器對 /Differences 直接拒收,只有專責的 Differences 傳輸碰它
把附加綁定到明確的基準修訂版
SaveFDFDifferencesToFile 拒絕去猜 FDF 屬於哪個修訂版。呼叫方傳入 BaseBytes——表單打開自的那份完整 PDF 修訂版的精確長度——與 BaseSHA256,即 64 位十六進位的 SHA-256。方法對已載入文件的那段前綴重新雜湊,不符就以「The loaded PDF does not match the explicitly selected base」拒絕,並檢查該前綴確實結束在一個真正的修訂版端點上、其 startxref 指向實際的交叉參照資料
實務規則是寄出表單時就記下這兩個數字,而不是等 FDF 寄回來。包一下 HPDFCrypt 裡的 SHA256Init / SHA256Update / SHA256Final 就夠了:
uses
SysUtils, Classes, HPDFCrypt;
function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
Stream: TFileStream;
Ctx: TSHA256Context;
Digest: TSHA256Digest;
Buffer: array[0..65535] of Byte;
Remaining: Int64;
Count, I: Integer;
begin
Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
raise EArgumentException.Create('Base length is outside the file');
SHA256Init(Ctx);
Remaining := PrefixBytes;
while Remaining > 0 do
begin
Count := SizeOf(Buffer);
if Remaining < Count then
Count := Integer(Remaining);
Stream.ReadBuffer(Buffer, Count);
SHA256Update(Ctx, Buffer, Count);
Dec(Remaining, Count);
end;
SHA256Final(Ctx, Digest);
finally
Stream.Free;
end;
Result := '';
for I := 0 to High(Digest) do
Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;
// 寄出時:整份外發檔案就是基準修訂版
// BaseLength := 'out\contract-0042.pdf' 的位元組大小
// BaseHash := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);
已載入的文件不必恰好是基準。它可以是基準加上您從更早提交已提交的修訂版,累積流正是因此保持可控:BaseBytes 始終指向原始前綴,其後的一切都被視為先前的附加位元組
用 SaveFDFDifferencesToFile 套用 Differences
THPDFHeadlessDocument.SaveFDFDifferencesToFile 收輸出檔名、FDF 位元組、基準長度與雜湊、可選的嵌入 FDF 選擇,以及一個 THPDFFDFPackageOptions 限制記錄,回傳 THPDFHeadlessFDFDifferencesResult。內部它自己把 FDF 打開成 THPDFFDFPackage 並呼叫 DecodeDifferences,所以您傳的是 FDF 檔,不是預先解碼的流。空的 Selection 指頂層 FDF;非空則先走入嵌入的子 FDF
uses
SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;
procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
BaseLength: Int64; const BaseHash: AnsiString);
var
Doc: THPDFHeadlessDocument;
Policy: THPDFHeadlessCMSOptions;
Selection: THPDFFDFChildSelections;
Applied: THPDFHeadlessFDFDifferencesResult;
begin
Doc := THPDFHeadlessDocument.Create;
try
Policy := THPDFHeadlessCMSOptions.Default;
Policy.TrustedCAFile := 'trust\issuing-ca.pem';
Doc.SetSignaturePolicyOptions(Policy);
Doc.LoadFromFile(CurrentPdf);
Selection := nil; // 頂層 FDF,無嵌入子檔
Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
[Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
finally
Doc.Free;
end;
end;
檢查的順序比呼叫本身更要緊。基準雜湊通過後,方法把已提交的附加位元組——BaseBytes 到已載入檔案結尾之間的一切——與解碼承載的開頭比對。比您已提交的還短的提交,以「A cumulative submission cannot remove committed append bytes」失敗;與其不一致的,以「The cumulative Differences conflict with the committed revision」失敗。只有剩下的後綴是新的,也只有這段後綴進 AddedBytes
組裝好的候選寫進目標目錄裡一個獨佔的暫存檔,沖刷、以獨立修訂來源的身分重新打開,驗證通過才替換輸出。解析、取消、原則、預算或發布的失敗,都不會動到已載入文件與任何既有輸出檔。兩個前置條件讓人早點踩線就停:輸出不得是已載入來源的別名,文件不得有未存檔的編輯,因為不透明的附加會靜默丟掉它們(「Publish pending document edits before applying Differences」)
為什麼重複套用是逐位元組的 no-op?
重複套用同一份累積 FDF 是 no-op,因為它的承載的每個位元組都已在檔案裡。當承載與先前附加位元組完全相等,SaveFDFDifferencesToFile 回報 NoOp = True 與 AddedBytes = 0,發布的輸出與當前 PDF 完全相同。這比「append-only」更強,也正因如此,網路逾時或 worker 崩潰後的重試是安全的
// Rev 1 已提交進 contract-0042-r1.pdf;同一份 FDF 來了兩次
// (Doc、Again、Fdf、Selection、BaseLength 與 BaseHash 如上宣告)
Doc := THPDFHeadlessDocument.Create;
try
Doc.LoadFromFile('store\contract-0042-r1.pdf');
Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
// Again.NoOp = True、Again.AddedBytes = 0,
// 且重試檔與 contract-0042-r1.pdf 逐位元組相同
finally
Doc.Free;
end;
驗收測試也照這個標準寫。斷言重試輸出與當前修訂版完全逐位元組相等,而不是只斷言檔案沒有縮小。只檢查 append-only 行為的測試,會很樂意放行一個把同一修訂版再附加一次的實作——那會產出一個看似有效的 PDF,帶著重複的更新節,留給日後的 DocMDP 稽核去解釋
修訂版發布之前跑哪些准入檢查?
發布之前,候選 PDF 得通過身分、簽章與權限檢查,而它們都不信任 FDF 外層欄位。Trailer /ID 的第一個元素必須原樣存活,已載入文件裡既有的每個簽章欄位都必須仍存在、帶相同的簽章字典與相同的簽章值身分。丟棄、替換或改寫既有簽章的 Differences 承載,以 hdSignatureModificationUnsupported 失敗
接下來是授權層。對加密文件的非空附加,被變更的物件按角色分類(catalog 與頁面、註解、AcroForm 欄位、外觀流),對照已驗證的使用者權限位元檢查,所以動註解的修訂版需要註解權限,依此類推。然後候選走 HotPDF Component 在別處也用的同一套認證與欄位鎖強制:DocMDP 認證簽章(ISO 32000-1 §12.8.2.2)限制後續修訂版可以做的變更種類,FieldMDP 鎖(§12.8.2.4)保護具名欄位。HotPDF 的 DocMDP 與 FieldMDP 修訂版分析說明了這些權限如何按修訂版分類
錯誤處理應該把「這份 FDF 是畸形的」與「這個修訂版不被允許」分開,因為第二種通常需要人來處理:
try
Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
except
on E: EHPDFFDFPackageError do
QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
on E: EHPDFHeadlessDocumentError do
case E.Code of
hdPermissionDenied, hdSignatureModificationUnsupported:
EscalateForReview(FdfFile, E.Message);
hdLimitExceeded:
QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
else
QuarantineFdf(FdfFile, E.Message); // 基準不符、衝突、端點錯誤
end;
end;
這個操作不做什麼,與它檢查什麼同樣重要。SaveFDFDifferencesToFile 從不抓取 FDF 的 /F 檔案規格,所以 FDF 內部指名的 PDF 無法把合併重導向到磁碟或網路上的其他檔案,它也從不執行 JavaScript 或 action。每個輸入都是顯式到達的:已載入的基準、FDF 位元組,以及您設定的信任材料
Windows 交易與 C ABI 的 fdf.differences.apply
同一套傳輸不用直接寫 headless 程式碼也能用。THPDFWindowsFormDataTransaction.ApplyFDFDifferences 收 FDF 位元組、基準長度、基準 SHA-256、選擇、套件選項與密碼,委託給 SaveFDFDifferencesToFile,並補上交易的位元組記帳、回滾、取消與同控制代碼重試行為。對非 Delphi 呼叫方,C ABI 暴露 JSON 操作 fdf.differences.apply,其中 base 帶長度與雜湊:
{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
"base": { "bytes": 673, "sha256": "..." },
"signaturePolicy": { "trustedCAPEM": "..." } }
JSON 回覆回報 baseBytes、priorAppendBytes、totalAppendBytes、addedBytes 與 noop,與 THPDFHeadlessFDFDifferencesResult 同樣的欄位。這套傳輸公布的驗收關卡在 Linux FPC 3.3.1、Windows Delphi 32 位元與 64 位元、Windows FPC 32 位元與 64 位元上執行;每個安全目標涵蓋 21 個案例、1,244 項原生檢查與 382 條回呼命令
值得知道的限制與測試夾具陷阱
Differences 傳輸刻意做得窄,它的幾條邊界咬過真實的測試套件:
- 基準必須是完整的 PDF 修訂版。落在檔案中途的長度、或
startxref沒指向實際交叉參照資料的長度,在任何位元組被複製之前就遭拒 - 簽章夾具需要間接的
/Lock。Windows 已載入文件權限讀取器對/Lock是內嵌字典的簽章欄位,以「Signature field /Lock must be an indirect reference」拒收,即使某些簽章路徑容忍內嵌形式,所以請把鎖字典寫成自己的物件 - 用裸的
openssl req -x509做的測試 CA 缺CA:TRUE與keyCertSign,鏈驗證會以一個看似程式庫 bug、其實不是的憑證驗證錯誤失敗 - 這個呼叫不會合併外層的
/Fields值。若您也想把它們放進文件,請明確決定它們僅供參考,並絕不把它們當成已簽署呈現 - FDF 本身保持非增量。多個 trailer、非零 generation 或重複物件編號的 FDF 檔是畸形 FDF,不是更豐富的 FDF
要的是 FDF 與 XFDF 的純值那一面、不涉及已簽署修訂版的話,HotPDF 的多選 FDF 與 XFDF 往返與Delphi 中的 XFDF 註解匯入與匯出是更好的起點
FAQ
可以傳已解碼的 Differences 流代替 FDF 檔嗎?
不行。SaveFDFDifferencesToFile 把您傳入的 FDF 解析成 THPDFFDFPackage,套用可選的嵌入選擇,並在內部呼叫 DecodeDifferences。想檢視或歸檔原始修訂位元組時,可以單獨用 THPDFFDFPackage.DecodeDifferences,但套用呼叫請交給它 FDF 檔
用戶端寄來第二份更大的累積 FDF 時會怎樣?
載入已含第一次提交修訂版的 PDF,並沿用同一組 BaseBytes 與 BaseSHA256。方法驗證新承載恰好以已提交的位元組開頭,只附加新後綴並記進 AddedBytes,然後對結果跑完整的權限與 DocMDP/FieldMDP 准入
套用成功就代表 /Fields 裡的欄位值被簽署了嗎?
不是。ISO 32000-1 並不要求同一份 FDF 裡的 /Fields、/Annots 與 /Differences 彼此一致,HotPDF Component 也不去調和它們。被簽署的是附加修訂版裡 CMS 簽章涵蓋的內容,所以要讀已簽署的值,請從合併後的 PDF 讀,不要從 FDF 外殼讀
FDF Differences 傳輸、headless 簽章原則引擎與 Windows 表單資料交易都隨 HotPDF Component 提供,支援 Delphi、C++Builder 與 Free Pascal