Technical Article

Delphi 中的 Unicode 安全电子表格导出:RTF 与 HTML

电子表格包含一列客户名称。有些是中文,有些是西里尔字母,有些带有德语变音符号或法语重音符号。您将其导出为 CSV 并打开结果,每个字符都完好无损。您将同一个工作簿导出为 RTF 以用于邮件合并模板,在字处理器中打开它,非 ASCII 名称已经折叠成一排排问号。数据从未改变。改变的是您写入的格式的编码契约,并且每个导出路径都携带不同的契约。

这就是捕获表面上看起来完全感知 Unicode 的库的陷阱。单元格文本在内部保存为 WideString,因此模型绝不会丢失字符。丢失发生在边界处,即必须将该文本序列化为格式的写入器中,该格式对于哪些字节是合法的以及必须如何对合法范围之外的任何内容进行编码有其自身的规则。做好一个写入器,您仍然可能交付另一个破坏相同文本的写入器。解决方法不是全局开关。它是在每个路径上做出独立、正确的决定。

RTF 在设计上是 7 位安全的格式

富文本格式(RTF)早于 Unicode,并被指定为能够在仅通过可打印 ASCII 的传输中幸存。RTF 文档在其页眉中声明代码页,并且写入器无法在该代码页中表示的任何字符都必须作为转义符而不是原始字节输出。相关的转义符是 \u,它携带一个有符号 16 位代码单元,后跟一个 ASCII 回退字符,以供完全无法理解转义符的老旧阅读器使用。

HotXLS 以这种方式写入 RTF。文档页眉通过声明代码页打开(格式为 \ansi\ansicpg1252\uc1),lxRTF 单元中的写入器遍历每个字符串,将高于普通 ASCII 的任何字符输出为 \u 转义符,以便无论声明的代码页能容纳什么,字节流都保持 7 位清洁。类似于 U+4E2D 的码点会变成字面序列  3?,not a raw byte that a viewer would then try to interpret through whatever code page it happened to assume. 没有这种约束,声明的代码页之外的任何内容都没有合法的字节表示,输出原始值的写入器就会产生作为本文开头的问号。

需要记住的细节是,声明的代码页和转义符是一个契约的两半。仅声明代码页对存在于其外部的文本没有帮助。在没有声明代码页的情况下输出转义符会使回退字符产生歧义。两者必须同时正确,这就是为什么仅处理其中之一的写入器在面对第一个多语言工作簿时仍会失败的原因。

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 进行折叠,因此对带重音或非拉丁文本的搜索匹配的是给定的相同字符,而不是它们的经过代码页破坏的近似值。

公式标记生成器携带一个相关的义务,该义务与字母无关,而与标记结束的位置完全相关。类似于 1E32.5E-3 的科学计数法是单个数字字面量,扫描器必须识别 E、可选的符号和随后的数字作为数字的一部分,而不是将输入拆分为名称后跟独立的数字。处理不当的扫描器会将完全有效的常数变成解析错误,或者更糟的是,变成默默错误的表达式。这属于相同的讨论,因为这两种情况都关乎读取器做出正确的字符级决定:一个关于如何折叠字符进行比较,另一个关于字符是否继续当前标记。

构建并导出多语言工作簿

公共 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
    // non-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。公式评估需要宽字符串大小写折叠以及保持科学计数法完整的标记生成器。其中每一个都是不同的契约,库可以满足其中一个而默默违反另一个。这就是为什么可以正确处理 CSV 的工具仍然可以交给您一个全是问号的 RTF 的原因。

如果您的导出倾向于分隔格式,它们之间的权衡在我们对 CSV、TSV 和 HTML 导出的演练中进行介绍,当源是结果集而不是手动构建的电子表格时,Delphi 报告的数据库导出模式自然与此处描述的编码规则相配套。所有这些都作为适用于 Delphi 和 C++Builder 的 HotXLS 组件的一部分提供,与本博客其他地方介绍的读取、公式和格式化 API 一起交付。