Java实现Markdown转PDF:基于iText7与HTML中间层的完整实战
真正开始做这个需求之前我先坦白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 groupIdcom.itextpdf/groupId artifactIditext7-core/artifactId version7.2.5/version typepom/type /dependency dependency groupIdorg.commonmark/groupId artifactIdcommonmark/artifactId version0.21.0/version /dependencyitext7-core是一个聚合依赖会把kernel、layout、io、pdfhtml等模块一起拉进来省去手动维护多个版本的麻烦。版本号建议用7.2.x不要用太老的7.0.x因为pdfHTML对CSS的支持在后续版本里完善了不少。2. 详细实操Markdown解析与HTML中间层生成2.1 Markdown解析器选型commonmark-java还是flexmarkJava生态里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 让解析结果变成规范HTMLMarkdown解析器输出的HTML片段通常是这样的h1标题/h1 p段落内容/p precode classlanguage-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\nhtml\nhead\nstyle css /style\n/head\nbody\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渲染为PDF3.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 charsetUTF-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.otfCSS里写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 srcpath altalt。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.pngpdfHTML本身也能加载但我建议不要依赖它下载网络图片一是网络延迟不可控二是外网图片可能随时失效。稳妥的做法是预处理阶段把图片下载到本地替换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-8ByteArrayInputStream用UTF_8head加meta charset代码块内容超出页面被截断没设置pre-wrappre里加white-space: pre-wrap表格边框不显示CSS选择器太复杂用简单元素选择器加table、th、td样式图片显示为空相对路径找不到ConverterProperties设置setBaseUri转换时内存溢出文档过大或对象重复创建复用FontProvider必要时拆分文档再合并页面之间内容断裂元素跨页对标题和表格加page-break-inside: avoid最后再分享一个经验在实际项目里Markdown内容和HTML模板的耦合远比你想象的大。你定的CSS模板会影响所有转换出来的PDF所以不要一次性把所有样式写完就上线。先拿两三篇真实文档做样本转换后逐页检查标题、表格、代码块、图片四类元素的表现确认没问题后再批量转换。这个习惯帮我避免了很多次线上文档导出返工的问题。如果你后续还要支持目录自动生成、PDF元数据标注、加密权限控制ITExt也都能实现。先从这条链路跑通后面扩展就不难了。