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 打印 *

* 打印的是**调用方给的 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 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) { } }