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#">
  <

内容预览已结束

此内容需要会员权限。请先登录以查看完整内容。