package cn.iocoder.yudao.module.qcreport.engine.render;
|
|
import cn.iocoder.yudao.module.qcreport.engine.Bindings;
|
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
|
import cn.iocoder.yudao.module.qcreport.engine.PageSizes;
|
import cn.iocoder.yudao.module.qcreport.engine.Paths;
|
import cn.iocoder.yudao.module.qcreport.engine.ResolvedPage;
|
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
|
import cn.iocoder.yudao.module.qcreport.engine.report.EvaluationOutcome;
|
import cn.iocoder.yudao.module.qcreport.engine.report.ReportEvaluator;
|
import cn.iocoder.yudao.module.qcreport.engine.rule.QualityRuleDefinition;
|
|
import java.util.ArrayList;
|
import java.util.LinkedHashMap;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.Set;
|
import java.util.regex.Pattern;
|
|
/**
|
* 报告渲染引擎。
|
* <p>
|
* 输入「模板 Schema + 报告上下文」,输出可直接打印的 HTML 文档。
|
* 渲染顺序固定为「先判定、后渲染」:PASS/FAIL 由 {@link ReportEvaluator} 算好写进上下文,
|
* 模板里只做取值,因此同一份数据在任何模板下判定一致,模板也无法左右判定结果。
|
* <p>
|
* 这里不认识具体组件类型,只吃 GrapesJS 导出的项目数据(组件树 + 样式),
|
* 只认三套协议:{@code data-qc-repeat} 重复行、{@code {{路径}}} 绑定、{@code schema.rules} 判定规则。
|
* 与前端 {@code engine/render.ts} 一一对应,两边产出的 HTML 必须一致。
|
*/
|
public final class HtmlRenderer {
|
|
/** 设计器标记,不进入产物 */
|
private static final String QUALITY_TYPE_ATTR = "data-quality-type";
|
private static final String QUALITY_PROP_PREFIX = "data-qc-";
|
private static final String QUALITY_REPEAT_ATTR = "data-qc-repeat";
|
private static final String QUALITY_REPEAT_ROW_ATTR = "data-qc-repeat-row";
|
/** GrapesJS 的组件 id 换成 data 属性:重复行里 id 会重名,data 属性不会 */
|
private static final String QUALITY_ID_ATTR = "data-qc-id";
|
|
/** 自闭合标签,不能生成结束标签 */
|
private static final Set<String> VOID_TAGS = Set.of(
|
"br", "col", "hr", "img", "input", "link", "meta", "source", "track", "wbr");
|
|
/**
|
* GrapesJS 组件类型 → HTML 标签。
|
* <p>
|
* 项目数据只在标签与类型默认值不同时才写 tagName(例如 th、span、h2),
|
* 表格结构(table/thead/tbody/row/cell)在数据里都只有 type,
|
* 所以漏掉这张表就会把整张表格渲染成一堆 div。未收录的类型回退到 div,
|
* 与 GrapesJS 基础组件的默认标签一致。
|
*/
|
private static final Map<String, String> TYPE_TAGS = Map.of(
|
"cell", "td",
|
"row", "tr",
|
"table", "table",
|
"tbody", "tbody",
|
"tfoot", "tfoot",
|
"thead", "thead",
|
"text", "div",
|
"wrapper", "div");
|
|
/** 渲染产物的基础排版。浏览器默认的表格与页边距会让报告走样,这里收敛成打印友好的基线 */
|
private static final String BASE_CSS = """
|
* { box-sizing: border-box; }
|
body { margin: 0; color: #000; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 12px; line-height: 1.5; }
|
table { border-collapse: collapse; width: 100%; }
|
img { max-width: 100%; }
|
tr, td, th { page-break-inside: avoid; }""";
|
|
/** 承载外部地址的属性,值里出现可执行协议一律拦掉 */
|
private static final Set<String> URL_ATTRIBUTES = Set.of(
|
"href", "src", "xlink:href", "action", "formaction", "background", "poster");
|
|
/** 可执行 / 可注入的 URL 协议 */
|
private static final Pattern DANGEROUS_SCHEME =
|
Pattern.compile("^\\s*(?:javascript|vbscript|data)\\s*:", Pattern.CASE_INSENSITIVE);
|
|
/** data: 里只有图片是正当用途,其余(text/html 等)会变成注入面 */
|
private static final Pattern DATA_IMAGE = Pattern.compile("^\\s*data:image/", Pattern.CASE_INSENSITIVE);
|
|
/** GrapesJS 用 #组件id 选择器,这里改写成 data 属性选择器 */
|
private static final Pattern ID_SELECTOR = Pattern.compile("#([\\w-]+)");
|
|
private HtmlRenderer() {
|
}
|
|
/**
|
* 渲染报告:判定 → 生成正文 → 拼样式 → 组装文档。
|
*
|
* @param grapes GrapesJS 项目数据(模板 Schema 的 grapes 字段)
|
* @param page 纸张与页边距配置
|
* @param context 报告上下文
|
* @param rules 判定规则,可为空
|
*/
|
public static RenderOutcome render(Map<String, Object> grapes, PageSetting page,
|
ReportContext context, List<QualityRuleDefinition> rules) {
|
EvaluationOutcome evaluated = ReportEvaluator.evaluate(context, rules);
|
RenderState state = new RenderState(evaluated.context().toScope(), "模板",
|
new ArrayList<>(evaluated.errors()), new LinkedHashSet<>());
|
|
RenderNode root = RenderNode.of(Paths.readPath(grapes, "pages[0].frames[0].component"));
|
String body = root == null ? "" : renderNode(root, state);
|
String css = buildCss(grapes == null ? null : grapes.get("styles"));
|
String html = buildDocument(page, body, css, evaluated.context());
|
return new RenderOutcome(html, body, evaluated.context(), state.errors);
|
}
|
|
/* ------------------------------ 节点展开 ------------------------------ */
|
|
/** 渲染单个节点:文本节点与重复容器单独处理,其余按「标签 + 属性 + 子节点」展开 */
|
private static String renderNode(RenderNode node, RenderState state) {
|
// 文本节点只贡献文本,自身不产生标签
|
if ("textnode".equals(node.type())) {
|
return renderText(node.content() == null ? "" : node.content(), state);
|
}
|
|
String repeatPath = node.attribute(QUALITY_REPEAT_ATTR);
|
if (repeatPath != null && !repeatPath.isEmpty()) {
|
return renderRepeat(node, repeatPath, state);
|
}
|
|
String tag = resolveTag(node);
|
String attributes = renderAttributes(node, state);
|
String style = renderStyle(node);
|
if (VOID_TAGS.contains(tag)) {
|
return "<" + tag + attributes + style + ">";
|
}
|
return "<" + tag + attributes + style + ">" + renderChildren(node, state) + "</" + tag + ">";
|
}
|
|
/** 标签优先取数据里的 tagName,其次按组件类型推断 */
|
private static String resolveTag(RenderNode node) {
|
String tagName = node.tagName();
|
if (tagName != null && !tagName.isEmpty()) {
|
return tagName;
|
}
|
String type = node.type();
|
String byType = type == null ? null : TYPE_TAGS.get(type);
|
return byType == null ? "div" : byType;
|
}
|
|
/**
|
* 子节点按声明顺序展开。
|
* <p>
|
* 有子组件时忽略 content——与 GrapesJS 一致:往一个带文本的组件里再拖入组件后,
|
* 文本就让位给子组件,这里必须同规则,否则渲染产物会和设计器看到的不一样。
|
*/
|
private static String renderChildren(RenderNode node, RenderState state) {
|
List<RenderNode> children = node.children();
|
if (!children.isEmpty()) {
|
StringBuilder builder = new StringBuilder();
|
for (RenderNode child : children) {
|
builder.append(renderNode(child, state));
|
}
|
return builder.toString();
|
}
|
String content = node.content();
|
return content == null ? "" : renderText(content, state);
|
}
|
|
/**
|
* 重复容器:按数组路径展开行模板,其余子节点在原位置渲染一次。
|
* <p>
|
* 行模板上的 {@code data-qc-repeat-row} 声明了行内的循环变量名(如 item),
|
* 行内容用 {@code {{item.xxx}}} 取值,与设计期在组件里约定的绑定路径一致。
|
*/
|
private static String renderRepeat(RenderNode node, String path, RenderState state) {
|
List<Object> rows = toRowList(Paths.readPath(state.scope, path));
|
StringBuilder inner = new StringBuilder();
|
for (RenderNode child : node.children()) {
|
String rowVar = child.attribute(QUALITY_REPEAT_ROW_ATTR);
|
if (rowVar == null || rowVar.isEmpty()) {
|
inner.append(renderNode(child, state));
|
continue;
|
}
|
for (int index = 0; index < rows.size(); index++) {
|
Map<String, Object> rowScope = new LinkedHashMap<>(state.scope);
|
rowScope.put(rowVar, rows.get(index));
|
// index 由重复容器注入,行内可写 {{index}},从 1 开始
|
rowScope.put("index", index + 1);
|
inner.append(renderNode(child, state.withScope(rowScope, "第 " + (index + 1) + " 行")));
|
}
|
}
|
|
String tag = resolveTag(node);
|
return "<" + tag + renderAttributes(node, state) + renderStyle(node) + ">" + inner + "</" + tag + ">";
|
}
|
|
/** 非数组按单行处理,空值得到空表;比抛错更贴合「数据没填全」的报告场景 */
|
private static List<Object> toRowList(Object value) {
|
if (value instanceof List<?> list) {
|
return new ArrayList<>(list);
|
}
|
if (value == null) {
|
return List.of();
|
}
|
return List.of(value);
|
}
|
|
/* ------------------------------ 属性与样式 ------------------------------ */
|
|
/**
|
* 渲染节点属性。
|
* <p>
|
* 设计器标记(data-quality-type / data-qc-*)不进入产物;
|
* 属性值里的 {@code {{path}}} 一并解析,图片地址、链接等也能绑定数据。
|
* <p>
|
* 比前端多一道闸:事件属性与可执行协议不进产物。产物是给人打印的文档,
|
* on* 之类的属性没有正当用途,放行等于给模板开了个执行口子。
|
*/
|
private static String renderAttributes(RenderNode node, RenderState state) {
|
StringBuilder builder = new StringBuilder();
|
for (Map.Entry<String, String> entry : node.attributes().entrySet()) {
|
String name = entry.getKey();
|
if (QUALITY_TYPE_ATTR.equals(name) || name.startsWith(QUALITY_PROP_PREFIX)) {
|
continue;
|
}
|
String text = renderText(entry.getValue() == null ? "" : entry.getValue(), state);
|
if (text.isEmpty()) {
|
continue;
|
}
|
if (!isSafeAttribute(name, text, state)) {
|
continue;
|
}
|
builder.append(' ').append("id".equals(name) ? QUALITY_ID_ATTR : name)
|
.append("=\"").append(escapeHtml(text)).append('"');
|
}
|
return builder.toString();
|
}
|
|
/** 属性名/值安全闸,拦下的写进问题清单,让模板作者看得见而不是悄悄少个属性 */
|
private static boolean isSafeAttribute(String name, String value, RenderState state) {
|
String lower = name.toLowerCase(Locale.ROOT);
|
if (lower.length() > 2 && lower.startsWith("on")) {
|
state.errors.add(state.where + "属性「" + name + "」是事件属性,报告产物不输出,已忽略");
|
return false;
|
}
|
if (URL_ATTRIBUTES.contains(lower) && DANGEROUS_SCHEME.matcher(value).find()
|
&& !DATA_IMAGE.matcher(value).find()) {
|
state.errors.add(state.where + "属性「" + name + "」的地址协议不安全,报告产物不输出,已忽略");
|
return false;
|
}
|
return true;
|
}
|
|
/** 节点自身的行内样式 */
|
private static String renderStyle(RenderNode node) {
|
String style = cssStyle(node.style());
|
return style.isEmpty() ? "" : " style=\"" + escapeHtml(style) + "\"";
|
}
|
|
/** 取文本里的绑定值,取不到的路径记进问题清单(未解析的 {{}} 不会留在产物里) */
|
private static String renderText(String template, RenderState state) {
|
return Bindings.resolveText(template, state.scope, path -> {
|
if (state.reported.add(state.where + "|" + path)) {
|
state.errors.add(state.where + "绑定「{{" + path + "}}」在当前数据中取不到值,已渲染为空");
|
}
|
});
|
}
|
|
/** 拼一段内联样式,过滤掉空值 */
|
private static String cssStyle(Map<String, String> style) {
|
List<String> parts = new ArrayList<>(style.size());
|
for (Map.Entry<String, String> entry : style.entrySet()) {
|
String value = entry.getValue();
|
if (value == null || value.isEmpty()) {
|
continue;
|
}
|
parts.add(entry.getKey() + ":" + value);
|
}
|
return String.join(";", parts);
|
}
|
|
/* ------------------------------ 样式与文档 ------------------------------ */
|
|
/** 样式规则数组 → CSS 文本;GrapesJS 用 #组件id 选择器,这里改写成 data 属性选择器 */
|
private static String buildCss(Object styles) {
|
if (styles instanceof String text) {
|
return text;
|
}
|
if (!(styles instanceof List<?> rules)) {
|
return "";
|
}
|
|
List<String> blocks = new ArrayList<>();
|
Map<String, List<String>> mediaBlocks = new LinkedHashMap<>();
|
for (Object raw : rules) {
|
if (!(raw instanceof Map<?, ?> rule)) {
|
continue;
|
}
|
String selector = buildSelector(rule.get("selectors"));
|
String body = cssStyle(textMap(rule.get("style")));
|
if (selector.isEmpty() || body.isEmpty()) {
|
continue;
|
}
|
String line = selector + " { " + body + " }";
|
String mediaText = textOf(rule.get("mediaText")).trim();
|
if (!mediaText.isEmpty()) {
|
mediaBlocks.computeIfAbsent(mediaText, key -> new ArrayList<>()).add(line);
|
continue;
|
}
|
blocks.add(line);
|
}
|
|
mediaBlocks.forEach((mediaText, lines) -> {
|
String atRule = mediaText.startsWith("@") ? mediaText : "@media " + mediaText;
|
blocks.add(atRule + " { " + String.join(" ", lines) + " }");
|
});
|
return String.join("\n", blocks);
|
}
|
|
private static String buildSelector(Object rawSelectors) {
|
if (!(rawSelectors instanceof List<?> selectors)) {
|
return "";
|
}
|
List<String> parts = new ArrayList<>(selectors.size());
|
for (Object raw : selectors) {
|
String selector = toSafeSelector(textOf(raw));
|
if (!selector.isEmpty()) {
|
parts.add(selector);
|
}
|
}
|
return String.join(", ", parts);
|
}
|
|
/** #组件id → [data-qc-id="组件id"],重复行复制后样式仍然命中 */
|
private static String toSafeSelector(String selector) {
|
return ID_SELECTOR.matcher(selector).replaceAll("[" + QUALITY_ID_ATTR + "=\"$1\"]");
|
}
|
|
private static Map<String, String> textMap(Object raw) {
|
Map<String, String> result = new LinkedHashMap<>();
|
if (raw instanceof Map<?, ?> map) {
|
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
result.put(String.valueOf(entry.getKey()),
|
entry.getValue() == null ? null : String.valueOf(entry.getValue()));
|
}
|
}
|
return result;
|
}
|
|
private static String textOf(Object value) {
|
return value == null ? "" : String.valueOf(value);
|
}
|
|
/** 组装最终文档:纸张与页边距交给 @page,正文里不再重复留白,才能保证每页都有边距 */
|
private static String buildDocument(PageSetting page, String body, String css, ReportContext context) {
|
ResolvedPage resolved = PageSizes.resolve(page);
|
String title = context.getReport().getReportName();
|
if (title == null || title.isEmpty()) {
|
title = context.getReport().getReportNo();
|
}
|
if (title == null || title.isEmpty()) {
|
title = "质检报告";
|
}
|
return String.join("\n",
|
"<!doctype html>",
|
"<html lang=\"zh-CN\">",
|
"<head>",
|
"<meta charset=\"utf-8\" />",
|
"<title>" + escapeHtml(title) + "</title>",
|
"<style>",
|
"@page { size: " + resolved.cssSize() + "; margin: " + resolved.cssMargin() + "; }",
|
BASE_CSS,
|
css,
|
"</style>",
|
"</head>",
|
"<body>" + body + "</body>",
|
"</html>");
|
}
|
|
/** 任何来自用户(设计器属性面板)的文本都必须先转义再拼进标签,否则渲染期就是注入点 */
|
private static String escapeHtml(String value) {
|
return value.replace("&", "&")
|
.replace("<", "<")
|
.replace(">", ">")
|
.replace("\"", """)
|
.replace("'", "'");
|
}
|
|
/** 一次渲染的行走状态:作用域 + 当前位置 + 问题清单,行内复制时共享后两者 */
|
private static final class RenderState {
|
|
private final Map<String, Object> scope;
|
/** 当前渲染位置的描述,用于把问题定位到具体行 */
|
private final String where;
|
private final List<String> errors;
|
/** 已上报的缺口,同一处只提示一次 */
|
private final Set<String> reported;
|
|
private RenderState(Map<String, Object> scope, String where,
|
List<String> errors, Set<String> reported) {
|
this.scope = scope;
|
this.where = where;
|
this.errors = errors;
|
this.reported = reported;
|
}
|
|
private RenderState withScope(Map<String, Object> scope, String where) {
|
return new RenderState(scope, where, errors, reported);
|
}
|
|
}
|
|
}
|