1. 项目背景与核心需求
最近在整理技术文档时,发现Markdown格式的README和API说明需要转换成PDF格式发给非技术同事。手动复制粘贴到Word再转PDF不仅效率低下,格式还经常错乱。于是研究了几种用Java实现Markdown转PDF的方案,这里把踩坑经验分享给大家。
这个需求在以下场景特别常见:
- 需要将GitHub项目的README导出为PDF归档
- 自动化生成技术文档交付物
- 企业知识库系统的文档导出功能
- 博客内容的多格式发布
2. 技术方案选型对比
2.1 常见技术路线分析
目前Java生态中主流的Markdown转PDF方案有以下几种:
CommonMark + PDFBox组合
- 优点:纯Java实现,依赖少
- 缺点:需要手动处理样式,开发量大
Flexmark + OpenHTMLToPDF
- 优点:支持最新Markdown语法
- 缺点:中文排版需要额外配置
PegDown + Flying Saucer
- 优点:成熟稳定
- 缺点:已停止维护
使用外部工具调用
- 如Pandoc,需要系统安装依赖
2.2 推荐方案:Flexmark + OpenHTMLToPDF
经过实际测试,我最终选择了Flexmark + OpenHTMLToPDF组合,原因如下:
- Flexmark支持CommonMark和GitHub Flavored Markdown
- OpenHTMLToPDF的PDF渲染质量高
- 中文排版问题可通过字体嵌入解决
- 纯Java实现,不依赖外部工具
3. 详细实现步骤
3.1 环境准备
首先在pom.xml中添加依赖:
<dependencies> <!-- Flexmark核心 --> <dependency> <groupId>com.vladsch.flexmark</groupId> <artifactId>flexmark-all</artifactId> <version>0.64.0</version> </dependency> <!-- PDF转换 --> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.15.3</version> </dependency> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-core</artifactId> <version>1.0.10</version> </dependency> <dependency> <groupId>com.openhtmltopdf</groupId> <artifactId>openhtmltopdf-pdfbox</artifactId> <version>1.0.10</version> </dependency> </dependencies>3.2 核心转换代码实现
import com.vladsch.flexmark.html.HtmlRenderer; import com.vladsch.flexmark.parser.Parser; import com.vladsch.flexmark.profile.pegdown.Extensions; import com.vladsch.flexmark.profile.pegdown.PegdownOptionsAdapter; import com.vladsch.flexmark.util.data.DataHolder; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; public class MarkdownToPdfConverter { // 配置Markdown解析选项 private static final DataHolder OPTIONS = PegdownOptionsAdapter.flexmarkOptions( Extensions.ALL ); public static void convert(String markdownFile, String pdfFile) throws IOException { // 1. 读取Markdown文件 String markdown = new String(Files.readAllBytes(Paths.get(markdownFile)), StandardCharsets.UTF_8); // 2. 转换为HTML Parser parser = Parser.builder(OPTIONS).build(); HtmlRenderer renderer = HtmlRenderer.builder(OPTIONS).build(); String html = renderer.render(parser.parse(markdown)); // 3. 处理HTML结构 Document doc = Jsoup.parse(html); doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml); // 4. 转换为PDF try (OutputStream os = new FileOutputStream(pdfFile)) { com.openhtmltopdf.pdfbox.PdfRendererBuilder builder = new com.openhtmltopdf.pdfbox.PdfRendererBuilder(); builder.withHtmlContent(doc.html(), null); builder.toStream(os); builder.run(); } } }3.3 中文支持配置
要让PDF正确显示中文,需要添加中文字体支持:
// 在PdfRendererBuilder后添加字体配置 builder.useFont(new File("fonts/NotoSansSC-Regular.ttf"), "Noto Sans SC");推荐使用思源黑体(Noto Sans SC)或阿里巴巴普惠体,需要将字体文件放在resources/fonts目录下。
4. 高级功能实现
4.1 自定义CSS样式
可以通过以下方式添加自定义样式:
String css = "@page { size: A4; margin: 1cm; } " + "body { font-family: 'Noto Sans SC'; font-size: 12pt; } " + "code { background: #f5f5f5; padding: 2px 4px; }"; builder.withHtmlContent( "<html><head><style>" + css + "</style></head><body>" + doc.body().html() + "</body></html>", null );4.2 处理图片嵌入
Markdown中的图片需要特殊处理才能正确显示在PDF中:
// 替换图片路径为绝对路径 Elements imgs = doc.select("img"); for (Element img : imgs) { String src = img.attr("src"); if (!src.startsWith("http")) { img.attr("src", new File(markdownFile).getParent() + "/" + src); } }4.3 分页与页眉页脚
使用CSS控制分页和页眉页脚:
@page { @top-left { content: "我的技术文档"; font-size: 10pt; } @bottom-center { content: counter(page); } } h1 { page-break-before: always; }5. 常见问题与解决方案
5.1 中文乱码问题
现象:PDF中中文显示为方框解决:
- 确认已添加中文字体
- 检查字体文件路径是否正确
- 在CSS中明确指定字体族
5.2 图片不显示
现象:PDF中图片位置空白解决:
- 使用绝对路径引用图片
- 确认图片文件存在且可读
- 对于网络图片,确保URL可访问
5.3 列表样式异常
现象:列表缩进或编号不正确解决:
- 添加CSS重置列表样式
- 检查Markdown列表语法是否正确
- 避免混合使用有序和无序列表
5.4 代码块换行问题
现象:代码块中的长行被截断解决:
pre { white-space: pre-wrap; word-wrap: break-word; }6. 性能优化建议
- 字体子集化:只嵌入实际使用的字形,减小PDF体积
- 缓存解析器实例:Parser和HtmlRenderer可以复用
- 批量处理:对多个文件使用同一个PdfRendererBuilder实例
- 异步处理:对大量文档使用线程池处理
// 缓存解析器实例示例 private static final Parser PARSER = Parser.builder(OPTIONS).build(); private static final HtmlRenderer RENDERER = HtmlRenderer.builder(OPTIONS).build();7. 替代方案比较
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Flexmark+OpenHTML | 纯Java、高质量输出 | 配置复杂 | 企业级应用 |
| Pandoc调用 | 功能强大、支持多格式 | 依赖外部工具 | 个人使用 |
| Thymeleaf PDF | 整合Spring方便 | 功能有限 | Spring项目 |
| JasperReports | 报表功能强 | 学习曲线陡 | 复杂报表 |
8. 完整工具类实现
以下是经过生产环境验证的完整工具类:
public class MarkdownPdfUtils { private static final DataHolder OPTIONS = PegdownOptionsAdapter.flexmarkOptions( Extensions.ALL ); private static final Parser PARSER = Parser.builder(OPTIONS).build(); private static final HtmlRenderer RENDERER = HtmlRenderer.builder(OPTIONS).build(); public static void convertToPdf(Path markdownPath, Path pdfPath, Path fontDir) throws IOException { String markdown = Files.readString(markdownPath); String html = RENDERER.render(PARSER.parse(markdown)); Document doc = Jsoup.parse(html); doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml); // 处理图片路径 processImages(doc, markdownPath); // 基础CSS String css = buildBasicCss(); try (OutputStream os = Files.newOutputStream(pdfPath)) { PdfRendererBuilder builder = new PdfRendererBuilder(); builder.withHtmlContent(wrapHtml(doc, css), markdownPath.getParent().toUri().toString()); builder.toStream(os); // 加载字体 loadFonts(builder, fontDir); builder.run(); } } private static void processImages(Document doc, Path markdownPath) { // 实现图片路径处理... } private static String buildBasicCss() { // 返回基础CSS样式... } private static String wrapHtml(Document doc, String css) { // 包装HTML结构... } private static void loadFonts(PdfRendererBuilder builder, Path fontDir) throws IOException { // 加载字体文件... } }实际项目中,这个工具类可以进一步扩展支持:
- 自定义页眉页脚
- 水印添加
- 文档加密
- 多文档合并
9. 测试与验证
建议编写单元测试验证转换效果:
@Test public void testConvertMarkdownToPdf() throws Exception { Path mdFile = Paths.get("src/test/resources/test.md"); Path pdfFile = Paths.get("target/test.pdf"); Path fontDir = Paths.get("src/main/resources/fonts"); MarkdownPdfUtils.convertToPdf(mdFile, pdfFile, fontDir); assertTrue(Files.exists(pdfFile)); assertTrue(Files.size(pdfFile) > 0); }测试时应检查:
- 中文显示是否正确
- 代码块格式是否保留
- 图片是否正常显示
- 超链接是否可用
- 表格边框是否完整
10. 部署注意事项
- 字体打包:确保字体文件被打包到最终jar中
- 资源路径:在生产环境注意文件路径问题
- 内存设置:大文档转换需要增加JVM内存
- 依赖冲突:注意与项目中其他库的版本兼容性
对于Spring Boot项目,可以创建自动配置:
@Configuration @ConditionalOnClass(Parser.class) public class MarkdownPdfAutoConfiguration { @Bean public MarkdownPdfUtils markdownPdfUtils() { return new MarkdownPdfUtils(); } }11. 扩展思路
基于这个基础功能,还可以实现:
- Web服务:暴露REST API提供转换服务
- 批量转换:监控目录自动处理新文件
- 邮件附件:自动将转换结果作为附件发送
- 文档预览:与前端集成实现实时预览
例如实现Spring Boot端点:
@RestController @RequestMapping("/api/docs") public class DocumentController { @PostMapping("/convert") public ResponseEntity<Resource> convertToPdf(@RequestBody String markdown) { // 实现转换逻辑... } }12. 经验总结
在实际项目中应用这套方案时,有几个关键点值得注意:
- 字体许可:确保使用的字体有合法的授权许可,商业项目尤其要注意
- 错误处理:对大型文档要做好内存管理和超时控制
- 版本升级:关注Flexmark和OpenHTMLToPDF的版本更新
- 样式调试:开发阶段可以输出中间HTML辅助调试
一个实用的调试技巧是保存中间HTML:
Files.writeString(Paths.get("debug.html"), html);这样当PDF输出不符合预期时,可以先检查HTML阶段是否正确。