每一個到達 PDF 檔案的實數都必須用小數點書寫,不管作業系統認為小數分隔符是什麼。HotPDF Delphi Component 讓全部實數都經過 HPDFTypes 裡唯一的不變式轉換層,所以德文、法文或巴西工作站會發出 0.5 0 0 0.5 5.5 7.25 cm,而不是任何合格讀取器都剖析不了的逗號分隔變體
聽起來像是兩行程式碼的修復。其實不是。缺陷多年來被一個副作用偽裝,在三個沒人會當成數字格式化程式碼的子系統裡重現,在一個編譯器下消失、在另一個下存活,而它的最後一層跟小數分隔符一點關係都沒有
小數逗號地區設定為什麼會弄壞 PDF 內容串流
因為 ISO 32000-1 §7.3.3 把數值物件定義為選用正負號、十進位數字與最多一個小數點。逗號不是那個產生式的一部分,依 §7.2.2 它也不是分隔符,所以讀取器不會看到你原本要的兩個運算元。它看到一個無法 lex 的 token,而包含它的運算子整個被丟棄。圖形狀態矩陣裡一個 0,5 就幹掉包住它的整個 q ... Q 區塊。這個失敗在現場也有獨特形狀:永遠不會在開發者機器重現,以某個國家的支援單形式到達,而且被當成渲染 bug 而不是語法錯誤,因為多數檢視器靜默丟棄畸形運算子,把頁面其餘部分照樣渲染
// 主機程序以 DecimalSeparator = ',' 執行
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);
// 追加的 /Contents 以前會攜帶的內容:
// 0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
// q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 沒有「0,5」的產生式,所以密文遮罩
// 矩形與拼接頁面都會消失而不報錯
多年來藏住缺陷的副作用
HotPDF 大多把建立路徑的數字寫進 _CutFloat,而這個函式的早期版本做了某件安靜的敵意之事:每一次呼叫它都把 '.' 指派給主機程序的全域 FormatSettings.DecimalSeparator。這弄壞了承載元件之應用程式的地區設定狀態,而且不是執行緒安全,但有趣的後果是第三個。一旦有任何內容被產生,程序的分隔符就是小數點,所以程式庫其他地方的每個赤裸 FloatToStr 與 StrToFloat 都變成意外正確。幾乎每個範例都用的建立後編輯順序因此通過,而被它遮住的正是第一次 _CutFloat 呼叫之前會跑的所有路徑:已載入文件的編輯器 StitchLoadedPage 與 RedactLoadedRect、文字與影像圖章、合成浮水印、透過 Format('%.2f') 畫的條碼、/DA 預設外觀剖析器,以及整個 Type 1 字型子系統
Type 1 剖析以最醜的方式失敗。FontMatrix、BlueValues、字幹提示與 Type 2 charstring 裡的實數,依 Adobe Type 1 Font Format 規格是以小數點書寫的 PostScript 實數。在逗號地區設定下剖析 0.001 會拋出 EConvertError,包著它的 try/except 吞掉例外,字型矩陣停在零。那個字型的每個字形接著全部塌縮,看起來像字型問題而不是地區設定問題,讓你花一個下午去除錯內嵌字形渲染
一個轉換層,而不是零散補丁
HotPDF 現在遵循的規則很直接:任何寫進或讀出 PDF、PostScript、JSON 或 JavaScript 語法的數字,都走明確的不變式轉換,沒有任何程式碼依賴全域 FormatSettings 副作用。HPDFTypes 持有那四個轉換、它們共用的設定 record,以及給條碼路徑的一個固定精確度 helper
// Lib/HPDFTypes.pas——唯一的不變式轉換表面
function HPDFInvariantFormatSettings: TFormatSettings;
function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
const Default: Extended): Extended;
function HPDFFormatInvariant2(Value: Extended): string;
有兩個單元本來各自帶私有的 InvariantFormatSettings 函式,HPDFContentStream 與 HPDFTextExtraction,兩者都被縮成一行 forward 而不是留下平行實作。Type 1 子系統是刻意的例外:它不帶 VCL 相依、不參照 HPDFTypes,所以保留一個語意相同、名為 HPDFType1Core.Type1StrToFloat 的區域鏡像,理由是多複製一個十行函式的負擔,小於把圖形層拖進字型剖析器。另外兩處刻意不動。HPDFHTMLImport 剖析 CSS 長度時,刻意把小數點換成地區分隔符再用地區設定剖析,對那種輸入這是對的;HPDFType1CB.GlyphCommands 與 FontBBox 字串是公開 API,呼叫端用各自的 StrToFloat 讀回,在那裡用同一個地區設定產生與消費是自洽的,不變式輸出反而會弄壞逗號地區的每個呼叫端
HPDF_HAS_FORMAT_SETTINGS 在編譯器之間保證什麼
它保證不變式路徑在每個支援的工具鏈都真的能編譯,而顯而易見的版本閘門做不到。Delphi XE2 引入 TFormatSettings.Create 與接受 TFormatSettings 的 FloatToStr / TryStrToFloat 多載,所以第一個直覺是在每個呼叫點寫 {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF}。這個直覺有盲點:HotPDF.inc 在 Free Pascal 下定義 D2009+,但從不定義 XE2+,因為 XE2+ 回答的是 Delphi 版本號的問題,而 Free Pascal 3.2.2 不管怎樣都帶那些多載。所以每個閘門在 FPC 建置裡都靜默退化到依地區設定的分支,而且恰好就在原始回報來源的那些平台上。HPDF_HAS_FORMAT_SETTINGS 在 HotPDF.inc 的 FPC 區塊與 XE2+ 區塊都定義,它是數字 helper 唯一測試的符號
{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
Result := DefaultFormatSettings; // FPC 沒有 TFormatSettings.Create
{$ELSE}
Result := TFormatSettings.Create;
{$ENDIF}
Result.DecimalSeparator := '.';
Result.ThousandSeparator := ',';
end;
{$ENDIF}
函式裡還剩一個編譯器分支,因為 Free Pascal 沒有 TFormatSettings.Create,你必須從 DefaultFormatSettings 起步。只有兩個數字分隔符餵進轉換,所以日期與貨幣欄位保留目前地區設定提供的值。XE2 之前的 Delphi 完全沒有多載,在那裡 helper 退回用程序地區設定轉換、再在結果字串裡改寫分隔符字元,很醜但絕不碰全域狀態
第三層:SVG 允許而 PDF 不允許的指數
修小數分隔符是必要的,但不充分。W3C SVG 1.1 §4.2 在它的數字產生式裡允許指數,XPS 標記也一樣,所以 1E-6 在進場時是完全合法的座標,而 ISO 32000-1 §7.3.3 根本不容許指數。撐過不變式格式化的值仍可能以 1E-6 進到內容串流並被拒絕,而且固定格式的 FloatToStrF 一旦值離開 RTL 數字範圍,自己也可能退回指數形式。所以 HPDFSafeSVG.SVGFloat 在不變式轉換之後自己動手:把低於 1.0E-7 的一切沖成零,然後如果 E 存活下來,就剝掉指數、手動移動小數點,位移需要哪邊就在哪邊補零。SVGFinite 守著入口,在值到達格式化器之前拒絕 NaN 與 ±1.0E30 以外的東西,SVGReadNumber 配 SVGSkipSeparators 逐字元處理入向方向,而不是交給 RTL
// Lib/HPDFSafeSVG.pas——給 PDF 運算元的十進位記法
if Abs(Value) < 0.0000001 then
Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
SetLength(S, ExponentAt - 1);
// ... 依 ExponentValue 位移小數點,在左側或右側
// 補 '0',然後接回正負號
end;
那同一個 TSVGBuffer 也餵SVG 頁面匯出與其圖形狀態處理,同樣的數字紀律在匯入側也適用,例如透過 HPDFCreateXPSDocumentHandler 與 THPDFHandledDocumentFormat 的 hdfXPS 成員把 XPS 與 OXPS 文件轉成 PDF 時。標記格式對數字語法很大方,PDF 內容串流不是,而這個差異必須在轉換邊界解決
自己重現地區設定、而不是假設它的回歸測試
只在別人機器上出現的地區設定 bug,值得用自己設定地區設定的測試釘死,HotPDF 帶兩個。LoadedEditsEmitInvariantNumbersUnderCommaLocale 在第一次編輯前把逗號指派給 FormatSettings.DecimalSeparator,跑 RedactLoadedRect、StampLoadedPageText、StitchLoadedPage 與一個合成圖章,然後把頁面內容位元組讀回來,同時斷言確切的運算子字串存在、且緩衝區任何地方都沒有逗號。DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale 涵蓋另一半與原始罪孽:它在同一個逗號地區設定下驅動建立路徑,包括帶固定兩位小數格式化的 DirectDrawBarcode,然後斷言內容串流不帶逗號、且 EndDoc 回傳時 FormatSettings.DecimalSeparator 仍然是逗號
第二個斷言正是寫下當天就能抓到原始缺陷的那個,因為它測的是程式庫對宿主做了什麼,而不只是寫進磁碟的內容。這個教訓超出 PDF 之外:任何帶固定詞法文法的格式,包括 PostScript、JSON 與 JavaScript,都需要在邊界做明確的不變式轉換。伸手去拿全域設定來得到正確答案,只有在沒人用不同順序、不同執行緒或不同編譯器呼叫你的程式碼時才有用,而到那時候,失敗看起來就像除格式化問題以外的任何東西
這裡描述的數字格式化層,以 HotPDF Delphi Component 的一部分出貨,給 Delphi 與 C++Builder 用,依賴它的已載入文件編輯、條碼、SVG 匯出與 XPS 轉換路徑也一起在內