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

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

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

前文:用 Markdown 写一本自己的电子书吧(一)手动篇


开始动手:自动篇

1. 创建项目

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

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

npm i -D eslint eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-import
复制代码

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

# .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 做专门的适配。

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 文件内容:

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,返回最终文件内容。
// 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 内的标准页面文件:

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 内容:

<?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.jsonpages 节点内定义 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 进行收集。

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

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 进行并发控制,节约整个任务的处理时间:

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(srcFromPubDir);
                }
            });
            // ...
        };
        // ...
    }
    // ...
}
复制代码

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

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 内容:

<?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 内容:

<?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 内容:

<?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 地址:github.com/krimeshu/ke…

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

5. 后续优化

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

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

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

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

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

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

项目地址:github.com/krimeshu/ke…

猜你喜欢

转载自juejin.im/post/7040331884319997983