Технічна стаття

Безпечний для Unicode експорт електронних таблиць у Delphi: RTF та HTML

Електронна таблиця містить стовпець імен клієнтів. Деякі китайською мовою, деякі кирилицею, кілька мають німецькі умляути або французький акцент. Ви експортуєте її в CSV, відкриваєте результат, і кожен символ залишається незмінним. Ви експортуєте ту саму робочу книгу у формат RTF для шаблону злиття пошти, відкриваєте її в текстовому процесорі, і імена, що не належать до ASCII, перетворюються на рядки знаків питання. Дані ніколи не змінювалися. Змінився лише контракт кодування формату, який ви записали, і кожен шлях експорту має свій власний

Це пастка, у яку потрапляє бібліотека, що на перший погляд виглядає повністю сумісною з Unicode. Текст клітинки зберігається внутрішньо як WideString, тому модель ніколи не втрачає жодного символу. Втрата відбувається на межі, у засобі запису, який повинен серіалізувати цей текст у формат із власними правилами щодо того, які байти є дозволеними і як має кодуватися будь-що за межами дозволеного діапазону. Налаштуйте один засіб запису правильно, і ви все одно можете випустити інший, який спотворює той самий текст. Рішення не полягає у глобальному перемикачі. Це окреме, правильне рішення для кожного шляху

RTF — це формат, безпечний для 7-бітного кодування за своїм задумом

Формат Rich Text Format з'явився до 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, містить спільні рядки, в яких символ уже може зберігатися як числова сутність 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';

    // 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 потрібна оголошена кодова сторінка плюс escape-послідовності \u. Для HTML потрібне екранування сутностей для символів, що мають значення для розмітки, і числові посилання, де набір символів не гарантується, а також правильне декодування сутностей, які надходять у спільних рядках. Контейнер ZIP потребує встановленого біта загального призначення 11, щоб ім'я елемента UTF-8 зчитувалося як UTF-8. Обчислення формул потребує згортання регістру широких рядків і токенізатора, який зберігає наукову нотацію єдиним цілим. Кожен із них — це окремий контракт, і бібліотека може задовольняти один, непомітно порушуючи інший. Саме з цієї причини інструмент, який правильно обробляє CSV, все одно може видати вам RTF, повний знаків питання

Якщо ваші експорти спираються на формати з роздільниками, компроміси між ними розглядаються в нашому огляді експорту CSV, TSV та HTML, а коли джерелом є набір результатів, а не створений вручну аркуш, шаблони в експорті бази даних для звітів Delphi природно поєднуються з правилами кодування, описаними тут. Усе це постачається як частина Компонента HotXLS для Delphi та C++Builder, поруч із API читання, формул і форматування, які розглядаються в інших розділах цього блогу