技術記事

2024!A1 が失敗する:式の中のシート名は引用符が必要

予算ブックに 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〜3 文字の字母に、1〜1048576 の行が続き、先頭 0 も許される
  • R1C1 参照のように始まる名前:R、C、RC の単独、またはその後に数字が続くもの
  • 引用符付きの名前の中ではアポストロフィが二重化される——Bob's Data は 'Bob''s Data'!A1 と書かれる

実務のブックが実際に突き当たるのは A1 規則です。ビジネスのシート名は、たまたま有効なセル番地であることが多すぎるからです。FY2024 は列 FY、行 2024。Jan2024 は列 JAN、行 2024 で、JAN は現代のシートが持つ 16,384 列の中に余裕で収まります。Q1、CR1、Ra1 もすべて番地で、3 つとも引用符が要ります。境界のケースは逆に外れます。A0 は行 0 がなく、XFE1 は端から 1 列外、XFD1048577 は 1 行外、ABCD1 は字母が 4 つで、Excel はどれも素のまま置きます。先頭 0 で名前は救われず、A01 はパーサが A1 と読むので今もアポストロフィを付けられます。名前の文字種だけを検査する writer は、年度レポートに FY2024!B7 を出力し、Excel はその式を読み戻せません

HotXLS の Delphi での XlsSheetNameNeedsQuotes シート名引用規則。名前以外の文字、先頭数字、TRUE か FALSE、FY2024 や Q1 のような A1 セル類似、R1C1 接頭はアポストロフィを強制し、Summary、TRUE1、XFE1、Region1 は素のまま
シート qualifier にアポストロフィが付くかは 5 つの試験で決まります。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 は素のままです。グリッドの限界とトークンの形をいくら組み合わせても、両方のリストは再現できません。そこで HotXLS は上位集合を採ります。R、C、RC のいずれかである名前、またはその後に数字が続く名前は、アポストロフィを付けます。この上位集合は Excel が素のままにするいくつかの名前、R0 や R1048577 などを引用します。それでも構わない理由は単純です。どんなシート名でも引用符付きの綴りは正当な式構文で、式をパースするリーダーは皆これを受け入れます。一方、Excel に 1 個ずつ名前合わせしようとするリストは、誰も述べられない規則の上に立つことになります。Chart1、Region1、Cost2024 のような普通の名前は両試験の外に落ちて素のままです。R1C1 のテキストも扱うなら、HotXLS での R1C1 式記法の解説が、そもそもその参照がどうパースされるかを述べています

ASCII 外の字母を含む名前は、多めに引用されます。Excel はここで寛容です。アクセント付きや CJK の字母は、Excel のパーサでは名前文字として数えられるので、Données!A1 や 数据!A1 をアポストロフィなしで書きます。HotXLS はファイルを開くとき、Excel が作るのだからと、そうした素の qualifier を読みます。ただし書くときは 'Données'!A1 のように引用符付きにします。引用符付きの形はどこでも正当で、writer の試験は、全消費者が同意する必要のある Unicode の文字表ではなく、短く予測可能なリストのままで済みます

Delphi でシート qualifier を組み立てる

lxStandard.XlsSheetNameNeedsQuotes はこの規則の公開形で、XLSX writer もクラシックエンジンも、シート参照を出力するときに使う関数です。自分のコードがシート名から式テキストを組み立てるとき——地域ごと、年度ごとに 1 シートを指す集計シートなど——Name + '!' を連結する代わりに、各名前をこの関数へ通しましょう。関数は yes か no しか答えません。内側のアポストロフィの二重化は呼び出し側の仕事です。下のヘルパーがやっているように。XLSX のセル式は先頭の = を付けずに代入します

program SheetQualifiers;

{$APPTYPE CONSOLE}

uses
  System.SysUtils, lxStandard, lxHandleX;

// 手組みの式テキスト用の qualifier。名前が必要とするときは
// アポストロフィを付け、名前内のアポストロフィは二重化する
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 文字で打ち止め、大文字小文字を問わず一意でなければならず、Excel が予約する History は使えません。TXLSXSheets.Add も TXLSXWorksheet.Name の setter も同じく強制します。禁じられた文字はアンダースコアになり、衝突には (2) の接尾が付き、予約名や空名は Sheet に落ちます。したがって代入した値と読み戻した値は違いうるし、リクエストした名前から組み立てた qualifier は、実在しないシートを指します。代入のたびに 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! でまるごと置き換えます。qualifier とセルや範囲が一緒にです。SUM(Data!A1:B2)+1 は SUM(#REF!)+1 と格納され、COUNT(Data!C:C) は COUNT(#REF!)、Data!$A$1:$B$2 を指していた defined name はただの #REF!、チャート系列の範囲は <c:f>#REF!</c:f> になります。ここでも COM の見え方は誤解を招きます。Range.Formula は =#REF!A1+1 と表示し、セル部分が生き延びたかのように見せるのに、Excel が保存する XML にはセル部分はまるごとありません。COM の表示を写すと、Excel 自身が決して書かない式テキストが出来上がります。他のツールが読むファイルにとっては悪い賭けです。TXLSXSheets.DeleteByName は格納形を書きます。#REF! への折り畳みの論理は、行や列が消えた参照にも同じく働きます。シート内のケースは行の挿入と削除が式参照をどう調整するかの解説が扱います

3 次元参照の振る舞いは違います。Excel は 3D スパンを壊すのではなく縮めます。シートが Data、Mid、Keep の順に並んでいるとき、Data の削除は SUM(Data:Keep!A1) を SUM(Mid:Keep!A1) に変えます。消えた端が 1 シート分内側へ寄せられるのです。シート順は削除前のタブ順で測ります。スパンが 2 シートだけなら、結果は単一シート形の SUM(Keep!A1) です。Mid の削除は式テキストをまったく変えません。スパンはまだ生きている 2 シートを名指し、間に挟まるものが 1 つ減るだけです。両端が削除されたシートを名指すときに限り、参照は #REF! になります。死んだ参照は式の返す値を変えるので、HotXLS は削除の最中に書き換えたすべての式セルのキャッシュ結果を stale と印を付けます。値を読む前に Recalculate を呼びましょう。名前変更はキャッシュ値に触れません。数値は変わりようがないので

HotXLS の Delphi での TXLSXSheets.DeleteByName。素のシート参照、defined name、チャート範囲は Excel の格納形どおり #REF! に折り畳まれ、SUM(Data:Keep!A1) のような 3D スパンは SUM(Mid:Keep!A1) に縮み、書き換えられたセルには Recalculate が要る
シートの削除は素の参照をセル部分ごと #REF! に折り畳みます。3D スパンは消えた端を失うだけで、生きているシートが 1 つでも名指しに残る限り生き延びます
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! への折り畳みを適用します。リストはセル式を大きく超えます:

  • 全シートのセル式。shared formula は従属セルごとに展開形で保持され、array formula は根セルに住むので、特別扱いなしでどちらも覆われます
  • 条件付き書式の式。データバー、カラースケール、アイコンセットの formula 型しきい値を含みます
  • データ入力規則の式。別シートのリストソースなどです
  • テーブルの集計列と集計行の式
  • defined name。defined name と cross-sheet 式の上に組まれるモデルの背骨です。名前変更では、改名されたシートからソース範囲を読む pivot cache も一緒に追従します
  • チャート範囲:系列名、カテゴリ、値、バブルサイズ、データラベル範囲、誤差範囲の plus と minus、チャートタイトルと軸タイトルの裏にあるセル参照

3D スパンは、どちらかの端が引用を要るとき、全体として引用されます。Keep を 2025 に改名すると 'Mid:2025'!A1 が生まれ、Mid:'2025'!A1 にはなりません。スパンは 1 つの qualifier だからです。改名の対象でない qualifier は、書かれたとおりにそのまま再生されます。どこかの式が、技術的には不要なアポストロフィ付きで 'Sheet1'!B4 と言っているなら、後もそのまま言い続けます。通りがかりに qualifier を正規化しても Excel には無害でしょうが、そうすると 1 シートの改名がブック中の全式に触れることになり、生成レポートの 2 版の diff では、本当の変更が何百もの化粧変更の下に埋もれます

シート参照にしか見えないテキスト

パターン検索には Name! qualifier に見えて、実際はそうでないテキストが 3 種あります。文字列リテラルの中では、"Data!A1" はテキストです。だから INDIRECT("Data!A1") は改名や削除の後も古い名前を指し続けます。Excel と同じです。レポートが INDIRECT 用にシート名を組み立てるなら、名前は改名と一緒に自分のコードが更新するセルに置きましょう。角括弧のブックインデックスを伴う qualifier、[1]Data!A1 のようなものは、たまたま Data というシートを持つ外部ブックに属し、ローカルの Data シートの改名は触れてはいけません。[ を含む引用符付き qualifier も同じ扱いです。さらに #REF! 自体は REF という名の qualifier の形をしています。HotXLS が REF! をシート名として扱う前に前の # を検査するのはこのためです。この検査がなければ、REF という名のシートの改名がブック中のすべての #REF! を書き換えます

HotXLS の Delphi XLSX ブックでの TXLSXWorksheet.Name 改名。セル、条件付き書式、データ入力規則、テーブル、defined name、チャートの式を書き換え、3D スパンは 'Mid:2025'!A1 と全体で引用符を付け、INDIRECT の文字列リテラル、外部 [1] qualifier、#REF! には触れない
Keep を 2025 に改名すると、ブック中のすべての式の居場所へ届きます。一方、文字列リテラル、外部ブックの qualifier、#REF!、無関係な qualifier は元のテキストのままです

生成した式を壊しやすいシート名は、空白や句読点を含む名前ではなく、FY2024、Q1、2025 のようなありふれた名前です。Delphi コードが式テキストを組み立てる場所では、どこでも XlsSheetNameNeedsQuotes で引用しましょう。その先では、HotXLS Delphi spreadsheet component での改名や削除は、すべての式を Excel 自身が保存したはずの形に保ちます