PDFium Delphi Component 透過把 UTF-8 片段串接進 AnsiString 來組裝 PDF/A 輸出的 XMP 資料包;在 Free Pascal 3.2.2 上,只要文件標題帶有非 ASCII 字元,這個資料包就會悄悄不再是有效 UTF-8。ISO 19005-1 第 6.7.2 節要求中繼資料串流使用有效 UTF-8,因此檔案驗證失敗。3.103.1 版本在 StringToUtf8 中直接修復了編碼器。真正有意思的不是修補程式,而是同一行未改動的原始碼,在 Delphi 下產生正確位元組,在 Lazarus LCL 應用程式中也產生正確位元組,卻在使用完全相同單元編譯的普通 Free Pascal 主控台程式中產生損壞位元組。只有把 Free Pascal 的三種獨立字串行為放在一起,結果才說得通,而它們各自都很合理
同一份中繼資料程式碼為什麼會在 Delphi 和 FPC 上輸出不同位元組
因為兩個編譯器中的 string 不是同一種型別。FPC 3.2.2 在 {$MODE Delphi} 下把 string 編譯為帶 DefaultSystemCodePage 標籤的 AnsiString,而 Delphi 將它編譯為 UnicodeString。TPdfASaveOptions 中的每個中繼資料欄位都宣告為 string,因此 Title、Author、Subject、Keywords、Creator 和 Producer 在一個編譯器中攜帶 UTF-16 程式碼單元,在另一個編譯器中攜帶單位元組字元和程式碼頁標籤。同一個記錄、同一個欄位,卻是不同的載荷。值本身以 UTF-16 形式從文件到達。TPdf.GetTitle 及其同類方法回傳 WString,在 FPC 上是 WideString,在 Delphi 上是 string;SaveAsPdfAToStream 會在注入標記前從 Info 字典為任何空選項欄位填充值。在 Free Pascal 上,這次賦值是窄化轉換,RTL 會透過目標字串程式碼頁執行它。在 LCL 程式中,LazUTF8 已經將 DefaultSystemCodePage 設為 CP_UTF8,所以窄化結果是 UTF-8,下游剛好全部正確。在普通主控台程式中,同樣的窄化會落到 ANSI 程式碼頁,而 StringToUtf8 又因為假定輸入已經是 UTF-8,直接原樣複製這些八位元組。六個儲存橋接都共享這種形狀:SaveAsPdfAToStream、SaveAsPdfUaToStream、SaveAsPdfEToStream、SaveAsPdfXToStream、SaveAsPdfRToStream 和 SaveAsPdfVTToStream,每個都有自己的選項記錄
// PDFium.pas:文件存取器一律使用 UTF-16
// WString 在 FPC 上是 WideString,在 Delphi 上是 string(UnicodeString)
function TPdf.GetTitle: WString;
// FPdfPdfa.pas:儲存選項記錄使用 `string` 承載中繼資料
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // Delphi 上是 UnicodeString
// FPC 上是 AnsiString + DefaultSystemCodePage
Author: string;
Subject: string;
Keywords: string;
Creator: string;
Producer: string;
CreationDate: string;
ModDate: string;
DocumentId: TBytes;
InstanceId: TBytes;
class function Default: TPdfASaveOptions; static;
end;
// SaveAsPdfAToStream 從 Info 字典回填空欄位
// 現在將窄化轉換明確寫出,而不是留給隱式轉換
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
讓六個橋接都透過一個 WStringToStr 輔助函式,並不會改變 RTL 的行為,但會把轉換放到讀者看得見的位置,同時清除了 92 個隱式轉換警告,而這些警告此前正好掩蓋了這類問題。這與我們在PDFium 建置中的 Delphi 與 FPC 跨編譯器陷阱筆記裡描述的 Delphi 端損壞相反,後者是 Delphi 上的串接破壞高位元組,而 Free Pascal 會保留它
三個會擊敗直覺修復的 Free Pascal 行為
直覺修復是呼叫 UTF8Encode,然後就結束。可是,在 FPC 3.2.2 的 Delphi 模式下,它會連續三次失敗,而且每一次都是靜默失敗
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// 陷阱 1:Delphi 模式下 UTF8String 變數就是普通 AnsiString,
// 因此賦值會把八位元組直接轉回主機程式碼頁
U := UTF8Encode(W);
// 陷阱 2:S 已經是 AnsiString,因此 UTF8Encode 完全不會做任何事
R := UTF8Encode(S); // 不解碼、不編碼、不報錯
R := UTF8Encode(UnicodeString(S)); // 這一處才真正編碼
// 陷阱 3:串接會將每個運算元統一到目標程式碼頁,
// RawByteString 作為目標也不例外
Xmp := Xmp + R;
end;
陷阱一意味著編碼結果必須留在它產生的 AnsiString 或 RawByteString 中。把它在輸出路上經過 UTF8String 暫存變數,就等於把剛完成的工作撤銷。陷阱二最隱蔽,因為 UTF8Encode(S) 能編譯、能執行、回傳正確長度的值;當參數已經是 AnsiString 時它完全不做轉換,只有先擴大到 UnicodeString 才會解碼。陷阱三解釋了為什麼正確的編碼器仍然能產生損壞文件:BuildXmpBytes 會在區域 Xmp: AnsiString 中累積資料包,而 Free Pascal 會把串接的每個運算元都轉換到目標變數的程式碼頁,在進入資料包時把多位元組序列折回單位元組 ANSI
SetCodePage 傳入 False 到底保證什麼
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) 會在不觸碰位元組的情況下重新標記字串。第三個參數是 Convert;傳入 False 表示「假定載荷已經屬於目標程式碼頁,只修改標籤」。這是有意對內容撒謊:這些八位元組實際上是 UTF-8,但將它們標記為主機程式碼頁,正是阻止陷阱三中的串接進行轉換的方法。它們會作為原始位元組加入 XMP 緩衝區,再從另一端原樣輸出
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi:UTF8Encode 已產生帶 CP_UTF8 標籤的位元組,串接到
// AnsiString 中會保留它們
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC:必須先擴大,否則 UTF8Encode 接收 AnsiString 時是空操作
Result := UTF8Encode(UnicodeString(S));
// 不轉碼而重新標記,這樣位元組能存活於組裝 XMP 資料包和 PDF
// 字串物件的 ANSI 標記緩衝區中
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
要明確邊界。重新標記只用於 FPC,而且不是混合帶標籤和未帶標籤字串的通用許可。這裡之所以可行,是因為下游只有一種消費者模式:追加到 AnsiString,然後以位元組形式寫出緩衝區。任何試圖在主機程式碼頁中將重新標記的值當作文字解釋的程式碼,都會讀到亂碼,而這是正確結果。反向方向在兩個編譯器上都使用另一種方式處理,而且完全相同:用 SetCodePage(..., False) 將輸入緩衝區標記為 CP_UTF8,然後呼叫 UTF8ToString
回歸測試為什麼也帶有同一個陷阱
因為根據原始碼字面值建立預期位元組的測試,測試的是編譯器而不是程式庫。Pascal 原始檔中寫出的 #$C3#$A9 之類常數帶有該原始檔的編譯時程式碼頁,當它傳入 AnsiString 參數時,RTL 會重新編碼它,這正是被測轉換。預期值必須在執行時逐位元組組裝並逐位元組比較,因為兩個帶不同標籤的 AnsiString 用 = 比較時會先協調程式碼頁,隨後回傳一個令人安心的假陰性
function BytesPattern(const Values: array of Byte): AnsiString;
var
I: Integer;
begin
SetLength(Result, Length(Values));
for I := 0 to High(Values) do
Result[I + 1] := AnsiChar(Values[I]);
end;
procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
Saved: Word;
Wide: WideString;
Narrowed: string;
Encoded, ExpectedUtf8: AnsiString;
begin
Saved := DefaultSystemCodePage;
try
SetMultiByteConversionCodePage(1252);
Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
Narrowed := Wide; // 被測的窄化
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 作為 UTF-8,在執行時建構,避免字面值被重新編碼
ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
SameOctets(Encoded, ExpectedUtf8));
end;
測試工具本身是一個 LCL 程式,因此 DefaultSystemCodePage 是 CP_UTF8,只有測試主動切換它時 bug 才會出現。SetMultiByteConversionCodePage(1252) 在 try..finally 內執行,使一個測試暫時重現普通主控台環境。端到端檢查還更進一步,斷言標記注入產生的 XMP 資料包必須包含 $43 $61 $66 $C3 $A9,且不得包含 $43 $61 $66 $E9,這樣未來重新退回原始單位元組輸出的回歸會明確失敗,而不會只產生一份在十六進位傾印中看似合理的檔案。如果你處理非拉丁中繼資料,同樣的擴大紀律也適用於會破壞 Delphi WideChar 處理的 emoji 與 CJK 文字
窄化還會落在哪裡
XMP 是最明顯的受害者,但同一程式碼庫中任何 TBytes 到 string 的橋接都暴露在相同風險下。v3.103.1 還修正了兩個位置:FPdfProduction 中來回轉換 XFA 資料集資料包的 Utf8BytesToString 和 StringToUtf8Bytes,這樣 MergePdfXfaDatasets 才能替換繫結值;以及 FPdfTrustedList 中去除位元組順序標記後解碼歐洲信任清單 XML 的 BytesToUtf8。現在它們都會先把緩衝區放入 RawByteString,在不轉換的情況下標記為 CP_UTF8,再使用 UTF8ToString 解碼。有一個模組原本就不受影響,原因值得照搬。XFDF 寫入器宣告了自己的文字型別 XFDFString,在 FPC 下解析為 WideString,在 Delphi 下解析為 UnicodeString,因此其編碼器根本不會看到帶程式碼頁標籤的 AnsiString。結構性修復就是這樣:在精確的序列化點之前,一直讓文字保持 UTF-16 型別,再讓一個窄函式獨占轉換為位元組。這個家族的每個 bug,根因都是一端為 UTF-16、另一端為八位元組的流程中間出現了 string 欄位
檢查自己的雙編譯器 PDF 程式碼
如果你交付同時執行在兩個編譯器上、並將中繼資料寫入符合標準 PDF 的 Object Pascal 程式碼,下面四項檢查可以在驗證器之前發現大多數此類問題
- 搜尋帶有
string參數的UTF8Encode。在 FPC 上這次呼叫是空操作,是最值得優先稽核的一行 - 在 Delphi 模式下,把每個
UTF8String變數都視為可疑物件。它在這裡是普通AnsiString,把編碼位元組賦給它會把位元組轉回去 - 至少在
SetMultiByteConversionCodePage切換到單位元組程式碼頁的環境下執行一次回歸。LCL 測試工具以CP_UTF8執行,永遠重現不了普通主控台程式 - 在執行時建立預期位元組向量,並逐八位元組比較。原始碼字面值和
=都會經過程式碼頁協調,反而會掩蓋正在尋找的缺陷
這不是奇異的 Free Pascal 軼聞,而是一個在 UTF-16 型別之外保留面向位元組字串型別的語言所要付出的普通代價;兩個編譯器對 string 應該表示什麼做出了合理卻不同的選擇。對 PDF 工作而言,實際後果很窄卻很尖銳:中繼資料在 IDE 中看起來正常,卻可能以無效 UTF-8 進入 XMP 資料包,而 ISO 19005-1 第 6.7.2 節並不在乎把它放進去的是哪個編譯器。如果你正在建構歸檔流程,編碼層值得和周圍的PDF/A 歸檔合規工作流程同等重視。PDFium Delphi Component 已將這些轉換作為程式庫的一部分,因此 SaveAsPdfA 及其五個標準兄弟入口可以在 Delphi、Lazarus 和普通 Free Pascal 建置中輸出符合要求的 UTF-8 中繼資料,不需要呼叫方設定任何程式碼頁。完整 API 文件和目前版本位於 PDFium Delphi Component 產品頁