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