MENU

【VBA→GAS検証】Drive経由方式で失われたExcelのスピルはUNIVERSAL_WRAPで復元できるか

Drive経由方式でスピルを復元できるか|GAS
目次

はじめに

親ファイル方式でExcelインポート時にスピルを自動ラップするでは、Google検索のAIモード回答で紹介されていた運用フロー——GASコードを仕込んだ「親ファイル」にExcelファイルをインポートし、メニューから万能カスタム関数UNIVERSAL_WRAPを自動適用する——を実機検証しました。この方式は、Excelデータのインポート先に、あらかじめスクリプトを仕込んでおくという前提のおかげで、カスタム関数のスコープ制約(カスタム関数はスクリプトが紐づいたスプレッドシート上でしか呼び出せない)を回避できていました。

一方、GASでローカルファイルを取り込む|Drive経由方式では、HTMLダイアログでアップロードしたファイルをGoogleドライブ上で新規のスプレッドシートとして自動変換する方式を採用しています。この「新規に作られる一時ファイル」には当然スクリプトが紐づいていないため、前回のように「あらかじめスクリプトを仕込んだ親ファイルにインポートする」という前提がそのままでは成立しません。

今回は、前回確立した自動ラップの仕組みを、Drive経由方式の自動アップロードパイプラインに組み込めるかを検証します。同記事で見つかった「変換時にスピル先が空白になる」問題への対応策として、一連のスピル関連の検証を締めくくる回になります。

全体像:検証したいこと

素朴に考えると、「Drive経由方式の転記処理の直後に自動ラップ関数を呼べばよいのでは」と思えます。しかし実際に設計してみると、1つの壁にすぐ突き当たりました。

  1. Drive経由方式で一時ファイル(変換されたスプレッドシート)が作られる
  2. この一時ファイルは、Drive APIで新規作成されただけのファイルであり、GASのスクリプトが一切紐づいていない(コンテナバインドスクリプトが存在しない)
  3. GASのカスタム関数(UNIVERSAL_WRAP)は、そのスクリプトが紐づいたスプレッドシート上でしか使えない
  4. つまり 1. の一時ファイル上で「=UNIVERSAL_WRAP(…)」という数式を書き込んでも、呼び出せる関数が存在せず #NAME? エラーになるはず。

この仮説どおり、一時ファイル上で直接UNIVERSAL_WRAPを使おうとすると成立しません。対策として、一時ファイルからは数式のテキストだけを取り出し、スクリプトが紐づいた書き込み先シート側で数式として書き込み直す設計にしました。実装を進める中でもう1つ壁が見つかりましたが、詳しい経緯はStep1で解説します。

準備

今回はゼロから環境を作りません。GASでローカルファイルを取り込む|Drive経由方式で作成したプロジェクト——Drive APIが有効化済みで、showUploadDialog()・importFileViaDrive()・UploadDialog.htmlが揃っているもの——を、引き続き使います。

「取込先」シートについての変更点: 今回はシート名を指定しない場合に全シートを取り込めるようにするため、固定の「取込先」シート1枚に書き込む方式をやめ、取り込んだシートと同じ名前のシートを自動作成(既にあれば中身をクリアして再利用) する方式に変更します。そのため、同記事の準備③で作った「取込先」シートは今回使いません(残っていても問題はありません)。

このプロジェクトのCode.gsを、次のStep1に掲載する完全なコードでまとめて更新します。個別にコピーする関数を選ぶ必要はありません。

貼り付け方: Step1のコードブロックを全部選択してコピーし、Code.gsの既存のimportFileViaDrive関数を削除してから、その場所に貼り付けてください(関数名が同じなので、削除せずに追記すると関数が2つ定義された状態になり、後に書いた方が有効になりますが紛らわしいため、削除してから貼り付けることをおすすめします)。showUploadDialog()とUploadDialog.htmlは変更不要なので、そのまま残しておいてください。

UploadDialog.htmlは1箇所だけ表記を直します。 sheetName未指定時の挙動が「先頭シートのみ」から「全シート」に変わったため、シート名入力欄のラベルを実際の挙動に合わせます。

実際のアップロードダイアログの画面(「取り込むシート名(Excelファイルのみ・省略時は全てのシート)」の表示に変更

Step1:一時ファイルの数式を書き込み先へ移植する実装

Code.gsに貼り付ける完全なコードです。importFileViaDrive本体に加え、前回の記事のUNIVERSAL_WRAP関連の関数もすべて含めています(このプロジェクトにはまだ存在しないため)。

実装は何度か作り直しました。「先に静的な値を書き込んでから数式で上書き」する案は、XLOOKUP・UNIQUE・SEQUENCEのようにDrive変換時点で正しい値が残っている関数がスピル先を「既存データあり」と誤判定され#REF!になりました。順序を逆にしても、UNIVERSAL_WRAPの計算結果は同じスクリプトの実行中には読み取れないという壁にぶつかり、待ち時間を延ばしても解決しませんでした。

最終的に、スピルの展開を待つ・確認するという発想自体をやめました。対象の数式はARRAY_CONSTRAIN(...,行数,列数)で展開サイズが明示されているため、getSpillFootprint()でこれを読み取り、数式書き込みと同時にスピル範囲を確保する方式にしたところ、全関数を生きた数式として復元できました。

書式も引き継ぎたいという要望からRange.copyTo()を試しましたが、別スプレッドシート間では使えないと判明。シート単位でコピーできるSheet.copyTo()に切り替え、値・数式・書式を一括で引き継いだうえで、対象数式のスピル先だけをクリアして書き換える方式にしました。

最後に、元のExcelで実際に#スピル!だったケースを検証すると、XLOOKUP・UNIQUE・SEQUENCEはブロック解消後も本来のサイズまで展開されませんでした。原因は、Drive変換がブロック時にARRAY_CONSTRAINの申告サイズ自体を小さい暫定値へ書き換え、エラーなく成功したように見せるためでした(FILTER・SORTは_xlws.という別問題で常にエラー扱いになり、この設計でも偶然うまくいっていただけでした)。そこで「元がエラーだったか」の判定をやめ、ARRAY_CONSTRAINは常に取り除きつつクリア範囲だけは申告サイズを使う設計に統一し、データ消失なくブロック解消後に正しく展開されることを確認しました。

// EXPAND・MAXIFSは前回の記事で「自動ラップでは解決できない真の限界」と判明したため対象外にしている
const TARGET_SPILL_FUNCTIONS = ['XLOOKUP', 'FILTER', 'UNIQUE', 'SORT', 'SEQUENCE'];

function importFileViaDrive(base64Data, fileName, mimeType, sheetName) {
  const decoded = Utilities.base64Decode(base64Data);
  const blob = Utilities.newBlob(decoded, mimeType, fileName);

  const resource = { name: fileName, mimeType: MimeType.GOOGLE_SHEETS };
  const convertedFile = Drive.Files.create(resource, blob);

  try {
    const tempSs = SpreadsheetApp.openById(convertedFile.id);

    // シート名を指定した場合はそのシートだけ、未指定なら全シートを取り込む
    let tempSheets;
    if (sheetName) {
      const s = tempSs.getSheetByName(sheetName);
      if (!s) throw new Error(`シート「${sheetName}」が見つかりませんでした。`);
      tempSheets = [s];
    } else {
      tempSheets = tempSs.getSheets();
    }

    const targetSs = SpreadsheetApp.getActiveSpreadsheet();
    let totalRows = 0;
    let totalWrapped = 0;

    tempSheets.forEach(function(tempSheet) {
      const lastRow = tempSheet.getLastRow();
      const lastCol = tempSheet.getLastColumn();
      if (lastRow === 0 || lastCol === 0) return;

      // シートを丸ごとコピーする(値・数式・書式すべて含む)。
      // Range.copyTo()は同一スプレッドシート内でしか使えないため、
      // 別のスプレッドシートへもシートごと複製できるSheet.copyTo()を使う。
      const desiredName = tempSheet.getName();
      const existing = targetSs.getSheetByName(desiredName);
      if (existing) {
        targetSs.deleteSheet(existing);
      }
      const targetSheet = tempSheet.copyTo(targetSs);
      targetSheet.setName(desiredName);

      const values = targetSheet.getRange(1, 1, lastRow, lastCol).getValues();
      const formulas = targetSheet.getRange(1, 1, lastRow, lastCol).getFormulas();

      // 対象の数式が入っているセルだけを見つけて、クリーニングしてから書き換える。
      // それ以外のセル(数式ではない通常のデータ)は、コピーした時点の値・書式のまま。
      let wrappedCount = 0;
      for (let r = 0; r < formulas.length; r++) {
        for (let c = 0; c < formulas[r].length; c++) {
          let formula = formulas[r][c];
          if (!formula) continue;

          let cleaned = formula.replace(/_xlws\.|_xlfn\./g, '');
          cleaned = rewriteFilterIfEmptyToIferror(cleaned);

          const hasTarget = TARGET_SPILL_FUNCTIONS.some(fn =>
            new RegExp(fn + '\\s*\\(', 'i').test(cleaned)
          );
          if (!hasTarget) continue;

          // ARRAY_CONSTRAINの行数・列数は常に信用せず取り除く(詳細は上記の解説参照)。
          // クリアする範囲だけは申告値をそのまま使い、無関係なデータを誤って消さないようにする。
          const footprint = getSpillFootprint(cleaned) || { rows: 1, cols: 1 };
          const finalFormula = stripArrayConstrain(cleaned);

          // スピル先の範囲をクリア(clearContent()は値だけを消し、書式は残す)してから数式を書き込む。
          // コピーしてきた静的な値が残っていると、生きた数式がスピルできず#REF!になるため。
          targetSheet.getRange(r + 1, c + 1, footprint.rows, footprint.cols).clearContent();
          const wrapped = '=UNIVERSAL_WRAP(' + finalFormula.substring(1) + ')';
          targetSheet.getRange(r + 1, c + 1).setFormula(wrapped);
          wrappedCount++;
        }
      }

      totalWrapped += wrappedCount;
      totalRows += lastRow;
    });

    return `${tempSheets.length}シート・合計${totalRows}行のデータを取り込みました(自動ラップ:${totalWrapped}件)。`;
  } finally {
    Drive.Files.remove(convertedFile.id);
  }
}

/**
 * FILTER(...)の第3引数が文字列リテラルなら、FILTER呼び出し自体を
 * IFERROR(FILTER(残りの引数), 元の文字列) に書き換える(詳しくは前回の記事のStep4参照)。
 */
function rewriteFilterIfEmptyToIferror(formula) {
  const upper = formula.toUpperCase();
  const idx = upper.indexOf('FILTER(');
  if (idx === -1) return formula;

  const openIdx = idx + 'FILTER'.length;
  const closeIdx = findMatchingParen(formula, openIdx);
  if (closeIdx === -1) return formula;

  const argsText = formula.substring(openIdx + 1, closeIdx);
  const args = splitTopLevelArgs(argsText);

  if (args.length >= 3 && /^"[^"]*"$/.test(args[args.length - 1].trim())) {
    const ifEmptyText = args[args.length - 1].trim();
    const newArgs = args.slice(0, args.length - 1);
    const newFilterCall = 'IFERROR(FILTER(' + newArgs.join(',') + '),' + ifEmptyText + ')';
    const rebuilt = formula.substring(0, idx) + newFilterCall + formula.substring(closeIdx + 1);
    return rewriteFilterIfEmptyToIferror(rebuilt);
  }
  return formula;
}

function findMatchingParen(str, openIdx) {
  let depth = 0;
  let inQuote = false;
  for (let i = openIdx; i < str.length; i++) {
    const ch = str[i];
    if (ch === '"') {
      inQuote = !inQuote;
    } else if (!inQuote) {
      if (ch === '(') depth++;
      else if (ch === ')') {
        depth--;
        if (depth === 0) return i;
      }
    }
  }
  return -1;
}

function splitTopLevelArgs(argsText) {
  const args = [];
  let depth = 0;
  let inQuote = false;
  let current = '';
  for (let i = 0; i < argsText.length; i++) {
    const ch = argsText[i];
    if (ch === '"') {
      inQuote = !inQuote;
      current += ch;
    } else if (!inQuote && ch === '(') {
      depth++;
      current += ch;
    } else if (!inQuote && ch === ')') {
      depth--;
      current += ch;
    } else if (!inQuote && ch === ',' && depth === 0) {
      args.push(current);
      current = '';
    } else {
      current += ch;
    }
  }
  if (current.length > 0) args.push(current);
  return args;
}

/**
 * ARRAY_CONSTRAIN(...,行数,列数)の最後の2引数から、スピルする行数・列数を取り出す。
 * ARRAY_CONSTRAINで囲まれていない数式の場合はnullを返す(呼び出し側で1x1として扱う)。
 */
function getSpillFootprint(formula) {
  const upper = formula.toUpperCase();
  const idx = upper.indexOf('ARRAY_CONSTRAIN(');
  if (idx === -1) return null;

  const openIdx = idx + 'ARRAY_CONSTRAIN'.length;
  const closeIdx = findMatchingParen(formula, openIdx);
  if (closeIdx === -1) return null;

  const argsText = formula.substring(openIdx + 1, closeIdx);
  const args = splitTopLevelArgs(argsText);
  if (args.length < 3) return null;

  const rows = parseInt(args[args.length - 2].trim(), 10);
  const cols = parseInt(args[args.length - 1].trim(), 10);
  if (isNaN(rows) || isNaN(cols)) return null;

  return { rows: rows, cols: cols };
}

/**
 * ARRAY_CONSTRAIN(元の式,行数,列数)から、ARRAY_CONSTRAINだけを取り除いて元の式を返す。
 * 元のExcelファイルの時点でエラーだった数式は、ARRAY_CONSTRAINの行数・列数自体が
 * ブロックされていた時点の暫定値である可能性があるため、信用せずに取り除く。
 */
function stripArrayConstrain(formula) {
  const upper = formula.toUpperCase();
  const idx = upper.indexOf('ARRAY_CONSTRAIN(');
  if (idx === -1) return formula;

  const openIdx = idx + 'ARRAY_CONSTRAIN'.length;
  const closeIdx = findMatchingParen(formula, openIdx);
  if (closeIdx === -1) return formula;

  const argsText = formula.substring(openIdx + 1, closeIdx);
  const args = splitTopLevelArgs(argsText);
  if (args.length < 1) return formula;

  const inner = args[0].trim();
  return formula.substring(0, idx) + inner + formula.substring(closeIdx + 1);
}

/**
 * 単一の値・2次元配列(スピル結果)のどちらが渡されても、同じロジックで一括処理して返す万能カスタム関数。
 * @customfunction
 */
function UNIVERSAL_WRAP(inputData) {
  if (!Array.isArray(inputData)) {
    return cleanValue(inputData);
  }
  return inputData.map(function(row) {
    return row.map(cleanValue);
  });
}

function cleanValue(value) {
  if (typeof value === 'string') {
    return value.replace(/^[\s ]+|[\s ]+$/g, '');
  }
  return value;
}

実機検証の結果は、次の「できること・できないことの整理」にまとめています。SORTが当初#NAME?だったのも、やはり_xlws.プレフィックスが原因だったことが確認できました(Drive経由方式の記事本体の訂正どおり)。

できること・できないことの整理

項目結果
FILTER・SORT・XLOOKUP・UNIQUE・SEQUENCEのスピル復元(すべて生きた数式として)✅ 実機確認済み
EXPAND・MAXIFSへの対応❌ 前回の記事と同じ「真の限界」、対象外
複数シートの一括取り込み(シート名未指定時)✅ 実機確認済み
元のExcelの書式(背景色・フォント・罫線・数値の表示形式など)の引き継ぎ✅ 実機確認済み
元のExcelファイルで#スピル!になっていたセルの安全性(データを消さないか)✅ 実機確認済み
ブロック解消後に本来のサイズまで展開されるか✅ 実機確認済み
通常の数式・値への影響✅ シート丸ごとコピーのため、対象外のセルは一切変更されないことを実機確認済み
大量データでの処理速度❓未検証

よくある質問

Q. なぜ一時ファイル上で直接UNIVERSAL_WRAPを使わないのですか?

→ GASのカスタム関数は、そのスクリプトが紐づいている(コンテナバインドされている)スプレッドシート上でしか呼び出せない仕様のためです。Drive APIで新規作成される一時ファイルにはスクリプトが一切紐づいていないため、そのまま使うと#NAME?エラーになります。

Q. ARRAY_CONSTRAINの行数・列数をそのまま信用してはいけないのですか?

→ はい。通常は正しい値ですが、元のExcelファイルでスピルがブロック(#スピル!)されていた場合、Drive変換時にこの数値自体がブロックされた時点の小さい暫定値に書き換えられてしまうことがあります。しかもこの場合エラーにはならず一見成功したように見えるため、エラーの有無では判定できません。対策として、クリアする範囲にはこの申告値をそのまま使いつつ(安全な範囲だけ確保する)、書き込む数式からはARRAY_CONSTRAINを常に取り除き、ネイティブなスピル機能にサイズ決定を任せています。

親ファイル方式との比較

前回の記事の親ファイル方式と今回のDrive経由方式は、同じUNIVERSAL_WRAPを使いながらアプローチがまったく異なります。

観点親ファイル方式Drive経由方式
事前準備親ファイルを1つ用意するだけDrive APIの有効化とHTMLダイアログの作成が必要
毎回の操作「ファイル→インポート」+メニュークリック(onChangeで自動化可能)ダイアログでファイル選択→ワンクリックで完結
取込先の扱いインポート先のシートをそのまま書き換えSheet.copyTo()で元のシート名のシートを都度作成
書式の引き継ぎ標準インポート機能がもとから書式ごと持ってくるSheet.copyTo()で書式ごとコピーする実装が必要だった

#スピル!保護とARRAY_CONSTRAIN除去の設計はどちらも同じです(発見したのはDrive経由方式が先で、後から親ファイル方式にも反映しました)。準備の手軽さなら親ファイル方式、完全自動のワンクリック運用ならDrive経由方式、という使い分けになります。

まとめ

  • Drive経由方式でExcelファイルを取り込んだ直後に、前回作った自動ラップの仕組みを組み込み、FILTER・SORT・XLOOKUP・UNIQUE・SEQUENCEをすべて生きた数式としてスピル復元できることを実機確認した(設計の変遷はStep1参照)
  • カスタム関数は紐づいたスクリプトのスコープでしか使えないため、一時ファイル上で直接使うのではなく、書き込み先シートに数式を移植してから使う設計にした
  • EXPAND・MAXIFSは前回の記事と同じく自動ラップでは解決できない真の限界であることが、Drive経由方式でも変わらず確認できた
  • シート名未指定時に複数シートを一括で取り込めるようにし、取込先も元のシート名で自動作成する方式に変更した
  • 元のExcelの書式もそのまま引き継がれ、#スピル!だった場合もデータを失わずブロック解消後は本来のサイズまで展開されることを実機確認した

次回予告

これで、Drive経由方式・親ファイル方式の両方でスピル関連の検証は一区切りとなりました。次回以降は、VBAからの移行で実務に役立つ別のテーマを引き続き取り上げていく予定です。

サンプルファイルについて

この記事で検証したスプレッドシートをこちらからコピーできます。GASのスクリプトはスプレッドシートに紐づいた状態(コンテナバインド型)で保存されているため、このシートをコピーすればスクリプトごとそのまま複製されます。

サンプルスプレッドシートを開く(コピーして使用)

↑ リンク先ファイルのURL末尾を/copyにした「コピー用リンク」にしています。クリックするだけでご自分のGoogleドライブにスプレッドシートのコピーが複製(GASスクリプトごと)されるようになっています。

関連記事

当サイトの記事で使用したVBAなどのサンプルをDLできます

この記事のサンプルファイルはリンクからコピーしてください!

ダウンロードページへは下のカードをクリックすればジャンプできます。
よろしければご利用ください!

よかったらシェアしてね!
  • URLをコピーしました!
目次