技術文章

Delphi 與 PDFium 的註解外觀往返:空外觀從哪來

v3.121.1 之前的 PDFium Component,透過 TPdf.Annotation[] 讀出註解、再把整筆記錄指派回去時,可能在 /AP 外觀字典裡多出空的 /R 與 /D 條目,就算原本只帶 /N 也一樣。PDF/A 驗證器會拒收這種字典。v3.121.1 起 getter 只回報真正讀到的外觀,因此原樣往返不會多寫任何東西。這個缺陷值得仔細弄懂,因為常見的觸發場景,是一段想把檔案變得更合規的修正

PDFium Component 註解往返示意:透過 TPdf.Annotation[] 與 SetAnnotationData 加上 afPrint 時,FPDFAnnot_SetAP 也寫出空的 /R 與 /D 串流,乾淨的 PDF/A 外觀字典從此被 veraPDF 拒收,直到 v3.121.1 只回報真正讀到的外觀
讀出註解再原樣寫回,過去會多出空的 rollover 與 down 外觀串流,讓 PDF/A 失敗的是它們,而不是您想加的 Print 旗標

把註解原樣寫回去,到底哪裡出錯?

簡短的答案:註解憑空多出它從未有過的外觀串流,編輯前還能通過 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 旗標的這筆編輯,打破的是另一條

ISO 32000-1 的註解外觀字典,含 normal、rollover、down 三個條目:串流缺席與既有空串流,PDFium 都回傳 2 位元組,透過 TPdf 讀起來都是無內容,只有 TPdf.ValidatePdfA 這類位元組層級檢查找得出 PDF/A 不允許的空串流
/R 缺席時退回 /N;空的 /R 會畫出一塊空白矩形且照樣過不了 PDF/A,而透過記錄根本分不出這兩者

外觀明明不存在,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 測試夾具裡現形,而不是在某個檢視器裡

FPDFAnnot_GetAP 在 PDFium 裡如何回報缺席的外觀串流:兩段式呼叫為 UTF-16 結束字元永遠至少回傳 2 位元組,舊的 SizeOf(FPDF_WCHAR) 門檻每次都通過、把所有 HasAppearance 哨兵設成 True,v3.121.1 的門檻則要求超過結束字元長度且位元組數為偶數
2 位元組是空字串的編碼結果,不是外觀存在的證據;修正後的 getter 把不超過結束字元長度的結果一律視為無內容,寫回端因此保持沉默

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 出貨