
Qiaomu Epub Book Generator
- 101 installs
- 129 repo stars
- Updated April 8, 2026
- joeseesun/qiaomu-epub-book-generator
Helps with ai & agent building tasks.
About
qiaomu-epub-book-generator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- qiaomu-epub-book-generator
- AI & Agent Building
- AI-coding skill
Qiaomu Epub Book Generator by the numbers
- 101 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #4,322 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joeseesun/qiaomu-epub-book-generator --skill qiaomu-epub-book-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 101 |
|---|---|
| repo stars | ★ 129 |
| Last updated | April 8, 2026 |
| Repository | joeseesun/qiaomu-epub-book-generator ↗ |
What it does
Helps with ai & agent building tasks.
Files
EPUB Book Generator
从 Markdown 文件生成专业 EPUB 电子书,支持封面、配图压缩、目录生成。
功能特性
- ✅ 单文件或多文件 EPUB 生成
- ✅ 自动下载 Markdown 中的图片(支持 http/https URL)
- ✅ SVG 自动转换(使用 Playwright 将 SVG 转为 PNG,完美支持网页文章)
- ✅ 本地文件路径支持(支持本地图片文件直接嵌入)
- ✅ 自动图片压缩(PNG→JPEG,可配置质量)
- ✅ 完整 Markdown 渲染(代码块、表格、列表等)
- ✅ 专业书籍封面设计(遵循出版行业最佳实践)
- HTML 封面:快速生成,适合大多数场景
- SVG 封面:KDP 标准尺寸(1600x2560),专业排版
- 6 种智能主题:tech, business, design, literature, science, personal
- 3 种布局风格:minimal, classic, modern
- ✅ 自动目录(TOC)生成
- ✅ 微信读书兼容模式
- ✅ 中文字体优化排版
使用方式
⚠️ 封面生成规则(重要)
默认行为:
- 如果用户提供了书名(
--title)或作者(--author),必须自动生成封面 - 默认使用
--cover-svg参数(KDP 标准尺寸,专业排版) - 副标题(
--subtitle)可选,默认为 "N articles" - 主题自动检测,也可手动指定
--cover-theme
封面方法选择:
| 方法 | 参数 | 尺寸 | 适用场景 |
|---|---|---|---|
| SVG(推荐) | --cover-svg | 1600x2560 (KDP) | 专业出版、高质量封面 |
| HTML | --cover-html | 1600x2560 | 快速生成、兼容性好 |
| 自定义图片 | --cover path.jpg | 任意 | 用户自己设计的封面 |
例外情况:
- 用户明确说"不要封面"或"无封面" → 不生成
- 用户提供了封面图片路径(
--cover)→ 使用用户封面
命令模板:
# SVG 封面(推荐,KDP 标准)
python3 gen_epub_enhanced.py <input_dir> <output.epub> \
--title "书名" \
--author "作者" \
--cover-svg \
--subtitle "副标题(可选)" \
--cover-theme "tech" \
--cover-layout "minimal"
# HTML 封面
python3 gen_epub_enhanced.py <input_dir> <output.epub> \
--title "书名" \
--author "作者" \
--cover-html \
--subtitle "副标题(可选)"可用主题:tech, business, design, literature, science, personal(自动根据标题内容检测) 可用布局(仅 SVG):minimal, classic, modern
基础用法
生成 EPUB:
- 输入目录:~/articles/
- 输出:~/output.epub
- 自动生成封面(如果有书名/作者)带自定义封面
生成带封面的 EPUB:
- 文章:~/articles/*.md
- 封面:~/cover.jpg
- 输出:~/book.epub自动生成封面(推荐)
生成 EPUB 并设计封面:
- 书名:《我的书》
- 副标题:精选文章合集
- 作者:向阳乔木
- 风格:Mondo 极简主义真实案例:
生成马斯克传记电子书:
- 书名:《埃隆·马斯克传》
- 副标题:从南非少年到世界首富的传奇人生
- 作者:向阳乔木
- 章节:10 章(01-南非少年.md 到 10-首富人生.md)
- 输出:0.2 MB,兼容微信读书和 Apple Books工作流程
1. 收集输入
- 扫描 Markdown 文件
- 检查配图(同名 PNG/JPG)
- 确认封面(可选)
2. 处理内容
- 解析 Markdown(标题、元数据、正文)
- 自动下载远程图片(http/https URL)
- SVG 自动转换(使用 Playwright 渲染为 PNG)
- 本地文件嵌入(支持相对/绝对路径)
- 压缩图片(宽度 1000px,JPEG 88%)
- 构建 XHTML 章节
3. 生成 EPUB
- 创建 EpubBook 对象
- 添加元数据(作者、语言、描述)
- 设置封面(如有)
- 生成目录(TOC)
- 写入 EPUB 文件
4. 验证输出
- 检查文件大小
- 统计章节数
- 报告图片压缩率
参数配置
详见 references/config-spec.md
脚本说明
scripts/gen_epub_enhanced.py- 推荐 增强版(自动下载图片 + SVG 转换 + 本地文件支持 + 完整 Markdown 渲染)scripts/gen_epub.py- 基础 EPUB 生成(本地图片匹配模式)scripts/gen_epub_v2.py- 微信读书兼容版scripts/gen_epub_with_cover.py- 带封面版本scripts/gen_cover.py- Mondo 风格封面生成
常见问题与解决方案
SVG 图片缺失问题
症状:生成的 EPUB 中 SVG 图片不显示
原因:大多数 EPUB 阅读器不支持 SVG 格式
解决方案: 1. gen_epub_enhanced.py 会自动检测 SVG 图片 2. 使用 Playwright 将 SVG 渲染为 PNG 3. 自动替换 Markdown 中的图片链接
技术细节:
- SVG 检测:通过 URL 扩展名、Content-Type、文件头识别
- 转换方式:Playwright headless browser 渲染 + 截图
- 批处理优化:共享浏览器实例,提升转换速度
Blob URL 问题
症状:脚本卡住不动,或报错 "unknown url type: blob:"
原因:Markdown 中包含 blob:http://localhost/... 等浏览器内存 URL,无法下载
解决方案:
# 清理 blob URLs
sed -i '' '/!\[.*\](blob:http:\/\/localhost\//d' your-article.md本地文件路径支持
用法:Markdown 中可以直接使用本地图片路径

支持的路径格式:
- 相对路径:
./images/photo.png - 绝对路径:
/tmp/photo.png - 父目录:
../assets/photo.png
网页文章转 EPUB 最佳实践
场景:从技术博客(如 tw93.fun)生成 EPUB
推荐流程: 1. 使用 qiaomu-markdown-proxy 或 baoyu-url-to-markdown 下载文章 2. 检查 Markdown 中的图片 URL(特别是 SVG) 3. 如果有大量 SVG,可选择:
- 方案 A(推荐):直接运行
gen_epub_enhanced.py,自动转换 - 方案 B:预先批量转换 SVG,然后替换 URL
4. 清理 blob URLs(如有) 5. 生成 EPUB
真实案例:
# Tw93 技术文集(4 篇文章,33 个 SVG 图片)
python3 gen_epub_enhanced.py /tmp/tw93_articles/ output.epub \
--title "Tw93技术文集" \
--author "Tw93" \
--image-width 1000 \
--image-quality 88
# 输出:2.8 MB,53 张图片(33 SVG→PNG + 20 其他)示例
详见 references/examples.md
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.DS_Store
.vscode/
.idea/
*.epub
*.mobi
*.azw3
/tmp/
/test/
name: qiaomu-epub-book-generator
version: 1.0.0
description: Generate EPUB ebooks from Markdown files with optional cover images, compressed illustrations, and WeChat Reading compatibility
inputs:
- name: input_dir
type: path
description: Directory containing Markdown files
required: true
- name: output_file
type: path
description: Output EPUB file path
required: true
- name: cover_image
type: path
description: Cover image path (optional)
required: false
- name: title
type: string
description: Book title (default: extracted from first article)
required: false
- name: author
type: string
description: Author name (default: extracted from Markdown frontmatter)
required: false
- name: language
type: string
description: Language code (default: zh)
default: zh
required: false
- name: image_quality
type: integer
description: JPEG compression quality (default: 88)
default: 88
required: false
outputs:
- name: epub_file
type: path
description: Generated EPUB file
- name: compression_report
type: text
description: Image compression statistics
- name: chapter_count
type: integer
description: Number of chapters in the EPUB
workflow:
- step: collect_input
description: Scan Markdown files and check for accompanying images
- step: process_content
description: Parse Markdown, compress images, build XHTML chapters
- step: generate_epub
description: Create EpubBook object, add metadata, set cover, generate TOC
- step: validate_output
description: Check file size, count chapters, report compression rate
examples:
- name: basic_single_file
description: Generate EPUB from single Markdown file
inputs:
input_dir: ~/articles/
output_file: ~/output.epub
- name: with_cover
description: Generate EPUB with custom cover image
inputs:
input_dir: ~/articles/
output_file: ~/book.epub
cover_image: ~/cover.jpg
title: 我的文集
author: 作者名
- name: generate_cover_first
description: Generate Mondo-style cover then create EPUB
inputs:
input_dir: ~/articles/
output_file: ~/book.epub
title: Paul Graham 文集
author: Paul Graham / 向阳乔木
name: qiaomu-epub-book-generator
version: 1.0.0
description: Generate EPUB ebooks from Markdown files with optional cover images, compressed illustrations, and WeChat Reading compatibility
author: vista8
license: MIT
dependencies:
python: ">=3.8"
packages:
- ebooklib>=0.18
- markdown>=3.0
- Pillow>=9.0
tools:
- Read
- Write
- Bash
- Glob
skills:
- qiaomu-mondo-poster-design # For cover generation
outputs:
- "*.epub files in specified output directory"
- "Compression report (image sizes before/after)"
- "Chapter count and metadata summary"
📚 EPUB Book Generator
把你的 Markdown 文章一键生成专业电子书
从博客文章、技术笔记、学习资料生成精美 EPUB 电子书。自动下载图片、完美渲染代码块、生成专业封面,兼容微信读书和 Apple Books。
[English](#english) | [中文](#中文)
---
<a name="english"></a>
English
What It Does
Converts Markdown files into professional EPUB ebooks with:
- ✅ Auto-downloads remote images (http/https URLs)
- ✅ SVG to PNG conversion (perfect for web articles)
- ✅ Code blocks, tables, lists rendering
- ✅ Professional cover generation (SVG/HTML)
- ✅ Image compression (PNG→JPEG)
- ✅ WeChat Reading & Apple Books compatible
Installation
Option 1: npx (Recommended)
npx skills add joeseesun/qiaomu-epub-book-generatorOption 2: Git Clone
git clone https://github.com/joeseesun/qiaomu-epub-book-generator.git ~/.claude/skills/qiaomu-epub-book-generatorPrerequisites
- [ ] Python 3.8+ (
python3 --version) - [ ] pip installed (
pip3 --version) - [ ] Install dependencies:
pip install ebooklib markdown Pillow playwright beautifulsoup4
playwright install chromiumVerify: python3 -c "import ebooklib; print('OK')"
Quick Start
Step 1: Prepare Markdown files
mkdir ~/my-articles
# Put your .md files in ~/my-articles/Step 2: Generate EPUB
Tell Claude Code:
- "Generate EPUB from ~/my-articles/"
- "Create ebook from my blog posts"
- "Make EPUB with title 'My Tech Blog' and author 'John Doe'"
Or run directly:
cd ~/.claude/skills/qiaomu-epub-book-generator/scripts
python3 gen_epub_enhanced.py ~/my-articles ~/output.epub \
--title "My Tech Blog" \
--author "John Doe" \
--cover-svgStep 3: Verify output
ls -lh ~/output.epub
# Should see file size (e.g., 2.5 MB)Usage Examples
Basic (auto-detect title from first article)
python3 gen_epub_enhanced.py ~/articles ~/book.epubWith SVG cover (KDP standard 1600x2560)
python3 gen_epub_enhanced.py ~/articles ~/book.epub \
--title "My Book" \
--subtitle "A Collection of Essays" \
--author "Your Name" \
--cover-svg \
--cover-theme tech \
--cover-layout minimalWith custom cover image
python3 gen_epub_enhanced.py ~/articles ~/book.epub \
--title "My Book" \
--author "Your Name" \
--cover ~/my-cover.jpgCustom image quality
python3 gen_epub_enhanced.py ~/articles ~/book.epub \
--image-quality 95 \
--image-width 1200Parameters
| Parameter | Description | Default |
|---|---|---|
input_dir | Directory with Markdown files | Required |
output_file | Output EPUB path | Required |
--title | Book title | From first article |
--author | Author name(s), separate with / | From frontmatter |
--subtitle | Subtitle for cover | None |
--language | Language code | zh |
--cover | Cover image path | None |
--cover-svg | Generate SVG cover (KDP 1600x2560) | False |
--cover-html | Generate HTML cover | False |
--cover-theme | Cover theme: tech, business, design, literature, science, personal | Auto-detect |
--cover-layout | SVG layout: minimal, classic, modern | minimal |
--image-quality | JPEG quality (1-100) | 88 |
--image-width | Max image width (pixels) | 1000 |
Cover Themes
| Theme | Keywords | Style |
|---|---|---|
tech | programming, AI, code, algorithm | Deep navy + cyan |
business | startup, marketing, growth, product | Deep blue + gold |
design | design, UI/UX, visual, brand | Deep purple + pink |
literature | novel, poetry, story, philosophy | Navy blue + warm yellow |
science | science, physics, research, theory | Teal + mint |
personal | growth, learning, notes, reflection | Deep purple + peach |
Real-World Example
Tw93 Tech Blog → EPUB
# 4 articles, 53 images (33 SVG→PNG), 2.9 MB output
python3 gen_epub_enhanced.py /tmp/tw93_articles ~/Tw93-Tech.epub \
--title "Tw93技术文集" \
--subtitle "Claude、Agent、LLM 与学习方法论" \
--author "Tw93" \
--cover ~/cover.png \
--image-quality 88 \
--image-width 1000Troubleshooting
| Problem | Solution |
|---|---|
ModuleNotFoundError: No module named 'ebooklib' | Run pip install ebooklib markdown Pillow |
playwright._impl._api_types.Error: Executable doesn't exist | Run playwright install chromium |
| Code blocks show red boxes around special characters | Fixed in v2.0+ (regex removes Pygments error borders) |
| Images not embedded | Check image URLs are valid http/https or local paths |
| SVG conversion fails | Ensure Playwright installed: pip install playwright && playwright install chromium |
| EPUB file too large | Reduce --image-quality (e.g., 75) or --image-width (e.g., 800) |
⚠️ Notes
- Playwright required for SVG conversion and cover generation
- Image download: Remote images are downloaded and embedded automatically
- File size: Typical 2-5 MB for 10-20 articles with images
- Compatibility: Tested on WeChat Reading, Apple Books, Calibre
Credits
- Based on ebooklib by Aleksandar Erkalović
- Markdown rendering: Python-Markdown
- Image processing: Pillow
---
<a name="中文"></a>
中文
核心价值
把 Markdown 文章转换成专业 EPUB 电子书,支持:
- ✅ 自动下载远程图片(http/https URL)
- ✅ SVG 自动转 PNG(完美支持网页文章)
- ✅ 完整渲染代码块、表格、列表
- ✅ 专业封面生成(SVG/HTML)
- ✅ 图片智能压缩(PNG→JPEG)
- ✅ 兼容微信读书和 Apple Books
安装方式
方式 1:npx(推荐)
npx skills add joeseesun/qiaomu-epub-book-generator方式 2:Git 克隆
git clone https://github.com/joeseesun/qiaomu-epub-book-generator.git ~/.claude/skills/qiaomu-epub-book-generator前置条件
- [ ] Python 3.8+ (
python3 --version检查) - [ ] pip 已安装 (
pip3 --version检查) - [ ] 安装依赖:
pip install ebooklib markdown Pillow playwright beautifulsoup4
playwright install chromium验证:python3 -c "import ebooklib; print('OK')"
快速开始
第 1 步:准备 Markdown 文件
mkdir ~/my-articles
# 把你的 .md 文件放到 ~/my-articles/ 目录第 2 步:生成 EPUB
直接对 Claude Code 说:
- "从 ~/my-articles/ 生成 EPUB"
- "把我的博客文章做成电子书"
- "生成 EPUB,书名《我的技术博客》,作者张三"
或直接运行命令:
cd ~/.claude/skills/qiaomu-epub-book-generator/scripts
python3 gen_epub_enhanced.py ~/my-articles ~/output.epub \
--title "我的技术博客" \
--author "张三" \
--cover-svg第 3 步:验证输出
ls -lh ~/output.epub
# 应该看到文件大小(如 2.5 MB)使用示例
基础用法(自动从第一篇文章提取标题)
python3 gen_epub_enhanced.py ~/articles ~/book.epub生成 SVG 封面(KDP 标准 1600x2560)
python3 gen_epub_enhanced.py ~/articles ~/book.epub \
--title "我的书" \
--subtitle "文章合集" \
--author "你的名字" \
--cover-svg \
--cover-theme tech \
--cover-layout minimal使用自定义封面图片
python3 gen_epub_enhanced.py ~/articles ~/book.epub \
--title "我的书" \
--author "你的名字" \
--cover ~/my-cover.jpg自定义图片质量
python3 gen_epub_enhanced.py ~/articles ~/book.epub \
--image-quality 95 \
--image-width 1200参数说明
| 参数 | 说明 | 默认值 |
|---|---|---|
input_dir | Markdown 文件目录 | 必填 |
output_file | 输出 EPUB 文件路径 | 必填 |
--title | 书名 | 从第一篇文章提取 |
--author | 作者(多个用 / 分隔) | 从 frontmatter 提取 |
--subtitle | 封面副标题 | 无 |
--language | 语言代码 | zh |
--cover | 封面图片路径 | 无 |
--cover-svg | 生成 SVG 封面(KDP 1600x2560) | False |
--cover-html | 生成 HTML 封面 | False |
--cover-theme | 封面主题:tech, business, design, literature, science, personal | 自动检测 |
--cover-layout | SVG 布局:minimal, classic, modern | minimal |
--image-quality | JPEG 压缩质量 (1-100) | 88 |
--image-width | 图片最大宽度(像素) | 1000 |
封面主题
| 主题 | 关键词 | 风格 |
|---|---|---|
tech | 编程、AI、代码、算法 | 深蓝 + 青色 |
business | 创业、营销、增长、产品 | 深蓝 + 金色 |
design | 设计、UI/UX、视觉、品牌 | 深紫 + 粉色 |
literature | 小说、诗歌、故事、哲学 | 海军蓝 + 暖黄 |
science | 科学、物理、研究、理论 | 青绿 + 薄荷 |
personal | 成长、学习、笔记、反思 | 深紫 + 桃色 |
真实案例
Tw93 技术博客 → EPUB
# 4 篇文章,53 张图片(33 SVG→PNG),2.9 MB 输出
python3 gen_epub_enhanced.py /tmp/tw93_articles ~/Tw93技术文集.epub \
--title "Tw93技术文集" \
--subtitle "Claude、Agent、LLM 与学习方法论" \
--author "Tw93" \
--cover ~/cover.png \
--image-quality 88 \
--image-width 1000常见问题
| 问题 | 解决方法 |
|---|---|
ModuleNotFoundError: No module named 'ebooklib' | 运行 pip install ebooklib markdown Pillow |
playwright._impl._api_types.Error: Executable doesn't exist | 运行 playwright install chromium |
| 代码块特殊字符有红框 | v2.0+ 已修复(正则移除 Pygments 错误边框) |
| 图片没有嵌入 | 检查图片 URL 是否有效(http/https 或本地路径) |
| SVG 转换失败 | 确保安装 Playwright:pip install playwright && playwright install chromium |
| EPUB 文件太大 | 降低 --image-quality(如 75)或 --image-width(如 800) |
⚠️ 注意事项
- 需要 Playwright:用于 SVG 转换和封面生成
- 图片下载:远程图片会自动下载并嵌入
- 文件大小:10-20 篇文章带图片通常 2-5 MB
- 兼容性:已在微信读书、Apple Books、Calibre 测试
致谢
- 基于 ebooklib(作者:Aleksandar Erkalović)
- Markdown 渲染:Python-Markdown
- 图片处理:Pillow
---
📱 关注作者
如果这个项目对你有帮助,欢迎关注我获取更多技术分享:
- X (Twitter): @vista8
- 微信公众号「向阳乔木推荐看」:
<p align="center"> <img src="https://github.com/joeseesun/terminal-boost/raw/main/assets/wechat-qr.jpg?raw=true" alt="向阳乔木推荐看公众号二维码" width="300"> </p>
- GitHub: @joeseesun
许可证
MIT License
EPUB 生成配置规范
基础参数
输入
input_dir: Markdown 文件目录(必需)output_file: 输出 EPUB 文件路径(必需)cover_image: 封面图片路径(可选)
元数据
title: 书名(默认:从第一篇文章提取)author: 作者(默认:从 Markdown frontmatter 提取)language: 语言代码(默认:zh)description: 书籍描述(可选)
图片处理
image_max_width: 图片最大宽度(默认:1000px)image_quality: JPEG 压缩质量(默认:88)convert_png_to_jpg: 是否转换 PNG 为 JPEG(默认:true)
排版
font_family: 字体族(默认:'Noto Serif SC', 'Source Han Serif SC', serif)line_height: 行高(默认:1.8)font_size: 基础字号(默认:16px)
使用示例
Python 脚本调用
from scripts.gen_epub_v2 import generate_epub
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
title="我的文集",
author="作者名",
cover_image="~/cover.jpg",
image_quality=85
)命令行调用
# 基础版(无封面)
python3 scripts/gen_epub_v2.py ~/articles ~/output.epub
# 带封面版
python3 scripts/gen_epub_with_cover.py ~/articles ~/output.epub ~/cover.jpg
# 生成封面
python3 scripts/gen_pg_covers.py 0 1 # 生成第 0 号封面设计微信读书兼容性
gen_epub_v2.py 针对微信读书优化:
- 使用
<h2>作为章节标题(而非<h1>) - 简化 CSS(避免复杂选择器)
- 图片居中对齐
- 段落间距优化
封面设计风格
gen_pg_covers.py 提供 10 种 Mondo 风格设计:
- 0-2: 极简主义(几何图形)
- 3-5: 瑞士国际主义(网格系统)
- 6-9: Paul Rand 风格(色块拼贴)
每种设计自动生成 1200x1800px 高清封面。
EPUB 生成示例
基础用法
1. 单个 Markdown 文件生成 EPUB
python3 ~/.claude/skills/qiaomu-epub-book-generator/scripts/gen_epub.py \
--title "我的书" \
--author "作者名" \
~/articles/ \
~/output.epub说明:
- 自动扫描
~/articles/目录下的所有.md文件 - 从第一篇文章提取标题和作者
- 自动查找同名 PNG/JPG 配图并压缩嵌入
- 生成微信读书兼容的 EPUB
2. 带封面的 EPUB
python3 ~/.claude/skills/epub-book-generator/scripts/gen_epub_with_cover.py \
~/articles/ \
~/book.epub \
~/cover.jpg说明:
- 第三个参数指定封面图片路径
- 封面会自动设置为 EPUB 的 cover image
- 支持 JPG/PNG 格式
3. 生成 Mondo 风格封面
# 生成第 0 号设计(极简主义)
python3 ~/.claude/skills/epub-book-generator/scripts/gen_pg_covers.py 0 1
# 生成第 3-5 号设计(瑞士国际主义)
python3 ~/.claude/skills/epub-book-generator/scripts/gen_pg_covers.py 3 3
# 生成所有 10 种设计
python3 ~/.claude/skills/epub-book-generator/scripts/gen_pg_covers.py 0 10说明:
- 第一个参数:起始设计编号(0-9)
- 第二个参数:生成数量
- 输出:
cover-{n}.png(1200x1800px)
设计风格:
- 0-2: 极简主义(几何图形)
- 3-5: 瑞士国际主义(网格系统)
- 6-9: Paul Rand 风格(色块拼贴)
Python 脚本调用
基础版本(gen_epub.py)
from scripts.gen_epub import generate_epub
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
title="我的文集",
author="作者名"
)微信读书优化版(gen_epub.py)
from scripts.gen_epub import generate_epub
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
title="我的文集",
author="作者名",
language="zh",
image_quality=85 # JPEG 压缩质量
)带封面版本(gen_epub_with_cover.py)
from scripts.gen_epub_with_cover import generate_epub_with_cover
generate_epub_with_cover(
input_dir="~/articles",
output_file="~/book.epub",
cover_image="~/cover.jpg",
title="我的书",
author="作者名"
)完整工作流示例
场景:传记类电子书制作(马斯克传)
真实案例:10 章节传记,从南非少年到世界首富
# 1. 准备文章目录
mkdir -p /tmp/musk-book/articles/
# 2. 文章命名规范(按章节顺序)
/tmp/musk-book/articles/
├── 01-南非少年.md
├── 02-初试锋芒.md
├── 03-PayPal传奇.md
├── 04-仰望星空.md
├── 05-电动革命.md
├── 06-多线作战.md
├── 07-AI野心.md
├── 08-推特风云.md
├── 09-政治漩涡.md
└── 10-首富人生.md
# 3. 一键生成(自动生成 HTML 封面)
python3 ~/.claude/skills/qiaomu-epub-book-generator/scripts/gen_epub.py \
--title "埃隆·马斯克传" \
--subtitle "从南非少年到世界首富的传奇人生" \
--author "向阳乔木" \
--language zh \
--cover-html \
/tmp/musk-book/articles/ \
~/Downloads/埃隆马斯克传.epub
# 4. 输出结果
# ✅ Done!
# Output: ~/Downloads/埃隆马斯克传.epub
# File size: 0.2 MB
# Chapters: 10最佳实践:
- ✅ 文章按章节编号(01-、02-...)确保顺序
- ✅ 使用
--cover-html自动生成精美封面 - ✅ 副标题概括核心内容
- ✅ 纯文本传记约 0.2 MB,适合快速阅读
- ✅ 兼容微信读书、Apple Books
场景:技术文集制作
# 1. 准备目录结构
mkdir -p ~/tech-essays/{articles,covers}
# 2. 生成 EPUB(自动生成封面)
python3 ~/.claude/skills/qiaomu-epub-book-generator/scripts/gen_epub.py \
--title "我的技术文集" \
--subtitle "230篇创业与编程经典" \
--author "作者名" \
--language zh \
--cover-html \
~/tech-essays/articles/ \
~/tech-essays/output.epub
# 3. 验证输出
ls -lh ~/tech-essays/output.epub场景:多篇文章快速生成
# 文章目录结构
~/my-articles/
├── article1.md
├── article1.png # 配图(可选)
├── article2.md
├── article2.png
└── article3.md
# 一键生成
python3 ~/.claude/skills/epub-book-generator/scripts/gen_epub_v2.py \
~/my-articles/ \
~/output.epub自动处理:
- 扫描所有
.md文件 - 查找同名
.png或.jpg配图 - 压缩图片(宽度 1000px,JPEG 88%)
- 生成目录(TOC)
- 输出压缩报告
配置参数示例
自定义图片压缩
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
image_max_width=800, # 图片最大宽度(默认 1000px)
image_quality=75, # JPEG 质量(默认 88)
convert_png_to_jpg=True # PNG 转 JPEG(默认 True)
)自定义排版
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
font_family="'Noto Serif SC', 'Source Han Serif SC', serif",
line_height=1.8, # 行高(默认 1.8)
font_size="16px" # 基础字号(默认 16px)
)完整元数据
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
title="我的文集",
author="作者名",
language="zh", # 语言代码(默认 zh)
description="这是一本关于创业和编程的文集",
cover_image="~/cover.jpg"
)输出验证
检查 EPUB 文件
# 查看文件大小
ls -lh ~/output.epub
# 验证章节数(需要 Python)
python3 -c "
from ebooklib import epub
book = epub.read_epub('~/output.epub')
print(f'章节数: {len([item for item in book.items if isinstance(item, epub.EpubHtml)])}')
"
# 在 Apple Books 中打开
open ~/output.epub压缩报告示例
图片压缩报告:
- 原始总大小: 460.8 MB
- 压缩后大小: 45.2 MB
- 压缩率: 90.2%
- 处理图片数: 230 张常见问题
Q: 图片太大,EPUB 文件超过 100MB?
A: 调整压缩参数:
generate_epub(
input_dir="~/articles",
output_file="~/output.epub",
image_max_width=600, # 降低宽度
image_quality=70 # 降低质量
)Q: 微信读书显示不正常?
A: 使用 gen_epub_v2.py(微信读书优化版):
- 使用
<h2>作为章节标题 - 简化 CSS
- 图片居中对齐
Q: 如何批量生成多本书?
A: 使用 Shell 脚本:
#!/bin/bash
for dir in ~/books/*/; do
book_name=$(basename "$dir")
python3 ~/.claude/skills/epub-book-generator/scripts/gen_epub_v2.py \
"$dir" \
~/output/"$book_name".epub
done技术细节
Markdown 解析
- 支持标准 Markdown 语法
- 自动提取 frontmatter 元数据
- 保留代码块、引用、列表等格式
图片处理流程
1. 扫描 Markdown 文件,查找同名图片 2. 使用 Pillow 加载图片 3. 按比例缩放到指定宽度 4. 转换为 JPEG(可选) 5. 压缩到指定质量 6. 嵌入 XHTML 章节
EPUB 结构
output.epub
├── META-INF/
│ └── container.xml
├── OEBPS/
│ ├── content.opf # 元数据和清单
│ ├── toc.ncx # 目录
│ ├── nav.xhtml # EPUB3 导航
│ ├── chapter1.xhtml # 章节内容
│ ├── chapter2.xhtml
│ ├── images/
│ │ ├── img1.jpg
│ │ └── img2.jpg
│ └── style.css # 样式表
└── mimetype相关资源
- 配置规范 - 完整参数说明
- ebooklib 文档 - Python EPUB 库
- EPUB 3 规范 - 官方标准
#!/usr/bin/env python3
"""
Generate EPUB cover from HTML template with intelligent theme detection.
Uses Playwright to render HTML and take screenshot.
"""
import sys
import os
import re
from pathlib import Path
# Theme definitions with keywords and visual styles
THEMES = {
"tech": {
"keywords": ["技术", "编程", "代码", "开发", "AI", "Claude", "Agent", "LLM", "算法", "架构", "前端", "后端", "数据", "机器学习"],
"gradient": "linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%)",
"accent": "#00d4ff",
"pattern": "circuit"
},
"business": {
"keywords": ["创业", "商业", "管理", "营销", "增长", "产品", "运营", "战略", "投资", "融资"],
"gradient": "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
"accent": "#f39c12",
"pattern": "grid"
},
"design": {
"keywords": ["设计", "美学", "艺术", "视觉", "UI", "UX", "品牌", "创意", "排版"],
"gradient": "linear-gradient(135deg, #2d1b69 0%, #5b247a 50%, #8b3a8b 100%)",
"accent": "#ff6b9d",
"pattern": "dots"
},
"literature": {
"keywords": ["文学", "小说", "诗歌", "散文", "故事", "传记", "历史", "哲学", "思想"],
"gradient": "linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e8ba3 100%)",
"accent": "#ffd89b",
"pattern": "lines"
},
"science": {
"keywords": ["科学", "物理", "化学", "生物", "数学", "研究", "实验", "理论", "发现"],
"gradient": "linear-gradient(135deg, #134e5e 0%, #71b280 100%)",
"accent": "#a8e6cf",
"pattern": "hexagon"
},
"personal": {
"keywords": ["成长", "学习", "方法", "思考", "笔记", "总结", "反思", "日记", "随笔"],
"gradient": "linear-gradient(135deg, #3a1c71 0%, #d76d77 50%, #ffaf7b 100%)",
"accent": "#ffeaa7",
"pattern": "wave"
}
}
def detect_theme(title, subtitle="", author=""):
"""Detect theme based on title, subtitle, and author."""
text = f"{title} {subtitle} {author}".lower()
scores = {}
for theme_name, theme_data in THEMES.items():
score = sum(1 for keyword in theme_data["keywords"] if keyword.lower() in text)
scores[theme_name] = score
# Return theme with highest score, default to "tech"
best_theme = max(scores, key=scores.get)
return best_theme if scores[best_theme] > 0 else "tech"
def get_pattern_svg(pattern_type, accent_color):
"""Generate SVG pattern based on type."""
patterns = {
"circuit": f'''
<svg width="100%" height="100%" style="position:absolute;top:0;left:0;opacity:0.08;">
<defs>
<pattern id="circuit" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="2" fill="{accent_color}"/>
<line x1="10" y1="10" x2="50" y2="10" stroke="{accent_color}" stroke-width="1"/>
<line x1="50" y1="10" x2="50" y2="50" stroke="{accent_color}" stroke-width="1"/>
<circle cx="50" cy="50" r="2" fill="{accent_color}"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#circuit)"/>
</svg>
''',
"grid": f'''
<svg width="100%" height="100%" style="position:absolute;top:0;left:0;opacity:0.05;">
<defs>
<pattern id="grid" x="0" y="0" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="{accent_color}" stroke-width="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
</svg>
''',
"dots": f'''
<svg width="100%" height="100%" style="position:absolute;top:0;left:0;opacity:0.1;">
<defs>
<pattern id="dots" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="20" cy="20" r="3" fill="{accent_color}"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#dots)"/>
</svg>
''',
"lines": f'''
<svg width="100%" height="100%" style="position:absolute;top:0;left:0;opacity:0.06;">
<defs>
<pattern id="lines" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="10" stroke="{accent_color}" stroke-width="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#lines)"/>
</svg>
''',
"hexagon": f'''
<svg width="100%" height="100%" style="position:absolute;top:0;left:0;opacity:0.08;">
<defs>
<pattern id="hexagon" x="0" y="0" width="56" height="100" patternUnits="userSpaceOnUse">
<path d="M28 0 L56 25 L56 75 L28 100 L0 75 L0 25 Z" fill="none" stroke="{accent_color}" stroke-width="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#hexagon)"/>
</svg>
''',
"wave": f'''
<svg width="100%" height="100%" style="position:absolute;top:0;left:0;opacity:0.07;">
<defs>
<pattern id="wave" x="0" y="0" width="100" height="50" patternUnits="userSpaceOnUse">
<path d="M0 25 Q25 0, 50 25 T100 25" fill="none" stroke="{accent_color}" stroke-width="2"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#wave)"/>
</svg>
'''
}
return patterns.get(pattern_type, patterns["circuit"])
def generate_cover_html(title, subtitle="", author="", output_path="/tmp/cover.html", theme=None):
"""Generate HTML cover page with professional book cover layout."""
# Auto-detect theme if not specified
if theme is None:
theme = detect_theme(title, subtitle, author)
theme_data = THEMES.get(theme, THEMES["tech"])
gradient = theme_data["gradient"]
accent = theme_data["accent"]
pattern = get_pattern_svg(theme_data["pattern"], accent)
print(f"🎨 Detected theme: {theme}")
html_content = f"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1600, initial-scale=1.0">
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700;900&display=swap');
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
width: 1600px;
height: 2560px;
background: {gradient};
font-family: 'Noto Serif SC', serif;
position: relative;
overflow: hidden;
}}
.pattern {{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}}
/* Visual accent block - top left */
.accent-block {{
position: absolute;
top: 15%;
left: 0;
width: 30%;
height: 5%;
background: {accent};
opacity: 0.9;
z-index: 1;
}}
/* Visual accent - bottom right */
.accent-circle {{
position: absolute;
bottom: 20%;
right: 10%;
width: 200px;
height: 200px;
border-radius: 50%;
background: {accent};
opacity: 0.15;
z-index: 1;
}}
/* Title zone - top 1/3 */
.title-zone {{
position: absolute;
top: 25%;
left: 0;
right: 0;
padding: 0 120px;
z-index: 2;
}}
.title {{
font-size: 96px;
font-weight: 900;
color: #ffffff;
line-height: 1.2;
letter-spacing: 0.02em;
text-shadow: 0 4px 30px rgba(0,0,0,0.5);
margin-bottom: 60px;
}}
.subtitle {{
font-size: 36px;
font-weight: 400;
color: #e0e0e0;
line-height: 1.5;
opacity: 0.9;
}}
/* Author zone - bottom */
.author-zone {{
position: absolute;
bottom: 8%;
left: 0;
right: 0;
text-align: center;
z-index: 2;
}}
.author {{
font-size: 40px;
font-weight: 400;
color: {accent};
letter-spacing: 0.15em;
text-shadow: 0 0 20px {accent}60;
}}
/* Separator line above author */
.separator {{
width: 200px;
height: 3px;
background: linear-gradient(90deg, transparent, {accent}, transparent);
margin: 0 auto 40px;
box-shadow: 0 0 20px {accent}80;
}}
/* KDP border */
.border {{
position: absolute;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
border: 3px solid #888;
opacity: 0.3;
z-index: 3;
pointer-events: none;
}}
</style>
</head>
<body>
<div class="pattern">{pattern}</div>
<div class="accent-block"></div>
<div class="accent-circle"></div>
<div class="title-zone">
<div class="title">{title}</div>
{f'<div class="subtitle">{subtitle}</div>' if subtitle else ''}
</div>
{f'''<div class="author-zone">
<div class="separator"></div>
<div class="author">{author}</div>
</div>''' if author else ''}
<div class="border"></div>
</body>
</html>"""
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"✅ HTML cover generated: {output_path}")
return output_path
def screenshot_cover(html_path, output_image="/tmp/cover.jpg"):
"""Take screenshot of HTML cover using Playwright."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("❌ Playwright not installed. Run: pip install playwright && playwright install chromium")
sys.exit(1)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 2560}, device_scale_factor=2)
page.goto(f"file://{os.path.abspath(html_path)}")
page.wait_for_timeout(2000) # Wait for fonts to load
page.screenshot(path=output_image, type='jpeg', quality=95)
browser.close()
print(f"✅ Cover screenshot saved: {output_image}")
return output_image
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 gen_cover_html.py <title> [subtitle] [author] [output_image] [theme]")
print("Example: python3 gen_cover_html.py 'Tw93技术文集' 'Claude、Agent、LLM 与学习方法论' 'Tw93' cover.jpg")
print(f"Available themes: {', '.join(THEMES.keys())}")
sys.exit(1)
title = sys.argv[1]
subtitle = sys.argv[2] if len(sys.argv) > 2 else ""
author = sys.argv[3] if len(sys.argv) > 3 else ""
output_image = sys.argv[4] if len(sys.argv) > 4 else "/tmp/cover.jpg"
theme = sys.argv[5] if len(sys.argv) > 5 else None
# Generate HTML
html_path = generate_cover_html(title, subtitle, author, theme=theme)
# Screenshot
screenshot_cover(html_path, output_image)
print(f"\n✅ Done! Cover image: {output_image}")
#!/usr/bin/env python3
"""
Professional book cover generator using SVG with multiple layout styles.
Follows industry best practices: proper typography hierarchy, visual zones, color theory.
"""
import sys
import os
import re
import hashlib
from pathlib import Path
# Theme definitions with professional book cover color palettes
THEMES = {
"tech": {
"keywords": ["技术", "编程", "代码", "开发", "ai", "claude", "agent", "llm", "算法", "架构", "前端", "后端", "数据", "机器学习"],
"primary": "#1a1a2e", # Deep navy
"secondary": "#16213e", # Dark blue
"accent": "#00d4ff", # Cyan
"text": "#ffffff",
"subtitle_text": "#e0e0e0"
},
"business": {
"keywords": ["创业", "商业", "管理", "营销", "增长", "产品", "运营", "战略", "投资", "融资"],
"primary": "#0f3460", # Deep blue
"secondary": "#16213e",
"accent": "#f39c12", # Gold
"text": "#ffffff",
"subtitle_text": "#e8e8e8"
},
"design": {
"keywords": ["设计", "美学", "艺术", "视觉", "ui", "ux", "品牌", "创意", "排版"],
"primary": "#2d1b69", # Deep purple
"secondary": "#5b247a",
"accent": "#ff6b9d", # Pink
"text": "#ffffff",
"subtitle_text": "#f0e6ff"
},
"literature": {
"keywords": ["文学", "小说", "诗歌", "散文", "故事", "传记", "历史", "哲学", "思想"],
"primary": "#1e3c72", # Navy blue
"secondary": "#2a5298",
"accent": "#ffd89b", # Warm yellow
"text": "#ffffff",
"subtitle_text": "#e8e8e8"
},
"science": {
"keywords": ["科学", "物理", "化学", "生物", "数学", "研究", "实验", "理论", "发现"],
"primary": "#134e5e", # Teal
"secondary": "#71b280",
"accent": "#a8e6cf", # Mint
"text": "#ffffff",
"subtitle_text": "#e8f5e9"
},
"personal": {
"keywords": ["成长", "学习", "方法", "思考", "笔记", "总结", "反思", "日记", "随笔"],
"primary": "#3a1c71", # Deep purple
"secondary": "#d76d77",
"accent": "#ffaf7b", # Peach
"text": "#ffffff",
"subtitle_text": "#fff3e0"
}
}
# Layout styles following professional book cover design principles
LAYOUTS = {
"minimal": {
"title_y": "32%",
"title_size": "150px",
"subtitle_y": "52%",
"subtitle_size": "52px",
"author_y": "88%",
"author_size": "60px",
"visual_element": "geometric_blocks"
},
"classic": {
"title_y": "34%",
"title_size": "140px",
"subtitle_y": "54%",
"subtitle_size": "50px",
"author_y": "90%",
"author_size": "56px",
"visual_element": "horizontal_lines"
},
"modern": {
"title_y": "30%",
"title_size": "160px",
"subtitle_y": "52%",
"subtitle_size": "56px",
"author_y": "86%",
"author_size": "64px",
"visual_element": "diagonal_accent"
}
}
def detect_theme(title, subtitle="", author=""):
"""Detect theme based on title, subtitle, and author."""
text = f"{title} {subtitle} {author}".lower()
scores = {}
for theme_name, theme_data in THEMES.items():
score = sum(1 for keyword in theme_data["keywords"] if keyword.lower() in text)
scores[theme_name] = score
best_theme = max(scores, key=scores.get)
return best_theme if scores[best_theme] > 0 else "tech"
def wrap_chinese_text(text, max_chars_per_line=12):
"""Wrap Chinese text for better readability on book covers."""
if len(text) <= max_chars_per_line:
return [text]
lines = []
current_line = ""
for char in text:
if len(current_line) >= max_chars_per_line:
lines.append(current_line)
current_line = char
else:
current_line += char
if current_line:
lines.append(current_line)
return lines
def generate_visual_element(layout_style, theme_colors, width=1600, height=2560):
"""Generate SVG visual elements based on layout style."""
primary = theme_colors["primary"]
secondary = theme_colors["secondary"]
accent = theme_colors["accent"]
if layout_style == "geometric_blocks":
return f'''
<!-- Top accent bar with glow -->
<rect x="0" y="{height * 0.12}" width="{width * 0.45}" height="{height * 0.06}" fill="{accent}" opacity="0.95"/>
<rect x="0" y="{height * 0.12}" width="{width * 0.45}" height="{height * 0.06}" fill="{accent}" opacity="0.3" filter="url(#glow)"/>
<!-- Bottom right geometric composition -->
<rect x="{width * 0.65}" y="{height * 0.62}" width="{width * 0.32}" height="{height * 0.04}" fill="{accent}" opacity="0.85"/>
<rect x="{width * 0.72}" y="{height * 0.68}" width="{width * 0.18}" height="{height * 0.025}" fill="{accent}" opacity="0.6"/>
<!-- Large decorative circle -->
<circle cx="{width * 0.88}" cy="{height * 0.22}" r="{width * 0.12}" fill="{accent}" opacity="0.12"/>
<circle cx="{width * 0.88}" cy="{height * 0.22}" r="{width * 0.09}" fill="none" stroke="{accent}" stroke-width="2" opacity="0.25"/>
<!-- Small accent dots -->
<circle cx="{width * 0.08}" cy="{height * 0.75}" r="8" fill="{accent}" opacity="0.8"/>
<circle cx="{width * 0.12}" cy="{height * 0.77}" r="6" fill="{accent}" opacity="0.6"/>
'''
elif layout_style == "horizontal_lines":
return f'''
<!-- Main separator lines with gradient effect -->
<line x1="0" y1="{height * 0.40}" x2="{width}" y2="{height * 0.40}" stroke="{accent}" stroke-width="4" opacity="0.9"/>
<line x1="{width * 0.1}" y1="{height * 0.405}" x2="{width * 0.9}" y2="{height * 0.405}" stroke="{accent}" stroke-width="2" opacity="0.5"/>
<!-- Bottom decorative lines -->
<line x1="0" y1="{height * 0.80}" x2="{width * 0.6}" y2="{height * 0.80}" stroke="{accent}" stroke-width="3" opacity="0.8"/>
<line x1="{width * 0.65}" y1="{height * 0.80}" x2="{width}" y2="{height * 0.80}" stroke="{accent}" stroke-width="3" opacity="0.8"/>
<!-- Accent rectangles -->
<rect x="{width * 0.05}" y="{height * 0.18}" width="{width * 0.25}" height="6" fill="{accent}" opacity="0.9"/>
<rect x="{width * 0.75}" y="{height * 0.65}" width="{width * 0.2}" height="4" fill="{accent}" opacity="0.7"/>
'''
elif layout_style == "diagonal_accent":
return f'''
<!-- Large diagonal shape -->
<polygon points="0,{height * 0.58} {width * 0.5},{height * 0.52} {width * 0.5},{height * 0.68} 0,{height * 0.74}"
fill="{accent}" opacity="0.25"/>
<polygon points="0,{height * 0.58} {width * 0.5},{height * 0.52} {width * 0.5},{height * 0.54} 0,{height * 0.60}"
fill="{accent}" opacity="0.5"/>
<!-- Top accent bars -->
<rect x="{width * 0.05}" y="{height * 0.16}" width="{width * 0.22}" height="6" fill="{accent}" opacity="0.95"/>
<rect x="{width * 0.05}" y="{height * 0.18}" width="{width * 0.15}" height="4" fill="{accent}" opacity="0.7"/>
<!-- Bottom right composition -->
<rect x="{width * 0.7}" y="{height * 0.75}" width="{width * 0.25}" height="5" fill="{accent}" opacity="0.85"/>
<circle cx="{width * 0.92}" cy="{height * 0.78}" r="12" fill="{accent}" opacity="0.9"/>
'''
return ""
def generate_svg_cover(title, subtitle="", author="", output_path="/tmp/cover.svg",
theme=None, layout="minimal", width=1600, height=2560):
"""Generate professional book cover in SVG format."""
# Auto-detect theme if not specified
if theme is None:
theme = detect_theme(title, subtitle, author)
theme_colors = THEMES.get(theme, THEMES["tech"])
layout_config = LAYOUTS.get(layout, LAYOUTS["minimal"])
print(f"🎨 Theme: {theme} | Layout: {layout}")
# Wrap title for better display
title_lines = wrap_chinese_text(title, max_chars_per_line=10)
subtitle_lines = wrap_chinese_text(subtitle, max_chars_per_line=16) if subtitle else []
# Calculate positions
title_y_base = int(height * float(layout_config["title_y"].strip('%')) / 100)
subtitle_y_base = int(height * float(layout_config["subtitle_y"].strip('%')) / 100)
author_y = int(height * float(layout_config["author_y"].strip('%')) / 100)
# Build title text elements
title_svg = ""
line_height = int(layout_config["title_size"].strip('px')) * 1.2
for i, line in enumerate(title_lines):
y_pos = title_y_base + (i * line_height)
title_svg += f'<text x="50%" y="{y_pos}" class="title">{line}</text>\n'
# Build subtitle text elements
subtitle_svg = ""
if subtitle_lines:
sub_line_height = int(layout_config["subtitle_size"].strip('px')) * 1.3
for i, line in enumerate(subtitle_lines):
y_pos = subtitle_y_base + (i * sub_line_height)
subtitle_svg += f'<text x="50%" y="{y_pos}" class="subtitle">{line}</text>\n'
# Generate visual elements
visual_elements = generate_visual_element(
layout_config["visual_element"],
theme_colors,
width,
height
)
# Build SVG
svg_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:{theme_colors['primary']};stop-opacity:1" />
<stop offset="100%" style="stop-color:{theme_colors['secondary']};stop-opacity:1" />
</linearGradient>
<!-- Glow filter for visual elements -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700;900&display=swap');
.title {{
font-family: 'Noto Serif SC', serif;
font-size: {layout_config['title_size']};
font-weight: 900;
fill: {theme_colors['text']};
text-anchor: middle;
dominant-baseline: middle;
}}
.subtitle {{
font-family: 'Noto Serif SC', serif;
font-size: {layout_config['subtitle_size']};
font-weight: 400;
fill: {theme_colors['subtitle_text']};
text-anchor: middle;
dominant-baseline: middle;
}}
.author {{
font-family: 'Noto Serif SC', serif;
font-size: {layout_config['author_size']};
font-weight: 400;
fill: {theme_colors['accent']};
text-anchor: middle;
dominant-baseline: middle;
letter-spacing: 0.1em;
}}
</style>
</defs>
<!-- Background -->
<rect width="{width}" height="{height}" fill="url(#bgGradient)"/>
<!-- Visual Elements -->
{visual_elements}
<!-- Title -->
{title_svg}
<!-- Subtitle -->
{subtitle_svg}
<!-- Author -->
<text x="50%" y="{author_y}" class="author">{author}</text>
<!-- Border (KDP recommendation for light covers) -->
<rect x="2" y="2" width="{width-4}" height="{height-4}"
fill="none" stroke="#888" stroke-width="3" opacity="0.3"/>
</svg>'''
with open(output_path, 'w', encoding='utf-8') as f:
f.write(svg_content)
print(f"✅ SVG cover generated: {output_path}")
return output_path
def convert_svg_to_image(svg_path, output_image="/tmp/cover.jpg", format="jpeg", quality=95):
"""Convert SVG to PNG/JPEG using Playwright (most reliable for Chinese fonts)."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("❌ Playwright not installed. Run: pip install playwright && playwright install chromium")
sys.exit(1)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 2560}, device_scale_factor=2)
page.goto(f"file://{os.path.abspath(svg_path)}")
page.wait_for_timeout(3000) # Wait for fonts to load
screenshot_type = 'jpeg' if format == 'jpeg' else 'png'
page.screenshot(path=output_image, type=screenshot_type, quality=quality if format == 'jpeg' else None)
browser.close()
print(f"✅ Cover image saved: {output_image}")
return output_image
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 gen_cover_svg.py <title> [subtitle] [author] [output_image] [theme] [layout]")
print("Example: python3 gen_cover_svg.py 'Tw93技术文集' 'Claude、Agent、LLM 与学习方法论' 'Tw93' cover.jpg tech minimal")
print(f"Available themes: {', '.join(THEMES.keys())}")
print(f"Available layouts: {', '.join(LAYOUTS.keys())}")
sys.exit(1)
title = sys.argv[1]
subtitle = sys.argv[2] if len(sys.argv) > 2 else ""
author = sys.argv[3] if len(sys.argv) > 3 else ""
output_image = sys.argv[4] if len(sys.argv) > 4 else "/tmp/cover.jpg"
theme = sys.argv[5] if len(sys.argv) > 5 else None
layout = sys.argv[6] if len(sys.argv) > 6 else "minimal"
# Generate SVG
svg_path = generate_svg_cover(title, subtitle, author, theme=theme, layout=layout)
# Convert to image
convert_svg_to_image(svg_path, output_image)
print(f"\n✅ Done! Cover image: {output_image}")
#!/usr/bin/env python3
"""
Enhanced EPUB generator with image downloading and better Markdown rendering.
Key improvements:
1. Downloads all images from Markdown (http/https URLs) and embeds them
2. Better code block styling with syntax highlighting
3. Table support with proper styling
4. Preserves all Markdown elements (lists, blockquotes, etc.)
Usage:
python3 gen_epub_enhanced.py <input_dir> <output.epub> [options]
"""
import argparse
import os
import glob
import re
import sys
import html as html_module
import hashlib
import urllib.request
import urllib.parse
from pathlib import Path
from ebooklib import epub
import markdown
from PIL import Image
import io
# Enhanced CSS with code block and table styling
CHAPTER_CSS = """
body {
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
line-height: 1.8;
margin: 1em;
padding: 0;
font-size: 1em;
color: #1a1a1a;
}
h1 {
font-size: 1.6em;
font-weight: bold;
margin: 0 0 0.5em 0;
color: #111;
line-height: 1.3;
}
h2 {
font-size: 1.3em;
font-weight: bold;
margin: 1.5em 0 0.5em 0;
color: #222;
}
h3 {
font-size: 1.1em;
font-weight: bold;
margin: 1.2em 0 0.4em 0;
color: #333;
}
h4 {
font-size: 1.05em;
font-weight: bold;
margin: 1em 0 0.3em 0;
color: #444;
}
p {
margin: 0 0 0.8em 0;
text-align: justify;
}
strong, b {
font-weight: bold;
color: #000;
}
em, i {
font-style: italic;
}
blockquote {
border-left: 3px solid #ccc;
padding-left: 1em;
margin: 1em 0;
color: #444;
background: #f9f9f9;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}
.metadata {
color: #666;
font-size: 0.85em;
margin-bottom: 1em;
}
.card-img {
text-align: center;
margin: 1em 0;
}
hr {
border: none;
border-top: 1px solid #ddd;
margin: 1.5em 0;
}
/* Code blocks */
pre {
background: #f8f8f8;
border-left: 3px solid #0066cc;
border-radius: 4px;
padding: 1em;
overflow-x: auto;
margin: 1em 0;
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", "Fira Mono",
"Roboto Mono", "Consolas", "Courier New", monospace;
font-size: 0.85em;
line-height: 1.4;
tab-size: 2;
}
code {
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", "Fira Mono",
"Roboto Mono", "Consolas", "Courier New", monospace;
font-size: 0.9em;
background: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre code {
background: none;
padding: 0;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 0.9em;
}
th, td {
border: 1px solid #ddd;
padding: 0.5em;
text-align: left;
}
th {
background-color: #0066cc;
color: white;
font-weight: bold;
}
tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
tbody tr:hover {
background-color: #f0f0f0;
}
/* Lists */
ul, ol {
margin: 0.5em 0 1em 1.5em;
padding: 0;
}
li {
margin: 0.3em 0;
}
/* Override Pygments error token red border */
.codehilite span[style*="border: 1px solid #FF0000"] {
border: none !important;
}
"""
def compress_image(img_data_or_path, target_width=1000, jpeg_quality=88):
"""Compress image to JPEG with configurable quality."""
try:
if isinstance(img_data_or_path, bytes):
img = Image.open(io.BytesIO(img_data_or_path))
else:
img = Image.open(img_data_or_path)
if img.mode == 'RGBA':
bg = Image.new('RGB', img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif img.mode != 'RGB':
img = img.convert('RGB')
if img.width > target_width:
ratio = target_width / img.width
new_height = int(img.height * ratio)
img = img.resize((target_width, new_height), Image.Resampling.LANCZOS)
output = io.BytesIO()
img.save(output, format='JPEG', quality=jpeg_quality, optimize=True)
return output.getvalue()
except Exception as e:
print(f" Warning: Image compression failed: {e}")
return None
def convert_svg_to_png(svg_data, width=1000):
"""Convert SVG data to PNG using Playwright headless browser."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print(" Warning: Playwright not installed, cannot convert SVG")
return None
try:
import tempfile
# Write SVG to temp file
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False, mode='wb') as f:
f.write(svg_data)
svg_path = f.name
png_path = svg_path.replace('.svg', '.png')
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": width, "height": 800})
page.goto(f"file://{svg_path}")
page.wait_for_timeout(1000)
# Get the SVG element dimensions
dimensions = page.evaluate("""() => {
const svg = document.querySelector('svg');
if (!svg) return null;
const rect = svg.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}""")
if dimensions:
# Resize viewport to fit SVG
page.set_viewport_size({
"width": max(int(dimensions['width']), 200),
"height": max(int(dimensions['height']), 100)
})
page.wait_for_timeout(300)
page.screenshot(path=png_path, full_page=True)
browser.close()
with open(png_path, 'rb') as f:
png_data = f.read()
# Clean up temp files
os.unlink(svg_path)
os.unlink(png_path)
return png_data
except Exception as e:
print(f" Warning: SVG conversion failed: {e}")
return None
# Shared Playwright browser instance for batch SVG conversion
_svg_browser = None
_svg_playwright = None
def _get_svg_browser():
"""Get or create a shared Playwright browser for SVG conversion."""
global _svg_browser, _svg_playwright
if _svg_browser is None:
try:
from playwright.sync_api import sync_playwright
_svg_playwright = sync_playwright().start()
_svg_browser = _svg_playwright.chromium.launch(headless=True)
except Exception as e:
print(f" Warning: Cannot start Playwright for SVG: {e}")
return None
return _svg_browser
def _close_svg_browser():
"""Close shared Playwright browser."""
global _svg_browser, _svg_playwright
if _svg_browser:
_svg_browser.close()
_svg_browser = None
if _svg_playwright:
_svg_playwright.stop()
_svg_playwright = None
def convert_svg_to_png_fast(svg_data, width=1000):
"""Convert SVG to PNG reusing a shared browser instance (faster for batch)."""
browser = _get_svg_browser()
if not browser:
return None
try:
import tempfile
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False, mode='wb') as f:
f.write(svg_data)
svg_path = f.name
png_path = svg_path.replace('.svg', '.png')
page = browser.new_page(viewport={"width": width, "height": 800})
try:
page.goto(f"file://{svg_path}")
page.wait_for_timeout(800)
dimensions = page.evaluate("""() => {
const svg = document.querySelector('svg');
if (!svg) return null;
const rect = svg.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}""")
if dimensions:
page.set_viewport_size({
"width": max(int(dimensions['width']), 200),
"height": max(int(dimensions['height']), 100)
})
page.wait_for_timeout(300)
page.screenshot(path=png_path, full_page=True)
finally:
page.close()
with open(png_path, 'rb') as f:
png_data = f.read()
os.unlink(svg_path)
os.unlink(png_path)
return png_data
except Exception as e:
print(f" Warning: SVG conversion failed: {e}")
return None
def download_image(url, timeout=15):
"""Download image from URL or read from local file, converting SVG to PNG via Playwright."""
try:
# Skip blob:, data: URLs
if url.startswith('blob:') or url.startswith('data:'):
return None
url_path = url.split('?')[0].lower()
is_svg = url_path.endswith('.svg')
# Handle local file paths
if url.startswith('/') or url.startswith('./') or url.startswith('../'):
if not os.path.exists(url):
print(f" Warning: Local file not found: {url}")
return None
with open(url, 'rb') as f:
data = f.read()
content_type = 'image/png' if url.lower().endswith('.png') else 'image/jpeg'
else:
# Handle remote URLs
# Strip CDN webp conversion params to get original format
# e.g. alipayobjects ?x-oss-process=image/auto-orient,1/resize,w_2000/format,webp
clean_url = url
if not is_svg and ('format,webp' in url or 'format/webp' in url):
# Try to get original format by removing format conversion
clean_url = re.sub(r'/format,webp', '', url)
clean_url = re.sub(r'/format/webp', '', clean_url)
# Also cap resize to reasonable width
clean_url = re.sub(r'/resize,w_\d+', '/resize,w_1200', clean_url)
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Accept': 'image/png, image/jpeg, image/webp, image/svg+xml, image/*'
}
req = urllib.request.Request(clean_url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as response:
content_type = response.headers.get('Content-Type', '')
data = response.read()
# Detect SVG content (by URL extension, content-type, or data signature)
is_svg_content = (
is_svg
or 'svg' in content_type
or data[:5] == b'<?xml'
or data[:4] == b'<svg'
or (data[:200].find(b'<svg') >= 0)
)
if is_svg_content:
print(f" Converting SVG: {url_path.split('/')[-1]}")
png_data = convert_svg_to_png_fast(data)
if png_data:
return png_data
else:
print(f" Warning: SVG conversion failed for: {url_path.split('/')[-1]}")
return None
# Skip HTML error pages
if content_type.startswith('text/html') or data[:15].lstrip().startswith(b'<!DOCTYPE'):
print(f" Skipping HTML error page from: {url[:60]}...")
return None
# Validate the data is a real image PIL can open
try:
img_test = Image.open(io.BytesIO(data))
img_test.load() # force decode; verify() consumes pointer and is less reliable
except Exception:
# Fallback: try stripping all query params
base_url = url.split('?')[0]
if base_url != url:
req2 = urllib.request.Request(base_url, headers=headers)
try:
with urllib.request.urlopen(req2, timeout=timeout) as response2:
data = response2.read()
# Validate fallback data too
img_test2 = Image.open(io.BytesIO(data))
img_test2.load()
except Exception:
print(f" Warning: Not a valid raster image: {url[:60]}...")
return None
else:
print(f" Warning: Not a valid raster image: {url[:60]}...")
return None
return data
except Exception as e:
print(f" Warning: Failed to download {url[:80]}...: {e}")
return None
def extract_and_download_images(markdown_text, book, image_width=1000, jpeg_quality=88):
"""
Extract image URLs from Markdown, download them, add to EPUB, and replace URLs.
Returns: (modified_markdown, image_count, total_size)
"""
# Find all image references: 
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
matches = list(re.finditer(img_pattern, markdown_text))
if not matches:
return markdown_text, 0, 0
downloaded_images = {}
total_size = 0
for match in matches:
alt_text = match.group(1)
img_url = match.group(2)
# Skip if already processed
if img_url in downloaded_images:
continue
# Download image
img_data = download_image(img_url)
if not img_data:
continue
# Compress image
compressed_data = compress_image(img_data, image_width, jpeg_quality)
if not compressed_data:
continue
# Generate unique filename
url_hash = hashlib.md5(img_url.encode()).hexdigest()[:8]
img_filename = f"images/img_{url_hash}.jpg"
# Add to EPUB
img_item = epub.EpubItem(
uid=f"img_{url_hash}",
file_name=img_filename,
media_type="image/jpeg",
content=compressed_data
)
book.add_item(img_item)
downloaded_images[img_url] = img_filename
total_size += len(compressed_data)
# Replace URLs in Markdown
def replace_url(match):
alt_text = match.group(1)
img_url = match.group(2)
if img_url in downloaded_images:
return f''
return match.group(0)
modified_markdown = re.sub(img_pattern, replace_url, markdown_text)
return modified_markdown, len(downloaded_images), total_size
def parse_article(md_path):
"""Parse Markdown article, extract title and metadata, clean jina.ai headers."""
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check for YAML frontmatter
yaml_frontmatter = {}
if content.startswith('---\n'):
parts = content.split('---\n', 2)
if len(parts) >= 3:
try:
import yaml
yaml_frontmatter = yaml.safe_load(parts[1]) or {}
content = parts[2] # Use content after frontmatter
except:
pass # If YAML parsing fails, continue with original content
lines = content.strip().split('\n')
title = yaml_frontmatter.get('title', "Untitled")
metadata = ""
body_start = 0
# Clean jina.ai metadata headers
i = 0
while i < len(lines):
line = lines[i].strip()
# Extract title from jina.ai format (if not from YAML)
if line.startswith('Title:') and title == "Untitled":
title = line.split(':', 1)[1].strip()
i += 1
continue
# Skip jina.ai metadata lines
if line.startswith('URL Source:') or line.startswith('Published Time:'):
i += 1
continue
# Skip "Markdown Content:" marker
if line.startswith('Markdown Content:'):
body_start = i + 1
break
# Standard Markdown title (if not from YAML)
if line.startswith('# ') and title == "Untitled":
title = line[2:].strip()
body_start = i + 1
if i + 1 < len(lines) and lines[i + 1].startswith('> '):
metadata = lines[i + 1][2:].strip()
body_start = i + 2
break
# Skip empty lines at start
if not line:
i += 1
continue
# If we hit content without finding title, use first line
if i < 5:
i += 1
continue
# Give up searching for title
body_start = i
break
body = '\n'.join(lines[body_start:]).strip()
return title, metadata, body
def split_markdown_by_headers(content):
"""
Split a single Markdown file into chapters based on ## headers.
Returns: [(title, content), ...]
"""
lines = content.split('\n')
chapters = []
current_title = None
current_content = []
for line in lines:
# Match ## headers (second level)
if line.startswith('## '):
# Save previous chapter
if current_title:
chapters.append((current_title, '\n'.join(current_content).strip()))
# Start new chapter
current_title = line[3:].strip()
current_content = [line] # Include the header in content
else:
if current_title:
current_content.append(line)
# Skip content before first ## header
# Save last chapter
if current_title:
chapters.append((current_title, '\n'.join(current_content).strip()))
return chapters
def build_xhtml(title, metadata, image_html, body_html):
"""Build valid XHTML document with inline CSS."""
escaped_title = html_module.escape(title)
meta_section = ""
if metadata:
meta_section = f'<p class="metadata">{html_module.escape(metadata)}</p>'
img_section = ""
if image_html:
img_section = f'<div class="card-img">{image_html}</div>'
return f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{escaped_title}</title>
<style type="text/css">
{CHAPTER_CSS}
</style>
</head>
<body>
<h1>{escaped_title}</h1>
{meta_section}
{img_section}
{body_html}
</body>
</html>"""
def fix_xhtml(html_str):
"""Fix common HTML issues for XHTML compatibility."""
# Fix self-closing tags
html_str = re.sub(r'<br\s*>', '<br/>', html_str)
html_str = re.sub(r'<hr\s*>', '<hr/>', html_str)
html_str = re.sub(r'<img([^/]*?)>', r'<img\1/>', html_str)
# Remove Pygments error token red borders
# Pygments marks unknown tokens with border: 1px solid #FF0000
html_str = re.sub(r'border:\s*1px\s+solid\s+#FF0000;?\s*', '', html_str)
# Fix remaining & symbols outside code blocks
# Note: Pygments (codehilite) already escapes content inside <code> blocks,
# so we only need to fix unescaped & in regular text
html_str = re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-fA-F]+;)', '&', html_str)
return html_str
def generate_html_cover(title, subtitle="", author=""):
"""Generate cover image from HTML template using Playwright."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Warning: Playwright not installed, skipping HTML cover generation")
return None
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from gen_cover_html import generate_cover_html, screenshot_cover
html_path = generate_cover_html(title, subtitle, author)
cover_path = "/tmp/epub_cover.jpg"
screenshot_cover(html_path, cover_path)
with open(cover_path, 'rb') as f:
return f.read()
def generate_svg_cover(title, subtitle="", author="", theme=None, layout="minimal"):
"""Generate cover image from SVG template using Playwright."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Warning: Playwright not installed, skipping SVG cover generation")
return None
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from gen_cover_svg import generate_svg_cover as gen_svg, convert_svg_to_image
svg_path = gen_svg(title, subtitle, author, theme=theme, layout=layout)
cover_path = "/tmp/epub_cover_svg.jpg"
convert_svg_to_image(svg_path, cover_path)
with open(cover_path, 'rb') as f:
return f.read()
def create_epub(args):
"""Generate EPUB with enhanced Markdown rendering and image downloading."""
input_dir = os.path.expanduser(args.input_dir)
output_path = os.path.expanduser(args.output_file)
md_files = sorted(glob.glob(os.path.join(input_dir, "*.md")))
if not md_files:
print(f"Error: No .md files found in {input_dir}")
sys.exit(1)
print(f"Generating Enhanced EPUB...")
print(f" Input: {input_dir} ({len(md_files)} articles)")
print(f" Output: {output_path}")
# Extract title from first article if not provided
title = args.title
if not title:
first_title, _, _ = parse_article(md_files[0])
title = first_title
book = epub.EpubBook()
book.set_identifier(f'epub-{title.replace(" ", "-").lower()[:30]}')
book.set_title(title)
book.set_language(args.language)
if args.author:
for a in args.author.split('/'):
book.add_author(a.strip())
book.add_metadata('DC', 'description', f'{len(md_files)} articles')
# Handle cover
cover_data = None
if args.cover:
cover_path = os.path.expanduser(args.cover)
if os.path.exists(cover_path):
cover_data = compress_image(cover_path, target_width=1400, jpeg_quality=95)
print(f" Cover: {cover_path}")
elif args.cover_svg:
subtitle = args.subtitle or f"{len(md_files)} articles"
theme = args.cover_theme if hasattr(args, 'cover_theme') else None
layout = args.cover_layout if hasattr(args, 'cover_layout') else "minimal"
cover_data = generate_svg_cover(title, subtitle, args.author or "", theme, layout)
print(f" Cover: SVG generated")
elif args.cover_html:
subtitle = args.subtitle or f"{len(md_files)} articles"
cover_data = generate_html_cover(title, subtitle, args.author or "")
print(f" Cover: HTML generated")
if cover_data:
book.set_cover("cover.jpg", cover_data)
# Process articles
chapters = []
toc_items = []
spine = ['nav']
total_img_size = 0
total_img_count = 0
# Check if we should split by headers (single file with ## headers)
should_split = len(md_files) == 1
if should_split:
with open(md_files[0], 'r', encoding='utf-8') as f:
full_content = f.read()
# Extract book title from # header
title_text, metadata, body = parse_article(md_files[0])
# Split by ## headers
chapter_list = split_markdown_by_headers(body)
if len(chapter_list) > 1:
print(f" Splitting into {len(chapter_list)} chapters by ## headers")
else:
# Fallback to single chapter
chapter_list = [(title_text, body)]
should_split = False
else:
chapter_list = None
chapter_num = 0
for i, md_path in enumerate(md_files, 1):
slug = Path(md_path).stem
if should_split and chapter_list:
# Process split chapters from single file
for ch_title, ch_body in chapter_list:
chapter_num += 1
print(f" [{chapter_num}/{len(chapter_list)}] {ch_title}")
# Download and embed images from Markdown
ch_body, img_count, img_size = extract_and_download_images(
ch_body, book, args.image_width, args.image_quality
)
total_img_count += img_count
total_img_size += img_size
if img_count > 0:
print(f" → Downloaded {img_count} images ({img_size / 1024:.1f} KB)")
# Markdown → HTML with full extensions
md_html = markdown.markdown(
ch_body,
extensions=[
'extra', # Tables, fenced code blocks, etc.
'codehilite', # Syntax highlighting
'nl2br', # Newline to <br>
'sane_lists' # Better list handling
],
extension_configs={
'codehilite': {
'noclasses': True,
'pygments_style': 'default'
}
}
)
md_html = fix_xhtml(md_html)
chapter_html = build_xhtml(ch_title, "", "", md_html)
chapter = epub.EpubHtml(
title=ch_title,
file_name=f"chapter_{chapter_num:03d}.xhtml",
lang=args.language
)
chapter.set_content(chapter_html.encode('utf-8'))
book.add_item(chapter)
chapters.append(chapter)
toc_items.append(epub.Link(f"chapter_{chapter_num:03d}.xhtml", ch_title, f"ch{chapter_num}"))
spine.append(chapter)
else:
# Process multiple files normally
chapter_num += 1
print(f" [{chapter_num}/{len(md_files)}] {slug}")
title_text, metadata, body = parse_article(md_path)
# Download and embed images from Markdown
body, img_count, img_size = extract_and_download_images(
body, book, args.image_width, args.image_quality
)
total_img_count += img_count
total_img_size += img_size
if img_count > 0:
print(f" → Downloaded {img_count} images ({img_size / 1024:.1f} KB)")
# Markdown → HTML with full extensions
md_html = markdown.markdown(
body,
extensions=[
'extra', # Tables, fenced code blocks, etc.
'codehilite', # Syntax highlighting
'nl2br', # Newline to <br>
'sane_lists' # Better list handling
],
extension_configs={
'codehilite': {
'noclasses': True,
'pygments_style': 'default'
}
}
)
md_html = fix_xhtml(md_html)
chapter_html = build_xhtml(title_text, metadata, "", md_html)
chapter = epub.EpubHtml(
title=title_text,
file_name=f"chapter_{chapter_num:03d}.xhtml",
lang=args.language
)
chapter.set_content(chapter_html.encode('utf-8'))
book.add_item(chapter)
chapters.append(chapter)
toc_items.append(epub.Link(f"chapter_{chapter_num:03d}.xhtml", title_text, f"ch{chapter_num}"))
spine.append(chapter)
# TOC and navigation
book.toc = toc_items
book.spine = spine
book.add_item(epub.EpubNcx())
# Write EPUB
options = {
'epub3_pages': False,
'epub3_landmark': False,
'spine_direction': True
}
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
epub.write_epub(output_path, book, options)
# Clean up shared Playwright browser for SVG conversion
_close_svg_browser()
size_mb = os.path.getsize(output_path) / (1024 * 1024)
img_size_mb = total_img_size / (1024 * 1024)
print(f"\n✅ Done!")
print(f" Output: {output_path}")
print(f" File size: {size_mb:.1f} MB")
print(f" Downloaded images: {total_img_count} ({img_size_mb:.1f} MB)")
print(f" Chapters: {len(chapters)}")
def main():
parser = argparse.ArgumentParser(description='Enhanced EPUB generator with image downloading')
parser.add_argument('input_dir', help='Directory containing Markdown files')
parser.add_argument('output_file', help='Output EPUB file path')
parser.add_argument('--title', help='Book title')
parser.add_argument('--author', help='Author name(s), separate multiple with /')
parser.add_argument('--language', default='zh', help='Language code (default: zh)')
parser.add_argument('--cover', help='Cover image path (JPG/PNG)')
parser.add_argument('--cover-html', action='store_true', help='Generate cover from HTML template')
parser.add_argument('--cover-svg', action='store_true', help='Generate cover from SVG template (KDP 1600x2560)')
parser.add_argument('--cover-theme', help='Cover theme: tech, business, design, literature, science, personal')
parser.add_argument('--cover-layout', default='minimal', help='SVG cover layout: minimal, classic, modern (default: minimal)')
parser.add_argument('--subtitle', help='Subtitle for cover')
parser.add_argument('--image-quality', type=int, default=88, help='JPEG quality 1-100 (default: 88)')
parser.add_argument('--image-width', type=int, default=1000, help='Max image width px (default: 1000)')
args = parser.parse_args()
create_epub(args)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Generate EPUB ebook from Markdown files.
WeChat Reading compatible, with optional cover image and illustration compression.
Usage:
python3 gen_epub.py <input_dir> <output.epub> [options]
Options:
--title Book title (default: extracted from first article)
--author Author name (default: extracted from Markdown frontmatter)
--language Language code (default: zh)
--cover Cover image path (JPG/PNG)
--cover-html Generate cover from HTML template (requires Playwright)
--subtitle Subtitle for HTML cover
--image-quality JPEG compression quality 1-100 (default: 88)
--image-width Max image width in pixels (default: 1000)
Examples:
python3 gen_epub.py ~/articles/ ~/output.epub
python3 gen_epub.py ~/articles/ ~/book.epub --title "My Book" --author "Author" --cover cover.jpg
python3 gen_epub.py ~/articles/ ~/book.epub --cover-html --title "My Book" --subtitle "100 essays"
"""
import argparse
import os
import glob
import re
import sys
import html as html_module
from pathlib import Path
from ebooklib import epub
import markdown
from PIL import Image
import io
# ─── CSS (inline for max WeChat Reading compatibility) ───
CHAPTER_CSS = """
body {
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
line-height: 1.8;
margin: 1em;
padding: 0;
font-size: 1em;
color: #1a1a1a;
}
h1 {
font-size: 1.6em;
font-weight: bold;
margin: 0 0 0.5em 0;
color: #111;
line-height: 1.3;
}
h2 {
font-size: 1.3em;
font-weight: bold;
margin: 1.5em 0 0.5em 0;
color: #222;
}
h3 {
font-size: 1.1em;
font-weight: bold;
margin: 1.2em 0 0.4em 0;
color: #333;
}
p {
margin: 0 0 0.8em 0;
text-align: justify;
}
strong, b {
font-weight: bold;
color: #000;
}
em, i {
font-style: italic;
}
blockquote {
border-left: 3px solid #ccc;
padding-left: 1em;
margin: 1em 0;
color: #444;
}
img {
max-width: 100%;
height: auto;
}
.metadata {
color: #666;
font-size: 0.85em;
margin-bottom: 1em;
}
.card-img {
text-align: center;
margin: 1em 0;
}
hr {
border: none;
border-top: 1px solid #ddd;
margin: 1.5em 0;
}
"""
def compress_image(img_path, target_width=1000, jpeg_quality=88):
"""Compress image to JPEG with configurable quality."""
try:
img = Image.open(img_path)
if img.mode == 'RGBA':
bg = Image.new('RGB', img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif img.mode != 'RGB':
img = img.convert('RGB')
if img.width > target_width:
ratio = target_width / img.width
new_height = int(img.height * ratio)
img = img.resize((target_width, new_height), Image.Resampling.LANCZOS)
output = io.BytesIO()
img.save(output, format='JPEG', quality=jpeg_quality, optimize=True)
return output.getvalue()
except Exception as e:
print(f" Warning: Image compression failed for {img_path}: {e}")
return None
def parse_article(md_path):
"""Parse Markdown article, extract title and metadata."""
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.strip().split('\n')
title = "Untitled"
metadata = ""
body_start = 0
for i, line in enumerate(lines):
if line.startswith('# '):
title = line[2:].strip()
body_start = i + 1
if i + 1 < len(lines) and lines[i + 1].startswith('> '):
metadata = lines[i + 1][2:].strip()
body_start = i + 2
break
body = '\n'.join(lines[body_start:]).strip()
return title, metadata, body
def build_xhtml(title, metadata, image_html, body_html):
"""Build valid XHTML document with inline CSS (WeChat Reading compatible)."""
escaped_title = html_module.escape(title)
meta_section = ""
if metadata:
meta_section = f'<p class="metadata">{html_module.escape(metadata)}</p>'
img_section = ""
if image_html:
img_section = f'<div class="card-img">{image_html}</div>'
return f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{escaped_title}</title>
<style type="text/css">
{CHAPTER_CSS}
</style>
</head>
<body>
<h1>{escaped_title}</h1>
{meta_section}
{img_section}
{body_html}
</body>
</html>"""
def fix_xhtml(html_str):
"""Fix common HTML issues for XHTML compatibility."""
html_str = re.sub(r'<br\s*>', '<br/>', html_str)
html_str = re.sub(r'<hr\s*>', '<hr/>', html_str)
html_str = re.sub(r'<img([^/]*?)>', r'<img\1/>', html_str)
html_str = re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-fA-F]+;)', '&', html_str)
return html_str
def generate_html_cover(title, subtitle="", author=""):
"""Generate cover image from HTML template using Playwright."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Warning: Playwright not installed, skipping HTML cover generation")
print("Install with: pip install playwright && playwright install chromium")
return None
# Import the cover generator
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from gen_cover_html import generate_cover_html, screenshot_cover
html_path = generate_cover_html(title, subtitle, author)
cover_path = "/tmp/epub_cover.jpg"
screenshot_cover(html_path, cover_path)
with open(cover_path, 'rb') as f:
return f.read()
def find_article_image(slug, input_dir):
"""Find matching image for an article (same dir or parent dir)."""
# Check common image locations and extensions
for ext in ['png', 'jpg', 'jpeg']:
# Same directory as articles
path = os.path.join(input_dir, f"{slug}.{ext}")
if os.path.exists(path):
return path
# Parent directory (common pattern: articles/ subdir + images in parent)
parent = os.path.dirname(input_dir.rstrip('/'))
path = os.path.join(parent, f"{slug}.{ext}")
if os.path.exists(path):
return path
return None
def create_epub(args):
"""Generate EPUB with WeChat Reading compatibility."""
input_dir = os.path.expanduser(args.input_dir)
output_path = os.path.expanduser(args.output_file)
md_files = sorted(glob.glob(os.path.join(input_dir, "*.md")))
if not md_files:
print(f"Error: No .md files found in {input_dir}")
sys.exit(1)
print(f"Generating EPUB (WeChat Reading compatible)...")
print(f" Input: {input_dir} ({len(md_files)} articles)")
print(f" Output: {output_path}")
# Extract title from first article if not provided
title = args.title
if not title:
first_title, _, _ = parse_article(md_files[0])
title = first_title
book = epub.EpubBook()
book.set_identifier(f'epub-{title.replace(" ", "-").lower()[:30]}')
book.set_title(title)
book.set_language(args.language)
if args.author:
for a in args.author.split('/'):
book.add_author(a.strip())
book.add_metadata('DC', 'description', f'{len(md_files)} articles')
# Handle cover
cover_data = None
if args.cover:
cover_path = os.path.expanduser(args.cover)
if os.path.exists(cover_path):
cover_data = compress_image(cover_path, target_width=1400, jpeg_quality=95)
print(f" Cover: {cover_path}")
elif args.cover_html:
subtitle = args.subtitle or f"{len(md_files)} articles"
cover_data = generate_html_cover(title, subtitle, args.author or "")
print(f" Cover: HTML generated")
if cover_data:
book.set_cover("cover.jpg", cover_data)
# Process articles
chapters = []
toc_items = []
spine = ['nav']
total_img_size = 0
for i, md_path in enumerate(md_files, 1):
slug = Path(md_path).stem
if i % 50 == 0 or i == 1:
print(f" [{i}/{len(md_files)}] {slug}")
title_text, metadata, body = parse_article(md_path)
# Find and compress illustration
img_path = find_article_image(slug, input_dir)
image_html = ""
if img_path:
img_data = compress_image(img_path, args.image_width, args.image_quality)
if img_data:
total_img_size += len(img_data)
img_filename = f"images/{slug}.jpg"
img_item = epub.EpubItem(
uid=f"img_{slug}",
file_name=img_filename,
media_type="image/jpeg",
content=img_data
)
book.add_item(img_item)
image_html = f'<img src="{img_filename}" alt="{html_module.escape(title_text)}"/>'
# Markdown → HTML
md_html = markdown.markdown(body, extensions=['extra'])
md_html = fix_xhtml(md_html)
chapter_html = build_xhtml(title_text, metadata, image_html, md_html)
chapter = epub.EpubHtml(
title=title_text,
file_name=f"chapter_{i:03d}.xhtml",
lang=args.language
)
chapter.set_content(chapter_html.encode('utf-8'))
book.add_item(chapter)
chapters.append(chapter)
toc_items.append(epub.Link(f"chapter_{i:03d}.xhtml", title_text, f"ch{i}"))
spine.append(chapter)
# TOC and navigation
book.toc = toc_items
book.spine = spine
book.add_item(epub.EpubNcx())
# Write EPUB
options = {
'epub3_pages': False,
'epub3_landmark': False,
'spine_direction': True
}
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
epub.write_epub(output_path, book, options)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
img_size_mb = total_img_size / (1024 * 1024)
print(f"\n✅ Done!")
print(f" Output: {output_path}")
print(f" File size: {size_mb:.1f} MB")
print(f" Images: {img_size_mb:.1f} MB")
print(f" Chapters: {len(chapters)}")
def main():
parser = argparse.ArgumentParser(description='Generate EPUB from Markdown files (WeChat Reading compatible)')
parser.add_argument('input_dir', help='Directory containing Markdown files')
parser.add_argument('output_file', help='Output EPUB file path')
parser.add_argument('--title', help='Book title')
parser.add_argument('--author', help='Author name(s), separate multiple with /')
parser.add_argument('--language', default='zh', help='Language code (default: zh)')
parser.add_argument('--cover', help='Cover image path (JPG/PNG)')
parser.add_argument('--cover-html', action='store_true', help='Generate cover from HTML template')
parser.add_argument('--subtitle', help='Subtitle for HTML cover')
parser.add_argument('--image-quality', type=int, default=88, help='JPEG quality 1-100 (default: 88)')
parser.add_argument('--image-width', type=int, default=1000, help='Max image width px (default: 1000)')
args = parser.parse_args()
create_epub(args)
if __name__ == "__main__":
main()