Technical Article

Xuất bảng tính an toàn Unicode trong Delphi: RTF và HTML

Một bảng tính chứa một cột tên khách hàng. Một số bằng tiếng Trung, một số bằng tiếng Kirin (Cyrillic), một số ít mang dấu phụ tiếng Đức hoặc dấu tiếng Pháp. Bạn xuất nó sang CSV và mở kết quả, mọi ký tự vẫn còn nguyên vẹn. Bạn xuất cùng một sổ làm việc đó sang RTF cho một mẫu thư trộn (mail-merge), mở nó trong một trình xử lý văn bản, và các tên không phải ASCII đã sụp đổ thành các hàng dấu hỏi. Dữ liệu không bao giờ thay đổi. Điều thay đổi là hợp đồng mã hóa của định dạng bạn đã ghi, và mỗi đường dẫn xuất mang một hợp đồng khác nhau.

Đây là cái bẫy bắt lấy một thư viện trông có vẻ hoàn toàn hiểu Unicode trên bề mặt. Văn bản ô được giữ bên trong dưới dạng WideString, vì vậy mô hình không bao giờ làm mất một ký tự nào. Sự mất mát xảy ra ở biên giới, trong trình viết (writer) phải tuần tự hóa văn bản đó thành một định dạng có các quy tắc riêng của nó về byte nào là hợp lệ và làm thế nào bất cứ thứ gì ngoài phạm vi hợp lệ phải được mã hóa. Viết đúng một trình viết nhưng bạn vẫn có thể xuất xưởng một trình viết khác làm hỏng cùng một đoạn văn bản. Giải pháp không phải là một công tắc toàn cục. Nó là một quyết định riêng biệt, chính xác trên mọi đường dẫn.

RTF là một định dạng an toàn 7-bit theo thiết kế

Định dạng văn bản giàu (Rich Text Format) có trước Unicode và được chỉ định để tồn tại qua các phương thức vận chuyển chỉ truyền tải mã ASCII có thể in được. Một tài liệu RTF khai báo một trang mã (code page) trong tiêu đề của nó, và bất kỳ ký tự nào trình viết không thể đại diện trong trang mã đó phải được phát ra dưới dạng một mã thoát (escape) thay vì một byte thô. Mã thoát có liên quan là \u, mang một đơn vị mã 16-bit có dấu kèm theo một ký tự dự phòng ASCII cho các trình đọc quá cũ để hiểu hoàn toàn mã thoát.

HotXLS ghi RTF theo cách này. Tiêu đề tài liệu mở đầu bằng việc khai báo trang mã, dưới dạng \ansi\ansicpg1252\uc1, và trình viết trong đơn vị lxRTF đi qua từng chuỗi, phát ra bất kỳ ký tự nào phía trên ASCII phẳng dưới dạng một mã thoát \u để stream byte duy trì sạch sẽ 7-bit bất kể trang mã đã khai báo có thể chứa gì. Một điểm mã chẳng hạn như U+4E2D trở thành chuỗi ký tự  3?, chứ không phải một byte thô mà sau đó trình xem sẽ cố gắng diễn giải qua bất kỳ trang mã nào mà nó vô tình giả định. Không có kỷ luật đó, bất cứ thứ gì nằm ngoài trang mã đã khai báo đều không có biểu diễn byte hợp lệ, và một trình viết phát ra giá trị thô sẽ tạo ra các dấu hỏi đã khởi đầu bài viết này.

Chi tiết cần lưu ý là trang mã đã khai báo và các mã thoát là hai nửa của một hợp đồng. Chỉ khai báo trang mã không giúp gì cho văn bản nằm bên ngoài nó. Phát ra mã thoát mà không có trang mã khai báo sẽ để lại các ký tự dự phòng mơ hồ. Cả hai phải chính xác cùng nhau, đó là lý do tại sao một trình viết chỉ xử lý một trong hai vẫn thất bại trên sổ làm việc đa ngôn ngữ đầu tiên.

Thoát HTML (HTML escaping) là về nhiều thứ hơn là dấu ngoặc nhọn

Việc xuất HTML tạo ra một tài liệu nhiều trang tính có các khung điều hướng (navigation frame) mang tên trang tính dưới dạng văn bản hiển thị. Những tên đó là các chuỗi do tác giả kiểm soát có thể chứa bất kỳ ký tự nào, bao gồm cả những ký tự có ý nghĩa đánh dấu. Một trang tính được đặt tên rõ ràng là Q1 & Q2 <draft> phải đến trang dưới dạng các thực thể được thoát (escaped entities), nếu không các dấu ngoặc nhọn sẽ mở một thẻ ma và dấu và (&) bắt đầu một tham chiếu thực thể không bao giờ được dự định. Đây là việc thoát HTML thông thường, và việc bỏ qua nó trên nhãn khung là loại thiếu sót có thể vượt qua mọi bài kiểm tra được xây dựng từ các tên trang tính chỉ dùng mã ASCII.

Câu hỏi mã hóa nằm dưới đó một lớp. Khi các ký tự không phải ASCII rơi vào ngữ cảnh không được đảm bảo phục vụ dưới dạng UTF-8, biểu diễn an toàn là một tham chiếu ký tự số (numeric character reference), vì vậy U+00E9 được viết là é thay vì một byte thô mà ý nghĩa của nó phụ thuộc vào bảng mã phản hồi (response charset). Hình ảnh phản chiếu của quy tắc này áp dụng theo chiều đi vào. Một sổ làm việc được đọc lại từ XLSX mang các chuỗi dùng chung (shared strings) trong đó một ký tự có thể đã được lưu trữ dưới dạng một thực thể XML số, và thực thể đó phải được giải mã thành một ký tự toàn vẹn trước khi đi vào mô hình ô. Giải mã nó một cách bất cẩn, tách một điểm mã thành các byte riêng biệt, và một ký tự duy nhất sẽ xuất hiện lại dưới dạng hai mảnh mojibake (lỗi hiển thị chữ) mà không một lượt xuất bản sau này nào có thể sửa chữa.

Container XLSX là một tệp ZIP, và ZIP có mã hóa tên riêng của nó

Một tệp XLSX là một lưu trữ ZIP, và lưu trữ lưu trữ một tên cho mỗi thành phần (member) nó giữ. Định dạng ZIP đủ cũ để thông số kỹ thuật ban đầu của nó không nói gì về việc mã hóa các tên đó, vì vậy một trình đọc không tìm thấy tín hiệu nào sẽ giả định trang mã cục bộ của tệp lưu trữ. Giả định đó là sai ngay khi tên thành phần chứa một ký tự không phải ASCII, điều này xảy ra với các tên phần trang tính được bản địa hóa và với các phương tiện nhúng có tên tệp mang dấu phụ hoặc chữ viết không phải Latin.

Giải pháp khắc phục là một bit duy nhất. Bit mục đích chung 11 (General-purpose bit 11) trong mỗi tiêu đề tệp cục bộ tuyên bố rằng tên thành phần được mã hóa dưới dạng UTF-8. HotXLS kiểm tra chính xác bit đó khi đọc một tệp lưu trữ, kiểm tra các cờ mục đích chung đối chiếu với mặt nạ $0800, và một trình đọc hoặc viết bỏ qua nó sẽ đọc sai một tên mà một triển khai chính xác đã lưu trữ dưới dạng UTF-8. Bit này rẻ để thiết lập và rẻ để tuân thủ, và nó là toàn bộ sự khác biệt giữa một tên thành phần tồn tại qua chuyến đi khứ hồi và một tên bị hỏng trước khi nội dung bảng tính được phân tích cú pháp.

Case folding và quét số ẩn chứa cùng mối nguy hại

Đánh giá công thức là nơi an toàn Unicode ngừng xoay quanh việc tuần tự hóa và bắt đầu xoay quanh việc so sánh. Hàm SEARCH không phân biệt chữ hoa chữ thường, có nghĩa là nó phải gập chữ (fold case) trước khi tìm kiếm một chuỗi con. Cách gập sai là thông qua trang mã ANSI, bởi vì việc viết hoa văn bản không phải ASCII theo cách đó sẽ định tuyến các ký tự qua một trang mã hẹp và làm hỏng bất cứ thứ gì bên ngoài nó. Cách đúng là viết hoa chuỗi rộng (wide-string uppercasing), giúp bảo toàn toàn bộ phạm vi UTF-16. HotXLS gập chữ với WideUpperCase chính xác vì lý do này, do đó việc tìm kiếm văn bản có dấu hoặc không phải Latin khớp với chính các ký tự đã được cung cấp thay vì một ước lượng bị trang mã làm sai lệch.

Trình tách mã báo hiệu công thức (formula tokenizer) mang một nghĩa vụ liên quan không liên quan gì đến các chữ cái và liên quan hoàn toàn đến nơi một mã báo hiệu (token) kết thúc. Ký hiệu khoa học như 1E3 hoặc 2.5E-3 is một hằng số số học duy nhất, và trình quét phải nhận ra chữ E, một dấu tùy chọn, và các chữ số tiếp theo là phần của số đó thay vì chia nhỏ đầu vào thành một tên theo sau bởi một số riêng biệt. Một trình quét xử lý sai điều này sẽ biến một hằng số hoàn toàn hợp lệ thành một lỗi phân tích cú pháp hoặc, tệ hơn, một biểu thức sai âm thầm. Nó thuộc về cùng một cuộc thảo luận vì cả hai trường hợp đều xoay quanh việc trình đọc đưa ra quyết định chính xác ở cấp độ ký tự: một về cách gập một ký tự để so sánh, một về việc liệu một ký tự có tiếp tục mã báo hiệu hiện tại hay không.

Xây dựng và xuất bản sổ làm việc đa ngôn ngữ

API công khai không yêu cầu bạn phải suy nghĩ về bất kỳ điều nào trong số này. Bạn xây dựng sổ làm việc từ các giá trị ô WideString và gọi điểm truy cập xuất bản bạn muốn. Các quyết định mã hóa diễn ra bên trong mỗi trình viết. Ví dụ bên dưới gieo một trang tính với văn bản bằng một số chữ viết, sau đó ghi cả tệp RTF và tệp HTML từ cùng một sổ làm việc, vì vậy hai đường dẫn chạy trên cùng một đầu vào.

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;

Cả hai cuộc gọi đều trả về trạng thái Integer, và cả hai đều tiêu thụ cùng một văn bản trong bộ nhớ. Không có gì trong mã gọi khai báo một trang mã hoặc thoát một ký tự, bởi vì trách nhiệm thuộc về trình viết vốn biết định dạng của riêng nó. Phương thức cấp sổ làm việc SaveAsCSV tuân theo cùng một hình dáng nếu bạn cần một xuất bản được phân cách (delimited export) từ cùng một nguồn.

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

An toàn Unicode là trên từng đường dẫn, không phải trên từng thư viện

Bài học đáng rút ra là không có một nơi duy nhất để an toàn với Unicode. RTF cần một trang mã được khai báo cộng với mã thoát \u. HTML cần thoát thực thể cho các ký tự có ý nghĩa đánh dấu và các tham chiếu số nơi bảng mã không được đảm bảo, cộng với giải mã chính xác các thực thể đến trong chuỗi dùng chung. Container ZIP cần thiết lập bit mục đích chung 11 để tên thành phần UTF-8 được đọc dưới dạng UTF-8. Đánh giá công thức cần gập chữ chuỗi rộng và một trình tách mã giữ cho ký hiệu khoa học nằm nguyên vẹn. Mỗi điều trong số này là một hợp đồng khác nhau, và một thư viện có thể đáp ứng một hợp đồng trong khi âm thầm vi phạm một hợp đồng khác. Đó là lý do tại sao một công cụ thực hiện đúng CSV vẫn có thể trao cho bạn một tệp RTF đầy dấu hỏi.

Nếu lượt xuất của bạn dựa trên các định dạng được phân cách, các sự cân nhắc giữa chúng được đề cập trong our walkthrough of CSV, TSV and HTML export, and when the source is a result set rather than a hand-built sheet, the patterns in database export for Delphi reports pair naturally with the encoding rules described here. All of it ships as part of the HotXLS Component for Delphi and C++Builder, alongside the reading, formula, and formatting APIs covered elsewhere on this blog.