9 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
package cn.iocoder.yudao.module.qcreport.service.render;
 
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.qcreport.config.QcReportPdfProperties;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.PlaywrightException;
import com.microsoft.playwright.options.Margin;
import com.microsoft.playwright.options.WaitUntilState;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
 
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_DISABLED;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.RENDER_PDF_FAILED;
 
/**
 * PDF 渲染:把报告 HTML 交给 Chromium 打印
 * <p>
 * 打印的是**调用方给的 HTML 原文**,这里不重渲、不碰渲染引擎。报告实例存着什么 HTML,
 * PDF 就是什么样子 —— 设计文档 §32 要求「不要出现设计器显示正常但 PDF 严重变形」,
 * 而保证这一点的唯一办法就是让两者出自同一份字节;HTML 需要重排是「重新生成」的职责。
 */
@Service
public class PdfRenderService {
 
    @Resource
    private QcReportPdfProperties properties;
 
    @Resource
    private BrowserManager browserManager;
 
    /**
     * 渲染 PDF
     *
     * @param html           报告 HTML 原文(实例里已存的渲染产物)
     * @param page           纸张配置,窗口大小与页边距由它换算而来,与 HTML 里的 {@code @page} 同源
     * @param withPageNumber 是否打印页脚「第 N 页 / 共 M 页」
     */
    public PdfResult render(String html, PageSetting page, boolean withPageNumber) {
        if (!Boolean.TRUE.equals(properties.getEnabled())) {
            throw new ServiceException(RENDER_PDF_DISABLED.getCode(),
                    RENDER_PDF_DISABLED.getMsg() + "(yudao.qcreport.pdf.enabled 当前为 false)");
        }
        PdfPrintOptions options = PdfPrintOptions.of(page, withPageNumber);
        long start = System.currentTimeMillis();
        BrowserManager.PageResult<byte[]> result =
                browserManager.withPage(p -> print(p, html, options, withPageNumber));
        long pdfDuration = System.currentTimeMillis() - start;
 
        byte[] content = result.value();
        if (content == null || content.length == 0) {
            throw new ServiceException(RENDER_PDF_FAILED.getCode(),
                    RENDER_PDF_FAILED.getMsg() + ":浏览器未返回任何 PDF 字节,请检查该报告实例的 HTML 是否可用后重试。");
        }
        return new PdfResult(content, pdfDuration, result.browserStatus());
    }
 
    private byte[] print(Page page, String html, PdfPrintOptions options, boolean withPageNumber) {
        try {
            page.setContent(html, new Page.SetContentOptions()
                    .setWaitUntil(WaitUntilState.NETWORKIDLE)
                    .setTimeout(timeout()));
            // 加固样式在这里注入,不写进渲染引擎的 BASE_CSS:产物 HTML 与前端冻结样例是逐字比对的
            page.addStyleTag(new Page.AddStyleTagOptions().setContent(options.printCss()));
            return page.pdf(new Page.PdfOptions()
                    .setWidth(options.widthCss())
                    .setHeight(options.heightCss())
                    .setMargin(new Margin()
                            .setTop(options.marginTopCss())
                            .setRight(options.marginRightCss())
                            .setBottom(options.marginBottomCss())
                            .setLeft(options.marginLeftCss()))
                    // PASS/FAIL 这类判定底色是靠背景色表达的,不打背景色就全白
                    .setPrintBackground(true)
                    .setDisplayHeaderFooter(withPageNumber)
                    // 必须显式给页眉,否则 Chromium 会补上它自带的「打印日期 + 文档标题」
                    .setHeaderTemplate(PdfPrintOptions.HEADER_TEMPLATE)
                    .setFooterTemplate(options.footerTemplate())
                    // 让 HTML 自带的 @page 做纸面几何的唯一权威,上面的显式宽高只作兜底
                    .setPreferCSSPageSize(true));
        } catch (PlaywrightException e) {
            throw new ServiceException(RENDER_PDF_FAILED.getCode(),
                    RENDER_PDF_FAILED.getMsg() + ":打印过程中浏览器报错,实际原因是「" + e.getMessage()
                            + "」。可先对该报告执行「重新生成」再导出,若仍失败请联系管理员查看渲染记录。");
        }
    }
 
    private double timeout() {
        Long configured = properties.getTimeoutMs();
        return configured == null || configured < 1 ? 30000L : configured;
    }
 
    /**
     * PDF 渲染结果。
     *
     * @param content       PDF 字节
     * @param pdfDurationMs 打印耗时(毫秒):仅含等页面资源与 {@code page.pdf()},
     *                      与调用方统计的「总耗时」之差即排队与浏览器启动的等待
     * @param browserStatus 本次浏览器状态({@code READY} / {@code RESTARTED})
     */
    public record PdfResult(byte[] content, long pdfDurationMs, String browserStatus) {
    }
 
}