はじめに
前回の記事の続きというか,実際には下記記事の前日譚に当たります。
上記の記事の執筆中に Spreadsheet Compare でエラーになる Excel ファイルの原因を究明するため AI に尋ねたところ「名前定義」が破損している可能性を指摘されました。
※AI の回答については本記事の末尾を参照下さい。
他の皆さんは VBA マクロで「名前」を削除する機能を作成して実行しているようですが,マクロを組み込むとファイルの拡張子を *.xlsm や *.xlsb に変える必要があるなど色々面倒なので,今回自分は外部スクリプト WSH (JavaScript) で実現することにしました。
スクリプトを作成して調べてみると,非表示状態の名前や参照先のない名前が数百個見つかりました。残念ながら,それらの名前を全て削除しても Spreadsheet Compare でエラーになる問題自体は解決できなかったのですが,このスクリプトの作成を通じて得られた知見が有用だったので,別の記事としてまとめることにしました。
先に結論を書く
Excel にはセル範囲に任意の名前を付ける機能があり,特に数式を記述する際には $A$1 のようなセルアドレスを指定するよりも分かり易いので重宝しています。
Excel ではユーザが設定した名前以外にもシステムが自動的に生成する _FilterDatabase,Print_Area,Print_Titles などの名前1の他に _xlfn.,_xleta.,_xlpm. 等のプリフィクスの付いた名前が生成されることがあります。これらの名前は非表示設定になっているものがあり,その場合は「名前の管理」から削除することはできません。
非表示の名前は VBA マクロ等を用いて表示状態に変更すれば「名前の管理」から削除することができます。もちろん VBA マクロだけで消すことも可能です。
このとき一部の名前は一度の削除では消せず,削除を二度行わないと消せなかったのです。
ということで今回,隠れた名前や参照先のない壊れた名前を削除するスクリプトを作りましたが,上記の Excel のバグ?に対処するため削除を複数回試みるようにしました。
スクリプト仕様案(お品書き)
- コマンドラインから実行する JavaScript (WSH) プログラムとし,名前は
XlsChkName.jsとします。これまで筆者はこのような Excel を操作する外部スクリプトは VBA に近しい VBScript で作成していたのですが,VBScript の使用期限2が迫って来ていることから JavaScript で作成することにしました。 - 使い方は下記のように第一引数が Excel ファイル名,第二引数がコマンド名とします。コマンドは
list,show,delの三つです。 -
delコマンドはさらに第三引数として削除するターゲットを指定します。 - 一回で削除できなかった場合,リトライします。デフォルトのリトライ回数は 2 回としますが,
/Rオプション により変更可能とします。 -
Excel ファイル名にハイフン
-を指定すればアクティブワークブックを選択します。 - Excel の仕様上,同じ名前の Excel ファイルを複数開けないので,指定した Excel ファイルを既に開いているのであればそのファイルを選択し,開いていない場合は新たにオープンします。ファイルが存在しない場合はエラーとします。
実装コード
実装コードを以下に示します。
実装コードはコチラ(JavaScript,約200行)
//------------------------------------------------------------------------------
// グローバル変数
//------------------------------------------------------------------------------
var max_retry = 2; // リトライ回数
//------------------------------------------------------------------------------
// メイン関数の呼び出し
//------------------------------------------------------------------------------
var args = WScript.Arguments.Unnamed;
var opts = WScript.Arguments.Named;
var ret = main(args, opts);
try {
WScript.Quit(ret);
} catch(e) {
/* 何もしない */
}
//------------------------------------------------------------------------------
// メイン関数
//------------------------------------------------------------------------------
function main(args, opts) {
//--------------------------------------------------------------------------
// ヘルプメッセージ
//--------------------------------------------------------------------------
if(args.Count < 2) {
WScript.Echo("Excel ファイルの名前をチェックします。");
WScript.Echo("");
WScript.Echo("XLSCHKNAME(.JS) [Excel ファイル名] (オプション) [コマンド] (ターゲット)");
WScript.Echo("");
WScript.Echo("オプション");
WScript.Echo("/R:[n] リトライ回数を指定します。デフォルトは " + max_retry + " 回です。");
WScript.Echo("");
WScript.Echo("コマンド");
WScript.Echo("list 名前を列挙します。");
WScript.Echo("show 隠れた名前を表示状態に変更します。");
WScript.Echo(" del [hide,unref] 名前を削除します。");
WScript.Echo("");
WScript.Echo("Excel ファイル名をハイフン(-)とするとアクティブワークブックを選択します。");
return -1;
}
//--------------------------------------------------------------------------
// オプションチェック
//--------------------------------------------------------------------------
if(opts.Exists("R")) max_retry = parseInt(opts("R"));
//--------------------------------------------------------------------------
// コマンドのチェック
//--------------------------------------------------------------------------
var filename = args(0);
var command = args(1).toLowerCase();
var target = args.Count >= 3 ? args(2).toLowerCase() : "";
//--------------------------------------------------------------------------
// コマンドの実行
//--------------------------------------------------------------------------
switch(command) {
case "list": return command_list(filename);
case "show": return command_show(filename);
case "del": return command_del (filename, target);
default:
WScript.Echo("コマンド「" + command + "」には対応していません!!");
return -1;
}
return 0;
}
//------------------------------------------------------------------------------
// Excel ファイルを開く
//------------------------------------------------------------------------------
function get_workbook(filename, readonly) {
//--------------------------------------------------------------------------
// EXCELを起動する
//--------------------------------------------------------------------------
var app;
try {
app = GetObject("", "Excel.Application");
} catch(e) {
app = WScript.CreateObject("Excel.Application");
}
if(app == null) {
WScript.Echo("Excel の起動に失敗しました!!");
return null;
}
app.Visible = true;
//--------------------------------------------------------------------------
// ワークブックを開く
//--------------------------------------------------------------------------
var book;
if(filename == "-") {
book = app.ActiveWorkbook;
if(book == null) {
WScript.Echo("アクティブワークブックがありません!!");
return null;
}
WScript.Echo("アクティブワークブックを取得します。");
WScript.Echo(book.FullName);
} else {
var fso = WScript.CreateObject("Scripting.FileSystemObject");
if(!fso.FileExists(filename)) {
WScript.Echo("Excel ファイル「" + filename + "」がありません!!");
return null;
}
var fullpath = fso.GetAbsolutePathName(filename);
for(var i = 1; i <= app.Workbooks.Count; i++) {
var book = app.Workbooks(i);
if(book.FullName.toUpperCase() == fullpath.toUpperCase()) {
WScript.Echo("Excel ファイル「" + filename + "」を選択します。");
return book;
}
}
book = app.Workbooks.Open(fullpath, 0, readonly);
if(book == null) {
WScript.Echo("Excel ファイル「" + filename + "」のオープンに失敗しました!!");
return null;
}
WScript.Echo("Excel ファイル「" + filename + "」をオープンします。");
}
return book;
}
//------------------------------------------------------------------------------
// コマンド LIST
//------------------------------------------------------------------------------
function command_list(filename) {
var book = get_workbook(filename, true);
if(book == null) return -1;
var count = { all:0, hide:0, unref:0 };
for(var i = 1; i <= book.Names.Count; i++) {
var name = book.Names(i);
count.all++;
if(!name.Visible) count.hide++;
if(name.RefersTo.indexOf("#REF!") >= 0) count.unref++;
var a = [name.Name, name.Visible, name.RefersTo];
WScript.Echo(a.join("\t"));
}
WScript.Echo(" 全ての名前:" + count.all);
WScript.Echo(" 隠れた名前:" + count.hide);
WScript.Echo("参照切れの名前:" + count.unref);
return 0;
}
//------------------------------------------------------------------------------
// コマンド SHOW
//------------------------------------------------------------------------------
function command_show(filename) {
var book = get_workbook(filename, false);
if(book == null) return -1;
var count = 0;
for(var i = 1; i <= book.Names.Count; i++) {
var name = book.Names(i);
if(name.Visible) continue;
count++;
var a = [name.Name, name.Visible, name.RefersTo];
WScript.Echo(a.join("\t"));
name.Visible = true;
}
if(count == 0)
WScript.Echo("隠れた名前はありません。")
else
WScript.Echo("隠れた名前 " + count + " 個を表示状態に変更しました。");
return 0;
}
//------------------------------------------------------------------------------
// コマンド DEL
//------------------------------------------------------------------------------
function command_del(filename, target) {
var check = {
hide: function(name) { return !name.Visible; },
unref: function(name) { return name.RefersTo.indexOf("#REF!") >= 0; }
};
if(check[target] == null) {
WScript.Echo("ターゲット「" + target + "」には対応していません!!");
return -1;
}
var book = get_workbook(filename, false);
if(book == null) return -1;
var retry = 0;
for(;;) {
var list = [];
for(var i = 1; i <= book.Names.Count; i++) {
var name = book.Names(i);
if(check[target](name)) list.push(name);
}
if(list.length == 0) {
if(retry == 0) WScript.Echo("削除対象の名前がありません。");
return 0;
}
if(++retry > max_retry) break;
for(var i = 0; i < list.length; i++) {
var name = list[i];
var a = [name.Name, name.Visible, name.RefersTo];
WScript.Echo(a.join("\t"));
name.Delete();
}
WScript.Echo((retry >= 2 ? "(再)" : "") + "削除対象の名前 " + list.length + " 個を削除しました。");
}
WScript.Echo("削除し切れない名前が " + list.length + " 個あります。");
return -1;
}
コード解説
Excel の起動
Excel の起動シーケンスです。既に Excel が起動している場合は GetObject が成功します。起動していない場合は WScript.CreateObject で Excel を立ち上げます。Excel がインストールされていない場合はエラーになります。
var app;
try {
app = GetObject("", "Excel.Application");
} catch(e) {
app = WScript.CreateObject("Excel.Application");
}
if(app == null) {
WScript.Echo("Excel の起動に失敗しました!!");
return null;
}
app.Visible = true;
ワークブックのオープン
Excel ファイルのオープンシーケンスです。Excel ファイル名として filename,読み取り専用フラグとして readonly が与えられているものとします。
- ファイル名がハイフン
-の場合,アクティブワークブックを選択します。アクティブワークブックが存在しない場合はエラーです。 - ファイル名がハイフン
-以外の場合,既にオープンしているワークブックのフルパス名と比較して一致すると,そのワークブックを選択します。一致しない場合はワークブックを新規オープンします。ファイルが存在しない場合はエラーです。
var book;
if(filename == "-") {
book = app.ActiveWorkbook;
if(book == null) {
WScript.Echo("アクティブワークブックがありません!!");
return null;
}
WScript.Echo("アクティブワークブックを取得します。");
WScript.Echo(book.FullName);
} else {
var fso = WScript.CreateObject("Scripting.FileSystemObject");
if(!fso.FileExists(filename)) {
WScript.Echo("Excel ファイル「" + filename + "」がありません!!");
return null;
}
var fullpath = fso.GetAbsolutePathName(filename);
for(var i = 1; i <= app.Workbooks.Count; i++) {
var book = app.Workbooks(i);
if(book.FullName.toUpperCase() == fullpath.toUpperCase()) {
WScript.Echo("Excel ファイル「" + filename + "」を選択します。");
return book;
}
}
book = app.Workbooks.Open(fullpath, 0, readonly);
if(book == null) {
WScript.Echo("Excel ファイル「" + filename + "」のオープンに失敗しました!!");
return null;
}
WScript.Echo("Excel ファイル「" + filename + "」をオープンします。");
}
削除コマンド
代表例として削除コマンドを紹介します。上記の Excel の起動シーケンスおよび Excel ファイルのオープンシーケンスは関数 get_workbook() として呼び出します。
-
Excel の起動前にターゲットのチェックを行います。これはスペルミス等でターゲット名を誤ったときに Excel を起動しないで終了させるためです。ここでターゲットのチェックに連想配列
check[]を使用しますが,後で再利用します。 -
Excel の名前の削除方法にはちょっとしたコツが必要です。まず,Excel のコレクションは 1-origin であること,削除する毎に
Countが減っていくので昇順ループは危険であり,下記のように降順ループにするか,あるいは本実装のようにいったん配列にオブジェクトをコピーしてから削除する必要があります。本実装でわざわざ配列にコピーしてから削除するようにしたのは削除した名前を表示するようにしたからです。他のコマンドでは昇順に名前を表示することもあり,表示順を統一したほうが良いだろうという判断です。 - ループの中では
check[target](name)の呼び出し一発で削除対象の名前か否かをチェックできるようにしました。将来的な拡張も容易です。
function command_del(filename, target) {
var check = {
hide: function(name) { return !name.Visible; },
unref: function(name) { return name.RefersTo.indexOf("#REF!") >= 0; }
};
if(check[target] == null) {
WScript.Echo("ターゲット「" + target + "」には対応していません!!");
return -1;
}
var book = get_workbook(filename, false);
if(book == null) return -1;
var retry = 0;
for(;;) {
var list = [];
for(var i = 1; i <= book.Names.Count; i++) {
var name = book.Names(i);
if(check[target](name)) list.push(name);
}
if(list.length == 0) {
if(retry == 0) WScript.Echo("削除対象の名前がありません。");
return 0;
}
if(++retry > max_retry) break;
for(var i = 0; i < list.length; i++) {
var name = list[i];
var a = [name.Name, name.Visible, name.RefersTo];
WScript.Echo(a.join("\t"));
name.Delete();
}
WScript.Echo((retry >= 2 ? "(再)" : "") + "削除対象の名前 " + list.length + " 個を削除しました。");
}
WScript.Echo("削除し切れない名前が " + list.length + " 個あります。");
return -1;
}
実行例
引数なしで実行するとヘルプメッセージを表示します。
c:\Qiita>xlschkname
Excel ファイルの名前をチェックします。
XLSCHKNAME(.JS) [Excel ファイル名] (オプション) [コマンド] (ターゲット)
オプション
/R:[n] リトライ回数を指定します。デフォルトは 2 回です。
コマンド
list 名前を列挙します。
show 隠れた名前を表示状態に変更します。
del [hide,unref] 名前を削除します。
Excel ファイル名をハイフン(-)とするとアクティブワークブックを選択します。
アクティブワークブックに対して list コマンドを実行した結果です。タブ区切りで名前と表示フラグ,参照先アドレスを表示します。
c:\Qiita>xlschkname - list
_FilterDatabase false =#REF!#REF!
~中略~
あ false =#REF!#REF!
ああああ false =#REF!#REF!
全ての名前:744
隠れた名前:734
参照切れの名前:678
アクティブワークブックに対して del コマンドを実行した結果です。_FilterDatabase だけは一度では消せずリトライしています。
c:\Qiita>xlschkname - del unref
_FilterDatabase false =#REF!#REF!
~中略~
あ false =#REF!#REF!
ああああ false =#REF!#REF!
削除対象の名前 678 個を削除しました。
_FilterDatabase false =#REF!#REF!
(再)削除対象の名前 1 個を削除しました。
もう少し深掘りしてみる
_FilterDatabase という名前自体は Excel でフィルタ機能を使用すると自動的に生成されます。ただし,参照先のない _FilterDatabase という名前をどうやっても再現できず,発生メカニズム自体が謎のままです。ただし,二度消す必要が生じた理由については少しずつ分かってきました。
削除する前の Excel ファイルの中身を ZIP 展開したものを以下に示します。Excel で定義された「名前」は xl/workbook.xml にありますが,どうやら _FilterDatabase という名前が複数存在していたようです。ただし,フィルタ機能はワークシートごとに設定可能なので,これ自体は正常だと思います。
<definedName name="_xlnm._FilterDatabase" localSheetId="1" hidden="1">Sheet1!$A$1:$R$1</definedName>
<definedName name="_xlnm._FilterDatabase" hidden="1">#REF!</definedName>
下記は参照先のない名前を削除した一度目の結果ですが,何故か参照先のない #REF! の名前のほうが残っています。
<definedName name="_xlnm._FilterDatabase" hidden="1">#REF!</definedName>
ちなみに二度目の削除を実行するとすべて消えます。
以上より,一度で消せない名前が存在するのは,同じ名前を誤って削除してしまう Excel のバグ?に起因しているように思います。XML ファイル上では二つの名前は別々に存在していますが,Excel に読み込まれると両者を混同し易いデータ構造になっているのではないかと推察します。
AIに質問した結果
Spreadsheet Compare で「オブジェクト参照がオブジェクトインスタンスに設定されていません。」というエラーについて原因を AI に尋ねた結果を以下示します。本例ではいずれの想定もハズレだった訳です。
✦ AI による概要
切り分けのためにできること
- 問題のファイルを「別名保存」して構造を再生成
Excel は保存時に XML を再構築するため、軽微な破損はこれで治ることがあります。- 名前定義を全チェック
「数式」→「名前の管理」で確認
- #REF!
- 空の参照
- 外部リンク
- シェイプ・コメントを一括削除して試す
コピーして新しいブックに貼り付けると、壊れたオブジェクトが除去されることがあります。- Open XML SDK Productivity Tool で XML を検査
技術的ですが、破損箇所が特定できます。
結局のところ,前回の記事で解明したように日本語ロケール特有の日付書式が原因だったわけですが,どうやって原因を特定したのかというと上記の想定原因を一つ一つ調べました。結果として上記のいずれでもなく,結局のところワークシートを半分ずつ,セルを半分ずつ削除しながら原因個所を特定していった訳です。