読者です 読者をやめる 読者になる 読者になる

M12i.

学術書・マンガ・アニメ・映画の消費活動とプログラミングについて

JavaでEPUB3形式ファイルをパッケージングする

Java

直近、例によっていろいろあってEPUB3形式ファイルのつくり方を調べていました。EPUB3はごくごく単純化して言えば拡張子が.epubで終わるZIPファイルなのですが、ディレクトリ構造や必須のファイルなどいくつかのルールがあります。こちらのページにある手順がサンプル・ファイルも提示されていて親切だったので、これをもとにJavaコードでパッケージングを行うサンプルを書いてみました(本題とは関係ありませんが、サンプルではStream APIおよびラムダ式と例外概念の相性の悪さが際立っています)。

EPUB3ファイル構造の詳細はリンク先の説明やそこで紹介されている東北大学が公開しているテキスト(EPUB3形式)をダウンロードしてみていただきたいですが、おおよその構造は以下のとおり:

seitaitekiou ←ベースディレクトリ
│  mimetype ←必須(こいつだけ非圧縮でZIPに投入)
│  ...
│
├─META-INF
│      container.xml ←必須
│      ...
│
└─OEBPS ←ディレクトリ名は自由
    │  content.opf ←container.xmlでパスを指定する必要あり
    │  ...
    │
    ├─images
    │      hyo1-1.jpg
    │      hyo1-2.jpg
    │      hyo7-1.jpg
    │      ...
    │
    ├─style
    │      nav.css
    │      ...
    │
    └─text
            1-01.xhtml
            2-01.xhtml
            3-01.xhtml
			...

まずimport文の抜粋。使用しているクラスのうちサンプルに固有のものは以下のとおり:

import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

エントリーポイントは以下のとおり。mimetypeファイルとそれ以外とで異なる方法でZIPファイルに追加しているのがポイント(紙幅の都合でメソッドの修飾子は省いています):

void main(String...args) throws Exception {
	// パッケージ対象ファイルが格納されたディレクトリのパス
	final Path srcPath = Paths.get("seitaitekiou");
	// その配下のmimetypeファイルのパス
	final Path mimeTypePath = srcPath.resolve("mimetype");
	// パッケージ後のEPUB3ファイル名のパス
	final Path epubPath = Paths.get("seitaitekiou.epub");
	// 古いパッケージ済みファイルがあれば削除
	Files.deleteIfExists(epubPath);
	
	// ZipOutputStreamを初期化する
	// *キャラクターセット指定のないコンストラクタを使用しているが、デフォルトでUTF-8が使用されるのでEPUB3的に問題なし
	try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(epubPath))) {
		// まずmimetypeファイルだけは非圧縮でZIPコンテナに投入
		putEntryWithStoredMethod(zipOut, mimeTypePath);
		
		// 続いてその他のファイルを圧縮してZIPコンテナに投入
		// *Stream APIを使用してベースとなるディレクトリ配下のファイルエントリーを処理
		Files.list(srcPath).forEach(p -> putEntriesRecursively(zipOut, p, mimeTypePath));
		
	}catch (Exception e) {
		e.printStackTrace();
	}
}

エントリーポイントから呼び出されているメソッドの定義は以下のとおり(こちらも紙幅の都合でメソッドの修飾子は省いています):

/**
 * ZIPコンテナに指定されたパスが指すファイルを投入する.
 * 当該パスがディレクトリを指している場合はその配下のエントリーのそれぞれについて自身を再帰的に適用する。
 * 当該パスが第3引数で指定された除外対象パスに含まれている場合はこのメソッドは何もせずに処理を終える。
 * @param stream ZIPコンテナのための出力ストリーム
 * @param fileOrDirectory ファイルもしくはディレクトリを指すパス
 * @param excludes 除外対象パス
 */
void putEntriesRecursively(final ZipOutputStream stream,
		final Path fileOrDirectory, final Path... excludes) {
	// 当該パスが除外対象パスに含まれていないかチェック
	if (Arrays.stream(excludes).anyMatch(fileOrDirectory::equals)) {
		// 含まれている場合は何もせずに処理を終える
		return;
	}
	
	// 当該パスがディレクトリを指すものでないかチェック
	if (Files.isDirectory(fileOrDirectory)) {
		// ディレクトリを指す場合は配下のエントリーを対象に再帰的に自身を適用する
		subEntries(fileOrDirectory).forEach(p -> putEntriesRecursively(stream, p));
	} else {
		// ファイルを指す場合はZIPコンテナに投入する
		putEntry(stream, fileOrDirectory);
	}
}

/**
 * ZIPコンテナに指定されたパスが指すファイルを非圧縮で投入する.
 * @param zipOut ZIPコンテナのための出力ストリーム
 * @param file ファイルを指すパス
 */
void putEntryWithStoredMethod(final ZipOutputStream zipOut, final Path file) {
	try {
		// ファイルの内容を一括で読み取ってしまう
		final byte[] data = Files.readAllBytes(file);
		// CRCを初期化
		final CRC32 crc32 = new CRC32();
		// ZIPエントリーを初期化
		final ZipEntry entry = new ZipEntry(pathInZipContainer(file));
		// ファイル内容サイズとCRCをエントリーに登録
		entry.setSize(data.length);
		entry.setCrc(crc32.getValue());
		// ZIPコンテナにエントリーとそのデータを登録
		zipOut.putNextEntry(entry);
		zipOut.write(data);
		
	}catch (IOException e) {
		// メソッドをStream APIとともに使用するためチェック例外は非チェック例外でラップする
		// *おそらく「アンチパターン」なプラクティスに該当する
		throw new RuntimeException(e);
	}
}

/**
 * ZIPコンテナに指定されたパスが指すファイルを投入する.
 * @param zipOut ZIPコンテナのための出力ストリーム
 * @param file ファイルを指すパス
 */
void putEntry(final ZipOutputStream zipOut, final Path file) {
	try {
		final byte[] buf = new byte[1024];
		final ZipEntry entry = new ZipEntry(pathInZipContainer(file));
		zipOut.putNextEntry(entry);
		try (InputStream fileIn = Files.newInputStream(file)) {
			while(true) {
				int len = fileIn.read(buf);
				if (len < 0) break;
				zipOut.write(buf, 0, len);
			}
		}
	}catch (IOException e) {
		throw new RuntimeException(e);
	}
}

/**
 * ZIPエントリー向けのファイルパスを生成する.
 * @param original 元のパス
 * @return ZIPエントリー向けのファイルパス文字列
 */
String pathInZipContainer(Path original) {
	// *originalが表すパスが「パッケージ対象ファイルが格納された
	// ディレクトリ名から始まる相対パス」であることを暗黙の前提としている
	return original.toString().replace('\\', '/').replaceFirst("^[^/]+/", "");
}

/**
 * 指定されたパスの配下(直下)のエントリーを内容とするストリームを生成する.
 * @param dir ディレクトリを指すパス
 * @return 配下のエントリーを内容とするストリーム
 */
Stream<Path> subEntries(final Path dir) {
	// Files.list(...)メソッドをStream APIとともに使用するためチェック例外は非チェック例外でラップする
	// *おそらく「アンチパターン」なプラクティスに該当する
	try {
		return Files.list(dir);
	} catch (IOException e) {
		throw new RuntimeException(e);
	}
}