DTPab

DTPにまつわるあれこれ

論文執筆者と掲載ページの一覧をGASでまとめてtxtファイルとしてダウンロードできるようにしてみた

やったこと

執筆者、その読みがな、論文掲載ページ(通巻ノンブル)、掲載号、という4つの要素が1シートにまとめられた(図1)データから、同じ執筆者であれば掲載ページ番号をカンマでつなげてまとめた上で五十音順にしたい、と言われてGoogle SheetとGASでそれを実装した話です(図2)。

f:id:uske_S:20201020103702p:plain
図1:Google Sheetの内容

f:id:uske_S:20201020194904p:plain
図2:ダウンロードしたtxtファイル

こんな感じで、

  • 執筆者を五十音順(昇順)に並べる
  • 同じ執筆者の場合はノンブルをまとめる
  • 掲載号は()で括ってノンブル末尾につける

という仕様でテキストを整形しました。

動作画面

シートに置いたボタンを押すと、シートの情報が整形されたtxtファイルをダウンロードできます。

f:id:uske_S:20201020195705g:plain
書き出しからダウンロードまでの一部始終

このくらい簡単にソートしたテキストがダウンロードできます……。

事前に準備しておくもの

  • 統一書式の執筆者名と正しい読みがなの一覧
  • Googleアカウント
  • Google Apps Scriptのセキュリティ設定

統一書式の執筆者名というのは、例えば「北沢志保」と「北沢 志保」などが混在しないこと、執筆者名が一意であることです。
読みがなについては、今回は先方にすべて用意してもらいました。機械的な変換やAPIによる処理などは挟んでいません。これが正しくないと五十音順に並べたときに辻褄が合わなくなります。
Googleアカウントはスクリプトを実行する際に必要になります。またGASの初回実行時には認証を求められます(初回実行時*1に必ず行う必要があります)。他所様のサイトなどを参照ください*2

Google Sheetでの作業

ひとつのシートにすべての情報をまとめて貼り付けます。
このとき、執筆者、その読みがな、論文掲載ページ(通巻ノンブル)、掲載号という順序を必ず守っておいてください

スクリプトエディタでの作業

Google Apps Script

ツールメニューからスクリプトエディタを起動し、GASを書きます。

function getData() {
    const ss = SpreadsheetApp.getActiveSheet();
    const myDataRange = ss.getDataRange();
    myDataRange.sort(2);
    const myValues = myDataRange.getValues();
    const myResult = {};
    myValues.forEach(elem => {
        const myKey = elem[0] + elem[1];
        if (!myResult[myKey]) { //漢字+読みをキーにした連想配列を作る
            myResult[elem[0] + elem[1]] = {
                name: elem[0],
                yomi: elem[1],
                nombre: [`${elem[2]}(${elem[3]})`]
            };
        } else {
            myResult[myKey].nombre.push(`${elem[2]}(${elem[3]})`);
        }
    });
    const temp = [];
    for (let k in myResult) {
        temp.push(`${myResult[k].yomi.slice(0, 1)}\t${myResult[k].name}\t${myResult[k].nombre}`);
    }
    return temp.join("\n");
}

function main() {
    // dialog.html をもとにHTMLファイルを生成
    // evaluate() は dialog.html 内の GAS を実行するため( <?= => の箇所)
    const html = HtmlService.createTemplateFromFile("dialog").evaluate();
    SpreadsheetApp.getUi().showModalDialog(html, "ファイルダウンロード");
}

スクリプトの解説は後述します。

HTML

ダウンロードのUIを表示させるためにHTMLファイルを作成します。
スクリプトエディタのファイルメニューからNew>HTMLファイルとたどってください。

f:id:uske_S:20201019170221p:plain
HTMLファイルの作成

ファイル名は「dialog」とでもしましょう(拡張子は勝手に付きます)。そうすると最低限の内容だけ記述された雛形が生成されますが、一旦消して下のコードを貼り付けます。

<!DOCTYPE html>
<html>

<head>
    <base target="_top">
    <script type='text/javascript'>
        function handleDownload() {
            var content = <?= getData(); ?>; // 出力データをGASから取得する
            var blob = new Blob([content], { "type": "text/csv" });
            document.getElementById("download").href = window.URL.createObjectURL(blob);
        }
    </script>
</head>

<body>
    <a id="download" href="#" download="sakuin.txt" onclick="handleDownload()">ダウンロード</a>
</body>

</html>

実行する

一旦スクリプトエディタからmain関数を実行し、Google Sheetのほうを見に行ってください。そうするとダウンロード用のダイアログが表示されているはずです。ここでうまく行っていなければコードが間違っていますので、改めてコピペしてください。動作ボタンの実装は最後に行います。

コード解説

GASの解説

スプレッドシートのソートと情報の取得

const ss = SpreadsheetApp.getActiveSheet();
const myDataRange = ss.getDataRange();
myDataRange.sort(2);
const myValues = myDataRange.getValues();

この部分までがスプレッドシートのソートと情報の取得になります。

SpreadsheetAppがスプレッドシートのホストオブジェクトで、各メソッドやオブジェクトはこれを起点にしていきます。SpreadsheetApp.getActiveSheet()メソッドで現在アクティブなスプレッドシートを取得します。
続いてSheet.getDataRange()メソッドでスプレッドシートのデータ範囲を取得、要するに入力されているデータ範囲を特定しています。
そうして取得したRangeオブジェクトに対してRange.sort()メソッド*3を使って読みがなを基準にデータを昇順にソートしています。

データの整形

const myResult = {};
myValues.forEach(elem => {
    const myKey = elem[0] + elem[1];
    if (!myResult[myKey]) { //漢字+読みをキーにした連想配列を作る
        myResult[elem[0] + elem[1]] = {
            name: elem[0],
            yomi: elem[1],
            nombre: [`${elem[2]}(${elem[3]})`]
        };
    } else {
        myResult[myKey].nombre.push(`${elem[2]}(${elem[3]})`);
    }
});

この部分でスプレッドシートから取得した情報を欲しい形に整形しています。
ポイントとしては、myResult変数を著者名+読みがなという一意のキーを持つ連想配列にしたところです。

const myKey = elem[0] + elem[1];
if (!myResult[myKey]) { //漢字+読みをキーにした連想配列を作る

注意点などは過去の記事を参照ください。要するにこの部分はちょっと手を抜いています。ただそれでも「著者名+読みがな」というキーがユニークであることを想定していますので、そこそこの精度ではないかと思います。

このif-else文で、myResult変数にまだ著者+読みがなのキーが存在しなければ新たに作成、すでに存在していればそのnombreプロパティに値を追加しています。

if (!myResult[myKey]) { //漢字+読みをキーにした連想配列を作る
    myResult[elem[0] + elem[1]] = {
        name: elem[0],
        yomi: elem[1],
        nombre: [`${elem[2]}(${elem[3]})`]
    };
} else {
    myResult[myKey].nombre.push(`${elem[2]}(${elem[3]})`);
}

ただあんまりかっこいい実装じゃないので今後の検討課題です。

Array.forEach()メソッドやテンプレートリテラルについてはMDNなどを適宜参照ください。

吐き出すテキストとして整形

const temp = [];
for (let k in myResult) {
    temp.push(`${myResult[k].yomi.slice(0, 1)}\t${myResult[k].name}\t${myResult[k].nombre}`);
}
return temp.join("\n");

最後のこの部分でmyResult変数に格納した内容を配列に落とし込み、すべてをArray.join()してreturnしています。
おいおい、for-in文って順序の問題は大丈夫なのかよと思われるかもしれませんが、たぶん大丈夫(ぉぃ
実際、ES2020ではfor-in文の順序が固定される*4のであまり心配していません…(甘いか?)

HTML側から実行するmain関数

function main() {
    // dialog.html をもとにHTMLファイルを生成
    // evaluate() は dialog.html 内の GAS を実行するため( <?= => の箇所)
    const html = HtmlService.createTemplateFromFile("dialog").evaluate();
    SpreadsheetApp.getUi().showModalDialog(html, "ファイルダウンロード");
}

ここがGASらしい実装になっています。
HtmlTemplate.evaluate()メソッドは、読み込んだHtmlTemplateオブジェクトをHTMLとしてパースするような動作をします(詳しくはわかってない)。より具体的には、後述するHTML側のスクリプトレット<?= =>部分にGAS側の関数が読み込まれた上でHTMLとしてブラウザが解釈します。
SpreadsheetApp.getUI()メソッドではGASのUI制御を行うことができるようです(詳しくは末尾の参考資料を参照)。モーダルダイアログやモードレスダイアログを生成できます。Ui.showModalDialog()メソッドの第一引数にはHtmlOutputクラスを指定します。今回は dialog.html をevaluate()した内容を代入した変数htmlを渡しています。第二引数はダイアログのタイトルになります。

以上でGASの説明はおしまいです。

HTMLの解説

scriptタグ

<script type='text/javascript'>
    function handleDownload() {
        var content = <?= getData(); ?>; // 出力データをGASから取得する
        var blob = new Blob([content], { "type": "text/csv" });
        document.getElementById("download").href = window.URL.createObjectURL(blob);
    }
</script>

今回のキモはなんといってもここでしょう。
<?= =>という特殊な表現がありますが、これがGASのスクリプトレットというものです。スクリプトレットには下記の3種類があります。

  • Standard scriptlets
  • Printing scriptlets
  • Force-printing scriptlets

今回の<?= =>はPrinting scriptletsに当たります。僕も100%理解できているわけではないので、詳細は末尾の参考資料を参照してください。
こうすることで、GAS側のgetData()関数がここで参照され、returnした内容がcontent変数に代入されます。めちゃくちゃ便利ですね。天才の所業。
あとは、得られた文字列をBlobクラスに渡してテキストとして取り出せるようにした上で、URLを生成しています。このあたりは定型句みたいなものなので、よくわからなければこういうものだと思ってもらえればいいのかなという気がします。

bodyタグ

<body>
    <a id="download" href="#" download="sakuin.txt" onclick="handleDownload()">ダウンロード</a>
</body>

解説するまでもないのですが、単に「ダウンロード」というリンクを生成しています。URL(href)は先のJSで生成したものが充てがわれる、というわけです。めちゃくちゃ便利ですね。天才の所業。

HTMLの解説は以上です。

実行ボタンを作成する

この記事などを参照ください。

uske-s.hatenablog.com

スプレッドシート側で図形を作成・挿入してmain関数を設定するだけです。めちゃくちゃ便利ですね。天才の所業(しつこい)。

さいごに

ちょっと長くなりましたが、索引のソートっぽいことをGASで処理する一例を紹介しました。
連想配列を使った一意のキーの取得とか、temp変数に逐一Array.push()していくのとか、けっこう泥臭いというかあまり美しい処理ではないので気に入らないのですが、ひとまず動いたので記事として公開した次第です。もっとこうしたほうがいいよ! みたいなツッコミがあればぜひお願いします。

余談ですが、WEBアプリケーションとしてAPI公開しておくことで、Socketオブジェクトやシェルのcurlコマンドなどを経由してInDesignからもこの処理を叩くことは可能です*5

今回実装して思いましたが、GASは本当によくできていて便利だなと思いました。仕組みから理解しようとするとなかなか難しいのですが、見かけ上のやり方を真似るだけで非常に簡単に強力な自動化ツールを作ることができます。ExcelでVBSやAppleScriptを組むよりも何倍も簡単だし精神衛生上も大変よいです。ガシガシGAS使いましょう。

僕はいい加減clasp導入しようと思います…。GASがV8エンジンになったことで新しいJavaScriptを利用できることになったはいいものの、標準スクリプトエディタのシンタックスハイライトやインデント仕様がダメすぎてストレスがすごかったですw そんでもってclasp導入してGitHubでバージョン管理したい。

参考資料

*1:スクリプトの内容によって「こういう権限をスクリプトに付与していいですか?」という確認が行われるため、スクリプトを改変した際に改めて認証を求められることがあります

*2:例えばこちらなど:【初心者向けGAS】スクリプト実行時の「承認」でびっくりしないために

*3:Range.sort()メソッドのリファレンス:Class Range  |  Apps Script  |  Google Developers
整数を引数として渡すと、その列を基準にソートを行います(今回は2列目を基準にソートしました)。このメソッドに限らず、列や行を指定する場合はインデックスが1から始まります。A1やB12など、スプレッドシートらしい参照になるためです。またこのメソッドは標準で昇順ソートです。降順ソートにしたい場合はリファレンスにあるように ascending プロパティを設定します

*4:ICSメディアさんのこちらの記事など参照:https://ics.media/entry/200128/

*5:手前味噌ですが、僕の執筆した同人誌でこのあたりの処理を実装しています:
DTP Automation Tech Magazine vol.1「InDesign × Google Apps Script ―ルビ振りAPI編」 - DTPab on BOOTH - BOOTH