HotPDF v2.743.0 不再靜默跳過沒有 /AP 外觀串流的 PDF 註解,而是將其展平。FlattenLoadedAnnotations 現在會把沒有外觀的 widget 交給 EnsureLoadedFieldAppearanceStream,並根據沒有外觀的標記註解自身屬性建立 Form XObject,因此在 /NeedAppearances 表單中輸入的值會進入頁面內容,而不會在展平時消失。促成這次改動的故障看起來像什麼都沒做。客戶從瀏覽器列印出一份填好的申請表 PDF。你在 HotPDF 中載入它,呼叫 FlattenLoadedAnnotations,得到回傳值 0,儲存後交付的文件中,申請人填寫的姓名和金額所在位置只剩下空框。沒有擲出錯誤,也沒有日誌。那些值一直在檔案裡,位於每個欄位的 /V 項目中;展平過程只是直接繞過了它們,因為這些 widget 一個也沒有外觀串流
為什麼瀏覽器列印的表單在展平時會遺失填寫值
因為 /NeedAppearances 表單儲存了值,卻沒有儲存值的影像。ISO 32000-1 第 12.7.2 節允許互動式表單在 AcroForm 字典中設定 /NeedAppearances true,告訴檢視器在開啟時根據 /V、/DA 和 /Q 建立每個欄位的視覺表面。廉價產生表單的生產者——瀏覽器列印路徑、伺服器端填入器以及某些掃描前端——會採用這個機制,完全不寫 /AP。按照 ISO 32000-1 第 12.5.5 節的外觀演算法,展平是一項轉錄工作:取得註解的普通外觀串流,將其 /BBox 映射到 /Rect,透過頁面內容串流中的 Do 運算子呼叫它,然後刪除註解。沒有來源串流就沒有可轉錄的內容。HotPDF 從 v2.386.0 開始的原始實作把這種情況當作「跳過」,單獨看有道理,合在一起卻很糟:最需要展平的文件,往往正是最不可能帶有外觀的文件。同一個缺口也會吞掉標記註解——審閱工具產生的 Highlight、修訂過程中的 Square、手寫批註——只要生產者依賴檢視器來繪製它們
HotPDF 將產生邏輯接入 FlattenLoadedAnnotations 的位置
接入點被有意放在較晚的位置:外觀查找失敗之後,而不是之前。FlattenLoadedAnnotations 仍會首先透過 GetLoadedAnnotationAppearanceStream 要求普通外觀,已經有外觀的註解會完全按照 v2.386.0 的方式烘焙。只有在回傳 nil、註解帶有非退化 /Rect 且沒有隱藏旗標時,才會進入產生路徑。順序很重要:文件作者既然費心寫入了 /AP,就應該取回自己的位元組,而不是得到 HotPDF 對它的重建
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
if Subtype= 'Widget' then
begin
FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
if FieldIdx>= 0 then
EnsureLoadedFieldAppearanceStream(FieldIdx);
// 再次查詢:產生器已經為 widget 附加 /AP /N
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
從這裡開始,兩類註解分開處理。widget 會透過 GetLoadedFormFieldIndexForAnnotation 找回所屬欄位,再交給 EnsureLoadedFieldAppearanceStream,也就是這個 Delphi PDF Library 從 v2.328.0 起就有的欄位外觀產生器。重用它而不是再寫一個欄位渲染器,正是關鍵——它已經涵蓋 Type0 字型、換行、對齊、核取方塊和單選方塊的 /AS 狀態以及 /MK 旋轉,這也是向已載入 PDF 新增 AcroForm 欄位所使用的同一套機制。其他內容都會進入標記產生器。對呼叫方來說沒有變化:同一個單行展平呼叫,現在會在過去回傳零的文件上回傳非零計數
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0:沒有 AP 的 widget 和標記註解會先產生外觀,再烘焙
Flattened:= Doc.FlattenLoadedAnnotations; // 所有頁面,所有子類型
// Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
if Flattened= 0 then
raise Exception.Create('nothing was flattened');
Doc.SaveLoadedDocument('flattened.pdf');
finally
Doc.Free;
end;
為什麼 QuadPoints 和 InkList 會落在錯誤位置
因為這些座標位於頁面使用者空間,而產生的外觀串流在自己的 /BBox 空間中繪製,兩個原點並不是同一個點。ISO 32000-1 表 176 為文字標記註解在預設使用者空間中定義了 /QuadPoints,表 174 對線註解的 /L 端點做了相同定義;/InkList 遵循同一約定。HotPDF 為產生的表單設定 [0 0 W H] 的 /BBox,其原點位於 /Rect 的左下角。因此,從 /QuadPoints、/L 或 /InkList 取出的每個點,都必須在寫入內容串流前減去 /Rect 左下角的座標。若這裡出錯,頁面上方 700 點處的一條 Highlight 就會在自身框體上方再畫 700 點,實際效果就是消失。修正每個座標只需要一次減法,而且能與烘焙隨後發出的 cm 組合——該矩陣將 /BBox 映射回 /Rect,因此兩步相互抵消後得到正確的絕對幾何位置
// /L 端點位於頁面使用者空間(ISO 32000-1 表 174);表單的
// BBox 原點位於 /Rect 左下角,因此需要平移 -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
_FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
_FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;
產生的標記外觀實際繪製什麼
標記產生器只讀取註解字典,不讀取其他內容,這讓輸出可預測,也誠實承認它無法知道的內容。FreeText 和 Stamp 使用從 /DA 解析出的字型和顏色繪製 /Contents,按照 /Q 對齊,並留出 2 pt 內邊距。Square 和 Circle 繪製 re 或由四段弧線組成的 Bezier 輪廓,使用 /C 描邊,在存在 /IC 時填滿,並使用 /BS /W 指定的寬度。Line 和 Ink 描邊它們的頂點。Highlight 填滿每個四邊形,Underline、StrikeOut 和 Squiggly 則分別在四邊形底部、四邊形中點或一條單點鋸齒線上描邊。小於 1 的 /CA 會變成帶有 ca 項目的 ExtGState,並在串流開頭透過 /GSA gs 引用
文字編碼由 AcroForm /DR /Font 中、由 /DA 指定的項目決定。如果字型的 /Subtype 是 Type0,HotPDF 會使用帶 FEFF 位元組順序標記的 UTF-16BE 十六進位常值寫入字串;否則會寫入轉義的常值字串,括號和反斜線會轉義,超過 126 的位元組會以八進位寫入。/DA 中的 Tf 運算子會在 BT 之前發出,這符合規範,因為文字狀態會跨越文字物件邊界持續存在,也避免拆解 /DA 字串。兩項限制需要直接說明。換行和對齊使用半 em 或全 em 啟發式估算,而不是真實字型度量,因此比例字型上的對齊接近但不精確。另一項限制是,沒有可產生內容的子類型——Popup、Link,或唯一內容是圖示名稱的 Stamp——會回傳 nil 並保持不變,完全遵循先前行為
暫時 /Annots 交換會懲罰善意的清理
FlattenOneWidget 是 FlattenLoadedFormFields 使用的逐 widget 路徑,共用展平迴圈中的任何改動都必須尊重它的別名陷阱。它會暫時將頁面的 /Annots 值替換為單元素陣列,讓通用展平過程只處理一個 widget,然後在 finally 區塊中恢復原來的 PHPDFDictionaryItem 指標。恢復操作會寫回呼叫前捕獲的字典槽位
DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
DictItem^.Value:= Item; // 如果內部迴圈釋放了該項目,這裡就是懸空指標
TemporaryAnnots.Free;
end;
在共用內部迴圈中增加一個看似合理的整理步驟——陣列變空後呼叫 DeleteValue('Annots'),讓儲存的頁面不攜帶多餘的空陣列——這個呼叫就會釋放 DictItem 所指向的字典項目。隨後 finally 會透過懸空指標寫入,程序以「Invalid pointer operation」崩潰。兩個現有測試立即捕獲了它,這正是它仍然只是腳註而沒有變成支援工單的唯一原因。這條規則可以推廣:向共用迴圈新增清理之前,先檢查呼叫方是否存在別名或交換契約。殘留的空 /Annots 陣列只是外觀瑕疵,不值得用指標生命週期保證去交換
哪些內容不會烘焙,以及展平的代價
隱藏註解會被有意排除。按照 ISO 32000-1 第 12.5.3 節,/F 整數的第 2 位被設定時,註解就是隱藏的;當它又沒有 /AP 時,很容易想要產生一個外觀並像其他註解一樣烘焙。這會帶來安全後果,因此是錯誤做法:把不可見批註烘焙進頁面內容,會讓任何開啟檔案的人都能看到它。HotPDF 會讓這些註解完全保持原狀,也不會把它們計入回傳值。對於確實被烘焙的內容,也要向使用者說清楚代價。展平不可逆——註解會從頁面的 /Annots 陣列中刪除,其視覺結果現在成為頁面內容,因此不能再編輯欄位值、維護評論執行緒、切換 /AS 狀態,也無法恢復結構化資料,除非保留原始檔案。請展平副本並保留原件,只在文件從表單變成記錄時使用。如果問題來自 XFA 而不是缺少外觀,應該從 HotPDF 獨立的XFA 到 AcroForm 展平路徑開始;如果還在建構表單,則連接 AcroForm 欄位動作與驗證的說明涵蓋寫入端
還有一個驗證方面的注意事項,否則它會讓你浪費一個下午。ExtractLoadedPageGlyphs 不會進入 Form XObject,而烘焙後的外觀正位於其中——頁面內容串流只包含 q ... cm /FlatAn<n> Do Q 序列。因此,在展平頁面上做字形擷取會回報空結果,這是正確行為,而不是烘焙結果遺失。應當在位元組層檢查 /FlatAn 資源名稱、Do 呼叫和 /Subtype /Form,或者透過會展開 XObject 的渲染流程驗證
註解展平看起來像三行轉錄工作,直到你遇到人們實際產生的文件。如果你在 Delphi 或 C++Builder 中處理填寫表單、審閱標記或歸檔輸出,那麼在自己基於它建立外觀產生器之前,值得先閱讀 HotPDF Delphi PDF 元件如何處理已載入文件端的 AcroForm 和註解