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 回報它們一模一樣。只有渲染抓到了它,這也是為什麼語料庫基準會把每一頁都渲染,而不是只信結構檢查
您解析到的值不是您該寫出的字面值
PDF 實數是一個十進位字串,而 ISO 32000-1 §7.3.3 講得很明,它就只是個十進位字串:不能有基底標記、不能有指數形式。Annex C 接著列出實作應該遵守的精度,大約是小數部分五位有效十進位數字。預設輸出精度四位已經低於那個水準,而且在接近零的時候更糟:PLDoubleToStr 會把值縮放、四捨五入成整數,並在結果為零時送出 0,所以一個值為 -0.000012345 的 matrix 項目不是掉一位數,而是整個消失
把預設值調高只是把懸崖往後移。修法是別再假裝 Double 就是那個數字本身。當 TPDFStructure.Decode 裡的 tokenizer 認出一個標準實數,也就是這個 token 含小數點、不含指數標記時,它會把來源文字存進新增的 FOriginalText 欄位,與轉換後的值並存。Output 之後就優先採用那段文字,只有在沒東西可優先時才回頭去格式化
// 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) 就夠了,因為那正是清掉文字的動作。原始的行內影像資料則一如往常地不被碰觸
// 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 產品頁上