用 Markdown 写一本自己的电子书吧(二)自动篇

在之前单《手动篇》里,我们已经手动完成了打包一个 .epub 所需要的基本文件内容,并且梳理出可以通过工具自动完成的流程,以及需要补充信息来完成的流程。

这次我们正式开始动手,编码实现我们的电子书生成小工具了。

开始动手:自动篇

1. 创建项目

创建一个目录 kepub 执行 npm init -y,然后修改 package.json 文件,设置 "type": "module" 启用 ES Module 模式。

安装 eslint,以及我们选择的 airbnb 标准和相关依赖:

1
npm i -D eslint eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-import

然后根据自己的需要,微调一下 ESLint 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# .eslintrc.yml
parserOptions:
ecmaVersion: 2021
sourceType: module
extends:
- airbnb
rules:
indent:
- off
- 4
no-console: off
import/extensions:
- warn
- always
- js: always

我们选用 marked 进行 Markdown 文章的渲染,再通过 cheerio 对文章中使用到的图片等资源进行解析和收集整理。
最后的 zip 打包的话用 adm-zip 来处理,它基于纯 node.js 实现,不依赖原生程序,确保我们的项目即可直接运行,不需要对 win/mac/linux 做专门的适配。

1
npm i -S marked cheerio adm-zip

2. 入口

在项目的入口文件 index.js 中,我们约定传入的第一个参数为需要处理的电子书目录,其中存在对应 book.json 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// index.js
import fs from 'fs/promises';
import path from 'path';

const work = async (target) => {
const targetDir = path.resolve(target);
await fs.access(targetDir);
if (!(await fs.stat(targetDir)).isDirectory()) {
console.error(`Target is not directory: ${JSON.stringify(target)}.`);
process.exit(1);
}
const configPath = path.join(targetDir, 'book.json');
try {
await fs.access(configPath);
} catch (ex) {
console.error(`Can't find "book.json" in target ${JSON.stringify(target)}.`);
process.exit(1);
}
if (!(await fs.stat(configPath)).isFile()) {
throw new Error('ConfigError: "book.json" is not file.');
}
const config = JSON.parse(await fs.readFile(configPath));
// TODO: 更多参数检查

// TODO: 开始处理
};

work(...process.argv.slice(2));

上面是一些处理工作的基础参数检查,根据实际需要,还可以进一步补充详细的 book.json 格式校验,这里就不再赘述。

3. 基础渲染

对于电子书的基础文件和 meta 信息部分,我们直接基于模板字符串配合传参就可以实现对应渲染函数。

比如渲染 package.opf 文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const renderPackageOpf = ({ meta }) = `
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<package xmlns="http://www.idpf.org/2007/opf"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
version="3.0"
xml:lang="${meta.lang}"
unique-identifier="pub-identifier">
<metadata>
<dc:identifier id="pub-identifier">${meta.id}</dc:identifier>
<dc:title id="pub-title">${meta.title}</dc:title>
<dc:language id="pub-language">${meta.lang}</dc:language>
<dc:date>${meta.date}</dc:date>
<meta property="dcterms:modified">${meta.modified}</meta>
</metadata>
<manifest>
<!-- TODO -->
</manifest>
<spine>
<!-- TODO -->
</spine>
</package>
`.trimStart();

如果有兴趣,还可以把其中的 id, date, modified 字段也改为自动生成机制,进一步减少创建电子书时的手动工作。

在处理流程中,只要调用上面的渲染函数,传入 book.json 的配置,即可得到电子书 package.opf 文件基本结构。

其中的 manifestspine 部分还需要整个电子书渲染完成后的相关资源配置参数,这里暂时留空。

1) 提取模板文件

虽然上面的渲染函数已经可以工作了,但可以看出一个明显问题:

渲染函数内的字符串内容格式是 xml,但是在我们的代码里编写时,只会被 IDE 当成普通的字符串,没有任何代码高亮和校验处理。对其中的内容做修改调正时,如果发生误删字符之类的格式问题,没法在编码阶段快速发现。

所以我们在这里做个小优化,把上面字符串模板的内容提取到 templates/EPUB/package.opf.xml 文件内,然后再重新实现一个 render 函数:

  • 通过传入模板名字 templateName,找到 templates 目录下对应的模板文件,读取为模板字符串。
  • 传入渲染参数 args,将其中的字段解析后作为渲染参数注入到模板渲染函数 fn 内。
  • 执行渲染函数 fn,返回最终文件内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// scripts/render.js
import fs from 'fs/promises';
import path from 'path';

const dirname = path.dirname(import.meta.url.replace(/^file:\/\/?/, ''));

export const render = async (templateName, args = {}) => {
const filePath = path.join(dirname, '../templates', templateName);
try {
await fs.access(filePath);
} catch (ex) {
throw Error(`TemplateError: can't find template ${JSON.stringify(templateName)}\n to file: ${filePath}`);
}
const template = (await fs.readFile(filePath)).toString();
const argKeys = Object.keys(args);
const argValues = argKeys.map((key) => args[key]);
// eslint-disable-next-line no-new-func
const fn = new Function(...argKeys, `return \`${template}\`;`);
return fn(...argValues);
};

这样,我们就实现了一个通用的模板渲染函数。

除了 package.opf 之外,之前的 mimetypeMETA-INF/container.xml 文件也可以提取为模板目录 templates 内的文件,在整个流程中传入对应名字就能完成它们的渲染了。

2) Markdown 渲染

Markdown 的渲染需要提前做个转换处理,在传入要渲染的文件路径 filePath,读取其内容后调用 marked 进行转换即可得到页面 html 内容。

我们创建一个书籍页面通用的 templates/EPUB/book-page.xhtml 模板,调用上一步中实现的 render() 即可渲染成 EPUB 内的标准页面文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import fs from 'fs/promises';

import { marked } from 'marked';

export const renderMdPage = async (filePath, args = {}) => {
try {
await fs.access(filePath);
} catch (ex) {
throw Error(`RenderError: can't find file ${JSON.stringify(filePath)}`);
}
const markdown = await fs.readFile(filePath);
const content = marked.parse(markdown.toString());

// TODO: 收集标题、图片

const { title = 'Untitled Page' } = args;
return render('EPUB/book-page.xhtml', {
title,
content,
});
};

模板文件 templates/EPUB/book-page.xhtml 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:epub="http://www.idpf.org/2007/ops"
xml:lang="en"
lang="en">
<head>
<title>${title}</title>
</head>
<body>
${content}
</body>
</html>

4. 任务开始

在对电子书的处理过程中,我们需要根据 book.json 内的 pages 字段处理多个 Markdown 页面文件,并且保留它们的目录层级结构。而与此同时,每个页面文件内可能会引用多个图片资源。只有将页面和页面内引用到的资源信息进行汇总,最终才能生成全书的 资源清单书脊导航目录

这就需要我们在过程中一边渲染和生成文件,一边整理相关信息。

所以我们在项目里创建一个 Task 任务类,每次任务就创建一个它的实例负责处理。在任务过程中,它会有一个属于自己的临时目录保存过程中的中间文件,可以在自己的实例变量中缓存的资源信息。最后由它统筹生成上面提到的基础信息,打包成书,随后清理临时目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import fs from 'fs/promises';
import path from 'path';
import os from 'os';

import mkdirp from 'mkdirp';

const tempDir = path.join(os.tmpdir(), 'kepub');

export default class Task {
constructor(targetDir, config) {
this.targetDir = targetDir;
this.config = config;
this.state = 'idle';

const stamp = Date.now();
const taskName = `task_${stamp}_${Math.random()}`;
const taskDir = path.join(tempDir, taskName);

this.name = taskName;
this.saveDir = taskDir;

this.rendering = [];
this.pageToc = [];
this.pageList = [];
}

async writeFile(subPath, content) {
const { saveDir } = this;
const filePath = path.join(saveDir, subPath);
const dirPath = path.dirname(filePath);
await mkdirp(dirPath);
return fs.writeFile(filePath, content);
}

async run() {
if (this.state !== 'idle') {
throw new Error(`TaskError: current task state is not "idle", but ${JSON.stringify(this.state)}`);
}
this.state = 'running';

const { meta } = this.config;

const manifestList = [];
const spineList = [];
// TODO: 处理电子书,更新资源清单和导航目录

await Promise.all([
this.writeFile('mimetype', await render('mimetype')),
this.writeFile('META-INF/container.xml', await render('META-INF/container.xml')),
this.writeFile('EPUB/package.opf', await render('EPUB/package.opf.xml', {
meta,
manifestList,
spineList,
})),
]);

this.state = 'complete';
}
}

1) 渲染单个页面,记录资源

之前我们在 render.js 模块内对于 Markdown 页面的渲染函数中,有个收集标题和图片的 TODO,现在就到了把这个坑填上的时候了。

我们在 book.jsonpages 节点内定义 title 字段,但实际书籍标题时往往还是和内容一起更新的。所以我们尝试读取文件内第一个 <h1> 标题的文本作为默认标题。这里使用 Cheerio 进行处理:

1
2
3
4
5
6
7
8
9
export const renderMdPage = async (filePath, args = {}) => {
// ...
const markdown = await fs.readFile(filePath);
const html = marked.parse(markdown.toString());

const $ = loadHtml(html);
const firstH1 = $('h1').text();
// ...
};

对于页面内的图片,我们也可以这样通过 Cheerio 进行收集。

最后在返回值内告知外部任务实例,最终渲染的标题和用到的图片资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const renderMdPage = async (filePath, args = {}) => {
// ...
const markdown = await fs.readFile(filePath);
const html = marked.parse(markdown.toString());

const $ = loadHtml(html);
const firstH1 = $('h1').text();
const extractSrc = (_, el) => $(el).attr('src');
const images = $('img').map(extractSrc).get();

const {
title = firstH1 || 'Untitled Page',
} = args;
const content = await render('EPUB/book-page.xhtml', {
title,
content: html.replace(/(<img[^>]+[^/])>/g, '$1/>'),
});

return {
title, // 返回最终标题,以供任务生成目录
content, // 页面 *.xhtml 文件内容
images, // 页面内应用过的图片资源列表
};

这样,单个页面的底层渲染函数基本就完成了,接下来我们就要在 Task 内通过调用它实现整本书所有页面的渲染。

2) 转换目录结构,渲染全书

之前我们在 book.config 内定义的 pages 字段是一个树形结构,便于我们日常灵活调整和更新,但最终需要生成的资源清单和书脊却是一维线性的(与真实书籍的纸张排列一样)。

所以我们开始任务前,先将这个结构扁平化处理一下,这也会方便我们在后续过程中使用 async-pool 一类的库实现并发控制。并且我们对 list 内节点的引用的方式,保留原目录数据的基本树形结构,便于之后生成树形的导航目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const flattenPages = (pages) => {
const list = [];
const toc = [];
pages.forEach((originPage) => {
const page = { ...originPage };
list.push(page);
const tocItem = { page };
toc.push(tocItem);

const { children } = page;
if (children && Array.isArray(children)) {
delete page.children;
const { list: subList, toc: subToc } = flattenPages(children);
tocItem.children = subToc;
list.push(...subList);
}
});
return {
list,
toc,
};
};

接着,我们在 Task.run() 内调用上面的 flattenPages() 处理页面结构,然后为每条页面记录 href 页面链接字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Task {
// ...
run() {
// ...

// 处理任务参数
const {
meta,
pages,
cover,
} = this.config;
const {
list: pageList,
toc: pageTree,
} = flattenPages(pages);

// 处理页面参数
pageList.forEach((page) => {
const refPage = page;
const basePath = page.file.replace(/\.md$/i, '');
const href = `${basePath}.xhtml`;
refPage.href = href;
});

await this.convertPages(pageList);
// ...
}
}

接着实现 Task.convertPages() 函数,处理上面的页面列表。

由于过程中有不少可以异步处理的 IO 操作,这里通过 tiny-async-pool 进行并发控制,节约整个任务的处理时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import asyncPool from 'tiny-async-pool';

const RENDER_CONCUR_RESTRICTION = 10;

class Task {
// ...
async convertPages(pageList) {
const { targetDir } = this;
const imageList = [];

// 处理单个页面
const convertPage = async (page) => {
const {
title: titleOrNot,
file,
href,
} = page;

const filePath = path.join(targetDir, file);
const {
title,
content,
images,
} = await renderMdPage(filePath, {
title: titleOrNot,
});

// 标题变化,更新源数据记录
if (titleOrNot !== title) {
const refPage = page;
refPage.title = title;
}

// TODO: 修复相对路径
imageList.push(...images);

const savePath = `EPUB/${href}`;
return this.writeFile(savePath, content);
};

// 并发处理页面
await asyncPool(RENDER_CONCUR_RESTRICTION, pageList, convertPage);

return {
imageList: images,
};
}
// ...
}

这样我们就实现了全书页面的转换生成处理,并且返回了全书用到的所有图片资源。

但这里其实还是有问题的:

  • 我们只获取了图片资源的引用,并没有真正将图片拷到任务目录,打包任务目录将缺失图片文件;
  • 获取的图片路径可能是基于页面文件的相对路径,需要转换成基于 EPUB/package.opf 的项目内路径;
  • 获取的图片路径还可能是网络资源链接,不需要拷贝;
  • 重复引用的图片没有去重。

我们先对图片资源相对目录路径做个转换,处理成相对 EPUB/package.opf 的项目路径,并且做去重处理。

找到刚才的 TODO: 修复相对路径 位置,将其改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const isAbsolute = (src) => /^([^:\\/]+:\/)?\//.test(src);

class Task {
// ...
async convertPages(pageList) {
// ...
const convertPage = async (page) => {
// ...
const pageDir = path.dirname(filePath);
images.forEach((src) => {
let fixedSrc = src;
if (!isAbsolute(src)) {
// 处理页面相对路径
const absSrc = path.join(pageDir, src);
fixedSrc = path.relative(targetDir, absSrc);
}
if (!imageList.includes(fixedSrc)) {
imageList.push(fixedSrc);
}
});
// ...
};
// ...
}
// ...
}

这样,我们就得到了图片基于项目目录的图片路径,或者绝对路径/网络路径。

3) 转移图片资源

这次我们创建 Task.copyImage()Task.convertImages() 处理刚才的图片列表。

在前者中,我们通过传入的图片路径类型找到真实位置,做相应处理后返回 package.opf 文件中 <manifest> 内的 href 路径:

  • 如果是网络资源,不处理,直接返回原路径;
  • 如果相对项目路径,推断出相对任务的路径后,复制并返回项目内路径;
  • 如果是绝对路径,则生成一个任务目录内的临时随机名字,将其作为 href 返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const COPY_CONCUR_RESTRICTION = 5;

class Task {
// ...
async copyImage(src) {
// 1. 网络资源
if (/^https?:\/\//.test(src)) return src;

const { targetDir, saveDir } = this;
const isAbs = isAbsolute(src);
// 2. 相对路径使用原路径
const href = !isAbs ? src
// 3. 绝对路径,生成任务内随机名字
: this.getTempName().concat(path.extname(src));

const srcPath = isAbs ? src : path.join(targetDir, src);
const savePath = path.join(saveDir, `EPUB/${href}`);

const dirPath = path.dirname(savePath);
await mkdirp(dirPath);

return new Promise((rs, rj) => {
pipeline(
createReadStream(srcPath),
createWriteStream(savePath),
(err) => {
if (err) rj(err);
rs(href);
},
);
});
}

getTempName() {
const usedName = this.$usedTempName || [];
this.$usedTempName = usedName;

const name = [Date.now(), Math.random()]
.map((n) => n.toString(16))
.join('_').replace(/\./g, '');
if (usedName.includes(name)) return this.getTempName();
usedName.push(name);
return name;
}

async transportImages(imageList) {
const imageHrefList = [];
const copyImage = async (image) => {
const href = await this.copyImage(image);
imageHrefList.push({
href,
});
};
// 并发复制图片
await asyncPool(COPY_CONCUR_RESTRICTION, imageList, copyImage);

return {
imageHrefList,
};
}
// ...
}

有兴趣的同学,页可以考虑尝试通过符号链接节省图片拷贝的成本;或者加入图片压缩处理,优化电子书体积。

最后,回到我们的 Task.run(),在其中执行完 Task.convertPages()Task.transportImages() 即可得到页面相关的资源清单 manifestList 基本内容了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Task {
// ...
run() {
// ...
// 转换页面
const {
imageList,
} = await this.convertPages(pageList);

// 处理图片
const {
imageHrefList,
} = await this.transportImages(imageList);

const manifestList = [
// 页面加入资源列表
...pageList.map(({ href }, index) => ({
id: `page-${index}`,
href,
})),
// 引用图片加入资源列表
...imageHrefList.map(({ href }, index) => ({
id: `image-${index}`,
href,
})),
];

// TODO: 补充更多资源
}
// ...
}

4) 生成目录与封面

实现了页面和图片的处理流程后,我们再来自动创建两个特殊资源:目录封面

前者我们可以根据之前的 pageTree 递归拼出目录部分的 html 结构,再通过通用的 render() 函数渲染生成,并加入到 manifestList 内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const parseToc = (toc) => {
if (!Array.isArray(toc) || toc.length < 1) return '';
const buffer = [];
buffer.push('<ol>');
toc.forEach((item) => {
const { page, children } = item;
const { href, title, hidden } = page;
buffer.push(`<li${hidden ? ' hidden=""' : ''}><a href="${href}">${title}</a>`);
if (children) {
buffer.push(parseToc(children));
}
buffer.push('</li>');
});
buffer.push('</ol>');
return buffer.join('\n');
};

class Task {
// ...
run() {
// ...
const {
list: pageList,
toc: pageTree,
} = flattenPages(pages);
// ...
const manifestList = [
// ...
];

// 生成目录
await this.writeFile('EPUB/toc.xhtml', await render('EPUB/toc.xhtml', {
tocHtml: parseToc(pageTree),
}));
manifestList.unshift({
id: 'toc-page',
href: 'toc.xhtml',
properties: 'nav',
});

// ...
}
}

不要忘了加上目录页的特殊 attribute[properties="nav"]

对应模板 templates/EPUB/toc.xhtml 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:epub="http://www.idpf.org/2007/ops"
xml:lang="en"
lang="en">

<head>
<title>Table of Contents</title>
</head>

<body>
<nav epub:type="toc"
id="toc">
<h1>Table of Contents</h1>
${tocHtml}
</nav>
</body>

</html>

封面由图片资源和图片页面两部分组成,前者直接转移图片后加入 manifestList 即可,后者也通过模板渲染处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Task {
// ...
run() {
// ...
const {
meta,
pages,
cover,
} = this.config;
// ...

// 处理封面
if (cover) {
manifestList.push({
id: 'cover-image',
href: await this.copyImage(cover),
properties: 'cover-image',
});
await this.writeFile('EPUB/cover.xhtml', await render('EPUB/cover.xhtml', {
cover,
}));
manifestList.unshift({
id: 'cover-page',
href: 'cover.xhtml',
});
}
// ...
}
}

对应模板 templates/EPUB/cover.xhtml 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:epub="http://www.idpf.org/2007/ops"
xml:lang="en"
lang="en">

<head>
<title>Cover</title>
<style type="text/css">
img {
max-width: 100%;
}
</style>
</head>

<body>
<figure id="cover-image">
<img src="${cover}"
alt="Book Cover" />
</figure>
</body>

</html>

5) 完成清单,打包并清理

经过前面的所有处理后,manifestList 内已经集齐了书籍内需要的所有资源基础信息。

我们再对其稍加处理,通过 media-types 查询各个资源的媒体类型 (MIME):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import mimeTypes from 'mime-types';

class Task {
// ...
run() {
// ...

// 处理资源类型
manifestList.forEach((item) => {
const refItem = item;
const { href } = item;
const mediaType = mimeTypes.lookup(href);
const isPage = mediaType === 'application/xhtml+xml';
refItem.mediaType = mediaType;
refItem.isPage = isPage;
});
const spineList = manifestList.filter((item) => item.isPage);

// ...
}
}

现在,我们可以完成最开始的 EPUB/package.opf 模板文件,实现资源清单 <manifest> 和书脊 <spine> 的渲染了:

更新模板 templates/EPUB/package.opf.xml 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<package xmlns="http://www.idpf.org/2007/opf"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
version="3.0"
xml:lang="${meta.lang}"
unique-identifier="pub-identifier">
<metadata>
<dc:identifier id="pub-identifier">${meta.id}</dc:identifier>
<dc:title id="pub-title">${meta.title}</dc:title>
<dc:language id="pub-language">${meta.lang}</dc:language>
<dc:date>${meta.date}</dc:date>
<meta property="dcterms:modified">${meta.modified}</meta>
</metadata>
<manifest>
${manifestList.map(item => `
<item id="${item.id}" href="${item.href}" media-type="${item.mediaType}" ${
item.properties ? `properties="${item.properties}"` : ''
}/>`
).join('')}
</manifest>
<spine>
${spineList.map(item => `
<itemref idref="${item.id}" ${
item.id === 'cover' ? 'linear="no"' : ''
}/>`
).join('')}
</spine>
</package>

最后在 Task.run() 中,将任务目录打包为 .epub 文件并在完成后清理任务目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import AdmZip from 'adm-zip';
import rimraf from 'rimraf';

class Task {
// ...
run() {
// ...

// 打包
const savePath = `${this.targetDir}.epub`;
const zip = new AdmZip();
zip.addLocalFolder(this.saveDir);
zip.writeZip(savePath);

// 清理
if (!isDebug) {
rimraf.sync(this.saveDir);
}
this.state = 'complete';
}
}

至此,我们便完成了一个可以将已有 Markdown 文集、经过简单的配置后转换成 .epub 电子书的工具。

本文完整 DEMO 地址:https://github.com/krimeshu/kepub/tree/v1.0.0-beta.2

有兴趣的同学可以 clone 下来后,npm test 查看效果。

5. 后续优化

我们的工具目前处于“能用”的阶段,日后可能还要根据更多更复杂的实际情况,做相应调整和完善。

再或者优化现有的流程,如:

  • 实现 cli 命令形式的调用;
  • 定制封面页、目录页效果;
  • 自定义子页面样式;
  • 个性化字体;
  • 引入 SVG;
  • 多语言支持;
  • 加入触发器交互、脚本。

其中,由于 EPUB3 中增加的对于 HTML5 的支持,我们可以通过加入触发器和脚本,实现类似互动电子书、AVG 文字冒险游戏的效果,极大地增强互动性。

虽然对于 EPUB3 标准完整支持的电子书阅读器,除了苹果家的 图书 外暂时还没有几个,但可能随着以后设备性能、软件支持的普及,通过电子书实现这样效果的日子或许终会来临。

有兴趣的同学也欢迎参与到本项目的开发中来~

项目地址:https://github.com/krimeshu/kepub/