技術書典裏話・電子書籍と印刷物を同時に生成する

さて、先日お伝えしたとおり、技術書典4に参加し、100部刷った「WordPressで始めるGoogle Cloud Platform本格入門」は無事完売した。

書籍を購入してくれた方にはePubをダウンロードできるURLをプレゼントしたのだが、これこそ本来筆者がやりたかったことであった。本稿では我々Capital Pがいかにして電子書籍と印刷用のPDFを作成したのかについて、裏話を交えて紹介したい。

執筆

今回の執筆陣は私高橋文樹と宮内隆行。二人とも書籍を出版した経験はあるのだが、次のような認識を共有していた。

  • WordPress本で売れるのはほとんどが入門本で、専門的な内容は出版社によって敬遠される。ぜひ自分たちで専門的かつ高度な内容の本を書きたい。
  • 半年をかけて一冊を作るようなやり方はしたくない。できれば総期間一ヶ月以内、執筆時間は一週間以内。
  • Markdownで書く。

幸い、今回はリテラシーが高い執筆陣しかいないので、このフローでも問題はなかった。我々はgithub上にリポジトリを作成し、ビルドツールもあわせて構築していった。

Githubではこんな感じ。manuscriptsディレクトリに原稿が入っている。

ビルドは主にgulpで行い、BrowsersyncでHTMLの確認を行なった。

var gulp = require('gulp');
var $ = require('gulp-load-plugins')();
var fs = require('fs');
var es = require('event-stream');
var pug = require('pug');
var MarkdonwIt = require('markdown-it');
var mkdirp = require('mkdirp');
var pngquant = require('imagemin-pngquant');
var browserSync = require('browser-sync').create();

/**
 * Convert figure tag.
 *
 * @param {String} html
 * @return {String}
 */
function replaceFigureTag(html){
  return html.replace(/<p><img([^>]*)alt="(.*?)"([^>]*)><\/p>/mg, '<figure><img$1$3><figcaption>$2</figcaption></figure>');
}

// Sass tasks
gulp.task('sass', function () {
  return es.merge(
    gulp.src(['./src/scss/print.scss'])
      .pipe($.plumber({
        errorHandler: $.notify.onError('<%= error.message %>')
      }))
      .pipe($.sass({
        errLogToConsole: true,
        outputStyle    : 'compressed',
        includePaths   : [
          './src/sass'
        ]
      }))
      .pipe($.autoprefixer({browsers: ['last 2 version', '> 5%']}))
      .pipe(gulp.dest('./html/css')),
    gulp.src(['./src/scss/epub.scss'])
      .pipe($.plumber({
        errorHandler: $.notify.onError('<%= error.message %>')
      }))
      .pipe($.sass({
        errLogToConsole: true,
        outputStyle    : 'compressed',
        includePaths   : [
          './src/sass'
        ]
      }))
      .pipe($.autoprefixer({browsers: ['last 2 version', '> 5%']}))
      .pipe(gulp.dest('./epub/OEBPS/css'))
  );
});

// ePub task
gulp.task('epub', function () {
  var compiler = pug.compileFile('src/templates/chapter.pug');
  var md = new MarkdonwIt({
    xhtmlOut: true
  });
  var dl = require('markdown-it-deflist');
  md = md.use(dl);
  mkdirp.sync('epub/OEBPS/documents');
    // Compile contents
    fs.readdir('./manuscripts', function (err, files) {
      var tocs = [];
      var contents = [];
      // Compile all html.
      files.filter(function (file) {
        return /^\d{2}_(.*)\.md$/.test(file);
      }).map(function (file) {
        var id = file.replace('.md', '');
        // Create HTML
        var content = fs.readFileSync('manuscripts/' + file).toString();
        var title = content.match(/^# (.*)$/m);
        //content = content.replace('../images', './images');
        content = md.render(content);
        content = replaceFigureTag(content);
        var html = compiler({
          id     : id,
          content: content,
          title  : title[1]
        });
        tocs.push({
          id: id,
          title: title[1],
          href: id + '.xhtml'
        });
        fs.writeFile('epub/OEBPS/documents/' + id + '.xhtml', html, function (err) {
          if (err) {
            throw err;
          }
          console.log(id + ' is generated.');
        });
      });
      return gulp.src('src/templates/00_toc.pug')
        .pipe($.pug({
          locals: {
            title: '目次',
            toc  : tocs
          },
          pretty: true
        }))
        .pipe($.rename({
          extname: '.xhtml'
        }))
        .pipe(gulp.dest('epub/OEBPS/documents'));
    });
    // Generate title page.
    gulp.src('src/templates/00_title.pug')
      .pipe($.pug({
        locals: {
          title: 'WordPressで始める  Google Cloud Platform  本格入門'
        },
        pretty: true
      }))
      .pipe($.rename({
        extname: '.xhtml'
      }))
      .pipe(gulp.dest('epub/OEBPS/documents'))
});

// Pug task
gulp.task('pug', function () {
  var compiler = pug.compileFile('src/templates/index.pug');
  var md = new MarkdonwIt();
  var dl = require('markdown-it-deflist');
  md = md.use(dl);
  mkdirp.sync('html/manuscripts');
  fs.readdir('./manuscripts', function (err, files) {
    var tocs = [];
    var contents = [];
    // Compile all html.
    files.filter(function (file) {
      return /^\d{2}_(.*)\.md$/.test(file);
    }).map(function (file) {
      var id = file.replace('.md', '');
      // Create HTML
      var content = fs.readFileSync('manuscripts/' + file).toString();
      var title = content.match(/^# (.*)$/m);
      content = content.replace('../images', './images');
      content = md.render(content);
      content = replaceFigureTag(content);
      contents.push({
        id  : id,
        html: content
      });

      tocs.push({
        title: title[1]
      });
      console.log(id + ' rendered.');
    });
    // Save html
    var html = compiler({
      toc     : tocs,
      contents: contents
    });
    fs.writeFile('html/index.html', html, function (err) {
      if (err) {
        throw err;
      }
      console.log("HTML is generated.");
    });
  });
});

// Imagemin
gulp.task('imagemin', function () {
  return gulp.src('images/**/*')
    .pipe($.imagemin({
      progressive: true,
      svgoPlugins: [{removeViewBox: false}],
      use        : [pngquant()]
    }))
    .pipe(gulp.dest('./html/images'))
    .pipe(gulp.dest('./epub/OEBPS/images'));
});

// watch print
gulp.task('sync:print', function () {
  browserSync.init({
    files : ["html/**/*"],
    server: {
      baseDir: "./html",
      index  : "index.html"
    },
    reloadDelay: 2000
  });
});

// watch epub
gulp.task('sync:epub', function () {
  browserSync.init({
    files : ["epub/OEBPS/**/*"],
    server: {
      baseDir: "./epub/OEBPS",
      index  : "documents/00_toc.html"
    },
    reloadDelay: 2000
  });
});

gulp.task('reload', function () {
  browserSync.reload();
});

// watch
gulp.task('watch', function () {
  // Make SASS
  gulp.watch(['src/scss/**/*.scss'], ['sass']);
  // HTML
  gulp.watch('manuscripts/**/*.md', ['pug', 'epub']);
  gulp.watch('src/templates/**/*.pug', ['pug', 'epub']);
  // Minify Image
  gulp.watch('images/**/*', ['imagemin']);
  // Sync browser sync.
  gulp.watch([ 'html/**/*', 'epub/OEBPS/**/*' ], ['reload']);

});

gulp.task('server:print', ['sync:print', 'watch']);
gulp.task('server:epub', ['sync:epub', 'watch']);

gulp.task('build', ['pug', 'epub', 'sass', 'imagemin']);

できればGithub上でのコラボレーションも行いたかったのだが、執筆期間が短かったこともあり「履歴の保存」以上の意味はなかったように感じた。

印刷物

さて、これまで同人誌などを作成した方はご存知だと思うのだが、印刷物を作成するには印刷所に入稿するデータを作成しなければならない。最近はPDFでの入稿を受け付ける印刷所がほとんどだが、ではそのPDFをなにで作ると良いのだろうか。

筆者はこれまで破滅派という文芸同人誌で何十冊も同人誌を作成してきた。破滅派ではWordPressサイトに投稿された小説をXMLとして書き出し、それをInDesignにインポートする形で入稿データを作成している。

https://helpx.adobe.com/jp/indesign/using/importing-xml.html

しかし、XML組版といっても、修正が入るたびに筆者がそれをダウンロードして当て込まないといけないため、非常に面倒だった。Adobeがいうほど「自動」ではないわけだ。

そこで今回筆者がとったアプローチは「印刷用PDFをブラウザから印刷する」というものだった。そう、print.cssを使って、PDFとして保存するのである。

こんなWebページを作成し、印刷する。

このページを⌘+Pで印刷するときにPDFとして書き出せば終わりである。ヘッドレスChromeを使えば自動的にPDFを保存することも可能だ。後処理として必要なものは次の通り。

  • ノンブル(ページ番号)の概念がブラウザにはないので、それはAcrobatを利用して後から追加した。ちなみに後から知ったのだが、Paged Mediaという現在策定中の仕様(Chromeは採用済み)を使えばノンブルをふることはできるらしい。
  • 同じく目次を自動で作成することはできないので、これもあとからAcrobatで追加した。

いずれにせよ、これまでのInDesign組版と比べると、圧倒的に楽。とはいえ問題点もいくつかあった。

  • 画像の解像度が異常に低くなった。これは後から調べたのだが、グレースケールにするためのCSSフィルターがよくなかったらしい。filter: grayscale(100%);という記述を入れると、印刷時の画像解像度が低くなるとのこと。大体の印刷所はカラー原稿を入稿してもモノクロで出力するだけなので、フィルターを入れなければよかった。
  • 余白の設定を誤り、きつきつの見た目になってしまった。これは筆者の最大の失敗であった。購入された方はePubもあるのでご寛恕いただきたい。

その他、Tipsとしては次の通り。

  • Webフォントは使えない(はず)なので、システムにインストールされているフォントをCSSで指定すること。
  • リンクは動作しないので、:after擬似要素などでURLを書き出すこと。HTMLを変換する処理を一つ挟めば、文末中などでも実現可能だ。
  • 画像の泣き別れ(途中でぱかっと別れてしまうこと)や余白が出てしまうことはある程度しかたないので割り切りも必要。ただし、印刷物はページサイズが決まっているので、CSSで調整することもできなくはない。

あとはこれを印刷所に送りつけて終了。表紙はPhotoshopで作成したのでそのまま入稿したが、がんばればHTML+CSSだけで表紙用PDFを作成することもできなくはない。

作成した表紙データ。いおりさんのイラストがかわいいから送ってくれと海外から要望が来たのでポスターデータを送ってあげた。

電子書籍

電子書籍に関しては、印刷物同様のソース(Markdown)を利用しHTMLとして出力する。ePubの場合、それらをパッケージングして、色々とXMLを書かなければならない。

だが、幸いにして筆者が作成したPHPライブラリHamePubを利用すると、あっというまにePubが出来上がる。ソースはちょっと長いのだが、こんな感じだ。

<?php

namespace Hametuha\Rest;

require dirname( __DIR__ ) . '/vendor/autoload.php';

use Hametuha\HamePub\Factory;

define( 'BASE_DIR', dirname( __DIR__ ) . '/epub' );

try {
  // 初期化を開始
  echo "Start factory.\n";
  $factory = Factory::init( basename( BASE_DIR ), dirname( BASE_DIR ) );
  // ディレクトリをスキャンして、HTMLを全部登録
  // ePubはxmlに宣言されていないファイルが同梱されていてはいけない
  echo "Scan HTML and register them all.\n";
  $toc = [];
  foreach ( scandir( BASE_DIR . '/OEBPS/documents' ) as $file ) {
    if ( preg_match( '#^\.#', $file ) ) {
      continue;
    }
    switch ( $file ) {
      case '00_toc.xhtml':
        $property = [ 'nav' ];
        break;
      default:
        $property = [];
        break;
    }
    $id = 'documents-' . preg_replace( '#[._]#', '-', $file );
    $factory->opf->addItem( "documents/{$file}", $id, $property );
    $factory->opf->addIdref( $id, 'yes' );
    $html = file_get_contents( BASE_DIR . '/OEBPS/documents/' . $file );
    if ( preg_match( '#<title>([^<]+)</title>#u', $html, $matches ) ) {
      if ( '00_title.html' === $file ) {
        $title = '扉';
      } else {
        $title = $matches[1];
      }
      $toc[ 'OEBPS/documents/' . $file ] = $title;
    }
  }
  // 目次を生成(ePubの目次は少し特殊)
  echo "Create TOC.\n";
  foreach ( $toc as $id => $label ) {
    $factory->toc->addChild( $label, $id );
  }
  // CSSと画像を登録
  echo "Register all images and CSS.\n";
  $factory->opf->addItem( 'css/epub.css', '' );
  foreach ( scandir( BASE_DIR . '/OEBPS/images' ) as $file ) {
    if ( preg_match( '#^\.#u', $file ) ) {
      continue;
    }
    if ( '00_cover.png' === $file ) {
      // 特定の画像だけカバー画像に
      $factory->opf->addItem( 'images/' . $file, 'cover', [ 'cover-image' ] );
      $factory->opf->addMeta( 'meta', '', [
        'name'    => 'cover',
        'content' => 'images/' . $file,
      ] );
    } else {
      $factory->opf->addItem( 'images/' . $file, '' );
    }
  }
  // メタ情報を定義。ここはおいおいひとまとめにした方がよいかもしれない。
  echo "Setup opf.\n";
  $factory->opf->setIdentifier( 'https://github.com/fumikito/wp-gcp-log' );
  $factory->opf->setLang( 'ja' );
  $factory->opf->setTitle( 'WordPressではじめるGoogle Cloud Platform本格入門', 'main-title' );
  $factory->opf->setTitle( 'WordPress on Google Cloud Platform', 'sub-title', 'subtitle', 2 );
  $factory->opf->setModifiedDate( time() );
  $factory->opf->direction = 'ltr';
  $factory->opf->putXML();
  $factory->container->putXML();
  // コンパイル
  echo "Compile ePub.\n";
  $factory->compile( './wp-gcp-log.epub' );

} catch ( \Exception $e ) {
  die( $e->getMessage() );
}

ePubはよく「HTMLとCSSをzipで固めただけ」と言われるのだが、zipに圧縮する際の順番と圧縮レベルに指定があったり、XMLファイルをいくつか書かなければいけなかったりと、癖のあるフォーマットだ。筆者はePubに2年間ほど苦しめられた末、それを抽象化するライブラリーを自作することになった。

技術書というのは異常に長い時間をかけても完成しないことがままあるなか、一週間程度でできたのは大きな収穫である。

ちなみに、こうしたマルチパブリッシングツールとしては、ReViewというのが有名なのだが、筆者はRubyのバージョン違いでセグフォってばかりいるので、採用しなかった。また、electron-pdfというツールをPDF生成に使おうと思っていたのだが、これも謎のエラーで起動さえしなかったので、30分ほど検証した末採用を見送った。

Web技術をベースにしているのは、いつかWordPressとすべて連携させようと考えているからである。破滅派でもKindleへの文芸作品出品を行なっているが、WordPressの中でHamePubを動かしている。

さて、以上でCapital Pの書籍データ作成にまつわる裏話は終わりだ。あとは有料会員向けにさらなる裏話をお届けする。

続きの 8% を読み、添付されたファイルにアクセスするには、Gumroadでライセンスキーを取得してください!

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください