真正开始做这个需求之前,我先坦白:Markdown转PDF这条路,我前前后后踩了小半个月的坑。
起因是一个文档系统要导出PDF,业务方给了一批Markdown格式的知识库文章,要求生成排版干净、支持中文、能直接分发给客户的PDF文件。我第一反应是找现成工具,但试了一圈,要么是命令行工具部署麻烦,要么是样式控制太弱。后来定了Java后端自己做,技术栈选了ITextPDF,链路是Markdown先转HTML,再用ITextPDF的HTML转PDF能力生成最终文件。
这套流程跑通之后,我发现网上资料很零散,要么只讲Markdown解析,要么只讲IText渲染,很少有人把两段拼起来讲完整。这篇文章我把整个链路拆开讲清楚,包括选型理由、依赖配置、完整代码、样式适配,以及我在实际项目中遇到的几个大坑。适合Java后端开发者、做文档导出或电子书生成功能的小伙伴直接参考。
1. 方案选型:为什么是Markdown转HTML再到PDF的三级跳
1.1 Markdown直接转PDF为什么不靠谱
Markdown本身是轻量级标记语言,设计初衷是给人类阅读和网页渲染用的,不是为PDF排版服务。你可以在IDE里看到漂亮的Markdown预览,但那个渲染效果依赖编辑器的CSS引擎,和PDF的排版引擎完全是两回事。
市面上的确有一些库声称支持Markdown直接转PDF,但它们内部通常做了两件事:要么内置一套写死的样式,要么偷偷调用了系统浏览器打印。前者的问题是样式不可控,你没法定制页边距、页眉页脚、代码块配色;后者的问题是部署环境复杂,服务器上未必有浏览器环境,而且打印出来的PDF尺寸和分页经常不稳定。
更关键的是,ITextPDF这个库本身不解析Markdown。它擅长的是PDF底层操作、元素绘制、以及HTML/CSS渲染。所以你需要在它前面加一层解析器,把Markdown翻译成它认识的语言,也就是HTML。
1.2 选择iText5还是iText7,以及pdfHTML模块
如果你搜过ITextPDF,会看到有iText 5和iText 7两大版本。iText 5是经典老版本,包名是com.itextpdf.text,很多老项目还在用。iText 7是重写后的新架构,包名是com.itextpdf.kernel、com.itextpdf.layout、com.itextpdf.html2pdf,模块划分更清晰。
做HTML转PDF必须用iText 7的pdfHTML模块,这个模块负责接收HTML字符串和CSS样式,解析后映射到iText的layout元素,最终写入PDF。iText 5没有对应的HTML转PDF能力,硬要用的话得自己写标签解析器,工作量巨大,不建议。
同类方案里还有Flying Saucer、wkhtmltopdf、Openhtmltopdf等。Flying Saucer基于Java,但CSS支持停留在老版本,表格和Flex布局经常出问题。wkhtmltopdf要依赖Qt和WebKit,服务器部署费劲。Openhtmltopdf是不错的替代品,但如果你已经在项目里用了iText处理PDF合并、签名等操作,直接用pdfHTML是最省事的,毕竟底层渲染模型统一,调试起来心智负担小。
1.3 整体技术链路与核心依赖
我最终采用的链路是这样的:
Markdown源文件 -> 解析器(commonmark-java或flexmark) -> HTML片段 -> 套用CSS模板 -> 完整HTML字符串 -> ITEXT pdfHTML模块 -> PDF文件这个链路的核心优势是每一层都可替换。Markdown解析器不喜欢可以换,CSS模板不喜欢可以改,PDF生成逻辑不喜欢可以换成其他HTML转PDF库,不会互相绑架。
依赖方面,我用的Maven坐标如下:
<dependency> <groupId>com.itextpdf</groupId> <artifactId>itext7-core</artifactId> <version>7.2.5</version> <type>pom</type> </dependency> <dependency> <groupId>org.commonmark</groupId> <artifactId>commonmark</artifactId> <version>0.21.0</version> </dependency>itext7-core是一个聚合依赖,会把kernel、layout、io、pdfhtml等模块一起拉进来,省去手动维护多个版本的麻烦。版本号建议用7.2.x,不要用太老的7.0.x,因为pdfHTML对CSS的支持在后续版本里完善了不少。
2. 详细实操:Markdown解析与HTML中间层生成
2.1 Markdown解析器选型:commonmark-java还是flexmark
Java生态里Markdown解析器有十几种,我用过的有commonmark-java和flexmark。
commonmark-java是CommonMark规范的标准实现,胜在轻量、解析速度快、依赖少。它默认支持标准Markdown语法,标题、列表、引用、代码块、链接这些都没问题。如果需求就是标准Markdown,它最合适。
flexmark是commonmark-java的增强版,扩展了表格、脚注、任务列表、删除线、自动链接等。如果你的Markdown内容来自语雀、飞书或者某些富文本编辑器导出的文档,里面很可能包含表格和任务列表,这时候flexmark更稳。
两种的选择我建议这样判断:先看源文档格式,如果表格是管道符写法,比如| 姓名 | 年龄 |,那就要用flexmark,或者commonmark-java加上表格扩展插件。如果只是普通文章,没有特殊语法,commonmark-java足够。
我项目里的Markdown源文件大量使用表格和代码块,所以最终选了flexmark。但为了思路清晰,下面示例先用commonmark-java,逻辑更简单,你们换解析器时只动一个类就行。
2.2 让解析结果变成规范HTML
Markdown解析器输出的HTML片段通常是这样的:
<h1>标题</h1> <p>段落内容</p> <pre><code class="language-java">代码块</code></pre> <table> <thead>...</thead> <tbody>...</tbody> </table>这段HTML可以直接扔给浏览器,但如果直接扔给pdfHTML,你会发现样式很难看。因为pdfHTML虽然支持CSS,但默认情况下没有你对Markdown加的任何样式,标题就是默认字号,代码块没有背景色,表格没有边框。
所以必须做一步:把解析器输出的HTML片段,拼接进一个完整的HTML模板,模板里用CSS定义整套文档样式。
这里有个关键点:IText的pdfHTML对XHTML的规范性要求比浏览器严格。标签没有闭合、属性值没有加引号、<img>没有自闭合,这些在浏览器里都能忍,pdfHTML却可能直接解析失败或渲染异常。好在CommonMark规范的解析器输出都是标准闭合标签,只要你不手工拼字符串拼出错,一般没这个问题。
2.3 代码示例:Markdown解析并套用模板
下面这段代码是我测试时写的第一版,核心逻辑至今没变:
import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; public class MarkdownToHtmlConverter { public static String toHtml(String markdownText) { Parser parser = Parser.builder().build(); Node document = parser.parse(markdownText); HtmlRenderer renderer = HtmlRenderer.builder().build(); return renderer.render(document); } }拿到HTML片段后,套用模板:
public static String wrapWithTemplate(String bodyHtml) { String css = """ body { font-family: "SourceHanSansSC", "Noto Sans CJK SC", sans-serif; font-size: 12pt; line-height: 1.8; margin: 2cm; } h1 { font-size: 22pt; color: #1a1a1a; border-bottom: 1.5pt solid #333; padding-bottom: 6pt; } h2 { font-size: 18pt; color: #2c3e50; margin-top: 18pt; } h3 { font-size: 15pt; color: #34495e; } pre { background-color: #f6f8fa; border-radius: 4pt; padding: 10pt; font-family: "JetBrains Mono", "Courier New", monospace; font-size: 10pt; white-space: pre-wrap; } code { font-family: "JetBrains Mono", "Courier New", monospace; background-color: #f6f8fa; } table { border-collapse: collapse; width: 100%; margin: 12pt 0; } th, td { border: 0.75pt solid #bbb; padding: 6pt 10pt; text-align: left; } th { background-color: #f2f2f2; font-weight: bold; } img { max-width: 100%; } """; return "<!DOCTYPE html>\n<html>\n<head>\n<style>" + css + "</style>\n</head>\n<body>\n" + bodyHtml + "\n</body>\n</html>"; }模板设计有几个注意点。
字号单位建议用pt,不要用px。浏览器里px是屏幕像素,PDF里更适合用pt(磅),12pt接近小四字号,阅读体感合适。
行高line-height用1.8,比浏览器默认的1.2大不少。原因很简单,PDF是打印阅读场景,行距太密在纸面上很难受,尤其是中文内容。
pre代码块的white-space要设成pre-wrap。不设的话,代码行太长会导致内容超出页面宽度被截断,设置了pre-wrap后会自动换行,虽然牺牲了一点代码格式化,但保证了不丢内容。
3. 核心环节:使用ITExtPDF将HTML渲染为PDF
3.1 三步走的转换流程
HTML转PDF最核心的一段代码其实很短,就三步:
import com.itextpdf.html2pdf.ConverterProperties; import com.itextpdf.html2pdf.HtmlConverter; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfWriter; import java.io.FileOutputStream; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; public class HtmlToPdfService { public static void convert(String fullHtml, String outputPath) throws Exception { // 1. 创建PDF写入器 try (FileOutputStream fos = new FileOutputStream(outputPath)) { PdfWriter writer = new PdfWriter(fos); PdfDocument pdfDoc = new PdfDocument(writer); pdfDoc.setTagged(true); // 开启标签,便于无障碍阅读和元数据 // 2. 配置转换属性 ConverterProperties props = new ConverterProperties(); // 这一步在后面字体章节详细讲 props.setBaseUri("/path/to/resource/root/"); // 3. 执行转换 HtmlConverter.convertToPdf( new ByteArrayInputStream(fullHtml.getBytes(StandardCharsets.UTF_8)), pdfDoc, props ); } } }这段逻辑很简单:PdfWriter写文件,PdfDocument承载文档,HtmlConverter.convertToPdf把HTML流解析后写入PDF。
有一个隐藏细节是编码。HTML字符串写入流之前必须用UTF-8编码,并且在HTML模板的head标签里加上<meta charset="UTF-8">,否则中文容易出现乱码。上面的代码已经用StandardCharsets.UTF_8指定了。
3.2 中文字体:ITExtPDF最绕不开的坑
新装的iText默认字体是Helvetica,这是一套西文字体,不含中文。如果你不加处理直接转换,PDF里所有中文会变成空白甚至乱码。这是我当时踩的第一个大坑,也是最多人卡住的地方。
正确做法是自定义字体集,通过ConverterProperties里挂一个FontProvider。
import com.itextpdf.io.font.FontProgram; import com.itextpdf.io.font.FontProgramFactory; import com.itextpdf.kernel.font.PdfFontFactory; import com.itextpdf.layout.font.FontProvider; public static ConverterProperties createProperties() throws Exception { FontProvider provider = new FontProvider(); // 方式一:注册系统字体目录 provider.addSystemFonts(); // 方式二:注册单个字体文件(推荐,可控性更强) String fontPath = "/fonts/SourceHanSansSC-Regular.otf"; FontProgram fontProgram = FontProgramFactory.createFont(fontPath); provider.addFont(fontProgram); ConverterProperties props = new ConverterProperties(); props.setFontProvider(provider); return props; }FontProvider对象建议只初始化一次并复用,不要每次转换都重新加载字体文件,不然性能会很差。
关于字体文件选择,我建议直接在项目里放一套开源字体,比如思源黑体或Noto Sans CJK系列。这套字体内含简体中文、繁体中文、日文、韩文,覆盖了大多数文档场景。字体文件较大,几十MB到一百多MB都有可能,但为了PDF正常显示,这个体积代价可以接受。
注意:font-family里写的字体名要和你注册的字体实际匹配。如果注册的是SourceHanSansSC-Regular.otf,CSS里写font-family: "SourceHanSansSC"或者"思源黑体"都能对上。如果CSS里写的是某个系统没有的字体名,iText会回退到默认字体,中文又变空白。
3.3 分页控制和页眉页脚
HTML在PDF里分页是pdfHTML模块自动处理的,内容超过一页会自然分到下一页。但有些时候你想要更精确的控制,比如某个表格必须整块展示,不想跨页断开。
CSS里有几个分页属性pdfHTML是支持的:
page-break-before: always:强制元素前分页page-break-after: always:强制元素后分页page-break-inside: avoid:尽量避免元素内部跨页
我给标题和表格都加了page-break-inside: avoid,效果是如果表格在当前页剩余空间放不下,会整体挪到下一页,而不是从中间断开。这个体验对阅读PDF来说很重要。
页眉页脚用CSS做不到,需要写iText的页面事件处理器。核心思路是监听PdfDocumentEvent.END_PAGE,在每页结束绘制时往页脚位置画页码。
import com.itextpdf.kernel.events.PdfDocumentEvent; import com.itextpdf.kernel.events.Event; import com.itextpdf.kernel.events.IEventHandler; import com.itextpdf.kernel.pdf.canvas.PdfCanvas; import com.itextpdf.kernel.font.PdfFont; import com.itextpdf.kernel.font.PdfFontFactory; public class FooterEventHandler implements IEventHandler { private PdfFont font; public FooterEventHandler() throws Exception { font = PdfFontFactory.createFont("/fonts/SourceHanSansSC-Regular.otf"); } @Override public void handleEvent(Event event) { PdfDocumentEvent docEvent = (PdfDocumentEvent) event; com.itextpdf.kernel.pdf.PdfPage page = docEvent.getPage(); int pageNum = docEvent.getDocument().getPageNumber(page); int totalPages = docEvent.getDocument().getNumberOfPages(); String text = "第 " + pageNum + " 页 / 共 " + totalPages + " 页"; PdfCanvas canvas = new PdfCanvas(page); canvas.beginText() .setFontAndSize(font, 9) .moveText(0, 20) .showText(text) .endText(); } }不要忘了给PdfDocument注册这个处理器:
PdfDocument pdfDoc = new PdfDocument(writer); pdfDoc.addEventHandler(PdfDocumentEvent.END_PAGE, new FooterEventHandler());这段代码有几个细节值得说。
moveText(0, 20)里的20是页面底部预留的高度,单位是用户空间单位,基本等价于pt。如果你的页边距是2cm,也就是大约57pt,那么20pt的页脚位置基本就在页边距下方空白处,不会压到正文。
因为是END_PAGE事件,每页结束时执行,所以页码不会乱,包括最后一页的更新也会正常。
4. 常见问题与排查技巧实录
4.1 表格样式错乱或边框消失
Markdown里的表格经过解析器转换后,得到的是<table>、<th>、<td>这些标准标签。但ITExt的pdfHTML对表格的默认样式很朴素,没有边框、没有padding,列宽也可能挤压在一起。
我第一次转换出来的表格,单元格文字叠在一起,边框看不到,简直没法看。后来排查发现原因有两个。
第一个是CSS没有生效。pdfHTML对CSS选择器的支持有限,它支持th、td这样的标签选择器,但不支持某些高级选择器比如table > tbody > tr:nth-child(odd)这类伪类。解决方案是尽量用简单的元素选择器,别用太复杂的组合。
第二个是单元格里内容过长,导致表格被撑爆。解决方案是在td上设置word-wrap: break-word;,并且给table设置table-layout: fixed;。我用的模板里加了这两条之后,表格再没出过问题。
4.2 图片不显示或路径解析失败
Markdown里的图片语法,解析后变成<img src="path" alt="alt">。pdfHTML要显示图片,必须能通过src路径加载到图片文件。
这里有个很容易踩的坑:如果src是相对路径,比如./images/foo.png,默认情况下pdfHTML会把路径当成相对于当前工作目录的路径来解析。你如果是在Tomcat里跑的Web服务,工作目录可能是bin目录,图片自然找不到。
解决办法是在ConverterProperties上设置setBaseUri(),指定一个资源根目录。
ConverterProperties props = new ConverterProperties(); props.setBaseUri("C:/project/uploads/");如果图片是网络URL,比如https://example.com/img/foo.png,pdfHTML本身也能加载,但我建议不要依赖它下载网络图片,一是网络延迟不可控,二是外网图片可能随时失效。稳妥的做法是预处理阶段把图片下载到本地,替换img标签的src为本地路径。
4.3 性能问题和内存控制
文档量大时,HTML转PDF很容易吃内存。核心原因是pdfHTML解析HTML时会在内存里构建完整的DOM树和样式树,如果HTML有几MB,内存占用可能达到几百MB。
我处理过单文件超过10MB的Markdown文档,转换时堆内存直接飙到1.5GB,后来做了两个优化。
第一个优化是复用FontProvider和ConverterProperties。这两个对象创建成本高,尤其是字体加载,不要在循环里每次new。
第二个优化是可以考虑拆分文档。如果源文档结构允许,比如目录章节清晰,可以拆成多个HTML块分别转换,再用ITExt的PdfMerger合并PDF。这样单次转换的内存占用能显著降低,缺点是合并后总页码和页脚处理要额外设计。
4.4 常见问题速查表
| 问题现象 | 根源分析 | 解决方案 |
|---|---|---|
| PDF里中文全变空白 | 未注册中文字体 | FontProvider注册Noto Sans CJK或思源黑体 |
| 中文出现乱码 | HTML流编码不是UTF-8 | ByteArrayInputStream用UTF_8,head加meta charset |
| 代码块内容超出页面被截断 | 没设置pre-wrap | pre里加white-space: pre-wrap |
| 表格边框不显示 | CSS选择器太复杂 | 用简单元素选择器,加table、th、td样式 |
| 图片显示为空 | 相对路径找不到 | ConverterProperties设置setBaseUri |
| 转换时内存溢出 | 文档过大或对象重复创建 | 复用FontProvider,必要时拆分文档再合并 |
| 页面之间内容断裂 | 元素跨页 | 对标题和表格加page-break-inside: avoid |
最后再分享一个经验:在实际项目里,Markdown内容和HTML模板的耦合远比你想象的大。你定的CSS模板会影响所有转换出来的PDF,所以不要一次性把所有样式写完就上线。先拿两三篇真实文档做样本,转换后逐页检查标题、表格、代码块、图片四类元素的表现,确认没问题后再批量转换。这个习惯帮我避免了很多次线上文档导出返工的问题。
如果你后续还要支持目录自动生成、PDF元数据标注、加密权限控制,ITExt也都能实现。先从这条链路跑通,后面扩展就不难了。