技術文章

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

一份問卷表單在 Acrobat 裡顯示兩個下拉清單,group.a 與 group.b。用「任何帶自己 /FT 的節點就是成品欄位」的假設走訪 /AcroForm /Fields,數出來是 1:一個叫 group 的欄位、帶兩個 widget。用名字要 group.b,查找失敗。打開子節點的字典,裡面根本沒有 /FT,沒有 /Ff,也沒有選項清單。東西沒有壞:型別、旗標與選項都放在上一層的父節點,而 PDF 規範明文允許它們住在那裡

這種版面合法且省位元組。製作工具寫一次帶 /FT /Ch、/Ff 131078 與 /Opt 陣列的群組節點,底下掛兩個具名子節點,各是一個欄位加 widget 的合併字典,只握有 /T、/Parent、/Rect 與自己的 /V。正確讀懂這種表單需要三件事:為每個可繼承鍵追隨 /Parent、判斷欄位樹真正的結尾、確保重設寫入的是正確 PDF 型別的值。本文從以 Delphi 為已載入 PDF 的表單欄位設值一文的終點接著講

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

ISO 32000-1 §12.7.3.1(Table 220)把 /FT、/Ff、/V 與 /DV 標為可繼承,§12.7.4.3 的 Table 229 對文字欄位的 /MaxLen 同樣如此。選項陣列在這裡也比照辦理,因為跨子節點共用選項清單的生產器會把 /Opt 存在群組上。只檢查本地字典的讀取器,因此會對一個完全合法的子節點回報錯誤型別、旗標 0 與空值

HotPDF Delphi Component 對這類讀取全用同一套答案。它在欄位自己的字典裡找鍵,找到了就解析間接參照、停手。找不到就沿 /Parent 上行再試,至多 128 層。這個上限不是裝飾。損壞檔案裡的 /Parent 指標可能形成與 /Kids 毫無關係的環,無條件信任它們的讀取器會在碰到第一個這種檔案時掛掉

這趟走訪裡有條規則很容易弄反:第一個含有該鍵的字典說了算,即使那裡存的值是空字串。group.b 上的本地 /V () 是作者在說「這個子節點是空白的」,走訪就此停住,不會再爬上去拿父節點可能握有的值。把空當「不存在」處理,會讓被清空的子節點顯示父節點的選取,與作者寫下的意思正好相反

HotPDF 繼承 AcroForm 屬性圖解:群組節點只寫一次 /FT、/Ff 與 /Opt,具名子節點 group.a 與 group.b 只握 /T、/Parent、/Rect 與本地 /V,展示繼承屬性查找沿 /Parent 上行至多 128 層——第一個含鍵的字典說了算,本地的空值刻意遮蔽父層
HotPDF 以同一個沿父層上行的解析器解析 /FT、/Ff、/V、/DV 與 /Opt,具名子節點保持可定址,而本地的空值刻意蓋掉上方群組帶的一切

公開的 getter 全都坐在這套查找上:GetFormFieldType、GetFormFieldValue、GetLoadedFormFieldFlags、IsFormFieldRequired、IsFormFieldNoExport、GetLoadedFormFieldMaxLength、GetLoadedFormFieldDefaultValue,以及選項輔助函式 GetLoadedFormFieldOptionCount 與 GetLoadedFormFieldOptions。所以讀取問卷群組的子節點不需要任何特殊程式碼:

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;

GetFormField 每次回傳一個新的 THPDFLoadedFormField 包裹物件,名字不存在時拋出例外:把呼叫放在包裹物件的 try 區塊之外,並自己釋放包裹物件

找出欄位樹在哪裡結束

拿本地 /FT 當終端欄位的判準是錯的,因為父節點可以提供型別、同時還擁有具名子欄位。誘人的走訪法是:節點有自己的 /FT 或沒有 /Kids 就不再往下。在問卷表單裡,group 兩者兼有——/FT /Ch 與 /Kids——於是這種走訪把它註冊成一個欄位、兩個 widget 註解。group.a 與 group.b 這兩個名字消失,欄位數是 1,按子名查找失敗,值的寫入也只能落在共用的父節點上

HotPDF 改看 kids。一個 kid 在有自己的 /T、有自己的 /Kids、或不是 /Subtype /Widget 字典時,算一個子欄位。只有沒有任何 kid 符合時,節點才算終端,其 kids 這時當作它的 widgets。這條規則背後的兩個邊界情況都來自合併字典——§12.7.3.1 允許欄位恰有一個 widget 時這樣寫:

  • 帶名的合併字典帶 /Subtype /Widget,仍然是子欄位。光憑 subtype 不能把它推進父節點的匿名 widget 清單,/T 說了算
  • 有些生產器在每個匿名 widget 上重複父層的 /FT。所以 widget 上的 /FT 也不能讀成「這裡開始一個新欄位」

同一套分類也驅動欄位索引、FormFieldExists 與 RemoveFormField。這幾趟走訪同樣記錄已拜訪的字典,超過 128 層就放棄。假設群組是物件 5,還把自己的物件號列進自己的 kids 兩次:/Kids [5 0 R 5 0 R 6 0 R 7 0 R]。它照樣回報恰好兩個欄位,即子節點 6 與 7。沒有無限遞迴,也沒有自引用製造出的第三個幽靈欄位

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

RemoveFormField 只刪你點名的那個子節點,因為發現與刪除共用同一套終端欄位定義。按名刪除的多載先從欄位清單解析出欄位索引,再把 /AcroForm /Fields 重走一遍、數終端欄位,找出該刪的字典。若第二趟走訪用了誘人的 /FT 規則而第一趟用 kid 規則,兩邊的索引就會對不上:刪除走訪會把 group 當欄位 0,刪 group.a 會連父節點、兄弟節點與兩個 widget 一起端走

所以刪除走訪套用與發現相同的子欄位測試與同一個已拜訪集合。它收集屬於被刪子節點的 widget 註解,只把這些從每頁的 /Annots 摘掉,父節點只有在 /Kids 陣列最後空掉時才移除。group.b 還在清單上,父節點就留著,group.b 繼承的型別、旗標與選項也跟著留下

HotPDF RemoveFormField 兄弟節點存活圖解:刪除走訪使用與發現相同的子欄位測試與已拜訪集合,只把具名子節點 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');

在自己的表單上驗收刪除結果時,看三個地方,因為各自抓的是不同的錯:父節點的 /Kids、頁面的 /Annots、以及存活兄弟節點的值與外觀。SaveLoadedDocument 之後驗一次,SaveIncrementalUpdate 之後再驗一次,因為增量存檔只附加被改動的物件,父節點的 /Kids 必須在其中

ResetLoadedFormField 從繼承的 /DV 複製什麼

ResetLoadedFormField 寫入的本地 /V 是繼承 /DV 的新副本、PDF 物件型別相同,而且它在碰欄位之前先驗證完整的預設值。核取方塊的預設是 /Yes 這類名稱物件;多選清單方塊的預設是字串陣列;文字預設可能是 <FEFF...> 這類十六進位 UTF-16 字串。把其中任何一個經 GetLoadedFormFieldDefaultValue 與 SetFormFieldValue 往返一趟,你會拿到該是名稱物件處的字串、原本是陣列處的空字串、或字面上的十六進位數字

因此重設依繼承 /DV 的實際型別分支:

  • 帶字串預設的文字與 choice 欄位,拿到保留原十六進位旗標的新字串物件
  • 帶陣列預設的 choice 欄位,拿到裝著新字串的新陣列;陣列裡混入非字串項目會拋出例外
  • 核取方塊與選項按鈕拿到新的名稱物件
  • 其他任何型別的預設都拋出例外,/V 與 /I 原封不動
HotPDF 帶型別重設圖解:ResetLoadedFormField 依繼承 /DV 的物件型別分支——核取方塊寫新的名稱物件,多選 choice 寫裝著新字串的新陣列,hex 文字寫保留 IsHexadecimal 的字串,無 /DV 時寫空字串或 /Off,型別不合時拋出例外且不碰 /V 與 /I
複製而非指向父層物件,讓日後的改值不會悄悄改動預設;按鈕與簽章欄位走單純的字串路徑

用複製而非參照是刻意的,也是唯一安全的選項。指向父層 /DV 陣列的子節點 /V,會讓「目前值」與「預設值」共用同一個物件;下一次對該子節點的 SetFormFieldValue 會悄悄改寫所有兄弟節點的預設。每個重設欄位多花幾個位元組,換來兩者分明,划算。沒有值的按鈕(Table 226,位元 17)與簽章欄位走單純路徑,寫入自己 /DV 的字串副本

整條繼承鏈上都沒有 /DV 時,方法寫入本地空字串,核取方塊與選項按鈕欄位則寫 /Off。刪掉子節點的 /V 看起來更整潔,答案卻是錯的。若父節點握有目前值,移除子節點的覆寫會把那個值原樣請回來,「重設」過的欄位於是顯示使用者從未輸入過的東西

var
  Field: THPDFLoadedFormField;
begin
  // 父節點 'palette' 是 MultiSelect 清單方塊,帶
  // /Opt [(b) (g) (r)] 與 /DV [(b) (r)];子節點只握 /T 與 /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // 子節點現在有自己的 /V [(b) (r)] 與 /I [0 2];
      // 父節點的 /DV 陣列原封不動
    except
      on E: Exception do
        // 畸形預設(型別錯誤、陣列含非字串項目):
        // /V 與 /I 維持原樣
        Writeln('Reset skipped: ', E.Message);
    end;
    // 純量 getter 無法表達陣列預設
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // empty
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

單一欄位的重設也與 §12.7.5.3 的 ResetForm 動作是兩回事——那是使用者在按鈕上點擊時、檢視器對一組欄位執行的動作,以 HotPDF 建構 AcroForm 欄位與動作一文講的是那個。ResetLoadedFormField 編輯一個已載入欄位、對無預設情況遵循自己的規則,並把欄位標記為已變更,增量重算才接得住

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

重設要正確,選取索引與外觀狀態必須跟著值走,ResetLoadedFormField 收尾時做與 SetFormFieldValue 相同的兩步後續。對 choice 欄位,它移除子節點的本地 /I,把每個值對上每條 /Opt 項目的匯出半邊,寫出新的已排序 /I。對著選項 b、g、r 重設為 [(b) (r)],得到 /I [0 2]。父節點繼承的 /I 若存在,原封不動

按鈕從繼承的型別辦事。/FT /Btn 住在父層的子核取方塊,其 /AS 照樣會更新。寫入端,SetFormFieldValue 與 SetLoadedFormFieldDefaultValue 對繼承的非按鈕型按鈕存名稱物件,即使子節點沒有本地項目可供抄型別。而 EnsureLoadedFieldAppearanceStream 重建按鈕外觀時,除非值與 on 狀態相符,否則寫 /AS /Off,並給每個狀態串流 /Type /XObject、/Subtype /Form 與一個 /BBox。少了那道 /Off 檢查,重設後重建外觀會讓核取方塊在檔案還沒存檔前就再次呈現勾選

純量 getter 與其他該先想好的邊界

字串 getter 維持純量,拿它們的輸出做比較的程式碼該知道它們壓平了什麼。GetFormFieldValue 與 GetLoadedFormFieldDefaultValue 對陣列值回傳空字串,把數字與布林轉成 42 或 true,hex 編碼字串以其十六進位拼寫回報。若你需要知道某清單方塊的預設是選了兩項還是零項,預設 getter 答不了:兩者回來都是空字串

/Parent 成環時查找靜默結束、不拋例外,所以型別迷失在環裡的欄位會回報 lfftUnknown 與旗標 0。具名欄位出現 lfftUnknown,值得記進日誌——那是檔案受損的跡象。SetFormFieldValue 與 ResetLoadedFormField 永遠寫你定址的那個子節點,絕不把值升級到共用父層。這對問卷裡兩個獨立的下拉清單是正確的。選項按鈕群不同:選取屬於擁有群組的欄位,所以請定址那個欄位,而不是它的某個 kid

每個呼叫也各自提交一個欄位。對二十個欄位的重設迴圈是二十次獨立編輯;第五個因畸形預設拋出例外時,前四個已經套用。批次工作請先讀完每個目標的型別與預設、決定要碰哪些欄位,然後才開始寫

繼承屬性查找、以 kid 為準的欄位樹分類與帶型別的重設,都屬於HotPDF Delphi Component 的已載入表單 API;需要為既有文件新增欄位的 Delphi 與 C++Builder 專案也涵蓋在內,如以 Delphi 為已載入 PDF 加入 AcroForm 欄位一文所示