この記事は Speee Advent Calendar 2018 の 10 日目の記事です。先日は @iida-hayato による "AWS Sumerian で AR アプリを作る" でした。
前段
現在、社内で社内ツールの開発/運用にあたる傍らで、 Markdown によるスライド作成ツール Marp(の次世代版)をいそいそと開発しています。
Marp (旧版)
遡ること 3 年近く前、 『Electron で Markdown プレゼン作成ツールを作って公開するまで』 という記事を Qiita で公開し、その成果物が Marp でした。
Marp は 累計 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 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() })()
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
再現性もバッチリです。
ということで、CLI 環境に適した PDF 変換処理が無事実現できました。
プレビュー機能をつけたい
話はちょっと変わりまして、Marp CLI には最初に示した通り、ウォッチモード (--watch
, -w
) やサーバーモード (--server
, -s
) があります。
変換した HTML をブラウザに表示すれば、Markdown の保存を検知してリロードしてくれるので、お好みのエディタでスライドが書けます。4
そうなると、Deckset のように、プレビューできる小窓も欲しくなってくるかもしれません。
というわけで、CLI からプレビュー小窓を表示する機能の実装もはじめました。 (今のところ Experimental です)
Carlo
プレビュー機能の実装にあたり、Carlo を使ってみました。
Carlo は、先月リリースされたばかりの、Puppeteer + Chrome による Node アプリケーションフレームワークです。実は、これも puppeteer-core でインストール済みの Chrome を使用する構成で、全く同じ構成を持つ Marp CLI にスッと導入できました。
今回の導入例は、以下の Qiita 記事 で解説されているユースケースそのままですね。
所感として、ガッツリとしたデスクトップアプリを作るというよりも、CLI として提供していたような Node.js のツールに対して、少しだけ GUI を足したい、みたいなケースに一番向いてそうです。
2020/03/03 追記:
残念ながら、現在 Carlo はもうメンテナンスされていません。元の開発者が Google を辞めてしまい、Chrome チームもメンテナンスに時間を取れないとのことです。そのため、現状は新たに Carlo を利用することはあまりお勧めしません。
Marp CLI でもこの状況を見越してずっと Expereimental のままにしてあったので、メンテナンスされなくなった今、実装した機能をどう対処すべきかは改めて検討中です。(利用頻度が少ないと判断した場合、削除される可能性もあります)
使い方
すでにサーバーの実装は済んでいるため、やることはローカルサーバーを表示させることだけです。拍子抜けするほど簡単でした。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 .
利用イメージ
Vim 側で編集した内容を保存すると、その内容がプレビューウィンドウにもちゃんと反映されます。
ちなみに、このウィンドウを最大化 or フルスクリーン表示すれば、そのままプレゼンモードとして使うこともできます。今後プレゼンテーション関連機能は拡充していくつもりですが、待ちきれない方はこちらをお使いください。
終わりに
Puppeteer と Carlo の採用例として、Markdown スライド作成 CLI ツール (Marp CLI) での活用についてご紹介しました。
Marp CLI は、年内にはドキュメントを整備し、プレリリースを脱せるように鋭意開発中です。Marp Next は、PWA を採用した Web 版も技術検証段階 に入っていますので、続報をお待ちいただければ幸いです。
明日は @ryukku-n です。
追記: 2018/12/23
その後、マイナーアップデート Marp CLI v0.1.0 をリリースし、無事年内にプレリリースを卒業しました
-
CoffeeScript/jQuery という古い構成と、テストが皆無だった(当時は JS のテストの書き方すら知らなかった)のが継続的なメンテナンスの足を引っ張りました。 ↩
-
他にも、Marp が著名になって以降、『好みのエディタで書きたい』『Vim のキーバインドで書きたい』といった声が非常に多かったのも理由です。そのため、現在はエディタを自前で作るよりも、コンバーターの充実に注力しています。 ↩
-
現状の Marp CLI では、私の理解不足もあり若干回りくどい書き方になっていますが、特にこんなことしなくても大丈夫です。 ↩