Java后端生成PDF并在前端打印的三种实现方案与避坑指南

 更新时间:2026年10月08日 09:08:47   作者:菩提风  
PDF文档是现代企业系统中数据留存与流转的重要载体,无论是合同报告还是发票对账,都离不开稳定的生成与打印链路,本文将手把手教你用Java和前端框架实现稳定可靠的PDF生成与打印功能,有需要的小伙伴可以参考下

先说结论:这个需求看着简单,点个按钮出PDF、点打印出纸,对吧?但真正做过一次你就会发现,后端生成PDF的方案选型、前端拿到文件之后的预览和打印兼容性,每一环都有让人挠头的坑。我做了几年全栈,前后帮团队接过不下十个"生成报告/合同/发票PDF"的需求,踩过的雷比看过的教程多。这篇就把一套比较稳的实现方案拆开讲透,适合正在搞前后端分离项目、后端是Java、前端是Vue或React这类框架的朋友参考,不管你是刚接到需求还是已经在联调阶段卡住了,应该都能找到对应的问题解法。

1. 先分清你要的是"PDF落档"还是"页面打印"

很多人一开口就是"后端生成PDF、前端打印",听起来需求很明确,可实际上这个描述掩盖了三种完全不同的需求,选错方向后面全白干。

1.1 我见到的三种需求变体

第一种是真正的"生成PDF文件":比如财务对账单、电子合同、检测报告,用户点了之后得到一个正式的PDF文档,可以下载、发邮件、归档留痕。这种场景核心诉求是 文件本身 ,打印只是文件交付后的附加动作。

第二种是"网页打印":比如订单详情页、工单、物流面单,用户就是想点一下打印,把当前页面以比较好的排版打到A4纸上。这种场景根本不关心PDF,你非要后端生成一份PDF再让浏览器去打印PDF,属于绕路。直接前端渲染HTML,然后调 window.print() ,反而是最可控的方案。

第三种是混合型:页面要一份PDF用于下载,同时也要能直接打印。这时候才需要后端生成PDF + 前端处理预览打印的完整链路。

1.2 需求边界决定技术路线

判断方法很简单,问产品经理或者需求方一句话: "这个PDF除了打印之外,会不会被下载存档、发邮件、传给别的系统?" 会,走PDF文件路线;不会,走HTML打印路线;都有,走混合路线。

我之所以把这一步放在最前面,是因为后面所有技术选型都建立在这个结论上。如果一开始就闷头选PDF生成库,完了才发现对方只是想点个打印,那不仅后端写了一堆样式拼接代码,前端打印时还得跟浏览器插件、PDF预览页斗智斗勇,纯属给自己加戏。反过来,如果对方要的是正式存档文件,你只给他一个打印页面,那文件、邮件场景全都覆盖不到。

2. 后端生成PDF的主流实现与选型对比

确定要产生PDF文件之后,后端怎么生成就成了第一个分岔路。我以Java技术栈为例,因为后端生态最成熟、遇到问题网上答案最多。常见的生成路线有三条:纯代码拼装、HTML模板渲染、XML转换,实际生产中主力和值得用的主要是前两条。

2.1 iText路线:适合固定位置报表

iText是Java里最有名的PDF库,它干活的方式是"用代码一行一行往PDF里画内容",加个段落、画条线、设置字体、控制坐标,所有排版都由代码控制。

举一个最简单的Spring Boot接口示例,用iText生成一份PDF字节流返回给前端:

@GetMapping(value = "/api/export/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> exportPdf() throws Exception {
    ByteArrayOutputStream out = new ByteArrayOutputStream();

    PdfWriter writer = new PdfWriter(out);
    PdfDocument pdf = new PdfDocument(writer);
    Document document = new Document(pdf);

    document.add(new Paragraph("订单报告").setFontSize(20).setBold());
    document.add(new Paragraph("订单号:" + orderNo));
    document.add(new Paragraph("客户:" + customerName));
    document.close();

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_PDF);
    headers.setContentDisposition(ContentDisposition.builder("inline")
            .filename("report.pdf", StandardCharsets.UTF_8).build());
    return new ResponseEntity<>(out.toByteArray(), headers, HttpStatus.OK);
}

这种方式的优点是完全可控、不依赖外部环境,生成速度快。缺点是布局复杂时代码极其痛苦:一个表格几列几行、每个单元格怎么对齐、边框多粗,全在代码里调,改一版样式等于返工一遍。另外中文字体需要额外注册,经常有人在Linux服务器上因为缺字体,PDF里中文全变方块。

一般我建议: 只有PDF内容是固定表单、数据字段少、版式简单的情况才用iText ,比如发票、回执、简单的确认单。

2.2 HTML模板 + 渲染引擎:Java生态的两种实际用法

如果PDF版式复杂,比如带封面、带图片、表格多,最舒服的办法是用写网页的方式设计模板,然后用渲染引擎把HTML转成PDF。这样前端和后端开发都能参与模板维护,改样式就是改HTML和CSS,效率高很多。

Java生态里两种常见做法:

第一种是Openhtmltopdf。纯Java实现,可以一边用Thymeleaf这类模板引擎渲染出HTML字符串,一边直接转PDF:

TemplateEngine engine = new TemplateEngine();
SpringTemplateEngine springEngine = new SpringTemplateEngine();
springEngine.setTemplateResolver(new ClassLoaderTemplateResolver());
Context context = new Context();
context.setVariable("customerName", customerName);
String html = springEngine.process("order-report.html", context);

PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.withHtmlContent(html, "file:///tmp/");
builder.toStream(out);
builder.run();

优缺点是明显的:省去外部进程调用,部署简单;但它对CSS支持有限, 不支持JavaScript、不支持Flex布局、不支持position: fixed 。意味着你用现代CSS写的模板,十个有八个在PDF里渲染不出来,需要退回用table或者float布局。第一次用的人通常会在这里被折腾到怀疑人生。

第二种是wkhtmltopdf。这是把HTML转PDF的老牌工具,它可以跟Thymeleaf配合:后端先渲染出HTML,然后调用系统命令,把HTML通过标准输入传给wkhtmltopdf:

String html = springEngine.process("order-report.html", context);
ProcessBuilder pb = new ProcessBuilder("wkhtmltopdf", "--enable-local-file-access", "-", "-");
Process process = pb.start();
OutputStream os = process.getOutputStream();
os.write(html.getBytes(StandardCharsets.UTF_8));
os.close();
byte[] pdfBytes = process.getInputStream().readAllBytes();
process.waitFor();

wkhtmltopdf对CSS支持比Openhtmltopdf好,但也基于老版本WebKit内核,同样不支持Flex、不支持Grid。部署服务器时需要在操作系统里装二进制包,Java只是它的调用者,跨平台部署时会多一个依赖项。

2.3 选型建议表:按场景直接抄

方案样式支持部署成本适合场景不建议场景
iText差,代码硬编码低简单固定表单、发票复杂版式、频繁改样式
Openhtmltopdf中,不支持JS/Flex低,纯Java合同、简单报表像素级还原页面
wkhtmltopdf较好,但不支持Flex中,需装二进制复杂表格、多页报告需要现代CSS布局的页面
Chromium headless截图最好高,需安装浏览器网页转PDF服务器资源受限的项目

还有一种很强力的方案是用Chromium无头浏览器渲染页面后直接打印PDF(很多第三方服务就是这么做的),技术上限最高,什么CSS都能认。但要在Java里集成、管理无头浏览器进程、控制好内存,对大部分业务团队来说过重了。我的经验是: 先搞清楚你的版式复杂度打几分,再决定要不要上重装备。 80%的后台管理系统的PDF需求,wkhtmltopdf + Thymeleaf就够了。

3. 前端拿到PDF后的几种打印姿势

后端接口把PDF吐出来了,前端接住是第一步,接住之后怎么让用户方便地预览和打印,是第二个重灾区。核心问题在于: 很多项目接PDF打印接口时带了登录鉴权,直接用 window.open(url) 是拿不到文件的 。

3.1 敏感接口怎么安全拿:Blob请求链路

如果你用Token放在请求头里做鉴权,就不能把PDF接口地址直接塞给 window.open 。正确姿势是用 fetch 或 axios 拿二进制流,然后在前端拼出一个本地URL。

axios写法:

const res = await axios.get('/api/export/pdf', {
  responseType: 'blob',
  headers: { Authorization: `Bearer ${token}` }
});
const blob = new Blob([res.data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);

这一步做完,你就拿到了一个类似 blob:http://localhost:8080/xxx 的地址,接下来所有预览和打印都围绕这个URL展开。

3.2 姿势A:window.open预览交给用户

最简单的交付方式,新窗口打开PDF,让用户自己在浏览器自带的PDF预览页里按打印按钮:

window.open(url, '_blank');

优点:代码量几乎为零,用户能在打印前先看到文档内容,浏览器原生预览器的操作大家都会。缺点:多一步手动操作,有些用户会卡在"我怎么点打印"上。另外如果你没设置 Content-Disposition: inline ,后端默认返回 attachment 头,浏览器会直接触发下载而不是预览,窗口打开的是个空白页。

3.3 姿势B:隐藏iframe自动打印

场景更常见的是"点一下打印按钮,直接弹出打印对话框"。这时候的通用玩法是隐藏iframe加载PDF,再调用 iframe 的打印方法:

function printPdf(url) {
  const iframe = document.createElement('iframe');
  iframe.style.position = 'fixed';
  iframe.style.right = '0';
  iframe.style.bottom = '0';
  iframe.style.width = '0';
  iframe.style.height = '0';
  iframe.style.border = 'none';
  iframe.style.visibility = 'hidden';
  document.body.appendChild(iframe);
  iframe.onload = () => {
    iframe.contentWindow.focus();
    iframe.contentWindow.print();
    // 清理:打印完过几秒移除iframe,避免页面残留隐藏帧
    setTimeout(() => {
      document.body.removeChild(iframe);
      URL.revokeObjectURL(url);
    }, 3000);
  };
  iframe.src = url;
}

这里有三个细节必须注意。第一, 不要用 display: none 隐藏iframe ,某些浏览器在隐藏帧里不执行打印,用固定定位+透明+移到屏幕外的方式实现"看不见但存在"。第二, iframe.onload 是打印的关键时机,PDF还没有加载完就调用print,弹出来的是空白。第三,打印完了一定要清理iframe和 URL.createObjectURL 生成的临时地址,不清理会占内存,尤其是报表每点一次就生成一个,页面久了会卡。

3.4 姿势C:pdf.js嵌入预览并触发打印

如果你希望用户在打印之前还能看到PDF里的内容,不管在应用内做文档预览,而不是交给浏览器原生的外层页面,那可以用pdf.js把PDF渲染到Canvas上,再在页面上放自定义的打印按钮。

pdf.js渲染核心代码量比较大,简单说思路:用 pdfjsLib.getDocument(url) 加载文档,逐页渲染到Canvas,打印按钮调用 window.print() 并配合 @media print 的CSS,打印时只显示当前预览区域、隐藏其他布局。

这个方案的好处是用户体验统一,打印和预览都在应用内完成。代价是你得自己处理分页显示、缩放、加载态这些交互,开发成本显著提高。如果不是产品硬性要求"在系统内预览PDF",我通常不推荐第一步就上pdf.js, window.open 或iframe打印对大多数后台系统已经足够实用。

4. 打印环节的隐藏坑:边距、分页、浏览器差异

当用户真正按下打印按钮那一刻,才是这场仗最难打的地方。浏览器打印PDF和打印HTML的规则不一样,踩坑点非常多。

4.1 打印PDF时浏览器的"页眉页脚"怎么处理

如果你走的是"预览PDF → 用户手动打印"这条路,Chrome会在打印预览里默认带上页眉页脚,包括文件标题、日期、URL地址,这些东西出现在正式合同或报告里相当难看。问题是这些选项只有用户在打印预览弹窗里手动取消,前端代码没法直接控制Chrome的PDF打印参数。

这算是浏览器出于安全限制给出的答案: 页面不能强制决定用户打印出来的PDF带不带页眉 。所以如果公司比较在意这件事,基本就两条路:要么在用户点打印之前弹一个对话框说明,写清楚"请取消打印设置里的页眉页脚";要么就直接避开"打印PDF文件",走"打印HTML页面"路线,用CSS完全控制输出效果。

4.2 如果绕开PDF直接打印HTML,控制在CSS

前面说了,纯打印需求直接打印HTML是更好的选择。因为浏览器打印HTML时,你是可以通过CSS控制一切的:

@page {
  size: A4;
  margin: 12mm 10mm;
}
@media print {
  body {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  .no-print {
    display: none !important;
  }
  .print-area {
    display: block;
    width: 100%;
  }
}

@page 控制纸张大小和页边距; print-color-adjust: exact 确保背景色、表格底色能打印出来,默认情况下浏览器为了省墨会把背景色删掉,不加这段你精心设计的灰色表头打印出来全是白底,这个问题我第一次做的时候着实懵了很久。

还有个常被忽略的点: @page 里的 margin 和打印对话框里的边距设置会叠加。你在CSS里设了10mm,用户打印时又在对话框里选了默认边距,两边一加,内容就被挤到角落里去了。实测建议是: CSS控制为主,并把打印对话框的边距明细通过前端说明或默认参数尽量引导,两边别同时设边距 。

4.3 分页和表格重复表头的处理

HTML打印里最典型的需求是长表格跨页。默认情况下,表格跨页后第二页是没有表头的,读者不知道那一列数据是什么意思。CSS提供了可控手段:

thead {
  display: table-header-group;
}
tr {
  page-break-inside: avoid;
}

display: table-header-group 是让表格头部在每一页重复显示的关键属性。 page-break-inside: avoid 避免一个数据行被拦腰截断到两页上。这两个属性是老浏览器也能照顾到的,别放了可惜。

如果页面本身是多页的,手动分页规则也要心里有数:

.page-break {
  page-break-before: always;
}

后台管理系统打印工作任务单、报表这类内容,用这个类名给每个部分单独开头新页,比让浏览器自己切页更可控。切页断在标题中间这种事,交给浏览器自动处理,十次有八次切得不是你想要的位置。

5. 联调常见的三个"隐藏关卡"

接口通了、PDF能出来、打印对话框能弹,你以为完了?下面这几个问题是联调阶段才集中爆发的,提前排查能少加好多班。

5.1 跨域不是主要问题,鉴权才是

前后端分离项目后端接口和前端静态服务器域名不同,跨域是常态。如果你用的是Blob链路,PDF是通过 fetch 请求拿回来的,只要后端CORS配置放行了这个接口, blob: 本地URL天然不存在跨域限制,所以放行CORS就能正常打印。

真正麻烦的是如果你图省事,想用 window.open('/api/export/pdf') 直接打开,那请求头里带不了Token,只能靠后端识别Session或者Cookie。很多纯API项目Session是分离的,这么干基本拿不到PDF。要么后端额外提供一次性短链接,要么就老老实实走Blob。别问我怎么知道的,我第一次就是直接被鉴权拦在打印门外,排查了半天还以为是跨域问题。

5.2 后端生成慢怎么办

生成PDF不是所有时候都快。用HTML模板渲染的方式,如果模板大、数据多、还需要加载远程图片,接口响应时间可能飙到5秒甚至10秒,前端体验就是按钮点了没反应,用户能急哭。

追求稳定体验的做法是前端给出明确loading态,比如axios请求时按钮进入loading、文案显示"正在生成文档,请勿关闭"。axios可以设置超时,但注意别把超时设置得过短,我曾经设成5秒然后被测试妹妹投诉,后来统一放在20秒。

后端如果在极端情况下确实要生成很久(比如几百页的报表),那就别做同步接口了,改成异步:提交生成任务返回任务ID,前端轮询查进度,生成完成后下载。这个改造不复杂但很见效,本人见过的最能让你避免深夜改代码的方案就是这个。如果你预估接口最多两三秒,同步够用,别过度设计。

5.3 文件名中文乱码的处理

还有一个低调的坑:下载PDF时文件名如果是中文,设置 Content-Disposition 头要处理UTF-8编码,否则浏览器保存时的默认文件名会变成乱码。

后端设置时用 ContentDisposition.builder("inline").filename("月度订单报告.pdf", StandardCharsets.UTF_8).build() ,或者手动拼:

String fileName = URLEncoder.encode("月度订单报告.pdf", StandardCharsets.UTF_8.toString());
headers.set("Content-Disposition", "inline; filename*=UTF-8''" + fileName);

filename* 加上编码参数是国际通用的做法,比直接塞中文文件名稳得多。前端如果走Blob链路,更推荐自己在localStorage里存文件名,然后用 URL.createObjectURL 下载时指定文件名,完全绕开后端响应头的编码问题。

关于这套链路,我的个人体会是: 没有一劳永逸的方案,只有分清楚边界的选择 。后端生成PDF要在文件存档和打印展示之间做出取舍,前端打印要在预览、静默打印、防篡改之间做出取舍。大多数踩坑都源于需求没分清:想要打印体验的时候别硬生成PDF想要存档文件,想存档的时候别图省事只打印HTML。

最后分享一个我自己常用的小技巧:每次做这类功能,我都先做一个"技术验证Demo",后端生成一个最简单的合同PDF,前端用iframe打印一次,走通整个链路再开始开发正式模板。链路没打通之前谈排版没意义,链路通了之后再难的样式也只是时间问题。希望这篇能帮你少走几步弯路。

以上就是Java后端生成PDF并在前端打印的三种实现方案与避坑指南的详细内容,更多关于Java生成PDF的资料请关注脚本之家其它相关文章!

相关文章

  • Java实现文件图片的预览和下载功能

    Java实现文件图片的预览和下载功能

    这篇文章主要为大家详细介绍了如何使用Java实现文件图片的预览和下载功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下
    2025-04-04
  • 详解java IO流之缓冲流的使用

    详解java IO流之缓冲流的使用

    本文主要介绍了java的IO流中的缓冲流的使用,缓冲流分为字节和字符缓冲流。分享了有关它们的实例代码,具有一定的参考价值,下面跟着小编一起来看下吧
    2017-01-01
  • mybatis中返回主键一直为1的问题

    mybatis中返回主键一直为1的问题

    这篇文章主要介绍了mybatis中返回主键一直为1的问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-03-03
  • 一文搞懂Java代理模式:接口、继承、JDK、CGLIB全解析

    一文搞懂Java代理模式:接口、继承、JDK、CGLIB全解析

    还在为Java代理模式头疼吗?本文用卖房子的例子,带你轻松搞懂静态代理和动态代理的区别,包括基于接口的JDK动态代理和基于继承的CGLIB动态代理,附代码示例,看完就会用
    2026-08-08
  • SpringBoot项目中jar发布获取jar包所在目录路径的最佳方法

    SpringBoot项目中jar发布获取jar包所在目录路径的最佳方法

    在开发过程中,我们经常要遇到上传图片、word、pdf等功能,但是当我们把项目打包发布到服务器上时,对应的很多存储路径的方法就会失效,下面这篇文章主要给大家介绍了关于SpringBoot项目中jar发布获取jar包所在目录路径的相关资料
    2022-07-07
  • 最详细的Java循环结构解析之for循环教程(适合小白)

    最详细的Java循环结构解析之for循环教程(适合小白)

    :循环结构是指在程序中需要反复执行某个功能而设置的一种程序结构,下面这篇文章主要给大家介绍了关于Java循环结构解析之for循环的相关资料,文中通过示例代码介绍的非常详细,需要的朋友可以参考下
    2021-09-09
  • spring 整合 mybatis 中数据源的几种配置方式(总结篇)

    spring 整合 mybatis 中数据源的几种配置方式(总结篇)

    因为spring 整合mybatis的过程中, 有好几种整合方式,尤其是数据源那块,经常看到不一样的配置方式,总感觉有点乱,所以今天有空总结下,感兴趣的朋友跟随脚本之家小编一起学习吧
    2018-05-05
  • Java实现顺序栈的示例代码

    Java实现顺序栈的示例代码

    线性表和栈都是我们常用的数据结构,栈可以看成一种特殊状态的线性表。线性表分为顺序表和链表,使用线性表中的顺序表来实现栈时这种栈被称为顺序栈。这篇文章总结了如何使用顺序表实现栈,需要的可以参考一下
    2022-11-11
  • java小数位的例子

    java小数位的例子

    在java中要保留数字小数位我们有常用的四种方法,分别为:四舍五入,DecimalFormat,format,String .format与struts标签操作实现,下面给出例子
    2013-11-11
  • SpringBoot自定义maven-plugin插件整合asm代码插桩

    SpringBoot自定义maven-plugin插件整合asm代码插桩

    本文主要介绍了SpringBoot自定义maven-plugin插件整合asm代码插桩,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-02-02

最新评论