最近在做一个管理系统,遇到一个很典型的需求:后端用Java生成PDF报表,然后直接返回给前端,让用户在浏览器里预览或者下载。这个需求在项目里太常见了——订单导出、合同生成、数据报表、发票打印,基本每个后台系统都会碰到。我做完之后整理了一下思路和踩过的坑,写出来给后来人参考,尤其是那些刚接触这块、不太清楚怎么把文件流正确回传给页面的朋友。先说一下这个需求的核心难点,其实不在于“用Java生成PDF”,Java生态里有大把库能画PDF,真正的坑在于三件事:第一,选哪个库最合适;第二,PDF里中文不显示或者乱码怎么办;第三,文件流返回去之后浏览器识别不了、下载不了或者文件名乱码怎么办。这三件事处理好了,整个功能就稳了。后面我会从头到尾把完整流程走一遍,包括依赖引入、代码实现、前端接收处理,以及我实测中遇到的各种问题。保证你看完能直接抄作业。1. 动手之前先把技术选型想清楚1.1 主流的Java PDF生成库有哪些Java生态里做PDF的方案确实不少,我先把我用过或者研究过的几个列出来,做一个横向对比,这样大家在选型的时候心里有底。首先是iText,这是最老牌、功能最全的PDF库,社区版用的是AGPL协议,如果你是在开源项目里用没问题,但如果是在公司内部商业项目里用,不买商业授权就有合规风险。不过它的功能确实强大,支持HTML转PDF、数字签名、表单填写,几乎所有需求都能覆盖。然后是OpenPDF,它其实是iText早期版本的fork,基于LGPL和MPL双协议,对商业项目友好很多,可以放心用在公司内部系统里。API的写法跟老版iText几乎一样,功能上也够用,支持基本的文字、表格、图片、页面设置。我这次就是用的它。还有Apache PDFBox,Apache基金会的开源项目,协议宽松,功能上更偏底层,API设计得很“硬核”——你要画一个文本,得自己计算坐标、控制换行,做简单文档没问题,做复杂模板报表就有点费劲了。另外还有一个方案是wkhtmltopdf或者飞书云文档之类的HTML转PDF服务,这类工具的优势是可以用HTML和CSS排版,做漂亮的可视化单据非常方便,但引入额外组件或服务,在小项目里反而增加了部署和维护成本。我整理了一个对比表格,方便大家直观参考:库名称开源协议上手难度中文支持适合场景iTextAGPL(商用需付费)中等需配置字体复杂表单、签名、合规文档OpenPDFLGPL/MPL中等需配置字体常见业务报表、导出功能Apache PDFBoxApache 2.0偏难需配置字体底层文档操作、文本提取wkhtmltopdfLGPL简单依赖系统字体HTML转PDF、可视化模板1.2 我为什么选OpenPDF这次我选OpenPDF,核心原因有三个。第一是协议上没有坑。我不想在公司项目里埋一个需要商业授权的雷,OpenPDF的LGPL协议对内部系统很友好,不需要交钱也不用担心法律风险。很多开发者在选型时只关注功能,忽略授权协议,这是个大隐患,等业务做大了再发现license问题,排查成本巨高。第二是API足够简洁。这个项目的需求是生成一个中规中矩的报表:标题、表格、几段说明文字、公司logo,OpenPDF用Document + PdfWriter + 各种Element(Paragraph、PdfPTable)就能轻松搞定,没必要引入太重的东西。第三是实际产出物可靠。我实测用它生成的PDF在浏览器、手机预览、桌面阅读器里显示都正常,打印也不会错位。做这类内部工具型需求,稳定压倒一切,不需要那些花哨的高级功能。当然,如果你明确要做大批量、高性能的PDF生成,建议去做线程池、并发控制等设计,这属于另一层的故事了,本次先不展开。2. 生成PDF的核心流程与原理拆解2.1 PDF生成其实就是一个“画布作画”的过程我觉得理解Java生成PDF最简单的方式,是把它类比成一个画图的过程。你在一个页面上画内容,先确定纸张大小(页面设置),然后用“画笔”(比如Paragraph、Table)往画布上绘制文字和表格,最后合上画布保存成文件或流。关键在于,PDF的坐标是以左下角为原点的,跟我们在网页里从左上角布局的习惯完全不同。这算是一个隐蔽的思维转换点,尤其你如果习惯了在HTML里用CSS写布局,刚上手PDF时很容易在坐标上翻车。举个例子:页码固定在页面底部,很多人上来就想算“底部坐标”,但因为PDF的原点在左下角,所以你需要基于页面高度减去一个底部边距来计算,而不是基于顶部往下偏移。这个思维一旦转过来,后面的操作就顺手多了。具体到OpenPDF,基本的三步流程是:创建Document对象,指定页面大小和边距创建PdfWriter实例,关联输出流(OutputStream,重点是这里可以接ByteArrayOutputStream)用document.open()、document.add()添加内容,最后document.close()注意,document.close()不是可选项,它负责把PdfWriter内部缓存的内容真正刷进输出流,并且在PDF文件末尾写入EOF标记。如果你忘了关闭Document,生成出来的文件大多是损坏的,打开就报错。2.2 动态数据填充的思路实际业务里,很少有PDF是纯静态写死的,绝大多数是从数据库查数据然后动态填充。比如订单导出,你要根据订单列表生成一个多行多列的表格;比如报表,你要根据时间段统计结果生成汇总数据。OpenPDF里面做动态填充,核心就是PdfPTable这个类。你要做的就三件事:设置表格列数,用new PdfPTable(列数)创建表格依次addCell添加单元格文字,它会自动按从左到右、从上到下的顺序排列处理表头、合并单元格、背景色等样式如果表格数据特别多,PdfPTable还支持跨页自动重复表头,方法是给表格设置setHeaderRows(1),这样翻页后表头会在下一页自动出现。这些小细节在实际项目里很能提升成品的专业度。另外,动态生成时如果涉及金额、日期这些字段,不要直接在Java代码里字符串拼接,建议统一格式化。比如金额保留两位小数并加千分位分隔符,日期用“yyyy年MM月dd日”还是“yyyy-MM-dd”要看最终用户的习惯,这属于产品细节,但直接影响观感。我在第一次做导出时偷懒没格式化,结果老板打开PDF看到一串“1234567.899999”,立刻让我返工。2.3 页面设置与样式控制页面大小这一块,OpenPDF支持A4、A5、LETTER等预设,也可以用new Rectangle(width, height)自定义。国内做业务单据,绝大多数场景A4就够了,用PageSize.A4是省事又稳妥的选择。边距设置注意一个点:左、右、上、下四个边距是Document构造函数的第三到第六个参数,顺序是document.setMargins(left, right, top, bottom),这个顺序看起来不大直观,我写的时候容易把top和left搞混。设置完边距后,文档内容默认会在这个区域内自动换行,表格宽度超出边距就会出问题,所以表格宽度尽量用百分比设置(setWidthPercentage(100)表示填满内容区),而不是写死绝对厘米数。字体样式方面,Paragraph里可以设置setFont、setSize、setBold、setSpacingBefore、setSpacingAfter等属性。行间距用setLeading来控制,多个段落之间的间距用SpacingBefore和SpacingAfter调整,效果比在每个段落末尾多加一个空行要干净得多。3. 核心环节:把PDF流正确送回前端3.1 响应头设置是关键我见过很多朋友踩过同一个坑:PDF生成后塞进流里返回给前端,但浏览器要么直接下载成乱码文件,要么预览时提示文件损坏。绝大多数问题出在HTTP响应头设置不对。返回流给浏览器时,有两个Headers是必须重点关注的。第一个是Content-Type,必须设置为application/pdf,这是告诉浏览器“这包数据是PDF”。如果你不设置这个,有些浏览器会把流当成text/html处理,预览效果就乱了。第二个是Content-Disposition,它决定浏览器是直接展示(inline)还是下载(attachment)。一般有两个选择:如果你想在浏览器里以内置预览器打开PDF,就用Content-Disposition: inline;如果你想强制保存到本地,就用attachment,还可以通过filename指定默认文件名。这里面有个很坑的细节:filename如果直接写中文,在Chrome和Firefox下大概率会出现文件名乱码或者被截断,因为HTTP响应头默认编码不直接支持UTF-8中文。标准做法是对文件名做URLEncoder.encode处理,把中文转成UTF-8的百分号编码,再拼到filename=后面。这样下载时浏览器就能正确解码显示。3.2 输出流的写法选择Java后台把PDF返回给前端,一般有两种常见写法:直接往HttpServletResponse的输出流里写,或者用Spring MVC的ResponseEntity包装后返回。第一种写法适合Servlet风格或者简单Controller,在方法参数里直接注入HttpServletResponse对象,设置好上面说的响应头,然后把生成的PDF字节写入response.getOutputStream()。这种方式代码直观,也不需要额外的返回类型,但有一个局限:不方便在返回后做紧凑的二次处理(比如同时记录日志、统计下载次数)。第二种写法是用ResponseEntity<byte[]>,配合ContentType和HttpHeaders构造完整的HTTP返回体。这种方式思路更“Spring MVC化”,返回结构清晰,测试起来也方便(可以直接用MockMvc断言返回头),我建议在项目代码风格统一的情况下选这种。不过这里有一个性能相关的问题:如果PDF文件特别大(几十MB甚至更大),我们用byte[]把整个文件读进内存再返回,会占用大量JVM堆内存,影响系统整体稳定性。这种场景建议回调函数分块写流,或者把PDF先落盘再通过文件流接力返回。但一般内部系统导出的PDF几MB以内,byte[]完全够用,不需要过度设计。3.3 为什么用ByteArrayOutputStream而不是FileOutputStream我在实现时特意用ByteArrayOutputStream作为PDF输出的目标,而不是把PDF临时写到磁盘上一个文件再读回来。这样做有几个好处。第一个好处是快。PDF内容在内存里拼完直接转为字节数组返回给前端,少了一次磁盘写和一次磁盘读的I/O开销。系统并发量高的时候,这个差距会被放大。第二个好处是清理零成本。如果先落盘,你得处理临时文件的删除——什么时候删、谁来删、程序崩溃了残留文件怎么办,都是麻烦事。用内存流就没这些烦恼,方法执行完GC自己回收。第三个好处是逻辑闭环清晰。Controller返回byte[]的时候,PDF内容已经完整地存在内存里了,后续无论是下载、预览还是加密,都只需要操作这一个字节数组,流程好理解。当然,ByteArrayOutputStream也有缺点,就是大文件会撑内存。所以我上面的建议是:小报表用内存流,超大导出换成磁盘中转,分场景选择。4. 实战代码与步骤详解4.1 Maven依赖引入我用的Spring Boot版本是2.7.x,JDK是1.8,所以依赖上要选和JDK8兼容的版本。OpenPDF我用的是1.3.30,这个版本比较稳定,API也挺完整。在pom.xml里加这一段就可以:<dependency> <groupId>com.github.librepdf</groupId> <artifactId>openpdf</artifactId> <version>1.3.30</version> </dependency>顺便说一句,如果你用的不是Maven而是Gradle,对应的依赖写法是下面这样,其他内容都相同:implementation 'com.github.librepdf:openpdf:1.3.30'4.2 Service层的PDF生成逻辑先说Service层。我把PDF的生成封装成一个独立方法,返回byte[],这样Controller层只用关心HTTP响应,职责分离。下面是我实际项目里的代码结构,我简化为一个订单导出场景。我们模拟一个场景:根据订单记录生成一个PDF,内容包含公司名称、订单编号、下单时间、商品明细表格、合计金额,然后在文档末尾加上一行温馨提示。这种结构基本覆盖了日常工作用到的所有元素。import com.lowagie.text.Document; import com.lowagie.text.Element; import com.lowagie.text.Font; import com.lowagie.text.PageSize; import com.lowagie.text.Paragraph; import com.lowagie.text.Phrase; import com.lowagie.text.pdf.BaseFont; import com.lowagie.text.pdf.PdfPCell; import com.lowagie.text.pdf.PdfPTable; import com.lowagie.text.pdf.PdfWriter; import java.io.ByteArrayOutputStream; import java.util.List; public class PdfService { // 生成PDF并返回字节数组 public byte[] generateOrderPdf(OrderDto order) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { // 1. 创建A4纸张的文档 Document document = new Document(PageSize.A4); PdfWriter.getInstance(document, baos); // 2. 打开文档 document.open(); // 3. 中文字体设置(关键点,后面详细讲) BaseFont baseFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font titleFont = new Font(baseFont, 18, Font.BOLD); Font normalFont = new Font(baseFont, 10, Font.NORMAL); // 4. 添加标题 Paragraph title = new Paragraph("订单详情", titleFont); title.setAlignment(Element.ALIGN_CENTER); document.add(title); // 5. 添加基本信息段落 document.add(new Paragraph("订单编号:" + order.getOrderNo(), normalFont)); document.add(new Paragraph("下单时间:" + order.getCreateTime(), normalFont)); // 6. 创建商品明细表 PdfPTable table = new PdfPTable(4); table.setWidthPercentage(100); table.setSpacingBefore(10f); table.setSpacingAfter(10f); // 设置各列宽度比例(相对值) table.setWidths(new float[] {2, 4, 2, 2}); // 表头 String[] headers = {"商品ID", "商品名称", "单价", "数量"}; for (String header : headers) { PdfPCell headerCell = new PdfPCell(new Phrase(header, normalFont)); headerCell.setBackgroundColor(new java.awt.Color(240, 240, 240)); headerCell.setHorizontalAlignment(Element.ALIGN_CENTER); table.addCell(headerCell); } // 明细数据 for (ProductItemDto item : order.getProductList()) { table.addCell(new PdfPCell(new Phrase(item.getProductId(), normalFont))); table.addCell(new PdfPCell(new Phrase(item.getProductName(), normalFont))); table.addCell(new PdfPCell(new Phrase(String.valueOf(item.getPrice()), normalFont))); table.addCell(new PdfPCell(new Phrase(String.valueOf(item.getQuantity()), normalFont))); } document.add(table); // 7. 合计金额 Paragraph total = new Paragraph("合计金额:" + order.getTotalAmount() + " 元", new Font(baseFont, 12, Font.BOLD)); total.setAlignment(Element.ALIGN_RIGHT); document.add(total); // 8. 关闭文档(必须!) document.close(); } catch (Exception e) { throw new RuntimeException("PDF生成失败", e); } return baos.toByteArray(); } }自己看这个代码,几个细节我觉得值得强调。第一个是new PdfPCell(new Phrase(..., normalFont))这种写法,很多人会直接table.addCell("字符串"),如果字符串里面带样式或者需要指定字体,addCell(String)就做不到了。我在代码里统一用PdfPCell + Phrase的方式,灵活度更高。第二个是table.setWidths(new float[] {2, 4, 2, 2})这个数组,它表示各列宽度的相对比例,总和不必是100,只要比例关系正确即可。比如2:4:2:2表示第二列最宽,适合商品名称这种长文本。第三个是标题Paragraph的对齐方式用的是Element.ALIGN_CENTER,不要用PdfPCell里的setHorizontalAlignment,概念不一样。段落和单元格是两种不同的容器,设置对齐的API也不同。4.3 Controller层的返回流实现接下来是Controller层。我用ResponseEntity<byte[]>的方式,完整实现如下:import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.net.URLEncoder; @RestController @RequestMapping("/api/order") public class OrderController { private final PdfService pdfService; public OrderController(PdfService pdfService) { this.pdfService = pdfService; } @GetMapping("/export") public ResponseEntity<byte[]> exportOrderPdf(@RequestParam String orderId) { // 这里走订单查询逻辑 OrderDto order = queryOrderById(orderId); byte[] pdfBytes = pdfService.generateOrderPdf(order); // 设置响应头 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); // 文件名做URL编码,避免中文乱码 String fileName = URLEncoder.encode("订单_" + order.getOrderNo() + ".pdf", "UTF-8"); headers.setContentDispositionFormData("attachment", fileName); return new ResponseEntity<>(pdfBytes, headers, HttpStatus.OK); } }这里有个小坑需要注意:headers.setContentDispositionFormData("attachment", fileName)这个方法,它对filename值做了处理,但在某些Spring版本下,传入的fileName需要已经编码或者不编码。我实测下来,先URLEncoder.encode再传入,在Chrome、Edge和Safari下文件名都正常;如果你不编码,Chrome会乱码。如果用的是老版本Spring,也可以手动拼Content-Disposition字符串,即:headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"");这种方式更底层,但要注意引号转义,文件名里如果含逗号或分号,最好也一并处理。我建议优先用setContentDispositionFormData,如果遇到问题再手动拼字符串。4.4 前端如何接收和落地前端这块,后端返回的是二进制流,如果用普通的axios JSON请求去拿,会拿不到正确的结果。Axios拿到这个响应后,ResponseType要设成arraybuffer或blob,然后根据响应头里的Content-Disposition提取文件名,再手动触发浏览器下载。我用一个简单的Vue + axios示例做演示:<template> <button @click="handleExport">导出订单PDF</button> </template> <script setup> import axios from 'axios'; function handleExport() { axios.get('/api/order/export', { params: { orderId: 'abc123' }, responseType: 'blob' }).then(response => { // 从响应头里取文件名,注意取不到就兜底 const disposition = response.headers['content-disposition']; let filename = 'order.pdf'; if (disposition && disposition.includes('filename=')) { const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/); if (match && (match[1] || match[2])) { filename = decodeURIComponent(match[1] || match[2]); } } // 创建Blob对象,生成临时下载链接 const blob = new Blob([response.data], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); }); } </script>实际操作里,Content-Disposition头在跨域请求下有时候会被CORS策略挡住,拿不到文件名。这时候有两个兜底方案:一个是后端把文件名放到自定义响应头里(比如X-File-Name),并在CORS配置里把这个头暴露给前端;另一个是文件名由前端直接拼,比如“订单+当前时间戳+固定前缀”。我一般用第一种,因为自定义响应头处理起来很明确。4.5 中文字体的完整解法这一节我要专门讲中文问题,因为在PDF生成里,这是新手遇到频率最高的坑。OpenPDF默认不支持中文,因为它自带的字体集合里没有中文映射。如果你直接用new Font(Font.HELVETICA, 12)去写中文,生成的PDF里中文会变成一堆方框。网上很多老帖子告诉你用systemFont.ttc之类的方法,其实还有更简便的:OpenPDF内置了Asian字体支持,通过BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED)即可加载中文字体,不需要额外配置任何字体文件。这段代码在完整示例里已经出现过了,这是最省事的解法。这个方法本质是让PDF引用系统内嵌CID字体映射。UiGB-UCS2-H是Unicode到GB编码的映射方式,可以理解成“告诉PDF解释器,这个文本里的中文字符用哪种编码规则解析”。用这个方式生成的PDF文件体积小,因为字体没有被完全嵌入,只是在查看器端用系统字体渲染。但有一个反向问题:如果你在Linux服务器上部署,且服务器上没有安装中文字体,预览时可能有些环境还是显示不出中文。这时候你需要用系统字体文件,比如在CentOS上常见的是/usr/share/fonts/chinese目录下的某个ttc文件,你可以通过FontFactory去注册并加载。一般开发环境Windows能正常显示,生产Linux却乱码,十有八九就是这个问题。我的建议是:小项目先用STSong-Light方案,快速交付;如果发现特定Linux环境下显示异常,再切到FontFactory.register(字体文件名)方案。切换的成本不高,但能避免很多后续麻烦。5. 常见问题与排查技巧实录5.1 下载下来的PDF打开提示文件损坏这个问题排第一,因为它最让人崩溃,而且原因往往很简单。排查思路按顺序来:检查Document是否正常close。如果异常抛出时没有close,PdfWriter没有写完整,文件必然损坏。检查输出流是否被提前write或flush。有人习惯在PdfWriter.getInstance(document, baos)之后又往baos里写东西,这会把PDF的头部数据污染掉,导致损坏。检查是否在document.add()之后忘记让表格结束。如果表格行数不是列的整数倍,OpenPDF会自己补空单元格,有时候会造成页面布局混乱,进而让文件异常。我遇到过一次特别诡异的损坏:Controller里用ResponseEntity<byte[]>返回,但某些请求走了异常分支,在beforeBodyWrite里又对response的输出流做了write,结果把HTTP报文体写乱了。这类问题多留意Spring的增强逻辑就好。5.2 文件名中文乱码文件名乱码的根源在HTTP响应头是ISO-8859-1编码,你直接放入UTF-8中文,接收方解码必然出问题。解决方式就是我在前面代码里展示的:先用URLEncoder.encode转成百分号编码,放到Content-Disposition的filename*里。一个补充细节:用setContentDispositionFormData("attachment", fileName)时,Spring在较新版本里会自动处理UTF-8编码,自动使用filename*标准,因此有时不需要手动encode。但为了兼容老版本,我建议显式encode一次,反正encode后再set也不会产生问题。5.3 单元格内容换行PdfPTable的默认行为是单元格文字超长会横向撑开,不会自动换行。要让文本在单元格内换行,有几个做法:给PdfPCell设置最小高度,通过cell.setMinimumHeight()实现,但这对文本自动换行帮助不大。用Paragraph构造内容,Paragraph在cell里支持自动换行。new PdfPCell(new Phrase("很长的一段文本", font)) 在列宽固定时基本都能换行。给PdfPTable设置锁定宽度,即table.setLockedWidth(true),然后给每一列setFixedWidth。实际业务里,商品名称通常比较长,我的建议是在写入单元格前做一次字符串裁剪,超过20个字符加省略号,或者在打印时给名称列更宽的相对比例,两招配合效果最佳。5.4 页码不显示PDF文档经常要加页码,OpenPDF里没有直接“底部页码”这种高层API,你得自己写一个PdfPageEvent,在页面结束事件里绘制文字。代码大概是:public class PageNumberEventHandler extends PdfPageEventAdapter { @Override public void onEndPage(PdfWriter writer, Document document) { Rectangle rect = writer.getPageSize(); // 把文字画在页面底部中间位置 ColumnText.showTextAligned( writer.getDirectContentUnder(), Element.ALIGN_CENTER, new Phrase(String.valueOf(writer.getCurrentPageNumber())), (rect.getLeft() + rect.getRight()) / 2, rect.getBottom() + 18, 0 ); } }然后创建PdfWriter之后用writer.setPageEvent(new PageNumberEventHandler())注册进去。注意不要忘了页码本身走的是getDirectContentUnder(),它会把文字画在正文下面一层,不会和表格内容互相遮挡。5.5 大数据量导出内存溢出最后说一个性能问题。如果订单明细有好几万行,用ByteArrayOutputStream把所有内容同时放进内存,加上PDF内部的字体缓存、表格对象,很容易导致老年代堆内存飙升。我做过的处理方案是:分批查询数据,每次取5000条,循环add进表格。每处理完一批,将当前PdfPTable对象置空,降低短期对象存活时间。如果PDF实在太大(几十MB),就改为临时文件中转,先写到磁盘上,再用FileInputStream以流的方式输出到response。实测下来,几万行的PDF用前两个方案基本不会OOM。但要注意,即使表格可以分页,JVM的堆内存还是会被大量String对象占住,所以能分页查询就分页,不要一次性List加载全表。5.6 前后端联调时常见的三个低级失误联调过程中还有几个特别容易踩的坑,也值得记录一下:后端忘了设置Content-Type为application/pdf,前端下载下来的文件类型是text/plain,打开就是乱码。解决方案自然是对照响应头检查。前端没有设置responseType: 'blob'或arraybuffer,导致拿到的是乱码字符串,也无法生成有效Blob。后端在接口上加了网关或拦截器,拦截器对响应做了压缩或二次包装,导致Content-Disposition头被覆盖。排查时在浏览器开发者工具的Network面板里直接看响应头,一眼就能发现。6. 实际操作中的几点经验总结最后分享几条个人经验,算是在多次交付里沉淀出来的。第一,PDF生成服务尽量做成独立的工具类,不要和具体的业务Controller揉在一起。我一开始把生成逻辑放在Controller里,写起来确实快,但后面需求一变(比如同一份订单要同时导出PDF和Excel),代码就拆得很痛苦。现在我会把PDF生成放到单独的Service里,入参是一个DTO,出参是byte[],其他业务服务也能直接复用。第二,PDF模板的维护要提前考虑。如果是固定格式的合同、证明书,硬编码在代码里也能跑,但后期频繁调整格式会很烦。建议定义一套PDF模板DTO,把每个字段位置、字体样式用配置字段管理起来,改版时只改配置,不动代码逻辑。第三,接口尽量返回文件流而不是先保存文件再返回URL。我在一个项目里见过这种做法:PDF生成后存到服务器磁盘,然后把文件URL返回给前端。表面上看起来可行,但带来了几个新问题:磁盘文件清理不及时会堆积、文件URL暴露给用户可能导致越权访问、下载量大的时候磁盘I/O压力增大。直接返回流,轻量、安全、状态少,是绝大多数场景的首选。第四,日志和监控别忽略。PDF生成涉及IO和编码,一旦出问题很难定位。我的做法是在Service入口和出口打印关键参数,包括订单编号、PDF字节长度、耗时,异常时打印完整堆栈。这样线上反馈“导出失败”时,翻日志就能快速定位是查数慢还是生成PDF时IOException。这个功能本身不复杂,但很多细节点的处理确实要看经验。希望这篇内容能帮你少走一些弯路,遇到PDF相关需求能一次站稳。
最近在做一个管理系统,遇到一个很典型的需求:后端用Java生成PDF报表,然后直接返回给前端,让用户在浏览器里预览或者下载。这个需求在项目里太常见了——订单导出、合同生成、数据报表、发票打印,基本每个后台系统都会碰到。我做完之后整理了一下思路和踩过的坑,写出来给后来人参考,尤其是那些刚接触这块、不太清楚怎么把文件流正确回传给页面的朋友。先说一下这个需求的核心难点,其实不在于“用Java生成PDF”,Java生态里有大把库能画PDF,真正的坑在于三件事:第一,选哪个库最合适;第二,PDF里中文不显示或者乱码怎么办;第三,文件流返回去之后浏览器识别不了、下载不了或者文件名乱码怎么办。这三件事处理好了,整个功能就稳了。后面我会从头到尾把完整流程走一遍,包括依赖引入、代码实现、前端接收处理,以及我实测中遇到的各种问题。保证你看完能直接抄作业。1. 动手之前先把技术选型想清楚1.1 主流的Java PDF生成库有哪些Java生态里做PDF的方案确实不少,我先把我用过或者研究过的几个列出来,做一个横向对比,这样大家在选型的时候心里有底。首先是iText,这是最老牌、功能最全的PDF库,社区版用的是AGPL协议,如果你是在开源项目里用没问题,但如果是在公司内部商业项目里用,不买商业授权就有合规风险。不过它的功能确实强大,支持HTML转PDF、数字签名、表单填写,几乎所有需求都能覆盖。然后是OpenPDF,它其实是iText早期版本的fork,基于LGPL和MPL双协议,对商业项目友好很多,可以放心用在公司内部系统里。API的写法跟老版iText几乎一样,功能上也够用,支持基本的文字、表格、图片、页面设置。我这次就是用的它。还有Apache PDFBox,Apache基金会的开源项目,协议宽松,功能上更偏底层,API设计得很“硬核”——你要画一个文本,得自己计算坐标、控制换行,做简单文档没问题,做复杂模板报表就有点费劲了。另外还有一个方案是wkhtmltopdf或者飞书云文档之类的HTML转PDF服务,这类工具的优势是可以用HTML和CSS排版,做漂亮的可视化单据非常方便,但引入额外组件或服务,在小项目里反而增加了部署和维护成本。我整理了一个对比表格,方便大家直观参考:库名称开源协议上手难度中文支持适合场景iTextAGPL(商用需付费)中等需配置字体复杂表单、签名、合规文档OpenPDFLGPL/MPL中等需配置字体常见业务报表、导出功能Apache PDFBoxApache 2.0偏难需配置字体底层文档操作、文本提取wkhtmltopdfLGPL简单依赖系统字体HTML转PDF、可视化模板1.2 我为什么选OpenPDF这次我选OpenPDF,核心原因有三个。第一是协议上没有坑。我不想在公司项目里埋一个需要商业授权的雷,OpenPDF的LGPL协议对内部系统很友好,不需要交钱也不用担心法律风险。很多开发者在选型时只关注功能,忽略授权协议,这是个大隐患,等业务做大了再发现license问题,排查成本巨高。第二是API足够简洁。这个项目的需求是生成一个中规中矩的报表:标题、表格、几段说明文字、公司logo,OpenPDF用Document + PdfWriter + 各种Element(Paragraph、PdfPTable)就能轻松搞定,没必要引入太重的东西。第三是实际产出物可靠。我实测用它生成的PDF在浏览器、手机预览、桌面阅读器里显示都正常,打印也不会错位。做这类内部工具型需求,稳定压倒一切,不需要那些花哨的高级功能。当然,如果你明确要做大批量、高性能的PDF生成,建议去做线程池、并发控制等设计,这属于另一层的故事了,本次先不展开。2. 生成PDF的核心流程与原理拆解2.1 PDF生成其实就是一个“画布作画”的过程我觉得理解Java生成PDF最简单的方式,是把它类比成一个画图的过程。你在一个页面上画内容,先确定纸张大小(页面设置),然后用“画笔”(比如Paragraph、Table)往画布上绘制文字和表格,最后合上画布保存成文件或流。关键在于,PDF的坐标是以左下角为原点的,跟我们在网页里从左上角布局的习惯完全不同。这算是一个隐蔽的思维转换点,尤其你如果习惯了在HTML里用CSS写布局,刚上手PDF时很容易在坐标上翻车。举个例子:页码固定在页面底部,很多人上来就想算“底部坐标”,但因为PDF的原点在左下角,所以你需要基于页面高度减去一个底部边距来计算,而不是基于顶部往下偏移。这个思维一旦转过来,后面的操作就顺手多了。具体到OpenPDF,基本的三步流程是:创建Document对象,指定页面大小和边距创建PdfWriter实例,关联输出流(OutputStream,重点是这里可以接ByteArrayOutputStream)用document.open()、document.add()添加内容,最后document.close()注意,document.close()不是可选项,它负责把PdfWriter内部缓存的内容真正刷进输出流,并且在PDF文件末尾写入EOF标记。如果你忘了关闭Document,生成出来的文件大多是损坏的,打开就报错。2.2 动态数据填充的思路实际业务里,很少有PDF是纯静态写死的,绝大多数是从数据库查数据然后动态填充。比如订单导出,你要根据订单列表生成一个多行多列的表格;比如报表,你要根据时间段统计结果生成汇总数据。OpenPDF里面做动态填充,核心就是PdfPTable这个类。你要做的就三件事:设置表格列数,用new PdfPTable(列数)创建表格依次addCell添加单元格文字,它会自动按从左到右、从上到下的顺序排列处理表头、合并单元格、背景色等样式如果表格数据特别多,PdfPTable还支持跨页自动重复表头,方法是给表格设置setHeaderRows(1),这样翻页后表头会在下一页自动出现。这些小细节在实际项目里很能提升成品的专业度。另外,动态生成时如果涉及金额、日期这些字段,不要直接在Java代码里字符串拼接,建议统一格式化。比如金额保留两位小数并加千分位分隔符,日期用“yyyy年MM月dd日”还是“yyyy-MM-dd”要看最终用户的习惯,这属于产品细节,但直接影响观感。我在第一次做导出时偷懒没格式化,结果老板打开PDF看到一串“1234567.899999”,立刻让我返工。2.3 页面设置与样式控制页面大小这一块,OpenPDF支持A4、A5、LETTER等预设,也可以用new Rectangle(width, height)自定义。国内做业务单据,绝大多数场景A4就够了,用PageSize.A4是省事又稳妥的选择。边距设置注意一个点:左、右、上、下四个边距是Document构造函数的第三到第六个参数,顺序是document.setMargins(left, right, top, bottom),这个顺序看起来不大直观,我写的时候容易把top和left搞混。设置完边距后,文档内容默认会在这个区域内自动换行,表格宽度超出边距就会出问题,所以表格宽度尽量用百分比设置(setWidthPercentage(100)表示填满内容区),而不是写死绝对厘米数。字体样式方面,Paragraph里可以设置setFont、setSize、setBold、setSpacingBefore、setSpacingAfter等属性。行间距用setLeading来控制,多个段落之间的间距用SpacingBefore和SpacingAfter调整,效果比在每个段落末尾多加一个空行要干净得多。3. 核心环节:把PDF流正确送回前端3.1 响应头设置是关键我见过很多朋友踩过同一个坑:PDF生成后塞进流里返回给前端,但浏览器要么直接下载成乱码文件,要么预览时提示文件损坏。绝大多数问题出在HTTP响应头设置不对。返回流给浏览器时,有两个Headers是必须重点关注的。第一个是Content-Type,必须设置为application/pdf,这是告诉浏览器“这包数据是PDF”。如果你不设置这个,有些浏览器会把流当成text/html处理,预览效果就乱了。第二个是Content-Disposition,它决定浏览器是直接展示(inline)还是下载(attachment)。一般有两个选择:如果你想在浏览器里以内置预览器打开PDF,就用Content-Disposition: inline;如果你想强制保存到本地,就用attachment,还可以通过filename指定默认文件名。这里面有个很坑的细节:filename如果直接写中文,在Chrome和Firefox下大概率会出现文件名乱码或者被截断,因为HTTP响应头默认编码不直接支持UTF-8中文。标准做法是对文件名做URLEncoder.encode处理,把中文转成UTF-8的百分号编码,再拼到filename=后面。这样下载时浏览器就能正确解码显示。3.2 输出流的写法选择Java后台把PDF返回给前端,一般有两种常见写法:直接往HttpServletResponse的输出流里写,或者用Spring MVC的ResponseEntity包装后返回。第一种写法适合Servlet风格或者简单Controller,在方法参数里直接注入HttpServletResponse对象,设置好上面说的响应头,然后把生成的PDF字节写入response.getOutputStream()。这种方式代码直观,也不需要额外的返回类型,但有一个局限:不方便在返回后做紧凑的二次处理(比如同时记录日志、统计下载次数)。第二种写法是用ResponseEntity<byte[]>,配合ContentType和HttpHeaders构造完整的HTTP返回体。这种方式思路更“Spring MVC化”,返回结构清晰,测试起来也方便(可以直接用MockMvc断言返回头),我建议在项目代码风格统一的情况下选这种。不过这里有一个性能相关的问题:如果PDF文件特别大(几十MB甚至更大),我们用byte[]把整个文件读进内存再返回,会占用大量JVM堆内存,影响系统整体稳定性。这种场景建议回调函数分块写流,或者把PDF先落盘再通过文件流接力返回。但一般内部系统导出的PDF几MB以内,byte[]完全够用,不需要过度设计。3.3 为什么用ByteArrayOutputStream而不是FileOutputStream我在实现时特意用ByteArrayOutputStream作为PDF输出的目标,而不是把PDF临时写到磁盘上一个文件再读回来。这样做有几个好处。第一个好处是快。PDF内容在内存里拼完直接转为字节数组返回给前端,少了一次磁盘写和一次磁盘读的I/O开销。系统并发量高的时候,这个差距会被放大。第二个好处是清理零成本。如果先落盘,你得处理临时文件的删除——什么时候删、谁来删、程序崩溃了残留文件怎么办,都是麻烦事。用内存流就没这些烦恼,方法执行完GC自己回收。第三个好处是逻辑闭环清晰。Controller返回byte[]的时候,PDF内容已经完整地存在内存里了,后续无论是下载、预览还是加密,都只需要操作这一个字节数组,流程好理解。当然,ByteArrayOutputStream也有缺点,就是大文件会撑内存。所以我上面的建议是:小报表用内存流,超大导出换成磁盘中转,分场景选择。4. 实战代码与步骤详解4.1 Maven依赖引入我用的Spring Boot版本是2.7.x,JDK是1.8,所以依赖上要选和JDK8兼容的版本。OpenPDF我用的是1.3.30,这个版本比较稳定,API也挺完整。在pom.xml里加这一段就可以:<dependency> <groupId>com.github.librepdf</groupId> <artifactId>openpdf</artifactId> <version>1.3.30</version> </dependency>顺便说一句,如果你用的不是Maven而是Gradle,对应的依赖写法是下面这样,其他内容都相同:implementation 'com.github.librepdf:openpdf:1.3.30'4.2 Service层的PDF生成逻辑先说Service层。我把PDF的生成封装成一个独立方法,返回byte[],这样Controller层只用关心HTTP响应,职责分离。下面是我实际项目里的代码结构,我简化为一个订单导出场景。我们模拟一个场景:根据订单记录生成一个PDF,内容包含公司名称、订单编号、下单时间、商品明细表格、合计金额,然后在文档末尾加上一行温馨提示。这种结构基本覆盖了日常工作用到的所有元素。import com.lowagie.text.Document; import com.lowagie.text.Element; import com.lowagie.text.Font; import com.lowagie.text.PageSize; import com.lowagie.text.Paragraph; import com.lowagie.text.Phrase; import com.lowagie.text.pdf.BaseFont; import com.lowagie.text.pdf.PdfPCell; import com.lowagie.text.pdf.PdfPTable; import com.lowagie.text.pdf.PdfWriter; import java.io.ByteArrayOutputStream; import java.util.List; public class PdfService { // 生成PDF并返回字节数组 public byte[] generateOrderPdf(OrderDto order) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { // 1. 创建A4纸张的文档 Document document = new Document(PageSize.A4); PdfWriter.getInstance(document, baos); // 2. 打开文档 document.open(); // 3. 中文字体设置(关键点,后面详细讲) BaseFont baseFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font titleFont = new Font(baseFont, 18, Font.BOLD); Font normalFont = new Font(baseFont, 10, Font.NORMAL); // 4. 添加标题 Paragraph title = new Paragraph("订单详情", titleFont); title.setAlignment(Element.ALIGN_CENTER); document.add(title); // 5. 添加基本信息段落 document.add(new Paragraph("订单编号:" + order.getOrderNo(), normalFont)); document.add(new Paragraph("下单时间:" + order.getCreateTime(), normalFont)); // 6. 创建商品明细表 PdfPTable table = new PdfPTable(4); table.setWidthPercentage(100); table.setSpacingBefore(10f); table.setSpacingAfter(10f); // 设置各列宽度比例(相对值) table.setWidths(new float[] {2, 4, 2, 2}); // 表头 String[] headers = {"商品ID", "商品名称", "单价", "数量"}; for (String header : headers) { PdfPCell headerCell = new PdfPCell(new Phrase(header, normalFont)); headerCell.setBackgroundColor(new java.awt.Color(240, 240, 240)); headerCell.setHorizontalAlignment(Element.ALIGN_CENTER); table.addCell(headerCell); } // 明细数据 for (ProductItemDto item : order.getProductList()) { table.addCell(new PdfPCell(new Phrase(item.getProductId(), normalFont))); table.addCell(new PdfPCell(new Phrase(item.getProductName(), normalFont))); table.addCell(new PdfPCell(new Phrase(String.valueOf(item.getPrice()), normalFont))); table.addCell(new PdfPCell(new Phrase(String.valueOf(item.getQuantity()), normalFont))); } document.add(table); // 7. 合计金额 Paragraph total = new Paragraph("合计金额:" + order.getTotalAmount() + " 元", new Font(baseFont, 12, Font.BOLD)); total.setAlignment(Element.ALIGN_RIGHT); document.add(total); // 8. 关闭文档(必须!) document.close(); } catch (Exception e) { throw new RuntimeException("PDF生成失败", e); } return baos.toByteArray(); } }自己看这个代码,几个细节我觉得值得强调。第一个是new PdfPCell(new Phrase(..., normalFont))这种写法,很多人会直接table.addCell("字符串"),如果字符串里面带样式或者需要指定字体,addCell(String)就做不到了。我在代码里统一用PdfPCell + Phrase的方式,灵活度更高。第二个是table.setWidths(new float[] {2, 4, 2, 2})这个数组,它表示各列宽度的相对比例,总和不必是100,只要比例关系正确即可。比如2:4:2:2表示第二列最宽,适合商品名称这种长文本。第三个是标题Paragraph的对齐方式用的是Element.ALIGN_CENTER,不要用PdfPCell里的setHorizontalAlignment,概念不一样。段落和单元格是两种不同的容器,设置对齐的API也不同。4.3 Controller层的返回流实现接下来是Controller层。我用ResponseEntity<byte[]>的方式,完整实现如下:import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.net.URLEncoder; @RestController @RequestMapping("/api/order") public class OrderController { private final PdfService pdfService; public OrderController(PdfService pdfService) { this.pdfService = pdfService; } @GetMapping("/export") public ResponseEntity<byte[]> exportOrderPdf(@RequestParam String orderId) { // 这里走订单查询逻辑 OrderDto order = queryOrderById(orderId); byte[] pdfBytes = pdfService.generateOrderPdf(order); // 设置响应头 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); // 文件名做URL编码,避免中文乱码 String fileName = URLEncoder.encode("订单_" + order.getOrderNo() + ".pdf", "UTF-8"); headers.setContentDispositionFormData("attachment", fileName); return new ResponseEntity<>(pdfBytes, headers, HttpStatus.OK); } }这里有个小坑需要注意:headers.setContentDispositionFormData("attachment", fileName)这个方法,它对filename值做了处理,但在某些Spring版本下,传入的fileName需要已经编码或者不编码。我实测下来,先URLEncoder.encode再传入,在Chrome、Edge和Safari下文件名都正常;如果你不编码,Chrome会乱码。如果用的是老版本Spring,也可以手动拼Content-Disposition字符串,即:headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"");这种方式更底层,但要注意引号转义,文件名里如果含逗号或分号,最好也一并处理。我建议优先用setContentDispositionFormData,如果遇到问题再手动拼字符串。4.4 前端如何接收和落地前端这块,后端返回的是二进制流,如果用普通的axios JSON请求去拿,会拿不到正确的结果。Axios拿到这个响应后,ResponseType要设成arraybuffer或blob,然后根据响应头里的Content-Disposition提取文件名,再手动触发浏览器下载。我用一个简单的Vue + axios示例做演示:<template> <button @click="handleExport">导出订单PDF</button> </template> <script setup> import axios from 'axios'; function handleExport() { axios.get('/api/order/export', { params: { orderId: 'abc123' }, responseType: 'blob' }).then(response => { // 从响应头里取文件名,注意取不到就兜底 const disposition = response.headers['content-disposition']; let filename = 'order.pdf'; if (disposition && disposition.includes('filename=')) { const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/); if (match && (match[1] || match[2])) { filename = decodeURIComponent(match[1] || match[2]); } } // 创建Blob对象,生成临时下载链接 const blob = new Blob([response.data], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); }); } </script>实际操作里,Content-Disposition头在跨域请求下有时候会被CORS策略挡住,拿不到文件名。这时候有两个兜底方案:一个是后端把文件名放到自定义响应头里(比如X-File-Name),并在CORS配置里把这个头暴露给前端;另一个是文件名由前端直接拼,比如“订单+当前时间戳+固定前缀”。我一般用第一种,因为自定义响应头处理起来很明确。4.5 中文字体的完整解法这一节我要专门讲中文问题,因为在PDF生成里,这是新手遇到频率最高的坑。OpenPDF默认不支持中文,因为它自带的字体集合里没有中文映射。如果你直接用new Font(Font.HELVETICA, 12)去写中文,生成的PDF里中文会变成一堆方框。网上很多老帖子告诉你用systemFont.ttc之类的方法,其实还有更简便的:OpenPDF内置了Asian字体支持,通过BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED)即可加载中文字体,不需要额外配置任何字体文件。这段代码在完整示例里已经出现过了,这是最省事的解法。这个方法本质是让PDF引用系统内嵌CID字体映射。UiGB-UCS2-H是Unicode到GB编码的映射方式,可以理解成“告诉PDF解释器,这个文本里的中文字符用哪种编码规则解析”。用这个方式生成的PDF文件体积小,因为字体没有被完全嵌入,只是在查看器端用系统字体渲染。但有一个反向问题:如果你在Linux服务器上部署,且服务器上没有安装中文字体,预览时可能有些环境还是显示不出中文。这时候你需要用系统字体文件,比如在CentOS上常见的是/usr/share/fonts/chinese目录下的某个ttc文件,你可以通过FontFactory去注册并加载。一般开发环境Windows能正常显示,生产Linux却乱码,十有八九就是这个问题。我的建议是:小项目先用STSong-Light方案,快速交付;如果发现特定Linux环境下显示异常,再切到FontFactory.register(字体文件名)方案。切换的成本不高,但能避免很多后续麻烦。5. 常见问题与排查技巧实录5.1 下载下来的PDF打开提示文件损坏这个问题排第一,因为它最让人崩溃,而且原因往往很简单。排查思路按顺序来:检查Document是否正常close。如果异常抛出时没有close,PdfWriter没有写完整,文件必然损坏。检查输出流是否被提前write或flush。有人习惯在PdfWriter.getInstance(document, baos)之后又往baos里写东西,这会把PDF的头部数据污染掉,导致损坏。检查是否在document.add()之后忘记让表格结束。如果表格行数不是列的整数倍,OpenPDF会自己补空单元格,有时候会造成页面布局混乱,进而让文件异常。我遇到过一次特别诡异的损坏:Controller里用ResponseEntity<byte[]>返回,但某些请求走了异常分支,在beforeBodyWrite里又对response的输出流做了write,结果把HTTP报文体写乱了。这类问题多留意Spring的增强逻辑就好。5.2 文件名中文乱码文件名乱码的根源在HTTP响应头是ISO-8859-1编码,你直接放入UTF-8中文,接收方解码必然出问题。解决方式就是我在前面代码里展示的:先用URLEncoder.encode转成百分号编码,放到Content-Disposition的filename*里。一个补充细节:用setContentDispositionFormData("attachment", fileName)时,Spring在较新版本里会自动处理UTF-8编码,自动使用filename*标准,因此有时不需要手动encode。但为了兼容老版本,我建议显式encode一次,反正encode后再set也不会产生问题。5.3 单元格内容换行PdfPTable的默认行为是单元格文字超长会横向撑开,不会自动换行。要让文本在单元格内换行,有几个做法:给PdfPCell设置最小高度,通过cell.setMinimumHeight()实现,但这对文本自动换行帮助不大。用Paragraph构造内容,Paragraph在cell里支持自动换行。new PdfPCell(new Phrase("很长的一段文本", font)) 在列宽固定时基本都能换行。给PdfPTable设置锁定宽度,即table.setLockedWidth(true),然后给每一列setFixedWidth。实际业务里,商品名称通常比较长,我的建议是在写入单元格前做一次字符串裁剪,超过20个字符加省略号,或者在打印时给名称列更宽的相对比例,两招配合效果最佳。5.4 页码不显示PDF文档经常要加页码,OpenPDF里没有直接“底部页码”这种高层API,你得自己写一个PdfPageEvent,在页面结束事件里绘制文字。代码大概是:public class PageNumberEventHandler extends PdfPageEventAdapter { @Override public void onEndPage(PdfWriter writer, Document document) { Rectangle rect = writer.getPageSize(); // 把文字画在页面底部中间位置 ColumnText.showTextAligned( writer.getDirectContentUnder(), Element.ALIGN_CENTER, new Phrase(String.valueOf(writer.getCurrentPageNumber())), (rect.getLeft() + rect.getRight()) / 2, rect.getBottom() + 18, 0 ); } }然后创建PdfWriter之后用writer.setPageEvent(new PageNumberEventHandler())注册进去。注意不要忘了页码本身走的是getDirectContentUnder(),它会把文字画在正文下面一层,不会和表格内容互相遮挡。5.5 大数据量导出内存溢出最后说一个性能问题。如果订单明细有好几万行,用ByteArrayOutputStream把所有内容同时放进内存,加上PDF内部的字体缓存、表格对象,很容易导致老年代堆内存飙升。我做过的处理方案是:分批查询数据,每次取5000条,循环add进表格。每处理完一批,将当前PdfPTable对象置空,降低短期对象存活时间。如果PDF实在太大(几十MB),就改为临时文件中转,先写到磁盘上,再用FileInputStream以流的方式输出到response。实测下来,几万行的PDF用前两个方案基本不会OOM。但要注意,即使表格可以分页,JVM的堆内存还是会被大量String对象占住,所以能分页查询就分页,不要一次性List加载全表。5.6 前后端联调时常见的三个低级失误联调过程中还有几个特别容易踩的坑,也值得记录一下:后端忘了设置Content-Type为application/pdf,前端下载下来的文件类型是text/plain,打开就是乱码。解决方案自然是对照响应头检查。前端没有设置responseType: 'blob'或arraybuffer,导致拿到的是乱码字符串,也无法生成有效Blob。后端在接口上加了网关或拦截器,拦截器对响应做了压缩或二次包装,导致Content-Disposition头被覆盖。排查时在浏览器开发者工具的Network面板里直接看响应头,一眼就能发现。6. 实际操作中的几点经验总结最后分享几条个人经验,算是在多次交付里沉淀出来的。第一,PDF生成服务尽量做成独立的工具类,不要和具体的业务Controller揉在一起。我一开始把生成逻辑放在Controller里,写起来确实快,但后面需求一变(比如同一份订单要同时导出PDF和Excel),代码就拆得很痛苦。现在我会把PDF生成放到单独的Service里,入参是一个DTO,出参是byte[],其他业务服务也能直接复用。第二,PDF模板的维护要提前考虑。如果是固定格式的合同、证明书,硬编码在代码里也能跑,但后期频繁调整格式会很烦。建议定义一套PDF模板DTO,把每个字段位置、字体样式用配置字段管理起来,改版时只改配置,不动代码逻辑。第三,接口尽量返回文件流而不是先保存文件再返回URL。我在一个项目里见过这种做法:PDF生成后存到服务器磁盘,然后把文件URL返回给前端。表面上看起来可行,但带来了几个新问题:磁盘文件清理不及时会堆积、文件URL暴露给用户可能导致越权访问、下载量大的时候磁盘I/O压力增大。直接返回流,轻量、安全、状态少,是绝大多数场景的首选。第四,日志和监控别忽略。PDF生成涉及IO和编码,一旦出问题很难定位。我的做法是在Service入口和出口打印关键参数,包括订单编号、PDF字节长度、耗时,异常时打印完整堆栈。这样线上反馈“导出失败”时,翻日志就能快速定位是查数慢还是生成PDF时IOException。这个功能本身不复杂,但很多细节点的处理确实要看经验。希望这篇内容能帮你少走一些弯路,遇到PDF相关需求能一次站稳。