HotXLS 的 AddCopy 方法会将工作表从一个 Excel 工作簿复制到另一个工作簿:先把该工作表中的每个公式反编译为 A1 样式文本,再在目标工作簿中重新编译这些文本,而不是直接复制已编译的公式树,因为图表系列引用、富文本字体索引和外部链接编号在每个工作簿文件中都是独立分配的
这种故障会出现在最容易预料的工作簿中:月末作业从每个分支办公室的报表中取出一个工作表,并将其追加到汇总文件。打开结果后,汇总图表绘制的竟是另一个分支的数字,源文件中原本加粗且为红色的备注变回普通黑色文本,而曾经从配套查找工作簿获取税率的公式现在显示为一个没人能解释的固定数字。这里不会抛出任何异常——文件可以打开,数字看起来也合理,问题会一直存在,直到有人发现旁边的图表标题不对
AddCopy 为什么不能直接复制已编译的公式树
AddCopy 不能原样移动已编译的公式树,因为已编译的 BIFF 公式不是独立存在的文本——它是一串标记,其中一些标记是只有在生成它们的工作簿中才能正确解析的小整数。诸如 Sheet2!A1:A10 的三维引用在编译后不再携带字面名称 Sheet2;它携带的是 BIFF 规范称为 ixti 的字段(HotXLS 在自己的编译树中使用字段名 FExternID 保存相同的值),该字段是指向工作簿私有 EXTERNSHEET 表的索引,而编号取决于该特定工作簿以何种顺序登记其工作表和外部工作簿。将这个标记原样移动到 EXTERNSHEET 表构建顺序不同的工作簿后,索引 3 不再表示 Sheet2——它表示另一边恰好位于第 3 个槽位的任何工作表,而 Excel 无法标记这个错误,因为就文件格式而言,该公式完全符合要求。这正是 TXLSWorksheets.AddCopy 要避免的故障:在 Delphi 或 C++Builder 代码中从任一工作簿自己的工作表集合调用它时,它会将工作表——单元格值、格式、公式、图表、批注、合并单元格、页面设置等内容——从一个可能并非调用它的工作簿复制到目标工作簿,并以你选择的名称或原名称的消歧副本将结果追加进去
var
Summary, Branch: IXLSWorkbook; // interface-counted: do not Free
begin
Summary := TXLSWorkbook.Create;
Branch := TXLSWorkbook.Create;
Branch.Open('branch-east.xls');
// Appends a copy of Branch's first sheet onto Summary, renamed to
// stay unique inside the destination workbook
Summary.Sheets.AddCopy(Branch.Sheets[1], 'East Detail');
Summary.SaveAs('consolidated.xls');
end;
修复方法:反编译为文本,再在目标工作簿中重新编译
HotXLS 通过不让已编译的树本身跨越工作簿边界来解决索引问题。对于跨工作簿复制中的每个公式单元格,AddCopy 会将源公式反编译为用户在 Excel 公式栏中看到的相同 A1 样式文本,然后将文本交给目标工作簿,由目标工作簿从头解析回一棵使用自身表的新树——此时诸如 Data!D2:D100 这样的工作表限定引用只是一个字符串,而字符串在任何工作簿中含义都相同,所以只要目标工作簿已经有名为 Data 的工作表,该引用就能正确解析,完全不需要进行索引转换,因为传递过程中从未存在需要转换的原始索引。HotXLS 只在必要时承担这次往返开销:在同一工作簿内复制工作表时,会采用更快的路径,直接在内存中复制已编译的树,因为其中的每个索引在它要停留的位置都已经有效;只有当 AddCopy 检测到源工作簿和目标工作簿确实是不同实例时,才会执行一次文本转换。还需要准确说明这种重写不是什么。它与在单个工作表中插入或删除行时执行的行列偏移无关,配套文章对此有详细介绍——那个引擎会原地重写 A1 文本,以跟踪在一个工作簿内向上或向下移动几行的单元格;而当前机制发生在公式离开编译它的工作簿时,问题不在于移动的行,而在于工作簿私有的编号
// Conceptually, this is what AddCopy does for each formula cell: turn
// the compiled tree back into text using the source workbook's own
// tables, then let the destination workbook parse that text back into
// a tree using its own tables, from scratch
FormulaText := SourceBook.GetUnCompiledFormula(SourceFormula, Row, Col, SourceSheetID);
DestFormula := DestBook.GetCompiledFormula(FormulaText, DestSheetID);
如果目标工作簿还没有那个工作表或名称,该怎么办
只有当目标工作簿已经拥有公式文本所引用的全部内容时,AddCopy 的重新编译才会成功;实际中出现的两个缺口是:本批次中尚未复制过来的同名工作表,以及目标工作簿中从未存在过的工作簿范围定义名称。工作表复制进行到一半时,如果重新编译失败,HotXLS 不会抛出异常——单元格的 Value 赋值会静默地将公式文本作为普通字符串保存下来。这是一种有意设计、可检查的失败模式,而不是静默出错,因为公式单元格意外显示类似 =SUM(Q1!B2:B12) 的字面文本而非计算结果,正是上游复制解析失败的信号。在放弃之前,AddCopy 会尝试一次修复:它遍历失败公式的语法树,收集公式涉及的每个定义名称 ID;对于源工作簿中存在而目标工作簿中尚不存在的每个工作簿范围名称,它会将该名称复制过来,并再次使用相同文本重新编译。工作表范围名称不在此修复能力之内,因为只对源工作簿某个工作表上的公式可见的名称没有可迁移到的对应槽位;如果目标工作簿已经拥有拼写相同的名称,则会保持不变而不会覆盖,前提是调用方有意预先创建的名称就是希望保留的名称。在单个工作簿中,跨工作表公式的名称查找会自动从工作表范围向上查找到工作簿范围,这正是 HotXLS 定义名称与跨工作表公式文章所介绍的机制;而跨越真正的工作簿边界会完全移除这层保护,名称必须被明确带过去,否则依赖它的公式就会退化为文本
图表系列引用需要相同的修复,但走不同的代码路径
绘制单元格区域的 HotXLS 图表系列与普通单元格公式遇到的编号问题完全相同,因为图表的数据区域引用同样是已编译的公式标记流——BIFF 规范将承载它的记录称为 BRAI([MS-XLS] 第 2.4.51 节)——但 AddCopy 不能通过复用普通图表加载路径来修复它,因为正是那条路径制造了问题。文件正常打开时从磁盘解析图表记录,公式树会通过执行解析的计算器实例转换原始字节来构建;如果改为将源图表的原始 BRAI 字节交给目标工作簿自己的普通记录加载器,嵌入这些字节中的 ixti 就会根据目标工作簿的 EXTERNSHEET 表解析,于是系列会静默地指向另一边恰好占据该槽位的工作表——这与原样复制单元格已编译树属于同一类错误,只是更难发现,因为没有人会像阅读单元格公式那样阅读图表系列公式。HotXLS 通过专用克隆路径避开这个陷阱:TXLSCustomChart.AssignFrom 会逐字节复制每条图表记录自身的非公式头部字节,然后通过与普通单元格相同的反编译和重新编译原语重建附加区域,因此新树从头依据目标工作簿的 EXTERNSHEET 表构建,而不是事后针对该表重新解释
相同的编号问题,一次处理一个字体索引
图表或富文本单元格中的并非每个工作簿本地数字都是公式,字体索引就是同类问题的缩小版。富文本运行以及另外两种携带标题或坐标轴字体的图表记录类型,会将字体引用存储为指向所属工作簿自身字体表的原始整数索引,而这个索引在其他工作簿的表中没有任何意义——它完全可能在那里指向完全不同的字体、大小或颜色。HotXLS 不按数字而按值解析:它会根据该索引在源表中查找实际字体属性,在目标字体表中查找或创建匹配项,然后重写存储的索引,使其指向新的槽位。有一个格式细节让查找本身变得棘手——文件中的索引会跳过槽位 4,这是 [MS-XLS] 第 2.5.339 节记录的编号间隔,因此代码必须在比较字体前将索引减一,并在写回结果前将索引加一
// The file-numbered font index skips slot 4 (MS-XLS section 2.5.339);
// shift into the in-memory slot, migrate the font by value if the
// destination differs, then shift back before writing the result
if Ifnt >= 5 then
Dec(Ifnt);
if DestFonts.Key[Ifnt] <> SourceFonts.Key[Ifnt] then
Ifnt := DestFonts.SetKey(0, SourceFonts.Key[Ifnt]);
if Ifnt >= 4 then
Inc(Ifnt);
如果公式已经指向工作簿外部,会发生什么
在调用 AddCopy 之前就已经指向第三个工作簿的公式,是文本往返机制无法传递的情况,因为 HotXLS 自己的公式到文本反编译器有意不会为外部引用生成 [Book]Sheet! 方括号文本,而另一端的编译器也不接受这种语法作为输入——因此这一种情况会通过完全不接触文本的第二种机制处理。当上文所述的名称迁移修复仍使单元格保持为字符串,且源工作簿有真实文件名时,AddCopy 会切换策略:直接深度复制已编译的公式树,而不是复制其文本,然后将副本交给专用的重新绑定过程 RebindExternRefsInTree,逐个节点遍历它。对于找到的每个区域引用,该过程会将源工作簿的 EXTERNSHEET 条目解析回一对工作表名称,并在目标工作簿自己的外部引用表中注册或复用等效条目;如果目标工作簿以前从未引用过该源文件,还会创建一个全新的外部工作簿链接
这里的工作簿本地编号问题最为直观,因为外部引用标记将三个独立坐标捆绑到一个字段中,而每个坐标都只属于写入它的工作簿:第一个是外部工作簿,即目标工作簿自己的外部工作簿列表中的一个槽位,其编号顺序取决于该工作簿当时登记这些外部工作簿的顺序;第二个是该外部工作簿内部工作表列表中的工作表,存储为专门限定于该外部工作簿的从 1 开始的索引,与目标工作簿自己的内部工作表 ID 完全属于不同的编号域;第三个是单元格区域本身,即不需要转换的普通行列坐标,因为它从来就不是相对于工作簿的坐标。前两个坐标中任意一个出错,Excel 仍会打开文件,仍会显示公式,并且会毫无提示地针对错误的外部单元格计算公式。有一种节点甚至会击败这一级别的树重新绑定:对定义名称的引用,它是对自身工作簿私有名称表的索引,正如工作表索引对于自身 EXTERNSHEET 一样私有,而且没有可用的树级修复——重新绑定遍历在树中的任何位置遇到名称引用的瞬间,就会放弃整个公式,而不是写出部分正确的公式。即使重新绑定成功,目标单元格也不会显示刚刚重新计算的数字;它会显示源单元格在复制时已经持有的值,并将其保存在缓存槽中,方式与 Excel 自己缓存任何外部引用的最后已知值相同,直到你明确刷新链接为止。这是合理的默认行为,因为跨实时链接重新计算另一个文件正是应该有意触发一次的操作,而不是每次打开时都执行
这种设计的代价
AddCopy 的反编译和重新编译机制并非没有成本,因此在编写大型合并作业脚本之前就应规划好这项成本,而不是事后才处理。在同一工作簿内复制工作表会走便宜的路径,直接在内存中复制已编译的树,因为其中的每个索引在它所停留的工作簿中已经有效;跨工作簿复制则会为每个公式单元格执行真正的解析,先反编译为文本,再从头重新编译这些文本;对于只有几十个公式的工作表,这种差异不值得测量,但如果一个包含数万个公式单元格的源工作簿在批处理作业中作为几十个工作表之一被复制,就应该预期重新编译会成为运行时间的主要部分,而不是周边的文件 I/O。复制顺序还有第二个与速度无关的重要原因:如果某个公式引用了本批次中 AddCopy 尚未复制到的工作表,它的重新编译会因同样的原因失败,就像引用真正不存在的工作表一样;因此,先复制工作表 B,再复制依赖它的工作表 A 公式,会看到该公式按上文所述退化为字符串文本,或退化为指回刚刚复制来源文件的外部链接回退。由于合并批次中的每个源工作簿通常都是独立创建的,因此有必要专门测试单个源文件永远无法预警的一种故障模式——五个分支工作簿分别汇总同级分支的数字后,可能在汇总工作簿中组合成真正的循环引用,而任何单独的源文件都不包含该循环;只有当所有工作表落在同一个位置并对合并集合执行重新计算后,这个循环才会出现
跨工作簿工作表复制是 AddCopy 在面向 Delphi 和 C++Builder 的 HotXLS Delphi Excel Component 中提供的标准行为;产品页面提供完整的工作表和工作簿 API 参考,包括本文介绍的图表、富文本和外部引用行为