Technical Article

DelphiでのUnicodeセーフなスプレッドシートエクスポート:RTFとHTML

スプレッドシートが顧客名の列を保持しているとします。一部は中国語、一部はキリル文字、いくつかはドイツ語のウムラウトやフランス語のアクセン記号を含んでいます。これをCSVにエクスポートして結果を開くと、すべての文字が無傷です。同じワークブックをメールマージ(差し込み印刷)テンプレート用のRTFにエクスポートし、ワードプロセッサで開くと、非ASCIIの名前が疑問符(?)の列に崩壊しています。データは何も変わっていません。変わったのは、書き出したフォーマットのエンコーディング規則であり、各エクスポートパスには異なる規則が伴っています。

これは、表面上は完全にUnicode対応しているように見えるライブラリが陥る罠です。セルテキストは内部的にWideStringとして保持されているため、モデルが文字を失うことはありません。損失が発生するのは境界、つまりそのテキストを、どのバイトが合法であり、合法的な範囲外の文字をどのようにエンコードすべきかという独自のルールを持つフォーマットへとシリアル化しなければならないライター(書き込み処理)の中です。1つのライターを正しく構成できても、同じテキストを文字化けさせる別のライターを出荷してしまう可能性があります。解決策はグローバルなスイッチではありません。すべてのパスにおける個別の正しい決定です。

RTFは設計上、7ビットセーフなフォーマットである

Rich Text FormatはUnicodeよりも古く、印刷可能なASCII文字のみを通過させるトランスポート(転送経路)を生き残るように仕様が定められています。RTFドキュメントはそのヘッダーでコードページを宣言し、ライターがそのコードページで表現できない文字は、生のバイトではなくエスケープとして出力しなければなりません。関連するエスケープは\uであり、これは符号付きの16ビットコードユニットと、それに続いてエスケープを理解できない古いリーダーのためのASCII代替文字を保持します。

HotXLSはこの方法でRTFを書き込みます。ドキュメントヘッダーはまず、\ansi\ansicpg1252\uc1という形でコードページを宣言して開始します。そしてlxRTFユニット内のライターは、すべての文字列を巡回し、プレーンなASCIIを超える文字を\uエスケープとして出力するため、宣言されたコードページの内容に関係なく、バイトストリームは常に7ビットクリーンに保たれます。U+4E2Dのようなコードポイントは生のバイトではなく、リテラルシーケンスである\u20013?になります。これにより、ビューアがその時に想定した任意のコードページで生の値を解釈しようとするのを防ぎます。この規律がないと、宣言されたコードページの外にある文字は合法的なバイト表現を持たず、生のデータを出力するライターは、この記事の冒頭で述べたような疑問符(?)を生成することになります。

覚えておくべきディテールは、宣言されたコードページとエスケープは1つの取り決めの両輪であるということです。コードページを宣言するだけでは、その外側にあるテキストは救えません。宣言されたコードページなしでエスケープを出力すると、代替文字が曖昧になります。両方が共に正確である必要があり、そのため、一方しか処理しないライターは、最初の多言語ワークブックで失敗することになります。

HTMLエスケープはアングルブラケット(不等号)の処理だけではない

HTMLエクスポートは、ナビゲーションフレームが表示テキストとしてシート名を保持するマルチシートドキュメントを生成します。それらの名前は作成者が制御する文字列であり、マークアップにおいて重要な意味を持つ文字を含むことができます。文字通りQ1 & Q2 <draft>と名付けられたシートは、エスケープされたエンティティとしてページに到達しなければなりません。さもないと、アングルブラケットが幻のタグを開き、アンパサンド(&)が意図していなかったエンティティ参照を開始してしまいます。これは通常のHTMLエスケープ処理ですが、ASCIIのみのシート名で構築されたテストを通過してしまうような見落としになりやすい部分です。

エンコーディングの問題はその1つ下のレイヤーにあります。非ASCII文字がUTF-8として提供されることが保証されていないコンテキストに着地する場合、安全な表現は数値文字参照(numeric character reference)です。したがって、U+00E9は、ビューアの解釈Charsetに依存する生バイトではなく、éとして書き込まれます。このルールの鏡像が入力時に適用されます。XLSXから読み戻されたワークブックは共有文字列(shared strings)を保持し、そこで文字がすでに数値XMLエンティティとして格納されている場合があり、そのエンティティはセルモデルに入る前に1つの完全な文字にデコードされなければなりません。デコードを不注意に行い、コードポイントを個別のバイトに分割してしまうと、1つの文字が2つの文字化け(モジバケ)として再浮上し、後のどのエクスポートでも修復できなくなります。

XLSXコンテナはZIPであり、ZIPには独自の名前エンコーディングがある

XLSXファイルはZIPアーカイブであり、アーカイブはその中に保持するすべてのメンバーの名前を格納します。ZIPは古いため、元の仕様にはそれらの名前のエンコーディングに関する規定がありませんでした。そのため、シグナルが見つからない場合、リーダーはアーカイブのローカルコードページを想定します。この想定は、メンバー名に非ASCII文字(ローカライズされたワークシートパーツ名や、ファイル名にアクセントや非ラテン文字を持つ埋め込みメディアなど)が含まれている瞬間に間違ったものとなります。

解決策は1つのビットです。ローカルファイルヘッダーの汎用目的ビット11は、メンバー名がUTF-8でエンコードされていることを宣言します。HotXLSはアーカイブを読み取る際にまさにこのビットをチェックし、汎用目的フラグをマスク値$0800に対してテストします。これを無視するリーダーやライターは、正しい実装がUTF-8として格納した名前を誤って読み取ってしまいます。このビットの設定と尊重はコストが低く、アーカイブのメンバー名が正常に往復するか、あるいはスプレッドシートのコンテンツがパースされる前に破損してしまうかの決定的な違いとなります。

ケースフォールディングと数値スキャンに潜む共通の危険

数式評価は、Unicodeセーフティがシリアル化の問題から比較の問題へと移行する場所です。SEARCH関数はケースインセンシティブ(大文字小文字を区別しない)であり、これはサブ文字列を検索する前にケースフォールディング(大文字小文字の統合)を行う必要があることを意味します。間違った方法はANSIコードページを使用することです。非ASCIIテキストをその方法で大文字化すると、文字が狭いコードページを通過して破損するためです。正しい方法は、完全なUTF-16範囲を保持するワイド文字列の大文字化です。HotXLSはまさにこの理由からWideUpperCaseを使用してフォールディングを行うため、アクセント付きテキストや非ラテンテキストの検索は、コードページで mangled(文字化け)された近似値ではなく、与えられた文字と正確に一致します。

数式トークナイザーは、文字ではなく、トークンがどこで終わるかという関連する義務を負っています。1E32.5E-3のような科学的記数法は単一の数値リテラルであり、スキャナーはE、オプションの符号、およびそれに続く数字を、名前の後に個別の数値が続く形式として分割するのではなく、数値の一部として認識しなければなりません。これを誤るスキャナーは、完全に有効な定数をパースエラーにするか、あるいは静かに間違った式にしてしまいます。どちらのケースも、リーダーが文字レベルの正しい決定(1つは比較のために文字をどのように折りたたむか、もう1つは文字が現在のトークンを継続するかどうか)を行うことに関するものであるため、同じ議論に含まれます。

多言語ワークブックの構築とエクスポート

公開APIは、これらのことを意識させることはありません。WideStringのセル値からワークブックを構築し、希望するエクスポートエントリーポイントを呼び出します。エンコーディングの決定は各ライター内で行われます。以下の例では、いくつかのスクリプトのテキストをシートに配置し、同じワークブックからRTFファイルとHTMLファイルの両方を書き出すため、2つのパスが同一の入力に対して実行されます。

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にはマークアップに重要な文字のエンティティエスケープと、Charsetが保証されない場所での数値参照、および共有文字列に到達するエンティティの正しいデコードが必要です。ZIPコンテナには、UTF-8メンバー名がUTF-8として読み取られるように汎用目的ビット11を設定する必要があります。数式評価には、ワイド文字列のケースフォールディングと、科学的記数法を1つに保つトークナイザーが必要です。これらはそれぞれ異なる取り決めであり、ライブラリは一方を満たしつつ他方に違反する可能性があります。これが、CSVを正しく処理するツールが疑問符だらけのRTFを渡してくる原因なのです。

エクスポートが区切り文字形式に依存している場合、それらのトレードオフについては、CSV、TSV、およびHTMLエクスポートのチュートリアルでカバーしています。また、ソースが手動で作成されたシートではなく結果セットである場合、Delphiレポートのデータベースエクスポートのパターンが、ここで説明したエンコーディングルールと自然に組み合わされます。これらすべてが、読み込み、数式、およびフォーマットAPIと並んで、DelphiおよびC++Builder向けのHotXLS Componentの一部として提供されています。