Puppeteer & Carlo を Markdown スライド作成 CLI ツール (Marp CLI) で活用する

この記事は [Speee Advent Calendar 2018](https://qiita.com/advent-calendar/2018/speee) の 10 日目の記事です。先日は [@iida-hayato](https://qiita.com/iida-hayato) による "[AWS Sumerian で AR アプリを作る](https://qiita.com/iida-hayato/items/f96387473f88697c6995)" でした。

# 前段

現在、社内で社内ツールの開発／運用にあたる傍らで、 Markdown によるスライド作成ツール **[Marp](https://yhatt.github.io/marp/)**（[の次世代版](https://github.com/marp-team/)）をいそいそと開発しています。

### Marp (旧版)

遡ること 3 年近く前、 **『[Electron で Markdown プレゼン作成ツールを作って公開するまで](https://qiita.com/yhatt/items/0bf65699a538d5508c33)』** という記事を Qiita で公開し、その成果物が Marp でした。

Marp は [:star: 累計 7300 over](https://github.com/yhatt/marp/stargazers) と、国内外問わず多くの方々から支持されており、『ありがとうございます』という気持ちの一方で、『JS の勉強のつもりで作ったのにこんなに伸びちゃった…』という後悔の念もあります。 [^old]

そのため、最近のフロントエンド事情を猛勉強し、ここ 1 年ほどでようやく自在に手足を動かせるようになってきました。

[^old]: CoffeeScript/jQuery という古い構成と、テストが皆無だった（当時は JS のテストの書き方すら知らなかった）のが継続的なメンテナンスの足を引っ張りました。

### Marp Next (次世代版)

[<img src="https://raw.githubusercontent.com/marp-team/marp/master/marp.png" width="300"/>](https://github.com/marp-team/marp)

現在、GUI ツールのみならず、 **より広い分野で Marp を使ってもらえるようにする** ため、Marp の構成要素を [独立したフレームワーク (Marpit)](https://github.com/marp-team/marpit) や [コアモジュール (marp-core)](https://github.com/marp-team/marp-core) などに分解し、**Marp Next** として 1 から作り直しています。

正式な発表は、各アプリケーションの準備が整い次第 [https://marp.app/](https://marp.app/) にサイトを立てて行う予定です。この記事は Sneak peek と思ってご覧いただければと思います。

ちなみに、旧 Marp とは互換性が一部失われていますので、お試しの際はご注意ください。

# 本題

### [Marp CLI](https://github.com/marp-team/marp-cli)

現在、開発に注力しているのが [**Marp CLI**](https://github.com/marp-team/marp-cli) です。その名の通り、Marp の CLI インタフェース版として開発しているもので、[要望が](https://github.com/yhatt/marp/issues/50)[そこそこ](https://github.com/yhatt/marp/issues/164)[多かった](https://github.com/yhatt/marp/issues/222)こともあり、今年 8 月に最初のプレリリース版をリリースしました。[^cli]

[^cli]: 他にも、Marp が著名になって以降、『好みのエディタで書きたい』『Vim のキーバインドで書きたい』といった声が非常に多かったのも理由です。そのため、現在はエディタを自前で作るよりも、コンバーターの充実に注力しています。

Marp Next 向けの Markdown を HTML / PDF に変換する CLI ツールで、Node がインストールされていれば、 `npx` で以下のようなコマンドを叩けばすぐに使えます。

```shell
# HTML に変換
npx @marp-team/marp-cli slide-deck.md
npx @marp-team/marp-cli slide-deck.md -o output.html

# PDF に変換
npx @marp-team/marp-cli slide-deck.md --pdf
npx @marp-team/marp-cli slide-deck.md -o output.pdf

# ウォッチモード（ファイルの変更を監視）
npx @marp-team/marp-cli -w slide-deck.md

# サーバーモード（指定フォルダをローカルで配信）
npx @marp-team/marp-cli -s .
```

Node が入ってなくても、[Docker 経由で使うこともできます。](https://github.com/marp-team/marp-cli#docker) [^docker]

[^docker]: Docker 対応も[寄せられていた要望](https://github.com/yhatt/marp/issues/232)[です](https://github.com/yhatt/marp/issues/233)。

## PDF 変換をどうするか

Marp は一貫して、**『スライドを PDF に変換して使用する』** というスタンスなので、PDF 変換の機能は必須です。旧版の Marp では、 [Electron](https://electronjs.org/) が提供する API を使用し、内臓の Chromium を使って HTML を印刷する形で PDF を作成していました。

この構成は Marp CLI でも変わりませんが、Electron は GUI ツール向きなので、CLI ツールには (使えはしますが) あまりマッチしません。また、ファイルサイズをかなり喰ってしまうのも課題でした (> 100MB) 。 CLI ツールには軽くあってほしいですよね。

これらの理由から、Marp CLI は **『ユーザーがインストールした Chrome を使って PDF に変換する』** 方針を取っています。

Chrome の入っていないユーザーは切り捨てることになってしまいますが、代替策として Docker イメージに Chrome を同梱することで、Docker 経由であれば環境を問わず変換できるようにしました。

## [Puppeteer]

[Puppeteer] は、(Headless) Chrome および Chromium を操作するための Node API を提供するライブラリです。[公式の例](https://github.com/GoogleChrome/puppeteer#usage)にもある通り、HTML :arrow_right: PDF 変換がドえらいカンタンにできてしまいます。

> ```javascript
> const puppeteer = require('puppeteer')
>
> ;(async () => {
>   const browser = await puppeteer.launch()
>   const page = await browser.newPage()
>   await page.goto('https://news.ycombinator.com', {
>     waitUntil: 'networkidle2',
>   })
>   await page.pdf({ path: 'hn.pdf', format: 'A4' })
>
>   await browser.close()
> })()
> ```
>
> _─ https://github.com/GoogleChrome/puppeteer#usage_

### [puppeteer-core]

実際の Marp CLI では、今年 8 月から提供が開始された **[puppeteer-core]** を使用しています。

Puppeteer はデフォルトで、`npm install puppeteer` を実行した際に、バージョンに沿って最適な Chromium をダウンロードし、それを使用してくれます。この挙動自体は問題ないのですが、先に挙げた『ファイルサイズを喰ってしまう』という課題が引き続き残ります (> 300MB) 。

その点 `npm install puppetter-core` は、Chroimum の自動インストールを行わないため、ディスク容量の浪費の心配がありません。操作する Chrome (Chromium) は、Puppeteer でブラウザを起動する際に自分で指定する必要があります。

### サンプル

現行の Marp CLI から、PDF 変換に関するコードを抜き出してみました。変換対象として、https://yhatt-marp-cli-example.netlify.com/ でホストしている、Marp CLI の HTML スライド例を使用しています。

`npm i chrome-launcher puppeteer-core is-wsl` を実行後、Node で以下を実行すると、`output.pdf` にスライド PDF が出力されます。

```javascript
const chromeFinder = require('chrome-launcher/dist/chrome-finder')
const puppeteer = require('puppeteer-core')
const isWSL = require('is-wsl')

;(async () => {
  // Chrome を開く
  const browser = await (async () => {
    // 起動する Chrome の設定 (後述)
    const args = ['--enable-blink-gen-property-trees']
    let finder = isWSL ? chromeFinder.wsl : chromeFinder[process.platform]

    if (process.env.IS_DOCKER) {
      // Docker 環境の設定 (後述)
      args.push('--no-sandbox', '--disable-dev-shm-usage')
      finder = () => ['/usr/bin/chromium-browser']
    }

    // Puppeteer で Chrome を起動
    return await puppeteer.launch({
      args,
      executablePath: finder ? finder()[0] : undefined,
    })
  })()

  try {
    // タブを開く
    const page = await browser.newPage()

    try {
      // スライドの HTML にアクセス
      await page.goto('https://yhatt-marp-cli-example.netlify.com/', {
        waitUntil: ['domcontentloaded', 'networkidle0'],
      })

      // PDF に変換 (後述)
      await page.pdf({
        path: './output.pdf',
        printBackground: true,
        preferCSSPageSize: true,
      })

      console.log('Success!')
    } finally {
      await page.close()
    }
  } finally {
    await browser.close()
  }
})()
```

コードのボリュームは増えましたが、流れは公式のサンプルとそれほど変わりません。

#### 起動する Chrome の設定

puppeteer-core では `executablePath` でパスを指定する必要があります。Chrome のパス解決ロジックを自前でメンテナンスするのも大変なので、Chrome 公式が提供する別プロジェクト **[chrome-launcher]** のものを拝借しています ([`chrome-finder.ts`](https://github.com/GoogleChrome/chrome-launcher/blob/master/src/chrome-finder.ts)) 。

[is-wsl](https://www.npmjs.com/package/is-wsl) による判定処理は、WSL (Windows Subsystem for Linux) 環境で Windows の Chrome を使用するための分岐です。

##### `--enable-blink-gen-property-trees`

コレはかなり新しめのフラグです。Marp Next のベースとなるフレームワーク [Marpit](https://github.com/marp-team/marpit) では、SVG タグでスライドを描画する [Inline SVG slide](https://marpit.marp.app/inline-svg) というモードがあり、Marpit の高度な機能を活かし切るのに欠かせないモードになっています。（高度な背景レイアウト、JavaScript 不要、Markdown 本来の DOM 構造を壊さない etc）

しかし、このレンダリングは Firefox がめっぽう安定している一方で、Chrome にはバグが結構あります（スクロールバーが出るとスケーリングがおかしくなったり、ビデオコンポーネントが中にあるとレイアウトが崩れたりする）。このフラグである程度解消されるため、レンダリングバグを避けるために ON に設定しています。

同じように、Blink 派生元の WebKit でも [10 年来の未解決問題](https://bugs.webkit.org/show_bug.cgi?id=23113)となっており、Safari 環境でのスライド表示には [polyfill を作って対策しています。](https://github.com/marp-team/marpit-svg-polyfill) JS が必須になってしまうのが痛い...

#### Docker 環境向けの設定

前述の通り、Marp CLI は Docker 環境で Chrome による変換ができますが、Puppeteer をコンテナ内で動かす際はいくつか注意点があり、[ドキュメント](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#running-puppeteer-in-docker) でも言及されています。

`--disable-dev-shm-usage` は、共有メモリ (`/dev/shm`) への書き込みを回避するためのオプションです。[デフォルトの共有メモリの容量は Chrome を動かすには小さすぎる](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#tips) (64MB) ため、このオプションを指定することで大きなページでのクラッシュを回避します。

#### PDF 変換時のオプション

設定したのは以下の 2 つです。

- `printBackground`: 背景画像を印刷するかどうか
- `preferCSSPageSize`: CSS の `@page` で設定したサイズを使用するかどうか

読んで字の如くですね。

### 出来上がった PDF

<img alt="スクリーンショット 2018-12-10 9.50.04.png" src="https://qiita-image-store.s3.amazonaws.com/0/70467/56dfa84c-67e8-1ecb-8c28-1c84ebf45ca8.png" width="500" />

再現性もバッチリです。

ということで、CLI 環境に適した PDF 変換処理が無事実現できました。:ok_hand:

---

## プレビュー機能をつけたい

話はちょっと変わりまして、Marp CLI には最初に示した通り、ウォッチモード (`--watch`, `-w`) やサーバーモード (`--server`, `-s`) があります。

変換した HTML をブラウザに表示すれば、Markdown の保存を検知してリロードしてくれるので、お好みのエディタでスライドが書けます。[^reload]

[^reload]: 変更の検知についても[要望に上がっていた](https://github.com/yhatt/marp/issues/25)ものの、ずっと後手に回っていました。。

そうなると、[Deckset] のように、プレビューできる小窓も欲しくなってくるかもしれません。

> [![Deckset](https://deckset.imgix.net/preview-window.jpg?auto=format&q=100&ixlib=imgixjs-3.3.2&w=400)](https://www.deckset.com/features/)
>
> _─ [Features – Deckset for Mac](https://www.deckset.com/features/)_

というわけで、CLI からプレビュー小窓を表示する機能の実装もはじめました。 (今のところ Experimental です)

## [Carlo]

プレビュー機能の実装にあたり、**[Carlo]** を使ってみました。

[<img src="https://user-images.githubusercontent.com/883973/47826256-0531fc80-dd34-11e8-9c8d-c1b93a6ba631.png" width="480" />](https://github.com/GoogleChromeLabs/carlo)

Carlo は、先月リリースされたばかりの、Puppeteer + Chrome による Node アプリケーションフレームワークです。実は、これも [puppeteer-core] でインストール済みの Chrome を使用する構成で、全く同じ構成を持つ Marp CLI にスッと導入できました。

今回の導入例は、以下の [Qiita 記事](https://qiita.com/Quramy/items/d38b26a7c0007e757353) で解説されているユースケースそのままですね。

> 所感として、ガッツリとしたデスクトップアプリを作るというよりも、CLI として提供していたような Node.js のツールに対して、少しだけ GUI を足したい、みたいなケースに一番向いてそうです。
>
> _─ [Carlo で Node.js のツールに GUI をもたせる - Qiita](https://qiita.com/Quramy/items/d38b26a7c0007e757353)_

### <ins>:warning: 2020/03/03 追記:</ins>

残念ながら、[**現在 Carlo はもうメンテナンスされていません**](https://github.com/GoogleChromeLabs/carlo/issues/163#issuecomment-592238093)。元の開発者が Google を辞めてしまい、Chrome チームもメンテナンスに時間を取れないとのことです。そのため、現状は新たに Carlo を利用することはあまりお勧めしません。

Marp CLI でもこの状況を見越してずっと Expereimental のままにしてあったので、メンテナンスされなくなった今、実装した機能を[どう対処すべきかは改めて検討中です。](https://github.com/marp-team/marp-cli/issues/211)（利用頻度が少ないと判断した場合、削除される可能性もあります）

### 使い方

すでにサーバーの実装は済んでいるため、やることはローカルサーバーを表示させることだけです。拍子抜けするほど簡単でした。[^fix]

[^fix]: 現状の Marp CLI では、私の理解不足もあり[若干回りくどい書き方](https://github.com/marp-team/marp-cli/blob/0aa3322b8dbc04afb80b7e9e541bb241a449147f/src/preview.ts#L58)になっていますが、特にこんなことしなくても大丈夫です。

```javascript
const carlo = require('carlo')

;(async () => {
  const app = await carlo.launch({
    channel: ['canary', 'stable'],
    title: 'Marp CLI',
    args: ['--enable-blink-gen-property-trees'],
  })

  // ローカルサーバーを表示
  await app.load('http://localhost:8080/')
})()
```

[carlo の API は非常にシンプル](https://github.com/GoogleChromeLabs/carlo/blob/master/API.md)で、Puppeteer の薄いラッパーとなっているため、Puppeteer が分かればすぐ理解できます。

<!--

// ###### 注意事項

ドキュメントには、[`app.serveOrigin()` でローカルサーバーを配信する](https://github.com/GoogleChromeLabs/carlo/blob/master/API.md#appserveoriginbase-prefix) 例がありますが、このまま使うとページ遷移が発生する場合に `https://domain/` が Origin になったりします。ごく普通に `app.load()` で URL を読み込めば OK です。

-->

##### 複数ウィンドウを使用する場合

```javascript
const win = await app.createWindow({
  height: 480,
  width: 640,
})

await win.load('https://yhatt-marp-cli-example.netlify.com/')
```

ちょっとクセがあり、引数を省略した `app.createWindow()` を使うと、タブバーやアドレスバーのついた、良く見慣れた Chrome ウィンドウになるようです。サイズや位置の指定があると、フレームのみのウィンドウになります。

#### Marp CLI で使う

~~まだまだ実験中なので、サーバーモードでのみ対応しています。~~ 普通のファイルでもプレビューできるようになりました。

```shell
npx @marp-team/marp-cli --preview -s .
```

<img src="https://user-images.githubusercontent.com/3993388/49495207-a0734000-f8a5-11e8-9f5b-f901ef0e49f2.png" width="500">

##### 利用イメージ

![marp-cli-cast.gif](https://qiita-image-store.s3.amazonaws.com/0/70467/4189ee8c-6c83-e982-da9e-c21d91a36bc0.gif)

Vim 側で編集した内容を保存すると、その内容がプレビューウィンドウにもちゃんと反映されます。 :ok_hand:

ちなみに、このウィンドウを最大化 or フルスクリーン表示すれば、そのままプレゼンモードとして使うこともできます。今後プレゼンテーション関連機能は拡充していくつもりですが、待ちきれない方はこちらをお使いください。:bow:

# 終わりに

[Puppeteer] と [Carlo] の採用例として、Markdown スライド作成 CLI ツール (Marp CLI) での活用についてご紹介しました。

Marp CLI は、年内にはドキュメントを整備し、プレリリースを脱せるように鋭意開発中です。Marp Next は、PWA を採用した [Web 版も技術検証段階](https://web.marp.app/) に入っていますので、続報をお待ちいただければ幸いです。

明日は [@ryukku-n](https://qiita.com/ryukku-n) です。

#### <ins>追記: 2018/12/23</ins>

その後、マイナーアップデート Marp CLI v0.1.0 をリリースし、無事年内にプレリリースを卒業しました :tada:

![](https://raw.githubusercontent.com/marp-team/marp-cli/master/docs/images/marp-cli.gif)

[puppeteer]: https://github.com/GoogleChrome/puppeteer
[puppeteer-core]: https://www.npmjs.com/package/puppeteer-core
[chrome-launcher]: https://github.com/GoogleChrome/chrome-launcher
[Carlo]: https://github.com/GoogleChromeLabs/carlo
[deckset]: https://www.deckset.com/