v3.121.1 之前的 PDFium Component,透過 TPdf.Annotation[] 讀出註解、再把整筆記錄指派回去時,可能在 /AP 外觀字典裡多出空的 /R 與 /D 條目,就算原本只帶 /N 也一樣。PDF/A 驗證器會拒收這種字典。v3.121.1 起 getter 只回報真正讀到的外觀,因此原樣往返不會多寫任何東西。這個缺陷值得仔細弄懂,因為常見的觸發場景,是一段想把檔案變得更合規的修正
把註解原樣寫回去,到底哪裡出錯?
簡短的答案:註解憑空多出它從未有過的外觀串流,編輯前還能通過 PDF/A 驗證的檔案,編輯後就過不了。典型的場景是這樣:客戶的封存檔送來一批缺 Print 旗標的方形與文字註解,而 PDF/A 規定每個註解都必須列印,於是您逐頁走迴圈、加上 afPrint、把每筆記錄指派回去。這段程式碼完全沒碰外觀。從 TPdf.Annotation[] 拿到的是一筆 TPdfAnnotation,SetAnnotationData 會寫出每個 Has* 哨兵已設值的欄位,HasContents 與 ContentsText 這一對本來就是這樣用的。問題出在 getter 曾把 HasAppearanceRollover 與 HasAppearanceDown 設成 True 並附上空字串,即使那兩個模式根本不存在,而 setter 也就乖乖寫出兩條空串流:
procedure MarkAnnotationsPrintable(const FileName: string);
var
Pdf: TPdf;
PageNo, I: Integer;
A: TPdfAnnotation;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for PageNo := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := PageNo;
for I := 0 to Pdf.AnnotationCount - 1 do
begin
A := Pdf.Annotation[I];
if not (afPrint in A.Flags) then
begin
A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
// v3.121.1 之前,原始註解只帶 /AP/N 時,這行指派也會寫出空的
// /AP/R 與 /AP/D 串流
Pdf.Annotation[I] := A;
end;
end;
end;
Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
finally
Pdf.Free;
end;
end;
ISO 32000-1 §12.5.5 為外觀字典定義了三個條目:/N 是正常外觀,/R 是 rollover,/D 是 down。/R 與 /D 是選用的,缺了它們,檢視器就退回 /N。但空的 /R 串流不等於缺席:它是一條合法卻什麼都不畫的串流,尊重 rollover 外觀的檢視器,只要指標一移到註解上就會顯示一塊空白矩形。PDF/A 更嚴:ISO 19005-1(含 Corrigendum 2)與 ISO 19005-2 / 19005-3 只允許註解外觀字典裡出現 /N。veraPDF 對往返後的檔案,在 PDF/A-1 下報 rule 6.5.3-4,在 PDF/A-2 與 PDF/A-3 下報 rule 6.3.3-2;內建的 TPdf.ValidatePdfA 則把它列為 pvaiAnnotationApDictViolation。為了滿足標準裡一條條款而加上 Print 旗標的這筆編輯,打破的是另一條
外觀明明不存在,FPDFAnnot_GetAP 為什麼回傳 2?
就算請求的外觀串流根本不存在,FPDFAnnot_GetAP 也從不回傳零。這個函式遵循 PDFium 慣用的兩段式呼叫:先傳 nil 緩衝區拿到位元組數,配置記憶體,再呼叫一次把 UTF-16LE 文字拷回來。回傳的長度永遠含 UTF-16 結束字元,所以缺席的串流回報 2 位元組——空字串加上結束字元。v3.121.1 之前的 getter 判斷的是 ByteLength >= SizeOf(FPDF_WCHAR),這個條件每次呼叫都成立,於是只要註解帶有任何一種外觀,三個 HasAppearance* 旗標統統回 True。經由記錄走一趟往返,就會要求 FPDFAnnot_SetAP 為每個模式存入空字串,PDFium 便建立串流來裝它。沒有例外、沒有警告,畫面上也看不出任何差別,所以這個缺陷是在 veraPDF 測試夾具裡現形,而不是在某個檢視器裡
v3.121.1 怎麼判斷外觀確實存在
ReadAppearance 是 GetPageAnnotation 裡負責填入 AppearanceNormal、AppearanceRollover 與 AppearanceDown 的輔助函式,現在只有在結果至少多帶一個結束字元以外的字元時,才把它當成內容。第一次呼叫必須回傳超過 SizeOf(FPDF_WCHAR) 的位元組數,且位元組數必須是偶數——長度為奇數就不可能是 UTF-16。實際拷貝文字的第二次呼叫也會再驗一次:回傳長度小於等於 2,或大於當初配置的緩衝區,就把 HasValue 重設為 False、字串保持為空。寫入端則什麼都沒動。SetAnnotationData 依舊只對 HasAppearance* 旗標為 True 的模式呼叫 FPDFAnnot_SetAP,所以從只帶 /N 的註解讀出的記錄,寫回去也只會有 /N。迴歸測試夾具兩個方向都蓋到:帶正常外觀的方形註解原樣讀出寫回,能通過 PDF/A-1b、PDF/A-2b 與 PDF/A-3b;同一個註解若拿掉 Print 旗標,則只會在預期的旗標規則上失敗,其他一概不報
缺席與空串流長得一模一樣,getter 只好持續保守
原生 API 分不出缺席的外觀串流與存在卻為空的串流,PDFium Component 也不假裝分得出。兩種情況從 FPDFAnnot_GetAP 拿到的是同樣的 2 位元組,讀回來都是 HasAppearanceRollover = False 加上空的 AppearanceRollover。這帶來兩個設計上必須繞著走的後果。第一,False 哨兵的意思是「沒讀到內容,所以寫回時不會動這個模式」,不是「字典裡沒有 /R 這個鍵」。第二,記錄偵測不到檔案裡已經存在的空串流:被舊版本或別的工具弄壞的文件讀回來乾乾淨淨,把記錄指派回去既不會修好它,也不會弄得更糟。要揪出這些檔案得靠位元組層級的檢查,TPdf.ValidatePdfA 與PDFium Component 的 PDF/A 預檢驗證流程正是做這件事的
想刻意清掉某個外觀,該怎麼做?
把哨兵明確設成 True 再傳入空字串,setter 就會照寫。直接在 SetAnnotationData 裡擋掉空字串,是這個 bug 最省事的修法,但也會弄壞刻意清除外觀的呼叫端——HasContents 與 HasAuthor 對文字遵循的正是同一份契約。所以修正全部落在 getter,setter 繼續照單全收呼叫端的要求:
// 先替換 rollover 外觀,再把它清掉
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover 為 True,文字往返後仍是 'q Q'
A.HasAppearanceRollover := True; // 明確重申意圖
A.AppearanceRollover := ''; // 刻意寫出一條空串流
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// 讀回來是 HasAppearanceRollover = False 加空字串:
// 空串流與缺席的串流在這裡分不出來
請記住,在前面引用的 PDF/A 規則下,被刻意清空的 /R 或 /D 照樣算多出來的鍵。目標若是封存設定檔,唯一能通過驗證的形狀是寫出非空的 /N、其餘兩個模式原封不動。任何在文件之間搬移註解的流程都該守同一條規矩,例如PDFium Component 的 XFDF 匯出與匯入:來源真正擁有的模式才複製,其餘哨兵一律維持 False
讀取、修改、寫回:不破壞 PDF/A 的安全套路
升級到 v3.121.1 以上,外觀哨兵照 getter 回傳的原樣保留,出貨前驗證存好的檔案。由於殘留的空串流讀回來像缺席,驗證步驟必須檢查序列化後的文件而不是記錄本身,好在每批跑一次的成本很低:
uses
PDFium, FPdfPdfa; // TPdfAValidationIssue 宣告於 FPdfPdfa
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// 驗證 Pdf 目前載入的文件,包含開檔之後
// 透過 Pdf.Annotation[] 做過的所有編輯
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
同樣的紀律也適用於任何為審閱而上色或加註頁面的面板,用 PDFium Component 打造 Delphi 註解審閱流程一文涵蓋的就是這種工作流:記錄只是引擎讀得到什麼的快照,不是您自己設的哨兵,就該原樣帶回去。完整的註解 API、PDF/A 預檢與原生 PDFium 引擎,一起隨 PDFium Component for Delphi, C++Builder and Lazarus 出貨