Java动态生成PDF文件

Java动态生成PDF文件

​ 思路:先有个pdf模板,然后将数据放到pdf模板的对应位置即可。

生成pdf模板过程:先用html写一个pdf模板样式,然后用Java程序读取整个文件,修改数据部分做个拼接。一般从数据库查到数据,封装成一个对象,遍历这个对象,赋值给pdf模板的数据(数据替换)。然后service层返回一个byte\[\]给controller,controller调用工具类响应给前端。前端就下文件即可(文件上传下载无论是pdf还是excel我的文章里面有可以翻翻),这个是轻量级做法,但是足够小系统使用。

1.pdf的html模板:

这个文件一般放在后端resource/static下面(根据需求自己写):grade-template.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <title>{{trainName}}-考核成绩报告单</title>
    <style>
        /* 【基础重置】 */
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'SimSun', serif !important;
            -webkit-print-color-adjust: exact; /* 强制打印背景色 */
        }

        /* 【页面设置】A4 纸张,边距适中 */
        @page {
            size: A4;
            margin: 15mm 10mm; /* 上下留白稍大,左右稍窄,利用横向空间 */
        }

        body {
            background: #ffffff;
            padding: 0;
            margin: 0;
        }

        /* 容器:不再限制高度,完全由内容撑开 */
        .report-paper {
            width: 100%;
            /* 【修改点1】减小内边距,防止溢出 */
            padding: 0;
            position: relative;
        }

        /* 头部 Logo */
        .header {
            text-align: right;
            margin-bottom: 4px;
            height: 30px;
        }
        .logo-img {
            height: 28px;
            width: auto;
        }

        h1 {
            text-align: center;
            font-size: 18px; /* 稍微调小字号以节省空间 */
            color: #0052cc;
            margin-bottom: 8px;
            letter-spacing: 1px;
            font-weight: bold;
        }

        /* 基础信息表 */
        .base-info {
            width: 100%;
            border-collapse: collapse;
            margin-bottom: 8px;
            table-layout: fixed;
        }
        .base-info td {
            border: 1px solid #aaa;
            padding: 4px 5px; /* 减小单元格内边距 */
            font-size: 11px;
            vertical-align: middle;
            word-wrap: break-word;
        }
        .base-info td:nth-child(1) { width: 14%; background: #eef5ff; font-weight: bold; text-align: center;}
        .base-info td:nth-child(2) { width: 36%; }
        .base-info td:nth-child(3) { width: 14%; background: #eef5ff; font-weight: bold; text-align: center;}
        .base-info td:nth-child(4) { width: 36%; }

        /* 明细表 - 核心修改区域 */
        .detail-table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 0;
            -fs-table-paginate: paginate;
        }

        .detail-table th {
            background: #0052cc;
            color: white;
            padding: 4px 2px;
            font-size: 10px;
            font-weight: normal;
            border: 1px solid #2a6ccf;
            text-align: center;
            vertical-align: middle;
        }

        .detail-table td {
            border: 1px solid #aaa;
            padding: 3px 2px; /* 极度压缩单元格高度 */
            font-size: 9.5px; /* 稍微缩小字体以容纳更多内容 */
            text-align: center;
            vertical-align: middle;
            word-break: break-all;
            line-height: 1.2;
        }

        /* 列宽微调 */
        .col-step { width: 11%; }
        .col-question { width: 20%; }
        .col-answer { width: 9%; }
        .col-right { width: 9%; }
        .col-score { width: 6%; }
        .col-analysis { width: 30%; } /* 分析栏给多一点空间 */
        .col-status { width: 8%; }

        .right { color: #00843d; font-weight: bold; }
        .wrong { color: #c62b2b; font-weight: bold; }
        .analysis-text {
            font-size: 9px;
            text-align: left;
            padding-left: 2px;
            line-height: 1.2;
        }

        /* 汇总区域 */
        .score-summary {
            margin-top: 2px;
            padding: 6px 8px;
            border: 1px solid #ddd;
            background: #f9f9f9;
            font-size: 10px;
            /* 防止汇总框被拆分到两页 */
            page-break-inside: avoid;
        }
        .total-score {
            font-size: 14px;
            color: #0052cc;
            font-weight: bold;
        }
    </style>
</head>
<body>
<div class="report-paper">
    <div class="header">
        <!-- 确保 logoBase64 不为空,否则 img 标签可能会占据奇怪的高度 -->
        <img class="logo-img" src="{{logoBase64}}" alt="" />
    </div>

    <h1>{{trainName}} 成绩单</h1>

    <table class="base-info">
        <tr>
            <td>姓名</td>
            <td>{{userName}}</td>
            <td>考核得分</td>
            <td style="color:#0052cc; font-weight:bold;">{{totalScore}}</td>
        </tr>
        <tr>
            <td>实训名称</td>
            <td>{{trainName}}</td>
            <td>零件编号</td>
            <td>{{partId}}</td>
        </tr>
        <tr>
            <td>考核时间</td>
            <td colspan="3">{{time}}</td>
        </tr>
    </table>

    <!-- 注意:这里添加了 -fs-table-paginate: paginate 样式在 CSS 类中 -->
    <table class="detail-table">
        <thead>
        <tr>
            <th class="col-step">考核步骤</th>
            <th class="col-question">考题/考核点</th>
            <th class="col-answer">学员作答</th>
            <th class="col-right">参考答案</th>
            <th class="col-score">得分</th>
            <th class="col-score">满分</th>
            <th class="col-analysis">考评分析</th>
            <th class="col-status">操作状态</th>
        </tr>
        </thead>
        <tbody>
        {{detailsRows}}
        </tbody>
    </table>

    <div class="score-summary">
        <p>学员实际总得分:<span class="total-score">{{totalScore}}</span> 分 / 100分</p>
        <p style="margin-top:2px; color:#666;">说明:考核共计11个操作步骤,第11步不计分;前10步骤单步满分10分,合计总分:<strong>100分</strong></p>
    </div>
</div>
</body>
</html>

2.Java程序读取模板

在你的service层中这样写:

java 复制代码
@Override
    public byte[] generateGradePdf(Long gradeId) throws IOException, DocumentException {
        // 1.查成绩详情
        GradeDO grade = gradeMapper.selectById(gradeId);
        if (grade == null) {
            throw new BusinessException("成绩记录不存在");
        }
        UserDO userDO = userMapper.selectById(grade.getUserId());
        String userName = userDO.getRealName();
        // 2. 读取 HTML 模板
        String template = readTemplate("grade-template.html");

        // 读取图片转 Base64 示例
        String logoBase64 = "";
        try {
            InputStream is = getClass().getClassLoader().getResourceAsStream("static/headLogo.png");
            if (is != null) {
                byte[] bytes = org.apache.commons.io.IOUtils.toByteArray(is);
                logoBase64 = "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(bytes);
            }
        } catch (Exception e) {
            log.error("Logo读取失败", e);
        }
		// jackson处理的字段可以忽略,这里根据业务需求来。
        List<StepDetail> detailList = JSON.parseArray(
                JSON.toJSONString(grade.getDetailJson()),
                StepDetail.class
        );
        String time = "";
        TrainDO train = trainMapper.selectById(grade.getTrainId());
        if(train != null){
            if(train.getBeginTime() != null){
                time += train.getBeginTime().format(formatter);
            }
            if(train.getBeginTime() != null && train.getEndTime() != null){
                time += " - " + train.getEndTime().format(formatter);
            }
        }
        String submitTime = "";
        if(grade.getCreateTime() != null){
            submitTime = grade.getCreateTime().format(formatter);
        }

        // 3. 替换占位符
        String html = template
                .replace("{{logoBase64}}", logoBase64)
                .replace("{{userName}}", nullToEmpty(userName))
                .replace("{{trainName}}", nullToEmpty(grade.getTrainName()))
                .replace("{{partId}}", nullToEmpty(grade.getPartId()))
                .replace("{{totalScore}}", String.valueOf(grade.getTotalScore()))
                .replace("{{detailsRows}}", buildDetailsRows(detailList))
                .replace("{{time}}", nullToEmpty(time))
                .replace("{{submitTime}}", nullToEmpty(submitTime));

        System.out.println(html);

        // 4. HTML 转 PDF
        ByteArrayOutputStream pdfStream = new ByteArrayOutputStream();
        ITextRenderer renderer = new ITextRenderer();
        // 这里的 "simsun" 是字体名称,必须和 CSS 中的 font-family: 'SimSun' 对应
        // 第二个参数是类路径下的字体文件位置
        String fontPath = "fonts/Simsun.ttf";
        try {
            renderer.getFontResolver().addFont(
                    fontPath,
                    BaseFont.IDENTITY_H,
                    BaseFont.EMBEDDED // <--- 改为 EMBEDDED
            );
        } catch (Exception e) {
            log.error("字体加载失败,请检查 resources/fonts/Simsun.ttf 是否存在", e);
            throw new RuntimeException("PDF生成失败:缺少中文字体文件", e);
        }
        renderer.setDocumentFromString(html, "UTF-8");
        renderer.layout();
        renderer.createPDF(pdfStream);

        // 5. 返回 PDF
        return pdfStream.toByteArray();
    }


    private String readTemplate(String templateName) throws IOException {
        StringBuilder content = new StringBuilder();
        try (
                InputStream inputStream = getClass().getClassLoader().getResourceAsStream("templates/" + templateName);
                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    content.append(line).append("\n");
            }
        }
        return content.toString();
    }
    private String buildDetailsRows(List<StepDetail> details) {
        StringBuilder rows = new StringBuilder();

        for (StepDetail step : details) {
            rows.append("<tr>")
                    // 1. 考核步骤
                    .append("<td>").append(nullToEmpty(step.getStep())).append("</td>")
                    // 2. 考题/考核点
                    .append("<td>").append(nullToEmpty(step.getExamQuestion() != null ? step.getExamQuestion().getQuestion() : "")).append("</td>")
                    // 3. 学员作答
                    .append("<td class=\"").append(isRight(step) ? "right" : "wrong").append("\">")
                    .append(formatAnswer(step.getUserAnswer())).append("</td>")
                    // 4. 参考答案
                    .append("<td class=\"right\">").append(formatAnswer(step.getExamQuestionAnswer())).append("</td>")
                    // 6. 得分
                    .append("<td>").append(step.getCount() != null ? step.getCount() : 0).append("</td>")
                    // 5. 满分
                    .append("<td>").append(step.getOperationScore() != null ? step.getOperationScore() : 0).append("</td>")
                    // 7. 考评分析
                    .append("<td class=\"analysis-text\">").append(nullToEmpty(step.getAnalysis())).append("</td>")
                    // 8. 操作状态
                    .append("<td>").append(step.getOperation() != null && step.getOperation() ? "已完成" : "未操作").append("</td>")
                    .append("</tr>");
        }
        System.out.println(rows);
        return rows.toString();
    }

    // 添加 null 安全方法
    private String nullToEmpty(String str) {
        return str == null ? "" : escapeHtml(str);
    }

    private boolean isRight(StepDetail step) {
        if (step.getUserAnswer() == null || step.getExamQuestionAnswer() == null) {
            return false;
        }
        return step.getUserAnswer().equals(step.getExamQuestionAnswer());
    }

    private String formatAnswer(List<String> answer) {
        if (answer == null || answer.isEmpty()) {
            return "未作答";
        }
        return String.join("、", answer);
    }

    private String escapeHtml(String str) {
        if (str == null) return "";
        return str
                .replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }

3.controller响应给前端

java 复制代码
    @Operation(summary = "导出PDF成绩单")
    @GetMapping("/downloadGradePdf")
    public void downloadGradeById(@RequestParam Long gradeId, HttpServletResponse response) throws IOException {
        try {
            byte[] pdfBytes = gradeService.generateGradePdf(gradeId);
            String fileName = "我的成绩单";
            FileUtils.downloadPdf(response,fileName,pdfBytes);

        } catch (BusinessException | IOException e) {
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
        } catch (DocumentException e) {
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"PDF生成失败:" + e.getMessage() + "\"}");
        }
    }

总结:html转pdf,程序处理后响应给前端。

相关推荐
学渣超1 小时前
微服务日志智能诊断系统(九) 让 LLM 成为日志分析专家
java·后端·agent
学渣超1 小时前
微服务日志智能诊断系统(七) 日志 RAG 引擎——蒸馏、索引与精准检索
java·后端·agent
掉鱼的猫1 小时前
代码审查 Agent Harness 实战:AI 自动 Code Review
java·llm·agent
学渣超1 小时前
微服务日志智能诊断系统(六) Fast Path vs Slow Path——双路径日志诊断策略
java·后端·agent
添砖java_8571 小时前
网页聊天室测试报告
java·spring boot·spring·maven
hai3152475431 小时前
九章编译法:DEEPSEEK V3.2汇编编译实例
java·开发语言
ONE_SIX_MIX2 小时前
lightweight-charts-onesixth v2.8.1 → v3.1.1 更新总览
java·前端·python
SimonKing2 小时前
一文终结 SpringBoot 国际化!从 0 到 1 完整案例(中/英/日)
java·后端·程序员
ByWalker_2 小时前
web应用技术—MyBatis入门
java·数据库·mybatis·lombok
在书中成长2 小时前
HarmonyOS 小游戏《对战五子棋》开发第17篇 - AI的两种评估策略
android·java·javascript