在之前单《手动篇》里,我们已经手动完成了打包一个 .epub
所需要的基本文件内容,并且梳理出可以通过工具自动完成的流程,以及需要补充信息来完成的流程。
这次我们正式开始动手,编码实现我们的电子书生成小工具了。
开始动手:自动篇
1. 创建项目
创建一个目录 kepub
执行 npm init -y
,然后修改 package.json 文件,设置 "type": "module"
启用 ES Module 模式。
安装 eslint,以及我们选择的 airbnb 标准和相关依赖:
代码语言:javascript复制npm i -D eslint eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-import
然后根据自己的需要,微调一下 ESLint 配置:
代码语言:javascript复制# .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 做专门的适配。
代码语言:javascript复制npm i -S marked cheerio adm-zip
2. 入口
在项目的入口文件 index.js
中,我们约定传入的第一个参数为需要处理的电子书目录,其中存在对应 book.json
配置:
// 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 文件内容:
代码语言:javascript复制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
文件基本结构。
其中的 manifest
和 spine
部分还需要整个电子书渲染完成后的相关资源配置参数,这里暂时留空。
1) 提取模板文件
虽然上面的渲染函数已经可以工作了,但可以看出一个明显问题:
渲染函数内的字符串内容格式是 xml,但是在我们的代码里编写时,只会被 IDE 当成普通的字符串,没有任何代码高亮和校验处理。对其中的内容做修改调正时,如果发生误删字符之类的格式问题,没法在编码阶段快速发现。
所以我们在这里做个小优化,把上面字符串模板的内容提取到 templates/EPUB/package.opf.xml 文件内,然后再重新实现一个 render
函数:
- 通过传入模板名字
templateName
,找到 templates 目录下对应的模板文件,读取为模板字符串。 - 传入渲染参数
args
,将其中的字段解析后作为渲染参数注入到模板渲染函数fn
内。 - 执行渲染函数
fn
,返回最终文件内容。
// 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 之外,之前的 mimetype 和 META-INF/container.xml 文件也可以提取为模板目录 templates 内的文件,在整个流程中传入对应名字就能完成它们的渲染了。
2) Markdown 渲染
Markdown 的渲染需要提前做个转换处理,在传入要渲染的文件路径 filePath
,读取其内容后调用 marked
进行转换即可得到页面 html 内容。
我们创建一个书籍页面通用的 templates/EPUB/book-page.xhtml 模板,调用上一步中实现的 render()
即可渲染成 EPUB 内的标准页面文件:
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 内容:
代码语言:javascript复制<?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
任务类,每次任务就创建一个它的实例负责处理。在任务过程中,它会有一个属于自己的临时目录保存过程中的中间文件,可以在自己的实例变量中缓存的资源信息。最后由它统筹生成上面提到的基础信息,打包成书,随后清理临时目录。
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.json 的 pages
节点内定义 title
字段,但实际书籍标题时往往还是和内容一起更新的。所以我们尝试读取文件内第一个 <h1>
标题的文本作为默认标题。这里使用 Cheerio 进行处理:
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 进行收集。
最后在返回值内告知外部任务实例,最终渲染的标题和用到的图片资源:
代码语言:javascript复制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
内节点的引用的方式,保留原目录数据的基本树形结构,便于之后生成树形的导航目录。
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
页面链接字段:
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 进行并发控制,节约整个任务的处理时间:
代码语言:javascript复制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: 修复相对路径
位置,将其改成:
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
返回。
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
基本内容了:
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
内:
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 内容:
代码语言:javascript复制<?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
即可,后者也通过模板渲染处理:
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 内容:
代码语言:javascript复制<?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):
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 内容:
代码语言:javascript复制<?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
文件并在完成后清理任务目录:
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/