IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    krimeshu发表于 2023-08-27 05:13:46
    love 0

    在之前单《手动篇》里,我们已经手动完成了打包一个 .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 文件基本结构。

    其中的 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
    // 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 内的标准页面文件:

    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.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, // 页面 *.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/



沪ICP备19023445号-2号
友情链接