从 Delphi 生成工作簿,用经典的批注 API 批注单元格,Excel 365 会将您的文本归档在“备注(Notes)”下:即旧式的黄色文本框,没有“回复”字段、没有作者头像、没有“解决”按钮;HotXLS 通过 XLSX 引擎上的 AddThreadedComment 和 AddThreadedReply 编写真正的 Excel 365 线索类批注(Threaded Comments),为每个工作表发出专用的 xl/threadedComments/threadedCommentN.xml 部分,并在工作簿级的 person 部分中注册每个作者,因此您的代码所编写的对话,就是评审人员在 Excel 中回复的同一个对象
这种区别很重要,因为这两个功能只是看起来相似;备注是一个悬浮文本框,线索类批注则是带有稳定标识符、回复树、作者注册表和已解决标志的对话记录 —— 这些正是评审工作流实际运行所依赖的;本文首先介绍 OOXML 结构,因为一旦您并排看到这两种存储模型,Excel 中的每个行为差异就都不再神秘了
为什么我的批注在 Excel 365 中显示为备注?
简短答案:两代批注共享同一个网格,而且它们生活在包中包中完全不同的部分; AddComment 所针对的遗留模型将文本存储在 xl/comments1.xml 中(内容类型为 application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml),并通过配套的 VML 绘图部分 xl/drawings/vmlDrawing1.vml 来定位气泡;这组遗留模型早于 Ribbon 界面出现,Excel 365 仍能忠实地读取它,但会将其结果渲染为“备注”,并且不提供任何线索类回复界面,因为存储中没有任何用于线索化处理的内容;作者是一个显示字符串,位置是一个 VML 形状,这就是整个模型
随 Excel 365 引入的线索类批注则完全忽略了 VML 层;每个带有对话的工作表都在内容类型 application/vnd.openxmlformats-officedocument.spreadsheetml.threadedComments+xml 下携带其自有的 xl/threadedComments/threadedCommentN.xml 部分,而工作簿则携带单个 xl/person/person.xml 部分(内容类型为 ...spreadsheetml.people+xml)来一次性列出所有参与者;HotXLS 在同一工作表上暴露了这两种模型: AddComment 继续编写遗留配对, AddThreadedComment 编写新部分;如果您的输出显示为备注,说明您调用了第一个 API,却想要的是第二个;遗留模型对于某些工作仍是正确的工具 —— 先前关于评审工作流中的单元格批注与超链接的文章进行了深入涵盖(包括 XLS 侧),但它本身没有任何内容可以升级为线索回复
HotXLS 向包中写入了什么
HotXLS 将每个回复建模为一个 TXLSXThreadedComment 对象,它携带单元格锚点(Ref)、GUID 样式的标识符(Id)、可选的 ParentId、通过人名列表解析的 AuthorId、 DisplayName 镜像、文本主体、 Done 标志以及 ISO-8601 DateTime;保存时,每个条目都变成一个 <threadedComment> 元素,其 t 属性持有标识符,其 dT 属性持有时间戳,其 parentT 属性(仅存在于子回复上)命名父标识符;Excel 通过将 parentT 匹配到 t 来重建对话树,XML 中没有嵌套,仅是一个扁平的列表加引用
person 部分是大多数手动编写的实现所遗忘的部分;线索类批注中的每个 authorId 都必须解析为 xl/person/person.xml 中的 <person> 条目(已连接到工作簿关系,且具有自有的内容类型覆盖) —— 否则 Excel 就没有可以显示的名称;HotXLS 自动处理注册: AddThreadedComment 通过显示名称在工作簿的 Persons 列表中查找作者,如果不存在则创建一个带有全新 GUID 的条目,以便同一作者的连续批注共享同一个身份;工作表关系获得了 threadedComments 关系,工作簿关系获得了 person 关系,内容类型清单获得了这两个覆盖,而这一切都无需出现在您的代码中
使用 AddThreadedComment 和 AddThreadedReply 构建回复链
AddThreadedComment 创建对话根节点:其 ParentId 保持为空,这标志着它是线索的顶部; AddThreadedReply 接受 ParentRef 参数 —— 即根节点所在的单元格引用 —— 在该锚点处找到根节点,并将新回复的 ParentId 盖上根节点的 Id 印记;两者都返回新的 TXLSXThreadedComment,因此您可以在刚刚创建的对象上设置时间戳或已解决状态:
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Root: TXLSXThreadedComment;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('forecast.xlsx');
Sheet := Book.Sheets[0];
// Conversation root; ParentId stays empty
Root := Sheet.AddThreadedComment(8, 3,
'Q3 figure looks low against the pipeline export', 'Maria Ortiz');
Root.DateTime := '2026-07-06T09:12:00Z';
// Replies anchor at the same cell; ParentRef names the root cell
Sheet.AddThreadedReply(8, 3, Root.Ref,
'Pipeline export missed the EMEA renewals, re-running', 'Jan Kowalski');
Sheet.AddThreadedReply(8, 3, Root.Ref,
'Confirmed, refreshed figure lands tomorrow', 'Maria Ortiz');
Book.SaveAs('forecast-reviewed.xlsx');
finally
Book.Free;
end;
end;
该列表中的两个细节值得注意;首先,传递的是 Root.Ref,而不是手动构建的字符串:库生成的引用保证能与 AddThreadedReply 将要查找的引用相匹配,这消除了整类偏离一位的锚点 bug;其次,两个作者都作为简单的显示名称传递 —— 人员注册表、GUID 身份和 authorId 接线都发生在调用背后;在 Excel 365 中打开保存的文件,单元格锚定、回复顺序和两个不同的参与者全都是处于活动状态的:点击“回复”,Excel 就会追加到您代码开始的同一个线索中
读回对话:往返处理行为
HotXLS 从 2.122.0 版本起支持线索类批注的往返处理:重新打开已保存的包会从各个部分重建每个工作表的对话列表和工作簿参与者列表,因此标识符、回复链接和显示名称在完整的读→写周期中得以存活;读取器在读取任何工作表内容之前会解析 person.xml,这使得在加载时每个 authorId 都能解析为显示名称;解析的人员标识符按原样保留,而不是重新生成,因此在您的代码和 Excel 之间穿梭的工作簿可以在任意多次的往返处理中保持稳定的作者身份
var
i: Integer;
Cmt: TXLSXThreadedComment;
begin
Book.Open('forecast-reviewed.xlsx');
Sheet := Book.Sheets[0];
for i := 0 to Sheet.ThreadedComments.Count - 1 do
begin
Cmt := Sheet.ThreadedComments[i];
if Cmt.ParentId = '' then
Writeln('Root at ', Cmt.Ref, ' by ', Cmt.DisplayName, ': ', Cmt.Text)
else
Writeln(' reply by ', Cmt.DisplayName, ': ', Cmt.Text);
end;
Writeln('Participants: ', Book.Persons.Count);
end;
“扁平列表加引用”的存储在这里被刻意显露出来: ThreadedComments 按部分顺序枚举回复,并且空 ParentId 标识出每个根节点;如果您需要树状结构 —— 比如要导出评审日志 —— 请首先按 Ref 分组,然后在每个单元格的分组内将 ParentId 链接到 Id;这种“保留您所解析的内容”的行为是库中更广泛政策的一个实例;关于主题、扩展列表和计算链的无损往返处理的文章介绍了相同的原则如何应用于包的其余部分
解决状态、时间戳和作者身份
有三个属性承载着评审语义,并且在返回的对象上这三个属性都是可写的; Done 映射到 done 属性 —— Excel 显示已解决的线索被折叠起来,并带有一个“重新打开”链接; DateTime 是 ISO-8601 UTC 创建标记(dT),当您将其留空时,HotXLS 会替代为固定的占位符,以便该部分始终验证通过,但真实的时间戳才是使线索历史可读的关键,因此建议对其进行设置;在身份侧,每个 TXLSXPerson 都会公开 ProviderId (对于本地创作的条目通常为 None)以及一个您可以在应用程序知晓时填入 UPN 或电子邮件的 UserId:
var
Root: TXLSXThreadedComment;
Person: TXLSXPerson;
begin
Root := Sheet.ThreadedComments.FindAt('D9');
if Root <> nil then
Root.Done := True; // saved as done="1"; Excel shows the thread resolved
Person := Book.Persons.FindByDisplayName('Maria Ortiz');
if Person <> nil then
Person.UserId := 'maria.ortiz@example.com';
end;
需要坦诚说明的一个边界是:HotXLS 写入的 <mentions> 元素为空;库中并未对 @-mention 记录(在批注文本内点亮同事的名字并触发 Microsoft 365 里的通知的机制)进行建模,因此包含 @ 符号的文本将被存储为纯文本;对于生成的评审工作簿,这很少是损失,但如果您的工作流依赖于提及通知,请计划由人工在 Excel 内部添加这些内容;人员部分中的作者身份与工作簿元数据和文档属性中涵盖的工作簿级出处字段自然配对,这也是生成文件其余审计追踪所在的位置
旧版 Excel 如何处理线索类批注?
Excel 2016 及更早版本早于线索类模型,它们仅仅忽略它们不理解的部分;当 Excel 365 本身保存线索类对话时,它还会写入一个遗留批注阴影 —— 一个读取为“[Threaded comment]…”的占位备注,恰是为了让旧版本在锚定单元格上能显示一些内容;HotXLS 仅发出线索类部分,而不带遗留阴影,因此您的代码编写的文件在 Excel 2016 中打开时完全不会显示批注指示器;如果您的受众包括 365 之前的安装且批注在其中必须可见,则遗留的 AddComment API 仍是兼容的渠道;只要避免在同一单元格上叠加两种模型,而不对您交付的每个 Excel 版本进行测试,因为读取器如何协调一个单元格上的备注和线索,是读取器的决定,而不是您的决定
另一个硬边界是文件格式本身;线索类批注是一个 OOXML 结构,没有 BIFF8 对应项,因此 HotXLS 的 .xls 侧没有线索类 API —— 必须保持遗留二进制格式的工作簿仅限于传统的备注;在实践中,决策表很短:为 Excel 365 或 Web/移动端 Excel 读取器编写 .xlsx,使用 AddThreadedComment 并获得真实的对话,针对 Excel 2016 或 .xls,使用 AddComment 并接受备注模型;审计您收到的文件时,同时检查 ThreadedComments and Comments,因为在这两个世界中都活过的工作簿可以合法地同时携带这两者;这两代的完整 API 表面在 HotXLS Delphi Excel Component 产品页面上都有记录