Technical Article

Безопасный экспорт таблиц в формате Unicode в Delphi: RTF и HTML

В электронной таблице есть столбец с именами клиентов. Некоторые из них написаны на китайском языке, некоторые на кириллице, другие содержат немецкие умлауты или французские диакритические знаки. Вы экспортируете данные в формат CSV, открываете файл и видите, что все символы отображаются корректно. Затем вы экспортируете ту же книгу в формат RTF для шаблона рассылки писем, открываете ее в текстовом процессоре и обнаруживаете, что все имена с символами не-ASCII превратились в цепочки знаков вопроса. Сами данные не изменились. Изменились правила работы с кодировками в выбранном формате экспорта, поскольку каждый путь сохранения имеет свою специфику.

Это ловушка, в которую часто попадают библиотеки, позиционирующие себя как полностью поддерживающие Unicode. Внутреннее представление текста ячеек в виде WideString гарантирует сохранность данных внутри модели. Потеря символов происходит на границе выгрузки, в модуле записи, который сериализует текст в формат со своими правилами допустимых байтов и представления символов вне стандартного диапазона. Корректная реализация одного модуля записи не гарантирует отсутствие ошибок в другом. Проблему невозможно решить одним глобальным переключателем, здесь требуется точная настройка каждого пути экспорта.

RTF как безопасный 7-битный формат по своей структуре

Формат Rich Text Format (RTF) появился задолго до разработки Unicode и создавался с расчетом на передачу по каналам связи, поддерживающим только 7-битный ASCII. Документ RTF объявляет кодовую страницу в своем заголовке, и любой символ, который модуль записи не может отобразить в этой кодировке, должен записываться в виде управляющей последовательности (escape-последовательности), а не в виде обычных байтов. Для этого используется последовательность \u, которая содержит знаковое 16-битное значение кода символа и резервный символ ASCII на случай работы со старыми текстовыми процессорами, не поддерживающими Unicode.

HotXLS записывает файлы RTF именно так. Заголовок документа объявляет кодовую страницу в виде \ansi\ansicpg1252\uc1, а модуль записи в модуле lxRTF обрабатывает все строки, выводя любые символы за пределами ASCII в виде управляющих последовательностей \u. Это позволяет сохранить 7-битную чистоту потока байтов независимо от ограничений объявленной кодовой страницы. Кодовая точка вроде U+4E2D преобразуется в литеральную последовательность \u20013?, а не в сырые байты, которые программа просмотра пыталась бы интерпретировать через случайную кодовую страницу. Без этого строгого правила любые символы вне кодовой страницы не имеют корректного представления, а попытка записать их напрямую приводит к появлению знаков вопроса.

Важно помнить, что объявление кодовой страницы и управляющие последовательности представляют собой две взаимосвязанные части одного механизма. Простое объявление кодовой страницы не защищает символы за ее пределами. Вывод последовательностей без указания кодовой страницы делает резервные символы неопределенными. Оба этих элемента должны быть настроены корректно, иначе модуль записи не сможет корректно обработать многоязычные данные книги.

Экранирование HTML: больше чем просто угловые скобки

Экспорт в формат HTML создает многостраничный документ, в навигационных фреймах которого выводятся имена листов в виде видимого текста. Эти имена задаются пользователем и могут содержать любые спецсимволы разметки. Лист с именем Q1 & Q2 <draft> должен записываться на страницу в виде экранированных сущностей, иначе угловые скобки создадут ложный тег, а амперсанд начнет некорректную ссылку на сущность. Это стандартное экранирование HTML, отсутствие которого часто упускают при проверках с использованием только латинских имен листов.

Вопросы кодирования лежат уровнем ниже. Если символы не-ASCII выводятся в контексте, где нет гарантии использования кодировки UTF-8, наиболее безопасным решением является использование числовых ссылок на символы: например, код U+00E9 записывается как &#233;, а не как сырой байт, значение которого зависит от кодовой страницы ридера. Обратное правило действует при чтении данных. Книга, загружаемая обратно из формата XLSX, содержит общие строки, в которых символы могут быть сохранены в виде числовых сущностей XML. Эти сущности должны быть декодированы в единые символы перед занесением в ячейки. Ошибки при декодировании с разделением кода символа на отдельные байты приводят к появлению кракозябр (mojibake), которые невозможно исправить при последующем экспорте.

Контейнер XLSX как архив ZIP и кодирование имен файлов

Файл XLSX представляет собой архив ZIP, в котором хранятся имена всех входящих в него файлов. Формат ZIP довольно стар, и его первоначальная спецификация не определяла правила кодирования имен файлов. При отсутствии специальных флагов ридер считывает имена в локальной кодовой странице. Это предположение оказывается неверным, как только имя файла внутри архива содержит символы не-ASCII (например, при локализованных именах частей листов или наличии встроенных медиафайлов с диакритикой или нелатинскими символами).

Решение этой проблемы заключается в использовании одного флага. 11-й бит в заголовке каждого локального файла архива указывает на то, что имя файла закодировано в формате UTF-8. HotXLS проверяет этот бит при чтении архивов, сопоставляя флаги с маской $0800. Любые ридеры или писатели, игнорирующие этот флаг, некорректно прочитают имя файла, сохраненное в кодировке UTF-8. Использование этого бита довольно просто реализовать, и именно от него зависит, сохранятся ли имена файлов архива после перезаписи документа.

Сравнение строк без учета регистра и парсинг чисел

На этапе вычисления формул безопасность работы с Unicode переходит из плоскости сериализации в плоскость сравнения значений. Функция SEARCH не чувствительна к регистру, поэтому она должна приводить символы к одному регистру перед поиском подстроки. Неверный подход заключается в использовании кодовых страниц ANSI для приведения регистра, так как перевод символов не-ASCII в верхний регистр через узкую кодовую страницу приводит к порче данных. Правильный подход основан на переводе регистра широких строк (wide-string uppercasing), сохраняющем весь диапазон UTF-16. HotXLS использует функцию WideUpperCase именно по этой причине, гарантируя точное совпадение нелатинских символов при поиске.

Токенизатор формул имеет схожие требования, связанные не с буквами, а с определением границ токенов. Экспоненциальная запись чисел (таких как 1E3 или 2.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;

Оба вызова возвращают целочисленный статус выполнения и обрабатывают один и тот же текст в памяти. Вызывающий код не объявляет кодовые страницы и не выполняет экранирование, так как вся ответственность лежит на модуле записи конкретного формата. Метод уровня книги SaveAsCSV используется по аналогичной схеме при необходимости экспорта в текстовый файл с разделителями.

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

Безопасность Unicode определяется путями экспорта, а не библиотекой в целом

Главный вывод состоит в том, что безопасность Unicode невозможно обеспечить в каком-то одном месте. Для экспорта в RTF требуется объявление кодовой страницы и использование управляющих последовательностей \u. Для HTML требуется экранирование спецсимволов разметки и создание числовых ссылок на символы при отсутствии гарантии использования UTF-8, а также корректное декодирование сущностей из общих строк. В контейнере ZIP необходимо выставлять 11-й бит, чтобы имена файлов читались в UTF-8. При вычислении формул требуется перевод регистра широких строк и токенизатор, не разделяющий экспоненциальную запись чисел. Каждая из этих задач представляет собой отдельное требование, и библиотека может отлично справляться с одной, допуская скрытые сбои в другой. Именно поэтому компонент, корректно генерирующий CSV, может выгружать файлы RTF со знаками вопроса.

Если ваши задачи экспорта ориентированы на текстовые форматы с разделителями, сравнение их возможностей представлено в нашем обзоре экспорта в CSV, TSV и HTML. При получении данных напрямую из базы данных шаблоны выгрузки описаны в статье об экспорте баз данных для отчетов в Delphi. Все эти функции входят в состав библиотеки HotXLS Component для Delphi и C++Builder наряду с API для чтения данных, расчета формул и форматирования таблиц.