技術文章

headless 渲染器如何繼承 widget 欄位外觀

有欄位值卻沒有外觀串流的 PDF 表單,經過 Linux 渲染作業可能變成空白方塊、錯的字型或對齊,或丟出一個訊息為「Widget field requires a name field type」的 EHPDFHeadlessRenderError。HotPDF 的 headless 渲染器為文字、按鈕與選擇型 widget 繪製臨時外觀,沿欄位樹繼承 /FT、/V、/DA 與 /Q

widget 把欄位設定放在哪裡

大多數麻煩始於欄位與其 widget 的分工。欄位字典保管表單控制項的邏輯部分:類型、值、旗標與預設外觀。widget 註解保管頁面需要的東西:/Rect、邊框、指向所屬頁面的指標。只有一個 widget 的欄位通常合併成單一字典。同一個值得出現在多個頁面時,widget 就成為欄位的子項,往往只帶幾何資訊與一個 /Parent 參照。ISO 32000-1 §12.7.3.1 讓 /FT、/Ff、/V 與 /DV 沿這條鏈可繼承(表 220),§12.7.3.3 則只把兩個可變文字屬性加進表 222 的可繼承集合:/DA(必填,但可來自祖先)與 /Q。互動式表單字典(表 218)提供全文件層級的 /DA 與 /Q 預設值,外加 /DR——查找 /DA 字型名稱時用的預設資源字典。/DR 不在表 222 裡;它屬於表單整體。只讀 widget 字典的渲染器,在下面的檔案裡找不到類型、值或 /DA,便退回自己的預設字型

1 0 obj  % 目錄
<< /Type /Catalog /Pages 2 0 R
   /AcroForm << /Fields [10 0 R] /DA (/Helv 0 Tf 0 g) /Q 0
                /DR << /Font << /Helv 20 0 R /Cour 21 0 R >> >> >> >>
endobj
10 0 obj  % 終端欄位:類型、值與外觀預設都在這裡
<< /FT /Tx /T (invoice_no) /V (INV-2026-0417)
   /DA (/Cour 10 Tf 0 0 0.6 rg) /Q 2
   /Kids [11 0 R 12 0 R] >>
endobj
11 0 obj  % 第 1 頁的 widget:只有幾何,角點先給右上
<< /Type /Annot /Subtype /Widget /Parent 10 0 R
   /Rect [300 720 160 700] /F 4 /P 3 0 R >>
endobj
12 0 obj  % 第 2 頁的 widget:只有幾何
<< /Type /Annot /Subtype /Widget /Parent 10 0 R
   /Rect [160 60 300 80] /F 4 /P 4 0 R >>
endobj

HotPDF 先在 widget 上找每個欄位項目,找不到就沿 /Parent 一層字典一層字典往上爬;最近的值贏,只有 /DA 與 /Q 在整條鏈走完之後才退回 AcroForm 字典。就上面的檔案而言,預期結果是兩個 widget 都以 10 點 Courier、深藍色、右對齊顯示 INV-2026-0417,全文件層級的 /Helv 0 Tf 從頭到尾派不上用場,因為物件 10 有自己的 /DA。當欄位反而以 12 點黑色 Helvetica 出現,頭號嫌犯是那台 Linux 機器上缺 Courier 字型。字的大小出賣了它——那正是 widget 上、任何祖先上、AcroForm 字典裡都找不到 /DA 時,HotPDF 畫出來的東西。所以把 widget 傾印出來,手動沿著 /Parent 走,找出產品把 /DA 放在哪裡,例如放在 widget 從未回指的欄位字典上

Delphi 版 HotPDF headless 渲染的 AcroForm 物件圖:只有幾何資訊的 widget 沿 /Parent 爬向持有 /FT、/V、/DA 與 /Q 的欄位
/Parent 鏈上最近的值贏,鏈走完之後只有 /DA 與 /Q 退回 AcroForm 字典

/DA 裡的字型名稱按這個順序解析:widget 或父欄位上的 /DR、AcroForm 的 /DR、頁面的字型資源。欄位層級的 /DR 不在表 222 裡,但它只要存在就毫無歧義,所以 HotPDF 接受它。向上爬是有界的:樹裡出現環時以「Widget field inheritance contains a cycle」停住,/Parent 不是字典時以「Widget Parent requires a dictionary」停住,鏈深超過渲染選項裡的 MaxDepth(預設 32)時以「Widget field inheritance depth budget exceeded」停住。VCL 元件在讀取繼承的 AcroForm 值並重設欄位時,套用同一套繼承

headless 渲染器如何決定畫什麼?

HPDFRenderHeadlessPagePNG 與其檔案版本,只對可見且 /AP 缺失或為 null 的 widget 跑後備繪製;ISO 32000-1 §7.3.7 說 null 字典值等同條目缺席,渲染器也把 /AP null 當成這樣對待。已存在的一般外觀永遠贏,/NeedAppearances 說什麼都一樣,所以先前填表留下的過期外觀會一直顯示,直到有東西重新產生它。已載入的文件絕不被修改。畫什麼,取決於解析出的欄位類型與旗標:

欄位合成的外觀
單行文字,/FT /Tx字串 /V,以解析出的 /DA 與 /Q 排版在 /Rect 內
核取方塊,/FT /Btn矩形邊框配 /Border 給定的圓角半徑、/MK /BG 填色,選中時加一道描邊的向量勾選記號
選擇鈕,Btn 設定位元 16圓形邊框,選中時加實心圓點
按鈕,Btn 設定位元 17矩形邊框,/MK /CA 標題文字置中
下拉式方塊,/FT /Ch 設定位元 18所選 /Opt 項目的顯示文字作為置中標題
清單方塊,Ch 位元 18 未設自 /TI 起每個選項一列,每個選取列後方有高亮色帶

勾選記號與選擇鈕圓點是向量路徑,而不是許多產生器採用的 ZapfDingbats 字形,因為精簡的 Linux 容器裡可能根本沒裝 ZapfDingbats。按鈕與下拉式方塊的面貌與 HotPDF 的 VCL 外觀產生器為同一欄位畫出的一致,所以無論圖像由哪一端產出,表單看起來都相同。標題文字的字型、大小與顏色取自 /DA;標題排版不會自動縮放,/DA 大小為 0 時取方塊高度的 70%,上限 12 點。核取方塊與選擇鈕的狀態來自 widget 自己的 /AS,退回到繼承的 /V,任何非 Off 的狀態都畫出記號。給每個選擇鈕子項自己的 /AS。父項的 /V 為整組指名選中狀態,分不清哪個子項是哪個

Delphi 版 HotPDF headless 渲染器的決策流程:/AP 存在時永遠獲勝,否則由解析出的欄位類型挑選合成面貌
只有 /AP 缺失或為 null 時才繪製臨時外觀,已載入的文件本身絕不被修改
uses
  SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;

function RenderFormPage(const FileName, PngName: string; PageIndex: Integer;
  out Diagnostic: string): Boolean;
var
  Doc: THPDFHeadlessDocument;
  Options: THPDFHeadlessRenderOptions;
  Info: THPDFHeadlessRenderResult;
begin
  Result := False;
  Diagnostic := '';
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(FileName);
    Options := THPDFHeadlessRenderOptions.Default;  // RenderAnnotations = True, MaxDepth = 32
    Options.Usage := hruPrint;  // 如同檢視器的列印:沒有 Print 旗標的 widget 一律跳過
    try
      // PageIndex 以 0 為基底
      Info := HPDFRenderHeadlessPagePNGFile(Doc.GetRenderAccess(PageIndex),
        PngName, Options);
      Diagnostic := Format('%d appearances drawn', [Info.Appearances]);
      Result := True;
    except
      on E: EHPDFHeadlessRenderError do
        // 例如 'Widget field requires a name field type';表單已填值時,
        // 別用 RenderAnnotations := False 重試,那會連 widget 帶值一起丟掉
        Diagnostic := E.Message;
    end;
  finally
    Doc.Free;
  end;
end;

文字欄位的後備繪製只處理持有字串值的單行欄位。更豐富的文字排版,請在填表當下於 Windows 端產生外觀——EnsureLoadedFieldAppearanceStream 會在檔案扁平化或出貨之前補出缺失的 /AP 串流;Linux 作業於是收到真正的外觀串流,而 headless 渲染器會指名它遇到的任何仍缺外觀的欄位。不過,判斷 Linux 輸出要用 Linux 渲染器。RenderLoadedPageToBitmap 透過 Windows GDI 後端繪製,從不跑這套後備邏輯,所以那裡看起來正常的頁面,對 Cairo 裝置會從同一份檔案產出什麼,毫無預測力

欄位旗標的位元從 1 起算

ISO 32000-1 §12.7.3.1 規定 /Ff 字組的位元位置從低位端的 1 數到 32,表 226、228 與 230 都用這套編號。所以在 Delphi 裡,位元 N 對應 1 shl (N - 1)。照位元編號本身去位移,每個測試都會上移一格:為「radio,位元 16」寫的 1 shl 16 實際測的是 pushbutton 旗標,為「combo,位元 18」寫的 1 shl 18 測的是 Edit。兩個錯的遮罩都是真實旗標。程式碼照樣編譯、照樣執行,然後把欄位歸錯類

PDF 欄位旗標在 Delphi 裡從 1 起算位元:尺規圖顯示為何 radio 是 1 shl 15,而 1 shl 16 抵達 pushbutton
/Ff 字組的位元 N 採用 Delphi 遮罩 1 shl (N - 1),每個差一位元的遮罩都是相鄰位元的真實旗標
旗標表位元Delphi 遮罩
Multiline228(文字)131 shl 12 = 4096
Password228(文字)141 shl 13 = 8192
Comb228(文字)251 shl 24 = 16777216
RichText228(文字)261 shl 25 = 33554432
Radio226(按鈕)161 shl 15 = 32768
Pushbutton226(按鈕)171 shl 16 = 65536
Combo230(選擇)181 shl 17 = 131072
uses
  Classes, SysUtils, HPDFHeadlessDocument;

const
  // ISO 32000-1 對 Ff 的位元從 1(低位端)起算,所以位元 N 是 1 shl (N - 1)
  TxMultiline   = 1 shl 12;  // 位元 13
  TxPassword    = 1 shl 13;  // 位元 14
  TxFileSelect  = 1 shl 20;  // 位元 21
  TxDoNotScroll = 1 shl 23;  // 位元 24
  TxComb        = 1 shl 24;  // 位元 25
  TxRichText    = 1 shl 25;  // 位元 26
  TxProducerLayout = TxMultiline or TxPassword or TxFileSelect or
    TxDoNotScroll or TxComb or TxRichText;

procedure ListTextFieldsForProducerAppearance(const FileName: string;
  Report: TStrings);
var
  Doc: THPDFHeadlessDocument;
  Field: THPDFHeadlessFormField;
  I: Integer;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(FileName);
    for I := 0 to Doc.FormFieldCount - 1 do
    begin
      // FieldType、Flags 與 MaxLength 抵達時已繼承自祖先
      Field := Doc.GetFormField(I);
      if Field.FieldType <> 'Tx' then
        Continue;
      if (Field.Flags and TxProducerLayout) <> 0 then
        Report.Add(Format('%s: flags $%.8x, needs a producer /AP',
          [Field.Name, Field.Flags]))
      else if Field.MaxLength > 0 then
        Report.Add(Format('%s: /MaxLen %d, needs a producer /AP',
          [Field.Name, Field.MaxLength]))
      else if Field.Value = '' then
        Report.Add(Format('%s: blank, fill it or give it an /AP', [Field.Name]));
    end;
  finally
    Doc.Free;
  end;
end;

掃描讀的是欄位層級的旗標——產品正是把旗標放在那裡;渲染器也認得獨立 widget 上的 /Ff,所以把清單當規劃輔助,把渲染診斷當最終裁決。空白檢查很重要,因為沒有 /V 也沒有 /AP 的文字欄位無東西可合成:渲染以「A text-field widget without a value has no appearance to synthesize」收場,解方是在檔案抵達渲染器之前補上值或外觀

「Degenerate appearance geometry」是什麼意思?

它指的是矩形或外觀方塊沒有面積,而反轉的 /Rect 不是原因。ISO 32000-1 §7.9.5 允許矩形以任兩個對角角落指名,其註記要處理軟體做好把它們正規化的準備。想都不想就把寬度算成 x2 - x1 的渲染器,從 [300 720 160 700] 得到負數,接著不是拒絕註解,就是鏡射它的外觀。HotPDF 在任何尺寸檢查之前,先把座標換成左下與右上的順序,合成面貌與既有 /AP 串流一視同仁,所以上面的 widget 11 渲染起來如同角點是常規寫法。這個錯誤只針對帶有外觀串流的註解拋出,且是在正規化之後 /Rect 寬或高為零,或串流的 /BBox 在其 /Matrix 之下塌縮成線段或點時。錯誤出現時,把 widget 的 /Rect 連同串流的 /BBox 與 /Matrix 一起傾印出來:三者之一有零尺寸

沒有 /AP 且 /Rect 為零面積的 widget 走另一條路:後備邏輯跳過它,繼續畫頁面。ISO 32000-2 §12.5.2 只豁免 Popup、Projection 與 Link 註解,以及塌縮成單一一點的矩形。僅是一條線的矩形在 PDF 2.0 下仍欠一份外觀;渲染器連它也跳過,純粹因為沒有面積可畫

找不到欄位類型時

「Widget field requires a name field type」意味著渲染器從 widget 沿 /Parent 連結爬到了頭,卻沒找到 /FT。渲染器從頁面的 /Annots 陣列出發,向上的 /Parent 連結是它通往欄位的唯一路徑。載入走的是另一個方向,從 AcroForm 的 /Fields 陣列沿 /Kids 向下,這就是 LoadFromFile 能在一份頁面後來渲染失敗的檔案上成功的原因。典型病因是欄位 /Kids 裡列著的 widget 沒有回指。ISO 32000-1 表 188 給了這種做法一點依據:它只要求 widget 是多個子項之一時帶 /Parent,並寫著「其餘情況省略」,於是照字面實作的寫入端,會在只有一個獨立 widget 的欄位上省掉連結。ISO 32000-2 已把同一條目放寬為「其餘情況選用」。無論如何,我們會在每個未與欄位合併的 widget 上寫 /Parent。ISO 32000-2 允許這樣做,而這也是頁面驅動的讀取端——我們的也罷、別人的也罷——能夠觸及欄位的唯一方法。無法修正產品時,把單獨的 widget 合併進其欄位字典能得到同樣效果

widget 回指其父項、並在向上的路上某處找到 /DA,那麼即使毫無 /AP,在HotPDF Delphi component 的 headless 渲染器裡也能正確渲染——這使得在 Linux 表單管線裡,最該先弄對的是欄位樹,而非外觀串流