设想一个几乎不做什么的任务:打开一份月度工作簿,把今天的日期写入一个单元格,再保存回去。让这套流程通过某项服务跑足够多次,投诉终究会来。宏没了,或者链接的汇率现在显示 #REF!,运营团队坚信是你的代码把它们删了。你的代码什么也没删。通常发生的事情是:一个启用宏的工作簿以普通的 .xlsx 名字保存出去,而 Excel 遵守 ECMA-376 的内容类型规则:一个内容类型声明不含 VBA 的包,无论字节是否就摆在那里,都无法加载 VBA 工程。文件没有损坏。它只是被重命名成了一种 Excel 必须忽略其中一部分的状态
宏和外部工作簿链接是自动化最容易丢失的两样东西,原因同出一辙。两者都生活在编辑代码实际触碰的单元格网格之外,所以以行列思路推理的代码会在从未发出任何删除指令的情况下把它们丢掉。HotXLS 是一个原生 Delphi 与 C++Builder 库,可在不安装 Excel 的情况下读写 XLS 和 XLSX,它把这两类资产当作有意承载的载荷,而不是恰好复制下来的数据。下面要讲的是每一样在保存路径上需要什么,以及这些保证在哪里止步
为什么这两类资产在重写下表现不同
一个 VBA 工程是一个不透明的二进制块。在 OOXML 包中它是文件 vbaProject.bin;在遗留的 BIFF 文件中它是一个 OLE 存储。丢失它的方式只有两种:写入者从不把它复制进输出,或者输出得到了一个禁止它的文件类型。任何一种失败都是彻底而静默的。工程要么存在,要么不存在
一个外部链接根本不是一个二进制块。它是一小张关系图:一个指向另一个工作簿的目标路径或 URL、该目标所暴露的工作表名列表,以及这些工作表上次所见值的一个可选缓存,让 Excel 在目标离线时也能显示点什么。这三部分在重写下有不同的寿命,一个库可以忠实地保留一部分而悄悄丢掉另一部分。这种不对称性正是需要精确把握的部分,因为单元格编辑代码里没有任何东西会把它暴露出来
在 XLSX 重写中携带 VBA 工程
在 XLSX 侧,TXLSXWorkbook 把宏载荷原样保留。VbaProject 属性把原始的 vbaProject.bin 字节存放在一个 AnsiString 里,而空字符串正是模型表示没有宏的方式。围绕它的是三个操作:HasVbaProject 回答是否存在工程,ClearVbaProject 出于目的移除它,而 LoadVbaProjectFromFile 注入一个从模板中提取出来的工程。最后一个调用比看起来更有价值。它让生成的工作簿无需把一个完整模板文件拖过整个流水线就能获得一个标准宏工程
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Data');
Sheet.Cells[1, 1].Value := 'Refreshed ' + DateTimeToStr(Now);
Book.LoadVbaProjectFromFile('macros\vbaProject.bin');
if not Book.HasVbaProject then
raise Exception.Create('VBA payload failed to load');
// .xlsm 扩展名并非装饰:它选择的
// 是包内启用宏的内容类型。
Book.SaveAs('monthly-report.xlsm');
finally
Book.Free;
end;
end;
保存那一行是整个问题的转折点。一个持有 VBA 工程的工作簿必须以启用宏的语义写入,而 HotXLS 在目标名以 .xlsm 结尾时应用这些语义。如果给它 .xlsx,Excel 就会拒绝宏,即便字节确实物理上存在于包中且能正常反序列化。扩展名不是装饰;它选择的内容类型告诉 Excel 允许 VBA 工程存在。大多数时候你只需要把载荷原样带过去。当你需要读取其中内容时,比如为审计报告列出模块名,ParsedVBAProject 暴露一个解析后的模块模型,而 VbaProject 保持原始未触碰的字节
从遗留 XLS 工作簿复用宏
BIFF 门面镜像了那套工具,只是多了一步。HasVBAProject 探测一个已加载的文件,SaveVBAProjectToFile 把工程存储写到磁盘,而 LoadVBAProjectFromFile 把它读回另一个工作簿。经由文件的迂回让一项常见的现代化杂务变得直截了当:把 2003 年代模型中的宏提取出来,植入到新生成的 XLS 输出中,运行时无需原始模板
var
Src, Dst: IXLSWorkbook; // 接口引用:无需手动 Free
begin
Src := TXLSWorkbook.Create;
if Src.Open('legacy-model.xls') <= 0 then
raise Exception.Create('Cannot open legacy model');
if Src.HasVBAProject then
Src.SaveVBAProjectToFile('extracted-vba.bin');
Dst := TXLSWorkbook.Create;
Dst.Sheets.Add.Name := 'Report2026';
Dst.LoadVBAProjectFromFile('extracted-vba.bin');
Dst.SaveAs('report-with-macros.xls');
end;
这里的陷阱在于内存模型,而且它与 XLSX 类正好相反。TXLSWorkbook 通过引用计数的 IXLSWorkbook 接口持有,所以你从不手动释放它;而 XLSX 的 TXLSXWorkbook 是一个普通对象,你必须用 try..finally 包裹并释放。在一个单元里混用两种约定就会导致重复释放崩溃。还有一个值得遵守的边界:把提取和注入保持在单一文件格式内。BIFF 工程存储与 OOXML 的 vbaProject.bin 是表亲,不是同一个容器,必须在两种格式中都输出宏的流水线应为每种格式保留单独的宏模板
外部链接:映射存活,缓存值不存活
对于 XLSX 工作簿,HotXLS 通过 ExternalLinks 集合暴露外部链接。每个 TXLSXExternalLink 携带一个 Target(远程工作簿的路径或 URL),加上一个命名其所引用工作表的 SheetNames 列表。两者在一次打开-保存循环中都完好无损,你也可以从零开始构建一个链接:
var
Link: TXLSXExternalLink;
begin
Link := Book.ExternalLinks.Add('\\fileserver\finance\fx-rates-2026.xlsx');
Link.SheetNames.Add('FX');
if Book.ExternalLinks.Count > 0 then
Writeln(Format('%d external link(s): delivery requires reachable targets',
[Book.ExternalLinks.Count]));
end;
边界比目标列表更深一层。HotXLS 往返的是链接映射,也就是目标和这些工作表名,但它不解析也不重写 OOXML 在链接的 sheetDataSet 元素中保存的缓存单元格值。那份缓存是让 Excel 在源文件离线时显示一个最近已知数字的东西,而一个生成的工作簿在交付时并不带它。后果落在接收方身上,而不是你。打开这样一个目标不可达的文件——一台脱离 VPN 的笔记本或一个被重命名的共享——依赖该链接的公式就会解析为 #REF!,或者卡在一个更新提示后面。由此得出两条规则。不要承诺一个生成的工作簿能在离线状态下显示其外部链接的值。而且把一个非零的 ExternalLinks.Count 当作交付前提条件而非功能:每个目标都必须能从文件实际打开之处可达
XLS 读取器逐字节保留的内容
对于它不建模的结构,BIFF 侧有一个不同的答案:原样保留,原样不动。透视缓存和透视视图(SX* 记录族)、QueryTable 定义、外部数据连接、自定义视图、页眉图片以及主题记录,全都以原始记录块的形式通过一次打开-保存循环,不被解析也不被修改。外部引用本身通过底层的 EXTERNSHEET 和 SupBook 记录往返。XLS 侧没有为它们提供类型化的创建 API,但一个已有的链接能在编辑中完好无损地存活
逐字节保留是一项货真价实的保证,但它有锋利的边缘。因为没有东西读取被保留的结构,你的编辑无法损坏它。出于同样的原因,也没有东西更新它。在一个被保留的透视缓存或查询表所指向的区域中插入行,该结构会保持其原始坐标,而其下方的数据已经移动。文件仍然是有效的 XML 或 BIFF;含义已经悄悄地偏离了对齐,而且不会有任何错误发出告诉你。可取的布局是把生成的编辑放在不持有被保留结构的工作表上,这与我们关于工作表保护与页面设置的文章中保护已锁定和已配置打印的工作表是同一种纪律
校验你实际写入的文件
两种失败模式在写入时都是静默的,所以真正重要的断言是由重新打开输出来做出的,而不是信任生成它的代码。三项检查几乎覆盖一切。重新打开文件,确认 HasVbaProject 在期望有宏时仍然返回 true——这能在一次测试中同时捕获丢失的载荷和错误的扩展名。读取 ExternalLinks.Count 并把它与重写前的计数相比较。然后在禁用宏的状态下用 Excel 打开一次该文件,因为 Excel 的内容类型校验比任何库都严格,而 Excel 正是你的客户用来评判文件的程序
这些都不要求进入时做一次完整解析。当工作簿大批到达而你只需要分诊哪些携带受管控内容时,我们关于工作表列举与轻量级工作簿检查的文章中的轻量探测让你能在第一次重写运行之前,就把带宏和带链接的文件引导到更严格的流水线
有几个问题常见到值得直接回答。HotXLS 从不执行它保留的宏:库里没有 VBA 运行时,只有把工程作为数据存储、复制、提取和注入的机制。在一台服务器上,这是一项值得说明的安全属性,因为一个流经流水线的恶意宏会保持惰性,直到桌面 Excel 打开文件且用户启用内容。把 .xlsm 转成 .xlsx 同时保留宏是不可能的,这是格式规则而非库的限制:.xlsx 内容类型声明的是无宏工作簿,所以唯一诚实的结局要么保持 .xlsm,要么调用 ClearVbaProject 并交付一个真正不含宏的文件。静默重命名是唯一谁也满足不了的选择。而当链接单元格在重写后显示 #REF! 时,原因是上文讨论的缺失值缓存:新文件带着目标但不带缓存的数字,所以 Excel 必须在打开时解析源,而一个不可达或相对于环境的路径会击败它。要么保证目标可达,要么在交付前把算好的值写入单元格并彻底去掉依赖
编辑别人的工作簿,主要是保留那些你没写过也不完全理解的东西的工作。此处描述的 VBA 与外部链接往返设施随 HotXLS Delphi Component(面向 Delphi 和 C++Builder)一同发布,并附带让你在文件到达瞬间就能检测受管控内容的审计属性