@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(使用
fetchAPI) - 系统必须有
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+ziptemplates/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