Edited at
SpeeeDay 10

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

この記事は Speee Advent Calendar 2018 の 10 日目の記事です。先日は @iida-hayato による "AWS Sumerian で AR アプリを作る" でした。


前段

現在、社内で社内ツールの開発/運用にあたる傍らで、 Markdown によるスライド作成ツール Marpの次世代版)をいそいそと開発しています。


Marp (旧版)

遡ること 3 年近く前、 Electron で Markdown プレゼン作成ツールを作って公開するまで という記事を Qiita で公開し、その成果物が Marp でした。

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

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


Marp Next (次世代版)

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

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

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


本題


Marp CLI

現在、開発に注力しているのが Marp CLI です。その名の通り、Marp の CLI インタフェース版として開発しているもので、要望がそこそこ多かったこともあり、今年 8 月に最初のプレリリース版をリリースしました。2

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

# 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 経由で使うこともできます。 3


PDF 変換をどうするか

Marp は一貫して、『スライドを PDF に変換して使用する』 というスタンスなので、PDF 変換の機能は必須です。旧版の Marp では、 Electron が提供する 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 を提供するライブラリです。公式の例にもある通り、HTML :arrow_right: PDF 変換がドえらいカンタンにできてしまいます。


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 が出力されます。

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) 。

is-wsl による判定処理は、WSL (Windows Subsystem for Linux) 環境で Windows の Chrome を使用するための分岐です。


--enable-blink-gen-property-trees

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

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

同じように、Blink 派生元の WebKit でも 10 年来の未解決問題となっており、Safari 環境でのスライド表示には polyfill を作って対策しています。 JS が必須になってしまうのが痛い...


Docker 環境向けの設定

前述の通り、Marp CLI は Docker 環境で Chrome による変換ができますが、Puppeteer をコンテナ内で動かす際はいくつか注意点があり、ドキュメント でも言及されています。

--disable-dev-shm-usage は、共有メモリ (/dev/shm) への書き込みを回避するためのオプションです。デフォルトの共有メモリの容量は Chrome を動かすには小さすぎる (64MB) ため、このオプションを指定することで大きなページでのクラッシュを回避します。


PDF 変換時のオプション

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



  • printBackground: 背景画像を印刷するかどうか


  • preferCSSPageSize: CSS の @page で設定したサイズを使用するかどうか

読んで字の如くですね。


出来上がった PDF

スクリーンショット 2018-12-10 9.50.04.png

再現性もバッチリです。

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



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

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

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

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


Deckset

Features – Deckset for Mac


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


Carlo

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

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

今回の導入例は、以下の Qiita 記事 で解説されているユースケースそのままですね。


所感として、ガッツリとしたデスクトップアプリを作るというよりも、CLI として提供していたような Node.js のツールに対して、少しだけ GUI を足したい、みたいなケースに一番向いてそうです。

Carlo で Node.js のツールに GUI をもたせる - Qiita



使い方

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

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 は非常にシンプルで、Puppeteer の薄いラッパーとなっているため、Puppeteer が分かればすぐ理解できます。


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

const win = await app.createWindow({

height: 480,
width: 640,
})

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

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


Marp CLI で使う

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

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


利用イメージ

marp-cli-cast.gif

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

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


終わりに

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

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

明日は @ryukku-n です。


追記: 2018/12/23

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





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



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



  3. Docker 対応も寄せられていた要望です。 



  4. 変更の検知についても要望に上がっていたものの、ずっと後手に回っていました。。 



  5. 現状の Marp CLI では、私の理解不足もあり若干回りくどい書き方になっていますが、特にこんなことしなくても大丈夫です。