在之前单《手动篇》里,我们已经手动完成了打包一个 .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
| 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
| 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));
};
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
文件基本结构。
其中的 manifest
和 spine
部分还需要整个电子书渲染完成后的相关资源配置参数,这里暂时留空。
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
| 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]); 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 内的标准页面文件:
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());
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 = []; 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 进行处理:
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, 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; }
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) { if (/^https?:\/\//.test(src)) return src;
const { targetDir, saveDir } = this; const isAbs = isAbsolute(src); const href = !isAbs ? src : 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, })), ];
} }
|
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/