技術文章

Delphi 的 FDF 註解匯入:修正無聲的零

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

PDFlibPas 的 ImportAnnotationsFromFDFString 找到 /Subtype 後,讓 ReadName 從鍵之後的空白起步,因而回傳空名稱;AddAnnotationToPage 因 subtype 缺失而退出,呼叫端卻照樣遞增結果,回報匯入了七條註解,文件裡卻一條也沒加
每道防護單獨看都合理,湊在一起卻把什麼都沒成功變成樣樣都成功——這正是匯入測試絕不能只檢查回傳值的原因

那個修正之後,回傳值仍值得細究。直到 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 軟性失敗而非拋出例外。四個矩形數字有任何一個失敗,四個全部退回零,而不是交出一個只讀了一半的矩形

PDFlibPas 的 FindDictEnd 現在把每條 FDF 註解從開頭的 << 定界到配對的 >>,每次鍵查詢都從條目起點重新出發、在其結尾停住;ReadNumber 改用 var 位置、跳過方括號,並以 PLTryStrToFloatInvariant 解析
共用的游標連函式庫自己的匯出都讀不了:一旦越過 /Contents,/Page 與 /Rect 的搜尋就闖進下一條註解的鍵——鍵序從此不許再有任何影響力

為什麼 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 數值——三位小數、點號分隔、不用指數——只有儲存的陣列缺失或不足四個數字時,才退回計算出來的矩形

PDFlibPas 過去把 FDF /Rect 序列化成繪圖座標裡的 left、top、width、height,把這四個數字當 llx lly urx ury 匯入回去,上緣就落在左下角該在的位置,每往返一趟,每條註解都上移自己的高度
匯出器現在複製註解自己的 /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 以上