技術記事

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

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

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

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

リッチテキストフォーマット(RTF)はUnicodeより古く、印字可能なASCIIのみを通過させるトランスポートを生き延びるように仕様化されました。RTFドキュメントはヘッダーでコードページを宣言し、ライターがそのコードページで表現できない文字は、生のバイトではなくエスケープとして出力されなければなりません。関連するエスケープは \u であり、符号付きの16ビットコード単位の後に、エスケープをまったく理解できないほど古いリーダー向けのASCIIフォールバック文字が続きます

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

覚えておくべき詳細は、宣言されたコードページとエスケープが1つの契約の2つの半分であるということです。コードページを宣言するだけでは、その外側にあるテキストの助けにはなりません。宣言されたコードページなしでエスケープを出力すると、フォールバック文字が曖昧なままになります。両方が一緒に正しくなければならず、それが一方だけを処理するライターが最初の多言語ワークブックで依然として失敗する理由です

HTMLエスケープは山括弧以上の意味を持ちます

HTMLエクスポートでは、ナビゲーションフレームにシート名を可視テキストとして保持するマルチシートドキュメントが生成されます。それらの名前は、マークアップで重要な文字を含む任意の文字を含めることができる、作成者が制御する文字列です。文字通り Q1 & Q2 <draft> という名前のシートは、エスケープされたエンティティとしてページに到達しなければなりません。そうしないと、山括弧がファントムタグを開き、アンパサンドが意図されていなかったエンティティ参照を開始します。これは通常のHTMLエスケープであり、フレームラベルでそれをスキップすることは、ASCIIのみのシート名で構築されたすべてのテストを通過するような類の省略です

エンコーディングの問題は、その1層下にあります。非ASCII文字がUTF-8として提供されることが保証されていないコンテキストに着地した場合、安全な表現は数値文字参照になります。したがって、U+00E9は応答文字セットに依存する意味を持つ生のバイトとしてではなく、é として書き込まれます。このルールの鏡像は、入力方法に適用されます。XLSXから読み戻されたワークブックは共有文字列を保持しており、その中で文字はすでに数値XMLエンティティとして保存されている可能性があり、そのエンティティはセルモデルに入る前に1つの完全な文字にデコードされる必要があります。コードポイントを別々のバイトに分割して不用意にデコードすると、1つの文字が2つの文字化けした断片として再出現し、後続のエクスポートでは修復できなくなります

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

XLSXファイルはZIPアーカイブであり、アーカイブは保持するすべてのメンバーの名前を保存します。ZIPは古いため、当初の仕様ではそれらの名前のエンコーディングについて何も指定されていませんでした。そのため、シグナルを見つけられないリーダーはアーカイブのローカルコードページを想定します。メンバー名に非ASCII文字が含まれた瞬間に、その想定は間違ったものになります。これは、ローカライズされたワークシートのパーツ名や、ファイル名にアクセントや非ラテン文字スクリプトが含まれる埋め込みメディアで発生します

修正は単一のビットです。各ローカルファイルヘッダーの汎用ビット11は、メンバー名がUTF-8としてエンコードされていることを宣言します。HotXLSはアーカイブを読み取るときにまさにそのビットをチェックし、汎用フラグをマスク $0800 に対してテストします。これを無視するリーダーまたはライターは、正しい実装がUTF-8として保存した名前を誤読します。このビットは設定するのも尊重するのも低コストであり、ラウンドトリップを生き延びるメンバー名と、スプレッドシートのコンテンツが解析される前に破損して到着するメンバー名との完全な違いになります

ケースの折りたたみと数値スキャンには同じ危険が潜んでいる

数式の評価は、Unicodeの安全性がシリアライズについてではなくなり、比較についてになる場所です。SEARCH 関数は大文字と小文字を区別しないため、部分文字列を探す前にケース(大文字・小文字)を折りたたむ必要があります。ANSIコードページを介して折りたたむのは間違った方法です。なぜなら、その方法で非ASCIIテキストを大文字にすると、狭いコードページを通過することになり、その範囲外のものが破損するからです。正しい方法は、UTF-16の全範囲を保持するワイド文字列の大文字化です。HotXLSはまさにこの理由で WideUpperCase を使用して折りたたむため、アクセント付きのテキストや非ラテン語のテキストを検索すると、コードページで破損した近似値ではなく、与えられた同じ文字に一致します

数式トークナイザーには、文字とは関係がなく、トークンがどこで終わるかにすべて関係する関連する義務があります。1E32.5E-3 のような科学的記数法は単一の数値リテラルであり、スキャナーは E、オプションの符号、およびそれに続く数字を、入力を名前とその後の個別の数字に分割するのではなく、数値の一部として認識する必要があります。これを誤って処理するスキャナーは、完全に有効な定数を解析エラー、またはさらに悪いことに静かに間違った式に変えてしまいます。これは同じ議論に属します。なぜなら、両方のケースはリーダーが正しい文字レベルの決定を下すことに関するものだからです。一方は比較のために文字をどのように折りたたむかについてであり、もう一方は文字が現在のトークンを継続するかどうかについてです

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

パブリック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';

    // セルのテキストはWideStringとして保持されるため、すべてのスクリプトがモデルを生き延びます。
    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:lxRTFライターはコードページを宣言し、すべての
    // 非ASCII文字を \u エスケープとして出力し、ファイルを7ビットクリーンに保ちます。
    Book.SaveAsRTF('Customers.rtf');

    // HTML:シート名はHTMLエスケープされ、非ASCIIテキストは書き込まれるため
    // 推測された応答文字セットに依存しません。
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

どちらの呼び出しも Integer ステータスを返し、どちらも同じインメモリテキストを消費します。呼び出し側のコードでは、コードページを宣言したり文字をエスケープしたりするものは何もありません。その責任は独自のフォーマットを知っているライターにあるからです。同一のソースから区切り文字付きのエクスポートが必要な場合は、ワークブックレベルの SaveAsCSV が同じ形に従います

// 同じワークブック、独自のエンコーディングルールを持つ3番目のエクスポートパス。
Book.SaveAsCSV('Customers.csv');

Unicodeの安全性はライブラリ単位ではなくパス単位です

持ち帰る価値のある教訓は、Unicodeセーフになるための単一の場所は存在しないということです。RTFには宣言されたコードページと \u エスケープが必要です。HTMLでは、マークアップに重要な文字のエンティティエスケープと、文字セットが保証されていない場所での数値参照、さらに共有文字列で到着するエンティティの正しいデコードが必要です。ZIPコンテナでは、UTF-8のメンバー名がUTF-8として読み取られるように、汎用ビット11を設定する必要があります。数式の評価には、ワイド文字列のケースの折りたたみと、科学的記数法を1つにまとめておくトークナイザーが必要です。これらはそれぞれ異なる契約であり、ライブラリは一方を満たしながら、もう一方を静かに違反する可能性があります。これが、CSVを正しく処理するツールでも、疑問符だらけのRTFを渡す可能性がある理由です

エクスポートが区切り文字付きフォーマットに依存している場合は、それらの間のトレードオフについてCSV、TSV、HTMLエクスポートのウォークスルーで解説しています。また、ソースが手作業で構築されたシートではなく結果セットである場合、Delphiレポートのデータベースエクスポートのパターンは、ここで説明したエンコーディングルールと自然に組み合わされます。そのすべては、このブログの他の場所で説明している読み取り、数式、およびフォーマットのAPIとともに、DelphiおよびC++Builder用のHotXLSコンポーネントの一部として出荷されています