Technical Article

Delphi 中的 Unicode 安全試算表匯出:RTF 與 HTML

試算表包含一欄客戶名稱。有些是中文,有些是西里爾字母,有些帶有德語元音變音或法語重音。您將其匯出為 CSV 並開啟結果,每個字元都完好無損。您將同一個活頁簿匯出為 RTF 以用作合併列印範本,在文書處理器中開啟它,非 ASCII 名稱已摺疊成多行問號。資料從未改變。改變的是您寫入的格式的編碼約定,且每個匯出路徑都攜帶不同的約定。

這就是表面上看起來完全支援 Unicode 的函式庫所陷入的陷阱。儲存格文字在內部作為 WideString 保留,因此模型絕不會遺失任何字元。遺失發生在邊界,即必須將該文字序列化為格式的寫入器中,該格式對哪些位元組是合法的以及如何對合法範圍之外的任何內容進行編碼有其自身的規則。弄對一個寫入器,您仍然可以出貨另一個破壞相同文字的寫入器。修正方法不是全域切換。它是對每條路徑做出獨立、正確的決定。

RTF 在設計上是 7 位元安全的格式

RTF (Rich Text Format) 早於 Unicode,且被指定在僅傳輸可列印 ASCII 的傳輸中倖存下來。RTF 檔案在它的標頭中宣告代碼頁,且寫入器無法在該代碼頁中表示的任何字元都必須作為逸出發送,而不是作為原始位元組。相關的逸出是 \u,它攜帶帶正負號的 16 位元代碼單元,後面跟著一個 ASCII 遞補字元,供因年代太久而完全無法理解該逸出的閱讀器使用。

HotXLS 以這種方式寫入 RTF。檔案標頭藉由宣告代碼頁(形式為 \ansi\ansicpg1252\uc1)開啟,且 lxRTF 單元中的寫入器遍歷每個字串,將高於普通 ASCII 的任何字元發送為 \u 逸出,以便位元流保持 7 位元乾淨,而不管宣告的代碼頁能保存什麼。諸如 U+4E2D 之類的碼點變為字面序列  3?,而不是檢視器隨後會嘗試透過它碰巧假設的任何代碼頁進行直譯的原始位元組。如果沒有該規則,宣告的代碼頁之外的任何內容都沒有合法的位元組表示,而發送原始值的寫入器會產生引出本文的問號。

需要記住的細節是,宣告的代碼頁和逸出是一個約定的兩個半部。僅宣告代碼頁對其之外的文字沒有幫助。發送不含宣告代碼頁的逸出語法會使遞補字元產生歧義。這兩者必須同時正確,這就是為什麼僅處理其中一個的寫入器在第一個多語言活頁簿上仍然會失敗的原因。

HTML 逸出不僅僅是角括號

HTML 匯出會產生一個多工作表檔案,其導覽框架將工作表名稱作為可見文字攜帶。這些名稱是作者控制的字串,可以包含任何字元,包括具有標記意義的字元。字面上命名為 Q1 & Q2 <draft> 的工作表必須作為逸出實體到達頁面,否則角括號會開啟一個虛擬標籤,而安培符號會啟動一個從未打算使用的實體參照。這是普通的 HTML 逸出,而在框架標籤上跳過它是那種能通過所有基於僅 ASCII 工作表名稱建立的測試的疏漏。

編碼問題位於其下一層。當非 ASCII 字元落在無法保證作為 UTF-8 提供服務的環境中時,安全的表示法是數值字元參照,因此 U+00E9 會寫入為 é,而不是原始位元組(其意義取決於回應字元集)。此規則的鏡像適用於輸入端。從 XLSX 讀回的活頁簿攜帶共享字串,其中字元可能已經儲存為數值 XML 實體,且該實體在進入儲存格模型之前必須被解碼為一個完整的字元。如果不小心解碼,將碼點分割為單獨的位元組,單個字元會重新出現為兩片亂碼 (mojibake),後續任何匯出都無法修復。

XLSX 容器是 ZIP,且 ZIP 有其自身的名稱編碼

XLSX 檔案是 ZIP 封存檔,且該封存檔為其保存的每個成員儲存名稱。ZIP 年代已久,以致其原始規格對這些名稱的編碼隻字未提,因此找不到訊號的讀取器會假設封存檔的本機代碼頁。一旦成員名稱包含非 ASCII 字元,該假設就是錯誤的(這發生在本地化的工作表部分名稱以及其檔案名稱帶有重音符號或非拉丁指令碼的嵌入式媒體中)。

修正方法是單個位元。每個本機檔案標頭中的通用位元 11 宣告成員名稱編碼為 UTF-8。HotXLS 在讀取封存檔時精確檢查該位元,針對遮罩 $0800 測試通用旗標,且忽略它的讀取器或寫入器會誤讀正確實作儲存為 UTF-8 的名稱。該位元設定成本低且易於遵守,它是成員名稱在往返中倖存下來與在試算表內容被剖析之前就損壞到達之間的全部區別。

大小寫折疊與數字掃描隱藏了相同的危害

公式評估是 Unicode 安全不再涉及序列化而涉及比較的地方。SEARCH 函式不區分大小寫,這意味著它在尋找子字串之前必須先進行大小寫折疊。錯誤的折疊方式是透過 ANSI 代碼頁,因為以此方式將非 ASCII 文字轉換為大寫會使字元通過狹窄的代碼頁,並損壞其之外的任何內容。正確的方法是寬字串大寫化,這保留了完整的 UTF-16 範圍。HotXLS 正是出於這個原因使用 WideUpperCase 進行折疊,因此對帶重音符號或非拉丁文字的搜尋會與給予它的相同字元相符,而不是與它們被代碼頁破壞的近似值相符。

公式 token 解析器攜帶一個相關的職責(與字母無關,而與 token 在何處結束完全相關)。科學記號(例如 1E32.5E-3)是單個數值字面量,且掃描器必須識別 E、可選的符號以及後續數字作為數字的一部分,而不是將輸入分解為名稱,後面跟著獨立的數字。處理不當的掃描器會將完全有效的常數轉換為剖析錯誤,或者更糟的是,轉換為悄悄出錯的運算式。它屬於同一個討論,因為這兩個案例都涉及讀取器做出正確的字元級決定:一個是關於如何折疊字元以進行比較,一個是關於字元是否繼續目前的 token。

建立並匯出多語言活頁簿

公用 API 不要求您考慮任何這些。您從 WideString 儲存格值建立活頁簿,並呼叫您想要的匯出進入點。編碼決策發生在每個寫入器內部。下面的範例在工作表中播種了幾種指令碼的文字,然後從同一個活頁簿中寫入 RTF 檔案和 HTML 檔案,這樣這兩条路徑就在相同的輸入上執行。

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Customers');

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // new-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

兩個呼叫都傳回 Integer 狀態,且兩者都使用相同的記憶體中文字。呼叫程式碼中沒有任何內容宣告代碼頁或逸出字元,因為職責在於了解其自身格式的寫入器。如果您需要從相同來源進行分隔符號匯出,活頁簿級的 SaveAsCSV 遵循相同的形式。

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Customers.csv');

Unicode 安全是針對每條路徑,而非針對每個函式庫

值得記住的教訓是,沒有單個地方可以做到 Unicode 安全。RTF 需要宣告的代碼頁加上 \u 逸出。HTML 需要針對標記意義字元進行實體逸出,以及在字元集無法保證的情況下進行數值參照,外加正確解碼在共享字串中到達的實體。ZIP 容器需要設定通用位元 11,以便將 UTF-8 成員名稱讀取為 UTF-8。公式評估需要寬字串大小寫折疊,以及使科學記號保持在一個整體中的 token 解析器。這些都是不同的約定,且函式庫可以滿足其中一個,同時悄悄地違反另一個。這就是為什麼將 CSV 做對的工具仍然會交給您一個充滿問號的 RTF 的原因。

如果您的匯出偏向於分隔格式,它們之間的折衷在我們的 CSV、TSV 和 HTML 匯出逐步解說中介紹,而當來源是結果集而非手動建立的工作表時,Delphi 報表的資料庫匯出模式自然會與此處介紹的編碼規則配對。所有這些都作為適用於 Delphi 和 C++Builder 的 HotXLS 試算表元件 的一部分出貨,同時也提供本部落格其他地方介紹的讀取、公式和格式化 API。