v3.539.30 之前,losLab PDF Library 的 TPDFlib.ImportAnnotationsFromFDFString 回傳的是它解析了幾條 FDF 註解條目——但一條也沒加進文件:每條都被計數,每條都被丟棄。從 v3.539.30 起,FDF 匯入器接受任何鍵序,/Rect 解析正確且不受地區設定影響,搭配的匯出器則寫出註解真正的 /Rect,於是匯出、匯入、再匯出得到逐位元組相同的 FDF。這篇筆記接下來要講:一個錯的起始偏移如何造就一次完美的無聲失敗,另外三個缺陷如何躲在它後面,以及如何自己驗證匯入結果,而不是只信回傳值
場景再平常不過。審閱者在合約上做了標註,註解以一份 FDF 檔傳來(Acrobat 稱之為 Export Comments),您的 Delphi 服務用 ImportAnnotationsFromFDF 把它們合併進乾淨的副本。呼叫回傳 7,日誌寫著「已匯入 7 條註解」,工作亮綠燈,而輸出的 PDF 一條註解都沒有。沒有例外、沒有警告,那個數字看起來也合理——因為它確實是檔案裡條目的真實數量。這是 bug 能呈現的最糟形態:一個函式,成功的唯一訊號是一個與它宣稱回報的工作互不相干的計數器
為什麼 ImportAnnotationsFromFDFString 回報成功,卻什麼都沒加?
匯入器把每個 /Subtype 都讀成空字串,而建立註解的輔助函式一遇到空 subtype 就提前退出,呼叫端卻照樣遞增結果。鍵搜尋回傳 /Subtype 之後緊鄰的位置,也就是值前面的空白。ReadName 從那個空白起步、在第一個空白字元處停住,所以一個字元都沒讀到。AddAnnotationToPage 拒絕在沒有 subtype 的情況下建註解——單獨看這是正確的防禦選擇——但它是不帶回傳值的 procedure,Inc(Result) 卻寫在它外面。每道防護單獨看都合理;湊在一起,就把「什麼都沒成功」變成了「樣樣都成功」。修法讓 ReadName 跳過空白、要求 PDF 名稱物件開頭的 /、並在任何分隔符處停住,包括 [、( 與 ),於是 /Subtype/Text 與 /Subtype /Text 都能得出 Text
那個修正之後,回傳值仍值得細究。直到 v3.539.39,ImportAnnotationsFromFDFString 還是對 /Annots 陣列裡每個格式完好的字典遞增結果,包括 0 起算的 /Page 超出範圍、或 /Subtype 缺失的條目——這兩種其實都會被跳過。從 PDFlibPas v3.539.40 起,ImportAnnotationsFromFDFString 與 ImportAnnotationsFromFDF 回傳實際加入的註解數,跟 XFDF 匯入一致:FDF 輔助函式 AddAnnotationToPage 現在回傳 Boolean,計數器只在成功時前進。量測文件仍是更強的檢查,因為它在舊版上也成立,所以下面的示意程式碼比較匯入前後每頁的 AnnotationCount
function TotalAnnotations(Lib: TPDFlib): Integer;
var
Page, Saved: Integer;
begin
Result := 0;
Saved := Lib.SelectedPage;
for Page := 1 to Lib.PageCount do
if Lib.SelectPage(Page) = 1 then
Inc(Result, Lib.AnnotationCount); // 針對目前選取的頁面,widget 也算
Lib.SelectPage(Saved);
end;
var
Lib: TPDFlib;
Before, Reported, Added: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contract.pdf', '');
Before := TotalAnnotations(Lib);
Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
Added := TotalAnnotations(Lib) - Before;
if Added <> Reported then // 自 v3.539.40 起兩者相等
Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
Lib.SaveToFile('contract-reviewed.pdf');
finally
Lib.Free;
end;
end;
躲在第一個缺陷後面的另外三個
只修 subtype,同一個函式裡還會接連浮出三個 bug,它們之所以一直隱形,只是因為從來沒有註解真正落到頁面上。第一,ReadNumber 把位置宣告成值參數,所以依序讀 /Rect 的四個數字,等於在同一個位置讀了四次;它也不跳過開頭的 [,實際上什麼都沒讀到。第二,FindKey 讓所有查詢共用一個只前進的游標。匯出器寫出 /Subtype、/Rect、/Page、/Contents、/T、/Subj,匯入器卻按 /Subtype、/Contents、/T、/Subj、/Page、/Rect 的順序搜尋;游標一旦越過 /Contents,對 /Page 與 /Rect 的搜尋就跑出目前條目之外,要麼一無所獲,要麼對上下一條註解的鍵。函式庫讀不懂自己的輸出。第三,數字走 PLStrToFloat,跟著系統的小數點分隔符走。ISO 32000-1 §12.7.7 把 FDF 定義成 PDF 物件語法,而 PDF 的字典鍵無序(§7.3.7),所以任何假設鍵序的 FDF 解析器從構造上就是錯的,無論檔案是哪家工具產的
修好的匯入器先為每個條目定界。FindDictEnd 從開頭的 << 走到配對的 >>,追蹤巢狀字典、帶著反斜線跳脫跳過字串字面量的內容,所以像 (see section >> 4) 這種留言裡的 >> 不會提前終止條目。之後每次鍵查詢都從條目自身的起點出發、以自身結尾為界,鍵序從此無關緊要,一條註解也借不到另一條的 /Page。鍵比對也接受緊跟在名稱後面的分隔符,因為 /Contents(Hi) 與 /Contents (Hi) 同樣合法;而字邊界規則讓 /Subj 對不上 /Subtype 的開頭、/T 對不上 /Type。ReadNumber 現在把位置宣告成 var 參數,跳過空白與 [,並以 PLTryStrToFloatInvariant 解析,遇到格式不良的 token 軟性失敗而非拋出例外。四個矩形數字有任何一個失敗,四個全部退回零,而不是交出一個只讀了一半的矩形
為什麼 FDF 往返一趟,每條註解就上移自己的高度?
舊匯出器把矩形寫進了錯的座標模型。註解的 /Rect 是預設使用者空間裡的 [llx lly urx ury](ISO 32000-1 §12.5.2,矩形定義見 §7.9.5),FDF 帶的也是同一種陣列。ExportAnnotationsToFDFString 卻呼叫 GetAnnotRectEx,取得的是繪圖座標——SetOrigin 控制的那個空間——下的 Left、Top、Width、Height,然後序列化成 [L T L+W T+H]。匯入器修好之後,把這四個值原封不動寫回 PDF 矩形,於是上緣落在左下角該在的位置,每往返一趟,註解就往上移動自己的高度。匯出器現在直接複製註解自己的 /Rect 數值——三位小數、點號分隔、不用指數——只有儲存的陣列缺失或不足四個數字時,才退回計算出來的矩形
釘死這個問題的迴歸測試值得照抄,因為它斷言的是文件狀態與第二次匯出,而不是匯入器的回傳值。注意期望值是 2:AddNoteAnnotation 會建立一條 Text 註解加上它的 Popup,兩個都會跟著走。這個測試也在逗號小數分隔符下執行匯出與匯入——本故事的另一半就住在那裡
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // 現在共兩頁
Source.SelectPage(2);
Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
Target.NewPages(1);
OldSep := FormatSettings.DecimalSeparator;
FormatSettings.DecimalSeparator := ','; // 模擬德語或法語的桌面環境
try
FDF := Source.ExportAnnotationsToFDFString; // 仍寫出 /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // 便條註解與它的 popup
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
要弄清楚 FDF 這條路徑到底帶什麼。匯入器把每個條目重建成帶 /Type、/Subtype、/Rect、/Contents、/T 與 /Subj 的字典;顏色、旗標、框線樣式、popup 連結與外觀串流都不走這條路,匯出器也跳過 Widget 註解,因為表單欄位歸表單資料方法管。哪些資料經哪個方法流動,全景在FDF、XFDF 與 XFA 表單資料互換那篇概覽裡;需要檢查實際抵達了什麼,GetAnnotType、GetAnnotTitle、GetAnnotContentsEx 這類按索引的讀取器見大綱、註解與 action 內省
怎麼讀舊版匯出的逗號小數 FDF 與 XFDF 檔?
FDF 的答案毫不含糊:逗號在 PDF 語法裡不是分隔符,所以一個恰好含一個逗號、不含點的數字 token,只可能是逗號地區機器寫出的小數。舊版確實產過這種檔,例如 /Rect [10,500 20,250 40,750 60,125],新的 ReadNumber 會在解析前把那個孤零零的逗號換成點。含兩個逗號、或一逗號一點的 token,直接拒收而不猜。讀取器也不吃指數記法,這與 ISO 32000-1 §7.3.3 一致:PDF 數字從不用指數
XFDF 比較麻煩,因為在 XML 屬性裡逗號就是分隔符。標準 XFDF(ISO 19444-1)寫 rect="50.5,80.25,70.75,100.125" 與 dashes="4,2";v3.539.28 以前在逗號地區系統上卻寫 rect="50,500 80,250 70,750 100,125" 與 opacity="0,600",讀標準的 opacity="0.6" 還會丟 EConvertError。從 v3.539.29 起,兩個方向都不受地區設定影響,而 XFDFNormalizeLegacyDecimals 只在屬性按空白拆開後 token 數恰好符合預期(rect 四個、opacity 與 width 各一個)、且每個 token 都是「數字-逗號-數字」的形式時,才辨識舊格式。標準 rect 永遠不會誤配:它要麼是含三個逗號的單一 token,要麼是以逗號結尾的 token。dashes 被刻意放著不動,因為 4,2 可能是兩段虛線長度,也可能是舊格式的 4.2,沒有規則分得開
const
// 鍵序與匯出器相反,並帶舊版逗號地區匯出的逗號小數
LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
'<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
'/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
'] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create; // 新建文件有一頁
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// 以點小數重新匯出為 XFDF:rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
註解匯入測試到底該斷言什麼?
有用的匯入測試斷言的是目標文件的狀態,絕不只有匯入器對自己的說法。整個測試套件從來沒在 FDF 匯入之後檢查過 AnnotationCount,而回傳值——人人唯一盯著的數字——恰恰是這個 bug 完好保留的那一個。三個斷言就能抓出本文描述的每一個缺陷:預期頁面上的註解數、用 GetAnnotType 或 GetAnnotContentsEx 讀回一個欄位、以及第二次匯出與第一次逐位元組比較。同樣的紀律適用於任何整批改寫文件結構的 API,包括合併重複表單欄位描述的欄位整併:檢查最終的樹,而不是回傳的總數。FDF 與 XFDF 註解方法連同其檔案與字串變體,都隨 losLab PDF Library for Delphi and C++Builder 出貨;註解要能活過旅程,請用 v3.539.30 以上,回傳計數要與實際加入相符,請用 v3.539.40 以上