技術文章

Delphi 中的 AcroForm 欄位繼承值與重設

HotPDF Delphi Component 把已載入 AcroForm 欄位上的 /FT、/Ff、/V 與 /DV 當成可繼承屬性,沿著 /Parent 鏈向上解析。從 v2.754.3 與 v2.754.4 起,型別來自父節點的具名子欄位可以單獨定址,RemoveFormField 不會動到它的兄弟欄位,而 ResetLoadedFormField 會以原本的 PDF 物件型別複製繼承來的預設值。在此之前,多得驚人的普通表單被誤讀

把這一切暴露出來的表單一點都不特殊。作者工具建立一個群組節點 group,只帶一次 /FT /Ch、欄位旗標與選項清單,底下掛兩個具名子節點 a 與 b,每個都是欄位加介面元件的合併字典,身上只有 /T、/Parent、/Rect 與自己的 /V。這是完全合法的屬性共享方式,也正是在 Delphi 中設定已載入 PDF 的表單欄位值那篇 Limits 一節點名未處理的情況:按鈕對帳只看了本地的 /FT。本文從那裡接著講:欄位樹怎麼分類、繼承值怎麼讀、單欄位重設允許寫什麼

欄位可以從父節點繼承哪些 AcroForm 條目?

ISO 32000-1 §12.7.3.1 的表 220 把 /FT、/Ff、/V 與 /DV 標為可繼承,§12.7.4.3 的表 229 對文字欄位的 /MaxLen 也是,所以只看本地字典的讀取器,會對完全合法的子欄位報出錯的型別、錯的旗標與空值。HotPDF 把這些讀取全部收斂到一個內部解析器 HPDFLoadedInheritedFieldObject:先在字典裡找鍵,找到間接參照就解析,否則沿 /Parent 最多走 128 層,因為畸形檔案能造出跟 /Kids 毫無關係的 /Parent 環。公開的 getter 坐在它上面:GetFormFieldType、GetFormFieldValue、GetLoadedFormFieldFlags、IsFormFieldRequired、IsFormFieldNoExport、GetLoadedFormFieldMaxLength、GetLoadedFormFieldDefaultValue,以及選項輔助 GetLoadedFormFieldOptionCount 與 GetLoadedFormFieldOptions,後者也會撿到存在父節點上的 /Opt 陣列。解析器裡有一條規則很容易搞錯:走訪停在第一個含有該鍵的字典,就算那裡的值是空字串也一樣。本地的 /V () 是刻意的覆寫,把父節點蓋住,不是等著從樹上更遠處補值的缺口

HotPDF 的 AcroForm 繼承屬性示意圖:群組節點只帶一次 /FT、/Ff 與 /Opt,具名子節點 group.a 與 group.b 只有 /T、/Parent、/Rect 與本地 /V,HPDFLoadedInheritedFieldObject 沿 /Parent 最多走 128 層,第一個含鍵的字典勝出,本地空值蓋住父節點
HotPDF 用同一個走訪父節點的解析器處理 /FT、/Ff、/V、/DV 與 /Opt,具名子欄位因此保持可定址,而本地空值刻意蓋掉上方群組的一切
var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' 帶 /FT /Ch、/Ff 131078 與 /Opt;子節點
    // 'group.b' 只有 /T、/Parent、/Rect 與自己的 /V
    Field := Pdf.GetFormField('group.b');
    try
      if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
      begin
        // 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
        Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
        Writeln(Pdf.IsFormFieldRequired(Field.Index));    // TRUE
        Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
        Writeln(Pdf.GetFormFieldValue(Field.Index));       // 本地的 /V
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

為什麼本地有沒有 /FT 不是終端欄位的好判準?

因為父節點可以供應型別、同時又擁有具名子欄位,所以 /FT 的存在說明不了欄位樹在哪裡結束。舊走訪只要節點自帶 /FT 或沒有 /Kids 就判它終端。在上面的表單裡,group 既帶 /FT /Ch 又帶 /Kids,於是被註冊成一個名為 group、帶兩個介面元件的欄位,而完整限定名 group.a 與 group.b 就這麼憑空消失。GetFormFieldCount 回傳 1,按子名查詢失敗,SetFormFieldValue 只能寫共用的父節點。替代的測試 HPDFLoadedFieldHasChildFields 改看 kids 而不是父節點:一個 kid 是子欄位,當它有自己的 /T、有自己的 /Kids,或根本不是 /Subtype /Widget 字典。只有當沒有任何 kid 符合,節點才是終端,kids 當成它的介面元件註記處理

塑造那條規則的兩個邊界情況都來自合併字典,§12.7.3.1 允許欄位只有單一介面元件時合併。帶名的合併字典身上有 /Subtype /Widget,但它仍然是子欄位,所以單憑 subtype 不能把它丟進父節點的匿名元件清單;/T 說了算。反過來的情況也存在:有些製造器把父節點的 /FT 複寫到每個匿名元件上,所以 /FT 也不能當成「這個元件開啟新欄位」的證據。這套分類由關係快取、FormFieldExists 與 RemoveFormField 共用,每一處走訪現在都記下已造訪的字典、超過 128 層就停。某個迴歸檔案的群組把自己列了兩次,/Kids [5 0 R 5 0 R 6 0 R 7 0 R],它照樣精確回報兩個欄位,而不是無限遞迴、或把同一節點數兩次

RemoveFormField 怎麼避免刪掉兄弟欄位?

RemoveFormField 現在只刪您點名的那個子欄位,因為發現與刪除終於對「什麼是終端欄位」達成一致。這個一致比看起來重要。按名稱的重載先透過關係快取解析索引,再在第二次走訪 /AcroForm /Fields 時數終端欄位。就算快取修好了、看得見 group.a 與 group.b,沒修的刪除走訪仍會把 group 當成單一終端欄位,索引 0 就會把父節點連同每個兄弟與它們的元件全部移除。刪除走訪現在用同一個 HPDFLoadedFieldHasChildFields 測試與同一個已造訪集合,只收集被刪子欄位的介面元件註記、把它們從每頁的 /Annots 拆下,並且只在父節點的 /Kids 陣列清空時才移除父節點。迴歸檢查了錯誤會現形的三個地方:父節點的 /Kids、頁面的 /Annots,以及存活兄弟的值與外觀,完整重寫與增量更新之後都查

HotPDF 的 RemoveFormField 兄弟存活示意圖:刪除走訪重用發現階段的 HPDFLoadedFieldHasChildFields 與已造訪集合,只把具名子欄位 group.a 從 AcroForm /Fields 與頁面 /Annots 拆除,共用父節點保留,因為 /Kids 陣列裡還有存活的 group.b
發現與刪除終於對終端欄位的定義一致,所以刪掉一個具名子欄位之後,兄弟的值與外觀在完整重寫或增量更新下都完好
// 刪一個具名子欄位;兄弟與共用父節點都活下來
Pdf.RemoveFormField('group.a');

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// 型別、旗標與選項仍透過父節點解析
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');

預設值是繼承來的時候,ResetLoadedFormField 寫什麼?

ResetLoadedFormField 寫入的本地 /V,是繼承 /DV 的新副本、帶相同的 PDF 物件型別,而且它在碰欄位之前先驗證整個預設值。物件型別要緊,因為純量 getter 會把一切壓平成文字。核取方塊的預設是 /Yes 這樣的名稱,多選清單方塊的預設是字串陣列,文字預設可能是十六進位的 UTF-16 字串;用 GetLoadedFormFieldDefaultValue 拷貝任何一個,都會把名稱變字串、陣列變空字串、十六進位字串變成它的字面數字。所以重設按繼承型別分支:文字與 choice 欄位拿到新的字串物件、保留 IsHexadecimal 旗標;帶陣列預設的 choice 欄位拿到新的陣列、裝新的字串;非 pushbutton 的按鈕拿到新的名稱物件。用拷貝、而不是指向父節點的物件,是刻意的:共用父節點 /DV 陣列或物件編號的 /V,會在下次任何人編輯值時把預設一起改掉。型別不對的預設、或含有非字串成員的 choice 陣列,會丟例外,/V 與 /I 原封不動。pushbutton(表 226,bit 17,沒有值)與簽章欄位退回較舊的純字串路徑

HotPDF 的型別化重設示意圖:ResetLoadedFormField 按繼承 /DV 的物件型別分支,核取方塊寫新的名稱物件,多選 choice 寫新的字串陣列,hex 文字寫保留 IsHexadecimal 的字串,沒有 /DV 時寫空字串或 /Off,型別不符則丟例外且不碰 /V 與 /I
用拷貝、不指向父節點的物件,之後的值編輯就不會悄悄改掉預設;pushbutton 與簽章欄位則退回較舊的純字串路徑

鏈上任何地方都沒有 /DV 時,這個方法靠寫入本地空字串維持它的清空契約,核取方塊或單選欄位則寫 /Off。刪掉本地 /V 看起來更俐落,但是錯的:父節點可能握有當前值,拿掉子節點的覆寫會悄悄把那個值請回來。這也是為什麼單欄位重設不是 §12.7.5.3 的 ResetForm 動作,那是使用者在按鈕上按一下時、檢視器對一組欄位跑的東西,如用 HotPDF 建 AcroForm 欄位與動作所述。ResetLoadedFormField 是對單一已載入欄位的編輯操作,無預設的情況有它自己的規則,而且它透過 NoteLoadedFormFieldDirty 記錄欄位,增量重算才看得見變化

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // 父節點在 MultiSelect 清單方塊上帶 /DV [(b) (r)]:group.a 拿到
    // 自己的 /V [(b) (r)] 與新的 /I [0 2];父節點不動
    Pdf.ResetLoadedFormField(Field.Index);
    // 純量 getter 表示不了陣列預設
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // 空
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

讓 /V、/I 與 /AS 保持一致

重設要算正確,選擇索引與外觀狀態必須跟著值走,所以 ResetLoadedFormField 用與 SetFormFieldValue 相同的兩個對帳器收尾。HPDFReconcileChoiceSelection 現在接受陣列值:它刪掉本地 /I 而不變動它、把每個值對照各 /Opt 條目的匯出半邊比對,寫出一份新的排序 /I,所以對選項 b、g、r 重設為 [(b) (r)] 會得到 /I [0 2]。ReconcileLoadedButtonAppearanceStates 現在索取繼承型別,所以 /FT /Btn 住在父節點上的子核取方塊,終於能把 /AS 設起來。寫入側,SetFormFieldValue 與 SetLoadedFormFieldDefaultValue 對繼承的非 pushbutton 按鈕存入名稱物件,即使子節點沒有本地條目可抄型別。而當 EnsureLoadedFieldAppearanceStream 重建按鈕外觀時,值不符合 on 狀態就寫 /AS /Off,並給每個狀態串流正經的 /Type /XObject、/Subtype /Form 與 /BBox;v2.754.4 之前,重設後重建外觀可能在存檔前又把方塊勾起來

在這之上動工前,值得知道的極限

純量 getter 保持純量。GetFormFieldValue 與 GetLoadedFormFieldDefaultValue 對陣列值回傳空字串,數字與布林字串化成 42 或 true,hex 編碼字串以十六進位拼寫回報。/Parent 環會無例外地終止走訪,所以型別陷在環裡的欄位回報 lfftUnknown 與旗標 0,而不是失敗。SetFormFieldValue 與 ResetLoadedFormField 永遠寫您定址的那個子欄位、從不把值晉升到共用父節點,這對獨立子欄位是對的,但表示單選群組應該透過擁有選擇的那個欄位去定址。而且每次呼叫只提交一個欄位;這裡的任何東西都不會讓一批重設變成交易

這裡描述的繼承屬性解析、統一的欄位樹分類與型別化重設,都是 HotPDF Delphi Component 已載入表單 API 的一部分,支援 Delphi 與 C++Builder;欄位建立的部分見在 Delphi 中為已載入 PDF 新增 AcroForm 欄位