技術文章

與地區設定無關的 PDF 數字:逗號小數 Delphi 環境對策

losLab 的 Delphi PDF 開發函式庫 PDFlibPas,寫進內容串流的每個數字都用點號小數、不帶指數,Windows 地區設定說什麼都一樣。從 v3.539.26 起,AddPageMatrix、ScalePage、DeskewPage、RedactRegion、文字轉路徑輸出與重新上色都透過 PLDoubleToStrConst 格式化運算元;從 v3.539.33 起,把這些數字讀回來的解析器改用 PLTryStrToFloatInvariant,不再跟著系統地區走。在德語、法語或巴西的機器上,同一段程式碼現在產出與美國機器相同的位元組——這是檔案格式唯一能容忍的行為

為什麼逗號小數的地區設定會無聲毀掉一份 PDF?

逗號小數地區會無聲毀掉 PDF,是因為逗號在 PDF 語法裡不是數字字元,於是損害呈現成一個個「合法但意思錯了」的 token。修復前,PLFloatToStr 就只是一次裸的 FloatToStr 呼叫,而 FloatToStr 聽 FormatSettings.DecimalSeparator 的。分隔符是逗號時,AddPageMatrix(0.5, 0.5, 0, 0) 寫出 0,5 0 0 0,5 0 0 cm。ISO 32000-1 §7.3.3 只允許數字裡出現位數、一個句點與前置正負號,別無其他,所以內容解析器把那一行讀成數字 0 加上未知 token ,5,cm 運算元最後拿到的就是錯的操作數。沒有例外、沒有日誌。頁面只是帶著一個漂移過的變換矩陣渲染出來,而從一張畫錯位置的圖反推回某個地區設定,是一個悲慘的下午

第二個缺陷躲在第一個後面。FloatToStr 用的是 ffGeneral 格式,量級一低於 1E-4 就切到指數記法,於是很小的偏移輸出成 1E-5。同一條 §7.3.3 明說 PDF 不支援指數形式,這意味著就算在美式地區的機器上,值夠小一樣能寫出非法運算元。這一版的迴歸測試把兩種失敗形狀都釘死:把分隔符翻成逗號、呼叫 API、然後掃描產出的內容,任何含逗號或指數的 token 都不放過

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // 模擬德語(de-DE)桌面
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 起寫出:0.5 0 0 0.25 0.00001 12.75 cm
      // 舊版寫的是:        0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
PDFlibPas 的 AddPageMatrix 在逗號小數桌面上的修復前版本寫出 0,5 0 0 0,25 1E-5 12,75 cm,PDF 解析器把這行讀成數字 0 加一串未知 token,cm 拿到錯的運算元、頁面無聲變形;PLDoubleToStrConst 則寫出合法的點號小數
逗號在 PDF 語法裡不是數字字元,損害因此讀起來是合法 token、意思卻錯了——而 1E-5 這種 ffGeneral 指數在任何地區都不合法,不只逗號地區

兩種數字,兩族輔助函式

PDFlibPas 裡的修法是一刀切乾淨:給人看的數字可以跟著地區走,寫給機器的數字永遠不跟。PLFloatToStr 與 PLStrToFloat 留在 PDFlibExtra.pas 裡服務面向使用者的文字,它們的宣告現在還帶著一句註解,把這件事講明白。凡最終變成 PDF 語法的東西一律走 PLDoubleToStrConst,小數位數按用途固定:矩陣六位、座標與 TJ 調整四位、顏色與 FDF 矩形三位。v3.539.26 的那次盤點碰到的呼叫點,比原始 bug 報告暗示的多:

  • AddPageMatrix、ScalePage 與 DeskewPage,全都會在既有頁面內容前面加一個 cm
  • 輸出 Tm 歸零、TJ 推進與 cm 變換的頁面元素建構器
  • 文字轉路徑轉換器裡的字形擺放矩陣與輪廓點
  • RedactRegion 前置的黑色填充方塊、FDF 匯出裡的 /Rect 值,以及重新上色寫出的運算元

PLDoubleToStrConst 是手寫的格式化器,不是 FloatToStrF 的包裝,這裡有三個性質要緊。它永遠寫句點、去掉尾隨零,所以 0.5 保持 0.5,不會變成 0.500000。有限輸入它絕不寫指數。而非零但小於要求精度的值,它保留有效數字、不塌縮成零,所以 PLDoubleToStrConst(1E-9, 6) 回傳 0.000000001;只有低於大約 5E-16 的值才變成 0。最後這條規則存在,是因為把很小的縮放係數四捨五入成零,會把合法矩陣變成奇異矩陣——這比正在修的 bug 還糟

PDFlibPas 把數字格式化一分為二:PLFloatToStr 與 PLStrToFloat 綁在地區設定上服務面向使用者的文字,PLDoubleToStrConst 與 PLTryStrToFloatInvariant 則以點號、無指數、按用途固定的六、四或三位小數,格式化一切將成為 PDF 語法的數字
不變式格式化器是刻意手寫的:去掉尾隨零、絕不寫指數、保留極小值的有效數字——把縮放係數湊成零會讓合法矩陣變奇異
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // 機器輸出:點號小數、無指數、去尾隨零
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // 保留 4 位有效數字
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // 機器輸入:軟性失敗,不丟 EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // 內容數字從不用逗號
end;

為什麼解析端比寫出端更危險?

解析端更危險,因為綁地區的解析器不是給出錯的數字,而是直接丟例外。PLStrToFloat 呼叫 StrToFloat,文字與系統分隔符對不上時丟 EConvertError。在逗號小數系統上,這意味著 RecolorPage 一碰到普通的 0.5 g 運算元就中斷,真實世界的每一頁都失敗,不是什麼罕見情況。RenderPageRegionToFile 拒收自己文件裡寫明的剪裁格式 "10.5,20.5,50.5,40.5",SVG 長度屬性、SVG 匯出顏色、註解頂點清單與 output intent 的濃度值,要麼被拒、要麼無聲換成預設值。在開發者機器上完美、在慕尼黑第一位客戶那裡就掛的函式庫,正是「碰巧能動的 Delphi 程式碼」那篇文章講的那類程式碼:只因為測試地點對,它看起來才正確

v3.539.33 按輸入來源給每個 StrToFloat 與 TryStrToFloat 呼叫分了類。內容串流運算元、SVG 屬性、繪製器顏色字串與逗號分隔的剪裁、頂點清單都有固定的點號語法,所以現在都走 PLTryStrToFloatInvariant:它去頭尾空白、以 PLInvariantFormatSettings 解析,遇到空字串、格式不良或非有限輸入回傳 False,不丟例外。逗號分隔的清單沒有妥協空間——逗號不能既是清單分隔符又是小數點。同一輪還修掉一個越界寫入:RenderPageRegionToFile 過去會把第五個剪裁值存進四元素緩衝區之外。對把 PDF 轉成單一色彩空間的指南描述的重新上色管線,實際結果是 RecolorPage 與 RecolorDocument 在逗號小數系統上不再中斷。呼叫端親手打進 CheckDocumentPolicy 的規則值,是唯一改用寬鬆輔助函式的解析案例,理由見下一節

往返兩端只修一端會怎樣?

地區往返只修一端,會弄壞原本能動的程式碼,所以 v3.539.32 的結構屬性變更把寫出端與讀取端一起搬。SetStructElem* 包裝以字串中繼數字:SetStructElemBBox 把四個值格式化成一個字串,經 AddTagAttribute 存起來,/A 寫出器稍後再解析那個字串,決定它該變成數字、陣列還是名稱。兩端原本都聽系統地區,所以在逗號小數系統上往返是自洽的。bug 只在呼叫端照文件說的把 "0.5" 傳給 AddTagAttribute 時現形:讀取端解析不了,輸出成 PDF 名稱 /0.5。PDF/VCR 佔位符遇到的是鏡像問題:函式庫用點號產生 GTS_BBox,存檔前卻用 locale 驗證它

只把寫出端改成點號會比什麼都不做更糟——每個 SetStructElem* 值都會過不了綁地區的讀取端、退化成名稱。所以寫出端現在用 PLDoubleToStrConst(v, 6),讀取端用新的 PLTryStrToFloatLenient:先試點號形式,退回系統地區。過去傳 "1,25" 的逗號地區呼叫端,照樣得到數字 1.25。取捨是刻意且寫進文件的:在德語系統上,"1.500" 過去會變成名稱,因為 StrToFloat 拒收千分位分隔符,現在則讀成 1.5;而字面 NAN 與 INF 字串不再被當成數字接受

PDFlibPas 的 SetStructElemBBox 與同族方法以字串經 AddTagAttribute 中繼數字,/A 寫出器再把字串解析回來,所以 v3.539.32 兩端一起動:PLDoubleToStrConst 以點號寫出,PLTryStrToFloatLenient 先讀點號、退回地區設定,逗號地區的 1,25 照樣讀成 1.25
只修寫出端,會把每個結構化元素屬性都退化成 PDF 名稱——往返要嘛兩端一起動,要嘛別動
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // 逗號小數的呼叫端
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5,過去是 /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // 仍是 /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

NaN 與無限大在哪裡被攔下

AddPageMatrix、ScalePage 與 RedactRegion 現在在入口就拒收 NaN 與無限大參數並回傳 0,因為沒有任何 PDF 數字表示得了它們。ScalePage 本來就拒絕零或負的係數,但 NaN 能通過 <= 0 測試,所以 NaN 縮放過去一路走進格式化器。v3.539.26 時,那個格式化器還對 NaN 呼叫 Round——在 x87 單元不遮罩無效運算的 Win32 上這會丟 EInvalidOp;v3.539.31 讓 PLDoubleToStrConst 對 NaN 寫 0 作最後一道防線,但矩陣裡的零是奇異變換,所以 API 層的檢查才是真修復。兩條邊界刻意保留。Metafile 狀態字串在同一個行程內以地區設定寫入讀出、從不離開行程,所以原樣不動。另外,讓 1E-5 走頁面元素路徑的測試必須在圖層被改寫之前讀內容,因為以文件精度重新輸出運算元,本來就會把那個值變成 0

如果您的應用程式要出貨到點號小數世界之外的客戶手裡,最保險的習慣就是 PDFlibPas 測試套件現在用的那套:把 FormatSettings.DecimalSeparator 設成逗號,把產生 PDF 的路徑跑一遍,再掃描輸出裡的逗號與指數。保留解析後小數精度的文章講的是同一個故事的另一半:從既有檔案讀進的數字如何在存檔時保住自己的原文。下載、完整 API 參考與試用版都在PDFlibPas Delphi PDF library 產品頁