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) {
|
}
|
|
}
|