技术文章

2024!A1 报错:Excel 公式里表名何时要加引号

一个预算工作簿里有个叫 FY2024 的表,汇总单元格写着 =FY2024!B7,Excel 读不了这条公式。FY2024 同时也是一个单元格地址,所以这个名字得加单引号,写成 'FY2024'!B7;HotXLS 按 Excel 保存时用的同一套规则来写,它的 XLSX 工作簿在工作表改名或删除时会改写每一条公式

哪些表名在公式里必须加引号

只要公式解析器可能把它读成表名以外的东西——数字、布尔值或单元格引用——表名就需要单引号。ECMA-376 Part 1 §18.17 的公式语法同时覆盖带引号和不带引号的表名,但真正实用的参照是 Excel 写进 sheet1.xml 的 <f> 元素里的那段文字,因为其他所有阅读器都是对着它调出来的。Excel 的 COM Range.Formula 回声与它保存的 XML 在某些名字上并不一致:R1. 和 A1.x 从 .Formula 读出来是裸的,在文件里却带着单引号。照 COM 回声总结出来的规则,会被 Excel 自己的文件推翻。HotXLS 跟随存储形态,以下情况要加引号:

  • 出现 A-Z、a-z、0-9 和下划线之外的任何字符:空格、连字符、点、括号
  • 以数字开头:2024、2nd_Half 和 3D 都要加引号
  • 任意大小写的 TRUE 或 FALSE。TRUE1 和 TRUEX 保持裸写,因为两者都读不成布尔值
  • 读起来像网格内 A1 单元格的名字:一到三个字母组成不晚于 XFD 的列,后面跟 1 到 1048576 的行号,允许前导零
  • 开头像 R1C1 引用的名字:单独一个 R、C 或 RC,或它们后面跟一个数字
  • 带引号的名字里,单引号写两遍——Bob's Data 写成 'Bob''s Data'!A1

A1 这条才是真实工作簿会撞上的,因为太多业务表名恰好是合法的单元格地址。FY2024 是 FY 列第 2024 行。Jan2024 是 JAN 列第 2024 行,而 JAN 稳稳落在现代工作表 16,384 列的范围之内。Q1、CR1 和 Ra1 也都是地址,这三个都必须加引号。边界情况反过来:A0 没有第 0 行,XFE1 超出一列,XFD1048577 超出一行,ABCD1 有四个字母,所以 Excel 对它们一律不加引号。前导零救不了名字,A01 照样加单引号,因为解析器把它读成 A1。只检查名字字符集的写入器,会给一份财年报告写出 FY2024!B7,而 Excel 读不回这条公式

HotXLS 的 XlsSheetNameNeedsQuotes 在 Delphi 里判断表名是否加引号:非名字字符、前导数字、TRUE 或 FALSE、FY2024 与 Q1 这类像 A1 单元格的名字、以及 R1C1 前缀都要加单引号,而 Summary、TRUE1、XFE1 与 Region1 保持裸写
五道检查决定表名限定符要不要加单引号,而 FY2024、Jan2024、Q1 这类业务名早在标点之前就撞上了 A1 单元格那道

像 R1C1 的名字,以及宁可多引号的理由

对 R1C1 形状的名字,没有一条干净的规则能从 Excel 的输出反推回来。Excel 加引号的有 R、C、RC、R1、C1、R1x、R1C1x,还有 R1C16385 和 C99999——后两个指向的列压根不存在。可它又让 R0、C0、RC0、R0C1、R1048577、Rx、Cx 和 R_1 保持裸写。网格边界与 token 形状怎么组合,都复现不出这两份名单,所以 HotXLS 取一个超集:名字是 R、C 或 RC,或者以其中之一加一个数字开头,就加单引号。这个超集会多给几个 Excel 不加引号的名字加上引号,R0 和 R1048577 就在其中。这点可以接受,理由很朴素:任何表名的带引号拼写都是合法公式语法,凡是解析公式的阅读器都认;而一份试图逐个对上 Excel 的名单,只能建立在一条没人能说清楚的规则上。Chart1、Region1 和 Cost2024 这类普通名字两道检查都过不去,保持裸写。如果你还要处理 R1C1 文本,HotXLS 的 R1C1 公式记法那篇讲了这些引用最先是怎么被解析的

含 ASCII 之外字母的名字同样会被多引号。Excel 在这里更宽松。它写 Données!A1 和 数据!A1 都不带单引号,因为带重音的字母和 CJK 字母在它的解析器里算名字字符。HotXLS 打开文件时读得懂这些裸限定符,因为它们是 Excel 产出的,但自己写这类名字时会加引号,比如 'Données'!A1。带引号的形式在哪儿都合法,而写入器那边的检查也就还是一个简短、可预期的列表,不必变成一张每个使用方都得点头的 Unicode 字母表

在 Delphi 里拼表名限定符

lxStandard.XlsSheetNameNeedsQuotes 是这条规则的公开形态,XLSX 写入器和经典引擎输出表引用时用的都是它。你自己的代码要用表名拼公式文本时——比如一张按地区或按年份各指一个表的汇总表——请把每个名字过一遍这个函数,而不是直接拼 Name + '!'。这个函数只回答是或否。名字内部单引号要写两遍,这归调用方负责,如下面的辅助函数所做;XLSX 单元格公式赋值时不要带开头的 =

program SheetQualifiers;

{$APPTYPE CONSOLE}

uses
  System.SysUtils, lxStandard, lxHandleX;

// 手拼公式文本的限定符:名字需要单引号时就加,
// 名字内部的单引号写两遍
function SheetQualifier(const Name: WideString): WideString;
begin
  if XlsSheetNameNeedsQuotes(Name) then
    Result := '''' + StringReplace(Name, '''', '''''', [rfReplaceAll]) + '''!'
  else
    Result := Name + '!';
end;

const
  Candidates: array[0..10] of WideString = (
    'Summary', '2024', 'FY2024', 'Q1', 'TRUE', 'TRUE1',
    'R1C1', 'Region1', 'XFE1', 'Données', 'Bob''s Data');
var
  Book: TXLSXWorkbook;
  Map, Sheet: TXLSXWorksheet;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Map := Book.Sheets.Add('Map');
    for I := Low(Candidates) to High(Candidates) do
    begin
      Sheet := Book.Sheets.Add(Candidates[I]);
      Sheet.Cells[1, 1].Value := (I + 1) * 10;
      // 用表实际拿到的名字,不是请求的那个
      Map.Cells[I + 1, 1].Value := Sheet.Name;
      Map.Cells[I + 1, 2].Formula := SheetQualifier(Sheet.Name) + 'A1';
      Writeln(Format('%-12s %s', [Sheet.Name, Map.Cells[I + 1, 2].Formula]));
    end;
    Book.SaveAs('sheet-map.xlsx');
  finally
    Book.Free;
  end;
end.

// 预期结果:
// Summary      Summary!A1
// 2024         '2024'!A1
// FY2024       'FY2024'!A1
// Q1           'Q1'!A1
// TRUE         'TRUE'!A1
// TRUE1        TRUE1!A1
// R1C1         'R1C1'!A1
// Region1      Region1!A1
// XFE1         XFE1!A1
// Données      'Données'!A1
// Bob's Data   'Bob''s Data'!A1

XLSX 文件里,工作表名不能含 : \ / ? * [ ],不能以单引号开头或结尾,最长 31 个字符,不区分大小写也必须唯一,而且不能叫 History——那是 Excel 保留的。TXLSXSheets.Add 和 TXLSXWorksheet.Name 的 setter 都会执行这些:非法字符变成下划线,撞名加一个 (2) 后缀,保留名或空名退回 Sheet。所以你赋进去的值和读回来的值可能不同,用请求名拼出来的限定符会指向一个不存在的表。每次赋值后都读回 Name,拿它去加引号

Sheet := Book.Sheets.Add('Totals');
Sheet.Name := 'Q1/Q2 Sales';                 // 表名里不允许出现 /
Writeln(Sheet.Name);                         // Q1_Q2 Sales
Writeln(SheetQualifier(Sheet.Name) + 'C9');  // 'Q1_Q2 Sales'!C9

工作表删掉之后 Excel 存的是什么

工作表被删掉时,Excel 会把整条指向它的引用换成 #REF!,限定符连同单元格或区域一起。SUM(Data!A1:B2)+1 存成 SUM(#REF!)+1,COUNT(Data!C:C) 存成 COUNT(#REF!),指过 Data!$A$1:$B$2 的已定义名称变成光秃秃的 #REF!,图表系列区域变成 <c:f>#REF!</c:f>。COM 视图在这里同样会带偏:Range.Formula 显示 =#REF!A1+1,好像单元格那截还在,而 Excel 保存的 XML 里根本没有单元格部分。照 COM 的显示去抄,会写出 Excel 自己从不写的公式文本——拿这种文本交给别的工具去读,可不是好赌注。TXLSXSheets.DeleteByName 写的是存储形态。行或列消失时引用也按同一套塌缩成 #REF! 的逻辑处理,表内那种情况在插入与删除行如何调整公式引用那篇里讲

三维引用是另一副脾气。Excel 会把 3D 区间缩小,而不是销毁。表按 Data、Mid、Keep 排列时,删掉 Data 会把 SUM(Data:Keep!A1) 变成 SUM(Mid:Keep!A1):消失的那一端往内挪一个表,按删除之前的标签顺序量。区间里只剩两个表时,结果是单表形式 SUM(Keep!A1)。删掉 Mid 完全不改公式文本,因为区间仍点着两个活着的表,只是中间少盖了一个。只有两端都点着被删的表时,引用才变成 #REF!。死引用会改变公式的返回值,所以删除过程中被 HotXLS 改写过的每个公式单元格,其缓存结果都标为过期;读值之前先调 Recalculate。改名则不动缓存值,数字不可能变

HotXLS 的 TXLSXSheets.DeleteByName 在 Delphi 里:普通表引用、已定义名称和图表区域都按 Excel 的存储方式塌成 #REF!,而 SUM(Data:Keep!A1) 这类 3D 区间缩成 SUM(Mid:Keep!A1),被改写的单元格还需要 Recalculate
删掉一个表,普通引用连单元格部分一起塌成 #REF!,而 3D 区间只丢掉消失的那端,只要还剩一个活表点着它就继续存在
uses
  System.SysUtils, lxHandleX;

procedure ShowFormulas(Book: TXLSXWorkbook; const Caption: string);
var
  Report: TXLSXWorksheet;
  Row: Integer;
begin
  Writeln(Caption);
  Report := Book.Sheets.SheetByName('Report');
  for Row := 1 to 4 do
    Writeln('  B', Row, ': ', Report.Cells[Row, 2].Formula);
  Writeln('  DataBlock: ', Book.DefinedNames.FindByName('DataBlock').Formula);
end;

var
  Book: TXLSXWorkbook;
  Report: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Sheets.Add('Data');
    Book.Sheets.Add('Mid');
    Book.Sheets.Add('Keep');
    Report := Book.Sheets.Add('Report');
    Book.DefinedNames.Add('DataBlock', 'Data!$A$1:$B$2');

    Report.Cells[1, 2].Formula := 'SUM(Data:Keep!A1)';
    Report.Cells[2, 2].Formula := 'SUM(Data!A1:B2)+1';
    Report.Cells[3, 2].Formula := 'Keep!A1*2';
    Report.Cells[4, 2].Formula := 'INDIRECT("Data!A1")';

    Book.Sheets.DeleteByName('Data');
    ShowFormulas(Book, 'After deleting Data:');

    Book.Sheets.SheetByName('Keep').Name := '2025';
    ShowFormulas(Book, 'After renaming Keep to 2025:');

    Book.Recalculate;   // 丢掉 Data 的单元格带着过期的缓存值
    Book.SaveAs('report.xlsx');
  finally
    Book.Free;
  end;
end.

// 预期结果:
// 删除 Data 之后:
//   B1: SUM(Mid:Keep!A1)
//   B2: SUM(#REF!)+1
//   B3: Keep!A1*2
//   B4: INDIRECT("Data!A1")
//   DataBlock: #REF!
// Keep 改名为 2025 之后:
//   B1: SUM('Mid:2025'!A1)
//   B2: SUM(#REF!)+1
//   B3: '2025'!A1*2
//   B4: INDIRECT("Data!A1")
//   DataBlock: #REF!

改名会把引用改遍整个工作簿

在 XLSX 工作簿上给 TXLSXWorksheet.Name 赋值,新名字会被带到公式可能存身的每一处,DeleteByName 也走同样的这些地方去施加 #REF! 塌缩。这张名单远不止单元格公式:

  • 每个表上的单元格公式。共享公式在每个跟随单元格上都展开存放,数组公式住在它的根单元格上,所以两者都不用特殊处理就覆盖到了
  • 条件格式公式,包括数据条、色阶与图标集里公式型的阈值
  • 数据验证公式,比如指向另一个表的列表来源
  • 表格的计算列公式与汇总行公式
  • 已定义名称——建立在已定义名称与跨表公式之上的模型的骨架;改名时,从改名表读取源区域的数据透视表缓存也跟着走
  • 图表区域:系列名、分类、值、气泡大小与数据标签区域,误差线的正负区域,以及图表标题和坐标轴标题背后的单元格引用

3D 区间只要有一端需要引号,整段就带引号:把 Keep 改名为 2025 产出的是 'Mid:2025'!A1,绝不会是 Mid:'2025'!A1,因为整段是一个限定符。与这次改名无关的限定符,都按原样回放。别处有公式写着 'Sheet1'!B4,那些单引号技术上没必要,改完之后它还是那个样子。顺手把这些限定符规范化对 Excel 无害,但改一个表名就会碰到工作簿里的每一条公式,两份生成报告的 diff 里,真正的改动会被几百处纯粹的修饰盖住

只是看着像表引用的文本

有三类文本在模式搜索眼里像 Name! 限定符,实际不是。字符串字面量里的 "Data!A1" 是文本,所以 INDIRECT("Data!A1") 在改名或删除之后仍指着旧名字,和 Excel 一样;如果报告要为 INDIRECT 拼表名,就把这些名字放在某个单元格里,由你的代码随改名一起更新。前面带方括号工作簿索引的限定符,比如 [1]Data!A1,属于一个恰好有张表叫 Data 的外部工作簿,改本地 Data 表的名字绝不能碰它;带引号的限定符里只要含 [,也按同样方式处理。而 #REF! 本身就长着叫 REF 的限定符的形状,所以 HotXLS 在把 REF! 当表名之前,会先检查前面的 #。没这道检查,改一个叫 REF 的表的名字,就会把工作簿里每一个 #REF! 都改写掉

HotXLS 在 Delphi XLSX 工作簿里给 TXLSXWorksheet.Name 改名:单元格、条件格式、数据验证、表格、已定义名称与图表公式都被改写,3D 区间被引成 'Mid:2025'!A1,而 INDIRECT 字符串字面量、外部 [1] 限定符与 #REF! 原封不动
把 Keep 改名为 2025 会触达工作簿里每一个公式位置,而字符串字面量、外部工作簿限定符、#REF! 和无关的限定符都保住原文

最容易弄坏生成公式的表名,是 FY2024、Q1、2025 这类普通名字,而不是带空格或标点的那些。你的 Delphi 代码在哪儿拼公式文本,就在哪儿用 XlsSheetNameNeedsQuotes 过一遍。做到这一点之后,HotXLS Delphi 电子表格组件里的改名或删除,都会让每条公式保持 Excel 自己保存时会写的样子