收到,我会严格按照要求,基于“ITextpdf将Markdown转换成PDF详细流程(HTML转PDF)”这一标题,撰写一篇结构独立、内容详实、可直接复现的实战型博文。以下为纯净的Markdown正文内容。
1. 为什么绕这么大一圈:Markdown到PDF的真实链路
先说一个反直觉的结论:iText本身并不知道Markdown是什么。它最擅长的输入格式是PDF语法级别的元素,而对外暴露的便捷API则更多围绕HTML/XHTML展开。所以当需求是“把Markdown转成PDF”时,最稳妥、最省力的技术路线不是让iText去解析Markdown,而是先把Markdown解析成HTML,再把HTML渲染成PDF。这一步绕路不是多此一举,而是把两个成熟工具的各自最佳能力组合起来:Markdown的解析交给专门的解析器,PDF的渲染交给iText。
我在实际项目里遇到这个需求的场景很典型:某内部工具平台需要把一批由运营同学维护的Markdown说明文档批量导出为PDF,用于线下分发和归档。文档里包含标题、列表、表格、代码块、引用块、加粗、链接这些最常见的Markdown元素,整体复杂度不高,但量比较大,一天要跑几百份。如果用手工复制到在线编辑器里导出PDF,效率太低;如果有几十份文档格式还不一样,维护成本就完全失控了。于是我把整条链路拆成了Markdown转HTML、HTML转PDF两个阶段,最终用一套Java程序搞定全流程。
值得说明的是,这条链路里有两个“桥接点”决定了最终的成品质量。第一个桥接点是Markdown解析器的选择:它必须能够正确输出与源文档语义一致的HTML片段,否则后面PDF还原度就无从谈起。第二个桥接点是iText渲染HTML的CSS支持能力:iText的pdfHTML模块对CSS的支持约等于一个小型浏览器核心,它支持常见的盒模型、字体样式、颜色、表格边框等属性,但低于CSS 2.1的某些规则也未必支持得很完美。理解了这两个边界,剩下的工作就变成了“在边界内合理配置,把常见文档场景逐一跑通”。
所以这篇文章不是简单的代码粘贴,而是一条从选型、配置、编码到踩坑的完整实战记录,适合正准备做类似功能的开发者参考,尤其是那些对iText不熟悉、又不想来回试错的初学者。
2. 环境准备与依赖选型:开源库之间的关键取舍
先把目标环境说清楚。我用的是JDK 8 + Maven 3.6 + iText 7.x + pdfHTML 2.x,这套组合在目前的开源生态里属于黄金搭配。很多人一上来会用iText 5的XMLWorker,那套方案其实也能用,但它对CSS 2.1的支持更弱,而且官方早已停止维护,遇到新的HTML结构会越来越吃力。iText 7的pdfHTML模块则是官方持续维护的产品,对HTML5结构、内联CSS的解析都要好得多,对中文乱码问题的处理也更干净。
依赖引入方面,Maven坐标如下:
<dependency> <groupId>com.itextpdf</groupId> <artifactId>kernel</artifactId> <version>7.2.5</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>io</artifactId> <version>7.2.5</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>layout</artifactId> <version>7.2.5</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>pdfhtml</artifactId> <version>4.0.5</version> </dependency>这五个依赖是最小集合。pdfhtml内部会传递依赖一些工具包,比如html2pdf公共模块,不需要我们再额外声明。如果只是想跑通一个最小Demo,最终编译时也就这五个jar包加上commons-logging这类传递依赖而已,体积不算大。
接下来是Markdown解析器的选型。Java生态里有几个选择:commonmark-java、flexmark、pegdown、markdown4j。我在这里强烈建议用commonmark-java,原因有三个。第一,它的实现遵循CommonMark规范,是目前社区兼容性最好的Markdown方言,GitHub风格的表格和任务列表虽然不在CommonMark核心规范里,但可以通过扩展模块拿到支持。第二,它的API干净,解析AST再导出HTML只需要几行代码,没有那么多包袱。第三,新版对并发解析的支持不错,在高并发批量导出场景下性能稳定。
<dependency> <groupId>org.commonmark</groupId> <artifactId>commonmark</artifactId> <version>0.21.0</version> </dependency> <dependency> <groupId>org.commonmark</groupId> <artifactId>commonmark-ext-gfm-tables</artifactId> <version>0.21.0</version> </dependency> <dependency> <groupId>org.commonmark</groupId> <artifactId>commonmark-ext-gfm-strikethrough</artifactId> <version>0.21.0</version> </dependency> <dependency> <groupId>org.commonmark</groupId> <artifactId>commonmark-ext-autolink</artifactId> <version>0.21.0</version> </dependency>依赖确定之后,还建议再检查一下字体环境,这块内容我会在后文专门讲。现在先把 “Markdown到HTML” 这半场跑通。
3. Markdown到HTML:这半步藏着最多的定制空间
很多人以为“Markdown转HTML”就是个库一把梭的事情,实际上真正写起来会发现,解析器给了你标准的HTML,但PDF渲染对HTML的整洁度很挑剔。我把这段流程分成两步:第一步,用commonmark-java将Markdown解析为HTML字符串;第二步,用Jsoup对HTML做一次“净化和微整形”。
3.1 基础解析:commonmark-java的完整接入
核心代码不复杂,但有一个关键点是解析器构建。直接看代码:
import org.commonmark.Extension; import org.commonmark.ext.autolink.AutolinkExtension; import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import java.util.Arrays; import java.util.List; public class MarkdownToHtmlConverter { private static final List<Extension> EXTENSIONS = Arrays.asList( TablesExtension.create(), StrikethroughExtension.create(), AutolinkExtension.create() ); private final Parser parser; private final HtmlRenderer renderer; public MarkdownToHtmlConverter() { this.parser = Parser.builder() .extensions(EXTENSIONS) .build(); this.renderer = HtmlRenderer.builder() .extensions(EXTENSIONS) .escapeHtml(true) .build(); } public String convert(String markdown) { Node documentNode = parser.parse(markdown); return renderer.render(documentNode); } }这里有两个细节值得展开说。第一个是escapeHtml(true)。默认情况下,commonmark-java的HTML渲染器会原样保留Markdown里写的HTML标签,这在下游传给iText时可能出现样式冲突或安全问题。比如用户写了一个<div style="page-break-before: always;">,这个东西到底该不该出现在PDF里?我更倾向于让业务侧的Markdown保持纯粹,所以开启转义,让所有HTML标签都变成纯文本展示,这样PDF输出的样式完全由渲染模块统一控制。如果你的场景确实需要允许用户塞自定义HTML,那把这个参数改成false即可,但要做好XSS方面的心理准备。
第二个细节是表格扩展。表格是Markdown转PDF流程中容错率最低的元素,因为PDF渲染引擎对表格宽度、边框合并的支持差异极大。commonmark-java的TablesExtension会把Markdown表格转成<table>,这是一个好消息;坏消息是它默认不生成行内样式,表格边框、宽度这些最后都要靠CSS补充。我后文会讲怎么用CssStyleTag补齐,这里只需要确认解析出的<table>结构正确即可。
3.2 净优化:为什么解析完之后还要动一次HTML
我接手的文档里有相当一部分是运营同学直接从平台里“复制粘贴”到Markdown源文件里的,里面残留了很多不必要的空段落、多余的空格和换行。这些内容在浏览器里看问题不大,但在PDF里会导致莫名其妙的大片空白。
所以我在解析器返回HTML字符串之后,会再用Jsoup做一次轻量清洗。只做三件事:去掉所有空<p>标签,把连续的空行折叠成一个;去掉HTML注释;如果存在<img>标签,检查图片路径是否可达,不可达的直接替换成占位文本。这一步不追求把HTML改得天翻地覆,只求它干净、稳定、不干扰后续的渲染。代码示例如下:
import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; public class HtmlCleaner { public static String clean(String rawHtml) { Document doc = Jsoup.parse(rawHtml); doc.select("p:empty").remove(); doc.select("comment").remove(); Elements images = doc.select("img[src]"); for (Element img : images) { String src = img.attr("src"); if (!src.startsWith("http") && !new java.io.File(src).exists()) { img.replaceWith(new Element("span").text("[图片缺失: " + src + "]")); } } return doc.body().html(); } }这里需要小心一点:Document对象本身会包含<html>和<head>骨架,所以不能直接doc.html()返回,否则会在后面插入到iText的HTML解析里产生奇怪的空字符。必须用doc.body().html()拿到body内部的HTML片段,然后由我们自己在最终HTML模板里拼装。
3.3 完整HTML模板的拼装“姿势”
拿到干净的HTML片段后,需要把它嵌入一个完整的HTML文档里。这一步不是简单拼接字符串,而是要给后续的PDF渲染留好定制接口。我的推荐做法是用一个Freemarker或者纯字符串模板,把CSS和HTML片段同时注入。
模板大概是这样的结构:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> body { font-family: "SourceHanSansCN", sans-serif; font-size: 11pt; line-height: 1.6; margin: 0; padding: 1.5em 1.8em; color: #24292e; } /* 这里会放针对表格、代码块、引用块的定制样式 */ </style> </head> <body> ${htmlFragment} </body> </html>注意几点。meta标签的charset要显式声明成UTF-8,因为iText在处理HTML到PDF转换时对编码非常敏感,缺少这个声明会直接导致中文乱码。body的margin和padding不要设置成0,否则PDF顶部文字会顶到页面边缘,打印感很差。字体方面,我在font-family里指定了思源黑体的名字,然后通过FontProvider注册实际的字体文件,具体做法在后文展开。样式放在<head>的<style>标签里,而不是依赖外部CSS文件,因为iText的pdfHTML默认不会去外部服务器拉取CSS资源,虽然它有资源Resolver的接口可以定制,但为了减少排查范围,直接内联样式是最可靠的。
4. iText渲染HTML到PDF:核心代码与字体不死之谜
搞定前半场之后,后半场就交给iText。这一节的代码其实很短,但每一个配置参数背后都有坑。
4.1 最小可运行的HTML转PDF代码
直接用iText 7 + pdfHTML的API,代码如下:
import com.itextpdf.html2pdf.ConverterProperties; import com.itextpdf.html2pdf.HtmlConverter; import com.itextpdf.io.font.FontProgram; import com.itextpdf.io.font.FontProgramFactory; import com.itextpdf.kernel.font.PdfFont; import com.itextpdf.kernel.font.PdfFontFactory; import com.itextpdf.kernel.geom.PageSize; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.layout.font.FontProvider; import com.itextpdf.layout.font.FontSet; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; public class HtmlToPdfConverter { private String fontPath; private FontProvider fontProvider; public HtmlToPdfConverter(String fontPath) throws IOException { this.fontPath = fontPath; this.fontProvider = new FontProvider(); // 注册中文字体 this.fontProvider.addFont(fontPath); // 设置默认字体族回退 this.fontProvider.setDefaultFontFamily("SourceHanSansCN"); } public byte[] convert(String htmlContent) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); PdfWriter writer = new PdfWriter(out); PdfDocument pdfDoc = new PdfDocument(writer); // 设置页面大小,这里使用A4 pdfDoc.setDefaultPageSize(PageSize.A4); ConverterProperties props = new ConverterProperties(); props.setFontProvider(fontProvider); props.setCharset("UTF-8"); try (InputStream in = new ByteArrayInputStream(htmlContent.getBytes(StandardCharsets.UTF_8))) { HtmlConverter.convertToPdf(in, pdfDoc, props); } finally { pdfDoc.close(); } return out.toByteArray(); } }这段代码看起来简单,但有一个核心问题比代码本身更重要,它对转换结果有决定性的影响,我单独拿出来讲。
4.2 字体:中文不乱码的唯一秘密
先说结论:iText的pdfHTML模块渲染HTML时,如果遇到字体表中没有覆盖的字符,默认行为是渲染成方块空白,不报错。这是绝大多数“HTML转PDF中文全变方块”问题的唯一根因。解决方案就是显式注册覆盖中文的字体,并且在CSS里指定以该字体为最终回退。
我使用思源黑体作为项目字体,原因有两个:真正开源、可商用,字体文件可以直接打包到项目里;字形覆盖范围大,尤其在文泉驿正黑、微软雅黑之外,它对于生僻字的覆盖也足够日常技术文档。字体文件路径在项目里要注意别用new File()的方式加载,因为打包成jar之后文件路径会失效,更稳的方式是从classpath读取为字节数组,再交给FontProgramFactory.createFont。示例:
private void loadFontFromClasspath() throws IOException { InputStream is = getClass().getResourceAsStream("/fonts/sourcehansanscn-regular.otf"); byte[] fontBytes = is.readAllBytes(); FontProgram fontProgram = FontProgramFactory.createFont(fontBytes); fontProvider.addFont(fontProgram); fontProvider.setDefaultFontFamily("SourceHanSansCN"); }同时,在HTML模板的CSS里必须指定font-family: "SourceHanSansCN", sans-serif;,这个字体名必须与字体程序内部的PostScript name或family name一致。检查字体内部名称的方式很简单:用系统自带字体查看器打开字体文件,看“字体名称”一栏,或者用FontForge导出name table。如果名字对不上,iText就会把这个字体当成无关字体丢弃,最终仍然回退到内置的无中文基础字体。
4.3 页面布局:页边距、页眉页脚的合理设置
如果是简单的一张A4纸,保持PageSize.A4默认页边距还能接受,但真实文档需求往往需要更精细的设置。pdfHTML的ConverterProperties本身不直接提供页边距配置,正确的做法是使用PdfDocument的自定义页面事件,在每个onEndPage阶段调整页面大小或绘制页眉页脚。
简单示例:
pdfDoc.addEventHandler(PdfDocumentEvent.END_PAGE, new PdfDocumentEvent() { @Override public void onEndPage(PdfDocumentEvent event) { PdfPage page = event.getPage(); PdfDocument doc = event.getDocument(); Rectangle pageSize = page.getPageSize(); PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc); canvas.beginText() .setFontAndSize(PdfFontFactory.createFont(), 8) .moveText(pageSize.getLeft() + 36, pageSize.getBottom() + 20) .showText("第 " + doc.getPageNumber(page) + " 页") .endText(); canvas.release(); } });这个事件处理方案有一个隐含前提:必须在HTMLConverter.convertToPdf调用之前挂好事件,否则END_PAGE事件不会触发。我在初版代码里就是把这个事件放在转换之后才添加,结果页面事件完全没生效,排查了半天才发现顺序问题。
如果你只需要简单的页脚,并且不追求复杂排版,用这个方案最省事。如果需要封面、目录、多章节的复杂排版,建议还是将整篇文档拆成多段HTML,分多次调用HtmlConverter写入同一个PdfDocument,事件机制依然有效。
5. CSS支持边界与表格、代码块的渲染调优
iText pdfHTML的CSS支持范围,基本上属于“够用,但要克制”的范畴。我在踩坑过程中总结了一张兼容性速查表,实测稳定可用的属性如下:
| 样式功能 | CSS属性 | 支持情况与备注 |
|---|---|---|
| 字体样式 | font-family, font-size, font-weight, font-style | 完全支持,注意字体名称映射 |
| 文本颜色 | color | 完全支持 |
| 行距 | line-height | 支持,推荐1.5~1.8 |
| 段落空白 | margin, padding | 支持,但嵌套多层时可能累积叠加 |
| 背景颜色 | background-color | 支持,只支持纯色 |
| 边框 | border, border-radius | 支持,border-radius在表格单元格里可能失效 |
| 列表 | list-style-type | 支持,但自定义图片样式不支持 |
| 表格布局 | width, border-collapse | 支持,border-collapse: collapse效果较稳 |
| 页面分页 | page-break-before / page-break-after | 支持,重点用于代码块和引用块前的换页 |
| Float | float | 支持不稳定,建议避免 |
| Flex / Grid | 所有属性 | 完全不支持,渲染会变成文档流顺序 |
| 伪元素 | ::before, ::after | 不支持 |
| 媒体查询 | @media | 不支持 |
核心结论是:在设计模板时,尽量用最朴素的块级元素布局,不要依赖Flex或Grid,否则pdfHTML会把元素当作普通块级流渲染,结果往往是内容堆叠得像一张乱码电报。
表格是个重点项。Markdown的表格经过commonmark-java转成<table>后,默认不带宽度和边框,直接渲染出来的PDF观感比较“素”。我通常在CSS里给表格补齐边框和宽度:
table { width: 100%; border-collapse: collapse; margin: 1em 0; } th, td { border: 1px solid #d0d7de; padding: 4px 8px; text-align: left; vertical-align: top; } th { background-color: #f6f8fa; font-weight: 600; }按我实际测试,border-collapse: collapse是最稳的表格方案,单独设置单元格边框间距会出现重叠黑边。另一个注意点是,pdfHTML对单元格内长文本的自动换行做得不错,但如果你在Markdown表格的单元格里塞了一个很长的URL,它默认不自动截断,会强行溢出到下一页或挤压整行,最好在CSS里给td补一条word-break: break-all;或overflow-wrap: break-word;。
代码块的处理也比较特殊。commonmark-java对代码块默认输出<pre><code class="language-java">,iText的pdfHTML默认情况下不会为<pre>提供等宽字体,这时候需要给pre和code显式指定等宽字体。环境里如果安装了Courier New,可以直接用;没有的话,我会在FontProvider里额外注册一份免费的JetBrains Mono字体,并在CSS里写:
pre, code { font-family: "JetBrains Mono", "Courier New", monospace; font-size: 9.5pt; } pre { background-color: #f6f8fa; padding: 10px 12px; border-radius: 4px; overflow-x: hidden; }注意,这里overflow-x实际上不生效,因为pdfHTML不支持横向滚动,它会直接把内容换行。所以如果你的代码行非常长,需要提前做好换行处理,或者在Markdown侧就主动控制代码行宽。这是我在实际导出某一本操作手册时踩到的最大坑:一段120字符长的SQL,渲染到PDF后被截断,读者根本看不到后半段。
6. 批量导出时的性能与稳定性经验
需求往往是“一次导出上百个Markdown”。最初我是在循环里创建了新的PdfWriter和PdfDocument,跑几十份没问题,但跑到两百份左右时内存几乎被吃满,GC频繁。后来我做了两个优化。
第一个优化是复用FontProvider。也就是说,字体加载是一次性的,不要放在convert方法内部,因为每次FontProvider.addFont()都会重新解析字体文件,开销极大。我把FontProvider作为成员变量,或者放进一个单例服务里,多个转换任务共用。
第二个优化是控制PdfDocument的生命周期。如果循环里每份文档都新建PdfDocument并close,这本身没有问题,但要注意HtmlConverter.convertToPdf的底层实现默认会创建一个WorkerContext,这个上下文内部持有一些缓存对象。我将循环内每次转换后的ByteArrayOutputStream及时释放,并显式调用System.gc()是不可取的,更好的做法是观察线程栈,判断是否因HtmlConverter并发实例化导致线程切换开销过大。
一个更实用的经验是,将大批量任务改成分批处理,每批生成一个临时文件放在磁盘上,全部完成后统一合并或者逐个返回。这样做的原因在于,iText的PdfDocument在close之后会触发大量对象释放,如果循环里同时保留几十个PdfDocument引用,GC压力很大。分批遍历,每批只保留一个文档引用,内存占用量会稳定很多。
我给出一个简化的批量接口设计参考:
public List<byte[]> batchConvert(List<String> markdownList, MarkdownToHtmlConverter mdConverter, HtmlToPdfConverter pdfConverter) { List<byte[]> result = new ArrayList<>(); for (int i = 0; i < markdownList.size(); i++) { String markdown = markdownList.get(i); String html = mdConverter.convert(markdown); html = HtmlCleaner.clean(html); html = wrapIntoTemplate(html); byte[] pdfBytes = pdfConverter.convert(html); result.add(pdfBytes); // 每50份休眠50ms,避免CPU过热或频繁GC if ((i + 1) % 50 == 0) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } return result; }真实场景里,如果对性能要求更高,可以考虑并行化。我测试过ExecutorService+Callable并行转换,效果不错,但需要注意,此时MdToHtmlConverter和HtmlCleaner必须是无状态单例,HtmlToPdfConverter的FontProvider线程安全,因为它们内部只做只读操作。如果中间引入了共享可变对象,不建议并行。
7. 排查链路实录:中文乱码、样式丢失、长文本截断
最后这部分记录我在调试过程中遇到过的三类高频问题,每个问题都是从一个错误现象出发,一步步排查到根因的完整链路,这对你后续使用会有直接帮助。
7.1 中文变成“方块字”的完整排查
第一次跑出PDF时,正文里的中文全部变成矩形空格,英文和数字正常。这个现象其实已经把问题范围缩小到字体覆盖,而不是解析层面。我的排查链路是:
- 先检查
ConverterProperties是否设置了正确的charset。如果没有,中文会变成问号或者乱码,而不是方块。这里现象是方块,所以排除charset问题。 - 检查
FontProvider是否添加了中文字体。如果没添加,字体回退会落到默认字体,必然缺字。但这一步我已经添加了思源黑体,按理说不该有问题。 - 继续查CSS里
font-family写的名字和FontProgram里的族名是否一致。我用系统字体查看器打开思源黑体字体文件,发现它的族名是“Source Han Sans CN”,不是“SourceHanSansCN”,而我在CSS里写的却是后者。两边不一致,iText自然匹配不上。
修正字体名之后,中文显示就正常了。这个坑很隐蔽,因为字体族名有时候带空格,有时候没有,拷贝时很容易丢。
7.2 样式完全丢失,却连报错都没有
另一类棘手问题是HTML里的加粗、标题、表格边框全部丢失,整个PDF变成一坨没有层次感的纯文本。我第一次遇到时,以为是CSS写法问题,加了各种前缀都没用。后来一步步排查才发现,问题出在HTML模板的拼装:我是用String.format()往模板里填HTML片段,但片段里有大量%字符,比如CSS里的百分比宽度,直接导致String.format抛异常或者把后面的参数吞掉。
正确做法是在拼HTML模板时使用String.replace()或Freemarker这类模板引擎,不要用String.format去拼接包含CSS的内容。否则一个小小的%就会让样式系统彻底亚健康。遇到类似现象,第一件事不是去查CSS写法,而是看拼装之后的完整HTML输出是否和预期一致。
7.3 长URL与长代码行的硬截断
上文已经提到代码块的overflow-x不生效。实际上不止代码块,任何无空格的长字符串在pdfHTML中都会拒绝自动换行。排查过程是这样的:某份文档的表格里有一个很长的API地址,PDF输出里那一行直接向右溢出页面边界,后面的列被挤到了页面外。我用Chrome预览同样HTML是正常的,因为浏览器默认会自动截断长URL。
解决方案是在CSS里设置word-break: break-all;,同时对pre和td都加上这个规则。这个规则对中文影响不大,因为中文字符本身可以逐字换行;它主要对连续的英文字母和数字有效。加上之后长URL和长代码都能正常折行,版面的完整度有了质的提升。
7.4 分页踩踏:标题和正文被硬生生切开
批量导出操作手册时,我发现一个标题正好落在页面底部,标题下方的内容却跑到了下一页,观感很糟。原因是page-break-after: avoid这个属性在pdfHTML里对标题块的支持不稳定。
我的处理方式是:对所有h1、h2、h3统一添加page-break-before: auto; page-break-after: avoid;,同时对代码块和表格包裹一层带page-break-inside: avoid的div。实际测试下来,page-break-inside: avoid对表格的兼容性比标题块的page-break-after: avoid更稳定,最终效果是表格不再跨页断裂,阅读体验好很多。
h1, h2, h3 { page-break-after: avoid; } pre, table { page-break-inside: avoid; }这套组合实测下来基本能达到“非必要不跨页”的效果。如果想再细调,还可以给每个大章节前的h1加上page-break-before: always,强制从新页开始。
8. 收尾:几个值得长期保留的实战习惯
项目走到这里,整条链路已经稳定跑了一段时间,我把自己沉淀下来的习惯写在这里。
第一,永远保留一份“脏HTML”检查工具。我在项目里留了一个简单的Servlet接口,接收任意Markdown,返回拼接后的HTML预览,这样测试新样式时不用每次跑完整PDF流程,先在浏览器里确认样式无大碍,再走iText转换。这个接口省了大量调试时间。
第二,把字体文件打进jar包,而不是依赖服务器系统字体。大多数Linux服务器默认是没有中文字体的,如果只在本机开发时把字体放到某个绝对路径,部署到生产环境必然翻车。一直用classpath方式读取字体文件,配合FontProgramFactory.createFont(byte[]),这是最稳的组合。
第三,对Markdown源文件做“健康检查”。在转换前用一个正则或者简单规则过滤掉空文档、超大代码块和异常表格,避免iText因为异常结构生成错误PDF时我们难以后续排错。这听起来不复杂,但真实运营环境里用户输入的无规律程度远超你的想象。
第四,给整个转换任务加上超时和降级策略。比如单份文档超过30秒就记录日志并跳过,而不是让整个批量任务卡死在某个诡异的HTML结构上。iText面对某些畸形HTML时,偶尔会出现解析器重试导致的卡顿,超时策略能有效保护整体流程。
这条链路从最初的手工复制粘贴,到现在的全自动批量导出,代码量不到三百行,却解决了每天几百分文档的导出需求。希望这篇文章能让你少走几个我踩过的坑,尤其在字体、CSS兼容性和排错思路上,直接把这些经验用到你自己的项目里去。