Java 电子书怎么制作:从 Markdown 文档到 EPUB

Java 电子书怎么制作:从 Markdown 文档到 EPUB
把 Markdown 文档制作成 EPUB,通常不需要在 Java 里手工拼接 EPUB 文件。更实用的方案是让 Java 负责输入检查、元数据和流程控制,再调用 Pandoc 完成 Markdown 到 EPUB 的转换,最后用 EPUBCheck 等工具验证成品。
本文给出一个可运行的 Java 17 示例,适用于技术手册、课程资料、长篇文档和内部出版物。它不是排版软件,也不能保证所有电子书阅读器都呈现一致;章节划分、图片路径、字体和复杂版式仍需要根据目标阅读器测试。
一、先判断 EPUB 是否适合你的文档
EPUB 是一种面向电子阅读器的开放电子书格式。EPUB 3 文件通常是一个 ZIP 容器,内部包含 XHTML 内容、样式、资源以及出版物元数据。阅读器根据屏幕尺寸和用户设置重新排版,因此它适合可流动排版的内容,不适合要求每一页都固定不变的版面。
- 适合:小说、教程、文档、课程讲义、以文字和普通插图为主的出版物。
- 需要测试:包含大量表格、数学公式、复杂代码排版、脚注或特殊字体的内容。不同阅读器支持程度不完全相同。
- 不一定适合:必须严格保持页码、栏位、复杂图文位置或印刷尺寸的文件。这类需求通常应考虑 PDF 或专业排版工具。
从 Markdown 转换到 EPUB,并不等同于“把 Markdown 原样塞进文件”。转换器会解析标题、列表、代码块等标记,再生成 EPUB 所需的内容文件和导航信息。最终效果取决于 Markdown 结构、转换选项、样式和阅读器实现。
二、准备工具和示例文档
示例需要 Java 17 或更新版本,以及已安装且可以从命令行运行的 Pandoc。先在终端检查:
java -version
pandoc --version
Pandoc 应使用官方发行渠道安装。部署到服务器时,应固定并记录所用版本;升级转换器可能影响输出结构或渲染效果。下文 Java 程序通过 ProcessBuilder 调用系统中的 pandoc 命令,不需要额外的 Java 依赖。
创建示例文件 book.md:
# Java 文档工程实践
## 前言
本文介绍如何组织项目文档。
## 第一章:准备项目
先创建一个清晰的目录结构:
- `src/main/java`:Java 源代码
- `docs`:项目文档
## 第二章:输出日志
下面的示例输出一条日志:
```java
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, EPUB");
}
}
```
示例中的一级标题通常可作为书名,二级标题可作为章节标题。标题层级会影响目录和内容组织;如果原文只有连续正文而没有章节标题,生成的 EPUB 也不会凭空得到合理的章节结构。
三、用 Java 调用 Pandoc 生成 EPUB
下面的程序接收 Markdown 文件、输出文件、书名和作者四个参数。它会检查输入和输出路径,使用明确的 EPUB 3 格式选项,生成目录,并在成功后将临时文件移动到目标位置。将代码保存为 MarkdownToEpub.java:
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
public class MarkdownToEpub {
private static final Duration TIMEOUT = Duration.ofMinutes(2);
public static void main(String[] args) throws Exception {
if (args.length != 4) {
System.err.println(
"用法: java MarkdownToEpub <输入.md> <输出.epub> <书名> <作者>"
);
System.exit(2);
}
Path input = Path.of(args[0]).toAbsolutePath().normalize();
Path output = Path.of(args[1]).toAbsolutePath().normalize();
String title = args[2];
String author = args[3];
if (!Files.isRegularFile(input)) {
throw new IllegalArgumentException("输入文件不存在或不是普通文件: " + input);
}
if (!input.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".md")) {
throw new IllegalArgumentException("输入文件扩展名应为 .md");
}
if (!output.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".epub")) {
throw new IllegalArgumentException("输出文件扩展名应为 .epub");
}
if (input.equals(output)) {
throw new IllegalArgumentException("输入和输出不能是同一个路径");
}
if (title.isBlank() || author.isBlank()) {
throw new IllegalArgumentException("书名和作者不能为空");
}
Path parent = output.getParent();
if (parent == null) {
throw new IllegalArgumentException("无法确定输出目录");
}
Files.createDirectories(parent);
String pandoc = System.getenv().getOrDefault("PANDOC", "pandoc");
Path temporaryOutput = Files.createTempFile(parent, ".book-", ".epub");
Path log = Files.createTempFile(parent, ".pandoc-", ".log");
try {
List<String> command = new ArrayList<>();
command.add(pandoc);
command.add(input.toString());
command.add("--output=" + temporaryOutput);
command.add("--to=epub3");
command.add("--standalone");
command.add("--toc");
command.add("--toc-depth=2");
command.add("--metadata=title=" + title);
command.add("--metadata=author=" + author);
command.add("--metadata=lang=zh-CN");
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.redirectOutput(log.toFile())
.start();
boolean finished = process.waitFor(
TIMEOUT.toMillis(), TimeUnit.MILLISECONDS
);
if (!finished) {
process.destroyForcibly();
process.waitFor();
throw new IOException(
"Pandoc 执行超时,日志位置: " + log
);
}
String outputLog = Files.readString(log);
if (process.exitValue() != 0) {
throw new IOException(
"Pandoc 执行失败,退出码为 " + process.exitValue()
+ "\\n" + outputLog
);
}
if (!Files.isRegularFile(temporaryOutput)
|| Files.size(temporaryOutput) == 0) {
throw new IOException("转换完成但没有生成有效的非空输出文件");
}
try {
Files.move(
temporaryOutput,
output,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (AtomicMoveNotSupportedException e) {
Files.move(
temporaryOutput,
output,
StandardCopyOption.REPLACE_EXISTING
);
}
System.out.println("已生成: " + output);
if (!outputLog.isBlank()) {
System.out.println(outputLog);
}
} finally {
Files.deleteIfExists(temporaryOutput);
Files.deleteIfExists(log);
}
}
}
在保存代码的目录中编译并运行:
javac MarkdownToEpub.java
java MarkdownToEpub book.md book.epub "Java 文档工程实践" "示例作者"
如果 Pandoc 不在系统 PATH 中,可以设置环境变量 PANDOC 指向可执行文件,再运行程序。例如,在类 Unix shell 中:
export PANDOC=/opt/pandoc/bin/pandoc
java MarkdownToEpub book.md book.epub "Java 文档工程实践" "示例作者"
Windows 的环境变量设置方式因终端和系统配置而异。也可以直接把程序中的默认命令改成可信的绝对路径。不要把 Pandoc 可执行文件路径交给不可信用户控制。
四、示例代码做了什么,哪些选项可以调整
--to=epub3明确指定输出格式为 EPUB 3,避免依赖 Pandoc 对输出扩展名的推断。--standalone要求生成完整文档,而不是仅输出文档片段。--toc生成目录;--toc-depth=2将目录深度限制在两级标题范围内。--metadata=title=...、author和lang设置出版物元数据。语言标记有助于阅读器和辅助技术识别内容语言。
--toc-depth=2 限制的是目录中包含的标题层级,并不保证所有阅读器以完全相同的方式显示目录。需要更细的目录时可以修改深度,并检查成品的导航信息和阅读器表现。
命令行参数以列表形式交给 ProcessBuilder,而不是拼成一条 shell 命令。这避免了通过 shell 解释空格、分号等字符所造成的一类命令注入风险,也让带空格的路径可以作为单个参数传入。但这不意味着可以无条件信任所有输入:Pandoc 仍会解析文档内容并处理它引用的资源。
代码对标题和作者只做了非空检查。它没有定义适用于所有出版流程的长度上限,也没有替调用者判断标题是否符合特定书店或出版平台的规则。若需要额外限制,应根据业务规范明确制定,再进行输入校验。
五、图片、附件和相对路径
如果 Markdown 引用了图片,例如:

图片路径通常是相对于 Markdown 文件或 Pandoc 的资源搜索路径解析的。最稳妥的做法是按项目统一组织源文件,例如:
project/
├── book.md
└── images/
└── project-layout.png
在命令行里从项目目录运行,并确保图片文件真实存在。复杂项目可以配置 Pandoc 的 --resource-path,将需要搜索的资源目录作为参数传入。不要假设图片在任意机器上都能找到,也不要只凭 EPUB 文件存在就断定图片已经嵌入;转换后应实际打开成品检查。
远程图片、视频、音频、字体等资源涉及可用性、版权和阅读器支持。远程地址在生成时可能无法访问,也可能在阅读时失效。需要离线阅读的出版物应优先使用已审核、可随书发布的本地资源,并检查相关授权。
六、章节拆分、元数据和封面
EPUB 阅读器通常可以自行调整字体和版面,章节标题则有助于目录导航。制作前建议统一标题层级:书名使用适当的顶层标题,章节使用一致的子标题,避免把普通加粗文字误当作章节标题。
Pandoc 支持通过元数据文件集中设置标题、作者、语言等信息,也支持按格式约定提供封面。元数据格式和封面参数应以所安装的 Pandoc 版本文档为准,不同版本的行为可能有差异。不要为了“有封面”而只在 Markdown 正文开头放一张图片:正文插图和出版物封面不是同一概念,是否成为正式封面取决于生成工具和 EPUB 包内的出版物信息。
如果需要按章节拆分内容,可以根据文档标题结构及 Pandoc 对 EPUB 的相关选项进行配置。拆分策略影响阅读器中的导航和内容文件组织,不能仅凭“文件数更多”判断质量更高。大多数一般用途书籍先采用清晰的标题层级和目录,再按目标阅读器测试即可。
七、验证 EPUB,不要只看转换退出码
程序退出码为零,说明 Pandoc 没有报告转换失败;这并不等价于书籍在所有阅读器中都正确。发布前建议至少完成以下检查:
- 用 EPUBCheck 检查文件结构和规范问题。按其官方说明安装或取得工具后,通常可以用
java -jar运行对应的 JAR 文件,并把生成的 EPUB 路径作为参数;具体命令以所用 EPUBCheck 版本的说明为准。 - 在至少一个实际阅读器中打开,检查封面、目录、章节跳转、代码块、中文标点和插图。
- 检查不同屏幕尺寸下的长标题、表格、横向内容和代码块。超宽内容可能需要水平滚动或调整样式。
- 抽查 EPUB 中的元数据和资源引用,确认没有漏图、错误链接或不应发布的临时内容。
验证工具能发现一部分结构或规范问题,但不能替代视觉检查、版权审核和目标平台的发布要求。阅读器也可能采用不同的 CSS 支持方式,因此同一份 EPUB 在不同软件中出现轻微差异并不罕见。
八、安全边界:处理不可信 Markdown 时要谨慎
上面的示例适合由可信团队维护的文档,不是一个隔离不可信内容的转换服务。几个边界需要特别注意:
- 命令参数与文档内容是两类风险。使用参数列表、避免 shell 拼接,可以降低命令字符串被 shell 解释的风险;它不能证明 Markdown 内容安全。
- 原始 HTML 需要审核。Markdown 可能包含 HTML 片段,Pandoc 对这类内容的处理取决于输入格式及选项。若内容来自用户或外部来源,应确认是否允许原始 HTML,并评估潜在的脚本、链接和跟踪内容风险。
- 资源读取需要约束。文档中的本地图片或其他资源引用可能引发文件读取范围、敏感文件意外打包等问题。处理外部提交内容时,应使用隔离的工作目录,限制可见文件和资源目录,并避免把机密文件放在转换进程可访问的位置。
- 资源和转换器都有攻击面。应及时维护 Pandoc 及相关依赖,限制运行账户权限,并按服务负载设置超时、文件大小和并发限制。本文代码只设置了转换超时,没有实现完整的资源配额或进程沙箱。
- 不要把临时文件放在公开目录。示例在输出目录创建临时 EPUB 和日志,适合受控环境。部署到共享或 Web 可访问目录时,应改用权限受限的临时工作目录,并做好异常清理和访问控制。
如果业务允许任意用户上传 Markdown,应把转换流程视为处理不可信文件的任务:由低权限独立进程执行,限制文件系统访问和网络访问,使用固定的转换器版本,明确允许的输入格式和资源类型,并对生成物进行检查。不能仅凭“没有使用 shell”就宣称转换过程安全。
九、常见错误及排查方法
1. 提示找不到 Pandoc
这通常是可执行文件未安装,或运行 Java 的进程环境变量中没有对应 PATH。先在同一个终端执行 pandoc --version,确认可执行;服务进程应使用明确配置的路径,并确认运行账户有权限。
2. 图片在 EPUB 中缺失
检查文件名大小写、图片是否存在、相对路径基准以及 Pandoc 的资源搜索目录。Linux 文件系统通常区分大小写,而某些开发环境不区分;本机成功不代表部署环境也成功。
3. 目录为空或层级不理想
确认文档中的章节是 Markdown 标题,而不是通过加粗模拟标题。再检查 --toc 是否传入,以及目录深度是否覆盖实际标题层级。
4. 中文或标点显示异常
确认 Markdown 文件按 UTF-8 保存,并检查阅读器所用字体及样式。不同阅读器处理字体回退的方式不同;不要把某台设备上的显示效果当成所有设备的保证。
5. 转换成功但输出打不开
使用 EPUBCheck 检查结构,再查看 Pandoc 日志。确认目标路径有写权限、输出文件非空,并排查文档中的资源引用和特殊内容。示例先写临时文件再移动,是为了尽量避免转换失败时覆盖原有成品;它仍不能替代备份和成品验证。
十、何时考虑其他方案
如果转换流程需要更复杂的出版物管理,例如大量定制 CSS、出版物导航、固定布局、复杂公式、严格的书店元数据,或者要求在 Java 内直接操作 EPUB 包,可以评估专门的电子书工具、出版工作流或 Java EPUB 库。选型时应确认项目维护情况、许可协议、EPUB 版本支持和实际阅读器兼容性。
不要因为“Java 项目”就必须用 Java 库完成文件格式的全部细节。用 Java 调用成熟的外部转换器,是一种清晰且易于自动化的边界:Java 管理业务流程,Pandoc 负责文档转换,验证工具负责检查出版物结构。部署时固定工具版本、记录输入输出、验证成品,并对不可信内容做隔离,通常比手工拼装 EPUB 更容易维护。




