技术文章

HotXLS:在 Delphi 中处理 Excel 批注与超链接

在一个生成的工作簿里把工作表从"Summary"改名为"Overview",每个指向 Summary!A1 的内部超链接就哪儿也去不了了。保存时不报异常,打开时也不报。链接仍然渲染,仍然看起来可点击,却悄悄解析到虚无。同样的破损在另存为转换或 .xls/.xlsx 往返之后也会出现,那时一条批注会偏出一列,或一个相对链接丢了目标。这两项功能都携带评审者会据此行动的评审状态,所以当它们出问题时,失败是隐形的,直到评审者点下去却什么也没发生

这就是批注和超链接值得比它们表面外观所暗示的更多关切的现实理由。HotXLS 让 Delphi 和 C++Builder 代码能在 XLS 和 XLSX 中直接写入两者,且循环中没有 Excel 自动化。这种控制力的另一面是责任:库精确地写入你交给它的目标,一个也不校验,所以保持评审工作流完好是你代码的活,而不是 Excel 的

作为机器写入评审记录的单元格批注

在 XLSX 类模型中,一条批注是工作表级对象:它知道自己的行、列、作者和正文。作者字段有它存在的理由。当你代码生成的工作簿经过一条评审链流转时,审计者问的第一个问题是谁写了某条备注,而一条没留作者的备注用一个空白回答了这个问题。用服务身份给生成的批注盖章,这样出处永远不会有歧义

Delphi HotXLS 注释重试图:FindAt 探测后更新既有单元格批注,而盲目重试 AddComment 会堆叠出重复批注
盲目重试 AddComment 会在同一单元格上再叠一条批注,FindAt 探测则编辑已有的那条
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Note: TXLSXComment;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('reconciliation.xlsx');
    Sheet := Book.Sheets[0];

    // 在调整后的数字上签署一条带作者的备注
    Sheet.AddComment(14, 4, 'Manual adjustment: late FX rate, see ticket FIN-2214',
      'recon-service');

    // 更新现有备注,而不是再堆一条
    Note := Sheet.Comments.FindAt(14, 4);
    if Note <> nil then
      Note.Text := Note.Text + ' [verified 2026-06-11]';

    Book.SaveAs('reconciliation-reviewed.xlsx');
  finally
    Book.Free;
  end;
end;

FindAt 探测承担的分量比看起来更重。一个在瞬时故障后重试的批处理任务会乐意在它已标注过的单元格上第二次调用 AddComment,于是该单元格最终堆了两条没人要的备注。先用 FindAt 探测,再更新它返回的对象。Comments 集合还暴露 DeleteAtDeleteInRange。在你让工作簿出门之前做净化时,那个范围变体正是该伸手去用的:把整个区域的内部 QA 批注清掉是一次调用,而不是手写一个遍历单元格的循环

外部 URL 与工作簿内跳转是不同的 API

OOXML 把两种链接放在不同的地方。一个外部 URL 变成工作表 .rels 部件中的一条关系条目,单元格通过 id 指向该关系。一个内部跳转从不触碰关系层,它只是一段普通的位置字符串(比如 Summary!A1)直接存在链接上。HotXLS 在 API 中保持这种区分可见,而不是用单个方法重载,这意味着你要通过知道目标住在哪里来选择正确的调用:

示意图:对比 HotXLS 在 Delphi 生成的工作簿中把外部 URL 存为 rels 部件中的关系,把内部跳转存为普通位置字符串
外部 URL 经过关系层,内部跳转只是纯文本,两者各有各的失败方式,也各需各的审计规则
Sheet.Cells[2, 1].Value := 'Source record';
Sheet.AddHyperlink(2, 1, 'https://intranet.example.com/records/2214',
  'Open record 2214', 'ERP source entry');

Sheet.Cells[3, 1].Value := 'Totals';
Sheet.AddHyperlinkToCell(3, 1, 'Overview!B12', 'Jump to totals');

在生成的 TXLSXHyperlink 对象上,UrlLocation 是互斥的,而 IsInternal 告诉你两者中哪个被填充了。这个标志正是你在清点已打开工作簿中的链接、需要按不同规则对待"离开文件"和"留在文件"时该检查的:一个外部主机可能要面对一个允许列表,而一个内部目标只需命名一个存在的工作表。内部链接在背后不携带任何关系部件,这也使它们更便宜地批量重写

开篇那种破损完全发生在内部侧,而且它源于一个事实:位置字符串不是已解析的引用。HotXLS 写入你交给它的精确文本,而当一个工作表后来被改名时,没有任何东西重新指向那段文本。两种防御在实践中站得住脚。第一种是关于顺序的纪律:在生成任何一条链接之前先改好每个工作表的名字,然后把工作表名当作冻结的标识符。第二种更结实,也能挺过事后才做的改名。把链接指向一个工作簿级的定义名,而不是原始的 Sheet!Cell 地址,因为 Excel 在底层工作表改变时会重写一个名字的定义,所以链接自动随之而行。这第二种做法与HotXLS 中的定义名与跨表公式中的技术天然搭配

XLS 侧:相同的概念,更老的管道

BIFF8 门面把批注挂在区域上,而不是一个工作表级集合上。你在一个 IXLSRange 上调用 AddComment 并拿回一个 TXLSComment;该区域的 Comment 属性读取一条已有备注,而 ClearComments 把它们清掉。这里的锋利边缘是位置性的。一个 TXLSComment 不公开暴露自己的行和列,所以那个自然的循环——"遍历每条批注并报告它坐在哪里"——反向地撞上了 API。你得从单元格出发。要么从你标注过的地址列表驱动审计,要么在写入时保留你自己的位置日志,因为批注对象事后不会告诉你它住在哪里

var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
  Remark: TXLSComment;
begin
  Book := TXLSWorkbook.Create;
  Sheet := Book.Sheets.Add;
  Sheet.Name := 'Review';
  Sheet.Cells.Item[5, 2].Value := 4821.50;

  Remark := Sheet.Cells.Item[5, 2].AddComment('Awaiting sign-off from controller');
  Remark.Visible := True;   // 首次查看时弹出该备注

  Sheet.AddHyperlink(7, 2, 'https://intranet.example.com/signoff/4821',
    'Sign-off form', 'Opens the controller queue');
  Book.SaveAs('review.xls');
end;

Visible 设为 True 是让一条备注无法被忽视的遗留方式:那个黄框在工作表上保持打开,而不是等待悬停。TXLSComment 比它的 XLSX 对应物更进一步,暴露了 TextRuns,所以一条备注可以在一段普通说明旁边携带一条粗体警告——这是 XLSX 批注 API 未能以同样方式暴露的格式化能力。这一侧的超链接通过三个递进的重载到达(仅地址、带显示文本、带屏幕提示),并通过工作表的 HyperLinks 集合读回,其中每条链接暴露 AddressSubAddressDisplayTextScreenTip

一张评审索引页胜过散落的备注

过了大约十几条标注之后,悬停阅读就悄悄地不再可扩展。备注堆在评审者从不打开的工作表上,而最重要的那些恰恰是最容易被错过的。最经得起考验的结构是一个生成的索引页:每个被标注位置一行,列出它的工作表名、单元格地址、作者和备注的简短摘录。最后一列携带一条用 AddHyperlinkToCell 构建的内部超链接,直接跳到被标注的单元格。现在评审者顺着一张列表读,而不是在一个网格上搜寻,而那张索引页的行数还兼任下方审计这一遍的批注清单

索引页构建起来很便宜,因为你的生成器已经知道它触碰过的每个位置。在写每条批注时把一个(工作表、行、列、作者、摘要)元组追加到一个列表里,最后再生成索引页,这样在保存之前它的行数就是最终的。两处改进有回报:按严重程度或按工作表而不是按插入顺序排列索引,并在索引页页眉放一条返回链接,让评审者在每条之后能弹回顶部。因为内部链接只是普通的位置字符串,背后在关系层中什么也没有,所以即便是一千行的索引页,对文件大小或保存时间也几乎不增加任何东西

同一张页在回程时还会再次回报。当评审过的工作簿回来时,你的代码读取键入到索引行旁边单元格中的状态值,而不是重新扫描每张工作表寻找可能已变化的批注。一列结构化的状态单元格能干净地解析;一堆散落的自由文本备注则不能

一道能真正捕获破损的交付前审计

这些 API 没有一个会校验目标。一个指向你已删除工作表的链接、一个拼错的内部网主机、一个上个季度退役的文件共享:它们全都毫无动静地保存下来。ECMA-376 规定的是链接如何存储,而不是它是否解析到任何东西。因此一个携带评审元数据的工作簿值得你自己在 SaveAs 之前跑一段简短的审计阶段:

HotXLS 交付前审计通道示意图:在 Delphi 中 SaveAs 之前检查内部目标、URL 允许列表、批注数量与收件人清理
四项检查恰在 SaveAs 之前运行,每一项都能抓到库本身永远不会抛出的失败
  • 收集生成期间写入的每个内部位置,并确认感叹号前的工作表名仍然存在于工作簿的工作表集合中
  • 对照一个方案和主机的允许列表检查外部 URL。裸 file:// 和 UNC 路径会泄露环境细节,并在文件一离开你的网络时就坏掉
  • 统计每张工作表的批注数并与你的生成器打算写入的数相比较。一次把备注翻倍的重试在这里浮出水面,而不是在评审者的收件箱里
  • 只要接收方在组织之外,就用 DeleteInRange 剥离仅内部使用的 的标注

从数据层构建工作簿的团队可以把这一阶段折叠进已经校验数据的同一个流水线步骤,这样元数据检查就免费搭车了。其机制正是把数据库查询结果导出为 Excel 报告中描述的那些,只不过转向了链接和批注而不是行

当人们手工构建位置字符串时,有一个引号细节会绊倒人。一个名字含空格的工作表必须在位置中加引号,方式与公式栏给它加引号完全一样:'Quarterly Totals'!A1,而不是 Quarterly Totals!A1。HotXLS 应用的规则与公式引擎用于跨表引用的相同,所以如果一条链接在一条工作表公式里能用,它的引号在这里也能用。给它一个未加引号、带空格的名字,你就会得到开篇警告过的那种同样的静默死链

批注和超链接是生成工作簿中评审者不假思索就据此行动的部分,这正是一个指向虚无的目标在任何人察觉之前造成真实损害的原因。把校验这一遍建好一次,在每份工作簿出厂前都跑一遍,评审工作流就能在改名和转换之间保持完好。XLS 和 XLSX 两套门面的完整 API 表面都记录在 HotXLS Delphi Component 产品页上