Техническая статья

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

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

Это ловушка, в которую попадает библиотека, которая на первый взгляд кажется полностью поддерживающей Unicode. Текст ячейки внутренне хранится как WideString, поэтому модель никогда не теряет ни одного символа. Потеря происходит на границе, в писателе (writer), который должен сериализовать этот текст в формат со своими собственными правилами о том, какие байты являются допустимыми и как должно кодироваться всё, что выходит за пределы допустимого диапазона. Сделайте один писатель правильным, и вы все равно можете выпустить другой, который исказит тот же самый текст. Исправление — это не глобальный переключатель. Это отдельное, правильное решение на каждом пути

RTF изначально является 7-битным безопасным форматом

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

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

Деталь, о которой следует помнить, заключается в том, что объявленная кодовая страница и escape-последовательности — это две половины одного контракта. Само по себе объявление кодовой страницы не помогает тексту, который находится за ее пределами. Вывод escape-последовательностей без объявленной кодовой страницы оставляет резервные символы неоднозначными. И то, и другое должно быть правильным одновременно, поэтому писатель, который обрабатывает только одно из них, все равно дает сбой на первой же многоязычной книге

Экранирование HTML касается не только угловых скобок

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

Вопрос кодировки находится на один уровень ниже этого. Когда символы, не относящиеся к ASCII, попадают в контекст, в котором не гарантируется использование UTF-8, безопасным представлением является числовая ссылка на символ, поэтому U+00E9 записывается как é, а не как необработанный байт, значение которого зависит от набора символов ответа. Зеркальное отражение этого правила применяется на входе. Книга, считанная обратно из XLSX, содержит общие строки (shared strings), в которых символ уже может храниться как числовая сущность XML, и эту сущность необходимо декодировать в один целый символ до того, как она попадет в модель ячейки. Если декодировать ее небрежно, разделяя кодовую точку на отдельные байты, один символ превратится в два куска кракозябр (mojibake), которые никакой последующий экспорт не сможет восстановить

Контейнер XLSX — это ZIP, а ZIP имеет собственную кодировку имен

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

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

Приведение регистра и сканирование чисел скрывают ту же опасность

Вычисление формул — это то место, где безопасность Unicode перестает быть вопросом сериализации и становится вопросом сравнения. Функция SEARCH не чувствительна к регистру, что означает, что она должна привести регистр перед поиском подстроки. Неправильный способ приведения — через кодовую страницу ANSI, поскольку перевод символов не-ASCII в верхний регистр таким способом пропускает их через узкую кодовую страницу и искажает всё, что находится за её пределами. Правильный способ — приведение к верхнему регистру для широких строк (wide-string), которое сохраняет полный диапазон 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';

    // Текст ячейки хранится как WideString, поэтому каждый алфавит сохраняется в модели.
    Sheet.Cells[2, 1].Value := '王伟';          // Китайский
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // Немецкий умлаут
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Кириллица
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // Французские акценты
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: писатель lxRTF объявляет кодовую страницу и выводит каждый
    // символ не-ASCII как escape-последовательность \u, сохраняя файл 7-битным чистым.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: имена листов экранируются для HTML, а текст не-ASCII записывается
    // так, чтобы он не зависел от угаданной кодировки ответа.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

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

// Та же книга, третий путь экспорта со своими собственными правилами кодировки.
Book.SaveAsCSV('Customers.csv');

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

Урок, который стоит извлечь, заключается в том, что не существует единого места, где можно обеспечить безопасность Unicode. RTF нуждается в объявленной кодовой странице плюс escape-последовательности \u. HTML нуждается в экранировании сущностей для символов, значимых для разметки, и числовых ссылках там, где набор символов не гарантирован, плюс правильное декодирование сущностей, которые прибывают в общих строках (shared strings). Контейнеру ZIP требуется установленный бит 11 общего назначения, чтобы имя элемента UTF-8 читалось как UTF-8. Вычислению формул требуется приведение регистра широких строк (wide-string case folding) и токенизатор, который сохраняет экспоненциальное представление единым целым. Каждый из них — это отдельный контракт, и библиотека может выполнять один, тихо нарушая другой. По этой причине инструмент, который правильно обрабатывает CSV, всё равно может выдать вам RTF, полный вопросительных знаков

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