技術文章

在 Delphi 存檔時保留已解析的 PDF 十進位精度

PDFlibPas,也就是 losLab PDF Developer Library,會把文件中每個實數解析時的確切十進位文字留住,並在該值從未被修改過時把那段文字逐字寫回。從 v3.539.19 起,SetPrecision 設定只管程式庫自己建立或編輯的數字,所以一次普通的載入再存檔,不會再把 CalRGB 的 /Gamma 從 2.22221 四捨五入成 2.2222,也不會讓一個沒人碰過的頁面顏色跑掉。這個改動在程式碼上很小,在它對剖析器所說的事情上卻很大:您解碼出來的值與您送出的字面值是兩回事,而一趟 Double 來回並不是恆等轉換

為什麼什麼都沒改的存檔會讓頁面顏色跑掉

因為被重新格式化的是色彩空間參數,不是影像。揭露出這件事的檔案,是本地回歸語料庫裡一份 35 頁的辦公文件,每一頁都重用同一張頁首影像。把它載入後直接存回去,產出的影像串流與輸入逐位元組相同,串流雜湊比對也回報文件沒有改變。渲染比對則不同意:35 頁每一頁都在頁首顯示出像素差異,其他地方都沒有

頁首影像是透過 CalRGB 色彩空間繪製的,ISO 32000-1 §8.6.5.3 以 /WhitePoint、一個選用的三元素 /Gamma 陣列,以及一個選用的九元素 /Matrix 來定義它。那些陣列是色彩空間字典裡再普通不過的數值物件。TPDFNumeric 把每一個都存成 Double,其他什麼都沒有,而 TPDFNumeric.Output 透過 PDFPrecNum 把那個 Double 格式化,預設是四位小數。於是 /Gamma 從 2.22221 變成 2.2222,某個 matrix 項目從 0.71519 變成 0.7152,而渲染器忠實地從略微不同的校正值產出略微不同的顏色。影像位元組是無辜的,它們周遭的數字不是。讓人不安的部分是這件事有多隱形。比對解碼後的串流位元組看不到,因為那些數字住在字典裡而不是串流裡。比對附件 payload 也看不到。連修改層級這篇所述的修訂差異比對,取指紋的也是正規化後的物件主體,所以兩個修訂版雜湊到同一個值,diff 回報它們一模一樣。只有渲染抓到了它,這也是為什麼語料庫基準會把每一頁都渲染,而不是只信結構檢查

PDFlibPas 在 Delphi 中於無作用存檔時失去 CalRGB 精度的地方:解析出來的 /Gamma 2.22221 與 0.71519 這個 matrix 項目在 TPDFNumeric 裡只是個 Double,Output 透過 PLDoubleToStr 以 PDFPrecNum 的四位小數格式化它們,每一項結構檢查都回報文件沒變,只有渲染比對顯示全部 35 張頁首影像都跑了色
影像位元組是無辜的:是 TPDFNumeric 透過 PDFPrecNum 把周遭的校正數字重新格式化,所以串流雜湊與指紋 diff 都回報修訂版相同,而渲染器在每一頁產出略微不同的顏色

您解析到的值不是您該寫出的字面值

PDF 實數是一個十進位字串,而 ISO 32000-1 §7.3.3 講得很明,它就只是個十進位字串:不能有基底標記、不能有指數形式。Annex C 接著列出實作應該遵守的精度,大約是小數部分五位有效十進位數字。預設輸出精度四位已經低於那個水準,而且在接近零的時候更糟:PLDoubleToStr 會把值縮放、四捨五入成整數,並在結果為零時送出 0,所以一個值為 -0.000012345 的 matrix 項目不是掉一位數,而是整個消失

把預設值調高只是把懸崖往後移。修法是別再假裝 Double 就是那個數字本身。當 TPDFStructure.Decode 裡的 tokenizer 認出一個標準實數,也就是這個 token 含小數點、不含指數標記時,它會把來源文字存進新增的 FOriginalText 欄位,與轉換後的值並存。Output 之後就優先採用那段文字,只有在沒東西可優先時才回頭去格式化

PDFlibPas 在 Delphi 中如何保留已解析的十進位文字:TPDFStructure.Decode 裡的 tokenizer 對任何含小數點且無指數的 token,把來源字面值留在 FOriginalText,Output 逐字寫出那段文字而不是呼叫 PLDoubleToStr,SetTo 會清掉它,因為被編輯過的數字就是新數字
您解碼出來的值與您送出的字面值是兩回事:優先採用解析到的文字讓 2.22221 保持精確,而程式庫建立與編輯過的數字仍然遵循 PDFPrecNum,這個設定也永遠碰不到未被觸動的輸入
// Lib/PDFlibStruct.pas — 輸出這一側的完整修法
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // 被編輯過的數字就是新數字
  FValue:= Value;
  FChanged:= True;
End;

有兩條界線是刻意的。整數不保留,因為整數格式化本來就是無損的。像 6.02E23 這種指數形式,為了那些壞掉的產生器在輸入時會容忍,但輸出時不保留,因為把它們寫回去等於延續一種 §7.3.3 明令禁止的語法;它們會像任何程式庫產生的數字一樣走格式化器。tokenizer 在存下文字之前也會套用它一貫的最小修補,所以像 .5 這種開頭只有小數點的字面值會留成 0.5,而像 5. 這種結尾只有小數點的字面值會留成 5.0。對每個讀取器來說兩者都是同一個數字,而且被接受的範圍廣得多

v3.539.19 之後 SetPrecision 保證了什麼

TPDFlib.SetPrecision 現在控制的是程式庫自己產生的數字的小數位數:透過 painter 繪製的值、從 Double 建立(例如經由 NewNumeric)的數字,以及任何之後被 SetTo 編輯過的解析值。要注意的是,經由物件 API 解碼的文字,例如傳給 SetObjectFromString 的字面值,也走同一個 tokenizer,也以同樣方式被保留。一個從未被修改的已解析十進位數,不管設定怎麼調都保持它輸入時的精度,而在載入之後才改設定也不會回頭動到它。SetPrecision 的參考條目在同一個版本裡更新成正是這個說法,因為舊措辭暗示這個設定適用於檔案裡的每一個數字

清掉的動作發生在 SetTo 裡,而不是由 Changed 旗標推導出來,這個區別很重要。儲存流程會在物件寫入之後重設它們的 Changed,所以「除非已變更否則送出原始文字」這種寫法,會對一個在同一段工作階段裡被編輯、儲存、又再編輯的值開始送出過期的文字。把原始文字綁在指定動作本身,就讓兩者不可能不一致。回歸測試用原始檔案裡的數值把這些行為逐一釘住

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // 未編輯的輸入逐字存活,包括那個在四位小數
    // 格式化下會被壓成 0 的值
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // 編輯會丟棄原始文字並遵循 PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // 之後調低精度也不會碰到未編輯的輸入
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

為什麼內容模型仍然會正規化數字

因為 TPDFContentProgram 承諾的是正規化的數值運算元,而那份承諾比內容串流裡的逐字文字更值錢。可編輯內容模型,也就是圖形狀態追蹤器所建立於其上的那一個,存在的目的是讓 NormalizeContentStreams、優化器與 Emit 能從任意輸入產出穩定、可比較的輸出。如果一個解析出來的運算元把原始文字帶進模型,那麼 0.50000 0 0 RG 這樣的運算子序列就會與 0.5 0 0 RG 送出不同結果,而下游每一次比對都會隨產生器的排版習慣漂移

所以模型在它的兩個入口就把原始文字剝掉。NormalizeContentNumbers 會在剖析器推入每個運算元時對它執行一次,並在呼叫端提供的來源被解碼時於 SetOperand 內再執行一次,而且它會遞迴穿過陣列與字典,好涵蓋虛線樣式、TJ 陣列與標記內容的屬性字典。對每個數值呼叫 SetTo(AsDouble) 就夠了,因為那正是清掉文字的動作。原始的行內影像資料則一如往常地不被碰觸

為什麼 PDFlibPas 的內容模型仍然會正規化數字:NormalizeContentNumbers 在剖析器推入每個運算元時執行,也在 SetOperand 內再執行一次,並遞迴穿過陣列與字典以涵蓋虛線樣式、TJ 陣列與標記內容屬性字典,而 SetTo AsDouble 會清掉原始文字,使 0.50000 與 0.5 送出相同結果
正規化的數值運算元是內容模型的承諾:原始行內影像資料不被碰觸,而內容串流之外未被觸動的字典數字仍保有逐字保證,所以單純的 LoadFromFile 加 SaveToFile 依然會保留它們
// Lib/PDFlibContentModel.pas — 內容模型維持它的契約
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

所以對呼叫端而言,實務上的規則很簡單。單純的 LoadFromFile 加上 SaveToFile,會讓未被觸動的內容串流與未被觸動的字典數字維持原樣。一頁走過 NormalizeContentStreams,或任何經由內容模型做的編輯,產出的是刻意的正規化結果,而文件其餘部分依然被保留。這是兩個不同的請求,而它們現在做的是兩件不同的事

代價是什麼,以及保證到哪裡為止

現在每個 TPDFNumeric 都多帶一個 AnsiString 參照,而每個已解析的十進位數都會讓它的來源文字活到物件壽命結束。在一個有幾百萬個實數的文件上,那是實實在在的記憶體,該被算進任何大文件量測裡,而不是被擺擺手帶過。這份保證也僅限於數字自己所屬的文件:在文件之間複製物件,或經由物件 API 重建數值,產生的都是新數字,而新數字就像任何其他新數字一樣遵循輸出精度。這個版本宣稱與不宣稱什麼,值得講精確。一份未被觸動的文件做載入再存檔,現在會保住渲染器實際消費的那些校正數字,而這正是語料庫基準所檢查的性質。它不宣稱輸出逐位元組相同,那還取決於物件編號、串流壓縮,以及決定性 PDF ID 這篇談到的 trailer 識別碼。它也不會讓指紋 diff 看見其他軟體產生之檔案裡的捨入差異,因為那些仍然對正規化後的主體取雜湊。這個教訓的適用範圍遠超過 CalRGB:當一個剖析器只留下轉換後的值,每一次存檔就都是一次編輯,而唯一能察覺的辦法是去看渲染結果。數值處理與 SetPrecision 語意的說明都在 losLab PDF Developer Library 產品頁上