技術文章

PDFDocEncoding 差一錯誤:Delphi 的 FDF 匯入壞掉

把 FDF 匯入表單,欄位值回來的樣子錯得很微妙:項目符號變成劍號,省略號變成全形破折號。PDFium Component 把整族症狀追到 PDFDocEncoding 表格裡的單一列——ISO 32000-1 Annex D.2 的特殊字元區塊從 $80 開始,不是 $7F

為什麼 FDF 匯入回傳的是鄰居字元?

因為偏移一位的編碼表看起來依然是一份完全正常的表格,表裡的每個查詢都成功。Annex D.2 指派給 $80 到 $9E 的區塊被抄早了一列,所以位元組 $80 解析到屬於 $81 的項目、位元組 $81 解析到 $82 的項目,一路錯過連續十九個編碼位置。沒有拋錯、沒有日誌,也沒有任何欄位被拒絕——TPdf.ImportFDF 回報的欄位數跟以前一模一樣

傷害是一致且無聲的。帶 $80 的位元組——Annex D.2 定義為 BULLET(U+2022)——回來變成 DAGGER(U+2020)。$83,HORIZONTAL ELLIPSIS(U+2026),回來變成 EM DASH(U+2014)。$8D,LEFT DOUBLE QUOTATION MARK(U+201C),回來變成 RIGHT DOUBLE QUOTATION MARK(U+201D)——這是沒人會注意的案例,因為錯誤的彎引號在表單欄位裡看起來還是彎引號。欄位名稱與欄位值遭到同樣的命運,而這更糟:一個不再對得上文件裡控制項的名稱,會無聲地套用失敗

PDFium Component 中 FDF 匯入損壞背後的 PDFDocEncoding 差一示意圖:ISO 32000-1 Annex D.2 把位元組 $80 對到 BULLET、$83 對到 HORIZONTAL ELLIPSIS、$8D 對到左雙引號,而抄早一列的表格對同樣的位元組回傳 DAGGER、EM DASH 與右雙引號,每個查詢卻仍然成功
偏移的編碼表看起來依然有效、每個查詢都成功,所以項目符號變成劍號,不再匹配的欄位名稱則無聲地套用失敗

PDF 文字字串承載兩種不同的編碼

ISO 32000-1 §7.9.2.2 把文字字串型別定義為恰好兩種編碼之一,靠檢查前兩個位元組決定,而不是靠任何字典項目。以位元組順序記號 FE FF 開頭的字串是 UTF-16BE,其他一律是 PDFDocEncoding。這單一分支決定兩套完全不同的解碼器誰要上場,PDFium Component 在 FDF 剖析器內部、碰到任何字元之前就先套用它

PDFium Component 中 ISO 32000-1 §7.9.2.2 文字字串分支的示意圖:以位元組順序記號 FE FF 開頭的字串解碼為 UTF-16BE 並驗證代理對,其他一律走單位元組的 FdfPdfDocEncodingChar 表格,這正是為什麼寫 UTF-16 的工具與純 ASCII 名稱一直沒讓偏移的區塊現形,直到某個印刷符號出現
靠檢查前兩個位元組而非字典項目決定誰上場,所以預設寫 UTF-16BE 的工具從未用到那張錯的表格
// FDF 文字字串:有 BOM 時是 UTF-16,否則為 PDFDocEncoding
if (Length(Raw) >= 2) and
   (((Byte(Raw[1]) = $FE) and (Byte(Raw[2]) = $FF)) or
    ((Byte(Raw[1]) = $FF) and (Byte(Raw[2]) = $FE))) then
begin
  BigEndian := Byte(Raw[1]) = $FE;
  // ... 解碼 UTF-16 code unit,並驗證代理對
end
else
begin
  SetLength(Result, Length(Raw));
  for I := 1 to Length(Raw) do
    Result[I] := FdfPdfDocEncodingChar(Byte(Raw[I]));
end;

這就是為什麼這隻 bug 在一般測試裡躲了那麼久。任何由預設寫 UTF-16BE 的工具產生的 FDF,根本不會碰到 FdfPdfDocEncodingChar,偏移的表格因此保持沉睡。只有單位元組字串——很多 FDF 產生器對純拉丁欄位名稱輸出這種格式,因為比較短——會走進那張表格有關係的分支。純 ASCII 的欄位名稱反正也解碼正確,因為 $80 以下 PDFDocEncoding 與 ASCII 一致。您需要一個含印刷符號的單位元組字串,才會看到明顯出錯

修正後的表格,與釘住它的兩個錨點

PDFium Component 現在直接在 FdfPdfDocEncodingChar 裡把邊界寫明,因為區塊的兩端正是實作者最容易弄錯的地方。$7F 是 DEL,在 PDFDocEncoding 裡完全沒有意義;區塊在 $80 以 BULLET 開頭,跑到 $9E 的 zcaron 結束;$9F 未指派;歐元符號單獨待在 $A0

PDFium Component 中釘住 Annex D.2 表格的邊界錨點示意圖:$7F 是 DEL、沒有 PDFDocEncoding 意義,$80 以 BULLET 開啟區塊,$9E 以 zcaron 收尾,$9F 未指派,歐元符號單獨待在 $A0,因此整塊平移無法同時滿足兩端與空隙
驗證第一列、最後一列與空隙就能同時釘住兩端,這正是整塊平移過不去的測試
function FdfPdfDocEncodingChar(Value: Byte): WideChar;
begin
  case Value of
    // ISO 32000-1 Annex D.2。區塊從 $80 開始,不是 $7F:$7F 是 DEL 且
    // 在 PDFDocEncoding 中沒有意義;歐元在 $A0,否則預設分支會把它當作
    // no-break space 回傳
    $7F: Result := WideChar($FFFD);
    $80: Result := WideChar($2022);   // BULLET,區塊的第一列
    $81: Result := WideChar($2020);   // DAGGER
    // ... $82 到 $9D
    $9E: Result := WideChar($017E);   // zcaron,最後一個有定義的列
    $9F: Result := WideChar($FFFD);   // Annex D.2 中未指派
    $A0: Result := WideChar($20AC);   // EURO SIGN
  else
    Result := WideChar(Value);
  end;
end;

看看偏移對這兩個端點做了什麼,因為差一錯誤就是從這裡開始不再是表面問題。zcaron 在規範裡位在 $9E,在偏移的表格裡卻在 $9D,所以文件裡一個 $9E 位元組會越過大家抄寫的最後一列,直接掉進身分對應的 fallback,回傳一個 C1 控制字元而不是字母。$A0 的歐元從一開始就沒被寫進表格——身分分支把它當 U+00A0 NO-BREAK SPACE 交回去,一個通過之後所有驗證、印出來什麼都不是、永遠對不上您要找的欄位名稱的字元

您怎麼測試一份轉錄的編碼表?

測試第一列、最後一列與空隙——不要抽中間的樣本。這是這次修正裡值得帶到任何其他從規範抄出來的表格的部分,因為區塊中間的抽查正是整塊平移能通過的測試。驗證 $8D 給出左雙引號證明不了什麼,因為 $8C 給出的東西看起來也合理;驗證 $80 給出 BULLET、$9E 給出 zcaron 才能同時釘住兩端,而偏移的區塊無法同時滿足兩者

procedure CheckPdfDocEncodingAnchors;
begin
  // Annex D.2 特殊區塊的兩端:整塊平移會讓這兩個失敗
  Assert(FdfPdfDocEncodingChar($80) = WideChar($2022), 'first row: BULLET');
  Assert(FdfPdfDocEncodingChar($9E) = WideChar($017E), 'last row: zcaron');
  // 規範未定義的編碼位置不得解碼成字母
  Assert(FdfPdfDocEncodingChar($7F) = WideChar($FFFD), '$7F is DEL, unmapped');
  Assert(FdfPdfDocEncodingChar($9F) = WideChar($FFFD), '$9F is unassigned');
  // 區塊上方孤立的項目,最容易整個漏掉
  Assert(FdfPdfDocEncodingChar($A0) = WideChar($20AC), 'EURO SIGN');
end;

未定義的位置值得擁有自己的 Assert,而不是當作無趣被跳過。差一錯誤總得把某個東西放到某個地方,而它放的地方幾乎總是規範留空的編碼位置——前面 $7F、後面 $9F。只檢查有定義位置的測試,可以在表格兩個方向都錯的情況下照樣通過;釘住空洞的測試第一次跑就會抓到偏移

在來回往返的表單流程裡,這要付出什麼代價

一個無聲地把每個符號改寫成另一個字元的 FDF 匯入,比失敗更糟,因為失敗在下游浮現,而且看起來像別的問題。欄位名稱是這裡的銳利邊緣。TPdf.ImportFDF 把進來的名稱跟開啟文件裡的控制項比對,一個解碼錯一個字元的名稱就是配不上——欄位保持空白,回傳值仍然把欄位算作已剖析,管線輸出的報告少一個值,日誌裡卻沒有解釋為什麼。任何在搭建使用 PDFium Component 匯出與匯入 XFDF 表單資料和註解那種匯出再匯入迴圈的人,在信任這個迴圈之前,都應該對一個含印刷符號的欄位名稱做來回往返,而不是只用純 ASCII

對修正的限制保持誠實也值得。表格現在對全部 256 個位元組值都符合 Annex D.2,單位元組 FDF 字串因此解碼正確,但 PDFDocEncoding 本身只涵蓋拉丁字元集、沒有更多——範圍外的東西必須以帶 BOM 的 UTF-16BE 進來,而這正是 §7.9.2.2 的本意。UTF-16 字串在 Delphi 端也還有自己獨立的地雷:超過 U+FFFF 的碼點需要代理對,而不是一個 WideChar,這個問題在為什麼 emoji 與 CJK 字元會弄壞 Delphi 的 WideChar裡有討論。更大的觀點可以推廣到編碼表之外:一個 PDF 剖析器可能拒絕它該接受的東西——您立刻就會發現;也可能接受並默默弄錯——您在生產環境才會發現。安全解析 PDF 字典裡描述的名稱 token 陷阱屬於第二類,偏移一列的表格也是——程式路徑成功、回傳值看起來合理,只有對照規範而非對照實作寫的測試會說出真相

TPdf 上的 ImportFDF、ExportFDF 與 ImportXFDF,連同背後支撐它們的 FDF 與 XFDF 剖析器,都是適用於 Delphi 與 C++Builder 的標準版 PDFium Component 的一部分