技術文章

FPC 程式碼頁陷阱如何破壞 Delphi PDF/A XMP 中繼資料

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 將它編譯為 UnicodeStringTPdfASaveOptions 中的每個中繼資料欄位都宣告為 string,因此 TitleAuthorSubjectKeywordsCreatorProducer 在一個編譯器中攜帶 UTF-16 程式碼單元,在另一個編譯器中攜帶單位元組字元和程式碼頁標籤。同一個記錄、同一個欄位,卻是不同的載荷。值本身以 UTF-16 形式從文件到達。TPdf.GetTitle 及其同類方法回傳 WString,在 FPC 上是 WideString,在 Delphi 上是 stringSaveAsPdfAToStream 會在注入標記前從 Info 字典為任何空選項欄位填充值。在 Free Pascal 上,這次賦值是窄化轉換,RTL 會透過目標字串程式碼頁執行它。在 LCL 程式中,LazUTF8 已經將 DefaultSystemCodePage 設為 CP_UTF8,所以窄化結果是 UTF-8,下游剛好全部正確。在普通主控台程式中,同樣的窄化會落到 ANSI 程式碼頁,而 StringToUtf8 又因為假定輸入已經是 UTF-8,直接原樣複製這些八位元組。六個儲存橋接都共享這種形狀:SaveAsPdfAToStreamSaveAsPdfUaToStreamSaveAsPdfEToStreamSaveAsPdfXToStreamSaveAsPdfRToStreamSaveAsPdfVTToStream,每個都有自己的選項記錄

// 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;

陷阱一意味著編碼結果必須留在它產生的 AnsiStringRawByteString 中。把它在輸出路上經過 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 程式,因此 DefaultSystemCodePageCP_UTF8,只有測試主動切換它時 bug 才會出現。SetMultiByteConversionCodePage(1252)try..finally 內執行,使一個測試暫時重現普通主控台環境。端到端檢查還更進一步,斷言標記注入產生的 XMP 資料包必須包含 $43 $61 $66 $C3 $A9,且不得包含 $43 $61 $66 $E9,這樣未來重新退回原始單位元組輸出的回歸會明確失敗,而不會只產生一份在十六進位傾印中看似合理的檔案。如果你處理非拉丁中繼資料,同樣的擴大紀律也適用於會破壞 Delphi WideChar 處理的 emoji 與 CJK 文字

窄化還會落在哪裡

XMP 是最明顯的受害者,但同一程式碼庫中任何 TBytesstring 的橋接都暴露在相同風險下。v3.103.1 還修正了兩個位置:FPdfProduction 中來回轉換 XFA 資料集資料包的 Utf8BytesToStringStringToUtf8Bytes,這樣 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 產品頁