ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java实现Markdown转PDF:从解析到渲染的完整实践

2026/9/8 3:59:12 拓冰建站 浏览量
Java实现Markdown转PDF:从解析到渲染的完整实践 简介这是一款用Java将Markdown转换为PDF的开源库资源面向Java开发者以及需要批量生成文档、报告、技术手册的人员解决由Markdown快速产出美观PDF的自动化转换问题。包内含完整源码工程共24个文件其中15个Java文件实现核心解析与转换逻辑2个shell脚本提供构建与持续集成辅助2个XML文件包含Maven依赖与工程配置另有YAML持续集成设置、LICENSE许可说明和README文档整个压缩包仅40KB代码库精简而API流畅适合学习库的设计思路或直接集成到项目中。资源支持JDK 1.6及以上环境并充分面向Java 8特性演进具备良好的兼容视野。已有2402人学习下载。通过源码可以掌握该库如何组合多个底层库、以流畅接口屏蔽转换细节同时了解其项目目录结构、依赖管理和多环境构建方式对希望自研转换工具或深入理解Markdown解析与PDF渲染流程的开发者颇具参考价值。 我用Java写了一个叫 Markdown2Pdf 的小工具库专门解决“把 Markdown 转成 PDF”这个看似简单、实操起来却到处是坑的问题。做后端这几年我打交道最多的文档格式就是 Markdown但交付给产品、客户或者领导审阅的时候大家要的往往又是 PDF。一开始我也图省事用在线转换工具后来发现内容泄露风险、隐私问题、排版不可控实在没法在正式项目里用。这个库的定位不是说搞一套多牛的企业级引擎而是用最朴素的组合拳——flexmark-java 负责把 Markdown 解析成 HTML 片段thymeleaf 模板或字符串模板负责套一层页面外壳openhtmltopdf 再负责把 XHTML 渲染成 PDF全程不走外部服务数据都留在本地。如果你也在做文档导出、报告生成、合同预览这类 Java 后端功能或者正好被“Markdown 转 PDF”的字体、表格、代码块问题折磨过这篇文章应该能帮上忙。1. 为什么我要在Java里自己搞一个Markdown转PDF的库先说个真实场景。有一次公司内部的知识库需要导出一批技术手册给客户文档全放在 Gitee 仓库里格式清一色 Markdown。我当时的第一反应是找现成工具于是把市面上的方案都试了一圈结果不约而同地踩进了同一个坑要么转换质量不行要么就根本没法集成进 Java 服务里。1.1 在线转换工具的不可控在线转 Markdown 的工具很多界面也确实漂亮但我很快就放弃了。首先是安全问题文档里经常带内部 IP、数据库连接方式、架构图这些内容放到第三方网站上等于把家底交出去稍微有点安全意识的团队都不会接受。其次是格式问题在线工具的文件上传大小限制很随意超过几 MB 就要开会员而且转出来的 PDF 目录结构、页码、页眉页脚基本都不受控制美观程度全靠运气。我后来意识到凡是涉及企业交付物的文档转换必须走自研或者本地开源组件否则光是合规审查这一关就过不去。1.2 现有Java方案的三个痛点在 Java 生态里能把 Markdown 转 PDF 的库其实有一些但用起来都不顺手。第一个痛点是配置太重很多库依赖 Playwright 或 Chromium 这类无头浏览器做渲染本地开发还好放到 Linux 服务器上光装浏览器依赖就要折腾半天镜像体积直接膨胀几百兆。第二个痛点是字体和中文支持不好一些直接基于 iText 的组件默认字体里就没有中文字形生成的 PDF 在 Windows 上正常到了线上服务器全变成方框。第三个痛点是定制能力弱我想在 PDF 里加上自己的页眉、水印、自定义标题样式但库封装得太死改起来比重新写一个还费劲。1.3 这个库解决的是哪一类问题所以我做的这个库目标非常明确就是解决“从 Markdown 到 PDF 的最后一公里”。它不试图成为一个全功能的排版系统而是把链路拆成几个语义清晰的环节每个环节都能被开发者替换和扩展。也就是说如果你只是想快速把一篇 Markdown 变成 PDF开箱即用默认样式不算惊艳但足够干净如果你想深度定制无论是换主题字体、增加代码高亮、还是加水印也能在代码里找到明确的修改点。这一点对于中小团队尤其重要因为文档处理的需求通常不会只是“转一次就完事”后面大概率会冒出各种魔改需求。2. Markdown到PDF完整转换链路和每一环的原理很多人在做 Markdown 转 PDF 的时候一上来就盯住“怎么调用 iText 画文本”这是一个方向性的误解。Markdown 本身只是语法标记要变成 PDF必须经过一个中间表示而最自然的中间表示就是 HTML。2.1 链路总览我现在的实现链路是下面这张表这样环节输入输出关键技术Markdown 解析Markdown 字符串AST 语法树flexmark-javaAST 渲染AST 语法树HTML 片段flexmark HtmlRenderer页面装配HTML 片段XHTML 完整页面字符串模板 / ThymeleafPDF 渲染XHTML 页面PDF 文件openhtmltopdf核心思路就是不要把 Markdown 直接“翻译”成 PDF而是先转成 HTML再交给一个能理解 CSS 的 PDF 渲染引擎。这样做的好处是HTML 和 CSS 本身就是一个极其成熟的排版语言很多样式问题比如列表缩进、表格边框、文字对齐浏览器和 PDF 引擎早就帮你处理好了你只需要关心内容组织和页面样式。2.2 flexmark-javaMarkdown解析与ASTMarkdown 解析我选的是 flexmark-java而不是之前项目里用的 commonmark-java。理由是 flexmark 对扩展语法和自定义渲染的支持更友好。官网说法是它以 CommonMark 为基准兼容 GFMGitHub Flavored Markdown表格、删除线、任务列表这些在技术文档里高频出现的语法默认就能解析。而且它把 Markdown 解析成一套完整的 AST你可以遍历这个树在渲染成 HTML 之前做各种 AST 级别的修改比如给每个代码块的语言类型打个标记或者提取所有标题自动生成目录。对于想做深度定制的人来说这是一个非常重要的入口。在代码里使用 flexmark 其实非常简单核心就是构建 Parser 和 HtmlRendererMutableDataSet options new MutableDataSet(); options.set(Parser.EXTENSIONS, Arrays.asList( TablesExtension.create(), StrikethroughExtension.create() )); Parser parser Parser.builder(options).build(); HtmlRenderer renderer HtmlRenderer.builder(options).build();构建之后的 parser 可以直接复用因为它是线程安全的不需要每次转换都重新创建。实际的项目中我一般把它封装成单例防止频繁初始化带来性能损耗。2.3 模板渲染与openhtmltopdf的职责边界flexmark 渲染出来的是一段 HTML 片段比如一个p、pre或者table它没有html、head这些完整的页面结构。如果直接把这段片段丢给 openhtmltopdf它能转但样式会很难看因为没有任何 CSS 来控制字体、边距、页眉这些元素。所以我用一个模板把 HTML 片段和完整的 CSS 合并成一个 XHTML 页面。这样做还有一个更重要的原因openhtmltopdf 本质上是一个 CSS 排版引擎它会把 HTML 解析成 DOM 树然后按照 CSS 规则把每个节点渲染到 PDF 页面上所以你在前期构建 HTML 时添加的任何 class 和 style都会直接影响最终的排版效果。这也是这个库“简单”却强大的根基。至于 openhtmltopdf它基于 PDFBox 实现支持 CSS 2.1 以及 CSS3 的部分特性比如border-radius、linear-gradient这对于渲染技术文档基本够用了。它也支持通过PdfRendererBuilder注册自定义字体这是中文字体能正常显示的关键。它的用法同样很直白PdfRendererBuilder builder new PdfRendererBuilder(); builder.withHtmlContent(html, null); builder.useFont(new File(simhei.ttf), SimHei); builder.toStream(outputStream); builder.run();可以说这条链路上每一环都有清晰的职责边界出了什么问题你能很快定位到是解析语法的问题还是模板样式的问题又或者是 PDF 渲染引擎对某个 CSS 属性支持不完整的问题而不是像之前用的某个一体化黑盒库一样报错了根本不知道从哪查起。3. 库的核心设计一个方法搞定Markdown转PDF做这个库的过程其实也是我在“省事”和“可控”之间反复权衡的过程。光是把功能跑通容易但要设计成一个别人能直接用的库API 的简洁程度和默认行为的合理程度比功能本身更关键。3.1 对外API和默认行为最终对外暴露的 API 收敛到了一个类、一个方法Markdown2Pdf.convert(markdown, outputStream);内部会按前面说的链路执行解析、渲染、装配、生成。默认行为我做了几个明确的取舍Markdown 解析默认启用表格和删除线扩展因为技术文档里这两类语法太常用了其他扩展不默认开保持依赖干净。默认字体优先找资源目录下的simhei.ttf和simsun.ttc找不到就注册系统字体。这样项目部署到容器里也不会因为缺少字体就全盘崩溃。默认页面样式是 A4 大小、2 厘米边距、正文 14px 字号标题逐级加大代码块有浅灰背景。默认在 PDF 底部输出页码格式是“第 x 页 / 共 y 页”这个信息在给客户交付文档时几乎是刚需。这些默认值不是拍脑袋定的而是我从实际交付过的文档里总结出来的。比如正文 14px 比常见的 16px 更适合中文阅读因为同样一页纸能容纳的信息量更多打印出来不浪费纸代码块浅灰背景和边框能明显区分正文与代码而这个样式只需要几行 CSS 就能实现。3.2 嵌入的HTML/CSS模板模板部分我没有引入 Thymeleaf而是直接用 Java 文本块拼字符串因为模板内容很简单没必要额外增加模板引擎的依赖。模板里最核心的是这段 CSSbody { font-family: SimSun, SimHei, sans-serif; font-size: 14px; line-height: 1.8; margin: 0; } h1 { font-size: 24px; font-weight: bold; } h2 { font-size: 20px; border-bottom: 1px solid #ddd; padding-bottom: 4px; } table { border-collapse: collapse; width: 100%; table-layout: fixed; } th, td { border: 1px solid #ccc; padding: 6px 8px; word-wrap: break-word; } pre { background-color: #f8f8f8; border: 1px solid #e0e0e0; padding: 12px; white-space: pre-wrap; word-wrap: break-word; }注意table-layout: fixed和word-wrap: break-word这两个属性是在处理超长文本和表格溢出时总结出来的后面我会单独讲。3.3 为什么“简单”反而更实用库设计成现在这样其实是一路砍需求砍出来的。一开始我也考虑过做插件机制、环境变量配置、热更新样式这些功能后来发现对一个文档转换工具来说真正高频使用的功能就是“传字符串拿到 PDF 文件”。功能越多测试矩阵就越大依赖冲突的概率就越高用户上手成本就越高。分布式系统里有个词叫“优雅降级”这个库的定位同样如此核心主线功能必须稳定异常分支尽力而为复杂的定制需求都留下扩展点而不是一开始就全部内置。事实证明这样设计之后团队里其他同事接进来几乎不需要看文档三分钟就能跑通。4. 实战接入从零写一个可用的Markdown转PDF工具这一节给出完整可运行的代码。我假设你用的是 Maven 项目JDK 版本在 11 以上要用到文本块如果你还在 JDK 8 上把文本块改成字符串拼接就行。4.1 Maven依赖pom.xml 里需要加两个核心依赖flexmark 负责 Markdown 解析openhtmltopdf 负责 PDF 生成properties flexmark.version0.64.8/flexmark.version openhtml.version1.0.10/openhtml.version /properties dependencies dependency groupIdcom.vladsch.flexmark/groupId artifactIdflexmark-all/artifactId version${flexmark.version}/version /dependency dependency groupIdcom.openhtmltopdf/groupId artifactIdopenhtmltopdf-pdfbox/artifactId version${openhtml.version}/version /dependency /dependenciesflexmark-all会把所有官方扩展一起打包进来虽然体积稍大但省去了逐个引入扩展模块的麻烦对于工具库来说这个 trade-off 是值得的。openhtmltopdf-pdfbox已经依赖了 PDFBox所以不需要单独再加 pdfbox 依赖。4.2 核心实现代码先写一个工具类把整个转换流程封装起来public class Markdown2Pdf { private static final Parser PARSER; private static final HtmlRenderer RENDERER; static { MutableDataSet options new MutableDataSet(); options.set(Parser.EXTENSIONS, Arrays.asList( TablesExtension.create(), StrikethroughExtension.create(), TaskListExtension.create() )); options.set(HtmlRenderer.FENCED_CODE_LANGUAGE_CLASS_PREFIX, ); PARSER Parser.builder(options).build(); RENDERER HtmlRenderer.builder(options).build(); } public static void convert(String markdown, OutputStream outputStream) throws Exception { String body RENDERER.render(PARSER.parse(markdown)); String html buildHtml(body); PdfRendererBuilder builder new PdfRendererBuilder(); builder.withHtmlContent(html, null); registerFonts(builder); builder.toStream(outputStream); builder.run(); } private static String buildHtml(String body) { return !DOCTYPE html html head meta charsetUTF-8 style body { font-family: SimSun, SimHei, sans-serif; font-size: 14px; line-height: 1.8; margin: 0; } h1 { font-size: 24px; } h2 { font-size: 20px; } table { border-collapse: collapse; width: 100%; table-layout: fixed; } th, td { border: 1px solid #ccc; padding: 6px 8px; word-wrap: break-word; } pre { background-color: #f8f8f8; border: 1px solid #e0e0e0; padding: 12px; white-space: pre-wrap; word-wrap: break-word; } code { font-family: Courier New, monospace; font-size: 13px; } /style /head body body /body /html ; } private static void registerFonts(PdfRendererBuilder builder) { try { builder.useFont( Markdown2Pdf.class.getResourceAsStream(/fonts/simhei.ttf), SimHei); builder.useFont( Markdown2Pdf.class.getResourceAsStream(/fonts/simsun.ttc), SimSun); } catch (Exception e) { // 找不到自定义字体就尝试系统字体不阻断转换流程 builder.useFont(new File(C:/Windows/Fonts/simsun.ttc), SimSun); } } }这里有几个细节值得说。PARSER和RENDERER都放在静态代码块里初始化因为它们可以跨线程复用。TaskListExtension是任务列表扩展用来支持- [ ]这种语法。字体注册时用了 try-catch避免因为打包时遗漏字体文件导致整个转换失败这种“尽力而为”的容错思路在工具库中很重要。4.3 测试和验证方法写一个简单的 main 方法来验证效果public class Demo { public static void main(String[] args) throws Exception { String markdown # 用户手册 ## 功能说明 | 功能 | 说明 | | --- | --- | | 导入 | 支持 Markdown 文件导入 | | 导出 | 支持 PDF 文件导出 | ## 代码示例 java public class Hello { public static void main(String[] args) { System.out.println(Hello); } } 注意本文档仅作演示用途。 ; try (FileOutputStream fos new FileOutputStream(output.pdf)) { Markdown2Pdf.convert(markdown, fos); } } }跑完之后打开 output.pdf你应该能看到带表格、代码块和引用块的三种典型文档块。我建议你在验证时多测试几个 Markdown 文件尤其是包含长文本、超长 URL、复杂嵌套列表的用例这些地方最容易暴露出排版问题。我自己还会用一个包含全量语法的 Markdown 文件做回归测试每次改完代码先跑一遍确保新的改动没有破坏旧语法。5. 我在使用中踩过的坑和对应解法这个库跑起来不难但要在真实项目里稳定产出合格的 PDF还是会遇到不少边界问题。下面这几个坑是我实际踩过并且已经解决的写出来给大家参考。5.1 中文字体全部变成方块的真相第一次在 Linux 服务器上运行时生成的 PDF 里所有中文都变成了方框。排查过程很典型本地 Windows 没问题线上 Linux 才有问题基本可以断定是字体缺失。openhtmltopdf 的 PDFBox 渲染引擎在绘制文字时会逐个字符到当前注册的字体里去查字形如果这个字体没有对应的字符就画一个方框占位。Linux 服务器没有安装 Windows 的宋体黑体自然就中招了。解决方案有两种。第一种是服务器上装中文字体常见的有fonts-wqy-zenhei、fonts-wqy-microhei在 Debian/Ubuntu 系上执行apt install fonts-wqy-zenhei即可。第二种更稳妥把字体文件打进项目的src/main/resources/fonts/目录启动时用builder.useFont()从 classpath 加载。第二种的好处是应用自包含不依赖服务器环境我最终采用的是这种。还有一点必须注意字体文件名和 CSS 里写的font-family不一定要完全一致但builder.useFont()里注册的字体别名和 CSS 中使用的字体名称要保持对应否则渲染引擎找不到匹配的字体又会退回方框。5.2 代码块换行与表格溢出的处理代码块是技术文档里最常见的元素也是排版最容易翻车的地方。默认情况下pre标签里的文本除非遇到换行符否则会被渲染成一行于是出现了超长的一行代码直接冲破页面边界的问题。网上很多方案是给pre加white-space: pre-wrap确实能让文本自动换行但缩进和空格会被压缩吗实测下来不会pre-wrap的意思是保留空白符同时允许自动换行这在展示代码时比pre合适得多。表格超宽则是另一个高频问题。Markdown 表格列数一多或者某个单元格里放了长 URL表格宽度很容易超过页面可用宽度。我的处理是给表格固定布局table-layout: fixed让列宽由浏览器按比例分配而不是根据内容自动撑开再给单元格加word-wrap: break-word允许长单词在任意位置断开换行。这样即使遇到几百个字符的 URL也能乖乖待在表格内部。5.3 HTML不严格导致的渲染中断openhtmltopdf 对 HTML 的合规性要求比浏览器严格得多浏览器会自动纠错的地方它不会。我遇到过的一个经典情况是Markdown 里面写了两个连续的div标签flexmark 解析后原样输出到 HTML 里但其中某个标签没有闭合Chrome 能自动补全并正常显示openhtmltopdf 却直接把这个块丢掉导致后面的内容全部消失了。解决思路不是去改 openhtmltopdf 的容错性而是从源头保证 HTML 是严格闭合的 XML。flexmark 生成的 HTML 本身是良好的问题往往出在 Markdown 原文里内嵌了 HTML 标签而写 Markdown 的人没有按 XHTML 规范闭合。遇到这种情况我建议在渲染的前置环节增加一个 HTML 净化步骤把br、hr这类没有显式闭合的标签规范成br/、hr/避免渲染器在解析阶段抛异常。6. 进阶玩法样式定制、代码高亮和中文字体适配基础功能稳定之后如果不做点定制会觉得这个库差口气。这里分享三个我实际用过的进阶方向按投入产出比排序。6.1 代码高亮的两种实现路线代码高亮几乎是技术文档 PDF 的标配。网上会有人说用 highlight.js 在前端渲染但 openhtmltopdf 对 JavaScript 的支持限制颇多虽然新版本有基础执行能力我却不敢在生产环境依赖它因为你没法保证高亮脚本在无头环境下一定正常执行。我的建议是走静态渲染路线在生成 HTML 之前用服务端的词法分析器把代码块里的关键字、字符串、注释标成不同的span classtoken-keyword等 CSS 类再由 PDF 引擎直接渲染。这套方案的性能开销可以忽略不计而且高亮样式完全由你 CSS 里的 class 决定可控性最好。flexmark 的HtmlRenderer允许注册自定义NodeRenderer因此可以只对FencedCodeBlock节点做特殊渲染其余内容保持默认。6.2 中文PDF的字体选择与CSS设置中文 PDF 相比英文多了一道字体栏。首先在线工具生成的 PDF 打开速度慢往往就是嵌入了超大字体子集所以字体文件不要贪多选一个正文衬线、一个等宽代码字体就够了。其次中文字体的行高和英文差别很大建议 CSS 里设line-height: 1.8甚至2.0否则中文密集区会挤成一团阅读体验很差。第三如果你在 CSS 里写了font-family: SimSun, SimHei, sans-serif请确保useFont()注册时对应的字体名和这个名称完全匹配例如SimSun而不是一个叫宋体的别名。我在 initial 版本里就是因为字体别名不一致导致页面里上一部分用宋体、下一部分又退回默认无衬线字体排出来的版面非常割裂。6.3 水印、页眉页脚这类扩展往哪里加水印和页眉页脚是文档交付场景里的常见需求。页眉页脚我建议优先用 CSS 解决因为page规则在 openhtmltopdf 里有完整支持可以很方便地设置页边距、页眉内容和页码格式。水印则稍微麻烦一点纯 CSS 用position: fixed实现时实测在部分版本上渲染不稳定更可靠的方式是拿到PdfRendererBuilder之后通过 PDFBox 层的文档事件处理器在每页绘制一个半透明文字。这个方案虽然绕开了 CSS但胜在稳定可控。我在项目里做“内部资料”水印时就是用这个思路把水印文字画在页面中央、旋转 45 度、填充色设为透明度较低的灰色既不影响正文阅读又能起到警示作用。具体代码我就不贴了各位可以根据自己的 PDFBox 版本来实现。最后再分享一个我在这个项目里学到的经验做文档转换类工具一定要建立“样例文档清单”来做回归测试。很多问题不是第一次转换就出现而是用户写 Markdown 的姿势千奇百怪今天来个嵌套表格明天来个超长代码块后天又有人内嵌了带 id 的 HTML 标签如果你没有一个覆盖这些边角场景的测试样例集只靠临时改 bug永远追不完。我把常见语法、极端输入、非法 HTML、超长内容都做成了自动化测试用例每次改完代码跑一遍心理踏实很多。这个库从第一版到现在稳定性的提升主要靠的就是这套测试而不是我写代码的水平。本文还有配套的精品资源点击获取