421 - 《如何生成 epub》
发布于 2024年3月12日
1、背景是之前翻译 Eloquent-JavaScript 这本书时,他生成的 xhtml 文件有个 bug,导致用 Immersive Translate 翻译时会报语法错误而无法解析,翻了下代码,手动修了下。这个 bug 今天去看时,作者自己也修复了,见 commit 。
2、作者基于 md 生成 html 的代码是写在仓库里的,感觉可以封装成一个通用的工具,于是就找时间研究了下 epub 的生成。简单实现了一个从包含 Markdown 文件的目录生成 epub 的类。上传到 readwise 上阅读的效果见下图,支持 TOC 等。
3、epub 我理解是一个压缩后的文件夹,解压后的最简文件组成如下。
+ META-INF
- container.xml
+ OEBPS
- content.opf
- toc.ncx
- mimetype
- mimetype,内容始终为
application/epub+zip
,注:此文件不可压缩 - META-INF/container.xml,固定路径,用于指定 opf 文件路径
- OEBPS/content.opf,非固定路径,描述 epub 书的原信息,标题、作者、封面、包含的文件清单等
- OEBPS/toc.ncx,非固定路径,描述目录和导航
4、epub 有两个在用的标准,epub2 和 epub3,在 opf 文件中通过 version
来声明。比较大区别是,epub2 只支持 xhtml,epub3 在此基础上支持了 html5。现在大部分用的应该都是 epub3,但是我解压了很多 epub 文件,发现大部分还是用的 xhtml。
5、要生成一个 epub 文件,一共有两步,1)按上面的目录组织文件,2)生成一个 epub 后缀的 zip 文件。生成 zip 文件时要也要分两步,1)添加 mimetype 文件,不能压缩,2)添加其他文件,可以压缩。我用于生成 zip 文件的脚本如下。
$ zip -X0 ../test.book.epub mimetype && zip -rDX9 ../test.book.epub * -x mimetype
6、我用于把 Markdown 目录生成 epub 文件的代码如下。
epub.ts
import { Markdown } from './markdown';
import assert from 'assert';
import fs from 'fs';
import path from 'path';
import os from 'os';
interface EpubOpts {
title: string;
author: string;
root: string;
pubId: string;
pubUrl?: string;
updatedAt?: Date;
}
interface MdInfo {
attributes: {
title: string;
order: number;
};
content: string;
path: string;
fileName: string;
html: string;
}
export class Epub {
#opts: EpubOpts;
constructor(opts: EpubOpts) {
this.#opts = opts;
}
async generate() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `epub-${randomString(6)}`));
console.log('tmpDir', tmpDir);
const mdFiles = await this.#parseMdFiles(tmpDir);
// prepare dirs
fs.mkdirSync(path.join(tmpDir, 'META-INF'));
fs.mkdirSync(path.join(tmpDir, 'OEBPS'));
// mimetype
fs.writeFileSync(path.join(tmpDir, 'mimetype'), 'application/epub+zip');
// META-INF/container.xml
fs.writeFileSync(path.join(tmpDir, 'META-INF', 'container.xml'), `
<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
`.trimStart());
// OEBPS/content.opf
const chapterItems = mdFiles.map((mdFile) => {
return `<item id="${mdFile.fileName}" href="${mdFile.fileName}.html" media-type="text/html" />`;
}).join('\n');
const chapterSpine = mdFiles.map((mdFile, i) => {
return `<itemref idref="${mdFile.fileName}" />`;
}).join('\n');
fs.writeFileSync(path.join(tmpDir, 'OEBPS', 'content.opf'), `
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" xml:lang="en" unique-identifier="pub-id" prefix="cc: http://creativecommons.org/ns#">
<