npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ignorance/epack

v1.0.1

Published

将 Markdown 章节打包为 .epub 电子书

Readme

@ignorance/epack 还原手册

将 Markdown 章节打包为 .epub 电子书。

目录结构

epack/
├── package.json
├── index.js                    # 库入口
├── cli.js                      # CLI 入口
├── src/
│   ├── kepub.js                # 核心引擎
│   └── prompts.js              # 交互式提示
└── templates/
    ├── mimetype
    ├── EPUB/
    │   ├── book-page.xhtml
    │   ├── cover.xhtml
    │   ├── package.opf.xml
    │   └── toc.xhtml
    └── META-INF/
        └── container.xml

系统依赖

  • Node.js >= 18(使用 fetch API)
  • 系统必须有 zip 命令(macOS / Linux 自带)

1. package.json

{
  "name": "@ignorance/epack",
  "version": "1.0.0",
  "description": "将 Markdown 章节打包为 .epub 电子书",
  "main": "index.js",
  "bin": {
    "epack": "cli.js"
  },
  "type": "module",
  "dependencies": {
    "cheerio": "^1.0.0-rc.10",
    "marked": "^4.0.5",
    "mime-types": "^2.1.34",
    "mkdirp": "^1.0.4",
    "prompts": "^2.4.2",
    "rimraf": "^3.0.2",
    "sharp": "^0.34.5",
    "tiny-async-pool": "^1.2.0"
  },
  "scripts": {
    "test": "node cli.js --help"
  },
  "keywords": ["epub", "markdown", "builder"],
  "license": "ISC"
}

2. index.js(库入口)

import { kepub, downloadImages, compressImages, traverseDirectory, scan, setRootDir, estimateTotalSize, extractTitle, flattenPages, Task } from './src/kepub.js';
import { promptTargetDir, promptBookConfig, IMAGE_MODES, SORT_MODES, COMPRESS_MODES } from './src/prompts.js';

export {
    // 主入口
    kepub,

    // 工具函数
    downloadImages,
    compressImages,
    traverseDirectory,
    scan,
    setRootDir,
    estimateTotalSize,
    extractTitle,
    flattenPages,

    // EPUB 构建器
    Task,

    // 交互式提示
    promptTargetDir,
    promptBookConfig,

    // 配置常量
    IMAGE_MODES,
    SORT_MODES,
    COMPRESS_MODES,
};

3. cli.js

#!/usr/bin/env node

import path from 'path';
import fs from 'fs/promises';
import { kepub } from './src/kepub.js';
import { promptTargetDir, promptBookConfig } from './src/prompts.js';

const args = process.argv.slice(2);
const cliTarget = args.find(arg => !arg.startsWith('--'));
const skipPrompts = args.includes('--yes') || args.includes('-y');
const showHelp = args.includes('--help') || args.includes('-h');

if (showHelp) {
    console.log(`
epack - 将 Markdown 章节打包为 .epub 电子书

用法:
  epack [目录路径] [选项]

选项:
  --yes, -y       跳过交互式提示,使用 book.json 中的已有配置
  --help, -h      显示帮助信息

示例:
  epack "path/to/book" --yes
  epack              # 交互式选择目录
`);
    process.exit(0);
}

let targetDir;
if (cliTarget) {
    targetDir = path.resolve(cliTarget);
    try {
        if (!(await fs.stat(targetDir)).isDirectory()) {
            console.error(`路径不是目录: ${targetDir}`);
            process.exit(1);
        }
    } catch {
        console.error(`目录不存在: ${targetDir}`);
        process.exit(1);
    }
} else {
    targetDir = await promptTargetDir();
}

if (skipPrompts) {
    kepub(targetDir, { skipPrompts: true });
} else {
    const config = await promptBookConfig(targetDir);
    kepub(targetDir, { config });
}

4. src/prompts.js

import path from 'path';
import fs from 'fs/promises';
import prompts from 'prompts';

const IMAGE_MODES = [
    { title: '展示图片', value: 'show', description: '保留图片在 EPUB 中' },
    { title: '不展示图片', value: 'hide', description: '删除所有图片' },
    { title: '展示图片链接', value: 'link', description: '图片替换为可点击链接' },
    { title: '展示文本占位', value: 'placeholder', description: '图片替换为 [图片略]' },
];

const SORT_MODES = [
    { title: '按文件名排序', value: 'filename', description: '如 001、002、003...' },
    { title: '按创建时间正序', value: 'birthtime-asc', description: '最早的文件在前' },
    { title: '按创建时间倒序', value: 'birthtime-desc', description: '最新的文件在前' },
    { title: '按 pages.json', value: 'pages-json', description: '使用已有 pages.json 的顺序' },
];

const COMPRESS_MODES = [
    { title: '不压缩', value: 'none', description: '保持原始画质和体积' },
    { title: '轻度压缩', value: 'light', description: 'JPEG 质量 85%' },
    { title: '中度压缩', value: 'medium', description: 'JPEG 质量 65%,最大 1920px' },
    { title: '重度压缩', value: 'heavy', description: 'JPEG 质量 45%,最大 1200px' },
];

function findInitialIndex(modes, value, fallback = 0) {
    const idx = modes.findIndex(m => m.value === value);
    return idx >= 0 ? idx : fallback;
}

/** 交互式提示输入目标目录路径 */
export async function promptTargetDir() {
    const response = await prompts({
        type: 'text', name: 'targetDir', message: '目标目录路径:',
    }, {
        onCancel: () => { console.log('\n已取消。'); process.exit(130); },
    });

    const targetDir = path.resolve(response.targetDir);
    try {
        if (!(await fs.stat(targetDir)).isDirectory()) {
            console.error(`路径不是目录: ${targetDir}`);
            process.exit(1);
        }
    } catch {
        console.error(`目录不存在: ${targetDir}`);
        process.exit(1);
    }
    return targetDir;
}

/** 交互式引导生成 book.json 配置 */
export async function promptBookConfig(targetDir, existingConfig = null) {
    const defaultTitle = path.basename(targetDir);
    const { title, author, lang, cover, imageMode, sortMode, compressMode, maxSizeMB } = existingConfig || {};

    console.log(existingConfig ? '\n编辑书籍配置:' : '\n设置书籍信息:\n');

    const response = await prompts([
        { type: 'text', name: 'title', message: '书名:', initial: title || defaultTitle },
        { type: 'text', name: 'author', message: '作者(可选):', initial: author || '' },
        { type: 'text', name: 'lang', message: '语言:', initial: lang || 'zh-CN' },
        { type: 'text', name: 'cover', message: '封面图片路径(可选):', initial: cover || '' },
        {
            type: 'select', name: 'imageMode', message: '图片处理方式:', choices: IMAGE_MODES,
            initial: findInitialIndex(IMAGE_MODES, imageMode),
        },
        {
            type: 'select', name: 'sortMode', message: '章节排序方式:', choices: SORT_MODES,
            initial: findInitialIndex(SORT_MODES, sortMode, 1),
        },
        {
            type: 'select', name: 'compressMode', message: '图片压缩级别:', choices: COMPRESS_MODES,
            initial: findInitialIndex(COMPRESS_MODES, compressMode),
        },
        { type: 'text', name: 'maxSizeMB', message: '单本最大大小(MB,留空表示不限制):', initial: maxSizeMB ? maxSizeMB.toString() : '' },
    ], {
        onCancel: () => { console.log('\n已取消。'); process.exit(130); },
    });

    const parsedMaxSizeMB = response.maxSizeMB ? parseInt(response.maxSizeMB, 10) : 0;

    return {
        meta: {
            id: `kepub:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
            title: response.title || defaultTitle,
            author: response.author || '',
            lang: response.lang || 'zh-CN',
            date: new Date().toISOString().slice(0, 10),
            modified: new Date().toISOString(),
        },
        cover: response.cover || '',
        pages: [],
        imageMode: response.imageMode || 'show',
        sortMode: response.sortMode || 'birthtime-asc',
        compressMode: response.compressMode || 'none',
        maxSizeMB: parsedMaxSizeMB,
    };
}

export { IMAGE_MODES, SORT_MODES, COMPRESS_MODES };

5. src/kepub.js(核心引擎)

import syncFs from 'fs';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { pipeline } from 'stream';
import { createReadStream, createWriteStream } from 'fs';
import { spawn } from 'child_process';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { dirname as pathDirname } from 'path';

import { marked } from 'marked';
import { load as loadHtml } from 'cheerio';
import mkdirp from 'mkdirp';
import mimeTypes from 'mime-types';
import asyncPool from 'tiny-async-pool';
import rimraf from 'rimraf';
import sharp from 'sharp';

// ============================================================
// 常量 & 全局状态
// ============================================================

const RENDER_CONCURRENCY = 10;
const COPY_CONCURRENCY = 5;

const isDebug = false;
const tempDir = isDebug ? './.temp' : path.join(os.tmpdir(), 'epack');

const __dirname = pathDirname(fileURLToPath(import.meta.url));
const templatesDir = path.join(__dirname, '../templates');

// ============================================================
// 模板渲染
// ============================================================

async function render(templateName, args = {}) {
    const filePath = path.join(templatesDir, templateName);
    try {
        await fs.access(filePath);
    } catch (ex) {
        throw Error(`TemplateError: can't find template ${JSON.stringify(templateName)}\n  at path: ${filePath}`);
    }
    const template = (await fs.readFile(filePath)).toString();
    const keys = Object.keys(args);
    const values = keys.map((k) => args[k]);
    const fn = new Function(...keys, `return \`${template}\`;`);
    return fn(...values);
}

export function extractTitle(filePath) {
    const base = path.basename(filePath, '.md');
    const match = base.match(/^[\d\u4e00-\u9fa5]+[||\-]\s*(.+)$/);
    return match ? match[1] : base;
}

function urlToFilename(url) {
    const parsed = new URL(url);
    const lastPart = path.basename(parsed.pathname);
    const name = path.parse(lastPart).name.slice(0, 10).replace(/[^\w-]/g, '');
    const hash = crypto.createHash('sha256').update(url).digest('hex').slice(0, 6);
    return `${name}-${hash}${path.extname(parsed.pathname)}`;
}

// ============================================================
// Markdown → HTML 页面渲染
// ============================================================

async function renderMdPage(filePath, args = {}, imageMode = 'show') {
    try { await fs.access(filePath); } catch (ex) {
        throw Error(`RenderError: can't find file ${JSON.stringify(filePath)}`);
    }

    const markdown = await fs.readFile(filePath);
    let html = marked.parse(markdown.toString());
    const $ = loadHtml(html);

    const firstH1 = $('h1').text();
    const { title = firstH1 || extractTitle(filePath) || 'Untitled Page' } = args;

    let images = [];
    if (imageMode === 'show') {
        images = $('img').map((_, el) => $(el).attr('src')).get();
    } else {
        $('img').each((_, el) => {
            const src = $(el).attr('src') || '';
            const alt = $(el).attr('alt') || '图片';
            if (imageMode === 'hide') $(el).remove();
            else if (imageMode === 'link') $(el).replaceWith(`<a href="${src}">[${alt}]</a>`);
            else if (imageMode === 'placeholder') $(el).replaceWith('[图片略]');
        });
        html = $.html();
    }

    const VOID_TAG = /<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)\b([^>]*?)(?<!\/)\s*>/gi;
    const content = await render('EPUB/book-page.xhtml', {
        title,
        content: html.replace(VOID_TAG, '<$1$2 />'),
    });

    return { title, content, images };
}

// ============================================================
// 图片处理
// ============================================================

async function collectRemoteImages(dir) {
    const urls = [];
    const entries = await fs.readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);
        if (entry.isDirectory()) {
            urls.push(...await collectRemoteImages(fullPath));
        } else if (path.extname(entry.name).toLowerCase() === '.md') {
            const content = await fs.readFile(fullPath, 'utf8');
            const imageRegex = /!\[(.*?)\]\((https?:\/\/.*?)\)/g;
            let match;
            while ((match = imageRegex.exec(content)) !== null) urls.push(match[2]);
        }
    }
    return urls;
}

async function downloadImage(url, imagesDirectory) {
    const filename = urlToFilename(url);
    try { await fs.access(path.join(imagesDirectory, filename)); return { status: 'skip', filename }; } catch {}

    try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`状态码: ${response.status}`);
        const buffer = await response.arrayBuffer();
        await fs.writeFile(path.join(imagesDirectory, filename), Buffer.from(buffer));
        return { status: 'ok', filename };
    } catch (error) {
        return { status: 'fail', error: error.message };
    }
}

async function saveImageMap(targetDir, urls) {
    const imagesDirectory = path.join(targetDir, 'images');
    const map = {};
    try {
        const files = await fs.readdir(imagesDirectory);
        const nameSet = new Set(files);
        for (const url of urls) {
            const filename = urlToFilename(url);
            if (nameSet.has(filename)) map[url] = filename;
        }
    } catch {}
    await fs.writeFile(path.join(targetDir, 'image-map.json'), JSON.stringify(map, null, 2));
    return map;
}

async function loadImageMap(targetDir) {
    try { return JSON.parse(await fs.readFile(path.join(targetDir, 'image-map.json'), 'utf8')); } catch { return null; }
}

function transformImagesInHtml(html, imageMap) {
    if (!imageMap) return html;
    const $ = loadHtml(html, { xmlMode: true });
    $('img').each((_, el) => {
        const src = $(el).attr('src') || '';
        if (imageMap[src]) $(el).attr('src', path.join('images', imageMap[src]));
    });
    return $.html();
}

export async function downloadImages(targetDir) {
    const imagesDirectory = path.join(targetDir, 'images');
    try { await fs.access(imagesDirectory); } catch (error) {
        if (error.code === 'ENOENT') await fs.mkdir(imagesDirectory, { recursive: true });
        else throw error;
    }

    const allUrls = await collectRemoteImages(targetDir);
    const total = allUrls.length;
    if (total === 0) { console.log('未发现远程图片,跳过下载。'); await saveImageMap(targetDir, allUrls); return; }

    let processed = 0, downloaded = 0, skipped = 0, failed = 0;
    for (const url of allUrls) {
        processed++;
        const result = await downloadImage(url, imagesDirectory);
        if (result.status === 'ok') { downloaded++; console.log(`[${processed}/${total}] 下载: ${result.filename}`); }
        else if (result.status === 'skip') { skipped++; console.log(`[${processed}/${total}] 跳过(已存在)`); }
        else { failed++; console.log(`[${processed}/${total}] 失败: ${result.error}`); }
    }
    console.log(`\n图片下载完成: ${downloaded} 下载, ${skipped} 跳过, ${failed} 失败`);
    const map = await saveImageMap(targetDir, allUrls);
    console.log(`\n图片映射已保存: ${Object.keys(map).length} 条 → image-map.json`);
}

export async function compressImages(targetDir, mode) {
    if (mode === 'none') return;
    const imagesDir = path.join(targetDir, 'images');
    try { await fs.access(imagesDir); } catch { return; }

    const cfg = { light: { quality: 85, resize: null }, medium: { quality: 65, resize: 1920 }, heavy: { quality: 45, resize: 1200 } }[mode];

    const files = await fs.readdir(imagesDir);
    const imageFiles = files.filter(f => /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(f));
    if (imageFiles.length === 0) return;

    console.log(`\n开始压缩 ${imageFiles.length} 张图片 (${mode} 模式)...`);
    let processed = 0, totalBefore = 0, totalAfter = 0;

    const compressImage = async (src, tmpFile, format) => {
        let pipeline = sharp(src);
        if (cfg.resize) pipeline = pipeline.resize(cfg.resize, cfg.resize, { fit: 'inside', withoutEnlargement: true });
        switch (format) {
            case 'jpeg': await pipeline.jpeg({ quality: cfg.quality, progressive: true }).toFile(tmpFile); break;
            case 'png': {
                const meta = await sharp(src).metadata();
                if (meta.hasAlpha) await pipeline.png({ quality: cfg.quality, progressive: true, effort: 10 }).toFile(tmpFile);
                else await pipeline.jpeg({ quality: cfg.quality, progressive: true }).toFile(tmpFile);
                break;
            }
            case 'gif': return false;
            case 'webp': await pipeline.jpeg({ quality: cfg.quality, progressive: true }).toFile(tmpFile); break;
            default: await pipeline.jpeg({ quality: cfg.quality, progressive: true }).toFile(tmpFile);
        }
        return true;
    };

    const getFormat = async (src) => {
        const ext = path.extname(src).toLowerCase();
        if (ext === '.jpg' || ext === '.jpeg') return 'jpeg';
        if (ext === '.png') return 'png';
        if (ext === '.gif') return 'gif';
        if (ext === '.webp') return 'webp';
        const meta = await sharp(src).metadata();
        return meta.format || 'jpeg';
    };

    const compressOne = async (filename) => {
        const src = path.join(imagesDir, filename);
        const tmpFile = src + '.compressed.tmp';
        const beforeSize = (await fs.stat(src)).size;
        try {
            const format = await getFormat(src);
            const success = await compressImage(src, tmpFile, format);
            if (!success) { processed++; totalBefore += beforeSize; totalAfter += beforeSize; console.log(`[${processed}/${imageFiles.length}] 跳过(动图): ${filename}`); return; }
            await fs.copyFile(tmpFile, src);
            await fs.unlink(tmpFile);
            const afterSize = (await fs.stat(src)).size;
            processed++; totalBefore += beforeSize; totalAfter += afterSize;
            console.log(`[${processed}/${imageFiles.length}] ${(beforeSize / 1024).toFixed(0)}KB → ${(afterSize / 1024).toFixed(0)}KB (${((afterSize / beforeSize) * 100).toFixed(0)}%)`);
        } catch (err) {
            try { await fs.unlink(tmpFile); } catch {}
            processed++; totalBefore += beforeSize; totalAfter += beforeSize;
            console.log(`[${processed}/${imageFiles.length}] 压缩失败: ${filename} — ${err.message}`);
        }
    };

    await asyncPool(COPY_CONCURRENCY, imageFiles, compressOne);
    console.log(`\n图片压缩完成: ${(totalBefore / 1024 / 1024).toFixed(0)}MB → ${(totalAfter / 1024 / 1024).toFixed(0)}MB (${((totalAfter / totalBefore) * 100).toFixed(0)}%)`);
}

// ============================================================
// 占位文件创建
// ============================================================

export async function traverseDirectory(dir) {
    try {
        const entries = await fs.readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
            const fullPath = path.join(dir, entry.name);
            if (entry.isDirectory()) {
                const mdFile = path.join(dir, `${entry.name}.md`);
                if (!mdFile.includes('images')) {
                    try { await fs.access(mdFile); } catch {
                        await fs.writeFile(mdFile, `# ${entry.name}`);
                        console.log(`创建文件: ${mdFile}`);
                    }
                }
                await traverseDirectory(fullPath);
            }
        }
    } catch (error) { console.error(`遍历目录 ${dir} 时出错:`, error); }
}

// ============================================================
// 目录扫描
// ============================================================

let rootDir = '';
export function setRootDir(dir) { rootDir = dir; }

export function scan(base = '', sortMode = 'birthtime-asc') {
    const currentPath = path.join(rootDir, base);
    const entries = syncFs.readdirSync(currentPath, { withFileTypes: true });

    const result = entries.reduce((acc, item) => {
        if (item.isFile() && path.extname(item.name) === '.md') {
            const filePath = path.join(currentPath, item.name);
            const stats = syncFs.statSync(filePath);
            const res = { file: `${base}${base ? '/' : ''}${item.name}`, birthtime: stats.birthtimeMs };

            const dirName = path.basename(item.name, '.md');
            const dirPath = path.join(rootDir, base, dirName);
            try {
                if (syncFs.statSync(dirPath).isDirectory()) {
                    const children = scan(path.join(base, dirName), sortMode);
                    if (children?.length) res.children = children;
                }
            } catch {}
            acc.push(res);
        }
        return acc;
    }, []);

    if (sortMode === 'filename') result.sort((a, b) => a.file.localeCompare(b.file, 'zh'));
    else if (sortMode === 'birthtime-asc') result.sort((a, b) => a.birthtime - b.birthtime);
    else if (sortMode === 'birthtime-desc') result.sort((a, b) => b.birthtime - a.birthtime);

    syncFs.writeFileSync(path.join(rootDir, 'pages.json'), JSON.stringify(result, null, '  '));
    return result;
}

// ============================================================
// 大小估算与页面切割
// ============================================================

async function getDownloadedImageSize(targetDir) {
    const imagesDir = path.join(targetDir, 'images');
    try {
        const files = await fs.readdir(imagesDir);
        let total = 0;
        for (const f of files) { try { total += (await fs.stat(path.join(imagesDir, f))).size; } catch {} }
        return total;
    } catch { return 0; }
}

function estimateHtmlSize(mdSize) { return (mdSize || 5000) * 2 + 500; }

async function estimateGroupSize(pageFiles, targetDir, imageMode, totalImageSize = 0, totalPages = 0) {
    let htmlSize = 0;
    for (const file of pageFiles) { try { htmlSize += estimateHtmlSize((await fs.stat(path.join(targetDir, file))).size); } catch { htmlSize += 15000; } }
    let imageSize = 0;
    if (imageMode === 'show' && totalPages > 0) imageSize = totalImageSize * pageFiles.length / totalPages;
    return htmlSize + imageSize + 50000;
}

async function attachMdSizes(pages, targetDir) {
    for (const page of pages) {
        try { page._mdSize = (await fs.stat(path.join(targetDir, page.file))).size; } catch { page._mdSize = 5000; }
        if (page.children?.length) await attachMdSizes(page.children, targetDir);
    }
}

export async function estimateTotalSize(pages, targetDir, imageMode) {
    await attachMdSizes(pages, targetDir);
    const { list: flatPages } = flattenPages(pages);
    const htmlSize = flatPages.reduce((sum, p) => sum + estimateHtmlSize(p._mdSize), 0);
    const imageSize = imageMode === 'show' ? await getDownloadedImageSize(targetDir) : 0;
    return { htmlSize, imageSize, totalSize: htmlSize + imageSize + 50000, totalPages: flatPages.length };
}

export function flattenPages(pages) {
    const result = [];
    const walk = (nodes) => {
        for (const node of nodes) {
            if (node._sectionMarker || (node.file && /\.md$/i.test(node.file))) result.push({ ...node });
            if (node.children?.length) walk(node.children);
        }
    };
    walk(pages);
    return { list: result, toc: pages };
}

function buildParentMap(pages) {
    const map = new Map();
    const walk = (nodes, parentFile = null) => {
        for (const node of nodes) {
            if (node.file && /\.md$/i.test(node.file)) map.set(node.file, parentFile);
            if (node.children?.length) walk(node.children, node.file);
        }
    };
    walk(pages);
    return map;
}

function findNodeByFile(pages, file) {
    for (const node of pages) {
        if (node.file === file) return node;
        if (node.children) { const found = findNodeByFile(node.children, file); if (found) return found; }
    }
    return null;
}

function addSectionMarker(group, pages, parentFile) {
    const parentNode = findNodeByFile(pages, parentFile);
    if (parentNode) {
        group.push({ file: parentNode.file, sectionTitle: parentNode.file, birthtime: parentNode.birthtime || 0, _sectionMarker: true });
    }
}

async function splitPages(pages, maxSizeBytes, targetDir, imageMode, totalImageSize = 0, totalPages = 0) {
    if (!maxSizeBytes || maxSizeBytes <= 0) return [pages];
    const effectiveLimit = maxSizeBytes * 0.95;
    const { list: flatPages } = flattenPages(pages);
    const parentMap = buildParentMap(pages);
    const groups = []; let currentGroup = []; let lastGroupParent = null;

    for (const page of flatPages) {
        const pageParent = parentMap.get(page.file) || null;
        if (currentGroup.length === 0 && pageParent !== lastGroupParent) addSectionMarker(currentGroup, pages, pageParent);
        currentGroup.push(page);

        const groupFiles = currentGroup.filter(p => !p._sectionMarker).map(p => p.file);
        const groupSize = await estimateGroupSize(groupFiles, targetDir, imageMode, totalImageSize, totalPages);

        if (groupSize > effectiveLimit && currentGroup.length > 1) {
            currentGroup.pop(); groups.push(currentGroup);
            const lastMd = currentGroup.findLast(p => !p._sectionMarker);
            lastGroupParent = lastMd ? (parentMap.get(lastMd.file) || null) : null;
            currentGroup = [];
            if (pageParent) addSectionMarker(currentGroup, pages, pageParent);
            currentGroup.push(page);
        }
    }
    if (currentGroup.length > 0) groups.push(currentGroup);

    if (groups.length <= 1) return [flatPages.map(p => ({ file: p.file, birthtime: p.birthtime }))];

    return groups.map(group => group.map(p => ({
        file: p.file, ...(p.sectionTitle ? { sectionTitle: p.sectionTitle } : {}), birthtime: p.birthtime, ...(p._sectionMarker ? { _sectionMarker: true } : {}),
    })));
}

function splitBookTitle(title, index) { return `${title}(${index})`; }

// ============================================================
// EPUB 构建 — Task 类
// ============================================================

const isAbsolutePath = (src) => /^([^:\\/]+:\/)?\//.test(src);

export class Task {
    constructor(targetDir, config) {
        this.targetDir = targetDir; this.config = config; this.state = 'idle';
        this.saveDir = path.join(tempDir, `task_${Date.now()}_${Math.random()}`);
        this.$usedTempName = [];
    }

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

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

    async copyImage(src) {
        if (/^https?:\/\//.test(src)) return src;
        const isAbs = isAbsolutePath(src);
        const href = !isAbs ? src : this.getTempName().concat(path.extname(src));
        const srcPath = isAbs ? src : path.join(this.targetDir, src);
        await mkdirp(path.dirname(path.join(this.saveDir, `EPUB/${href}`)));
        return new Promise((rs, rj) => {
            pipeline(createReadStream(srcPath), createWriteStream(path.join(this.saveDir, `EPUB/${href}`)), (err) => { if (err) rj(err); else rs(href); });
        });
    }

    async convertPages(pageList) {
        const { targetDir, config } = this;
        const imageList = []; let processed = 0;
        const imageMap = await loadImageMap(targetDir);

        const convertPage = async (page) => {
            processed++;
            if (page._sectionMarker) {
                const sectionContent = await render('EPUB/book-page.xhtml', { title: page.sectionTitle, content: `<h1>${page.sectionTitle}</h1>` });
                console.log(`[${processed}/${pageList.length}] Section: ${page.sectionTitle}`);
                return this.writeFile(`EPUB/${page.href}`, sectionContent);
            }
            const filePath = path.join(targetDir, page.file);
            const { title, content } = await renderMdPage(filePath, { title: page.title }, config.imageMode || 'show');
            const finalContent = transformImagesInHtml(content, imageMap);
            console.log(`[${processed}/${pageList.length}] Converting: ${page.file}`);
            if (page.title !== title) page.title = title;
            const $$ = loadHtml(finalContent);
            const pageImages = $$('img').map((_, el) => $$(el).attr('src') || '').get();
            pageImages.forEach((src) => {
                let fixedSrc = src;
                if (!isAbsolutePath(src) && !src.startsWith('images/')) fixedSrc = path.relative(targetDir, path.join(path.dirname(filePath), src));
                if (!imageList.includes(fixedSrc)) imageList.push(fixedSrc);
            });
            return this.writeFile(`EPUB/${page.href}`, finalContent);
        };

        await asyncPool(RENDER_CONCURRENCY, pageList, convertPage);
        return { imageList };
    }

    async transportImages(imageList) {
        const imageHrefList = [];
        await asyncPool(COPY_CONCURRENCY, imageList, async (image) => { imageHrefList.push({ href: await this.copyImage(image) }); });
        return { imageHrefList };
    }

    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, pages, cover } = this.config;
        const { tocTitle = '目录' } = this.config.options || {};
        const { list: pageList, toc: pageTree } = flattenPages(pages);

        pageList.forEach((page) => {
            if (page._sectionMarker) {
                page.href = `section-${page.sectionTitle.replace(/[^\w\u4e00-\u9fff]/g, '_')}.xhtml`;
                page.title = page.sectionTitle;
            } else {
                page.href = `${page.file.replace(/\.md$/i, '')}.xhtml`;
                page.title = page.title || extractTitle(page.file);
            }
        });

        const hrefMap = new Map(pageList.map(p => [p.file, { href: p.href, title: p.title }]));
        const walkTree = (nodes) => { for (const node of nodes) { const m = hrefMap.get(node.file); if (m) { node.href = m.href; node.title = m.title; } if (node.children) walkTree(node.children); } };
        walkTree(pageTree);

        const { imageList } = await this.convertPages(pageList);
        const { imageHrefList } = await this.transportImages(imageList);

        const manifestList = [...pageList.map(({ href }, i) => ({ id: `page-${i}`, href })), ...imageHrefList.map(({ href }, i) => ({ id: `image-${i}`, href }))];
        const tocHtml = parseToc(pageTree);
        await this.writeFile('EPUB/toc.xhtml', await render('EPUB/toc.xhtml', { tocTitle, tocHtml }));
        manifestList.unshift({ id: 'toc-page', href: 'toc.xhtml', properties: 'nav' });

        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' });
        }

        manifestList.forEach((item) => { item.mediaType = mimeTypes.lookup(item.href); item.isPage = item.mediaType === 'application/xhtml+xml'; });
        const spineList = manifestList.filter((item) => item.isPage);

        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 })),
        ]);

        let savePath = this.config.outputPath || `${this.targetDir}.epub`;
        if (!path.isAbsolute(savePath)) savePath = path.resolve(savePath);
        console.log(`\nPacking EPUB: ${savePath}`);
        try { await fs.unlink(savePath); } catch {}

        await new Promise((resolve, reject) => {
            const mimeWrite = spawn('zip', ['-0', '-X', savePath, 'mimetype'], { cwd: this.saveDir });
            mimeWrite.on('close', (code) => {
                if (code !== 0) return reject(new Error(`Failed to write mimetype (exit ${code})`));
                const zip = spawn('zip', ['-r', '-X', savePath, '.', '-x', 'mimetype'], { cwd: this.saveDir });
                zip.on('close', (code) => { if (code !== 0) return reject(new Error(`zip failed (exit ${code})`)); resolve(); });
            });
        });

        if (!isDebug) rimraf.sync(this.saveDir);
        this.state = 'complete';
        console.log(`EPUB 创建成功: ${savePath}`);
    }
}

function parseToc(toc) {
    if (!Array.isArray(toc) || toc.length < 1) return '';
    const buffer = []; buffer.push('<ol>');
    toc.forEach((item) => {
        const { children, page } = item;
        const node = page || item;
        const { href, title, hidden } = node;
        if (!href) buffer.push(`<li><span>${item.sectionTitle || node.sectionTitle || node.file || '章节'}</span>`);
        else 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');
}

// ============================================================
// 入口函数
// ============================================================

function formatBytes(bytes) {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

async function buildEpub(targetDir, config, titleSuffix) {
    const originalTitle = config.meta.title;
    if (titleSuffix) {
        config.meta.title = splitBookTitle(originalTitle, ...titleSuffix);
        config.outputPath = `${targetDir}(${titleSuffix[0]}).epub`;
    }
    await new Task(targetDir, config).run();
    config.meta.title = originalTitle; delete config.outputPath;
}

export async function kepub(targetDir, options = {}) {
    targetDir = path.resolve(targetDir);
    console.log('[1/4] 创建占位文件...');
    await traverseDirectory(targetDir);

    console.log('[2/4] 下载图片...');
    await downloadImages(targetDir);

    console.log('[3/4] 生成目录...');
    const configPath = path.join(targetDir, 'book.json');
    let existingConfig = null;
    try { existingConfig = JSON.parse(await fs.readFile(configPath)); } catch {}

    let config;
    if (options.config) {
        config = {
            meta: options.config.meta, cover: options.config.cover || '', pages: [],
            imageMode: options.config.imageMode || 'show', sortMode: options.config.sortMode || 'birthtime-asc',
            compressMode: options.config.compressMode || 'none', maxSizeMB: options.config.maxSizeMB || 0,
        };
    } else {
        config = existingConfig
            ? { meta: existingConfig.meta, cover: existingConfig.cover || '', pages: [], imageMode: existingConfig.imageMode || 'show', sortMode: existingConfig.sortMode || 'birthtime-asc', compressMode: existingConfig.compressMode || 'none', maxSizeMB: existingConfig.maxSizeMB || 0 }
            : { meta: { id: `kepub:${Date.now()}`, title: path.basename(targetDir), author: '', lang: 'zh-CN', date: new Date().toISOString().slice(0, 10), modified: new Date().toISOString() }, cover: '', pages: [], imageMode: 'show', sortMode: 'birthtime-asc', compressMode: 'none', maxSizeMB: 0 };
    }

    const pagesJsonPath = path.join(targetDir, 'pages.json');
    let pageConfig;
    if (config.sortMode === 'pages-json') {
        await fs.access(pagesJsonPath);
        if (!(await fs.stat(pagesJsonPath)).isFile()) throw new Error('ConfigError: "pages.json" is not file.');
        pageConfig = JSON.parse(await fs.readFile(pagesJsonPath));
    } else { setRootDir(targetDir); pageConfig = scan('', config.sortMode); }
    config.pages = pageConfig;
    if (existingConfig?.options) config.options = existingConfig.options;

    if (config.imageMode === 'show' && config.compressMode && config.compressMode !== 'none') {
        await compressImages(targetDir, config.compressMode);
    }

    const sizeInfo = await estimateTotalSize(pageConfig, targetDir, config.imageMode);
    const { totalSize, totalPages } = sizeInfo;
    console.log(`\n预估大小: ${formatBytes(totalSize)},共 ${totalPages} 章`);

    const pageGroups = await splitPages(pageConfig, config.maxSizeMB * 1024 * 1024, targetDir, config.imageMode, sizeInfo.imageSize, totalPages);
    if (pageGroups.length > 1) console.log(`将切割为 ${pageGroups.length} 本`);

    console.log(`[4/4] 构建 EPUB...(共 ${pageGroups.length} 本)`);
    for (let i = 0; i < pageGroups.length; i++) {
        config.pages = pageGroups[i];
        await buildEpub(targetDir, config, pageGroups.length > 1 ? [i + 1, pageGroups.length] : null);
    }
}

6. 模板文件

templates/mimetype

application/epub+zip

templates/META-INF/container.xml

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
  <rootfiles>
    <rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>

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>

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>${tocTitle}</title></head>
<body>
    <nav epub:type="toc" id="toc">
        <h1>${tocTitle}</h1>
        ${tocHtml}
    </nav>
</body>
</html>

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>

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:creator id="creator">${meta.author || ''}</dc:creator>
    <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}"` : ''}/>
    `.trim()).join('')}
  </manifest>
  <spine>
    ${spineList.map(item => `
    <itemref idref="${item.id}" ${item.id === 'cover' ? 'linear="no"' : ''}/>
    `.trim()).join('')}
  </spine>
</package>

使用方式

book.json 配置

{
  "meta": {
    "id": "kepub:1715000000:abc123",
    "title": "书名",
    "author": "作者名",
    "lang": "zh-CN",
    "date": "2026-05-08",
    "modified": "2026-05-08T00:00:00Z"
  },
  "cover": "cover.jpg",
  "maxSizeMB": 100,
  "sortMode": "birthtime-asc",
  "imageMode": "show",
  "compressMode": "none",
  "options": { "tocTitle": "目录" },
  "pages": []
}

pages.json 嵌套结构(大章节分组)

[
  {
    "file": "第一大章",
    "birthtime": 0,
    "children": [
      { "file": "001|第一章.md", "birthtime": 1715000000000 },
      { "file": "002|第二章.md", "birthtime": 1715000001000 }
    ]
  }
]

父节点不需要对应的 .md 文件,仅作结构分组。

CLI

# 从 npm 安装后使用
pnpx @ignorance/epack "path/to/book" --yes
pnpx @ignorance/epack              # 交互式

# 本地开发
node epack/cli.js "path/to/book" --yes

作为库

import { kepub, Task, downloadImages, compressImages, scan, setRootDir, extractTitle, flattenPages, promptBookConfig } from '@ignorance/epack';

// 一行搞定
await kepub('path/to/book', { skipPrompts: true });

// 自定义配置
await kepub('path/to/book', {
  config: {
    meta: { id: 'my:book:001', title: '书名', author: '作者', lang: 'zh-CN', date: '2026-05-08', modified: '2026-05-08T00:00:00Z' },
    imageMode: 'show',
    sortMode: 'pages-json',
    compressMode: 'medium',
    maxSizeMB: 100,
  }
});

// 分步调用
setRootDir('path/to/book');
const pages = scan('', 'birthtime-asc');
await downloadImages('path/to/book');
await compressImages('path/to/book', 'medium');

// 直接构建 Task
const task = new Task('path/to/book', {
  meta: { id: 'test:001', title: '书', author: '', lang: 'zh-CN', date: '2026-05-08', modified: '2026-05-08T00:00:00Z' },
  pages: [{ file: '001|第一章.md', birthtime: 1 }],
  cover: 'cover.jpg',
  imageMode: 'show',
});
await task.run();

发布到 npm

cd epack
npm login
npm publish --access public