從 Delphi 網格複製一個區域並貼上到 Word 時,格式通常會消失:只剩純文字,沒有粗體表頭、邊框或填充色。HotXLS 透過 TXLSRange.CopyToClipboard 弥合了這一差距,它會把 CF_HTML 剪貼簿負載——一種帶样式 HTML 且具有精確位元組片段標記的 Windows 格式——與純 Unicode 文字一起放入剪貼簿
這听起來很簡單,但真正了解 CF_HTML 負載的要求後就不是這样了。這種格式需要一個简短的文字頭,用於準確標出片段在较大剪貼簿緩衝區中的起止位置,而這些位置是位元組偏移量,要按照 HTML 最终使用的多位元組編碼計算。即使只差一個位元組,目標應用也可能截取錯誤的標記片段,或者放弃處理並退回純文字;而且這兩種失敗都不像是代碼中的錯誤,更像是 Word 的一贯表現
為什麼 Delphi 網格的複製貼上通常會遺失格式
大多數 Delphi 代碼會呼叫的預設 Windows 剪貼簿函式 SetClipboardData,配合 CF_TEXT 或 CF_UNICODETEXT,只能攜帶純字元,因此源網格中的样式沒有任何地方可以傳遞。Word、Outlook 和所有基於 Chromium 的瀏覽器在貼上時都會尋找更丰富的格式:包含內聯样式、表格結構和連結的選區 HTML 表示。Excel 本身正是依賴這一技巧——在 Excel 中複製一個區域時,剪貼簿會悄悄同時接收多種格式,其中包括 HTML,這样無论貼上到哪個應用,都能選擇自己理解的最丰富格式。只寫入 CF_UNICODETEXT 的元件無法為這些丰富格式的使用者提供任何內容,因此用户刚刚複製的視觉效果也就無從貼上
CF_HTML 剪貼簿格式究竟是什麼
CF_HTML 並不是像 CF_TEXT 那样固定的係統剪貼簿格式,而是一種動態註册的格式,透過 RegisterClipboardFormat('HTML Format') 按名稱请求,其負載由简短的 ASCII 頭和 HTML 檔案或片段組成。標頭包含五個字段——Version、StartHTML、EndHTML、StartFragment、EndFragment——其中 Version 始终是 0.9,另外四個字段则以 ASCII 數字寫成十進製數。StartHTML 和 EndHTML 限定接收應用應解析的完整檔案範圍,包括字型和样式;StartFragment 和 EndFragment 则限定實際插入光標處的较窄片段,通常還會在標記中用 <!--StartFragment--> 和 <!--EndFragment--> 註釋標記邊界,使其在簡單重新序列化後仍能保留
是位元組偏移而不是字元計數:CF_HTML 的經典陷阱
CF_HTML 的四個數字頭字段是精確剪貼簿位元組序列中的位元組偏移,從標頭的第一個字元開始計算——不是字元數,不是 Unicode 代碼點,也不是相對於片段或 <body> 標簽的偏移。這一區別正是手工編寫 CF_HTML 實作時悄悄出錯的地方:Delphi UnicodeString 的 Length 回傳 UTF-16 代碼單元數,對於純 ASCII 文字恰好等於位元組數,因此使用英文示例資料編寫的測試很容易放過這個錯誤,直到複製的儲存格中出現長破折號、货币符號或重音字元才暴露出來——欧元符號是一個 UTF-16 代碼單元,但在 UTF-8 中占三個位元組,之後計算的每個偏移都會隨着編碼增加的额外位元組數發生漂移。接下來不會發生當機,而是接收應用取得標頭指向的精確位元組範圍,却發現標記片段從標簽中間開始或結束,於是渲染出乱碼,或無聲地放弃並退回剪貼簿中相邻的純文字,代碼里沒有任何線索解釋原因——下面就是會產生這種故障的代碼形態:
// 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 單元完全使用 AnsiString 建立 CF_HTML 檔案和標頭,Delphi 的位元組字串類型使得 Length 和 Pos 在整個計算過程中都直接回傳位元組位置,不需要额外步骤,也就不存在忘記把 Unicode 字元數轉換為位元組數後再寫入標頭的风险
如果你曾經手工建立 CF_HTML 標頭,還有一個较小但值得了解的技巧。標頭會寫入兩次:第一次用四個偏移字段各自的十個零數字作為占位符,以便測量標頭自身的位元組長度;第二次再寫入經過修補的真實偏移。由於每個真實偏移都會格式化為相同的固定十位寬度,第二次生成的標頭與占位版本在位元組長度上完全相同,因此之前的測量在重寫後仍然有效。如果跳過固定寬度而直接使用 IntToStr 格式化數字,標頭可能在兩次寫入之間因位數變化而縮短或增長,從而悄悄使後續所有偏移失效:
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,也就是顯示出來的字串,因此货币儲存格複製的是 $1,234.56,而不是底層的 1234.56;而包含製表符、引號或換行符的字段會使用双引號包裹,並將內部引號加倍,采用與 CSV 相同的約定
SaveAsHTML 並不是為了剪貼簿场景临時拼接的獨立渲染路徑。CopyToClipboard 會呼叫 HotXLS 的 CSV、TSV 和 HTML 匯出 中描述的同一個 HTML 寫入器,然後將寫入器生成的內容包裹進 CF_HTML 信封,而不是儲存為獨立檔案,因此該 HTML 的所有特性都會直接延續到剪貼簿內容中。一次呼叫同時取得工作表區域的兩種格式,可以這样寫:
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 中不存在的公式
驗證貼上結果並處理剪貼簿繁忙
兩個习惯可以在客户發現問題前捕捉大多數剪貼簿故障。先貼上到記事本,確認 CF_UNICODETEXT 回退內容是正常的製表符分隔文字,再將同一份複製內容貼上到 Word 或瀏覽器,確認帶样式的版本能夠顯示;如果一個地方正常而另一個地方異常,通常意味著片段標記落在了錯誤位置。然後要認真處理 CopyToClipboard 回傳的布林結果,不要把它當成装饰:當另一個進程占用剪貼簿時,OpenClipboard 可能失敗,這在繁忙桌面上很常見;如果忽略一次呼叫,最终可能什麼也貼上不出來,却沒有錯誤說明原因,下面的重試逻辑正是為此準備:
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 Component 產品頁中