Delphiのグリッドから範囲をコピーしてWordに貼り付けると、書式は通常消えてしまう:プレーンテキストだけで、太字の見出しも罫線も塗りつぶしもない。HotXLSはTXLSRange.CopyToClipboardでこのギャップを埋める。これはCF_HTMLクリップボードペイロード——バイト単位で正確なフラグメントマーカーを持つスタイル付きHTML用のWindows形式——を、プレーンなUnicodeテキストの隣にクリップボードへ配置する
これは、CF_HTMLペイロードが実際に何を要求するかを見るまでは単純に聞こえる。この形式は、より大きなクリップボードバッファ内でフラグメントがどこで始まりどこで終わるかを正確に名指しする短いテキストヘッダーを必要とし、その位置はバイトオフセットであり、HTMLが最終的にどんなマルチバイトエンコーディングになろうともそれを通してカウントされる。その計算をたった1バイトでも間違えると、ターゲットアプリケーションは間違ったマークアップの断片をつかむか、あきらめてプレーンテキストにフォールバックするかのどちらかになり、どちらの失敗もあなたのコードのバグには見えない——それはWordがWordであるように見えるだけだ
なぜDelphiのグリッドからのコピー&ペーストは通常書式を失うのか
ほとんどのDelphiコードが手を伸ばす既定のWindowsクリップボード呼び出し、CF_TEXTやCF_UNICODETEXTを伴うSetClipboardDataは、プレーンな文字しか運ばない。そのためソースのグリッドに適用されたどんなスタイルも行き場を失う。Word、Outlook、そしてすべてのChromiumベースのブラウザは、貼り付け時により豊かな形式を探す:インラインスタイル、テーブル構造、リンクを完備した選択範囲のHTML表現である。Excel自体もまさにこのトリックに頼っている——Excelで範囲をコピーすると、クリップボードは静かに複数の形式を同時に受け取り、その中にはHTMLも含まれる。そのため、あなたが何に貼り付けようとも、そのアプリケーションが理解できる最も豊かな形式を選ぶ。CF_UNICODETEXTしか書かないコンポーネントは、それらの豊かな消費者すべてに何も渡すものがなく、ユーザーがたった今コピーした視覚的な豊かさは貼り付けるべきものとして単純に存在しない
CF_HTMLクリップボード形式とは正確には何か
CF_HTMLはCF_TEXTのような固定のシステムクリップボード形式ではない。それは動的に登録される形式であり、RegisterClipboardFormat('HTML Format')を通じて名前で要求される。そのペイロードは短いASCIIヘッダーに続いてHTML文書またはフラグメントが来る。ヘッダーは5つのフィールド——Version、StartHTML、EndHTML、StartFragment、EndFragment——を運び、Versionは常に0.9で、他の4つはASCII数字として書き出された10進数である。StartHTMLとEndHTMLは、受け取るアプリケーションがフォントやスタイルを含めて文脈として解析すべき文書全体を境界付け、一方StartFragmentとEndFragmentは実際にカーソル位置に着地するより狭い断片を境界付ける。これは慣例的にマークアップ自体の中で<!--StartFragment-->と<!--EndFragment-->のコメントで印付けられ、素朴な再シリアライズを経ても境界が生き残るようになっている
文字数ではなくバイトオフセット:古典的なCF_HTMLの罠
CF_HTMLの4つの数値ヘッダーフィールドは、クリップボード上に座っている正確なバイト列へのバイトオフセットであり、ヘッダー自体の最初の文字からカウントされる——文字数でもUnicodeコードポイントでもなく、フラグメントや<body>タグからの相対オフセットでもない。この区別こそが、自前で実装したCF_HTMLが静かに間違ってしまうところである:DelphiのUnicodeStringのLengthはUTF-16コード単位を報告し、それはたまたま平易なASCIIテキストではバイト数と等しくなる。そのためこのバグは英語のサンプルデータで書かれたどんなテストもすり抜け、コピーされたセルがエムダッシュ、通貨記号、あるいはアクセント付き文字を保持した瞬間にだけ現れる——ユーロ記号は1つのUTF-16コード単位だがUTF-8では3バイトであり、その点以降に計算されるすべてのオフセットは、そのエンコーディングが追加した余分なバイト数だけずれていく。それに続く失敗はクラッシュではない:受け取るアプリケーションがヘッダーが指し示した正確なバイト範囲をつかみ、タグの途中で始まるか終わるマークアップの断片を見つけ、ガラクタをレンダリングするか、あきらめてクリップボード上に隣り合っているプレーンテキストに静かにフォールバックする。あなたのコードにはなぜかを説明するものが何もない——以下はまさにその失敗を生み出すコードの形である
// Fragile: Length() on a UnicodeString counts UTF-16 code units, not bytes
var
Header: string;
Fragment: string;
StartFragmentOfs: Integer;
begin
Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
// A currency symbol, an em dash, or any accented character placed
// before this point costs one character here but two or three bytes
// once the document is UTF-8 encoded, so StartFragmentOfs now points
// short of where the fragment actually begins on the real clipboard
end;
HotXLSはヘッダーをどうやってバイト単位で正確に保つのか
HotXLSはこの種のバグを構造的に回避する:TXLSRange.CopyToClipboardとその下にあるlxClipboardユニットは、CF_HTML文書とそのヘッダーをすべて、Delphiのバイト文字列型であるAnsiStringとして構築する。そのためLengthとPosは計算のどこでもすでにバイト位置を返している——Unicode文字数をヘッダーに入る前にバイト数へ変換する必要のある別個のステップは存在せず、したがって忘れうるステップも存在しない
自分でCF_HTMLヘッダーを組み立てることが今後あるなら知っておく価値のある、もう一つの小さなトリックがある。ヘッダーは2回書かれる:一度は4つのオフセットそれぞれの代わりに10桁のゼロを立てて、それ自身のバイト長を測定できるようにするためであり、もう一度は実際のオフセットを埋め込むためである。すべての実際のオフセットが同じ固定10桁幅にフォーマットされるため、2つ目のヘッダーはプレースホルダー版と正確に同じバイト長になって出てくる。これがまさに、書き換え後も先の測定値が有効であり続ける理由である。この固定幅を省略し、代わりに単純なIntToStrで数値をフォーマットすると、ヘッダーは2つのパスの間で1桁分縮んだり伸びたりすることがあり、それ以降のすべてのオフセットを静かに無効にしてしまう
const
Placeholder = '0000000000'; // 10 ASCII digits: fixed width in, fixed width out
var
Header: AnsiString; // AnsiString.Length is a byte count, not a char count
StartHtmlOfs: Integer;
begin
Header := 'Version:0.9'#13#10 +
'StartHTML:' + Placeholder + #13#10 +
'EndHTML:' + Placeholder + #13#10 +
'StartFragment:' + Placeholder + #13#10 +
'EndFragment:' + Placeholder + #13#10;
StartHtmlOfs := Length(Header); // safe to measure once, up front
// ...compute the real offsets against the AnsiString document...
// then rebuild Header with the real numbers formatted to the same
// 10-digit width, so its byte length -- and therefore StartHtmlOfs --
// never moves between the placeholder pass and the final one
end;
なぜプレーンテキストのペイロードも依然として一緒に運ばなければならないのか
TXLSRange.CopyToClipboardは決してCF_HTMLだけをクリップボードに置くことはない。それは常に同じ呼び出しの中でCF_UNICODETEXTも書き込む。なぜならCF_HTMLは、あらゆるWindowsアプリケーションがすでに探すことを知っている固定のCF_*定数の一つではなく、登録された形式だからだ——プレーンテキストエディタ、レガシーなグリッド、あるいは'HTML Format'を一度もチェックしたことのない何かは、それを全く見ることができず、コピーした範囲はタブ区切りテキストとして到着するか、全く到着しないかのどちらかになる。そのタブ区切りテキストも大まかな近似ではない:数式セルは、保存されたテキストが先頭の=を落としていた場合それを復元した数式文字列としてコピーされ、これはExcel自身のクリップボードテキストの振る舞いと一致する。通常のセルはそのFormattedText——表示される通りの文字列——をコピーする。そのため通貨のセルは1234.56という基礎データではなく$1,234.56としてコピーされ、タブ、引用符、改行を含むフィールドは、埋め込まれた引用符を二重化して引用される。これはCSVが使うのと同じ慣習である
SaveAsHTMLはクリップボードのケースのためだけに取り付けられた別個のレンダリング経路ではない。CopyToClipboardはHotXLSのCSV、TSV、HTMLエクスポートで説明されているのと全く同じHTMLライターを呼び出し、そのライターが生成するものが何であれ、それをスタンドアロンのファイルとして保存する代わりにCF_HTMLのエンベロープでラップする。そのためそのHTMLについて真であることはすべて、クリップボードに着地するものへそのまま引き継がれる。ワークシートの範囲を1回の呼び出しで両方の形式としてまとめる様子は次のようになる
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('quarterly-report.xlsx');
// Classic TXLSWorkbook ranges expose the identical method as
// Workbook.Sheets[1].Range['A1', 'F40'].CopyToClipboard
if Book.Sheets[1].Range['A1:F40'].CopyToClipboard then
ShowMessage('Range copied - press Ctrl+V in Word or a browser')
else
ShowMessage('Clipboard was busy; see the retry pattern below');
finally
Book.Free;
end;
end;
貼り付けられた範囲はフォント・色・結合セルを保つのか
保つ、なぜならペイロードのHTML側はその範囲の完全なレンダリングであり、単なるデータダンプではないからだ:フォント、塗りつぶし色、罫線、数値書式、結合セルはすべてインラインスタイルとテーブル構造として引き継がれる。これはHotXLSの条件付き書式とリッチテキストのガイドで扱われているのと同じスタイリングの仕組みである。なぜならセルのリッチテキストのランと条件付き書式の結果はどちらもCopyToClipboardが読むのと同じレンダリングを供給するからだ。この移動を生き延びないものはライブな数式の挙動である:数式セルのプレーンテキスト形式は数式文字列を運ぶため、スプレッドシートを認識する貼り付け先は原理的にそれを再計算できるが、HTML形式は最後に計算された結果しか運ばない。なぜならHTMLには、ブラウザやワードプロセッサが評価すべき数式という概念が存在しないからだ
貼り付けを検証する、そしてビジー状態のクリップボードを扱う
顧客より先にほとんどのクリップボードの問題を捕まえる2つの習慣がある。まずNotepadに貼り付けてCF_UNICODETEXTのフォールバックがまともなタブ区切りテキストであることを確認し、次に同じコピーをWordやブラウザに貼り付けてスタイル付きのバージョンが現れることを確認する——片方では正しく見えてもう片方では間違って見えるペイロードは、通常フラグメントマーカーが間違った場所に着地したことを意味する。次に、CopyToClipboardが返すブール型の結果を、飾りではなく意味のあるものとして扱うこと:OpenClipboardは別のプロセスがクリップボードを開いたまま保持しているときに失敗することがあり、これは忙しいデスクトップでは十分よくあることで、1回のチェックされない呼び出しが最終的に何も貼り付けず、なぜかを説明するエラーもない結果になる。これが以下のリトライが防ぐものである
function TryCopyRangeToClipboard(Workbook: TXLSXWorkbook): Boolean;
var
Attempt: Integer;
begin
Result := False;
for Attempt := 1 to 5 do
begin
Result := Workbook.Sheets[1].Range['A1:F40'].CopyToClipboard;
if Result then
Break;
Sleep(50); // give whichever app is holding the clipboard a moment
end;
if not Result then
raise Exception.Create('Could not take ownership of the clipboard');
end;
この形式自体は、ヘッダーがバイト単位で正確でプレーンテキストのフォールバックがその内容について正直である限り、それほど風変わりなものではない——それはInternet Explorerが最初にそれを定義して以来ほとんど変わらずに存在してきており、あらゆる主要なWindowsアプリケーションが今も同じ方法でそれを読む。CopyToClipboardは、同じ交換の読み取り側であるPasteFromClipboardと並んで、HotXLSコンポーネントの製品ページで文書化されているより広いクリップボード・エクスポート面に収まっている