Tech Do | メディアドゥの技術ブログ 

株式会社メディアドゥのエンジニアによるブログです。

EPUB Generatorをつくろう

メディアドゥでは、エンジニア有志によって執筆された【Tech Do Book】という合同誌を発行しています。
本日はその中から、Tech Do Book vol.1 【1章 EPUB Generator をつくろう】を紹介します。

はじめに

EPUB生成ツールの作り方を通じて、EPUBフォーマットの理解について深めましょう。

スコープ

シンプルなテキストベースのEPUBファイル生成ツールの作り方をまとめます。対象とするEPUBのバージョンは3.0です。 なお、コミックのような画像コンテンツを含むEPUBファイルの生成はここでは取り扱いません。

でき上がるもの

書籍ID、出版社、タイトルや目次内容、本文などをPOSTすると、EPUBファイルとしてダウンロードできるようになります。

図:フォームイメージ

必要な知識

  • HTML基礎
  • XML基礎
  • Spring Bootの簡単な使い方

EPUBフォーマットの構造をざっくりと理解する

EPUBを生成する仕組みを作る上で最低限必要なEPUBの構造について見ていきます。

EPUBファイルは仕様で定められた構造にしたがって配置された各種ファイルをZip圧縮したものです。 以下、EPUBファイルの構造です。

/ ----- mimetype
|
| - META-INF/
            | - container.xml
|
| - OEBPS/
         | - package.opf
         | - toc.xhtml
         | - main.xhtml

それぞれのファイルの役割をおっていきましょう。

mimetype

ファイルのメディアタイプを指定するためのファイルです。電子書籍フォルダーの直下に配置します。ファイル名は固定です。ファイル内容も以下の内容を記載します。

application/epub+zip

META-INF/container.xml

「container.xml」は名称固定のファイルです。META-INFフォルダ配下に配置することがルール付けされています。

<?xml version="1.0" ?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile full-path="OEBPS/package.opf"
      media-type="application/oebps-package+xml" />
  </rootfiles>
</container>

rootfileタグにて、パッケージドキュメントのファイルパスを指定します。 ここでは、OEBPSフォルダー配下にある、「package.opf」ファイルをパッケージドキュメントとして指定します。

パッケージドキュメント

電子書籍そのものの情報を管理するためのファイルです。ファイルの配置先、ファイル名は任意ですが、上記container.xmlファイルにてそのファイルパスを指定する必要があります。

<?xml version="1.0" encoding="utf-8"?>
<package unique-identifier="idName" version="3.0" 
  xmlns="http://www.idpf.org/2007/opf" xml:lang="ja">
  <!-- 書籍情報 -->
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:identifier 
      id="idName">urn:uuid:0fa99cb8-f735-4994-91fd-6fb3667582d0</dc:identifier>
    <dc:publisher>mediado.jp</dc:publisher>
    <dc:title>はじめての電子書籍</dc:title>
    <dc:language>ja</dc:language>
    <meta property="dcterms:modified">2019-02-22T13:50:40Z</meta>
  </metadata>
  <manifest>
    <item id="nav" 
      href="toc.xhtml" media-type="application/xhtml+xml" properties="nav" />
    <item id="main_xhtml" 
      href="main.xhtml" media-type="application/xhtml+xml" />
  </manifest>
  <spine page-progression-direction="default">
    <itemref idref="nav" />
    <itemref idref="main_xhtml" />
  </spine>
</package>

ナビゲーションドキュメント(目次)

ファイル名は任意で決められます。どのファイルがナビゲーションファイルかは上記パッケージドキュメントにて指定します。 ここでは「toc.xhtml」としています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" 
  xmlns:epub="http://www.idpf.org/2007/ops">
<head>
  <title>目次</title>
</head>
<body>
  <nav epub:type="toc">
    <h1>目次</h1>
    <ol>
      <li><a href="main.xhtml">最初のページ</a></li>
    </ol>
  </nav>
</body>
</html>

書籍内容ファイル

書籍内容そのものを保持するファイルです。ファイル名は任意です。また、複数ファイルに分割することも可能です。 サイズが大きくなるようなケースでは、複数ファイルに分割することが推奨されています。 ファイルの表示順序については、パッケージドキュメント内にて定義できます。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" 
  xmlns:epub="http://www.idpf.org/2007/ops">
<head>
  <title>本文</title>
</head>
<body>
手作り電子書籍です。
</body>
</html>

コーディング編

ファイル構造を理解したところで、さっそくコーディングに進みましょう。 せっかくなので、EPUBファイル生成の機能をWeb APIとして利用できるようにしてみたいと思います。

サーバー側の処理を実装する前に、EPUBデータの入力フォームから作っていきましょう。

入力フォームの準備

今回はシンプルな仕組みだけを作るので、入力できる情報は以下の内容だけに限定します。

  • 書籍ID
  • 出版社名
  • 著者名
  • 書籍タイトル
  • 言語
  • 目次ファイル内容
  • 本文ファイル内容

図:フォームイメージ

以下、入力用のフォームのコードです。

<!DOCTYPE html>
<html>
  <head>
  <title>Simple EPUB Generator</title>
  <meta charset="utf-8"/>
  <style>
  .vertical-box-wrap {
    display: flex;
    flex-flow: column wrap;
  }
  .horizon-left-box-wrap {
    display : flex;
    flex-flow : row wrap;
    margin : 4px;
  }
  .horizon-right-box-wrap {
    display : flex;
    flex-flow : row-reverse wrap;
    margin : 4px;
  }
  label {
    width : 180px;
    text-align : right;
  }
  .label-left {
    width : 180px;
    text-align : left;
  }
  textarea {
    width : 600px;
    height : 100px;
  }
  </style>
  </head>
  <form id="requestForm">
    <div class="vertical-box-wrap">
      <div class="horizon-left-box-wrap">
        <label for="book_id">書籍ID</label>
        <input name="book_id" id="book_id" type="text">
      </div>
      <div class="horizon-left-box-wrap">
        <label for="publisher">出版社</label>
        <input name="publisher" id="publisher" type="text">
      </div>
      <div class="horizon-left-box-wrap">
        <label for="creator">著者</label>
        <input name="creator" id="creator" type="text">
      </div>
      <div class="horizon-left-box-wrap">
        <label for="title">書籍タイトル</label>
        <input name="title" id="title" type="text">
      </div>
      <div class="horizon-left-box-wrap">
        <label for="language">言語</label>
        <select name="language" id="language">
          <option value="ja">日本語</option>
          <option value="en">English</option><
        /select>
      </div>
      <div class="horizon-left-box-wrap">
        <label 
          class="label-left" for="tocContent">目次(ナビゲーション)</label>
      </div>
      <div class="horizon-left-box-wrap">
        <textarea name="tocContent" id="tocContent"></textarea>
      </div>
      <div class="horizon-left-box-wrap">
        <label class="label-left" for="bookContent">本文</label>
      </div>
      <div class="horizon-left-box-wrap">
        <textarea name="bookContent" id="bookContent"></textarea>
      </div>
      <div class="horizon-left-box-wrap">
        <input type="submit">
      </div>
    </div>
  </form>
  <script 
    src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.4/jquery.min.js">
  </script>
  <script 
    src="https://cdnjs.cloudflare.com/ajax/libs/jquery.form/4.2.2/jquery.form.min.js">
  </script>
  <script>
    function toJSONString( form ) {
     var obj = {};
      var elements = form.querySelectorAll( "input, select, textarea" );
     for( var i = 0; i < elements.length; ++i ) {
       var element = elements[i];
       var name = element.name;
       var value = element.value;
       if( name ) {
          obj[ name ] = value;
       }
     }
     return JSON.stringify(obj);
   }
    $(document).ready(function () {
      $('#requestForm').on('submit', function(e) {
        e.preventDefault(); // prevent native submit
        $.ajax({
          type : 'post',
          contentType : 'application/json',
          url: 'http://localhost:8080/generate',
          data: toJSONString(this),
          xhrFields: {
            responseType: 'blob'
          },
          success: function (data) {
            var a = document.createElement('a');
            var url = window.URL.createObjectURL(data);
            a.href = url;
            a.download = 'generated.epub';
            a.click();
            window.URL.revokeObjectURL(url);
        }
        });
      })
    });
  </script>
  </body>
 </html>

入力フォーム受付用Bean

入力フォームからの値をサーバー側で受け付けるためのBeanです。ただのBeanなので、さらっと流して次に進みましょう。

package jp.kutsuna.epub.generator;

import lombok.Data;

@Data
public class BookInformation {
    private String book_id;
    private String publisher;
    private String creator;
    private String title;
    private String language;
    private String tocContent;
    private String bookContent;
}

XML生成用コードの準備

EPUBファイルはXMLフォーマットのものをいくつか利用します。今回ご紹介するEPUB GeneratorでもいくつかXMLファイルを出力する必要があるため、Jacksonが提供するXMLMapperを採用しています。

そのため、以下のようなXML出力用のBeanをいくつか定義しているのですが、すべてを乗せてしまうと記事が溢れてしまうので、割愛致します。

コードはGitHub(https://github.com/kutsuna/epub-generator)でも公開していますので、そちらをご覧ください。

(XMLBeanの一例:Containerファイル生成用Bean)

package jp.kutsuna.epub.entity.container;

import com.fasterxml.jackson.dataformat.xml.annotation.*;

import java.util.List;

@JacksonXmlRootElement(localName = "container", 
  namespace = "urn:oasis:names:tc:opendocument:xmlns:container")
public class Container {

    @JacksonXmlProperty(localName="version", isAttribute = true)
    public String version;

    @JacksonXmlElementWrapper(localName = "rootfiles", 
      namespace = "urn:oasis:names:tc:opendocument:xmlns:container")
    @JacksonXmlProperty(localName="rootfile", 
      namespace = "urn:oasis:names:tc:opendocument:xmlns:container")
    public List<Rootfile> rootfiles;
}

Generator起動処理

サービス起動用のmain処理です。SpringBootの説明が目的ではないので、ここもさくっと行きましょう。Web APIとして利用できるようにするため、RestControllerとして定義しています。また、Epub生成サービスのパスとして、「/generate」を指定しています。

package jp.kutsuna.epub;

import com.fasterxml.jackson.databind.JsonMappingException;
import jp.kutsuna.epub.EPubFileGenerator;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.*;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.config.annotation.*;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

@RestController
@EnableAutoConfiguration
public class Main {

    @Autowired
    private EPubFileGenerator ePubFileGenerator;

    @Bean
    public EPubFileGenerator ePubFileGenerator(
      MeterRegistry registry) {
        return new EPubFileGenerator(registry);
    }

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurerAdapter() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**").allowedOrigins("*");
            }
        };
    }

    @RequestMapping(value="/generate", 
      method = {RequestMethod.POST}, 
      consumes = MediaType.APPLICATION_JSON_VALUE)
    ResponseEntity<Resource> generate(
        @RequestBody BookInformation bookInformation) 
        throws IOException, JsonMappingException {

        File epubFile = ePubFileGenerator.generate(bookInformation);

        try {
            InputStreamResource resource = 
                new InputStreamResource(new FileInputStream(epubFile));
            return ResponseEntity.ok()
                    .contentType(MediaType.parseMediaType("application/zip"))
                    .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=\"generated.epub\"")
                    .body(resource);
        } finally {
            epubFile.delete();
        }
    }

    public static void main(String[] args) throws Exception {
        System.setProperty("spring.devtools.restart.enabled", "false");
        SpringApplication.run(Main.class, args);
    }
}

EPUBファイル生成のメイン処理

準備も整ったので、肝心のEPUBファイル生成部分のロジックを実装します。まずはコード全体をご覧ください。

package jp.kutsuna.epub;

import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator;
import jp.kutsuna.epub.BookInformation;
import jp.kutsuna.epub.entity.container.Container;
import jp.kutsuna.epub.entity.container.Rootfile;
import jp.kutsuna.epub.entity.packagedocument.*;
import jp.kutsuna.epub.entity.packagedocument.Package;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class EPubFileGenerator {
    private static final String OEBPS_DIR = "OEBPS/";
    private static final String META_INF_DIR = "META-INF/";
    private static final String MEDIA_TYPE_FILE = "mimetype";
    private static final String PACKAGE_DOCUMENT_FILENAME = "package.opf";
    private static final String CONTAINER_FILENAME = "container.xml";
    private static final String NAVIGATION_FILE = "toc.xhtml";
    private static final String BOOK_CONTENT_FILE = "main.xhtml";
    private static final String EPUB_FILE = "output.epub";

    private static final String MEDIA_TYPE_EPU
         = "application/oebps-package+xml";

    private final Counter counter;

    @Autowired
    public EPubFileGenerator(MeterRegistry registry) {
        this.counter = registry.counter("service.invoked.generate");
    }

    private void createMimetypeFile(String targetDir) throws IOException {
        try(BufferedWriter writer = new BufferedWriter(
            new FileWriter(targetDir + MEDIA_TYPE_FILE))) {
            writer.write("application/epub+zip");
        }
    }

    private void createContainerFile(String targetDir)
    throws IOException, JsonMappingException {

        new File(targetDir + META_INF_DIR).mkdir();

        Container container = new Container();
        container.version = "1.0";

        List<Rootfile> rootfiles = new ArrayList<>();
        Rootfile rootfile = new Rootfile();
        rootfile.fullPath = OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME;
        rootfile.mediaType = MEDIA_TYPE_EPUB;
        rootfiles.add(rootfile);
        container.rootfiles = rootfiles;

        ObjectMapper xmlMapper = new XmlMapper();
        ((XmlMapper) xmlMapper).enable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION);
        ((XmlMapper) xmlMapper).enable(SerializationFeature.INDENT_OUTPUT);
        xmlMapper.writeValue(new File(targetDir + META_INF_DIR + CONTAINER_FILENAME), container);
    }

    private String getCurrentTime() {
        return ZonedDateTime.now().format(
            DateTimeFormatter.ISO_INSTANT).replaceAll("\\..*Z$", "Z");
    }

    private void createPackageDocumentFile(
        String targetDir, BookInformation bookInformation)
        throws IOException, JsonMappingException {

        ObjectMapper xmlMapper = new XmlMapper();
        ((XmlMapper) xmlMapper).enable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION);
        ((XmlMapper) xmlMapper).enable(SerializationFeature.INDENT_OUTPUT);

        // TODO Builderに置換
        Package packageElement = new Package();
        packageElement.uniqueIdentifier = "idName";
        packageElement.version = "3.0";
        packageElement.language = "ja";
        Metadata metadata = new Metadata();
        Identifier identifier = new Identifier();
        identifier.id = packageElement.uniqueIdentifier;
        identifier.value = bookInformation.getBook_id();
        metadata.identifier = identifier;
        metadata.publisher = bookInformation.getPublisher();
        metadata.creator = bookInformation.getCreator();
        metadata.title = bookInformation.getTitle();
        metadata.language = bookInformation.getLanguage();
        packageElement.metadata = metadata;
        Meta meta = new Meta();
        meta.property = "dcterms:modified";
        meta.value = getCurrentTime();
        metadata.meta = meta;
        List<Item> manifest = new ArrayList<>();
        Item nav = new Item();
        nav.id = "nav";
        nav.href = NAVIGATION_FILE;
        nav.mediaType = "application/xhtml+xml";
        nav.properties = "nav";
        manifest.add(nav);
        Item main = new Item();
        main.id = "main_xhtml";
        main.href = BOOK_CONTENT_FILE;
        main.mediaType = "application/xhtml+xml";
        manifest.add(main);
        packageElement.manifest = manifest;
        Spine spine = new Spine();
        spine.pageProgressionDirection = 
            bookInformation.getPageProgressionDirection() == null ? 
                "default" : bookInformation.getPageProgressionDirection();
        List<Itemref> itemrefs = new ArrayList<>();
        Itemref navItemref = new Itemref();
        navItemref.idref = "nav";
        itemrefs.add(navItemref);
        Itemref mainItemref = new Itemref();
        mainItemref.idref = "main_xhtml";
        itemrefs.add(mainItemref);
        spine.itemref = itemrefs;
        packageElement.spine = spine;
        xmlMapper.writeValue(
            new File(targetDir + OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME),
            packageElement);
    }

    private void createNavigationFile(
        String targetDir, String tocContent) throws IOException {
        try(BufferedWriter writer = new BufferedWriter(
            new FileWriter(targetDir + OEBPS_DIR + NAVIGATION_FILE))) {
            writer.write(tocContent);
        }
    }

    private void createBookContentFile(String targetDir, String bookContent)
    throws IOException {
        try(BufferedWriter writer = new BufferedWriter(
            new FileWriter(targetDir + OEBPS_DIR + BOOK_CONTENT_FILE))) {
            writer.write(bookContent);
        }
    }

    private void addEntryAndWrite(
        ZipOutputStream zos, File file, ZipEntry entry) throws IOException {
        zos.putNextEntry(entry);

        byte[] buf = new byte[1024];
        try (InputStream is = new BufferedInputStream(
            new FileInputStream(file))) {
            int len = 0;
            while ((len = is.read(buf)) != -1) {
                zos.write(buf, 0, len);
            }
        }
        zos.closeEntry();
    }

    private File compressAndDeleteWorkFile(String targetDir) 
    throws FileNotFoundException, IOException {
        ZipOutputStream zos = null;
        File zipFile = Files.createTempFile("temp_", ".epub").toFile();
        try {
            zos = new ZipOutputStream(new BufferedOutputStream(
                new FileOutputStream(zipFile.getAbsolutePath())));
            zos.setLevel(0);
            File file = new File(targetDir + MEDIA_TYPE_FILE);
            addEntryAndWrite(zos, file, new ZipEntry(file.getName()));

            zos.setLevel(9);

            zos.putNextEntry(new ZipEntry(META_INF_DIR));
            addEntryAndWrite(zos,
                new File(targetDir + META_INF_DIR + CONTAINER_FILENAME),
                new ZipEntry(META_INF_DIR + CONTAINER_FILENAME));

            zos.putNextEntry(new ZipEntry(OEBPS_DIR));
            addEntryAndWrite(zos, 
                new File(targetDir + OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME), 
                new ZipEntry(OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME));
            addEntryAndWrite(zos, 
                new File(targetDir + OEBPS_DIR + NAVIGATION_FILE), 
                new ZipEntry(OEBPS_DIR + NAVIGATION_FILE));
            addEntryAndWrite(zos, 
                new File(targetDir + OEBPS_DIR + BOOK_CONTENT_FILE), 
                new ZipEntry(OEBPS_DIR + BOOK_CONTENT_FILE));

            // delete workfile
            new File(targetDir + MEDIA_TYPE_FILE).delete();
            new File(targetDir + META_INF_DIR + CONTAINER_FILENAME).delete();
            new File(
                targetDir + OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME).delete();
            new File(targetDir + OEBPS_DIR + NAVIGATION_FILE).delete();
            new File(targetDir + OEBPS_DIR + BOOK_CONTENT_FILE).delete();

        } finally {
            if(zos != null) {
                zos.close();
            }
        }

        return zipFile;
    }

    public File generate(BookInformation bookInformation) 
    throws IOException, JsonMappingException {
        counter.increment();

        String targetDir = "./output/" + UUID.randomUUID() + "/";
        new File(targetDir).mkdir();
        new File(targetDir + OEBPS_DIR).mkdir();

        createMimetypeFile(targetDir);
        createContainerFile(targetDir);
        createPackageDocumentFile(targetDir, bookInformation);
        createNavigationFile(targetDir, bookInformation.getTocContent());
        createBookContentFile(targetDir, bookInformation.getBookContent());
        return compressAndDeleteWorkFile(targetDir);
    }
}

全体を見ていただいた上で、少しずつ段階を踏んで整理します。

generateメソッド

まず、コード最下部にあるgenerateメソッドです。

    public File generate(BookInformation bookInformation)
    throws IOException, JsonMappingException {
        String targetDir = "./output/" + UUID.randomUUID() + "/";
        new File(targetDir).mkdir();
        new File(targetDir + OEBPS_DIR).mkdir();

        createMimetypeFile(targetDir);
        createContainerFile(targetDir);
        createPackageDocumentFile(targetDir, bookInformation);
        createNavigationFile(targetDir, bookInformation.getTocContent());
        createBookContentFile(targetDir, bookInformation.getBookContent());
        return compressAndDeleteWorkFile(targetDir);
    }

ここで、EPUBファイルを組み立てています。EPUBファイルは最終的にZIP圧縮する必要があるので次の手順を踏んでいます。

  1. 圧縮対象ファイル一式を配置するための一時フォルダーを作成。
  2. 作成した一時フォルダーにEPUBを構成するファイル一式を保存。
  3. 生成ファイル一式をZIP圧縮。ZIP圧縮完了後、すべての生成ファイルを削除

一時的に作成したファイル内容をメモリ上で管理してしまうと、メモリへの負荷が高くなってしまうので、すべてファイルとして出力する方法を取っています。

createMimetypeFileメソッド

Mimetypeファイル生成用のメソッドです。ファイルパスも内容も決まっているので、シンプルな実装です。

    private void createMimetypeFile(String targetDir)
    throws IOException {
        try(BufferedWriter writer = new BufferedWriter(
                new FileWriter(targetDir + MEDIA_TYPE_FILE))) {
            writer.write("application/epub+zip");
        }
    }

createContainerFileメソッド

META-INF/container.xmlを生成するためのメソッドです。ここでrootfileのパスが指定できますが、決め打ちで「OEBPS/package.opf」としています。

    private void createContainerFile(String targetDir)
    throws IOException, JsonMappingException {
        new File(targetDir + META_INF_DIR).mkdir();

        Container container = new Container();
        container.version = "1.0";

        List<Rootfile> rootfiles = new ArrayList<>();
        Rootfile rootfile = new Rootfile();
        rootfile.fullPath = OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME;
        rootfile.mediaType = MEDIA_TYPE_EPUB;
        rootfiles.add(rootfile);
        container.rootfiles = rootfiles;

        ObjectMapper xmlMapper = new XmlMapper();
        ((XmlMapper) xmlMapper).enable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION);
        ((XmlMapper) xmlMapper).enable(SerializationFeature.INDENT_OUTPUT);
        xmlMapper.writeValue(
            new File(targetDir + META_INF_DIR + CONTAINER_FILENAME),
            container);
    }

createPackageDocumentFileメソッド

タイトルや著者、出版社などの、書籍の基本情報を管理するファイルの生成を行うメソッドです。メソッドの中身はちょっとボリュームがありますが、行っている内容は単純なXMLファイル出力です。 (Builderを利用すればよかったのですが、冗長なコードになってしまいました。手抜きですみません。。)

    private void createPackageDocumentFile(
        String targetDir, BookInformation bookInformation)
    throws IOException, JsonMappingException {
        ObjectMapper xmlMapper = new XmlMapper();
        ((XmlMapper) xmlMapper).enable(
            ToXmlGenerator.Feature.WRITE_XML_DECLARATION);
        ((XmlMapper) xmlMapper).enable(
            SerializationFeature.INDENT_OUTPUT);

        Package packageElement = new Package();
        packageElement.uniqueIdentifier = "idName";
        packageElement.version = "3.0";
        packageElement.language = bookInformation.getLanguage();
        Metadata metadata = new Metadata();
        Identifier identifier = new Identifier();
        identifier.id = packageElement.uniqueIdentifier;
        identifier.value = bookInformation.getBook_id();
        metadata.identifier = identifier;
        metadata.publisher = bookInformation.getPublisher();
        metadata.creator = bookInformation.getCreator();
        metadata.title = bookInformation.getTitle();
        metadata.language = bookInformation.getLanguage();
        packageElement.metadata = metadata;
        Meta meta = new Meta();
        meta.property = "dcterms:modified";
        meta.value = getCurrentTime();
        metadata.meta = meta;
        List<Item> manifest = new ArrayList<>();
        Item nav = new Item();
        nav.id = "nav";
        nav.href = NAVIGATION_FILE;
        nav.mediaType = "application/xhtml+xml";
        nav.properties = "nav";
        manifest.add(nav);
        Item main = new Item();
        main.id = "main_xhtml";
        main.href = BOOK_CONTENT_FILE;
        main.mediaType = "application/xhtml+xml";
        manifest.add(main);
        packageElement.manifest = manifest;
        Spine spine = new Spine();
        spine.pageProgressionDirection = 
            bookInformation.getPageProgressionDirection() == null ?  
                "default" : bookInformation.getPageProgressionDirection();
        List<Itemref> itemrefs = new ArrayList<>();
        Itemref navItemref = new Itemref();
        navItemref.idref = "nav";
        itemrefs.add(navItemref);
        Itemref mainItemref = new Itemref();
        mainItemref.idref = "main_xhtml";
        itemrefs.add(mainItemref);
        spine.itemref = itemrefs;
        packageElement.spine = spine;
        xmlMapper.writeValue(
            new File(targetDir + OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME), 
            packageElement);
    }

createNavigationFileメソッド & createBookContentFileメソッド

目次と、本の内容をそれぞれファイルとして保存する処理です。今回は、入力フォームから直接内容を受け取り、保存するシンプルな方法を取っています。

    private void createNavigationFile(String targetDir, String tocContent) 
    throws IOException {
        try(BufferedWriter writer = new BufferedWriter(
            new FileWriter(targetDir + OEBPS_DIR + NAVIGATION_FILE))) {
            writer.write(tocContent);
        }
    }

    private void createBookContentFile(String targetDir, String bookContent)
    throws IOException {
        try(BufferedWriter writer = new BufferedWriter(
            new FileWriter(targetDir + OEBPS_DIR + BOOK_CONTENT_FILE))) {
            writer.write(bookContent);
        }
    }

Zipファイル生成

最後に、これまでに作成したファイルのZip圧縮を行う処理です。 一時ファイルとしてZipファイルを作成し、その中にEPUBコンテンツを1つずつ登録しています。

private void addEntryAndWrite(ZipOutputStream zos, File file, ZipEntry entry)
throws IOException {
        zos.putNextEntry(entry);

        byte[] buf = new byte[1024];
        try (InputStream is = new BufferedInputStream(
            new FileInputStream(file))) {
            int len = 0;
            while ((len = is.read(buf)) != -1) {
                zos.write(buf, 0, len);
            }
        }
        zos.closeEntry();
    }

    private File compressAndDeleteWorkFile(String targetDir)
    throws FileNotFoundException, IOException {
        ZipOutputStream zos = null;
        File zipFile = Files.createTempFile("temp_", ".epub").toFile();
        try {
            zos = new ZipOutputStream(new BufferedOutputStream(
                new FileOutputStream(zipFile.getAbsolutePath())));
            zos.setLevel(0);
            File file = new File(targetDir + MEDIA_TYPE_FILE);
            addEntryAndWrite(zos, file, new ZipEntry(file.getName()));

            zos.setLevel(9);

            zos.putNextEntry(new ZipEntry(META_INF_DIR));
            addEntryAndWrite(zos, 
                new File(targetDir + META_INF_DIR + CONTAINER_FILENAME), 
                new ZipEntry(META_INF_DIR + CONTAINER_FILENAME));

            zos.putNextEntry(new ZipEntry(OEBPS_DIR));
            addEntryAndWrite(zos, 
                new File(targetDir + OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME), 
                new ZipEntry(OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME));
            addEntryAndWrite(zos, 
                new File(targetDir + OEBPS_DIR + NAVIGATION_FILE), 
                new ZipEntry(OEBPS_DIR + NAVIGATION_FILE));
            addEntryAndWrite(zos, 
                new File(targetDir + OEBPS_DIR + BOOK_CONTENT_FILE), 
                new ZipEntry(OEBPS_DIR + BOOK_CONTENT_FILE));

            // delete workfile
            new File(targetDir + MEDIA_TYPE_FILE).delete();
            new File(targetDir + META_INF_DIR + CONTAINER_FILENAME).delete();
            new File(
                targetDir + OEBPS_DIR + PACKAGE_DOCUMENT_FILENAME).delete();
            new File(targetDir + OEBPS_DIR + NAVIGATION_FILE).delete();
            new File(targetDir + OEBPS_DIR + BOOK_CONTENT_FILE).delete();

        } finally {
            if(zos != null) {
                zos.close();
            }
        }

        return zipFile;
    }

ここでEPUBファイルの仕様上、1点注意することがあります。それは、「EPUBであるzipファイルの先頭ファイルは非圧縮の状態のmimetypeファイルを含んでいる必要がある」という仕様です。 そのため、mimetypeはZipファイルの最初にエントリし、かつ圧縮レベルを0(非圧縮)にする必要があります。それを実現しているのが、以下のコードです。

zos.setLevel(0);

残りのファイルについては、圧縮レベルを9(圧縮率最高)にしてエントリしています。
以上で実装完了です。最後に、実際に動作させてみます。

動作確認編

実際に作ったものをビルドして、実行します。ビルドに必要なpom.xmlはGitHubにあげてありますので、そちらをご参考ください。

https://github.com/kutsuna/epub-generator

ビルド & 実行

プロジェクト直下にpom.xmlを用意しています。実行可能なjarファイルを作成するため、以下のようにコマンドを実行してください。

mvn clean install

targetフォルダー配下に、epub-generator-1.0-SNAPSHOT.jarファイルが生成されるはずです。 jarファイルが生成されたことを確認したら、実際にサービスを起動してみましょう。

java -jar epub-generator-1.0-SNAPSHOT.jar

問題なければ8080ポートでサービスが立ち上がるはずです。
次に、フォームの準備をしていきましょう。nginxなどWebサーバを利用しても良いですが、ここではPythonのワンライナーWebサーバを利用します。 index.htmlを配置したフォルダーにて、以下のようにコマンドを実行します。

 sudo python -m SimpleHTTPServer 80

あとはブラウザでhttp://localhost/index.htmlにアクセスすれば入力フォームが表示されます。

EPUBファイル生成

EPUBファイルを実際に生成してみましょう。書籍ID、出版社、著者、書籍タイトル、言語は適当に値を入力してください。 なお、目次と本文は実際の書籍の内容となりますので、以下のような形の内容を入力してください。

(目次内容)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
  <title>目次</title>
</head>
<body>
  <nav epub:type="toc">
    <h1>目次</h1>
    <ol>
      <li><a href="main.xhtml">最初のページ</a></li>
    </ol>
  </nav>
</body>
</html>

(本文内容)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" 
  xmlns:epub="http://www.idpf.org/2007/ops">
<head>
  <title>本文</title>
</head>
<body>
手作り電子書籍です。
</body>
</html>

準備ができたら「送信」ボタンをクリックします。

図:フォームイメージ

処理完了後、生成されたEPUBファイルがダウンロードされます。

図:生成されたEPUB

まとめ

今回は本当にシンプルな構造のEPUBを作成するジェネレーターを作成しました。
ただ、書籍の内容が変ろうとも、電子書籍の基礎的な構造は変わらないので、縦書きやコミックのような画像をメインとするコンテンツの作成を行うツールも拡張次第では可能となります。
色々とアレンジしてみてもおもしろいと思いますので、試してみてください。

おわりに

今回はTech Do Book の紹介でした。 現在vol.5まで発行されている合同誌には、エンジニア組織が選択している技術、Go言語に関する内容、エンジニア採用の話など様々なコンテンツが入っています。 興味のある方は、こちらのリンクから無料ダウンロードいただけます! booth.pm