GASでXSLTしてみた（XML/XSL→HTML→PDF）

# 課題

Google Apps Script(GAS)単独でXML文書をXSLTし、PDF化することでMacOSでの閲覧を容易にする。

## 技術要素

本記事の対象となる技術要素です。

- macOS Catalina
- Google Apps Script
- @google/clasp 2.3.0
- node.js 10.19.0
- npm 6.13.4
- TypeScript
- [browserify](http://browserify.org/) ^16.5.1
- [gasify](https://github.com/fossamagna/gasify) ^1.0.0
- [backslash47/xslt](https://github.com/backslash47/xslt) 1.1.8
- [backslash47/xmldom](https://github.com/backslash47/xmldom) 0.3.1

## 背景

MacOSではXSLTが必要なXML文書の閲覧が非常に手間です。

XML/XSLファイルは特定のブラウザのみ正しく閲覧することができます。具体的にはInternet Explorer(IE)がそれに当たります。IEにはXSLTという実装がブラウザエンジンに標準で組み込まれています。

しかしながら、執筆時点でMac版のIEは提供されておらず、XSLTが必要なXML/XSLファイルを正しく閲覧することが非常に困難です。FirefoxもバージョンアップによりXSLTが非対応になってしまいました。

2020年4月現在、e-govから発行される公文書ファイルにはXSLTが必要なXML文書が含まれています。
上記の理由によりこれをMacでは容易に閲覧することができません。

※ e-govについては、2020年9月に更改されMac対応される予定とのことです。
[e-Gov更改のお知らせ （2020年9月末）｜電子政府の総合窓口e-Gov イーガブ](https://www.e-gov.go.jp/news/egov/2020/news20200122.html)

上述の通り、現時点ではe-govがMacに対応しておりません。
以前の記事でGASでSmartHRから公文書を自動でダウンロードしてくるスクリプトを組みましたが、ダウンロードされた公文書をMacからも閲覧できるようにしたい、ということで調査する運びとなりました。

[SmartHRの電子申請をGASで最適化した話 - Qiita](https://qiita.com/shikumiya_hata/items/a5b280896dd8632b3a86)

## XSLTとは

以下、Wikipediaより。

> XSL変換（英: XSL Transformations; XSLT）は、W3Cにより標準化されたXML文書の変換用言語である。

XSLTでは、スタイル定義されたXSLファイルと、データが記載されたXMLファイルとを組み合わせ、XHTML形式などの文書を生成することができます。他にもXMLファイル、CSVファイルなどにも変換できます。

# 変換方法

PDFへの変換は以下の手順にて行いました。

1. XSLTライブラリをGASのライブラリ化
2. Google Drive上のXMLファイルとXSLファイルをXSLT
3. XSLTしてできたXHTMLファイルをPDFファイルに変換

## XSLTライブラリをGASライブラリへ

今回、GASライブラリ化するにあたって、PCはMacを、Googleクラウド上へのファイル転送用にclaspを、ライブラリ作成(GAS V8用のJS変換)用にgasifyを使用しました。

GASの標準実装ではXSLTはできないため、node.js用の以下のnpmライブラリを使用しました。

- [backslash47/xslt](https://github.com/backslash47/xslt) 1.1.8
- [backslash47/xmldom](https://github.com/backslash47/xmldom) 0.3.1

ここが今回の肝でした。
HTMLからPDFへの変換はGAS標準で実装されているため、簡単でした。

### ライブラリの選定理由

「npm xslt」の[検索結果](https://www.npmjs.com/search?q=keywords:xslt)から、GAS単独で動作する＝ブラウザ上ではない環境で動作する、という条件でひとつひとつ解析して技術検証を行ったのですが、どれもブラウザありきで動作するものばかりでした。

これはブラウザ標準で用意されているDOMの実装を必要としています。documentやwindowオブジェクトなど。
また、他にも[XSLTProcessor](https://developer.mozilla.org/ja/docs/Web/API/XSLTProcessor)というブラウザに実装されているXSLTの仕組みを用いたライブラリもいくつかありました。

その中で唯一、ブラウザ環境を必要としなかったのが、[xslt-ts](https://www.npmjs.com/package/xslt-ts)でした。
こちらはGoogleのajaxslt projectの成果物を元に作られたようで、TypeScriptで書かれています。

GAS単独で動作できるものとして、このライブラリを選定しました。
XSLTのために、xslt-tsはxdom-tsとセットで動作しますので、これらの2つを用いています。

### GAS(V8)はES2015

既存のnpmライブラリからGASのライブラリを自作するわけですが、npmライブラリは必ずしもES2015以下のバージョンに対応されたものではなく、より最新のバージョンで実装されています。したがって、バージョンを合わせるための変換が必要となります。

これを解決するのが[gasify](https://www.npmjs.com/package/gasify)です。

以下の記事を参考にさせていただきました。（gasifyの作者さま！）

[Google Apps Scriptでrequire()してみる](https://qiita.com/fossamagna/items/7c65e249e1e5ecad51ff)

### ディレクトリ構成

以下は今回のディレクトリ構成です。（Visual Studio Codeを使用）

dev配下には変換前のソースを格納しています。
変換後のソースはsrc配下にmain.jsとして出力され、このmain.jsをGASライブラリのスクリプトとしてアップすることになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/25232838-ac0b-53f6-e9e0-64077dbf4ae5.png)

claspで新規のディレクトリを作成しましょう。
合わせてclasp loginしてください。（ブラウザが起動し、ログイン画面が表示されます）

```
$ clasp create your-library-name
$ cd your-library-name
```

### GASライブラリ化の手順

GASライブラリ化の手順を示します。

#### 1. Macにnode.jsをインストール

Macのnode.jsをインストールします。
今回はnvmを使用しました。

インストール手順は、以下の記事を参考にさせていただきました。
[nvm + Node.js + npmのインストール - Qiita](https://qiita.com/sansaisoba/items/242a8ba95bf70ba179d3)

合わせてclaspもインストールします。

```
$ npm i -g @google/clasp
```

#### 2. npmで必要なライブラリをインストール

本記事のバージョンと合わせるため、以下のpackage.jsonを作成して、一括でインストールしてしまいます。

```json:package.json
{
  "name": "your-library-name",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel": "^6.23.0",
    "babel-core": "^7.0.0-bridge.0",
    "babel-jest": "^25.2.4",
    "babel-preset-env": "^1.7.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-register": "^6.26.0",
    "babelify": "^10.0.0",
    "browserify": "^16.5.1",
    "chai": "^4.2.0",
    "gasify": "^1.0.0",
    "gulp": "^4.0.2",
    "gulp-babel": "^8.0.0",
    "gulp-exec": "^4.0.0",
    "gulp-load-plugins": "^2.0.2",
    "gulp-mocha": "^7.0.2",
    "gulp-plumber": "^1.2.1",
    "mocha": "^7.1.1",
    "vinyl-source-stream": "^2.0.0",
  },
  "babel": {
    "presets": [
      "babel-preset-env"
    ]
  }
}
```

一括でインストールします。（node_modulesディレクトリ配下にインストールされます。）

```
$ npm install
```

### 3. gulpの準備

コンパイルはgulpで行います。browserifyにbabel、gasifyを噛ませて変換してあげてます。

以下のファイルを作成します。

```js:gulpfile.babel.js
import gulp from 'gulp'
import gulpLoadPlugins from 'gulp-load-plugins'
import browserify from 'browserify'
import source from 'vinyl-source-stream'

const $ = gulpLoadPlugins()
const src_files = 'dev/**/*.js'

gulp.task('browserify', () =>
  browserify({
    entries: ['dev/main.js']
  }).transform('babelify')
    .plugin('gasify')
    .bundle()
    .pipe(source('main.js'))
    .pipe(gulp.dest('src'))
)

gulp.task('watch', () =>
  gulp.watch(src_files, gulp.series('browserify'))
)
```

### 4.TypeScript記述のライブラリをトランスパイル

次に、xslt-tsとxmldom-tsをGASコードに組み込んで行きますが、これらのライブラリはGASに対応されていないESバージョンでトランスパイルされているため、自前でトランスパイルします。

https://www.npmjs.com/package/xslt-ts
の「Developing and Testing」の手順に従って、ソースを落としてトランスパイルします。

まずはxslt-tsのソースを落とします。

```
$ git clone 'https://github.com/backslash47/xslt.git'
$ cd xslt
```

次にxslt-tsの依存関係にあるライブラリをインストールします。

```
$ npm install
```

※この時、以下のようなメッセージが出た場合は、npm audit fixを実行してください。

```
run `npm audit fix` to fix them, or `npm audit` for details
```

トランスパイルするESバージョンを変更します。
tsconfig.json中のtargetとlibを「ES5」に変更します。

```json:xslt/tsconfig.json

  "compilerOptions": {
    /* Basic Options */
    "target": "ES5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */,
    "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
    "lib": ["ES5", "dom"] /* Specify library files to be included in the compilation:  */,
 
```

トランスパイルを実行します。

```
$ npm run build
```

distフォルダに変換後のファイルが出力されるので、distごとdev配下に移動します。

```
$ mv dist ../dev/xslt
```

xmldom-tsも同様にトランスパイルを行います。

https://github.com/backslash47/xmldom

```
$ git clone 'https://github.com/backslash47/xmldom.git'
$ cd xmldom-ts
$ npm install
```

tsconfig.jsonをES5に変更します。

```json:xmldom/tsconfig.json
{
  "compilerOptions": {
    /* Basic Options */
    "target": "ES5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */,
    "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
    "lib": ["ES5", "dom"] /* Specify library files to be included in the compilation:  */,
```

```
$ npm run build
```

dist/libフォルダに変換後のファイルが出力されるので、dist/libごとdev配下に移動します。

```
$ mv dist/lib ../dev/xmldom
```

### 5. ライブラリスクリプトを作成

dev配下にmain.jsを作成します。

```js:dev/main.js
var xslt = require("../dev/xslt/index")
var xmldom = require("xmldom")

global.xslt = function() {
  return xslt
}

global.xmldom = function() {
  return xmldom
}
```

### 6. ライブラリソースを出力

gulpでwatchタスクを実行します。

```
$ gulp watch
```

※ Broserifyのタスクが完了すると以下のようなメッセージが出ます。

```
[21:37:52] Finished 'browserify' after 2.44 s
```

watchは変更監視によって変更があった場合にタスクを実行するため、browserifyが実行されていない場合は、main.jsを変更して保存してみましょう。

完了すると、src/main.jsが作成されます。


### 7. Google Drive上へpush

完成したスクリプトファイルをGoogle Drive上にpushします。

```
$ clasp push
$ clasp version '適当なコメント'
```

※ pushされたスクリプトはライブラリとして参照できるよう、適宜Drive上で共有設定を行ってください

### 8. ライブラリとして参照

GASのスクリプトエディタ上でライブラリを参照します。

[リソース]→[ライブラリ]→スクリプトIDを入力→バージョンを選択

※ ライブラリ名は仮に「libpack」として進めます

### 9. 実行してみる

以下のサンプルコードに従ってテスト実行してみます。

```js
// ライブラリを取得
const xslt = libpack.xslt()
const xmldom = libpack.xmldom()

/**
 * XMLパース処理
 * @param string xml XML文字列
 */
function xmlParse(xml) {
  const parser = xslt.getParser();
  return parser.parseFromString(xml, 'text/xml');
}

/**
 * このfunctionを実行する
 */
function main() {
  
  const srcXmlFile = DriveApp.getFileById('Drive上のファイルID XMLファイル')
  const srcXslFile = DriveApp.getFileById('Drive上のファイルID XSLファイル')
  
  // ファイルを読み込んで文字列に変換
  const xmlString = srcXmlFile.getBlob().getDataAsString()
  const xslString = srcXslFile.getBlob().getDataAsString()
  
   // XSLT実行
  xslt.install(new xmldom.DOMParser(), new xmldom.XMLSerializer(), new xmldom.DOMImplementation());
  const data = xslt.xsltProcess(xmlParse(xmlString), xmlParse(xslString))
  
  // HTMLとして出力
  const blob = Utilities.newBlob(data, 'text/html', srcXmlFile.getName() + '.html')
  const parent = DriveApp.getFolderById('親フォルダのID')
  parent.createFile(blob)

  // PDFファイルに変換(HTML→PDF変換はデフォルト実装なので簡単)
  const pdf = blob.getAs('application/pdf').setName(srcXmlFile.getName() + '.pdf')
  parent.createFile(pdf)
}
```

これでPDFファイルが出来あがれば完成です。

# おわりに

## ライブラリ化できるものとできないもの

GASでは他のnpmライブラリも同様の手順にて使用することができます。
ただし、上述している通り、ブラウザ上で動くことを前提としたライブラリは使用できません。そこはソースを読んでみるのが手っ取り早いのですが、ライブラリ化してみて以下のような文言がエラーに出力される場合は前提条件が制約に引っかかっていると考えると良いかと思います。

- documentがない
- windowがない
- setTimeoutがない
- clearTimeoutがない

また、TypeScriptから最新のESバージョンにトランスパイルされているライブラリではbrowserifyを実行した際に、以下のようなエラーが出ました。こちらも参考程度に。

```
/Users/hata/develop/gas/library/test/node_modules/esprima/dist/esprima.js:1995
	        throw this.unexpectedTokenError(token, message);
	        ^
Error: Line 18: Unexpected token ...
    at ErrorHandler.constructError (/Users/hata/develop/gas/library/test/node_modules/esprima/dist/esprima.js:5012:22)
```

## ライブラリ

今回作成したライブラリは外部公開しています。執筆時点のものはバージョン4２を使用してください。

Script ID:
13KJxU8q0ZYmZXyQswU2HrkQX-yXlgnlJ3BVzsKrS69oaE4FcViPRFPZb
バージョン：42

## まとめ

- claspでローカルPCでGASのスクリプトを作成し、ライブラリ化
- gasifyでGASで実行可能なバージョンにコンパイル
- ライブラリはnpmから使用できるものを選定
- ESバージョンが異なるものは自前で変換できないか検討

本記事が皆様の一助になれば幸いです。
最後までお付き合いいただき、ありがとうございました。


# 補足


以下、補足です。


- XML文書AからXML文書Bを参照するような、階層構造になっているXML文書については、工夫が必要そうです。この点についてはまだ解決できてません。
- XHTMLの変換精度はライブラリ依存であり、100%の精度ではないようです。ただ、可読性はあるため、XMLファイルのままよりは格段に読みやすいです。