从 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 产品页中