Prog-G

岐阜大学プログラミングサークル

電子書籍を作ろうと思って頑張った話

岐大祭2018

この記事は、岐大祭に向けてアドベントカレンダー的な記事を書く企画の 1 日目です。

この企画はもともと、各人の成果物に加え、共同制作としてそれら成果物を作る過程で得られた技術的な知見をまとめた本を配ろうというものでした。 結局はこのように、本ではなく記事として公開する形になりましたが、自分は本のためにいろいろ書いたりしていたのでその話をしたいと思います。

Re:VIEW と pandoc

一般的に、電子書籍つまり EPUB や PDF の生成には InDesign が使われるようですが、 OSS だと pandocRe:VIEW といった候補があります。 pandoc はメジャーな形式をほとんど押さえている文書コンバーターであり、 Markdown から EPUB や PDF を生成したり出来ます。 Re:VIEW も EPUB や PDF を生成でき 技術書典 などでも使われているようですが、入力ファイルが独自の形式で少しとっつきにくく感じました。

どちらを使おうかは結構考えたのですが、 自分が普段から pandoc を使っているというのと、 共同制作なので Markdown で書けたほうが楽かと思い pandoc を使うことにしました。 pandoc にもファイルを繋いだり目次を作ってくれる機能があったとか、 textlint が使いたかったとか、ドキュメントの充実具合とかも選定の理由だった気がします。

TeX と Chrome と puppeteer

これは Re:VIEW でも同じですが、そのまま pdf を吐こうとすると TeX のスタイルファイルを書く必要があります。 LaTeX の環境構築はそれなりに大変ですし、 EPUB 版とデザインを揃えたかったこともあり HTML を経由して PDF を生成しようと考えました。 具体的には Chrome の印刷機能をコマンドラインから呼び出します。 だいたい以下のような流れでした。

sed -e '1i---' -e '$a---' book.yml > yaml.md
pandoc -o book.html --toc -N -c style.css -s yaml.md chap01.md chap02.md
google-chrome --headless --disable-gpu --print-to-pdf=book.pdf book.html

最初の sedbook.ymlyaml metadata block だけの Markdown に変換しています。 --disable-gpu--headless を付けて Chrome を起動した際の描画のバグを回避するためだった気がします。 style.css は PDF の四隅の余白を消すために書いています。

@page {
  margin: 0;
}

@page で変更できるプロパティは限定的ですが、 @media print を使うと印刷時にのみ有効なスタイルを定義したりもできます。

といっても結局はヘッダーが入ってしまったので puppeteer を使いました。 これは 公式のサンプルコード もあったので結構簡単でした。 以下は次節の Docker コンテナ環境で動くように書いたモジュールです。

const puppeteer = require("puppeteer-core");

module.exports = async (html, pdf, papersize) => {
  const browser = await puppeteer.launch({
    executablePath: "/usr/bin/chromium-browser",
    args: ["--disable-dev-shm-usage"]
  });

  const page = await browser.newPage();
  await page.goto(`file:${__dirname}/${html}`);
  await page.pdf({
    path: pdf,
    printBackground: true,
    format: papersize
  });

  await browser.close();
};

html で PDF にしたい HTML ファイルを、 pdf で PDF のファイル名を、 papersize で PDF の用紙サイズを指定する感じです。 --disable-dev-shm-usageコンテナのメモリの関係で必要らしい です。

Docker と Alpine Linux

puppeteer のおかげで TeX の環境構築は不要になりましたが、 pandoc と Node.js が必要なので Alpine Linux ベースの Dockerfile も作ってみました。 chromium も edge にならあるのでついでに入れています。 Dockerfile はこのあたりを参考に作りました。

完成したのが こちら 。 Alpine は apk add 時に --virtual もしくは -t で名前を付けておくと、 apk del 時にその名前でパッケージをまとめて消せるので便利です。 ash だったり(BusyBox なので)コマンドのオプションがなかったり glibc でなく musl を使ってたりで嵌ることはありますが、全体的にはコンパクトで使いやすいと思います。

Noto と @import と @font-face

Alpine には日本語のフォントが入っていないため、このままでは PDF の文字が豆腐になってしまいます。 今回はとりあえず Noto を使ってみました。 日本語に対応した Noto Sans は(自分が調べた範囲では) 3 種類あり、上の2つは Web 用なので @import するだけで利用できます。 @importrel に似ていますが、メディアクエリで読み込む条件を指定したりできます。

ちなみに Sans は 「無し」 の意味で、 Serif つまり 「とめ・はね・はらい」 が無いことを表しており、いわゆるゴシック体に相当します。 CJK は中日韓用のフォントの意味です。

今回は EPUB にフォントを埋め込む ため、 @font-face も使いました。 @font-face を活用すれば柔軟なフォントの指定が可能になり、一般的に使っていいフォントかどうかなどを考えなくてもよくなるので便利そうです。 より詳細な使い方は ここ に載っています。 作業としては NotoSansCJKjp の *.otf 版をダウンロードし、 以下のような font.css を書いて -c font.css で pandoc に渡しました。 フォントのインストールは不要です。

@font-face {
  font-family: "Noto Sans JP";
  font-style: normal;
  font-weight: normal;
  src: url("NotoSansCJKjp-Regular.otf");
}

@font-face {
  font-family: "Noto Sans JP";
  font-style: normal;
  font-weight: bold;
  src: url("NotoSansCJKjp-Bold.otf");
}

body {
  font-family: "Noto Sans JP";
}

出力される HTML でも NotoSansCJKjp を有効にするには一工夫する必要があるかと思いましたが、 chromium が賢いのかちゃんとダウンロードしたものを使ってくれました。 ちなみに EPUB3 の仕様 的には *.woff を使ったほうがいい様子。

fs-extra と promisify

このあたりまでくると、 pandoc に渡すオプションも複雑になってきて、シェルスクリプトでは管理が難しくなってきました。 そこで、 puppeteer の時点で Node.js を使っていたこともあり、 YAML と Node.js でビルドスクリプトを書くことにしました。 YAML を読んでオプションを生成する部分を除くと、全体はこんな感じです。 html2pdf は最初のほうに紹介した puppeteer を使ったモジュール。

const yaml = require("js-yaml");
const fs = require("fs-extra");
const exec = require("util").promisify(require("child_process").exec);
const html2pdf = require("./lib/html2pdf");

const config = yaml.safeLoad(fs.readFileSync("book.yml", "utf8"));

...

fs.mkdirp("dist")
  .then(() => exec(`pandoc -o dist/book.html ${htmlOptions} chapters/*.md`))
  .then(() => html2pdf("dist/book.html", "dist/book.pdf", config.papersize))
  .then(() => exec(`pandoc -o dist/book.epub ${epubOptions} chapters/*.md`))
  .catch(console.log);

YAML のパースには js-yaml を使いました。 fs-extra を使っているのは、 Node の fs には mkdir -p をする関数が無いためです。 fs-extra は Promise 版の API を提供したり、 Node の fs の関数もエクスポートしてくれるのでとても便利です。 余談ですが、 Node v10 からは fs/promises も入ったので、 Promise 版の API が欲しいだけならこれでも十分かもしれませんね。 pandocchild_process.exec を使って呼び出しています。 child_process.execSync もありますが、 puppeteer の部分が既に Promise だったので util.promisify で Promise 化して使うことにしました。 util.promisify は、コールバック関数を最後の引数で取り、そのコールバック関数が最初の引数でエラーを取る関数を Promise 化できる関数です。

追記:

10/10 のリリースfs.mkdir に recursive オプションが追加されました。 fs/promises の API を使えば、上のコードの mkdirp はおおよそ以下のように実装できます。

const mkdirp = (path, mode = 0o777) =>
  require("fs").promises.mkdir(path, { recursive: ture, mode });

まとめ

まだ未完成ですが、現段階での全体のディレクトリ構成は以下のようになっています。

├── assets
│   ├── cover.png
│   ├── fonts.css
│   ├── meta.xml
│   └── style.css
├── book.yml
├── build.js
├── chapters
│   ├── 01.md
│   └── 02.md
├── dist
│   ├── book.epub
│   ├── book.html
│   ├── book.md
│   └── book.pdf
├── lib
│   ├── html2pdf.js
│   └── template.html
├── package-lock.json
└── package.json

cover.png は表紙で、 meta.xml--epub-metadata で埋め込む予定の EPUB 用の書籍情報です。 文章の本体は chapters/ に置き、 書籍の情報や assets/ に置いたリソースを book.yml にて指定する運用を想定しています。

とりあえずここまでは来ましたが、 EPUB の日付の扱いで詰まったり PDF だと微妙に表紙の位置がずれたりして、こういう OSS がありそうでない理由を悟っています。 とは言っても、いろいろ気になってた技術を実際に試せたのは有意義だったし楽しいですね。 別件で PDF 構造解説 を読んだり、 QPDF を触ったりもしてるので、そのうちまたチャレンジしてみたいです。 まあでも趣味で本を作るなら Re:VIEW を使ってみたりするかも。